Live agents dashboard: status, activity, progress, live tokens, model and effort, plus day/week/month usage by model

Claude Code mods by schwepps.
| Mod | What it does |
|---|---|
| model-router | Routes each turn and subagent to the cheapest Claude model that keeps quality, gated by a prompt-cache cost model. /route shows status and savings. |
| agent-ops | Live agents dashboard: status, activity, progress, tokens, model and effort, plus day/week/month usage by model. Open it with /agent-ops. |
| vtsls-lean | The vtsls TypeScript LSP with one tsserver per session and no automatic type acquisition, about 160 MB less per session. Replaces vtsls@claude-code-lsps. |
At the prompt of a terminal session:
/plugin install model-router --marketplace schwepps/claude-mods
/plugin install agent-ops --marketplace schwepps/claude-mods
From a shell:
claude plugin install model-router --marketplace schwepps/claude-mods
claude plugin install agent-ops --marketplace schwepps/claude-mods
Answer y to add the marketplace, then pick a scope (user scope loads the mod in every session).
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with schwepps/claude-mods, then Add plugin.
These mods are hooks modules and need a recent Claude Code build: model-router uses userConfig pickers (options) and does not load before Claude Code v2.1.271.
Run these with Claude Code v2.1.271 or later. Older builds reject the options pickers in model-router's manifest and have no plugin test. Check claude --version: an outdated global install earlier on PATH can shadow a newer one.
claude plugin validate model-picker-mod && claude plugin test model-picker-mod
claude plugin validate agent-ops-mod/agent-ops && claude plugin test agent-ops-mod/agent-opshooks/register.tsx 965 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, AgentStatus, Elements, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentRow, AgentState, AgentsView, Bucket, CardView, EconomicsView, History, Live, PricingNote, ProjectGroupView, Range, RepoHistory, SessionView, Tab, TabView, TopView, UsageView } from '../types'
5import {
6 FAMILIES,
7 FAMILY_COLOR,
8 FAMILY_LABEL,
9 RANGE_DAYS,
10 addUsage,
11 bucketTotals,
12 dayKey,
13 describeTool,
14 effortLevel,
15 familyOf,
16 fmt,
17 fmtInt,
18 fmtUsd,
19 mergeProjects,
20 modelLabel,
21 parseAliases,
22 projectResolver,
23 sessionsIn,
24 tokensByFamily,
25 USD,
26 type ProjectHistory,
27 series,
28 total,
29 type Point,
30} from './data'
31import { C } from './theme'
32
33const PANE = 'agent-ops'
34const MAIN = 'main'
35const SPARK_N = 30
36const POLL_MS = 1000
37const SCAN_MS = 3000
38const LINGER_MS = 60_000
39const LIVE_MINUTES = '10'
40/** Bumped with each change to dash.tsx or tab.tsx: a new key makes the desktop load the new module instead of keeping the mounted one. */
41export const UI_REV = 'r18'
42
43const EMPTY_LIVE: Live = { agents: [], selfUsd: 0, byModel: {}, rate: 0, peak: 0, at: 0 }
44const liveAtom = atom({ plugin: 'agent-ops', key: 'live' } as const, EMPTY_LIVE)
45const historyAtom = atom({ plugin: 'agent-ops', key: 'history' } as const, {})
46const rangeAtom = atom({ plugin: 'agent-ops', key: 'range' } as const, 'day')
47const tabAtom = atom({ plugin: 'agent-ops', key: 'tab' } as const, 'agents')
48const scanErrorAtom = atom({ plugin: 'agent-ops', key: 'scanError' } as const, null)
49const pricingAtom = atom({ plugin: 'agent-ops', key: 'pricing' } as const, [])
50const reposAtom = atom({ plugin: 'agent-ops', key: 'repos' } as const, {})
51const projectAtom = atom({ plugin: 'agent-ops', key: 'project' } as const, null)
52const showAllProjectsAtom = atom({ plugin: 'agent-ops', key: 'showAllProjects' } as const, false)
53const CHIP_LIMIT = 6
54const BREAKDOWN_LIMIT = 6
55
56type Track = AgentRow & {
57 liveOut: number
58 lastOut: number
59 todo: { done: number; total: number } | null
60 tasks: { created: number; done: number }
61 endedAt: number | null
62}
63
64type ScannedAgent = Omit<AgentRow, 'rate' | 'spark' | 'isLocal' | 'since'> & { lastAt: number }
65
66type ScanOutput = { agents: ScannedAgent[]; hist: History; projects: RepoHistory; selfUsd: number; pricing?: PricingNote[]; at: number }
67
68const STATUS: Record<AgentState, { color: string; icon: string; label: string }> = {
69 running: { color: C.ok, icon: '●', label: 'Running' },
70 waiting: { color: C.warn, icon: '▲', label: 'Waiting' },
71 idle: { color: C.idle, icon: '○', label: 'Idle' },
72 done: { color: C.accent, icon: '✓', label: 'Done' },
73 failed: { color: C.bad, icon: '✕', label: 'Failed' },
74}
75
76const RANGE_LABEL: Record<Range, string> = { day: 'Day', week: 'Week', month: 'Month', year: 'Year' }
77
78type Plain = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
79type Series = ReturnType<typeof series>
80type Setter<T> = (v: T) => () => Promise<void>
81
82const tracks = new Map<string, Track>()
83const remoteSpark = new Map<string, { out: number; at: number; spark: number[]; rate: number }>()
84let remote: AgentRow[] = []
85let selfUsd = 0
86let planMonthlyUsd = 200
87let aliases: Record<string, string> = {}
88let localRepo: string | null = null
89let lastTickAt = 0
90let peak = 0
91let isTicking = false
92let isScanning = false
93let sessionId = ''
94let lastLiveJson = ''
95
96function mapStatus(s: AgentStatus): AgentState {
97 if (s === 'completed') return 'done'
98 if (s === 'failed' || s === 'killed') return 'failed'
99 if (s === 'waiting') return 'waiting'
100 if (s === 'idle') return 'idle'
101 return 'running'
102}
103
104const progressOf = (t: Track) =>
105 t.todo ?? (t.tasks.created > 0 ? { done: Math.min(t.tasks.done, t.tasks.created), total: t.tasks.created } : null)
106
107function track(id: string, now: number): Track {
108 let t = tracks.get(id)
109 if (!t) {
110 t = {
111 id,
112 name: id === MAIN ? 'This session' : 'Starting agent…',
113 type: id === MAIN ? 'main' : 'agent',
114 status: id === MAIN ? 'idle' : 'running',
115 model: null,
116 effort: null,
117 tokensIn: 0,
118 tokensOut: 0,
119 cacheRead: 0,
120 rate: 0,
121 spark: Array(SPARK_N).fill(0),
122 progress: null,
123 steps: 0,
124 activity: id === MAIN ? 'Waiting for a prompt' : 'Starting',
125 startedAt: now,
126 session: null,
127 project: null,
128 repo: localRepo,
129 isLocal: true,
130 costUsd: 0,
131 since: now,
132 liveOut: 0,
133 lastOut: 0,
134 todo: null,
135 tasks: { created: 0, done: 0 },
136 endedAt: null,
137 }
138 tracks.set(id, t)
139 }
140 return t
141}
142
143const seenStatus = new Map<string, { status: AgentState; since: number }>()
144
145/** Local statuses change in several hooks; the first tick that sees a new one stamps when it began. */
146function statusSince(t: Track): number {
147 const prev = seenStatus.get(t.id)
148 if (prev && prev.status === t.status) return prev.since
149 const since = lastTickAt || t.startedAt
150 seenStatus.set(t.id, { status: t.status, since })
151 return since
152}
153
154function localRows(): AgentRow[] {
155 return [...tracks.values()].map(t => ({
156 id: t.id,
157 name: t.name,
158 type: t.type,
159 status: t.status,
160 model: t.model,
161 effort: t.effort,
162 tokensIn: t.tokensIn,
163 tokensOut: Math.round(t.tokensOut + t.liveOut),
164 cacheRead: t.cacheRead,
165 rate: t.rate,
166 spark: t.spark,
167 progress: progressOf(t),
168 steps: t.steps,
169 activity: t.activity,
170 startedAt: t.startedAt,
171 session: null,
172 project: t.project,
173 repo: localRepo,
174 isLocal: true,
175 costUsd: 0,
176 since: statusSince(t),
177 }))
178}
179
180function todayByModel(h: History, now: number): Bucket {
181 const key = dayKey(new Date(now))
182 const out: Bucket = {}
183 for (const [k, b] of Object.entries(h)) {
184 if (!k.startsWith(key)) continue
185 for (const [key, u] of Object.entries(b)) addUsage(out, key, u)
186 }
187 return out
188}
189
190async function scan($: EngineInterface): Promise<void> {
191 if (isScanning) return
192 isScanning = true
193 try {
194 const home = (await $.env.get('HOME')) ?? ''
195 const r = await $.process.run(
196 ['/usr/bin/env', 'python3', '-I', `${$.plugin.root}/scanner/scan.py`, `${home}/.claude/projects`, `${home}/.claude/agent-ops/state.json`, sessionId, LIVE_MINUTES],
197 { timeoutMs: 120_000, env: { PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin' } },
198 )
199 if (r.exitCode !== 0) {
200 const reason = r.stderr.trim().split('\n').pop() ?? `exit ${r.exitCode}`
201 await update($, scanErrorAtom, () => `Other sessions unavailable: ${reason.slice(0, 160)}`)
202 return
203 }
204 const out = JSON.parse(r.stdout) as ScanOutput
205 selfUsd = out.selfUsd ?? 0
206 remote = out.agents.map(a => {
207 const prev = remoteSpark.get(a.id)
208 const dt = prev ? Math.max(1, (out.at - prev.at) / 1000) : 1
209 const rate = prev ? Math.max(0, (a.tokensOut - prev.out) / dt) : 0
210 const sparkValues = [...(prev?.spark ?? Array(SPARK_N).fill(0)).slice(1), rate]
211 remoteSpark.set(a.id, { out: a.tokensOut, at: out.at, spark: sparkValues, rate })
212 const { lastAt, ...row } = a
213 return { ...row, rate, spark: sparkValues, isLocal: false, since: lastAt }
214 })
215 const ids = new Set(out.agents.map(a => a.id))
216 for (const id of remoteSpark.keys()) if (!ids.has(id)) remoteSpark.delete(id)
217 await update($, historyAtom, () => out.hist)
218 await update($, reposAtom, () => out.projects ?? {})
219 await update($, pricingAtom, () => out.pricing ?? [])
220 await update($, scanErrorAtom, () => null)
221 } catch (err) {
222 const msg = err instanceof Error ? err.message : String(err)
223 await update($, scanErrorAtom, () => `Other sessions unavailable: ${msg.slice(0, 160)}`)
224 } finally {
225 isScanning = false
226 }
227}
228
229async function tick($: EngineInterface): Promise<void> {
230 if (isTicking) return
231 isTicking = true
232 try {
233 const now = await $.clock.now()
234 let list: AgentInfo[] = []
235 try {
236 list = await $.agent.list()
237 } catch {
238 list = []
239 }
240 const seen = new Set([MAIN])
241 for (const a of list) {
242 seen.add(a.id)
243 const t = track(a.id, now)
244 t.name = a.name ?? a.description ?? a.type
245 t.type = a.type
246 const s = mapStatus(a.status)
247 if (s !== t.status) {
248 t.status = s
249 t.endedAt = s === 'done' || s === 'failed' ? now : null
250 if (s === 'done') t.activity = 'Returned its result'
251 }
252 }
253 for (const t of tracks.values()) {
254 if (!seen.has(t.id) && t.endedAt === null) {
255 t.status = 'done'
256 t.endedAt = now
257 t.activity = 'Returned its result'
258 }
259 }
260 for (const [id, t] of tracks) if (t.endedAt !== null && now - t.endedAt > LINGER_MS) tracks.delete(id)
261
262 const dt = lastTickAt ? Math.max(0.25, (now - lastTickAt) / 1000) : POLL_MS / 1000
263 lastTickAt = now
264 let rate = 0
265 for (const t of tracks.values()) {
266 const out = t.tokensOut + t.liveOut
267 t.rate = Math.max(0, (out - t.lastOut) / dt)
268 t.lastOut = out
269 t.spark = [...t.spark.slice(1), t.rate]
270 rate += t.rate
271 }
272 for (const r of remote) rate += r.rate
273 rate = Math.round(rate)
274 peak = Math.max(peak * 0.995, rate)
275 const history = (await read($, historyAtom)) as History
276 const body = { agents: [...localRows(), ...remote], selfUsd: Math.round(selfUsd * 100) / 100, byModel: todayByModel(history, now), rate, halfMinute: Math.floor(now / 30_000) }
277 const json = JSON.stringify(body)
278 if (json === lastLiveJson) return
279 lastLiveJson = json
280 const { halfMinute: _halfMinute, ...live } = body
281 const next: Live = { ...live, peak: Math.round(peak), at: now }
282 await update($, liveAtom, () => next)
283 } finally {
284 isTicking = false
285 }
286}
287
288let pollTimer: Timer | null = null
289let scanTimer: Timer | null = null
290
291/** Polling and the transcript scan run only while the pane is open, so closed sessions cost nothing. */
292function startPolling($: EngineInterface, refreshNow: boolean): void {
293 if (pollTimer) return
294 pollTimer = $.clock.every(POLL_MS, () => void tick($))
295 scanTimer = $.clock.every(SCAN_MS, () => void scan($))
296 if (!refreshNow) return
297 void scan($)
298 void tick($)
299}
300
301function stopPolling(): void {
302 pollTimer?.cancel()
303 scanTimer?.cancel()
304 pollTimer = null
305 scanTimer = null
306}
307
308function kpis(L: Live) {
309 const up = L.agents.filter(a => a.status === 'running' || a.status === 'waiting').length
310 const wait = L.agents.filter(a => a.status === 'waiting').length
311 const { tokens: today, usd: costToday } = bucketTotals(L.byModel)
312 const sessions = new Set(L.agents.map(a => (a.isLocal ? 'local' : a.id.split(':')[0]))).size
313 return { up, wait, today, sessions, costToday }
314}
315
316function summary(S: Series, range: Range) {
317 const live = S.points.filter(p => !p.isFuture)
318 const sum = live.reduce((a, p) => a + total(p), 0)
319 const cache = live.reduce((a, p) => a + p.cacheRead, 0)
320 const peakPoint = live.reduce<Point | undefined>((m, p) => (!m || total(p) > total(m) ? p : m), undefined)
321 const byFam = FAMILIES.map(f => [f, live.reduce((a, p) => a + p.byFamily[f], 0)] as const).sort((a, b) => b[1] - a[1])
322 const delta = S.previousTotal > 0 ? ((sum - S.previousTotal) / S.previousTotal) * 100 : null
323 const unit = range === 'day' ? 'hour' : range === 'year' ? 'month' : 'day'
324 const prevName = { day: 'yesterday', week: 'the 7 days before', month: 'the 30 days before', year: 'the 12 months before' }[range]
325 return { sum, cache, peakPoint, byFam, delta, unit, prevName, avg: live.length ? sum / live.length : 0 }
326}
327
328type Group = { key: string; title: string; subtitle: string; repo: string | null; agents: AgentRow[] }
329
330const ORDER: AgentState[] = ['waiting', 'running', 'idle', 'done', 'failed']
331
332function groups(agents: readonly AgentRow[]): Group[] {
333 const local = agents.filter(a => a.isLocal)
334 const bySession = new Map<string, AgentRow[]>()
335 for (const a of agents) {
336 if (a.isLocal) continue
337 const sid = a.id.split(':')[0] ?? a.id
338 bySession.set(sid, [...(bySession.get(sid) ?? []), a])
339 }
340 const sortRows = (rows: AgentRow[]) =>
341 [...rows].sort((a, b) => {
342 const am = a.type === 'session' || a.id === MAIN ? -1 : 0
343 const bm = b.type === 'session' || b.id === MAIN ? -1 : 0
344 return am - bm || ORDER.indexOf(a.status) - ORDER.indexOf(b.status) || a.startedAt - b.startedAt
345 })
346 const out: Group[] = local.length ? [{ key: 'local', title: 'This session', subtitle: 'tracked live', repo: localRepo, agents: sortRows(local) }] : []
347 for (const r of out[0]?.agents ?? []) if (r.id === MAIN) r.name = 'Main'
348 const remoteGroups = [...bySession.entries()].map(([sid, rows]) => {
349 const head = rows.find(r => r.type === 'session')
350 return {
351 key: sid,
352 title: head?.name ?? rows[0]?.session ?? 'Other session',
353 subtitle: rows[0]?.project ?? '',
354 repo: rows[0]?.repo ?? null,
355 agents: sortRows(rows).map(r => (r.type === 'session' ? { ...r, name: 'Main', type: 'main' } : r)),
356 }
357 })
358 remoteGroups.sort((a, b) => {
359 const rank = (g: Group) => Math.min(...g.agents.map(x => ORDER.indexOf(x.status)))
360 return rank(a) - rank(b)
361 })
362 return [...out, ...remoteGroups]
363}
364
365function topView(L: Live): TopView {
366 const k = kpis(L)
367 return {
368 view: 'top',
369 up: k.up,
370 count: L.agents.length,
371 rate: L.rate,
372 max: Math.max(200, Math.ceil((L.peak * 1.25) / 100) * 100),
373 today: k.today,
374 waiting: k.wait,
375 sessions: k.sessions,
376 costToday: k.costToday,
377 mix: FAMILIES.map(f => ({ family: f, tokens: tokensByFamily(L.byModel)[f] })),
378 }
379}
380
381function ago(ms: number): string {
382 const s = Math.max(0, Math.round(ms / 1000))
383 if (s < 60) return `${s}s`
384 const m = Math.round(s / 60)
385 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`
386}
387
388function cardView(a: AgentRow, now: number): CardView {
389 const isQuiet = a.status === 'idle' || a.status === 'done' || a.status === 'failed'
390 return {
391 id: a.id,
392 name: a.name,
393 status: a.status,
394 activity: a.activity,
395 model: modelLabel(a.model),
396 family: familyOf(a.model),
397 effort: a.effort,
398 effortLevel: effortLevel(a.effort),
399 type: a.type,
400 progress: a.progress && a.progress.total > 0 ? a.progress.done / a.progress.total : null,
401 progressText: a.progress ? `${a.progress.done}/${a.progress.total} tasks` : `${a.steps} steps`,
402 tokens: a.tokensIn + a.tokensOut,
403 rate: Math.round(a.rate),
404 spark: a.spark.map(v => Math.round(v)),
405 when: a.status === 'waiting' ? ago(now - a.since) : isQuiet ? `${ago(now - a.since)} ago` : '',
406 }
407}
408
409const quiet = (s: AgentState) => s === 'idle' || s === 'done' || s === 'failed'
410
411function sessionView(g: Group, now: number, localUsd: number, project: string): SessionView {
412 const cards = g.agents.map(a => cardView(a, now))
413 const running = g.agents.filter(a => a.status === 'running').length
414 const waiting = g.agents.filter(a => a.status === 'waiting')
415 const status: AgentState = waiting.length ? 'waiting' : running ? 'running' : g.agents.some(a => a.status === 'failed') ? 'failed' : 'idle'
416 const longestWait = waiting.reduce((m, a) => Math.max(m, now - a.since), 0)
417 const lastActive = g.agents.reduce((m, a) => Math.max(m, a.since), 0)
418 const parts = [running ? `${running} running` : '', waiting.length ? `${waiting.length} waiting ${ago(longestWait)}` : ''].filter(Boolean)
419 return {
420 key: g.key,
421 title: g.title,
422 subtitle: g.subtitle,
423 status,
424 summary: parts.length ? parts.join(' · ') : status === 'failed' ? 'failed' : 'idle',
425 tokens: g.agents.reduce((s, a) => s + a.tokensIn + a.tokensOut, 0),
426 costUsd: g.key === 'local' ? localUsd : g.agents.reduce((s, a) => s + a.costUsd, 0),
427 when: status === 'idle' || status === 'failed' ? `${ago(now - lastActive)} ago` : '',
428 project,
429 cards,
430 }
431}
432
433type Scope = {
434 resolve: (repo: string | null) => string
435 projects: Map<string, ProjectHistory>
436 selected: string | null
437}
438
439function todayTotals(h: History | undefined, now: number): { tokens: number; usd: number } {
440 return bucketTotals(todayByModel(h ?? {}, now))
441}
442
443function agentsView(L: Live, error: string | null, now: number, scope: Scope): AgentsView {
444 const sessions = groups(L.agents).map(g => sessionView(g, now, L.selfUsd, scope.resolve(g.repo)))
445 const active = sessions.filter(s => !quiet(s.status) || s.key === 'local')
446 const byProject = new Map<string, SessionView[]>()
447 for (const sv of active) byProject.set(sv.project, [...(byProject.get(sv.project) ?? []), sv])
448 const rank = (list: SessionView[]) => Math.min(...list.map(x => ORDER.indexOf(x.status)))
449 const lines: ProjectGroupView[] = [...byProject.entries()]
450 .sort((a, b) => rank(a[1]) - rank(b[1]))
451 .map(([name, list]) => {
452 const agents = list.flatMap(x => x.cards)
453 const running = agents.filter(c => c.status === 'running').length
454 const waiting = agents.filter(c => c.status === 'waiting').length
455 const today = todayTotals(scope.projects.get(name)?.hist, now)
456 const parts = [`${list.length} ${list.length === 1 ? 'session' : 'sessions'}`, running ? `${running} running` : '', waiting ? `${waiting} waiting` : '']
457 return { name, summary: parts.filter(Boolean).join(' · '), tokensToday: today.tokens, costToday: today.usd, sessions: list }
458 })
459 return {
460 view: 'agents',
461 error,
462 groups: lines,
463 showProjectLines: scope.selected === null,
464 idle: sessions.filter(s => quiet(s.status) && s.key !== 'local'),
465 }
466}
467
468/** The biggest few projects for a period, the rest folded into one "N more" row. */
469function topProjects<T extends { name: string }>(rows: T[], weight: (r: T) => number, fold: (rest: T[]) => T): T[] {
470 const sorted = [...rows].filter(r => weight(r) > 0).sort((a, b) => weight(b) - weight(a))
471 if (sorted.length <= BREAKDOWN_LIMIT + 1) return sorted
472 const rest = sorted.slice(BREAKDOWN_LIMIT)
473 return [...sorted.slice(0, BREAKDOWN_LIMIT), fold(rest)]
474}
475
476const DAYS_PER_MONTH = 30
477
478function pricingNotes(notes: readonly PricingNote[]): string[] {
479 return [...notes]
480 .sort((a, b) => b.tokens - a.tokens)
481 .map(n =>
482 n.as
483 ? `${modelLabel(n.model)} (${n.model}) has no price row; priced as ${modelLabel(n.as)}.`
484 : `${n.model} has no price row; its ${fmt(n.tokens)} tokens are counted at $0.`,
485 )
486}
487
488/** Days from the first day of recorded history through today, capped at the range. */
489function daysWithData(firstDay: string | null, range: Range, now: number): number {
490 const days = RANGE_DAYS[range]
491 if (!firstDay) return days
492 const [y, m, d] = firstDay.split('-').map(Number)
493 const today = new Date(now)
494 const span = Math.round((new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime() - new Date(y!, m! - 1, d!).getTime()) / 86_400_000) + 1
495 return Math.max(1, Math.min(days, span))
496}
497
498function economicsView(H: History, range: Range, now: number, plan: number, scope: Scope, totalCost: number, firstDay: string | null, pricing: readonly PricingNote[]): EconomicsView {
499 const S = series(H, range, now)
500 const live = S.points.filter(p => !p.isFuture)
501 const cost = live.reduce((a, p) => a + p.usd, 0)
502 const days = daysWithData(firstDay, range, now)
503 const planShare = (plan / DAYS_PER_MONTH) * days
504 const sum = (pick: (p: Point) => number) => live.reduce((a, p) => a + pick(p), 0)
505 const byKind = [
506 { label: 'Cache reads', usd: sum(p => p.kinds.readUsd), tokens: sum(p => p.kinds.readTokens) },
507 { label: 'Cache writes and input', usd: sum(p => p.kinds.inputUsd), tokens: sum(p => p.kinds.inputTokens) },
508 { label: 'Output', usd: sum(p => p.kinds.outputUsd), tokens: sum(p => p.kinds.outputTokens) },
509 ]
510 const allTokens = byKind.reduce((a, k) => a + k.tokens, 0)
511 const reads = byKind[0]!
512 let byProject: EconomicsView['byProject'] = null
513 let projectInsight = ''
514 if (scope.selected === null) {
515 const rows = [...scope.projects.entries()].map(([name, ph]) => {
516 const usd = series(ph.hist, range, now).points.reduce((a, p) => a + (p.isFuture ? 0 : p.usd), 0)
517 const n = sessionsIn(ph, range, now)
518 return { name, usd, sessions: n }
519 })
520 const totalSessions = rows.reduce((a, r) => a + r.sessions, 0)
521 const avgPerSession = totalSessions > 0 ? cost / totalSessions : 0
522 const top = topProjects(rows, r => r.usd, rest => ({
523 name: `${rest.length} more`,
524 usd: rest.reduce((a, r) => a + r.usd, 0),
525 sessions: rest.reduce((a, r) => a + r.sessions, 0),
526 }))
527 byProject = top.map(r => ({
528 name: r.name,
529 usd: r.usd,
530 share: cost > 0 ? r.usd / cost : 0,
531 perSession: r.sessions > 0 ? r.usd / r.sessions : null,
532 multiple: planShare > 0 ? r.usd / planShare : 0,
533 }))
534 const lead = byProject[0]
535 const outlier = byProject
536 .filter(r => r.perSession !== null && avgPerSession > 0 && r.perSession > avgPerSession * 3)
537 .sort((a, b) => (b.perSession ?? 0) - (a.perSession ?? 0))[0]
538 if (lead && cost > 0) projectInsight = ` ${lead.name} is ${Math.round(lead.share * 100)}% of the cost${lead.multiple >= 1 ? `, ${lead.multiple.toFixed(1)}× your plan on its own` : ''}.`
539 if (outlier && outlier.perSession !== null)
540 projectInsight += ` ${outlier.name} costs ${fmtUsd(outlier.perSession)} per session, ${(outlier.perSession / avgPerSession).toFixed(1)}× the average.`
541 }
542 const insight =
543 cost > 0 && allTokens > 0
544 ? `Cache reads are ${Math.round((reads.tokens / allTokens) * 100)}% of tokens but ${Math.round((reads.usd / cost) * 100)}% of the cost.${projectInsight}`
545 : 'No priced usage in this period yet.'
546 return {
547 view: 'economics',
548 range,
549 rangeLabel: { day: 'today', week: 'last 7 days', month: 'last 30 days', year: 'last 12 months' }[range],
550 planMonthly: plan,
551 cost,
552 planShare,
553 coveredDays: days < RANGE_DAYS[range] ? days : null,
554 pricing: pricingNotes(pricing),
555 multiple: planShare > 0 ? cost / planShare : 0,
556 shareOfTotal: scope.selected === null || totalCost <= 0 ? null : cost / totalCost,
557 byModel: [...new Set(live.flatMap(p => Object.keys(p.usdByModel)))]
558 .map(key => ({ key, label: modelLabel(key), family: familyOf(key), usd: sum(p => p.usdByModel[key] ?? 0), tokens: sum(p => p.tokensByModel[key] ?? 0) }))
559 .filter(x => x.usd > 0)
560 .sort((a, b) => b.usd - a.usd),
561 byKind,
562 insight,
563 points: S.points.map(p => ({ label: p.label, full: p.full, isFuture: p.isFuture, usd: p.usd })),
564 byProject,
565 scope: scope.selected ?? 'all projects',
566 }
567}
568
569function usageView(S: Series, range: Range, now: number, scope: Scope): UsageView {
570 const sm = summary(S, range)
571 const top = sm.byFam[0]
572 const families = FAMILIES.filter(f => sm.byFam.some(([g, v]) => g === f && v > 0))
573 const hasTop = top !== undefined && top[1] > 0
574 return {
575 view: 'usage',
576 range,
577 unit: sm.unit,
578 families,
579 tiles: [
580 {
581 label: 'TOTAL TOKENS',
582 value: sm.sum,
583 text: null,
584 note: sm.delta === null ? `no data for ${sm.prevName}` : `${sm.delta >= 0 ? '▲' : '▼'} ${Math.abs(sm.delta).toFixed(1)}% vs ${sm.prevName}`,
585 color: C.fg,
586 noteColor: sm.delta === null ? C.muted : sm.delta >= 0 ? C.ok : C.bad,
587 },
588 { label: `AVG PER ${sm.unit.toUpperCase()}`, value: sm.avg, text: null, note: 'input + output', color: C.fg, noteColor: C.muted },
589 {
590 label: `PEAK ${sm.unit.toUpperCase()}`,
591 value: sm.peakPoint && total(sm.peakPoint) > 0 ? total(sm.peakPoint) : null,
592 text: '—',
593 note: sm.peakPoint?.full ?? '',
594 color: C.fg,
595 noteColor: C.muted,
596 },
597 {
598 label: 'TOP MODEL',
599 value: null,
600 text: hasTop ? FAMILY_LABEL[top[0]] : '—',
601 note: hasTop && sm.sum > 0 ? `${Math.round((top[1] / sm.sum) * 100)}% of tokens` : 'no usage yet',
602 color: hasTop ? FAMILY_COLOR[top[0]] : C.fg,
603 noteColor: C.muted,
604 },
605 { label: 'CACHE READS', value: sm.cache, text: null, note: 'served from prompt cache', color: C.fg, noteColor: C.muted },
606 ],
607 points: S.points.map(p => ({ label: p.label, full: p.full, isFuture: p.isFuture, values: families.map(f => (p.isFuture ? 0 : p.byFamily[f])) })),
608 byProject:
609 scope.selected !== null
610 ? null
611 : topProjects(
612 [...scope.projects.entries()].map(([name, ph]) => {
613 const live = series(ph.hist, range, now).points.filter(p => !p.isFuture)
614 const values = families.map(f => live.reduce((a, p) => a + p.byFamily[f], 0))
615 return { name, values, total: values.reduce((a, b) => a + b, 0) }
616 }),
617 r => r.total,
618 rest => ({
619 name: `${rest.length} more`,
620 values: families.map((_, i) => rest.reduce((a, r) => a + (r.values[i] ?? 0), 0)),
621 total: rest.reduce((a, r) => a + r.total, 0),
622 }),
623 ),
624 }
625}
626
627function repoOf(cwd: string): string {
628 const root = cwd.split('/.claude/worktrees/')[0] ?? cwd
629 return root.split('/').filter(Boolean).pop() ?? 'unknown'
630}
631
632function narrowLive(L: Live, keep: (a: AgentRow) => boolean, h: History, now: number): Live {
633 const agents = L.agents.filter(keep)
634 return {
635 ...L,
636 agents,
637 selfUsd: agents.some(a => a.isLocal) ? L.selfUsd : 0,
638 byModel: todayByModel(h, now),
639 rate: Math.round(agents.reduce((s, a) => s + a.rate, 0)),
640 }
641}
642
643function spendSince(h: History, fromKey: string): number {
644 let usd = 0
645 for (const [k, b] of Object.entries(h)) {
646 if (k < fromKey) continue
647 usd += bucketTotals(b).usd
648 }
649 return usd
650}
651
652/** "All projects" plus the projects with live agents or spend in the last 30 days, busiest first. */
653function projectChips(L: Live, scope: Scope, now: number, showAll: boolean): TabView[] {
654 const live = new Map<string, number>()
655 for (const a of L.agents) {
656 if (a.status !== 'running' && a.status !== 'waiting') continue
657 const name = scope.resolve(a.repo)
658 live.set(name, (live.get(name) ?? 0) + 1)
659 }
660 const from = dayKey(new Date(now - 30 * 86_400_000))
661 const names = new Set([...live.keys(), ...[...scope.projects.entries()].filter(([, ph]) => spendSince(ph.hist, from) > 0).map(([n]) => n)])
662 const spend = (n: string) => spendSince(scope.projects.get(n)?.hist ?? {}, from)
663 const ordered = [...names].sort((a, b) => (live.get(b) ?? 0) - (live.get(a) ?? 0) || spend(b) - spend(a) || a.localeCompare(b))
664 const shown = showAll ? ordered : ordered.slice(0, CHIP_LIMIT)
665 if (scope.selected !== null && !shown.includes(scope.selected)) shown.push(scope.selected)
666 const chips: TabView[] = [{ id: 'proj:*', label: 'All projects', isOn: scope.selected === null }]
667 for (const n of shown) {
668 const count = live.get(n) ?? 0
669 chips.push({ id: `proj:${n}`, label: count ? `${n} ● ${count}` : n, isOn: scope.selected === n })
670 }
671 if (ordered.length > CHIP_LIMIT) chips.push({ id: 'proj-more', label: showAll ? 'Fewer' : `+${ordered.length - CHIP_LIMIT} more`, isOn: false })
672 return chips
673}
674
675function plainView(ui: Plain, L: Live, S: Series, range: Range, tab: Tab, setRange: Setter<Range>, setTab: Setter<Tab>, cols: number) {
676 const { Box, Text, Button } = ui
677 const k = kpis(L)
678 const sm = summary(S, range)
679 const bar = (frac: number, width: number) => {
680 const n = Math.round(Math.max(0, Math.min(1, frac)) * width)
681 return '█'.repeat(n) + '░'.repeat(width - n)
682 }
683 const ticks = '▁▂▃▄▅▆▇█'
684 const sparkline = (v: readonly number[]) => {
685 const m = Math.max(1, ...v)
686 return v.map(x => ticks[Math.min(7, Math.floor((x / m) * 7.99))]).join('')
687 }
688 const barW = Math.max(6, Math.min(20, cols - 60))
689 const maxFam = Math.max(1, ...sm.byFam.map(([, v]) => v))
690 return (
691 <Box flexDirection="column" gap={1}>
692 <Box columnGap={2} flexWrap="wrap">
693 <Text bold color={C.accent}>
694 ◉ AGENT/OPS
695 </Text>
696 <Text>
697 {k.up}/{L.agents.length} up · {fmtInt(L.rate)} tok/s · {fmt(k.today)} today
698 </Text>
699 {k.wait > 0 ? <Text color={C.warn}>▲ {k.wait} waiting</Text> : null}
700 </Box>
701 <Box columnGap={1}>
702 <Button key={`tab-agents-${UI_REV}`} label="Agents" variant={tab === 'agents' ? 'primary' : 'secondary'} onPress={setTab('agents')} />
703 <Button key={`tab-usage-${UI_REV}`} label="Usage" variant={tab === 'usage' ? 'primary' : 'secondary'} onPress={setTab('usage')} />
704 </Box>
705 {tab === 'agents' ? (
706 <Box flexDirection="column">
707 {groups(L.agents).map(g => (
708 <Box key={`grp-${g.key}`} flexDirection="column">
709 <Text bold color={C.accent}>
710 ▸ {g.title} <Text dimColor>{g.subtitle}</Text>
711 </Text>
712 {g.agents.map(a => {
713 const st = STATUS[a.status]
714 const p = a.status === 'done' ? 1 : a.progress && a.progress.total > 0 ? a.progress.done / a.progress.total : null
715 return (
716 <Box key={`row-${a.id}`} flexDirection="column">
717 <Box columnGap={1}>
718 <Text color={st.color}>{st.icon}</Text>
719 <Box width={22}>
720 <Text bold wrap="truncate-end">
721 {a.name}
722 </Text>
723 </Box>
724 <Text color={st.color}>{st.label.padEnd(8)}</Text>
725 <Text color={C.accent}>{p === null ? `${a.steps} steps`.padEnd(barW + 5) : `${bar(p, barW)} ${String(Math.round(p * 100)).padStart(3)}%`}</Text>
726 <Text color={FAMILY_COLOR[familyOf(a.model)]}>{modelLabel(a.model)}</Text>
727 {a.effort ? <Text dimColor>{a.effort}</Text> : null}
728 <Text>{fmt(a.tokensIn + a.tokensOut).padStart(7)}</Text>
729 <Text color={FAMILY_COLOR[familyOf(a.model)]}>{sparkline(a.spark.slice(-12))}</Text>
730 </Box>
731 <Text dimColor wrap="truncate-end">
732 {' '}
733 {a.activity}
734 </Text>
735 </Box>
736 )
737 })}
738 </Box>
739 ))}
740 </Box>
741 ) : (
742 <Box flexDirection="column" gap={1}>
743 <Box columnGap={1} alignItems="center">
744 <Text bold>Usage by model</Text>
745 {(['day', 'week', 'month', 'year'] as const).map(r => (
746 <Button key={`range-${r}`} label={RANGE_LABEL[r]} variant={r === range ? 'primary' : 'secondary'} onPress={setRange(r)} />
747 ))}
748 </Box>
749 <Text>
750 {fmt(sm.sum)} tokens
751 {sm.delta === null ? '' : ` · ${sm.delta >= 0 ? '▲' : '▼'} ${Math.abs(sm.delta).toFixed(1)}% vs ${sm.prevName}`} · cache reads {fmt(sm.cache)}
752 </Text>
753 <Text color={C.accent}>{sparkline(S.points.filter(p => !p.isFuture).map(total))}</Text>
754 <Box flexDirection="column">
755 {sm.byFam
756 .filter(([, v]) => v > 0)
757 .map(([f, v]) => (
758 <Box key={`fam-${f}`} columnGap={1}>
759 <Box width={8}>
760 <Text>{FAMILY_LABEL[f]}</Text>
761 </Box>
762 <Text color={FAMILY_COLOR[f]}>{bar(v / maxFam, barW + 8)}</Text>
763 <Text>{fmt(v)}</Text>
764 </Box>
765 ))}
766 </Box>
767 </Box>
768 )}
769 </Box>
770 )
771}
772
773export const register: Register = (on, options) => {
774 planMonthlyUsd = Math.max(0, Number(options.planMonthlyUsd ?? 200)) || 200
775 aliases = parseAliases(String(options.projectAliases ?? ''))
776 on('session.start', async ($, e, next) => {
777 await $.command.register({ name: 'agent-ops', description: 'Open the live agents and token usage dashboard' })
778 const now = await $.clock.now()
779 sessionId = await $.session.id()
780 localRepo = await $.session
781 .cwd()
782 .then(repoOf)
783 .catch(() => null)
784 const prior = await read($, liveAtom)
785 for (const row of prior.agents) {
786 if (!row.isLocal) continue
787 const t = track(row.id, now)
788 Object.assign(t, row, { lastOut: row.tokensOut })
789 }
790 peak = prior.peak
791 track(MAIN, now)
792 return next(e)
793 })
794
795 on('command.run', { command: 'agent-ops' }, async $ => {
796 await $.ui.open({ id: PANE, title: 'Agent Ops' })
797 startPolling($, true)
798 return { text: 'Agent Ops dashboard opened.' }
799 })
800
801 on('turn.start', async ($, e, next) => {
802 const t = track(MAIN, await $.clock.now())
803 t.status = 'running'
804 t.activity = 'Thinking…'
805 t.todo = null
806 return next(e)
807 })
808
809 on('turn.complete', async ($, e, next) => {
810 if (e.agentId === undefined) {
811 const t = track(MAIN, await $.clock.now())
812 t.status = 'idle'
813 t.activity = e.isAborted ? 'Interrupted' : 'Waiting for a prompt'
814 }
815 return next(e)
816 })
817
818 on('turn.step', async function* ($, e, next) {
819 const t = track(e.agentId ?? MAIN, await $.clock.now())
820 t.model = e.model
821 t.effort = e.effort === undefined ? null : typeof e.effort === 'number' ? `budget ${e.effort}` : e.effort
822 t.steps += 1
823 if (t.status === 'waiting' || t.status === 'idle') t.status = 'running'
824 let estimate = 0
825 const stream = next(e)
826 try {
827 while (true) {
828 const r = await stream.next()
829 if (r.done) {
830 const u = r.value?.usage
831 if (u) {
832 t.tokensIn += u.input_tokens + u.cache_creation_input_tokens
833 t.tokensOut += u.output_tokens
834 t.cacheRead += u.cache_read_input_tokens
835 }
836 return r.value
837 }
838 const c = r.value
839 if (c.kind === 'text' || c.kind === 'thinking') {
840 estimate += c.text.length / 4
841 t.liveOut += c.text.length / 4
842 if (c.kind === 'thinking') t.activity = 'Thinking…'
843 } else if (c.kind === 'input') {
844 estimate += c.json.length / 4
845 t.liveOut += c.json.length / 4
846 } else if (c.kind === 'tool') {
847 t.activity = `Calling ${c.name}`
848 }
849 yield c
850 }
851 } finally {
852 t.liveOut = Math.max(0, t.liveOut - estimate)
853 }
854 })
855
856 on('tool.call', async ($, e, next) => {
857 const t = track(e.agentId ?? MAIN, await $.clock.now())
858 const input = e as unknown as Record<string, unknown>
859 t.activity = describeTool(e.tool, input)
860 if (t.status === 'waiting') t.status = 'running'
861 if (e.tool === 'TodoWrite' && Array.isArray(input.todos)) {
862 const todos = input.todos as { status?: string }[]
863 t.todo = { done: todos.filter(x => x.status === 'completed').length, total: todos.length }
864 } else if (e.tool === 'TaskCreate') {
865 t.tasks.created += 1
866 } else if (e.tool === 'TaskUpdate') {
867 if (input.status === 'completed') t.tasks.done += 1
868 if (input.status === 'deleted') t.tasks.created = Math.max(0, t.tasks.created - 1)
869 }
870 return next(e)
871 })
872
873 on('classic.PermissionRequest', async ($, e, next) => {
874 const t = track(e.agent_id ?? MAIN, await $.clock.now())
875 t.status = 'waiting'
876 t.activity = `Permission: ${e.tool_name}`
877 return next(e)
878 })
879
880 on('ui.close', ($, e, next) => {
881 if (e.id === PANE) stopPolling()
882 return next(e)
883 })
884
885 on('ui.message', async ($, e, next) => {
886 if (e.requestId !== PANE) return next(e)
887 const press = (e.data as { press?: string } | null)?.press ?? ''
888 if (press === 'agents' || press === 'usage' || press === 'economics') await update($, tabAtom, () => press)
889 if (press === 'proj-more') await update($, showAllProjectsAtom, v => !v)
890 if (press.startsWith('proj:')) {
891 const name = press.slice(5)
892 await update($, projectAtom, () => (name === '*' ? null : name))
893 }
894 if (press.startsWith('range-')) {
895 const r = press.slice(6)
896 if (r === 'day' || r === 'week' || r === 'month' || r === 'year') await update($, rangeAtom, () => r)
897 }
898 return {}
899 })
900
901 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
902 // A pane left open across a reload has no timers yet; drawing may not write state, so only start them.
903 startPolling($, false)
904 const L = await read($, liveAtom)
905 const H = (await read($, historyAtom)) as History
906 const range = (await read($, rangeAtom)) as Range
907 const tab = (await read($, tabAtom)) as Tab
908 const scanError = await read($, scanErrorAtom)
909 const pricing = (await read($, pricingAtom)) as PricingNote[]
910 const now = await $.clock.now()
911 const repos = (await read($, reposAtom)) as RepoHistory
912 const wanted = await read($, projectAtom)
913 const showAllProjects = await read($, showAllProjectsAtom)
914 const resolve = projectResolver([...Object.keys(repos), ...L.agents.flatMap(a => (a.repo ? [a.repo] : []))], aliases)
915 const projects = mergeProjects(repos, resolve)
916 const selected = wanted !== null && (projects.has(wanted) || L.agents.some(a => resolve(a.repo) === wanted)) ? wanted : null
917 const scope: Scope = { resolve, projects, selected }
918 const Hs = selected === null ? H : (projects.get(selected)?.hist ?? {})
919 const Ls: Live = selected === null ? L : narrowLive(L, a => resolve(a.repo) === selected, Hs, now)
920 const S = series(Hs, range, now)
921 const S_all = selected === null ? S : series(H, range, now)
922 const chips = projectChips(L, scope, now, showAllProjects)
923 if (e.surface === 'desktop' || e.surface === 'terminal') {
924 const { Box, Client } = $.ui.resolve(e)
925 return (
926 <Box flexDirection="column" gap={2} paddingX={2} paddingY={1} backgroundColor={C.bg}>
927 <Client key={`dash-top-${UI_REV}`} module="./dash.tsx" props={topView(Ls)} width="100%" />
928 <Box columnGap={2} rowGap={1} flexWrap="wrap" alignItems="center">
929 <Client key={`tab-agents-${UI_REV}`} module="./tab.tsx" props={{ id: 'agents', label: `Agents (${Ls.agents.length})`, isOn: tab === 'agents' }} />
930 <Client key={`tab-usage-${UI_REV}`} module="./tab.tsx" props={{ id: 'usage', label: 'Usage history', isOn: tab === 'usage' }} />
931 <Client key={`tab-economics-${UI_REV}`} module="./tab.tsx" props={{ id: 'economics', label: 'Economics', isOn: tab === 'economics' }} />
932 {tab !== 'agents' ? (
933 <Box flexGrow={1} justifyContent="flex-end" columnGap={2}>
934 <Client key={`range-day-${UI_REV}`} module="./tab.tsx" props={{ id: 'range-day', label: 'Day', isOn: range === 'day' }} />
935 <Client key={`range-week-${UI_REV}`} module="./tab.tsx" props={{ id: 'range-week', label: 'Week', isOn: range === 'week' }} />
936 <Client key={`range-month-${UI_REV}`} module="./tab.tsx" props={{ id: 'range-month', label: 'Month', isOn: range === 'month' }} />
937 <Client key={`range-year-${UI_REV}`} module="./tab.tsx" props={{ id: 'range-year', label: 'Year', isOn: range === 'year' }} />
938 </Box>
939 ) : null}
940 </Box>
941 <Box columnGap={1} rowGap={1} flexWrap="wrap" alignItems="center">
942 {chips.map((c, i) => (
943 <Client key={`proj-${i}-${UI_REV}`} module="./tab.tsx" props={c} />
944 ))}
945 </Box>
946 {tab === 'agents' ? (
947 <Client key={`dash-agents-${UI_REV}`} module="./dash.tsx" props={agentsView(Ls, scanError, now, scope)} width="100%" />
948 ) : tab === 'usage' ? (
949 <Client key={`dash-usage-${UI_REV}`} module="./dash.tsx" props={usageView(S, range, now, scope)} width="100%" />
950 ) : (
951 <Client key={`dash-economics-${UI_REV}`} module="./dash.tsx" props={economicsView(Hs, range, now, planMonthlyUsd, scope, S_all.points.reduce((a, p) => a + (p.isFuture ? 0 : p.usd), 0), Object.keys(H).sort()[0]?.slice(0, 10) ?? null, pricing)} width="100%" />
952 )}
953 </Box>
954 )
955 }
956 const setRange: Setter<Range> = r => async () => {
957 await update($, rangeAtom, () => r)
958 }
959 const setTab: Setter<Tab> = t => async () => {
960 await update($, tabAtom, () => t)
961 }
962 return plainView($.ui.resolve(e), Ls, S, range, tab, setRange, setTab, e.props.bodyColumns)
963 })
964}
965hooks/data.ts 318 lines1import type { Bucket, History, ModelFamily, Range, RepoHistory, Usage } from '../types'
2
3export const FAMILIES: readonly ModelFamily[] = ['opus', 'sonnet', 'haiku', 'fable', 'other']
4
5export const FAMILY_COLOR: Record<ModelFamily, string> = {
6 opus: '#a593ff',
7 sonnet: '#4fd1ff',
8 haiku: '#3ee0a0',
9 fable: '#ffb547',
10 other: '#8e9bb6',
11}
12
13export const FAMILY_LABEL: Record<ModelFamily, string> = {
14 opus: 'Opus',
15 sonnet: 'Sonnet',
16 haiku: 'Haiku',
17 fable: 'Fable',
18 other: 'Other',
19}
20
21export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
22
23export const HISTORY_DAYS = 760
24
25/** Bucket key of a model id: family plus version ("opus-5-5"), the bare family without a version, "other" when unknown. */
26export function modelKey(model: string | null): string {
27 const m = (model ?? '').toLowerCase().replace(/\[.*\]$/, '').replace(/-\d{8}$/, '')
28 const f = familyOf(m)
29 if (f === 'other') return 'other'
30 const hit = m.split(f)[1]?.match(/^-(\d{1,2})(?:-(\d{1,2}))?(?!\d)/) ?? m.match(new RegExp(`claude-(\\d{1,2})(?:-(\\d{1,2}))?-${f}`))
31 return hit ? [f, hit[1], hit[2]].filter(Boolean).join('-') : f
32}
33
34export function familyOf(model: string | null): ModelFamily {
35 const m = (model ?? '').toLowerCase()
36 for (const f of FAMILIES) if (f !== 'other' && m.includes(f)) return f
37 return 'other'
38}
39
40export function modelLabel(model: string | null): string {
41 if (!model) return 'Unknown model'
42 const f = familyOf(model)
43 if (f === 'other') return model
44 const version = modelKey(model).slice(f.length + 1).replace('-', '.')
45 return version ? `${FAMILY_LABEL[f]} ${version}` : FAMILY_LABEL[f]
46}
47
48export function effortLevel(effort: string | null): number {
49 if (!effort) return 0
50 const i = (EFFORTS as readonly string[]).indexOf(effort)
51 return i < 0 ? 0 : i + 1
52}
53
54const pad = (n: number) => String(n).padStart(2, '0')
55
56export function dayKey(d: Date): string {
57 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
58}
59
60export function hourKey(ms: number): string {
61 const d = new Date(ms)
62 return `${dayKey(d)}T${pad(d.getHours())}`
63}
64
65export function addUsage(into: Bucket, key: string, u: Usage): void {
66 const cur = into[key] ?? []
67 into[key] = Array.from({ length: Math.max(cur.length, u.length) }, (_, i) => (cur[i] ?? 0) + (u[i] ?? 0))
68}
69
70/** USD columns of a Usage: total, uncached input, output, cache writes, cache reads. */
71export const USD = { total: 3, input: 4, output: 5, write: 6, read: 7 } as const
72
73export function bucketTotals(b: Bucket | undefined): { tokens: number; usd: number } {
74 let tokens = 0
75 let usd = 0
76 for (const u of Object.values(b ?? {})) {
77 tokens += (u[0] ?? 0) + (u[1] ?? 0)
78 usd += u[USD.total] ?? 0
79 }
80 return { tokens, usd }
81}
82
83export function tokensByFamily(b: Bucket): Record<ModelFamily, number> {
84 const out = zeroFamilies()
85 for (const [key, u] of Object.entries(b)) out[familyOf(key)] += (u[0] ?? 0) + (u[1] ?? 0)
86 return out
87}
88
89export function mergeHistory(base: History, delta: History, nowMs: number): History {
90 const out: History = { ...base }
91 for (const [k, bucket] of Object.entries(delta)) {
92 const merged: Bucket = { ...(out[k] ?? {}) }
93 for (const [key, u] of Object.entries(bucket)) addUsage(merged, key, u)
94 out[k] = merged
95 }
96 const cutoff = dayKey(new Date(nowMs - HISTORY_DAYS * 86_400_000))
97 for (const k of Object.keys(out)) if (k.slice(0, 10) < cutoff) delete out[k]
98 return out
99}
100
101export type Point = {
102 label: string
103 full: string
104 isFuture: boolean
105 byFamily: Record<ModelFamily, number>
106 cacheRead: number
107 usd: number
108 usdByFamily: Record<ModelFamily, number>
109 tokensByModel: Record<string, number>
110 usdByModel: Record<string, number>
111 /** Tokens and USD by kind: input incl. cache writes, output, cache reads. */
112 kinds: { inputTokens: number; outputTokens: number; readTokens: number; inputUsd: number; outputUsd: number; readUsd: number }
113}
114
115const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
116const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
117
118const zeroFamilies = (): Record<ModelFamily, number> => ({ opus: 0, sonnet: 0, haiku: 0, fable: 0, other: 0 })
119
120function emptyPoint(label: string, full: string, isFuture: boolean): Point {
121 return {
122 label,
123 full,
124 isFuture,
125 byFamily: zeroFamilies(),
126 cacheRead: 0,
127 usd: 0,
128 usdByFamily: zeroFamilies(),
129 tokensByModel: {},
130 usdByModel: {},
131 kinds: { inputTokens: 0, outputTokens: 0, readTokens: 0, inputUsd: 0, outputUsd: 0, readUsd: 0 },
132 }
133}
134
135function addBucket(p: Point, b: Bucket | undefined): void {
136 if (!b) return
137 for (const [key, u] of Object.entries(b)) {
138 const f = familyOf(key)
139 const at = (i: number) => u[i] ?? 0
140 p.byFamily[f] += at(0) + at(1)
141 p.tokensByModel[key] = (p.tokensByModel[key] ?? 0) + at(0) + at(1)
142 p.usdByModel[key] = (p.usdByModel[key] ?? 0) + at(USD.total)
143 p.cacheRead += at(2)
144 p.usd += at(USD.total)
145 p.usdByFamily[f] += at(USD.total)
146 p.kinds.inputTokens += at(0)
147 p.kinds.outputTokens += at(1)
148 p.kinds.readTokens += at(2)
149 p.kinds.inputUsd += at(USD.input) + at(USD.write)
150 p.kinds.outputUsd += at(USD.output)
151 p.kinds.readUsd += at(USD.read)
152 }
153}
154
155function dayPoint(history: History, d: Date, label: string): Point {
156 const key = dayKey(d)
157 const p = emptyPoint(label, `${DOW[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]}`, false)
158 for (let h = 0; h < 24; h++) addBucket(p, history[`${key}T${pad(h)}`])
159 return p
160}
161
162/** The points a range draws, plus the same-length period before it for comparison. */
163export function series(history: History, range: Range, nowMs: number): { points: Point[]; previousTotal: number } {
164 const now = new Date(nowMs)
165 if (range === 'day') {
166 const key = dayKey(now)
167 const points = Array.from({ length: 24 }, (_, h) => {
168 const p = emptyPoint(pad(h), `${pad(h)}:00–${pad((h + 1) % 24)}:00`, h > now.getHours())
169 addBucket(p, history[`${key}T${pad(h)}`])
170 return p
171 })
172 const yesterday = new Date(nowMs - 86_400_000)
173 return { points, previousTotal: total(dayPoint(history, yesterday, '')) }
174 }
175 if (range === 'year') return yearSeries(history, now)
176 const n = range === 'week' ? 7 : 30
177 const at = (back: number) => new Date(now.getFullYear(), now.getMonth(), now.getDate() - back, 12)
178 const points = Array.from({ length: n }, (_, i) => {
179 const d = at(n - 1 - i)
180 return dayPoint(history, d, n === 7 ? DOW[d.getDay()]! : String(d.getDate()))
181 })
182 let previousTotal = 0
183 for (let i = 0; i < n; i++) previousTotal += total(dayPoint(history, at(n + i), ''))
184 return { points, previousTotal }
185}
186
187function monthPoint(history: History, year: number, month: number, label: string, isFuture: boolean): Point {
188 const prefix = `${year}-${pad(month + 1)}-`
189 const p = emptyPoint(label, `${MONTHS[month]} ${year}`, isFuture)
190 for (const [k, b] of Object.entries(history)) if (k.startsWith(prefix)) addBucket(p, b)
191 return p
192}
193
194/** Twelve calendar months ending with the current one, plus the twelve before them for comparison. */
195function yearSeries(history: History, now: Date): { points: Point[]; previousTotal: number } {
196 const at = (back: number) => new Date(now.getFullYear(), now.getMonth() - back, 1)
197 const points = Array.from({ length: 12 }, (_, i) => {
198 const d = at(11 - i)
199 return monthPoint(history, d.getFullYear(), d.getMonth(), MONTHS[d.getMonth()]!, false)
200 })
201 let previousTotal = 0
202 for (let i = 12; i < 24; i++) {
203 const d = at(i)
204 previousTotal += total(monthPoint(history, d.getFullYear(), d.getMonth(), '', false))
205 }
206 return { points, previousTotal }
207}
208
209export function total(p: Point): number {
210 let t = 0
211 for (const f of FAMILIES) t += p.byFamily[f]
212 return t
213}
214
215export function fmt(n: number): string {
216 if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'
217 if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'
218 if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
219 return String(Math.round(n))
220}
221
222export function fmtUsd(n: number): string {
223 if (n >= 10_000) return `$${(n / 1000).toFixed(1)}k`
224 if (n >= 100) return `$${Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
225 return `$${n.toFixed(2)}`
226}
227
228export function fmtInt(n: number): string {
229 return Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
230}
231
232const ARG_KEYS = ['file_path', 'path', 'command', 'pattern', 'url', 'query', 'description', 'prompt', 'skill'] as const
233
234export function describeTool(tool: string, input: Record<string, unknown>): string {
235 const name = tool.startsWith('mcp__') ? tool.split('__').slice(1).join(' · ') : tool
236 for (const k of ARG_KEYS) {
237 const v = input[k]
238 if (typeof v === 'string' && v.trim()) {
239 const arg = v.replace(/\s+/g, ' ').trim()
240 const short = k === 'file_path' || k === 'path' ? arg.split('/').slice(-2).join('/') : arg
241 return `${name} ${short.length > 80 ? short.slice(0, 79) + '…' : short}`
242 }
243 }
244 return name
245}
246
247export const NO_PROJECT = 'No project folder'
248
249/** "a=b, c=d" into a map; malformed pairs are skipped. */
250export function parseAliases(text: string): Record<string, string> {
251 const out: Record<string, string> = {}
252 for (const pair of text.split(',')) {
253 const [from, to] = pair.split('=').map(x => x.trim())
254 if (from && to) out[from] = to
255 }
256 return out
257}
258
259/**
260 * Maps a repository folder to its project: an alias first; the desktop app's folder-less sessions together;
261 * otherwise the longest other known repo it extends with a hyphen (aws-iac-acme-app -> acme-app).
262 */
263export function projectResolver(repos: readonly string[], aliases: Record<string, string>): (repo: string | null) => string {
264 const known = [...new Set(repos)]
265 const memo = new Map<string, string>()
266 return repo => {
267 const r = repo ?? NO_PROJECT
268 const hit = memo.get(r)
269 if (hit !== undefined) return hit
270 let name = aliases[r]
271 if (!name) {
272 if (/^scratch-\d{4}-\d{2}-\d{2}/.test(r) || r === 'unknown') name = NO_PROJECT
273 else {
274 const base = known
275 .filter(k => k !== r && k.length >= 3 && (r.endsWith(`-${k}`) || r.startsWith(`${k}-`)))
276 .sort((a, b) => b.length - a.length)[0]
277 name = base ?? r
278 }
279 }
280 memo.set(r, name)
281 return name
282 }
283}
284
285export type ProjectHistory = { hist: History; sessions: Record<string, Set<string>> }
286
287export function mergeProjects(repos: RepoHistory, resolve: (repo: string) => string): Map<string, ProjectHistory> {
288 const out = new Map<string, ProjectHistory>()
289 for (const [repo, data] of Object.entries(repos)) {
290 const name = resolve(repo)
291 const into = out.get(name) ?? { hist: {}, sessions: {} }
292 for (const [key, bucket] of Object.entries(data.hist)) {
293 const slot: Bucket = (into.hist[key] = { ...(into.hist[key] ?? {}) })
294 for (const [k, u] of Object.entries(bucket)) addUsage(slot, k, u)
295 }
296 for (const [day, ids] of Object.entries(data.sessions)) {
297 const set = (into.sessions[day] ??= new Set())
298 for (const id of ids) set.add(id)
299 }
300 out.set(name, into)
301 }
302 return out
303}
304
305/** Distinct sessions active in the period a range covers. */
306export const RANGE_DAYS: Record<Range, number> = { day: 1, week: 7, month: 30, year: 365 }
307
308export function sessionsIn(p: ProjectHistory, range: Range, nowMs: number): number {
309 const days = RANGE_DAYS[range]
310 const now = new Date(nowMs)
311 const ids = new Set<string>()
312 for (let back = 0; back < days; back++) {
313 const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - back, 12)
314 for (const id of p.sessions[dayKey(d)] ?? []) ids.add(id)
315 }
316 return ids.size
317}
318hooks/theme.ts 14 lines1export const C = {
2 bg: '#070a12',
3 panel: '#0d1322',
4 line: '#1c2840',
5 fg: '#dfe7f5',
6 muted: '#8e9bb6',
7 accent: '#4fd1ff',
8 accent2: '#8b7bff',
9 ok: '#3ee0a0',
10 warn: '#ffbe55',
11 bad: '#ff6b7a',
12 idle: '#6c7894',
13}
14hooks/dash.tsx 761 lines1import type { ClientModule, ClientSurface, RenderChildren } from 'claude-code'
2
3import type { AgentState, AgentsView, CardView, DashView, EconomicsView, SessionView, TopView, UsageView } from '../types'
4import { FAMILY_COLOR, FAMILY_LABEL, fmt, fmtInt, fmtUsd } from './data'
5import { C } from './theme'
6
7type Pt = { x: number; y: number } | null
8type S = { shown: Record<string, number>; hover: Pt }
9type Els = ClientSurface<S>['elements']
10
11const FRAME_MS = 33
12/** Share of the remaining distance covered per frame: about a second to settle, so values glide between updates. */
13const EASE = 0.12
14const METER_SEGMENTS = 24
15const SPARK_COLS = 12
16const CHART_ROWS = 10
17
18const STATUS: Record<AgentState, { color: string; icon: string; label: string }> = {
19 running: { color: C.ok, icon: '●', label: 'Running' },
20 waiting: { color: C.warn, icon: '▲', label: 'Waiting' },
21 idle: { color: C.idle, icon: '○', label: 'Idle' },
22 done: { color: C.accent, icon: '✓', label: 'Done' },
23 failed: { color: C.bad, icon: '✕', label: 'Failed' },
24}
25
26const latest = new Map<string, Record<string, number>>()
27
28/** Surfaces take whole percentages only. */
29const pct = (x: number) => `${Math.max(0, Math.min(100, Math.round(x)))}%`
30
31function targetsOf(v: DashView): Record<string, number> {
32 const t: Record<string, number> = {}
33 if (v.view === 'top') {
34 t.rate = v.rate
35 t.today = v.today
36 t.costToday = v.costToday
37 t.max = v.max
38 const sum = v.mix.reduce((a, m) => a + m.tokens, 0) || 1
39 for (const m of v.mix) t[`mix:${m.family}`] = (m.tokens / sum) * 100
40 } else if (v.view === 'agents') {
41 for (const line of v.groups) {
42 t[`pt:${line.name}`] = line.tokensToday
43 t[`pc:${line.name}`] = line.costToday
44 }
45 for (const g of [...v.groups.flatMap(line => line.sessions), ...v.idle]) {
46 t[`g:${g.key}`] = g.tokens
47 t[`gc:${g.key}`] = g.costUsd
48 for (const c of g.cards) {
49 t[`t:${c.id}`] = c.tokens
50 t[`r:${c.id}`] = c.rate
51 c.spark.slice(-SPARK_COLS).forEach((x, i) => (t[`s:${c.id}:${i}`] = x))
52 }
53 }
54 } else if (v.view === 'economics') {
55 t.cost = v.cost
56 t.plan = v.planShare
57 t.mult = v.multiple
58 t.share = (v.shareOfTotal ?? 0) * 100
59 v.byKind.forEach((k, i) => (t[`k:${i}`] = k.usd))
60 v.byModel.forEach(m => (t[`f:${m.key}`] = m.usd))
61 v.points.forEach((p, i) => (t[`c:${v.range}:${i}`] = p.usd))
62 for (const r of v.byProject ?? []) t[`ep:${r.name}`] = r.usd
63 } else {
64 v.tiles.forEach((tile, i) => tile.value !== null && (t[`tile:${i}`] = tile.value))
65 v.points.forEach((p, i) => p.values.forEach((x, f) => (t[`u:${v.range}:${i}:${f}`] = x)))
66 for (const r of v.byProject ?? []) r.values.forEach((x, f) => (t[`bp:${r.name}:${f}`] = x))
67 }
68 return t
69}
70
71function step(cur: Record<string, number>, target: Record<string, number>): { next: Record<string, number>; isMoving: boolean } {
72 const next: Record<string, number> = {}
73 let isMoving = false
74 for (const [k, to] of Object.entries(target)) {
75 const at = cur[k] ?? 0
76 const gap = to - at
77 if (Math.abs(gap) <= Math.max(0.01, Math.abs(to) * 0.001)) next[k] = to
78 else {
79 next[k] = at + gap * EASE
80 isMoving = true
81 }
82 }
83 return { next, isMoving }
84}
85
86const sameKeysAndValues = (a: Record<string, number>, b: Record<string, number>) => {
87 const ka = Object.keys(a)
88 return ka.length === Object.keys(b).length && ka.every(k => a[k] === b[k])
89}
90
91const Dash: ClientModule<DashView, S> = (props, surface) => {
92 const target = targetsOf(props)
93 latest.set(props.view, target)
94 if (surface.state === undefined) {
95 const view = props.view
96 surface.every(FRAME_MS, () => {
97 const cur = surface.state?.shown ?? {}
98 const goal = latest.get(view) ?? {}
99 const { next, isMoving } = step(cur, goal)
100 if (isMoving || !sameKeysAndValues(cur, next)) surface.setState({ shown: next, hover: surface.state?.hover ?? null })
101 })
102 surface.setState({ shown: {}, hover: null })
103 surface.onPointer(e => {
104 const cur = surface.state
105 if (!cur) return
106 if (e.type === 'leave') {
107 if (cur.hover) surface.setState({ ...cur, hover: null })
108 } else if (cur.hover?.x !== e.x || cur.hover?.y !== e.y) surface.setState({ ...cur, hover: { x: e.x, y: e.y } })
109 })
110 }
111 const shown = surface.state?.shown ?? {}
112 const val = (k: string) => shown[k] ?? 0
113 const els = surface.elements
114 if (props.view === 'top') return top(els, props, val)
115 if (props.view === 'agents') return agents(els, props, val, surface.columns)
116 const at = surface.state?.hover ?? null
117 if (props.view === 'economics') return economics(els, props, val, columnAt(at, economicsBand(surface.rows, surface.columns), surface.columns, props.points.length))
118 return usage(els, props, val, columnAt(at, usageBand(props, surface.columns), surface.columns, props.points.length))
119}
120
121export default Dash
122
123type Val = (k: string) => number
124
125/** One row tall; a fill of `fraction` drawn as boxes so it shares the layout grid with every other bar. */
126function meterSegments({ Box }: Els, fraction: number, colorAt: (i: number) => string) {
127 const lit = Math.max(0, Math.min(1, fraction)) * METER_SEGMENTS
128 return (
129 <Box width="100%" height={1} columnGap={1}>
130 {Array.from({ length: METER_SEGMENTS }, (_, i) => (
131 <Box key={`seg-${i}`} width={0} flexGrow={1} height={1} backgroundColor={C.line}>
132 <Box width={pct(Math.max(0, Math.min(1, lit - i)) * 100)} height={1} backgroundColor={colorAt(i)} />
133 </Box>
134 ))}
135 </Box>
136 )
137}
138
139const PLOT_LEFT = 11
140const PLOT_RIGHT = 3
141
142const BAND_SLACK = 1
143const ECONOMICS_NOTE_CHARS = 252
144
145type Band = { top: number; bottom: number }
146
147/** Rows the usage plot occupies, from the tiles above it: seven rows each, wrapped greedily with the row gap of two. */
148function usageBand(v: UsageView, columns: number): Band {
149 const widths = v.tiles.map(t => Math.max(t.label.length, t.text?.length ?? 8, t.note.length) + 6)
150 let lines = 1
151 let used = 0
152 for (const w of widths) {
153 if (used > 0 && used + 2 + w > columns) {
154 lines++
155 used = w
156 } else used += (used > 0 ? 2 : 0) + w
157 }
158 const top = lines * 7 + (lines - 1) * 2 + 2 + 2
159 return { top, bottom: top + CHART_ROWS }
160}
161
162/** Rows the economics plot occupies, counted up from the region's bottom: footnote, gap, chart panel border, padding, readout, axis, gaps. */
163function economicsBand(rows: number, columns: number): Band {
164 const note = Math.ceil(ECONOMICS_NOTE_CHARS / Math.max(1, columns))
165 const bottom = rows - note - 8
166 return { top: bottom - CHART_ROWS, bottom }
167}
168
169/** The chart column under the pointer, or null unless it is inside the plot's rows and columns. */
170function columnAt(at: Pt, band: Band, columns: number, count: number): number | null {
171 const width = columns - PLOT_LEFT - PLOT_RIGHT
172 if (!at || count === 0 || width < count) return null
173 if (at.y < band.top - BAND_SLACK || at.y >= band.bottom + BAND_SLACK) return null
174 if (at.x < PLOT_LEFT || at.x >= PLOT_LEFT + width) return null
175 return Math.min(count - 1, Math.floor(((at.x - PLOT_LEFT) / width) * count))
176}
177
178/** One fixed row under a chart, so showing or clearing it never moves the layout. */
179function readout({ Box, Text }: Els, key: string, parts: { text: string; color?: string }[] | null) {
180 return (
181 <Box key={key} width="100%" height={1} columnGap={2}>
182 {parts ? (
183 parts.map((r, j) => (
184 <Text key={`ro-${j}`} bold={j === 0} color={r.color ?? C.muted}>
185 {r.text}
186 </Text>
187 ))
188 ) : (
189 <Text color={C.muted}>Hover a column for details</Text>
190 )}
191 </Box>
192 )
193}
194
195function panel({ Box }: Els, key: string, children: RenderChildren) {
196 return (
197 <Box key={key} flexDirection="column" gap={1} flexGrow={1} width={0} minWidth={40} paddingX={2} paddingY={1} borderStyle="round" borderColor={C.line} backgroundColor={C.panel}>
198 {children}
199 </Box>
200 )
201}
202
203function top(els: Els, v: TopView, val: Val) {
204 const { Box, Text } = els
205 const rate = val('rate')
206 const max = Math.max(1, val('max') || v.max)
207 const segColor = (i: number) => {
208 const f = i / (METER_SEGMENTS - 1)
209 return f < 0.5 ? C.accent : f < 0.85 ? C.accent2 : C.warn
210 }
211 const kpi = (label: string, value: string, color: string = C.fg, width?: number) => (
212 <Box key={`kpi-${label}`} flexDirection="column" width={width}>
213 <Text color={C.muted}>{label}</Text>
214 <Text bold color={color}>
215 {value}
216 </Text>
217 </Box>
218 )
219 const used = v.mix.filter(m => m.tokens > 0)
220 return (
221 <Box flexDirection="column" gap={2} width="100%">
222 <Box justifyContent="space-between" flexWrap="wrap" columnGap={2}>
223 <Text bold color={C.accent}>
224 ◉ AGENT/OPS
225 </Text>
226 <Text color={C.muted}>
227 {v.sessions} {v.sessions === 1 ? 'session' : 'sessions'} · live
228 </Text>
229 </Box>
230 <Box flexWrap="wrap" columnGap={6} rowGap={1}>
231 {kpi('AGENTS UP', `${v.up}/${v.count}`)}
232 {kpi('THROUGHPUT', `${fmtInt(rate)} tok/s`, C.fg, 16)}
233 {kpi('TOKENS TODAY', fmt(val('today')))}
234 {kpi('WAITING', String(v.waiting), v.waiting ? C.warn : C.fg)}
235 {kpi('API VALUE TODAY', fmtUsd(val('costToday')), C.ok)}
236 </Box>
237 <Box flexWrap="wrap" gap={2}>
238 {panel(els, 'meter', [
239 <Box key="meter-h" justifyContent="space-between">
240 <Text color={C.muted}>THROUGHPUT · OUTPUT TOK/S</Text>
241 <Box width={8} justifyContent="flex-end">
242 <Text bold color={C.fg}>
243 {fmtInt(rate)}
244 </Text>
245 </Box>
246 </Box>,
247 <Box key="meter-bar" width="100%">
248 {meterSegments(els, rate / max, segColor)}
249 </Box>,
250 <Box key="meter-scale" justifyContent="space-between">
251 <Text color={C.muted}>0</Text>
252 <Text color={C.muted}>{fmt(max / 2)}</Text>
253 <Text color={C.muted}>{fmt(max)}</Text>
254 </Box>,
255 ])}
256 {panel(els, 'mix', [
257 <Box key="mix-h" justifyContent="space-between">
258 <Text color={C.muted}>MODEL MIX · TODAY</Text>
259 <Text bold color={C.fg}>
260 {fmt(val('today'))}
261 </Text>
262 </Box>,
263 <Box key="mix-bar" width="100%" height={1} backgroundColor={C.line}>
264 {used.map(m => (
265 <Box key={`mixbar-${m.family}`} width={pct(val(`mix:${m.family}`))} height={1} backgroundColor={FAMILY_COLOR[m.family]} />
266 ))}
267 </Box>,
268 <Box key="mix-lg" columnGap={3} flexWrap="wrap">
269 {used.length === 0 ? (
270 <Text color={C.muted}>No usage yet today</Text>
271 ) : (
272 used.map(m => (
273 <Text key={`mixlg-${m.family}`} color={C.muted}>
274 <Text color={FAMILY_COLOR[m.family]}>■</Text> {FAMILY_LABEL[m.family]}{' '}
275 <Text color={C.fg}>{Math.round(val(`mix:${m.family}`))}%</Text>
276 </Text>
277 ))
278 )}
279 </Box>,
280 ])}
281 </Box>
282 </Box>
283 )
284}
285
286function sparkBars({ Box }: Els, values: number[], color: string) {
287 const max = Math.max(1, ...values)
288 return (
289 <Box width={SPARK_COLS} height={1} alignItems="flex-end">
290 {values.map((x, i) => (
291 <Box key={`sp-${i}`} width={0} flexGrow={1} height={pct(Math.max(6, (x / max) * 100))} backgroundColor={x > 0 ? color : C.line} />
292 ))}
293 </Box>
294 )
295}
296
297type Columns = { model: boolean; spark: boolean }
298
299const W = { status: 15, model: 20, output: 18, outputNarrow: 7, tokens: 9, sessionTotal: 18 }
300
301function headerRow({ Box, Text }: Els, cols: Columns) {
302 const heading = (label: string) => <Text color={C.muted}>{label}</Text>
303 return (
304 <Box key="thead" gap={2}>
305 <Box width={W.status}>{heading('STATUS')}</Box>
306 <Box width={0} flexGrow={2} minWidth={0}>
307 {heading('AGENT')}
308 </Box>
309 <Box width={0} flexGrow={3} minWidth={0}>
310 {heading('DOING NOW')}
311 </Box>
312 {cols.model ? <Box width={W.model}>{heading('MODEL')}</Box> : null}
313 <Box width={cols.spark ? W.output : W.outputNarrow} justifyContent="flex-end">
314 {heading('TOK/S')}
315 </Box>
316 <Box width={W.tokens} justifyContent="flex-end">
317 {heading('TOKENS')}
318 </Box>
319 </Box>
320 )
321}
322
323function agentRow(els: Els, c: CardView, val: Val, cols: Columns) {
324 const { Box, Text } = els
325 const st = STATUS[c.status]
326 const isQuiet = c.status === 'idle' || c.status === 'done' || c.status === 'failed'
327 const text = isQuiet ? C.muted : C.fg
328 const color = FAMILY_COLOR[c.family]
329 const rate = val(`r:${c.id}`)
330 return (
331 <Box key={`row-${c.id}`} gap={2} alignItems="center">
332 <Box width={W.status}>
333 <Text color={st.color}>
334 {st.icon} {st.label}
335 {c.status === 'waiting' && c.when ? ` ${c.when}` : ''}
336 </Text>
337 </Box>
338 <Box width={0} flexGrow={2} minWidth={0} columnGap={1}>
339 <Text color={text} bold={!isQuiet} wrap="truncate-end">
340 {c.name}
341 </Text>
342 {c.progress !== null ? <Text color={C.accent}>{Math.round(c.progress * 100)}%</Text> : null}
343 </Box>
344 <Box width={0} flexGrow={3} minWidth={0}>
345 <Text color={C.muted} wrap="truncate-end">
346 {c.activity}
347 </Text>
348 </Box>
349 {cols.model ? (
350 <Box width={W.model}>
351 <Text color={C.muted} wrap="truncate-end">
352 <Text color={color}>■</Text> {c.model}
353 {c.effort ? ` · ${c.effort}` : ''}
354 </Text>
355 </Box>
356 ) : null}
357 <Box width={cols.spark ? W.output : W.outputNarrow} gap={1} justifyContent="flex-end" alignItems="center">
358 {cols.spark && !isQuiet ? sparkBars(els, Array.from({ length: SPARK_COLS }, (_, i) => val(`s:${c.id}:${i}`)), color) : null}
359 <Text color={rate > 0 ? C.fg : C.muted}>{isQuiet ? '—' : fmtInt(rate)}</Text>
360 </Box>
361 <Box width={W.tokens} justifyContent="flex-end">
362 <Text color={text} bold={!isQuiet}>
363 {fmt(val(`t:${c.id}`))}
364 </Text>
365 </Box>
366 </Box>
367 )
368}
369
370const RAIL: Record<AgentState, string> = { running: C.ok, waiting: C.warn, idle: C.idle, done: C.idle, failed: C.bad }
371
372/** One card per session: a status rail on the left, the session line, then one row per agent. */
373function sessionCard(els: Els, g: SessionView, val: Val, cols: Columns, withHeader: boolean) {
374 const { Box, Text } = els
375 const summaryColor = g.status === 'waiting' ? C.warn : g.status === 'running' ? C.ok : g.status === 'failed' ? C.bad : C.muted
376 return (
377 <Box key={`sess-${g.key}`} width="100%" borderStyle="round" borderColor={g.status === 'waiting' ? '#5a4520' : C.line} backgroundColor={C.panel}>
378 <Box width={1} backgroundColor={RAIL[g.status]} />
379 <Box flexDirection="column" flexGrow={1} minWidth={0} gap={1} paddingX={2} paddingY={1}>
380 <Box gap={3} alignItems="center">
381 <Box flexGrow={1} minWidth={0} columnGap={2}>
382 <Text bold color={C.fg} wrap="truncate-end">
383 {g.title}
384 </Text>
385 <Text color={C.muted} wrap="truncate-end">
386 {g.subtitle}
387 </Text>
388 </Box>
389 <Text color={summaryColor}>{g.summary}</Text>
390 <Box width={W.sessionTotal} justifyContent="flex-end">
391 <Text bold color={C.fg}>
392 {fmt(val(`g:${g.key}`))} <Text color={C.ok}>{fmtUsd(val(`gc:${g.key}`))}</Text>
393 </Text>
394 </Box>
395 </Box>
396 <Box flexDirection="column">
397 {withHeader ? headerRow(els, cols) : null}
398 {g.cards.map(c => agentRow(els, c, val, cols))}
399 </Box>
400 </Box>
401 </Box>
402 )
403}
404
405/** Sessions with nothing running share one compact card, a line each. */
406function idleCard(els: Els, sessions: SessionView[], val: Val, showProject: boolean) {
407 const { Box, Text } = els
408 return (
409 <Box key="idle" width="100%" borderStyle="round" borderColor={C.line} backgroundColor={C.bg}>
410 <Box width={1} backgroundColor={C.idle} />
411 <Box flexDirection="column" flexGrow={1} minWidth={0} paddingX={2} paddingY={1}>
412 {sessions.map(g => (
413 <Box key={`idle-${g.key}`} gap={2} alignItems="center">
414 <Box width={W.status}>
415 <Text color={STATUS[g.status === 'failed' ? 'failed' : 'idle'].color}>
416 {g.status === 'failed' ? '✕ Failed' : '○ Idle'}
417 </Text>
418 </Box>
419 <Box flexGrow={1} minWidth={0} columnGap={2}>
420 <Text color={C.fg} wrap="truncate-end">
421 {g.title}
422 </Text>
423 <Text color={C.muted} wrap="truncate-end">
424 {showProject ? g.project : g.subtitle}
425 </Text>
426 </Box>
427 <Text color={C.muted}>{g.when}</Text>
428 <Box width={W.sessionTotal} justifyContent="flex-end">
429 <Text color={C.fg}>
430 {fmt(val(`g:${g.key}`))} <Text color={C.ok}>{fmtUsd(val(`gc:${g.key}`))}</Text>
431 </Text>
432 </Box>
433 </Box>
434 ))}
435 </Box>
436 </Box>
437 )
438}
439
440function agents(els: Els, v: AgentsView, val: Val, columns: number) {
441 const { Box, Text } = els
442 const cols: Columns = { model: columns === 0 || columns >= 90, spark: columns === 0 || columns >= 72 }
443 return (
444 <Box flexDirection="column" gap={1} width="100%">
445 {v.error ? <Text color={C.warn}>{v.error}</Text> : null}
446 {v.groups.map((line, li) => (
447 <Box key={`proj-${line.name}`} flexDirection="column" gap={1} width="100%">
448 {v.showProjectLines ? (
449 <Box justifyContent="space-between" columnGap={3} flexWrap="wrap" paddingTop={li === 0 ? 0 : 1}>
450 <Box columnGap={2} flexWrap="wrap">
451 <Text bold color={C.accent}>
452 {line.name}
453 </Text>
454 <Text color={C.muted}>{line.summary}</Text>
455 </Box>
456 <Text color={C.muted}>
457 <Text color={C.fg}>{fmt(val(`pt:${line.name}`))}</Text> ·{' '}
458 <Text color={C.ok}>{fmtUsd(val(`pc:${line.name}`))}</Text> today
459 </Text>
460 </Box>
461 ) : null}
462 {line.sessions.map((g, i) => sessionCard(els, g, val, cols, li === 0 && i === 0))}
463 </Box>
464 ))}
465 {v.idle.length ? idleCard(els, v.idle, val, v.showProjectLines) : null}
466 </Box>
467 )
468}
469
470function usage(els: Els, v: UsageView, val: Val, hovered: number | null) {
471 const { Box, Text } = els
472 const totals = v.points.map((p, i) => p.values.reduce((a, _x, f) => a + val(`u:${v.range}:${i}:${f}`), 0))
473 const targetMax = Math.max(1, ...v.points.map(p => p.values.reduce((a, b) => a + b, 0))) * 1.1
474 const every = v.points.length > 24 ? 5 : v.points.length > 12 ? 3 : 1
475 return (
476 <Box flexDirection="column" gap={2} width="100%">
477 <Box flexWrap="wrap" gap={2}>
478 {v.tiles.map((t, i) => (
479 <Box key={`tile-${t.label}`} flexDirection="column" flexGrow={1} paddingX={2} paddingY={1} borderStyle="round" borderColor={C.line} backgroundColor={C.panel}>
480 <Text color={C.muted}>{t.label}</Text>
481 <Text bold color={t.color}>
482 {t.value !== null ? fmt(val(`tile:${i}`)) : t.text}
483 </Text>
484 <Text color={t.noteColor}>{t.note}</Text>
485 </Box>
486 ))}
487 </Box>
488 <Box flexDirection="column" gap={1} width="100%" paddingX={2} paddingY={1} borderStyle="round" borderColor={C.line} backgroundColor={C.panel}>
489 <Box width="100%" height={CHART_ROWS}>
490 <Box flexDirection="column" justifyContent="space-between" width={8} height={CHART_ROWS}>
491 <Text color={C.muted}>{fmt(targetMax)}</Text>
492 <Text color={C.muted}>{fmt(targetMax / 2)}</Text>
493 <Text color={C.muted}>0</Text>
494 </Box>
495 <Box flexGrow={1} height={CHART_ROWS} alignItems="flex-end">
496 {v.points.map((p, i) => (
497 <Box key={`col-${v.range}-${i}`} width={0} flexGrow={1} height={pct((totals[i]! / targetMax) * 100)} flexDirection="column-reverse">
498 {p.values.map((_x, f) => {
499 const part = totals[i]! > 0 ? (val(`u:${v.range}:${i}:${f}`) / totals[i]!) * 100 : 0
500 return part > 0 ? <Box key={`seg-${i}-${f}`} width="100%" height={pct(part)} backgroundColor={FAMILY_COLOR[v.families[f]!]} /> : null
501 })}
502 </Box>
503 ))}
504 </Box>
505 </Box>
506 <Box width="100%">
507 <Box width={8} />
508 <Box flexGrow={1}>
509 {v.points.map((p, i) => (
510 <Box key={`x-${v.range}-${i}`} width={0} flexGrow={1}>
511 <Text color={C.muted}>{i % every === 0 ? p.label : ''}</Text>
512 </Box>
513 ))}
514 </Box>
515 </Box>
516 {readout(
517 els,
518 'usage-readout',
519 hovered === null
520 ? null
521 : v.points[hovered]!.isFuture
522 ? [{ text: v.points[hovered]!.full }, { text: 'No data yet' }]
523 : [
524 { text: v.points[hovered]!.full, color: C.fg },
525 { text: `${fmt(v.points[hovered]!.values.reduce((a, b) => a + b, 0))} tokens`, color: C.fg },
526 ...v.points[hovered]!.values.flatMap((x, f) => (x > 0 ? [{ text: `■ ${FAMILY_LABEL[v.families[f]!]} ${fmt(x)}`, color: FAMILY_COLOR[v.families[f]!] }] : [])),
527 ],
528 )}
529 </Box>
530 <Box flexWrap="wrap" columnGap={3}>
531 {v.families.map(f => (
532 <Text key={`lg-${f}`} color={C.muted}>
533 <Text color={FAMILY_COLOR[f]}>■</Text> {FAMILY_LABEL[f]}
534 </Text>
535 ))}
536 </Box>
537 {v.byProject && v.byProject.length ? projectTokens(els, v, val) : null}
538 </Box>
539 )
540}
541
542const PROJECT_COL = 26
543
544function projectTokens({ Box, Text }: Els, v: UsageView, val: Val) {
545 const rows = v.byProject ?? []
546 const max = Math.max(1, ...rows.map(r => r.total))
547 return (
548 <Box flexDirection="column" gap={1} width="100%" paddingX={2} paddingY={1} borderStyle="round" borderColor={C.line} backgroundColor={C.panel}>
549 <Box justifyContent="space-between">
550 <Text color={C.muted}>BY PROJECT</Text>
551 <Text color={C.muted}>tokens, split by model</Text>
552 </Box>
553 {rows.map(r => {
554 const parts = r.values.map((_, f) => val(`bp:${r.name}:${f}`))
555 const shown = parts.reduce((a, b) => a + b, 0)
556 const isRest = /^\d+ more$/.test(r.name)
557 return (
558 <Box key={`bp-${r.name}`} gap={2} alignItems="center">
559 <Box width={PROJECT_COL}>
560 <Text color={isRest ? C.muted : C.fg} wrap="truncate-end">
561 {r.name}
562 </Text>
563 </Box>
564 <Box flexGrow={1} height={1} backgroundColor={C.line}>
565 {parts.map((x, f) =>
566 x > 0 ? <Box key={`bps-${r.name}-${f}`} width={pct((x / max) * 100)} height={1} backgroundColor={isRest ? C.idle : FAMILY_COLOR[v.families[f]!]} /> : null,
567 )}
568 </Box>
569 <Box width={8} justifyContent="flex-end">
570 <Text color={isRest ? C.muted : C.fg}>{fmt(shown)}</Text>
571 </Box>
572 </Box>
573 )
574 })}
575 </Box>
576 )
577}
578
579function projectCosts({ Box, Text }: Els, v: EconomicsView, val: Val) {
580 const rows = v.byProject ?? []
581 const max = Math.max(1e-9, ...rows.map(r => r.usd))
582 const head = (label: string) => <Text color={C.muted}>{label}</Text>
583 return (
584 <Box flexDirection="column" gap={1} width="100%" paddingX={2} paddingY={1} borderStyle="round" borderColor={C.line} backgroundColor={C.panel}>
585 <Box gap={2}>
586 <Box width={PROJECT_COL}>{head('COST BY PROJECT')}</Box>
587 <Box width={9} justifyContent="flex-end">
588 {head('COST')}
589 </Box>
590 <Box flexGrow={1}>{head('SHARE')}</Box>
591 <Box width={12} justifyContent="flex-end">
592 {head('PER SESSION')}
593 </Box>
594 <Box width={8} justifyContent="flex-end">
595 {head('VS PLAN')}
596 </Box>
597 </Box>
598 {rows.map(r => {
599 const usd = val(`ep:${r.name}`)
600 const isRest = /^\d+ more$/.test(r.name)
601 const tone = isRest ? C.muted : C.fg
602 return (
603 <Box key={`ep-${r.name}`} gap={2} alignItems="center">
604 <Box width={PROJECT_COL}>
605 <Text color={tone} wrap="truncate-end">
606 {r.name}
607 </Text>
608 </Box>
609 <Box width={9} justifyContent="flex-end">
610 <Text color={isRest ? C.muted : C.ok}>{fmtUsd(usd)}</Text>
611 </Box>
612 <Box flexGrow={1} gap={1} alignItems="center">
613 <Box flexGrow={1} height={1} backgroundColor={C.line}>
614 <Box width={pct((usd / max) * 100)} height={1} backgroundColor={isRest ? C.idle : C.ok} />
615 </Box>
616 <Box width={5} justifyContent="flex-end">
617 <Text color={C.muted}>{Math.round(r.share * 100)}%</Text>
618 </Box>
619 </Box>
620 <Box width={12} justifyContent="flex-end">
621 <Text color={tone}>{r.perSession === null ? '—' : fmtUsd(r.perSession)}</Text>
622 </Box>
623 <Box width={8} justifyContent="flex-end">
624 <Text color={tone}>{r.multiple >= 10 ? Math.round(r.multiple) : r.multiple.toFixed(1)}×</Text>
625 </Box>
626 </Box>
627 )
628 })}
629 </Box>
630 )
631}
632
633function barRow({ Box, Text }: Els, key: string, label: string, fraction: number, color: string, value: string, detail: string) {
634 return (
635 <Box key={key} flexDirection="column">
636 <Box justifyContent="space-between" columnGap={2}>
637 <Text color={C.fg}>{label}</Text>
638 <Text color={C.muted}>
639 <Text bold color={C.fg}>
640 {value}
641 </Text>
642 {detail ? ` ${detail}` : ''}
643 </Text>
644 </Box>
645 <Box width="100%" height={1} backgroundColor={C.line}>
646 <Box width={pct(fraction * 100)} height={1} backgroundColor={color} />
647 </Box>
648 </Box>
649 )
650}
651
652function economics(els: Els, v: EconomicsView, val: Val, hovered: number | null) {
653 const { Box, Text } = els
654 const cost = val('cost')
655 const plan = val('plan')
656 const mult = val('mult')
657 const kindMax = Math.max(1e-9, ...v.byKind.map((_, i) => val(`k:${i}`)))
658 const famMax = Math.max(1e-9, ...v.byModel.map(m => val(`f:${m.key}`)))
659 const kindColor = [C.accent2, C.accent, C.ok]
660 const colMax = Math.max(0.01, ...v.points.map(p => p.usd)) * 1.1
661 const every = v.points.length > 24 ? 5 : v.points.length > 12 ? 3 : 1
662 const box = (key: string, children: RenderChildren, grow = 1) => (
663 <Box key={key} flexDirection="column" gap={1} flexGrow={grow} minWidth={40} paddingX={2} paddingY={1} borderStyle="round" borderColor={C.line} backgroundColor={C.panel}>
664 {children}
665 </Box>
666 )
667 return (
668 <Box flexDirection="column" gap={2} width="100%">
669 <Box width="100%" borderStyle="round" borderColor={C.line} backgroundColor={C.panel}>
670 <Box width={1} backgroundColor={C.ok} />
671 <Box flexGrow={1} flexWrap="wrap" justifyContent="space-between" alignItems="center" gap={2} paddingX={2} paddingY={1}>
672 <Box flexDirection="column">
673 <Text color={C.muted}>
674 API-EQUIVALENT COST · {v.rangeLabel.toUpperCase()} · {v.scope.toUpperCase()}
675 </Text>
676 <Text bold color={C.ok}>
677 {fmtUsd(cost)}
678 </Text>
679 <Text color={C.muted}>
680 {v.shareOfTotal === null
681 ? `vs ${fmtUsd(plan)} of your ${fmtUsd(v.planMonthly)}/month plan for ${v.coveredDays === null ? 'the same period' : `the ${v.coveredDays} days with data`}`
682 : 'your plan covers all projects, so this one is shown as a share of total spend'}
683 </Text>
684 </Box>
685 {v.shareOfTotal === null ? (
686 <Box flexDirection="column" alignItems="flex-end">
687 <Text color={C.muted}>VALUE FROM YOUR PLAN</Text>
688 <Text bold color={mult >= 1 ? C.ok : C.warn}>
689 {mult >= 10 ? Math.round(mult) : mult.toFixed(1)}×
690 </Text>
691 <Text color={C.muted}>{cost >= plan ? `${fmtUsd(cost - plan)} saved` : `${fmtUsd(plan - cost)} under plan price`}</Text>
692 </Box>
693 ) : (
694 <Box flexDirection="column" alignItems="flex-end">
695 <Text color={C.muted}>SHARE OF TOTAL SPEND</Text>
696 <Text bold color={C.accent}>
697 {Math.round(val('share'))}%
698 </Text>
699 <Text color={C.muted}>{fmtUsd(cost)} of all projects' cost</Text>
700 </Box>
701 )}
702 </Box>
703 </Box>
704 <Box flexWrap="wrap" gap={2}>
705 {box('kinds', [
706 <Text key="kh" color={C.muted}>
707 COST BY TOKEN TYPE
708 </Text>,
709 ...v.byKind.map((k, i) => barRow(els, `k-${i}`, k.label, val(`k:${i}`) / kindMax, kindColor[i] ?? C.accent, fmtUsd(val(`k:${i}`)), `${fmt(k.tokens)} tokens`)),
710 ])}
711 {box('models', [
712 <Text key="fh" color={C.muted}>
713 COST BY MODEL
714 </Text>,
715 ...(v.byModel.length
716 ? v.byModel.map(m => barRow(els, `f-${m.key}`, m.label, val(`f:${m.key}`) / famMax, FAMILY_COLOR[m.family], fmtUsd(val(`f:${m.key}`)), `${fmt(m.tokens)} tokens`))
717 : [<Text key="fe" color={C.muted}>No priced usage yet</Text>]),
718 ...v.pricing.map((note, i) => (
719 <Text key={`price-${i}`} color={C.warn}>
720 ▲ {note}
721 </Text>
722 )),
723 ])}
724 </Box>
725 {v.byProject && v.byProject.length ? projectCosts(els, v, val) : null}
726 <Text color={C.accent}>◆ {v.insight}</Text>
727 {box('chart', [
728 <Text key="ch" color={C.muted}>
729 API-EQUIVALENT COST PER {v.range === 'day' ? 'HOUR' : v.range === 'year' ? 'MONTH' : 'DAY'}
730 </Text>,
731 <Box key="cplot" width="100%" height={CHART_ROWS}>
732 <Box flexDirection="column" justifyContent="space-between" width={8} height={CHART_ROWS}>
733 <Text color={C.muted}>{fmtUsd(colMax)}</Text>
734 <Text color={C.muted}>{fmtUsd(colMax / 2)}</Text>
735 <Text color={C.muted}>$0</Text>
736 </Box>
737 <Box flexGrow={1} height={CHART_ROWS} alignItems="flex-end" columnGap={0}>
738 {v.points.map((p, i) => (
739 <Box key={`cc-${v.range}-${i}`} width={0} flexGrow={1} height={pct((val(`c:${v.range}:${i}`) / colMax) * 100)} backgroundColor={C.ok} />
740 ))}
741 </Box>
742 </Box>,
743 <Box key="cx" width="100%">
744 <Box width={8} />
745 <Box flexGrow={1}>
746 {v.points.map((p, i) => (
747 <Box key={`cx-${v.range}-${i}`} width={0} flexGrow={1}>
748 <Text color={C.muted}>{i % every === 0 ? p.label : ''}</Text>
749 </Box>
750 ))}
751 </Box>
752 </Box>,
753 readout(els, 'chart-readout', hovered === null ? null : v.points[hovered]!.isFuture ? [{ text: v.points[hovered]!.full }, { text: 'No data yet' }] : [{ text: v.points[hovered]!.full, color: C.fg }, { text: fmtUsd(v.points[hovered]!.usd), color: C.ok }, { text: 'API-equivalent cost' }]),
754 ])}
755 <Text color={C.muted}>
756 Estimated from transcript token counts at Anthropic API list prices (Oct 2026), including cache writes and reads and fast mode. Excludes web search and other tool fees. Your plan's usage limits are not modelled. The plan price is a setting of this mod.
757 </Text>
758 </Box>
759 )
760}
761hooks/tab.tsx 36 lines1import type { ClientModule } from 'claude-code'
2
3import type { TabView } from '../types'
4import { C } from './theme'
5
6type S = { isHover: boolean }
7
8/** A tab drawn by the mod itself: the surface's native buttons ignore the pane's dark palette. */
9const Tab: ClientModule<TabView, S> = (props, surface) => {
10 const { Box, Text } = surface.elements
11 const press = () => surface.post({ press: props.id })
12 surface.onPointer(e => {
13 if (e.type === 'down' && e.button === 'left') press()
14 else if (e.type === 'enter') surface.setState({ isHover: true })
15 else if (e.type === 'leave') surface.setState({ isHover: false })
16 })
17 surface.onKey(e => {
18 if (e.key === 'return' || e.key === ' ') press()
19 })
20 const isHover = surface.state?.isHover ?? false
21 return (
22 <Box
23 paddingX={2}
24 borderStyle="round"
25 borderColor={props.isOn ? C.accent : isHover ? C.muted : C.line}
26 backgroundColor={props.isOn ? '#0f2a3b' : C.panel}
27 >
28 <Text bold={props.isOn} color={props.isOn ? C.accent : isHover ? C.fg : C.muted}>
29 {props.label}
30 </Text>
31 </Box>
32 )
33}
34
35export default Tab
36types/index.d.ts 185 lines1export type ModelFamily = 'opus' | 'sonnet' | 'haiku' | 'fable' | 'other'
2
3export type AgentState = 'running' | 'waiting' | 'idle' | 'done' | 'failed'
4
5export type AgentRow = {
6 id: string
7 name: string
8 type: string
9 status: AgentState
10 model: string | null
11 effort: string | null
12 tokensIn: number
13 tokensOut: number
14 cacheRead: number
15 rate: number
16 spark: number[]
17 progress: { done: number; total: number } | null
18 steps: number
19 activity: string
20 startedAt: number
21 /** Session title for a subagent of another session; null for this session's agents. */
22 session: string | null
23 project: string | null
24 /** The repository folder the agent works in, worktrees folded into their repo; merged into projects by name. */
25 repo: string | null
26 isLocal: boolean
27 /** API-equivalent spend of this agent's transcript so far; 0 for this session's agents, priced per session instead. */
28 costUsd: number
29 /** When the agent entered its current status (this session) or last wrote to its transcript (others). */
30 since: number
31}
32
33/**
34 * Per model family: [input incl. cache writes, output, cache reads, then USD at API list prices:
35 * total, uncached input, output, cache writes, cache reads]. Older entries may stop after the token counts.
36 */
37export type Usage = number[]
38
39/** Keyed by model version ("opus-5-5"); history from before versions were tracked is keyed by bare family ("opus"). */
40export type Bucket = Record<string, Usage>
41
42/** Keyed by local hour, `YYYY-MM-DDTHH`. */
43export type History = Record<string, Bucket>
44
45/** Per repository folder: usage (earlier days folded into one bucket at noon) and the session ids active each day. */
46export type RepoHistory = Record<string, { hist: History; sessions: Record<string, string[]> }>
47
48/** A model id the price table has no exact row for: priced as a related version, or not at all when `as` is null. */
49export type PricingNote = { model: string; as: string | null; tokens: number }
50
51export type Live = {
52 agents: AgentRow[]
53 /** API-equivalent spend of this session's own transcript. */
54 selfUsd: number
55 byModel: Bucket
56 rate: number
57 peak: number
58 at: number
59}
60
61export type Range = 'day' | 'week' | 'month' | 'year'
62
63export type Tab = 'agents' | 'usage' | 'economics'
64
65/** What the hooks module hands each live region; plain JSON. */
66export type TopView = {
67 view: 'top'
68 up: number
69 count: number
70 rate: number
71 max: number
72 today: number
73 waiting: number
74 sessions: number
75 costToday: number
76 mix: { family: ModelFamily; tokens: number }[]
77}
78
79export type CardView = {
80 id: string
81 name: string
82 status: AgentState
83 activity: string
84 model: string
85 family: ModelFamily
86 effort: string | null
87 effortLevel: number
88 type: string
89 progress: number | null
90 progressText: string
91 tokens: number
92 rate: number
93 spark: number[]
94 /** "2m" while waiting, "12m ago" while idle or done, empty otherwise. */
95 when: string
96}
97
98export type SessionView = {
99 key: string
100 title: string
101 subtitle: string
102 /** The most urgent status among its agents: what the rail shows. */
103 status: AgentState
104 summary: string
105 tokens: number
106 costUsd: number
107 project: string
108 when: string
109 cards: CardView[]
110}
111
112export type ProjectGroupView = {
113 name: string
114 summary: string
115 tokensToday: number
116 costToday: number
117 sessions: SessionView[]
118}
119
120export type AgentsView = {
121 view: 'agents'
122 error: string | null
123 /** Project lines above their session cards; one unnamed group when a project is selected. */
124 groups: ProjectGroupView[]
125 showProjectLines: boolean
126 idle: SessionView[]
127}
128
129export type UsageTile = { label: string; value: number | null; text: string | null; note: string; color: string; noteColor: string }
130
131export type UsageView = {
132 view: 'usage'
133 range: Range
134 unit: string
135 tiles: UsageTile[]
136 families: ModelFamily[]
137 points: { label: string; full: string; isFuture: boolean; values: number[] }[]
138 /** Tokens per project for the period, split like `families`; null while one project is selected. */
139 byProject: { name: string; values: number[]; total: number }[] | null
140}
141
142export type EconomicsView = {
143 view: 'economics'
144 range: Range
145 rangeLabel: string
146 planMonthly: number
147 cost: number
148 planShare: number
149 /** Days the plan is compared over when history starts inside the range; null when it covers the whole range. */
150 coveredDays: number | null
151 /** Warnings about models priced approximately or at $0. */
152 pricing: string[]
153 multiple: number
154 /** Share of all projects' cost for the period; null when no project is selected. */
155 shareOfTotal: number | null
156 byModel: { key: string; label: string; family: ModelFamily; usd: number; tokens: number }[]
157 byKind: { label: string; usd: number; tokens: number }[]
158 insight: string
159 points: { label: string; full: string; isFuture: boolean; usd: number }[]
160 /** Null while one project is selected. */
161 byProject: { name: string; usd: number; share: number; perSession: number | null; multiple: number }[] | null
162 scope: string
163}
164
165export type DashView = TopView | AgentsView | UsageView | EconomicsView
166
167export type TabView = { id: string; label: string; isOn: boolean }
168
169declare module 'claude-code' {
170 interface PluginState {
171 'agent-ops': {
172 live: Live
173 history: History
174 range: Range
175 tab: Tab
176 scanError: string | null
177 pricing: PricingNote[]
178 repos: RepoHistory
179 /** The selected project, null for all. */
180 project: string | null
181 showAllProjects: boolean
182 }
183 }
184}
185