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…

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.
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).
One line for the prompt in progress (or the last one):
| Model | the main model as a chip, with its full id (claude-opus-5-5), plus a chip for each other model its subagents used |
| Tokens | the prompt's total across the main conversation and every subagent, and how many were output |
| Time · cost | how long it has run, and what it cost |
| Usage | a button that shows or hides the pane |
| Section | Shows |
|---|---|
| Session | cost 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 conversation | prompts 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 prompt | total 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 |
| Subagents | each subagent the prompt started, nested under the one that started it, with live status, model, time, calls, tools and tokens |
| Prompts in this session | every 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.
# 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.
/cost totals. A prompt's cost is how much the session's total grew while it ran, subagents included; subagents show tokens, not dollars.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
| File | Role |
|---|---|
hooks/register.tsx | the hooks: bookkeeping, the pane, the band, the terminal views |
hooks/draw.ts | the desktop drawings (SVG) |
types/index.d.ts | the state contract |
tests/usage-panel.test.ts | behaviour tests |
install.sh | installs 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.
hooks/register.tsx 747 lines1import { 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}
747hooks/draw.ts 537 lines1// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
537types/index.d.ts 98 lines1export 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