Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tokcat",
"version": "0.1.24",
"version": "0.1.25",
"private": true,
"type": "module",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tokcat"
version = "0.1.24"
version = "0.1.25"
edition = "2021"
description = "Native macOS menubar dashboard for local AI token usage"
authors = ["handlecusion"]
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Tokcat",
"version": "0.1.24",
"version": "0.1.25",
"identifier": "com.handlecusion.tokcat",
"build": {
"beforeDevCommand": "npm run dev:vite",
Expand Down
52 changes: 45 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Panel } from './components/Panel'
import { HeaderBar } from './components/HeaderBar'
import { TokenUsageCard } from './components/TokenUsageCard'
import { StreaksCard } from './components/StreaksCard'
import { SettingsPanel } from './components/SettingsPanel'
import { AgentLimitsCard } from './components/AgentLimitsCard'
import { DashboardTabs } from './components/DashboardTabs'
import { UsageBarGraph2D } from './components/UsageBarGraph2D'
import { UsageBarGraph2D, UsageView } from './components/UsageBarGraph2D'
import { buildGrid } from './lib/grid'
import { useGraphStream } from './hooks/useGraphStream'
import { useAgentUsage } from './hooks/useAgentUsage'
import { computeStats } from './lib/stats'
Expand All @@ -19,6 +19,7 @@ import { getTheme, THEMES, ThemeName } from './lib/themes'
import { getClientStyle } from './lib/clients'

const THEME_KEY = 'tokcat:theme:v1'
const USAGE_VIEW_KEY = 'tokcat:usageview:v1'

function loadTheme(): ThemeName {
try {
Expand All @@ -28,6 +29,14 @@ function loadTheme(): ThemeName {
return 'Blue'
}

function loadUsageView(): UsageView {
try {
const raw = localStorage.getItem(USAGE_VIEW_KEY)
if (raw === '2d' || raw === '3d') return raw
} catch {}
return '2d'
}

function defaultYear(): string {
return String(new Date().getFullYear())
}
Expand All @@ -44,6 +53,7 @@ export default function App() {
: false
)
const [activeTab, setActiveTab] = useState<string>('overview')
const [usageView, setUsageView] = useState<UsageView>(() => loadUsageView())
const [settings, setSettings] = useState<Settings>(() => loadSettings())
const [settingsOpen, setSettingsOpen] = useState(false)

Expand All @@ -58,6 +68,10 @@ export default function App() {
try { localStorage.setItem(THEME_KEY, theme) } catch {}
}, [theme])

useEffect(() => {
try { localStorage.setItem(USAGE_VIEW_KEY, usageView) } catch {}
}, [usageView])

useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return
const mql = window.matchMedia('(prefers-color-scheme: dark)')
Expand Down Expand Up @@ -188,6 +202,18 @@ export default function App() {
return computeStats(payload, activeClientSet)
}, [activeClientSet, payload])

// Calendar grids for the 3D usage view, one per visible card. Built from the
// same per-day token totals the stats already aggregate, so 3D and 2D show
// identical data for the selected year.
const overviewGrid = useMemo(
() => buildGrid(year, overviewStats?.perDayMap ?? new Map()),
[year, overviewStats],
)
const activeGrid = useMemo(
() => buildGrid(year, activeStats?.perDayMap ?? new Map()),
[year, activeStats],
)

// Live tokens-per-minute + per-(client, agent, model) breakdown, pushed
// by the backend's JSONL tailer every ~5s. No client-side diffing — the
// tailer parses only growth since the last poll, so values stay accurate
Expand Down Expand Up @@ -319,14 +345,20 @@ export default function App() {
<DashboardTabs clients={dashboardClients} active={activeTab} onChange={setActiveTab} />
{activeTab === 'overview' ? (
<div className="dashboard-stack">
<TokenUsageCard stats={overviewStats} />
<AgentLimitsCard clients={dashboardClients} trace={trace} agentUsage={agentUsage.payload} />
<UsageBarGraph2D
payload={payload}
clientIds={presentClients}
title="30d usage"
title="Token Usage"
subtitle="Stacked by agent"
view={usageView}
onViewChange={setUsageView}
grid={overviewGrid}
graphLight={palette.graphLight}
graphDark={palette.graphDark}
accent={mode.accent}
stats={overviewStats}
/>
<AgentLimitsCard clients={dashboardClients} trace={trace} agentUsage={agentUsage.payload} />
<UsageTraceCard
buckets={trace}
windowSecs={600}
Expand All @@ -344,12 +376,18 @@ export default function App() {
title={`${getClientStyle(activeTab).displayName} limits`}
note="Session / weekly / model limits"
/>
<TokenUsageCard stats={activeStats} />
<UsageBarGraph2D
payload={payload}
clientIds={[activeTab]}
title={`${getClientStyle(activeTab).displayName} 30d usage`}
title="Token Usage"
subtitle="Local token history"
view={usageView}
onViewChange={setUsageView}
grid={activeGrid}
graphLight={palette.graphLight}
graphDark={palette.graphDark}
accent={mode.accent}
stats={activeStats}
/>
<StreaksCard longest={activeStats.streaks.longest} current={activeStats.streaks.current} />
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/components/ContributionGraph3D.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ interface Props {
const CELL = 1
const GAP = 0.15
const STEP = CELL + GAP
const BASE_HEIGHT = 0.35
// Near-flat base so inactive days read as a packed floor and only active days
// rise as bars (the original look). A taller base turns every tile into a
// separated cube, which reads as a scattered grid.
const BASE_HEIGHT = 0.05
const MAX_HEIGHT = 4.0

// Per-face shading: top is lighter than sides for a stylized 3D tile look.
Expand Down
40 changes: 22 additions & 18 deletions src/components/TokenUsageCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,32 @@ import React from 'react'
import type { Stats } from '../lib/types'
import { formatCost, formatMMDD, formatMonthDay, humanizeTokens } from '../lib/format'

export function TokenUsageCard({ stats }: { stats: Stats }) {
export function TokenUsageCard({ stats, bare = false }: { stats: Stats; bare?: boolean }) {
const range = `${formatMMDD(stats.dateRange.start)} → ${formatMMDD(stats.dateRange.end)}`
const grid = (
<div className={`usage-row-card${bare ? ' is-bare' : ''}`}>
<div className="usage-cell">
<div className="usage-num">{formatCost(stats.totalCost)}</div>
<div className="usage-label">Total</div>
<div className="usage-sub">{range}</div>
</div>
<div className="usage-cell">
<div className="usage-num">{humanizeTokens(stats.totalTokens)}</div>
<div className="usage-label">Tokens</div>
<div className="usage-sub">{stats.activeDays} active days</div>
</div>
<div className="usage-cell">
<div className="usage-num">{stats.bestDay ? formatCost(stats.bestDay.cost) : '$0.00'}</div>
<div className="usage-label">Best day</div>
<div className="usage-sub">{stats.bestDay ? formatMonthDay(stats.bestDay.date) : '—'}</div>
</div>
</div>
)
if (bare) return grid
return (
<div className="usage-card">
<h2 className="usage-heading">Token Usage</h2>
<div className="usage-row-card">
<div className="usage-cell">
<div className="usage-num">{formatCost(stats.totalCost)}</div>
<div className="usage-label">Total</div>
<div className="usage-sub">{range}</div>
</div>
<div className="usage-cell">
<div className="usage-num">{humanizeTokens(stats.totalTokens)}</div>
<div className="usage-label">Tokens</div>
<div className="usage-sub">{stats.activeDays} active days</div>
</div>
<div className="usage-cell">
<div className="usage-num">{stats.bestDay ? formatCost(stats.bestDay.cost) : '$0.00'}</div>
<div className="usage-label">Best day</div>
<div className="usage-sub">{stats.bestDay ? formatMonthDay(stats.bestDay.date) : '—'}</div>
</div>
</div>
{grid}
</div>
)
}
86 changes: 75 additions & 11 deletions src/components/UsageBarGraph2D.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import React, { useMemo, useState } from 'react'
import { getClientStyle } from '../lib/clients'
import { addDays, formatCost, formatMonthDay, isoDate, parseISODate } from '../lib/format'
import type { Contribution, TokenBreakdown, UsagePayload } from '../lib/types'
import type { Contribution, Stats, TokenBreakdown, UsagePayload } from '../lib/types'
import type { GridLayout } from '../lib/grid'
import { ContributionGraph3D } from './ContributionGraph3D'
import { TokenUsageCard } from './TokenUsageCard'

export type UsageView = '2d' | '3d'

interface Props {
payload: UsagePayload
clientIds: string[]
title: string
subtitle?: string
view: UsageView
onViewChange: (view: UsageView) => void
grid: GridLayout
graphLight: string
graphDark: string
accent: string
/** When provided, the card leads with these token-usage totals (shown in both 2D and 3D). */
stats?: Stats
}

interface Segment {
Expand Down Expand Up @@ -70,8 +83,21 @@ function exactTokens(tokens: number): string {
return tokens.toLocaleString('en-US')
}

export function UsageBarGraph2D({ payload, clientIds, title, subtitle }: Props) {
export function UsageBarGraph2D({
payload,
clientIds,
title,
subtitle,
view,
onViewChange,
grid,
graphLight,
graphDark,
accent,
stats,
}: Props) {
const [hover, setHover] = useState<HoverState | null>(null)
const headSubtitle = stats && view === '3d' ? 'Full year' : subtitle
const bars = useMemo(() => {
const allowed = new Set(clientIds)
const byDate = new Map<string, DayBar>()
Expand All @@ -94,7 +120,10 @@ export function UsageBarGraph2D({ payload, clientIds, title, subtitle }: Props)

const maxTokens = Math.max(1, ...bars.map(b => b.totalTokens))
const width = 520
const height = 164
// viewBox height matches the rendered CSS height (.bar2d-svg) so there is no
// vertical distortion, and equals the 3D canvas height so toggling 2D/3D
// never changes the card height.
const height = 240
const top = 14
const bottom = 24
const chartHeight = height - top - bottom
Expand Down Expand Up @@ -127,18 +156,52 @@ export function UsageBarGraph2D({ payload, clientIds, title, subtitle }: Props)
<div className="bar2d-head">
<div>
<h2 className="bar2d-title">{title}</h2>
{subtitle && <div className="bar2d-sub">{subtitle}</div>}
{headSubtitle && <div className="bar2d-sub">{headSubtitle}</div>}
</div>
<div className="bar2d-legend">
{activeClients.slice(0, 5).map(style => (
<span key={style.id} className="bar2d-legend-item">
<span className="bar2d-dot" style={{ background: style.color }} />
{style.displayName.replace(/\s+(CLI|Code|IDE)$/i, '')}
</span>
))}
<div className="bar2d-head-right">
<div className="bar2d-viewtoggle" role="group" aria-label="Chart view">
<button
type="button"
className={`bar2d-viewbtn${view === '2d' ? ' is-active' : ''}`}
onClick={() => onViewChange('2d')}
aria-pressed={view === '2d'}
>
2D
</button>
<button
type="button"
className={`bar2d-viewbtn${view === '3d' ? ' is-active' : ''}`}
onClick={() => onViewChange('3d')}
aria-pressed={view === '3d'}
>
3D
</button>
</div>
<div className="bar2d-legend">
{activeClients.slice(0, 5).map(style => (
<span key={style.id} className="bar2d-legend-item">
<span className="bar2d-dot" style={{ background: style.color }} />
{style.displayName.replace(/\s+(CLI|Code|IDE)$/i, '')}
</span>
))}
</div>
</div>
</div>

{stats && (
<div className="bar2d-stats">
<TokenUsageCard stats={stats} bare />
</div>
)}

{view === '3d' ? (
<ContributionGraph3D
grid={grid}
activeLight={graphLight}
activeDark={graphDark}
accent={accent}
/>
) : (
<div className="bar2d-chart" onMouseLeave={() => setHover(null)}>
<svg className="bar2d-svg" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
<line x1="0" x2={width} y1={height - bottom} y2={height - bottom} className="bar2d-axis" />
Expand Down Expand Up @@ -226,6 +289,7 @@ export function UsageBarGraph2D({ payload, clientIds, title, subtitle }: Props)
</div>
)}
</div>
)}
</div>
)
}
Loading
Loading