SLOPSHOPPER

usage-panel

Live Usage pane and a band above the prompt: session cost, context and rate limits; each prompt's tokens, models and subagents; and every prompt of the…

newpanebandrowscommandtimer
v0.6.5MITupdated 2026-10-11jamesnguyen1309/claude-usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-panel
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /usage-panel ⎿ usage-panel: Usage pane closed. ✓ — │ 0 tokens (0 out) │ 0s · ≈$0.000 Usage ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✓ — │ 0 tokens (0 out) │ 0s · ≈$0.000 Usage ⟨Claude Code's own drawing⟩
README

📊 claude-usage

See what every prompt costs you in Claude Code: tokens, models and subagents, live.

usage-panel is a Claude Code mod: a live Usage pane beside the conversation and a one-line band above the prompt input.

version Claude Code surfaces license


⚡ Install

On each device, in any shell:

gh api repos/jamesnguyen1309/claude-usage/contents/install.sh -H "Accept: application/vnd.github.raw" | bash

Then start a new Claude Code session: the Usage pane opens on its own.

Two commands, no script:

claude plugin marketplace add jamesnguyen1309/claude-usage
claude plugin install usage-panel@claude-usage --scope user

From inside a terminal claude session:

/plugin install usage-panel --marketplace jamesnguyen1309/claude-usage

Answer y to add the marketplace, then pick the user scope.

Only for one project: run the script with SCOPE=project (shared through the project's .claude/settings.json) or SCOPE=local (just you).

✨ What you get

Above the prompt input

One line for the prompt in progress (or the last one):

Modelthe main model as a chip, with its full id (claude-opus-5-5), plus a chip for each other model its subagents used
Tokensthe prompt's total across the main conversation and every subagent, and how many were output
Time · costhow long it has run, and what it cost
Usagea button that shows or hides the pane

The Usage pane

SectionShows
Sessioncost so far, context window fill, and the 5-hour and 7-day rate limits, with meters that turn amber at 70% and red at 90%
This conversationprompts you've sent, model calls and tool calls (main conversation and subagents), and total tokens, with a bar splitting them into input, output, cache reads and cache writes
This prompttotal tokens with a bar split into input, output, cache reads and cache writes; each model that worked on it: name, id, calls, tokens, share, and whether it ran the main conversation or subagents
Subagentseach subagent the prompt started, nested under the one that started it, with live status, model, time, calls, tools and tokens
Prompts in this sessionevery prompt you typed, newest first, as cards with room between them, in a fixed-height list (4 at a time) that scrolls on its own: the mouse wheel over it, or ▲ ▼, moves through older prompts while the rest of the pane stays put. Each card: a status mark (on the desktop, Claude walking while the prompt runs and cheering, claws up, once it's done; a coloured dot otherwise), its number and when you sent it; the prompt; when it finished, the models it used and its tokens, drawn like the token counter. Hovering a card tints it.

While Claude works, a little animated Claude (the orange pixel crab, stepping and waving) marks what is running: the prompt card, each subagent, the band and the prompt list. The desktop app draws cards, bars and chips; a terminal gets the same figures as text. /usage-panel shows or hides the pane.

🔄 Update · remove

# after a new version is pushed (the install script does this too)
claude plugin marketplace update claude-usage && claude plugin update usage-panel@claude-usage

# remove
claude plugin uninstall usage-panel@claude-usage

Start a new session afterwards for the change to load.

📐 How the figures are counted

  • Tokens come from each model call's usage as the API reports it, main conversation and subagents together. Cache reads usually dominate: each call re-reads the conversation from cache, at a fraction of the price of fresh input.
  • Cost is Claude Code's own ledger, the one /cost totals. A prompt's cost is how much the session's total grew while it ran, subagents included; subagents show tokens, not dollars.
  • Per session: each session shows only its own prompts. Nothing leaves your machine.
  • Prompts sent before the mod loaded are listed once the conversation has drawn them, without figures.
  • The list doesn't jump to a prompt in the conversation: the desktop app doesn't let mods scroll its conversation (Claude Code answers transcript not scrollable here).

🛠 Develop

claude plugin validate .   # manifest, marketplace and hooks module
claude plugin test .       # behaviour tests, terminal and desktop
claude --plugin-dir .      # run a session with this working copy
FileRole
hooks/register.tsxthe hooks: bookkeeping, the pane, the band, the terminal views
hooks/draw.tsthe desktop drawings (SVG)
types/index.d.tsthe state contract
tests/usage-panel.test.tsbehaviour tests
install.shinstalls or updates the mod on a device
.claude-plugin/the mod's manifest and the marketplace that lists it

To release: bump version in both .claude-plugin/plugin.json and .claude-plugin/marketplace.json, push, then update on each device.

Source 3 files
hooks/register.tsx 747 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementConstructor, EngineInterface, Register, SessionUsage, SvgProps, TurnUsage } from 'claude-code'
3
4import type { AgentRun, ConversationTotals, ModelUse, PromptRow, PromptRun, RunStatus, SessionFigures, Tokens } from '../types'
5import {
6  agentRow,
7  bandRow,
8  conversationCard,
9  crabDoneIcon,
10  crabIcon,
11  fmtTime,
12  fmtTokens,
13  fmtUsd,
14  modelName,
15  PARTS,
16  modelColor,
17  promptCard,
18  recordMeta,
19  sessionTiles,
20  total,
21  whereOf,
22} from './draw'
23
24const PANE = 'usage-panel'
25const TITLE = 'Usage'
26const KEEP = 50
27
28const prompts = atom({ plugin: 'usage-panel', key: 'prompts' } as const, [])
29const session = atom({ plugin: 'usage-panel', key: 'session' } as const, {
30  costUsd: 0,
31  contextWindow: 0,
32  rateLimits: [],
33})
34const rows = atom({ plugin: 'usage-panel', key: 'rows' } as const, [])
35const listStart = atom({ plugin: 'usage-panel', key: 'listStart' } as const, 0)
36const totals = atom({ plugin: 'usage-panel', key: 'totals' } as const, null as ConversationTotals | null)
37
38// The prompt list is the pane's last section, a fixed window: its header, then PAGE cards of
39// CARD_ROWS rows (a border and three lines), each after a blank row. A wheel over it scrolls it.
40const PAGE = 4
41const CARD_ROWS = 5
42const GAP_ROWS = 1
43const LIST_ROWS = 1 + PAGE * (GAP_ROWS + CARD_ROWS)
44const CRAB = crabIcon(16)
45const CRAB_DONE = crabDoneIcon(16)
46const isListOpen = atom({ plugin: 'usage-panel', key: 'isListOpen' } as const, true)
47const now = atom({ plugin: 'usage-panel', key: 'now' } as const, 0)
48
49// ---- bookkeeping ------------------------------------------------------------------
50
51const zero = (): Tokens => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
52
53const add = (t: Tokens, u: TurnUsage): Tokens => ({
54  input: t.input + (u.input_tokens || 0),
55  output: t.output + (u.output_tokens || 0),
56  cacheRead: t.cacheRead + (u.cache_read_input_tokens || 0),
57  cacheWrite: t.cacheWrite + (u.cache_creation_input_tokens || 0),
58})
59
60const sum = (a: Tokens, b: Tokens): Tokens => ({
61  input: a.input + b.input,
62  output: a.output + b.output,
63  cacheRead: a.cacheRead + b.cacheRead,
64  cacheWrite: a.cacheWrite + b.cacheWrite,
65})
66
67/** The prompt's tokens: its main loop and every subagent. */
68const promptTokens = (p: PromptRun): Tokens => p.agents.reduce((t, a) => sum(t, a.tokens), p.tokens)
69
70// The conversation's totals before this mod counted them itself: what its prompts hold.
71const seedTotals = (list: PromptRun[]): ConversationTotals => ({
72  modelCalls: list.reduce((n, p) => n + p.calls + p.agents.reduce((m, a) => m + a.calls, 0), 0),
73  toolCalls: list.reduce((n, p) => n + p.tools + p.agents.reduce((m, a) => m + a.tools, 0), 0),
74  tokens: list.reduce((t, p) => sum(t, promptTokens(p)), zero()),
75})
76
77// Credits one model call to its model in the prompt's breakdown.
78const useModel = (models: ModelUse[], model: string, usage: TurnUsage | null, agentId?: string): ModelUse[] => {
79  if (!model) return models
80  const one = models.find(m => m.model === model) ?? { model, mainCalls: 0, agentCalls: 0, agentIds: [], tokens: zero() }
81  const next: ModelUse = {
82    ...one,
83    mainCalls: one.mainCalls + (agentId ? 0 : 1),
84    agentCalls: one.agentCalls + (agentId ? 1 : 0),
85    agentIds: agentId && !one.agentIds.includes(agentId) ? [...one.agentIds, agentId] : one.agentIds,
86    tokens: usage ? add(one.tokens, usage) : one.tokens,
87  }
88  return models.some(m => m.model === model) ? models.map(m => (m.model === model ? next : m)) : [...models, next]
89}
90
91const figures = (u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): SessionFigures => ({
92  costUsd: u.cost?.usd ?? 0,
93  contextTokens: u.context.tokens,
94  contextWindow: u.context.window,
95  contextPercent: u.context.percent,
96  rateLimits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
97})
98
99// Subagents are filed under the prompt that was running when they started.
100const withCurrent = (list: PromptRun[], fn: (p: PromptRun) => PromptRun): PromptRun[] => {
101  const i = list.findLastIndex(p => p.status === 'running')
102  return i < 0 ? list : list.map((p, j) => (j === i ? fn(p) : p))
103}
104
105const withAgent = (list: PromptRun[], id: string, fn: (a: AgentRun) => AgentRun): PromptRun[] =>
106  list.map(p => (p.agents.some(a => a.id === id) ? { ...p, agents: p.agents.map(a => (a.id === id ? fn(a) : a)) } : p))
107
108const statusOf = (reason: string): RunStatus => (reason === 'answer' ? 'done' : reason === 'aborted' ? 'aborted' : 'failed')
109
110// Prompts kept by an earlier version of this mod may lack newer fields.
111const normalize = (p: PromptRun): PromptRun => ({ ...p, models: p.models ?? [] })
112
113const norm = (s: string): string => s.replace(/\s+/g, ' ').trim()
114
115// Your own prompts: typed here or sent from the Remote Control bridge.
116const isPersons = (origin: { kind: string } | undefined): boolean => origin?.kind === 'composer' || origin?.kind === 'bridge'
117
118// Records a prompt row once, by its id or, for a row first seen drawn, by its text.
119const addRow = (list: PromptRow[], row: { id: string; text: string; isDrawn: boolean; sentAt?: number }): PromptRow[] => {
120  const text = norm(row.text)
121  if (!text || list.some(r => r.id === row.id || r.renderId === row.id)) return list
122  if (row.isDrawn) {
123    // The row was appended earlier under its uuid: remember the id it is drawn under.
124    const i = list.findIndex(r => !r.renderId && r.text === text)
125    if (i >= 0) return list.map((r, j) => (j === i ? { ...r, renderId: row.id } : r))
126  }
127  if (!row.isDrawn) return [...list, { id: row.id, text, sentAt: row.sentAt }]
128  // Known only from its drawn row: sent before this mod was watching, so older than every
129  // appended row; it goes after the other rows like it, in the order they are drawn.
130  const at = list.findIndex(r => r.renderId !== r.id)
131  const drawnRow = { id: row.id, renderId: row.id, text }
132  return at < 0 ? [...list, drawnRow] : [...list.slice(0, at), drawnRow, ...list.slice(at)]
133}
134
135// The usage record of a prompt row: the latest run with its text.
136const runOf = (list: PromptRun[], row: PromptRow): PromptRun | undefined => list.findLast(p => norm(p.text) === row.text)
137
138const oneLine = (s: string, max: number): string => {
139  const flat = norm(s)
140  return flat.length > max ? flat.slice(0, Math.max(1, max - 1)) + '…' : flat
141}
142
143// 14:32:05 today; Oct 10, 14:32 on another day.
144const fmtClock = (ms: number, today: number): string => {
145  const d = new Date(ms)
146  const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
147  return d.toDateString() === new Date(today || ms).toDateString()
148    ? time
149    : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, ${time.slice(0, 5)}`
150}
151
152// A translucent grey: a hover tint that reads on light and dark alike (no theme key is one).
153const HOVER_BG = '#8080802a'
154
155const STATUS_TEXT: Record<RunStatus, string> = { running: 'suggestion', done: 'success', aborted: 'warning', failed: 'error' }
156
157const GLYPH: Record<RunStatus, string> = { running: '●', done: '✓', aborted: '◼', failed: '✗' }
158const COLOR: Record<RunStatus, string> = { running: 'cyan', done: 'green', aborted: 'yellow', failed: 'red' }
159
160const pctColor = (pct: number): string => (pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green')
161
162const bar = (pct: number, width: number): string => {
163  const filled = Math.round((Math.min(100, Math.max(0, pct)) / 100) * width)
164  return '█'.repeat(filled) + '░'.repeat(width - filled)
165}
166
167const LIMIT_NAME: Record<string, string> = { five_hour: '5-hour', seven_day: '7-day', spend_limit: 'spend' }
168
169async function togglePane($: EngineInterface): Promise<boolean> {
170  if ((await $.ui.panes()).some(p => p.id === PANE)) {
171    await $.ui.close({ id: PANE })
172    return false
173  }
174  await $.ui.open({ id: PANE, title: TITLE })
175  return true
176}
177
178// Prompt rows seen drawn, waiting to be saved: a render hook may not write state, so the
179// next event or the ticker saves them (within a second). Module scope: a reload redraws.
180let drawn: { id: string; text: string }[] = []
181
182async function saveDrawn($: EngineInterface): Promise<void> {
183  if (!drawn.length) return
184  const batch = drawn
185  drawn = []
186  await update($, rows, list => batch.reduce((l, r) => addRow(l, { ...r, isDrawn: true }), list))
187}
188
189export const register: Register = on => {
190  on('session.start', async ($, e, next) => {
191    const started = await next(e)
192    await $.command.register({
193      name: 'usage-panel',
194      description: 'Show or hide the live Usage pane: session cost, context, rate limits, this prompt, and every prompt of the session',
195    })
196    try {
197      const usage = await $.session.usage()
198      await update($, session, () => figures(usage))
199    } catch {
200      // No figures yet: the first session.measure fills them.
201    }
202    // Once a second: saves rows seen drawn, and ticks running clocks (quiet when nothing runs).
203    $.clock.every(1000, () => {
204      void (async () => {
205        await saveDrawn($)
206        if (!(await read($, prompts)).some(p => p.status === 'running' || p.agents.some(a => a.status === 'running'))) return
207        const at = await $.clock.now()
208        await update($, now, () => at)
209      })()
210    })
211    void $.ui.open({ id: PANE, title: TITLE })
212    return started
213  })
214
215  on('command.run', { command: 'usage-panel' }, async $ => ({
216    text: (await togglePane($)) ? 'Usage pane opened.' : 'Usage pane closed.',
217  }))
218
219  // /clear starts the session over.
220  on('session.end', async ($, e, next) => {
221    if (e.reason === 'clear') {
222      await update($, prompts, () => [])
223      await update($, rows, () => [])
224      await update($, totals, () => null)
225      await update($, session, s => ({ ...s, costUsd: 0 }))
226    }
227    return next(e)
228  })
229
230  // Cost, context and rate limits, as the status line and /cost see them.
231  on('session.measure', async ($, e, next) => {
232    const result = await next(e)
233    await update($, session, () => figures(e))
234    return result
235  })
236
237  // Your prompts as the conversation keeps them, by row id: the prompt list.
238  on('session.append', { door: 'prompt' }, async ($, e, next) => {
239    const result = await next(e)
240    await saveDrawn($)
241    if (!e.agentId && isPersons(e.origin)) {
242      const text = e.message.content.map(b => (b.type === 'text' ? b.text : '')).join('\n')
243      const sentAt = await $.clock.now()
244      await update($, rows, list => addRow(list, { id: e.uuid, text, isDrawn: false, sentAt }))
245    }
246    return result
247  })
248
249  // Your prompts' rows in the conversation, as drawn: prompts sent before this mod loaded,
250  // and the id each is drawn under (so it is listed once). The drawing is the engine's.
251  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
252    if (isPersons(e.props.origin)) {
253      const id = e.requestId
254      const known = await read($, rows)
255      if (!known.some(r => r.id === id || r.renderId === id) && !drawn.some(r => r.id === id)) {
256        drawn.push({ id, text: e.props.text })
257      }
258    }
259    return next(e)
260  })
261
262  // The wheel over the prompt list scrolls the list, a card a tick; the pane stays put. The
263  // list is the pane's last LIST_ROWS rows, so the pointer's row in the tree says which.
264  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
265    if (e.origin.kind !== 'person' || !e.pointer || !(await read($, isListOpen))) return next(e)
266    const count = (await read($, rows)).length
267    const treeRow = e.offset - e.by + e.pointer.row
268    if (count <= PAGE || treeRow < e.contentRows - LIST_ROWS || treeRow >= e.contentRows) return next(e)
269    await update($, listStart, v => Math.max(0, Math.min(count - PAGE, Math.min(v, count - PAGE) + Math.sign(e.by))))
270    return {}
271  })
272
273  // A main-loop turn: one per prompt (a subagent's run raises none).
274  on('turn.start', async ($, e, next) => {
275    const result = await next(e)
276    await saveDrawn($)
277    const at = await $.clock.now()
278    const { costUsd } = await read($, session)
279    const run: PromptRun = {
280      turnId: result.turnId ?? e.turnId,
281      text: e.text,
282      status: 'running',
283      startedAt: at,
284      model: '',
285      calls: 0,
286      tools: 0,
287      tokens: zero(),
288      costAtStart: costUsd,
289      agents: [],
290      models: [],
291    }
292    // A turn that never completed (the session was killed mid-turn) is closed here.
293    await update($, prompts, list =>
294      [...list.map(p => (p.status === 'running' ? { ...p, status: 'aborted' as const, endedAt: p.endedAt ?? at } : p)), run].slice(-KEEP),
295    )
296    await update($, now, () => at)
297    return result
298  })
299
300  // Every model call, main loop or subagent: tokens, model and the tools it asked for.
301  on('turn.step', async function* ($, e, next) {
302    const result = yield* next(e)
303    const usage = result.usage
304    const tools = result.toolUses.length
305    const model = usage?.model || e.model
306    // The conversation's running totals: every call, main loop and subagents alike.
307    const held = await read($, prompts)
308    await update($, totals, c => {
309      const base = c ?? seedTotals(held.map(normalize))
310      return { modelCalls: base.modelCalls + 1, toolCalls: base.toolCalls + tools, tokens: usage ? add(base.tokens, usage) : base.tokens }
311    })
312    if (e.agentId) {
313      const id = e.agentId
314      await update($, prompts, list =>
315        withAgent(list, id, a => ({
316          ...a,
317          model: model || a.model,
318          calls: a.calls + 1,
319          tools: a.tools + tools,
320          tokens: usage ? add(a.tokens, usage) : a.tokens,
321        })).map(p => (p.agents.some(a => a.id === id) ? { ...p, models: useModel(p.models ?? [], model, usage, id) } : p)),
322      )
323    } else {
324      await update($, prompts, list =>
325        list.map(p =>
326          p.turnId !== e.turnId
327            ? p
328            : {
329                ...p,
330                model: model || p.model,
331                calls: p.calls + 1,
332                tools: p.tools + tools,
333                tokens: usage ? add(p.tokens, usage) : p.tokens,
334                models: useModel(p.models ?? [], model, usage),
335              },
336        ),
337      )
338    }
339    return result
340  })
341
342  // A subagent started: filed under the running prompt, nested under its parent.
343  on('agent.spawn', async ($, e, next) => {
344    const started = await next(e)
345    if (started.deny !== undefined) return started
346    const at = await $.clock.now()
347    const run: AgentRun = {
348      id: started.agentId ?? e.tool_use_id,
349      parentId: e.parentAgentId,
350      type: e.subagentType,
351      description: e.description,
352      model: started.model,
353      status: 'running',
354      startedAt: at,
355      calls: 0,
356      tools: 0,
357      tokens: zero(),
358    }
359    await update($, prompts, list => withCurrent(list, p => ({ ...p, agents: [...p.agents.filter(a => a.id !== run.id), run] })))
360    await update($, now, () => at)
361    return started
362  }).catch(($, e, next) => next(e)) // bookkeeping only: never stand in a spawn's way
363
364  on('turn.complete', async ($, e, next) => {
365    const result = await next(e)
366    const at = await $.clock.now()
367    const status = statusOf(e.reason)
368    if (e.agentId) {
369      const id = e.agentId
370      await update($, prompts, list => withAgent(list, id, a => ({ ...a, status, endedAt: at })))
371    } else {
372      const { costUsd } = await read($, session)
373      await update($, prompts, list =>
374        list.map(p => (p.turnId === e.turnId ? { ...p, status, endedAt: at, costAtEnd: costUsd } : p)),
375      )
376    }
377    await update($, now, () => at)
378    return result
379  })
380
381  // ---- the pane -------------------------------------------------------------------
382
383  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
384    const ui = $.ui.resolve(e)
385    const { Box, Text, Button } = ui
386    const list = (await read($, prompts)).map(normalize)
387    const s = await read($, session)
388    const promptRows = await read($, rows)
389    const isOpen = await read($, isListOpen)
390    const convo = (await read($, totals)) ?? seedTotals(list)
391    const at = Math.max(await read($, now), ...list.map(p => p.startedAt), 0)
392
393    const costOf = (p: PromptRun): number =>
394      Math.max(0, (p.costAtEnd ?? (p.status === 'running' ? s.costUsd : p.costAtStart)) - p.costAtStart)
395    const elapsed = (r: { startedAt: number; endedAt?: number }) => (r.endedAt ?? at) - r.startedAt
396    const calls = (p: PromptRun) => p.calls + p.agents.reduce((n, a) => n + a.calls, 0)
397    const tools = (p: PromptRun) => p.tools + p.agents.reduce((n, a) => n + a.tools, 0)
398
399    // Agents in spawn order, each child right under its parent.
400    const ordered = (agents: AgentRun[]): { a: AgentRun; depth: number }[] => {
401      const ids = new Set(agents.map(a => a.id))
402      const out: { a: AgentRun; depth: number }[] = []
403      const walk = (parent: string | undefined, depth: number) => {
404        for (const a of agents) {
405          const isChild = parent === undefined ? !a.parentId || !ids.has(a.parentId) : a.parentId === parent
406          if (isChild) {
407            out.push({ a, depth })
408            walk(a.id, depth + 1)
409          }
410        }
411      }
412      walk(undefined, 0)
413      return out
414    }
415
416    const latest = list.at(-1)
417    // The prompt card and subagents show the latest prompt.
418    const focus = latest
419    const agents = focus ? ordered(focus.agents) : []
420
421    // Every prompt of the session, newest first, in a fixed window of PAGE cards that the wheel
422    // (or ▲ ▼) moves through. A card: a status dot, then its number and when it was sent; the
423    // prompt; when it finished, its models and tokens (drawn like the token counter on the
424    // desktop). Cards only show: a mod can't scroll the desktop app's conversation to a prompt.
425    const firstShown = await read($, listStart)
426    const promptList = (desk?: { W: number; Svg: ElementConstructor<SvgProps> }) => {
427      const cols = Math.max(24, (e.props.bodyColumns || 60) - 8)
428      const items = promptRows.map((row, i) => ({ row, run: runOf(list, row), n: i + 1 })).reverse()
429      const start = Math.max(0, Math.min(firstShown, items.length - PAGE))
430      const page = items.slice(start, start + PAGE)
431      const canScroll = items.length > PAGE
432      const move = (by: number) => update($, listStart, v => Math.max(0, Math.min(items.length - PAGE, Math.min(v, items.length - PAGE) + by)))
433
434      const card = ({ row, run, n }: (typeof items)[number]) => {
435        const isLatest = n === items.length
436        const t = run ? promptTokens(run) : undefined
437        const sent = run?.startedAt ?? row.sentAt
438        const status = run?.status
439        const done = run && status
440          ? status === 'running'
441            ? `Running ${fmtTime(at - run.startedAt)}`
442            : `${status === 'done' ? 'Done' : status === 'aborted' ? 'Stopped' : 'Failed'} ${fmtClock(run.endedAt ?? at, at)} · ${fmtTime((run.endedAt ?? at) - run.startedAt)}`
443          : ''
444        const line = (suffix: string, ...children: (string | ReturnType<typeof h>)[]) => (
445          <Box key={`line:${row.id}${suffix}`}>{h(Text, { wrap: 'truncate-end' }, ...children)}</Box>
446        )
447        const head = line(
448          '',
449          <Text key="index" color={isLatest ? 'claude' : 'subtle'} bold>
450            {`#${n}`}
451          </Text>,
452          <Text key="sent" dimColor>
453            {sent !== undefined ? `  ·  Sent ${fmtClock(sent, at)}` : '  ·  Sent before the panel was watching'}
454          </Text>,
455        )
456        const text = line(
457          ':prompt',
458          <Text key="text" bold>
459            {oneLine(row.text, cols)}
460          </Text>,
461        )
462        let meta
463        if (desk) {
464          const m = recordMeta(desk.W - 52, run, t, done)
465          meta = <desk.Svg key={`meta:${row.id}`} source={m.source} alt={m.alt} width={desk.W - 52} height={m.height} />
466        } else {
467          meta = line(
468            ':done',
469            ...(run && status
470              ? [
471                  <Text key="done" color={STATUS_TEXT[status]}>
472                    {done}
473                  </Text>,
474                  <Text key="models" dimColor>
475                    {`  ·  ${run.models.map(m => modelName(m.model)).join(', ') || 'no model yet'}`}
476                  </Text>,
477                  <Text key="tokens" bold>
478                    {`  ·  ${t && total(t) > 0 ? fmtTokens(total(t)) : 0} tokens`}
479                  </Text>,
480                ]
481              : [
482                  <Text key="none" dimColor>
483                    No figures: sent before the panel was watching
484                  </Text>,
485                ]),
486          )
487        }
488        return (
489          <Box
490            key={`card:${row.id}`}
491            flexDirection="row"
492            alignItems="flex-start"
493            gap={1}
494            height={CARD_ROWS}
495            borderStyle="round"
496            borderColor={isLatest ? 'claude' : 'subtle'}
497            hover={{ borderColor: 'claude', backgroundColor: HOVER_BG }}
498            paddingX={1}
499            marginTop={GAP_ROWS}
500          >
501            {/* The mark sits at the card's top left, beside its first line. On the desktop, Claude
502                marks a running prompt (walking) and a finished one (cheering). */}
503            <Box key={`mark:${row.id}`} alignSelf="flex-start" flexShrink={0}>
504              {desk && status === 'running' ? (
505                <desk.Svg key={`crab:${row.id}`} source={CRAB.source} alt="Claude at work" width={CRAB.width} height={CRAB.height} />
506              ) : desk && status === 'done' ? (
507                <desk.Svg key={`crab:${row.id}`} source={CRAB_DONE.source} alt="Completed" width={CRAB_DONE.width} height={CRAB_DONE.height} />
508              ) : (
509                <Text key={`dot:${row.id}`} color={status ? STATUS_TEXT[status] : 'inactive'}>
510                  ●
511                </Text>
512              )}
513            </Box>
514            <Box key={`lines:${row.id}`} flexDirection="column" flexGrow={1}>
515              {head}
516              {text}
517              {meta}
518            </Box>
519          </Box>
520        )
521      }
522
523      return (
524        <Box key="prompt-list" flexDirection="column" height={isOpen ? LIST_ROWS : 1} overflow="hidden" marginTop={1}>
525          <Box key="list-head" flexDirection="row" justifyContent="space-between">
526            <Button key="list-toggle" plain onPress={() => update($, isListOpen, v => !v)}>
527              <Text bold>{isOpen ? '▾' : '▸'} Prompts in this session</Text>
528              <Text dimColor> · {items.length}</Text>
529            </Button>
530            {isOpen && canScroll && (
531              <Box key="list-nav" flexDirection="row" gap={1}>
532                <Button key="list-newer" plain onPress={() => move(-PAGE)}>
533                  <Text color={start > 0 ? 'claude' : 'inactive'}>▲</Text>
534                </Button>
535                <Text dimColor>{`${start + 1}–${start + page.length} of ${items.length}`}</Text>
536                <Button key="list-older" plain onPress={() => move(PAGE)}>
537                  <Text color={start + PAGE < items.length ? 'claude' : 'inactive'}>▼</Text>
538                </Button>
539              </Box>
540            )}
541          </Box>
542          {isOpen && page.map(card)}
543        </Box>
544      )
545    }
546
547    if (e.surface !== 'terminal' && 'Svg' in ui) {
548      const { Svg } = ui
549      const W = Math.max(260, Math.min(900, (e.props.bodyColumns || 48) * 8 - 8))
550      const tiles = sessionTiles(W, s)
551      const conv = conversationCard(W, promptRows.length, convo)
552      const card = focus
553        ? promptCard(W, focus, promptTokens(focus), {
554            costUsd: costOf(focus),
555            elapsedMs: elapsed(focus),
556            calls: calls(focus),
557            tools: tools(focus),
558          })
559        : null
560      return (
561        <Box flexDirection="column" gap={1}>
562          <Svg key="tiles" source={tiles.source} alt={tiles.alt} width={W} height={tiles.height} />
563          <Svg key="conversation" source={conv.source} alt={conv.alt} width={W} height={conv.height} />
564          {card ? (
565            <Svg key="prompt" source={card.source} alt={card.alt} width={W} height={card.height} />
566          ) : (
567            <Text dimColor>Send a prompt to see its tokens, models and subagents.</Text>
568          )}
569          {agents.length > 0 && (
570            <Box flexDirection="column">
571              <Text bold>
572                Subagents · {agents.length}
573                {focus && focus.agents.some(a => a.status === 'running') ? ` (${focus.agents.filter(a => a.status === 'running').length} running)` : ''}
574              </Text>
575              {agents.map(({ a, depth }) => {
576                const r = agentRow(W, a, depth, elapsed(a))
577                return (
578                  <Svg
579                    key={`agent:${a.id}`}
580                    source={r.source}
581                    alt={`${a.description || a.type}: ${a.type}, ${modelName(a.model)}, ${a.status}, ${fmtTokens(total(a.tokens))} tokens`}
582                    width={W}
583                    height={r.height}
584                  />
585                )
586              })}
587            </Box>
588          )}
589          {promptList({ W, Svg })}
590        </Box>
591      )
592    }
593
594    // Terminal (and the editor and phone): the same content as text.
595    const cols = Math.max(30, e.props.bodyColumns || 60)
596    const barW = Math.max(8, Math.min(24, cols - 30))
597    const ctxPct = s.contextPercent ?? (s.contextTokens && s.contextWindow ? (s.contextTokens / s.contextWindow) * 100 : undefined)
598    const t = focus ? promptTokens(focus) : zero()
599
600    return (
601      <Box flexDirection="column">
602        <Text bold>Session</Text>
603        <Text>
604          {'  '}Cost <Text bold>{fmtUsd(s.costUsd)}</Text>
605        </Text>
606        {ctxPct !== undefined && (
607          <Text wrap="truncate-end">
608            {'  '}Context <Text color={pctColor(ctxPct)}>{bar(ctxPct, barW)}</Text> {Math.round(ctxPct)}%
609            <Text dimColor>{s.contextTokens !== undefined ? ` · ${fmtTokens(s.contextTokens)} / ${fmtTokens(s.contextWindow)}` : ''}</Text>
610          </Text>
611        )}
612        {s.rateLimits.map(r => (
613          <Text key={r.kind} wrap="truncate-end">
614            {'  '}
615            {(LIMIT_NAME[r.kind] ?? r.kind).padEnd(7)} <Text color={pctColor(r.percentUsed)}>{bar(r.percentUsed, barW)}</Text> {Math.round(r.percentUsed)}%
616          </Text>
617        ))}
618
619        <Box marginTop={1} flexDirection="column">
620          <Text bold>This conversation</Text>
621          <Text wrap="truncate-end">
622            {'  '}
623            <Text bold>{promptRows.length}</Text> prompts · <Text bold>{convo.modelCalls}</Text> model calls ·{' '}
624            <Text bold>{convo.toolCalls}</Text> tool calls · <Text bold>{fmtTokens(total(convo.tokens))}</Text> tokens
625          </Text>
626          <Text dimColor wrap="truncate-end">
627            {'  '}
628            {PARTS.map(p => `${p.label} ${fmtTokens(convo.tokens[p.key])}`).join(' · ')}
629          </Text>
630        </Box>
631
632        <Box marginTop={1} flexDirection="column">
633          <Text bold>This prompt</Text>
634          {focus ? (
635            <Box flexDirection="column">
636              <Text wrap="truncate-end">
637                <Text color={COLOR[focus.status]}>{GLYPH[focus.status]}</Text> “{oneLine(focus.text, cols - 6) || '(no text)'}”
638              </Text>
639              <Text dimColor wrap="truncate-end">
640                {'  '}≈{fmtUsd(costOf(focus))} · {fmtTime(elapsed(focus))} · {calls(focus)} calls · {tools(focus)} tools
641              </Text>
642              <Text wrap="truncate-end">
643                {'  '}Tokens <Text bold>{fmtTokens(total(t))}</Text>
644                <Text dimColor> · {PARTS.map(p => `${p.label} ${fmtTokens(t[p.key])}`).join(' · ')}</Text>
645              </Text>
646              <Text>{'  '}Models</Text>
647              {focus.models.length ? (
648                focus.models.map(m => (
649                  <Text key={m.model} wrap="truncate-end">
650                    {'    '}
651                    <Text bold>{modelName(m.model)}</Text>
652                    <Text dimColor>
653                      {' '}
654                      {m.model} · {whereOf(m)} · {m.mainCalls + m.agentCalls} calls · {fmtTokens(total(m.tokens))} tok
655                    </Text>
656                  </Text>
657                ))
658              ) : (
659                <Text dimColor>{'    '}waiting for the first model call…</Text>
660              )}
661              {agents.length > 0 && <Text>{'  '}Subagents · {agents.length}</Text>}
662              {agents.map(({ a, depth }) => (
663                <Box key={a.id} flexDirection="column">
664                  <Text wrap="truncate-end">
665                    {'    '}
666                    {'  '.repeat(depth)}
667                    <Text color={COLOR[a.status]}>{GLYPH[a.status]}</Text> <Text bold>{a.description || a.type}</Text>
668                    <Text dimColor> · {a.type}</Text>
669                  </Text>
670                  <Text dimColor wrap="truncate-end">
671                    {'      '}
672                    {'  '.repeat(depth)}
673                    {modelName(a.model)} · {fmtTime(elapsed(a))} · {a.calls} calls · {a.tools} tools · {fmtTokens(total(a.tokens))} tok
674                  </Text>
675                </Box>
676              ))}
677            </Box>
678          ) : (
679            <Text dimColor>{'  '}Send a prompt to see its tokens, models and subagents.</Text>
680          )}
681        </Box>
682        {promptList()}
683      </Box>
684    )
685  })
686
687  // ---- the band above the prompt: this prompt's tokens and model ------------------------
688
689  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
690    const list = await read($, prompts)
691    const last = list.at(-1)
692    if (!last || e.props.hasSurvey) return next(e)
693    // The band is shared: what the plugins beneath draw there stays, below this row.
694    const below = await next(e)
695    const p = normalize(last)
696    const s = await read($, session)
697    const at = Math.max(await read($, now), p.startedAt)
698    const t = promptTokens(p)
699    const elapsedMs = (p.endedAt ?? at) - p.startedAt
700    const cost = Math.max(0, (p.costAtEnd ?? (p.status === 'running' ? s.costUsd : p.costAtStart)) - p.costAtStart)
701    const mainModel = p.model || p.models[0]?.model || ''
702    const others = p.models.filter(m => m.model !== mainModel).map(m => modelName(m.model))
703    const alt = `${mainModel || 'no model yet'}${others.length ? ` + ${others.join(', ')}` : ''}: ${fmtTokens(total(t))} tokens (${fmtTokens(t.output)} out), ${fmtTime(elapsedMs)}, ≈${fmtUsd(cost)}`
704
705    const ui = $.ui.resolve(e)
706    const { Box, Text, Button } = ui
707    const open = <Button key="usage-open" label="Usage" plain onPress={() => void togglePane($)} />
708
709    if (e.surface !== 'terminal' && 'Svg' in ui) {
710      const { Svg } = ui
711      const W = Math.max(220, Math.min(1400, (e.props.bodyColumns || 100) * 8 - 72))
712      const row = bandRow(W, p, t, elapsedMs, cost)
713      return (
714        <Box flexDirection="column">
715          <Box flexDirection="row" alignItems="center" gap={1}>
716            <Svg key="usage-band" source={row.source} alt={alt} width={W} height={row.height} />
717            {open}
718          </Box>
719          {below}
720        </Box>
721      )
722    }
723
724    return (
725      <Box flexDirection="column">
726        <Box flexDirection="row" gap={1}>
727          <Text color={COLOR[p.status]}>{GLYPH[p.status]}</Text>
728          <Text bold>{mainModel ? modelName(mainModel) : '—'}</Text>
729          <Text dimColor wrap="truncate-end">
730            {mainModel}
731            {others.length ? ` +${others.join(', +')}` : ''}
732          </Text>
733          <Text>│</Text>
734          <Text bold>{fmtTokens(total(t))} tokens</Text>
735          <Text dimColor>({fmtTokens(t.output)} out)</Text>
736          <Text>│</Text>
737          <Text dimColor>
738            {fmtTime(elapsedMs)} · ≈{fmtUsd(cost)}
739          </Text>
740          {open}
741        </Box>
742        {below}
743      </Box>
744    )
745  })
746}
747
hooks/draw.ts 537 lines
1// SVG drawings for the desktop: the pane's cards and rows, and the band above the prompt.
2// Pure functions of the mod's state; each returns markup and its height in CSS pixels.
3import type { AgentRun, ConversationTotals, ModelUse, PromptRun, RunStatus, SessionFigures, Tokens } from '../types'
4
5// ---- shared ---------------------------------------------------------------------
6
7export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
8const MONO = "ui-monospace,'SF Mono',Menlo,Consolas,monospace"
9
10// Theme tokens: light by default, dark under the system's dark scheme.
11const CSS = `<style>
12.fg{fill:#1c1c1b}.mu{fill:#6b6a66}.su{fill:#9a9994}.card{fill:#f7f6f3;stroke:#e7e5e0}.track{fill:#ebe9e4}.ln{stroke:#e7e5e0}
13@media (prefers-color-scheme: dark){.fg{fill:#ededeb}.mu{fill:#a9a8a3}.su{fill:#7c7b77}.card{fill:#222221;stroke:#343432}.track{fill:#30302e}.ln{stroke:#343432}}
14.live{animation:pulse 1.6s ease-in-out infinite}@keyframes pulse{50%{opacity:.3}}
15.crab .la{animation:step .5s steps(1) infinite}.crab .lb{animation:step .5s steps(1) infinite -.25s}
16.crab .bd{animation:bob .5s steps(1) infinite -.125s}.crab .cl{animation:wave 1s steps(1) infinite}.crab .cr{animation:wave 1s steps(1) infinite -.5s}
17@keyframes step{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}@keyframes wave{50%{transform:translateY(-2px)}}
18@media (prefers-reduced-motion: reduce){.live,.crab *{animation:none!important}}
19</style>`
20
21const svg = (W: number, H: number, body: string): string =>
22  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${CSS}${body}</svg>`
23
24const xml = (s: string): string =>
25  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
26
27// Rough advance of system UI text, in em: enough to decide what fits. Digits are drawn
28// tabular (every digit as wide as a 0), bold runs a little wider. Text that sits beside
29// other text is never placed by this estimate: it shares one <text> (see spans).
30const charEm = (ch: string): number =>
31  /\d/.test(ch) ? 0.62 : /[\s.,:;'|!il()[\]]/.test(ch) ? 0.3 : /[A-ZMW@%mw]/.test(ch) ? 0.76 : 0.57
32
33export const textWidth = (s: string, size: number, isMono = false, isBold = false): number =>
34  isMono ? s.length * size * 0.6 : [...s].reduce((w, ch) => w + charEm(ch) * size, 0) * (isBold ? 1.07 : 1)
35
36export const fitText = (s: string, size: number, maxW: number, isMono = false): string => {
37  const flat = s.replace(/\s+/g, ' ').trim()
38  if (textWidth(flat, size, isMono) <= maxW) return flat
39  let out = ''
40  for (const ch of flat) {
41    if (textWidth(out + ch + '…', size, isMono) > maxW) break
42    out += ch
43  }
44  return out + '…'
45}
46
47type TextOpts = { size?: number; cls?: string; weight?: number; anchor?: 'start' | 'end' | 'middle'; fill?: string; isMono?: boolean }
48
49const text = (x: number, y: number, s: string, o: TextOpts = {}): string =>
50  `<text x="${x}" y="${y}"${o.fill ? ` fill="${o.fill}"` : ` class="${o.cls ?? 'fg'}"`} font-family="${o.isMono ? MONO : FONT}" font-size="${o.size ?? 12}"${o.weight ? ` font-weight="${o.weight}"` : ''}${o.anchor && o.anchor !== 'start' ? ` text-anchor="${o.anchor}"` : ''} font-variant-numeric="tabular-nums">${xml(s)}</text>`
51
52type Span = { s: string; size?: number; cls?: string; weight?: number; fill?: string; isMono?: boolean; gap?: number }
53
54/**
55 * Several styled runs as one <text>: the renderer lays them out end to end, so a label
56 * never overlaps the number before it however wide that number draws. `gap` is the space
57 * before a run, in pixels.
58 */
59const spans = (x: number, y: number, runs: Span[], anchor: 'start' | 'end' = 'start'): string =>
60  `<text x="${x}" y="${y}" font-family="${FONT}" font-variant-numeric="tabular-nums"${anchor === 'end' ? ' text-anchor="end"' : ''}>` +
61  runs
62    .map(
63      r =>
64        `<tspan${r.gap ? ` dx="${r.gap}"` : ''}${r.fill ? ` fill="${r.fill}"` : ` class="${r.cls ?? 'fg'}"`} font-size="${r.size ?? 12}"` +
65        `${r.weight ? ` font-weight="${r.weight}"` : ''}${r.isMono ? ` font-family="${MONO}"` : ''}>${xml(r.s)}</tspan>`,
66    )
67    .join('') +
68  '</text>'
69
70// ---- formatting -----------------------------------------------------------------
71
72export const total = (t: Tokens): number => t.input + t.output + t.cacheRead + t.cacheWrite
73
74export const fmtTokens = (n: number): string =>
75  n >= 1e6 ? `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
76
77export const fmtUsd = (usd: number): string => `$${usd < 1 ? usd.toFixed(3) : usd.toFixed(2)}`
78
79export const fmtTime = (ms: number): string => {
80  const s = Math.max(0, Math.round(ms / 1000))
81  const m = Math.floor(s / 60)
82  return m ? `${m}m ${String(s % 60).padStart(2, '0')}s` : `${s}s`
83}
84
85export const modelName = (id: string): string => {
86  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
87  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
88  const [, family = '', major = '', minor] = m
89  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
90}
91
92const FAMILY_COLOR: [RegExp, string][] = [
93  [/fable|mythos/i, '#C98A2B'],
94  [/opus/i, '#7F77DD'],
95  [/sonnet/i, '#378ADD'],
96  [/haiku/i, '#1D9E75'],
97]
98export const modelColor = (id: string): string => FAMILY_COLOR.find(([re]) => re.test(id))?.[1] ?? '#888780'
99
100export const STATUS_COLOR: Record<RunStatus, string> = { running: '#3B82F6', done: '#3B9C5F', aborted: '#C9A227', failed: '#D0453F' }
101
102// The four kinds of token, in the order the stacked bar draws them.
103export const PARTS: { key: keyof Tokens; label: string; color: string }[] = [
104  { key: 'input', label: 'in', color: '#3B82F6' },
105  { key: 'output', label: 'out', color: '#F97316' },
106  { key: 'cacheRead', label: 'cache read', color: '#14B8A6' },
107  { key: 'cacheWrite', label: 'cache write', color: '#A855F7' },
108]
109
110const pctColor = (pct: number): string => (pct >= 90 ? '#D0453F' : pct >= 70 ? '#C9A227' : '#3B9C5F')
111
112const LIMIT_NAME: Record<string, string> = { five_hour: '5-hour limit', seven_day: '7-day limit', spend_limit: 'Spend limit' }
113
114// Claude at work: the pixel crab, 24×16 on its grid, scaled to `w` and centred on (cx, cy).
115// Its legs step, its body bobs and its claws wave (CSS, so no redraws).
116const CLAY = '#D97757'
117const INK = '#1F1E1D'
118export const crab = (cx: number, cy: number, w = 16): string => {
119  const k = w / 24
120  const px = (x: number, y: number, pw: number, ph: number, fill: string, cls = '') =>
121    `<rect${cls ? ` class="${cls}"` : ''} x="${x}" y="${y}" width="${pw}" height="${ph}" fill="${fill}"/>`
122  return (
123    `<g transform="translate(${cx - w / 2},${cy - 8 * k}) scale(${k})" shape-rendering="crispEdges"><g class="crab">` +
124    `<g class="bd">${px(4, 0, 16, 12, CLAY)}${px(6, 3, 2, 2, INK)}${px(16, 3, 2, 2, INK)}</g>` +
125    px(0, 4, 4, 4, CLAY, 'cl') +
126    px(20, 4, 4, 4, CLAY, 'cr') +
127    px(4, 12, 2, 4, CLAY, 'la') +
128    px(14, 12, 2, 4, CLAY, 'la') +
129    px(8, 12, 2, 4, CLAY, 'lb') +
130    px(18, 12, 2, 4, CLAY, 'lb') +
131    '</g></g>'
132  )
133}
134
135// Claude, done: the crab standing still with both claws raised, cheering. 24×20 on
136// its grid, scaled to `w` and centred on (cx, cy).
137export const crabDone = (cx: number, cy: number, w = 16): string => {
138  const k = w / 24
139  const px = (x: number, y: number, pw: number, ph: number, fill = CLAY) => `<rect x="${x}" y="${y}" width="${pw}" height="${ph}" fill="${fill}"/>`
140  return (
141    `<g transform="translate(${cx - w / 2},${cy - 10 * k}) scale(${k})" shape-rendering="crispEdges">` +
142    // claws up, on arms reaching from the body's sides
143    px(0, 0, 4, 4) + px(1, 4, 2, 4) + px(20, 0, 4, 4) + px(21, 4, 2, 4) +
144    px(3, 7, 1, 2) + px(20, 7, 1, 2) +
145    px(4, 4, 16, 12) + px(6, 7, 2, 2, INK) + px(16, 7, 2, 2, INK) +
146    px(4, 16, 2, 4) + px(8, 16, 2, 4) + px(14, 16, 2, 4) + px(18, 16, 2, 4) +
147    '</g>'
148  )
149}
150
151/** The done crab alone, for a row drawn with text (the prompt list's mark for a finished prompt). */
152export const crabDoneIcon = (w = 16): { source: string; width: number; height: number } => {
153  const h = Math.ceil((w * 20) / 24) + 2
154  return { source: svg(w, h, crabDone(w / 2, h / 2, w)), width: w, height: h }
155}
156
157/** The crab alone, for a row drawn with text (the prompt list's dot while a prompt runs). */
158export const crabIcon = (w = 16): { source: string; width: number; height: number } => {
159  const h = Math.ceil((w * 16) / 24) + 2
160  return { source: svg(w, h, crab(w / 2, h / 2, w)), width: w, height: h }
161}
162
163const statusMark = (x: number, y: number, status: RunStatus): string => {
164  const c = STATUS_COLOR[status]
165  if (status === 'running') return crab(x, y, 16)
166  if (status === 'done') return `<path d="M${x - 4.5} ${y}l3 3 6-6.5" fill="none" stroke="${c}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
167  if (status === 'aborted') return `<rect x="${x - 3.5}" y="${y - 3.5}" width="7" height="7" rx="1.5" fill="${c}"/>`
168  return `<path d="M${x - 3.5} ${y - 3.5}l7 7M${x + 3.5} ${y - 3.5}l-7 7" stroke="${c}" stroke-width="1.8" stroke-linecap="round"/>`
169}
170
171// A track with a fill, rounded.
172const meter = (x: number, y: number, w: number, h: number, frac: number, color: string): string =>
173  `<rect class="track" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
174  (frac > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, Math.round(w * Math.min(1, frac)))}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
175
176// A rounded pill with centred white text; returns markup and width.
177const pill = (x: number, y: number, label: string, color: string, size = 11): { markup: string; w: number } => {
178  const w = Math.round(textWidth(label, size, false, true) + 14)
179  const h = size + 7
180  return {
181    markup: `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}" fill="${color}"/>${text(x + w / 2, y + h / 2 + size * 0.36, label, { size, weight: 600, anchor: 'middle', fill: '#ffffff' })}`,
182    w,
183  }
184}
185
186// ---- pane: session tiles ------------------------------------------------------------
187
188export function sessionTiles(W: number, s: SessionFigures): { source: string; height: number; alt: string } {
189  type Tile = { label: string; value: string; sub?: string; pct?: number }
190  const ctxPct = s.contextPercent ?? (s.contextTokens && s.contextWindow ? (s.contextTokens / s.contextWindow) * 100 : undefined)
191  const tiles: Tile[] = [{ label: 'Session cost', value: fmtUsd(s.costUsd) }]
192  if (ctxPct !== undefined) {
193    tiles.push({
194      label: 'Context',
195      value: `${Math.round(ctxPct)}%`,
196      sub: s.contextTokens !== undefined ? `${fmtTokens(s.contextTokens)} / ${fmtTokens(s.contextWindow)}` : undefined,
197      pct: ctxPct,
198    })
199  }
200  for (const r of s.rateLimits) {
201    const resets = r.resetsAt ? new Date(r.resetsAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
202    tiles.push({ label: LIMIT_NAME[r.kind] ?? r.kind, value: `${Math.round(r.percentUsed)}%`, sub: resets && `resets ${resets}`, pct: r.percentUsed })
203  }
204
205  const gap = 8
206  const cols = Math.min(tiles.length, W >= 560 ? 4 : 2)
207  const tw = (W - gap * (cols - 1)) / cols
208  const th = 64
209  const rows = Math.ceil(tiles.length / cols)
210  const body = tiles
211    .map((t, i) => {
212      const x = (i % cols) * (tw + gap)
213      const y = Math.floor(i / cols) * (th + gap)
214      return (
215        `<rect class="card" x="${x + 0.5}" y="${y + 0.5}" width="${tw - 1}" height="${th - 1}" rx="10"/>` +
216        text(x + 12, y + 20, t.label, { size: 11, cls: 'mu' }) +
217        text(x + 12, y + 42, t.value, { size: 19, weight: 650 }) +
218        (t.sub ? text(x + tw - 12, y + 42, fitText(t.sub, 10.5, tw - 36 - textWidth(t.value, 19, false, true)), { size: 10.5, cls: 'su', anchor: 'end' }) : '') +
219        (t.pct !== undefined ? meter(x + 12, y + th - 14, tw - 24, 4, t.pct / 100, pctColor(t.pct)) : '')
220      )
221    })
222    .join('')
223  const height = rows * th + (rows - 1) * gap
224  return { source: svg(W, height, body), height, alt: tiles.map(t => `${t.label} ${t.value}`).join(', ') }
225}
226
227// ---- pane: the current prompt -----------------------------------------------------
228
229export function promptCard(
230  W: number,
231  p: PromptRun,
232  t: Tokens,
233  extra: { costUsd: number; elapsedMs: number; calls: number; tools: number; title?: string },
234): { source: string; height: number; alt: string } {
235  const pad = 14
236  const inner = W - pad * 2
237  const sum = total(t)
238  let y = 0
239  const out: string[] = []
240
241  // Status, elapsed and cost.
242  y += 22
243  out.push(statusMark(pad + 4, y - 4, p.status))
244  const title = extra.title ?? (p.status === 'running' ? 'Working on your prompt' : 'Last prompt')
245  out.push(text(pad + 16, y, title, { size: 11, cls: 'mu', weight: 500 }))
246  out.push(text(W - pad, y, `${fmtTime(extra.elapsedMs)}  ·  ≈${fmtUsd(extra.costUsd)}`, { size: 11, cls: 'mu', anchor: 'end' }))
247
248  // The prompt itself.
249  y += 21
250  out.push(text(pad, y, fitText(`“${p.text || '(no text)'}”`, 13, inner), { size: 13, weight: 500 }))
251
252  // Big token count, calls and tools.
253  y += 38
254  const big = fmtTokens(sum)
255  out.push(spans(pad, y, [{ s: big, size: 26, weight: 700 }, { s: 'tokens', size: 12, cls: 'mu', gap: 7 }]))
256  out.push(text(W - pad, y, `${extra.calls} model calls  ·  ${extra.tools} tool calls`, { size: 11, cls: 'mu', anchor: 'end' }))
257
258  // Stacked composition bar.
259  y += 12
260  const barH = 10
261  out.push(`<defs><clipPath id="bar"><rect x="${pad}" y="${y}" width="${inner}" height="${barH}" rx="${barH / 2}"/></clipPath></defs>`)
262  out.push(`<rect class="track" x="${pad}" y="${y}" width="${inner}" height="${barH}" rx="${barH / 2}"/>`)
263  if (sum > 0) {
264    let x = pad
265    const segs = PARTS.map(part => {
266      const w = (t[part.key] / sum) * inner
267      const seg = w > 0 ? `<rect x="${x}" y="${y}" width="${w + 0.5}" height="${barH}" fill="${part.color}"/>` : ''
268      x += w
269      return seg
270    })
271    out.push(`<g clip-path="url(#bar)">${segs.join('')}</g>`)
272  }
273
274  // Legend.
275  y += barH + 18
276  let lx = pad
277  for (const part of PARTS) {
278    const label = `${part.label} ${fmtTokens(t[part.key])}`
279    const w = 14 + textWidth(label, 11) + 14
280    if (lx + w > W - pad + 14) break
281    out.push(`<rect x="${lx}" y="${y - 8}" width="8" height="8" rx="2" fill="${part.color}"/>`)
282    out.push(text(lx + 13, y, label, { size: 11, cls: 'mu' }))
283    lx += w
284  }
285
286  // Models.
287  y += 16
288  out.push(`<line class="ln" x1="${pad}" y1="${y}" x2="${W - pad}" y2="${y}"/>`)
289  y += 22
290  out.push(text(pad, y, 'Models', { size: 11, cls: 'mu', weight: 600 }))
291  if (p.models.length === 0) {
292    y += 22
293    out.push(text(pad, y, 'Waiting for the first model call…', { size: 12, cls: 'su' }))
294  }
295  for (const m of p.models) {
296    y += 12
297    out.push(modelRow(pad, y, inner, m, sum))
298    y += 34
299  }
300  y += 14
301
302  const height = y
303  const card = `<rect class="card" x="0.5" y="0.5" width="${W - 1}" height="${height - 1}" rx="12"/>`
304  const alt =
305    `${extra.title ?? (p.status === 'running' ? 'Working on' : 'Last prompt')}: ${p.text}. ${fmtTokens(sum)} tokens ` +
306    `(${PARTS.map(part => `${part.label} ${fmtTokens(t[part.key])}`).join(', ')}). ` +
307    `Models: ${p.models.map(m => `${modelName(m.model)} (${m.model}) ${whereOf(m)} ${fmtTokens(total(m.tokens))}`).join('; ') || 'none yet'}.`
308  return { source: svg(W, height, card + out.join('')), height, alt }
309}
310
311export const whereOf = (m: ModelUse): string => {
312  const n = m.agentIds.length
313  return [m.mainCalls ? 'main' : '', n ? `${n} subagent${n === 1 ? '' : 's'}` : ''].filter(Boolean).join(' + ')
314}
315
316// One model: colour chip, name, id, where it ran, its share of the prompt's tokens.
317const modelRow = (x: number, y: number, w: number, m: ModelUse, promptTotal: number): string => {
318  const color = modelColor(m.model)
319  const tokens = total(m.tokens)
320  const name = modelName(m.model)
321  const right = `${fmtTokens(tokens)}  ·  ${m.mainCalls + m.agentCalls} calls`
322  const nameW = textWidth(name, 12.5, false, true) + 8
323  const rightW = textWidth(right, 11) + 16
324  return (
325    `<rect x="${x}" y="${y + 1}" width="10" height="10" rx="3" fill="${color}"/>` +
326    spans(x + 16, y + 10.5, [
327      { s: name, size: 12.5, weight: 600 },
328      { s: fitText(m.model, 10.5, w - 16 - nameW - rightW, true), size: 10.5, cls: 'su', isMono: true, gap: 8 },
329    ]) +
330    text(x + w, y + 10.5, right, { size: 11, cls: 'mu', anchor: 'end' }) +
331    meter(x + 16, y + 17, w - 16 - 120, 4, promptTotal ? tokens / promptTotal : 0, color) +
332    text(x + w, y + 22, whereOf(m), { size: 10.5, cls: 'su', anchor: 'end' })
333  )
334}
335
336// ---- pane: subagent and history rows ------------------------------------------------
337
338export function agentRow(W: number, a: AgentRun, depth: number, elapsedMs: number): { source: string; height: number } {
339  const H = 46
340  const x0 = 6 + depth * 16
341  const tokens = fmtTokens(total(a.tokens))
342  const right = textWidth(tokens, 13) + 8
343  const color = modelColor(a.model)
344  const body =
345    (depth ? `<path class="ln" d="M${x0 - 10} 6v14h6" fill="none" stroke-width="1.2"/>` : '') +
346    statusMark(x0 + 4, 17, a.status) +
347    text(x0 + 16, 21, fitText(a.description || a.type, 13, W - x0 - 16 - right), { size: 13, weight: 600 }) +
348    text(W - 4, 21, tokens, { size: 13, weight: 650, anchor: 'end' }) +
349    `<rect x="${x0 + 16}" y="${29}" width="7" height="7" rx="2" fill="${color}"/>` +
350    text(
351      x0 + 28,
352      36,
353      fitText(`${a.type}  ·  ${modelName(a.model)}  ·  ${fmtTime(elapsedMs)}  ·  ${a.calls} calls  ·  ${a.tools} tools`, 11, W - x0 - 32),
354      { size: 11, cls: 'mu' },
355    ) +
356    `<line class="ln" x1="${x0 + 16}" y1="${H - 0.5}" x2="${W}" y2="${H - 0.5}"/>`
357  return { source: svg(W, H, body), height: H }
358}
359
360// ---- band above the prompt ------------------------------------------------------------
361
362/**
363 * One line: status, the main model's id (and other models as chips), the prompt's tokens
364 * with output beside them, elapsed time and cost; trimmed from the right as space runs out.
365 */
366export function bandRow(W: number, p: PromptRun, t: Tokens, elapsedMs: number, costUsd: number): { source: string; height: number } {
367  const H = 24
368  const mid = H / 2
369  const parts: string[] = [statusMark(9, mid, p.status)]
370  let x = 22
371
372  const mainModel = p.model || p.models[0]?.model || ''
373  if (mainModel) {
374    const chip = pill(x, mid - 9, modelName(mainModel), modelColor(mainModel))
375    parts.push(chip.markup)
376    x += chip.w + 6
377    const id = fitText(mainModel, 11, Math.max(0, W * 0.32), true)
378    parts.push(text(x, mid + 4, id, { size: 11, cls: 'mu', isMono: true }))
379    x += textWidth(id, 11, true) + 10
380  }
381
382  // Right side, one <text> anchored at the right edge: tokens, then output, time and cost
383  // while they fit. Decided by estimate; laid out by the renderer, so nothing overlaps.
384  const tokens = fmtTokens(total(t))
385  const right: Span[] = [
386    { s: tokens, size: 14, weight: 700 },
387    { s: 'tokens', size: 11, cls: 'mu', gap: 5 },
388  ]
389  let rightW = textWidth(tokens, 14, false, true) + 5 + textWidth('tokens', 11)
390  for (const extra of [`·  ${fmtTokens(t.output)} out`, `·  ${fmtTime(elapsedMs)}`, `·  ≈${fmtUsd(costUsd)}`]) {
391    const w = 10 + textWidth(extra, 11)
392    if (x + rightW + w + 24 > W) break
393    right.push({ s: extra, size: 11, cls: 'su', gap: 10 })
394    rightW += w
395  }
396
397  // Subagents' models, as small chips, while there is room before the right side.
398  for (const m of p.models.filter(one => one.model !== mainModel)) {
399    const label = `+ ${modelName(m.model)}`
400    const w = textWidth(label, 10, false, true) + 14
401    if (x + w + 24 > W - rightW) break
402    const chip = pill(x, mid - 8, label, modelColor(m.model), 10)
403    parts.push(chip.markup)
404    x += chip.w + 4
405  }
406  parts.push(spans(W - 2, mid + 5, right, 'end'))
407
408  return { source: svg(W, H, parts.join('')), height: H }
409}
410
411// ---- pane: a prompt record's last line ---------------------------------------------------
412
413/**
414 * The third line of a prompt record, in the token counter's style: when it finished (or how
415 * long it has run) at the left; a pill per model, then the count, bold, at the right. Without a
416 * run: a note that it has no figures.
417 */
418export function recordMeta(W: number, p: PromptRun | undefined, t: Tokens | undefined, done: string): { source: string; height: number; alt: string } {
419  const H = 18
420  const mid = H / 2
421  if (!p || !t) {
422    const note = 'No figures: sent before the panel was watching'
423    return { source: svg(W, H, text(0, mid + 4, note, { size: 11, cls: 'su' })), height: H, alt: note }
424  }
425  const sum = total(t)
426  const parts: string[] = []
427
428  // The count at the right edge, laid out with its label by the renderer.
429  const count = sum > 0 ? fmtTokens(sum) : '0'
430  const countW = textWidth(count, 12.5, false, true) + 5 + textWidth('tokens', 10.5)
431  parts.push(spans(W, mid + 4.5, [{ s: count, size: 12.5, weight: 700 }, { s: 'tokens', size: 10.5, cls: 'mu', gap: 5 }], 'end'))
432  const right = W - countW - 8
433
434  // The model pills sit just left of the count: as many as fit after the finish time, first used
435  // first. Where not even the first fits, the finish time drops its duration to make room.
436  const fit = (start: number) => {
437    const out: { label: string; color: string; w: number }[] = []
438    let width = 0
439    for (const m of p.models) {
440      const label = modelName(m.model)
441      const w = Math.round(textWidth(label, 10, false, true) + 14)
442      if (start + width + w + (out.length ? 4 : 0) > right) break
443      width += w + (out.length ? 4 : 0)
444      out.push({ label, color: modelColor(m.model), w })
445    }
446    return { pills: out, width }
447  }
448  let label = done
449  let { pills, width: pillsW } = fit(textWidth(label, 11, false, true) + 10)
450  if (!pills.length && p.models.length && label.includes(' · ')) {
451    label = label.split(' · ')[0] ?? label
452    ;({ pills, width: pillsW } = fit(textWidth(label, 11, false, true) + 10))
453  }
454  parts.push(text(0, mid + 4, label, { size: 11, fill: STATUS_COLOR[p.status], weight: 500 }))
455  let px = right - pillsW
456  for (const one of pills) {
457    parts.push(pill(px, mid - 8, one.label, one.color, 10).markup)
458    px += one.w + 4
459  }
460
461  const alt = `${done} · ${p.models.map(m => modelName(m.model)).join(', ') || 'no model yet'} · ${count} tokens`
462  return { source: svg(W, H, parts.join('')), height: H, alt }
463}
464
465// ---- pane: the whole conversation -------------------------------------------------------
466
467/**
468 * The conversation so far, in the token counter's style: tiles for prompts sent, model calls,
469 * tool calls and tokens, then the tokens split by kind in one bar with its legend.
470 */
471export function conversationCard(W: number, prompts: number, c: ConversationTotals): { source: string; height: number; alt: string } {
472  const pad = 14
473  const inner = W - pad * 2
474  const sum = total(c.tokens)
475  const out: string[] = []
476  let y = 22
477  out.push(text(pad, y, 'This conversation', { size: 11, cls: 'mu', weight: 500 }))
478
479  const tiles = [
480    { label: 'Prompts sent', value: String(prompts) },
481    { label: 'Model calls', value: String(c.modelCalls) },
482    { label: 'Tool calls', value: String(c.toolCalls) },
483    { label: 'Tokens', value: fmtTokens(sum) },
484  ]
485  const gap = 8
486  const cols = W >= 480 ? 4 : 2
487  const tw = (inner - gap * (cols - 1)) / cols
488  const th = 50
489  y += 10
490  tiles.forEach((t, i) => {
491    const x = pad + (i % cols) * (tw + gap)
492    const ty = y + Math.floor(i / cols) * (th + gap)
493    out.push(`<rect class="track" x="${x}" y="${ty}" width="${tw}" height="${th}" rx="8" opacity="0.55"/>`)
494    out.push(text(x + 10, ty + 18, t.label, { size: 10.5, cls: 'mu' }))
495    out.push(text(x + 10, ty + 40, t.value, { size: 18, weight: 700 }))
496  })
497  y += Math.ceil(tiles.length / cols) * (th + gap) + 4
498
499  // Tokens by kind.
500  const barH = 8
501  out.push(`<defs><clipPath id="conv"><rect x="${pad}" y="${y}" width="${inner}" height="${barH}" rx="${barH / 2}"/></clipPath></defs>`)
502  out.push(`<rect class="track" x="${pad}" y="${y}" width="${inner}" height="${barH}" rx="${barH / 2}"/>`)
503  if (sum > 0) {
504    let x = pad
505    const segs = PARTS.map(part => {
506      const w = (c.tokens[part.key] / sum) * inner
507      const seg = w > 0 ? `<rect x="${x}" y="${y}" width="${w + 0.5}" height="${barH}" fill="${part.color}"/>` : ''
508      x += w
509      return seg
510    })
511    out.push(`<g clip-path="url(#conv)">${segs.join('')}</g>`)
512  }
513  y += barH + 18
514  let lx = pad
515  for (const part of PARTS) {
516    const label = `${part.label} ${fmtTokens(c.tokens[part.key])}`
517    const w = 14 + textWidth(label, 11) + 16
518    // Out of room: the legend goes on to a second line.
519    if (lx > pad && lx + w > W - pad + 16) {
520      lx = pad
521      y += 18
522    }
523    out.push(`<rect x="${lx}" y="${y - 8}" width="8" height="8" rx="2" fill="${part.color}"/>`)
524    out.push(text(lx + 13, y, label, { size: 11, cls: 'mu' }))
525    lx += w
526  }
527  y += 16
528
529  const height = y
530  const card = `<rect class="card" x="0.5" y="0.5" width="${W - 1}" height="${height - 1}" rx="12"/>`
531  const alt =
532    `This conversation: ${prompts} prompt${prompts === 1 ? '' : 's'} sent, ${c.modelCalls} model call${c.modelCalls === 1 ? '' : 's'}, ` +
533    `${c.toolCalls} tool call${c.toolCalls === 1 ? '' : 's'}, ${fmtTokens(sum)} tokens ` +
534    `(${PARTS.map(part => `${part.label} ${fmtTokens(c.tokens[part.key])}`).join(', ')})`
535  return { source: svg(W, height, card + out.join('')), height, alt }
536}
537
types/index.d.ts 98 lines
1export type RunStatus = 'running' | 'done' | 'aborted' | 'failed'
2
3/** Token counts summed over a loop's model calls. */
4export type Tokens = {
5  input: number
6  output: number
7  cacheRead: number
8  cacheWrite: number
9}
10
11export type AgentRun = {
12  id: string
13  parentId?: string
14  type: string
15  description: string
16  model: string
17  status: RunStatus
18  startedAt: number
19  endedAt?: number
20  calls: number
21  tools: number
22  tokens: Tokens
23}
24
25/** One model's share of a prompt: main loop and subagents together. */
26export type ModelUse = {
27  model: string
28  /** Model calls made on the main loop and inside subagents. */
29  mainCalls: number
30  agentCalls: number
31  /** The subagents that called it. */
32  agentIds: string[]
33  tokens: Tokens
34}
35
36/** One prompt: the main turn it started and every subagent spawned during it. */
37export type PromptRun = {
38  turnId: string
39  text: string
40  status: RunStatus
41  startedAt: number
42  endedAt?: number
43  model: string
44  calls: number
45  tools: number
46  tokens: Tokens
47  /** The session's cost (USD) when the prompt started and when it ended. */
48  costAtStart: number
49  costAtEnd?: number
50  agents: AgentRun[]
51  /** Every model that worked on the prompt, first used first. */
52  models: ModelUse[]
53}
54
55/** One of your prompts as the conversation keeps it: an entry of the prompt list. */
56export type PromptRow = {
57  /** The transcript row's id (session.append's uuid, or the drawn row's requestId). */
58  id: string
59  /** The id the conversation draws the row under, once seen drawn. */
60  renderId?: string
61  text: string
62  /** When you sent it ($.clock.now() ms); absent for a prompt sent before the mod loaded. */
63  sentAt?: number
64}
65
66/** The whole conversation's usage since the panel started watching: every model call, main and subagents. */
67export type ConversationTotals = {
68  modelCalls: number
69  toolCalls: number
70  tokens: Tokens
71}
72
73export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
74
75export type SessionFigures = {
76  costUsd: number
77  contextTokens?: number
78  contextWindow: number
79  contextPercent?: number
80  rateLimits: RateLimit[]
81}
82
83declare module 'claude-code' {
84  interface PluginState {
85    'usage-panel': {
86      prompts: PromptRun[]
87      session: SessionFigures
88      rows: PromptRow[]
89      /** Null until the first model call this mod sees: then seeded from the prompts it holds. */
90      totals: ConversationTotals | null
91      /** How many records the list has scrolled past, newest first. */
92      listStart: number
93      isListOpen: boolean
94      now: number
95    }
96  }
97}
98