SLOPSHOPPER

agent-ops

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

paneguardcommandprocesstimer
v0.1.0no licenseupdated 2026-10-18schwepps/claude-mods/agent-ops-mod/agent-ops
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-ops
│ ┃ Agent Ops ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ▣ client module ./dash.tsx ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ▣ client module ./tab.tsx ▣ client module ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ▣ client module ./tab.tsx │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ▣ client module ./dash.tsx ✻ Worked for 42s · done 4:20 PM │ ┃ │ › /agent-ops │ ⎿ agent-ops: Agent Ops dashboard opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Ops
▣ client module ./dash.tsx ▣ client module ./tab.tsx ▣ client module ./tab.tsx ▣ cl ▣ client module ./tab.tsx ▣ client module ./dash.tsx
README

claude-mods

Claude Code mods by schwepps.

ModWhat it does
model-routerRoutes 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-opsLive agents dashboard: status, activity, progress, tokens, model and effort, plus day/week/month usage by model. Open it with /agent-ops.
vtsls-leanThe vtsls TypeScript LSP with one tsserver per session and no automatic type acquisition, about 160 MB less per session. Replaces vtsls@claude-code-lsps.

Install

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.

Develop

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-ops
Source 6 files
hooks/register.tsx 965 lines
1import { 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}
965
hooks/data.ts 318 lines
1import 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}
318
hooks/theme.ts 14 lines
1export 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}
14
hooks/dash.tsx 761 lines
1import 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}
761
hooks/tab.tsx 36 lines
1import 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
36
types/index.d.ts 185 lines
1export 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