Live usage pane beside the chat: last prompt tokens, API-equivalent spend, context fill, tool calls and session time

A live usage pane beside the chat. It shows, updating as you work:

<img src="../../docs/media/usage-pane.png" alt="usage pane: usage, prompts, tool calls and subagents" width="300">
/plugin install usage-pane --marketplace lambdamax/claude-code-mods
/usage-pane toggles the pane. It starts closed in every new session. It docks beside the transcript in fullscreen layouts and sits above the prompt otherwise.
$.state); nothing is written to disk and nothing leaves your machine.claude plugin validate .
claude plugin test .
hooks/register.tsx 594 lines1import { atom, read, update } from 'claude-code'
2import type { AgentStatus, EngineInterface, Register, RenderChildren, Timer } from 'claude-code'
3
4import type { AgentRow, CallRow, ContextFill, PromptTokens, ToolTally } from '../types'
5
6// The usage pane sits beside the chat: docked in fullscreen, above the prompt
7// otherwise. It starts closed in every new session; the session clock on it
8// ticks once a second while it is open.
9const PANE = 'usage-pane'
10const TITLE = 'Usage'
11const REFRESH_MS = 1000
12// The dock width asked for; a width the person drags the dock to wins.
13const PANE_COLUMNS = 38
14
15// The rows each section shows: the usage cards, then the latest prompts, tool calls and
16// subagents, one row each.
17const USAGE_ROWS = 5
18const PROMPT_ROWS = 6
19const CALL_ROWS = 6
20const AGENT_ROWS = 6
21// How long a finished tool call stays listed; a running one stays until it ends.
22const CALL_KEEP_MS = 2 * 60 * 1000
23// A section's frame lines, the top one carrying its title, and a blank row inside each.
24const SECTION_CHROME = 4
25// Every section is as tall as the tallest needs, however much or little it holds; a
26// shorter one's spare rows fall below its content.
27const SECTION_HEIGHT = Math.max(USAGE_ROWS, PROMPT_ROWS, CALL_ROWS, AGENT_ROWS) + SECTION_CHROME
28// The usage cards' labels, padded past the longest (CONTEXT, SESSION) so the values
29// line up after them, two spaces clear.
30const LABEL_WIDTH = 8
31// The context bar's length, at the right end of its card, and the least space kept
32// between it and the percent; the bar is shorter where the card is.
33const BAR_WIDTH = 11
34const BAR_GAP = 3
35// How many of each are kept; more than are shown, so nothing jumps as rows go.
36const KEPT = 20
37
38// The panel's accent (the theme's Claude orange), the purple of the tags set into the
39// frames' top right, and the context bar's track.
40const ACCENT = 'claude'
41const TAG = 'rgb(200,180,240)'
42const TRACK = 'rgb(70,70,66)'
43
44const promptTokens = atom({ plugin: 'usage-pane', key: 'promptTokens' } as const, null as PromptTokens | null)
45const costUsd = atom({ plugin: 'usage-pane', key: 'costUsd' } as const, 0)
46const context = atom({ plugin: 'usage-pane', key: 'context' } as const, null as ContextFill | null)
47const tools = atom({ plugin: 'usage-pane', key: 'tools' } as const, { total: 0, byName: {} } as ToolTally)
48const prompts = atom({ plugin: 'usage-pane', key: 'prompts' } as const, [] as string[])
49const promptTotal = atom({ plugin: 'usage-pane', key: 'promptTotal' } as const, 0)
50const calls = atom({ plugin: 'usage-pane', key: 'calls' } as const, [] as CallRow[])
51const agents = atom({ plugin: 'usage-pane', key: 'agents' } as const, [] as AgentRow[])
52const startedAt = atom({ plugin: 'usage-pane', key: 'startedAt' } as const, 0)
53const now = atom({ plugin: 'usage-pane', key: 'now' } as const, 0)
54const isShown = atom({ plugin: 'usage-pane', key: 'isShown' } as const, false)
55const isWorking = atom({ plugin: 'usage-pane', key: 'isWorking' } as const, false)
56
57export const formatTokens = (n: number): string => {
58 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 1 : 2)}M`
59 if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`
60 return String(n)
61}
62
63export const formatUsd = (usd: number): string =>
64 usd > 0 && usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`
65
66export const formatElapsed = (ms: number): string => {
67 const total = Math.max(0, Math.floor(ms / 1000))
68 const h = Math.floor(total / 3600)
69 const m = Math.floor((total % 3600) / 60)
70 const s = total % 60
71 const pad = (v: number) => String(v).padStart(2, '0')
72 return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
73}
74
75// A model id as people say it: claude-opus-5-5 is Opus 5.5, a [1m] context suffix 1M.
76export const formatModel = (id: string): string => {
77 const isLong = /\[1m\]$/i.test(id)
78 const parts = id.replace(/\[1m\]$/i, '').replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-')
79 const family = parts.filter(p => !/^\d+$/.test(p)).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ')
80 const version = parts.filter(p => /^\d+$/.test(p)).join('.')
81 return [family, version].filter(Boolean).join(' ') + (isLong ? ' · 1M' : '')
82}
83
84// Filled cells of a bar `width` wide for `percent`, held to 0..width.
85export const filledCells = (percent: number, width: number): number =>
86 Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
87
88// The longest title, and the longest tag beside it, that fit `room` cells with a space
89// between: a shorter title keeps a tag, and only when none fits is the tag dropped.
90// Narrow panes get the short forms.
91export const heading = (room: number, titles: string[], tags: string[]) => {
92 for (const title of titles) {
93 for (const tag of tags) if (title.length + 1 + tag.length <= room) return { title, tag }
94 }
95 return { title: titles.find(title => title.length <= room) ?? titles.at(-1) ?? '', tag: '' }
96}
97
98// The sections' titles, longest form first.
99const USAGE_TITLES = ['Usage']
100const PROMPT_TITLES = ['Prompts']
101const CALL_TITLES = ['Tool calls', 'Tools']
102const AGENT_TITLES = ['Subagents', 'Agents']
103
104// A section's top frame line, `width` cells, with its title set into the border:
105// `╭─ Prompts 12 ─────── latest ─╮`. The longest title and tag that leave a dash
106// between them; the tag gives way before the title is shortened.
107export const frameTop = (width: number, titles: string[], count: string, tags: string[]) => {
108 const countPart = count === '' ? '' : ` ${count}`
109 // `╭─ ` and ` ─╮` take 6 cells, and the dash run between title and tag ` ─ ` 3 at least.
110 const { title, tag } = heading(width - 8 - countPart.length, titles, tags)
111 const used = 3 + title.length + countPart.length + 1 + (tag === '' ? 2 : 1 + tag.length + 3)
112 return { title, count: countPart, fill: '─'.repeat(Math.max(1, width - used)), tag }
113}
114
115// What a frameTop line reads, end to end.
116export const frameTopText = (top: ReturnType<typeof frameTop>) =>
117 `╭─ ${top.title}${top.count} ${top.fill}${top.tag === '' ? '─╮' : ` ${top.tag} ─╮`}`
118
119// A prompt as one line, the way the pane lists it and finds its row.
120const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
121
122// Cuts a line to `width` cells, an ellipsis where it was cut.
123export const clip = (text: string, width: number): string =>
124 text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
125
126// The argument that says what a call is about: a file by its name, a command's first line.
127const DETAIL_KEYS = ['file_path', 'notebook_path', 'path', 'command', 'pattern', 'url', 'query', 'skill', 'description', 'prompt']
128
129export const callDetail = (args: Record<string, unknown>): string => {
130 for (const key of DETAIL_KEYS) {
131 const value = args[key]
132 if (typeof value !== 'string' || value.trim() === '') continue
133 const line = value.trim().split('\n')[0] ?? ''
134 return key.endsWith('path') ? (line.split('/').pop() ?? line) : line
135 }
136 return ''
137}
138
139// Opens the pane and remembers it is wanted, so a reload keeps the clock going.
140async function openPane($: EngineInterface) {
141 await update($, isShown, () => true)
142 await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
143}
144
145async function isPaneOpen($: EngineInterface) {
146 return (await $.ui.panes()).some(pane => pane.id === PANE)
147}
148
149// Moves the clock the pane reads, which redraws it, while the pane is wanted.
150async function tickClock($: EngineInterface) {
151 if (await read($, isShown)) {
152 await syncAgents($)
153 const at = await $.clock.now()
154 await update($, now, () => at)
155 }
156}
157
158// A row's status for where the engine says an agent's loop stands between its turns:
159// held on its own background work or on a message is waiting, ended is done or failed.
160// A running loop is left to the turn events, which see a turn start and end first.
161export const statusFromEngine = (status: AgentStatus): AgentRow['status'] | undefined =>
162 status === 'waiting' || status === 'idle' || status === 'pending' ? 'waiting'
163 : status === 'completed' ? 'done'
164 : status === 'failed' || status === 'killed' ? 'failed'
165 : undefined
166
167// Brings the listed subagents' rows in line with the engine's own record of them.
168async function syncAgents($: EngineInterface) {
169 const rows = await read($, agents)
170 if (rows.length === 0) return
171 const listed = await $.agent.list().catch(() => undefined)
172 if (listed === undefined) return
173 const statusOf = new Map(listed.map(info => [info.id, statusFromEngine(info.status)]))
174 if (!rows.some(row => { const status = statusOf.get(row.id); return status !== undefined && status !== row.status })) return
175 const at = await $.clock.now()
176 await update($, agents, list =>
177 list.map(row => {
178 const status = statusOf.get(row.id)
179 if (status === undefined || status === row.status) return row
180 return { ...row, status, endedAt: status === 'waiting' ? undefined : (row.endedAt ?? at) }
181 }),
182 ).catch(() => undefined)
183}
184
185let tick: Timer | undefined
186
187// Marks a subagent running or done; done stops its clock, running again starts it on.
188// The engine's record then tells done from waiting (see syncAgents).
189async function setAgentStatus($: EngineInterface, id: string, status: AgentRow['status']) {
190 const at = await $.clock.now()
191 await update($, agents, list =>
192 list.map(one => (one.id === id ? { ...one, status, endedAt: status === 'running' || status === 'waiting' ? undefined : at } : one)),
193 ).catch(() => undefined)
194}
195
196// Each prompt's row in the transcript, by its text as the pane shows it: the id
197// `$.ui.scroll` takes to bring that message into view. Learned as the rows are drawn;
198// the same text sent twice points at the latest.
199const rowOfPrompt = new Map<string, string>()
200
201// Scrolls the transcript to a row (a prompt's message, a tool call's row by its
202// tool_use_id), or says why it can't.
203async function showRow($: EngineInterface, requestId: string, what: string) {
204 const moved = await $.ui.scroll({ to: { requestId }, block: 'start' }).catch(() => ({ deny: 'failed' }))
205 // Refused outside fullscreen, once the row has left the rows the transcript keeps
206 // drawn, and for a subagent's call, whose row is in that agent's transcript.
207 if (moved.deny) $.ui.toast(`Couldn't scroll to that ${what}.`)
208}
209
210export const register: Register = on => {
211 on('session.start', async ($, e, next) => {
212 await $.command.register({
213 name: 'usage-pane',
214 description: 'Toggle the usage pane beside the chat',
215 })
216
217 // A new session (or a /clear) starts with the pane closed and every tally at zero;
218 // a reload of this module mid-session keeps both.
219 const usage = await $.session.usage()
220 if ((await read($, startedAt)) !== usage.startedAt) {
221 await update($, startedAt, () => usage.startedAt)
222 await update($, tools, () => ({ total: 0, byName: {} }))
223 await update($, promptTokens, () => null)
224 await update($, prompts, () => [])
225 await update($, promptTotal, () => 0)
226 await update($, calls, () => [])
227 await update($, agents, () => [])
228 await update($, isShown, () => false)
229 if (await isPaneOpen($)) await $.ui.close({ id: PANE })
230 }
231 await update($, costUsd, () => usage.cost?.usd ?? 0)
232 await update($, context, () => usage.context)
233
234 tick?.cancel()
235 tick = $.clock.every(REFRESH_MS, () => void tickClock($).catch(() => undefined))
236
237 return next(e)
238 })
239
240 on('command.run', { command: 'usage-pane' }, async $ => {
241 if (await isPaneOpen($)) {
242 await update($, isShown, () => false)
243 await $.ui.close({ id: PANE })
244 return { text: 'Usage pane hidden.' }
245 }
246 await openPane($)
247 return { text: 'Usage pane shown.' }
248 })
249
250 // Closed from its own mark or key: stay closed until asked again.
251 on('ui.close', async ($, e, next) => {
252 if (e.id === PANE && e.origin.kind === 'person') await update($, isShown, () => false)
253 return next(e)
254 }).catch(($, e, next) => next(e))
255
256 // The person's own prompts, newest first; slash commands and hidden prompts left out.
257 on('prompt.submit', async ($, e, next) => {
258 if (!e.command && !e.isMeta) {
259 const text = oneLine(e.text)
260 if (text !== '') {
261 await update($, prompts, list => [text, ...list].slice(0, KEPT)).catch(() => undefined)
262 await update($, promptTotal, n => n + 1).catch(() => undefined)
263 }
264 }
265 return next(e)
266 }).catch(($, e, next) => next(e))
267
268 // Learns where each of the person's prompts sits in the transcript; draws nothing.
269 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
270 if (e.props.origin.kind !== 'task-notification' && e.props.from === undefined) {
271 rowOfPrompt.set(oneLine(e.props.text), e.requestId)
272 }
273 return next(e)
274 })
275
276 on('turn.start', async ($, e, next) => {
277 if (e.agentId === undefined) {
278 await update($, promptTokens, () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }))
279 await update($, isWorking, () => true)
280 } else {
281 // A teammate woken by a message runs again.
282 await setAgentStatus($, e.agentId, 'running')
283 }
284
285 return next(e)
286 })
287
288 // Each subagent the session starts, newest first, once it has started.
289 on('agent.spawn', async ($, e, next) => {
290 const ran = await next(e)
291 const id = ran.agentId
292 if (id !== undefined) {
293 const startedAt = await $.clock.now()
294 const row: AgentRow = {
295 id,
296 toolUseId: e.tool_use_id,
297 type: e.subagentType,
298 description: oneLine(e.description),
299 model: ran.model ?? '',
300 tokens: 0,
301 status: 'running',
302 startedAt,
303 }
304 await update($, agents, list => [row, ...list.filter(one => one.id !== id)].slice(0, KEPT)).catch(() => undefined)
305 }
306 return ran
307 })
308
309 // Each model response of the prompt (subagents' included) adds its tokens as
310 // it lands, so the figure climbs live instead of waiting for the turn's end.
311 on('turn.step', async function* ($, e, next) {
312 const response = yield* next(e)
313 const u = response.usage
314 if (u) {
315 const agentId = e.agentId
316 if (agentId !== undefined) {
317 const spent = u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
318 await update($, agents, list =>
319 list.map(one => (one.id === agentId ? { ...one, tokens: one.tokens + spent } : one)),
320 ).catch(() => undefined)
321 }
322 await update($, promptTokens, t => ({
323 input: (t?.input ?? 0) + u.input_tokens,
324 output: (t?.output ?? 0) + u.output_tokens,
325 cacheRead: (t?.cacheRead ?? 0) + u.cache_read_input_tokens,
326 cacheWrite: (t?.cacheWrite ?? 0) + u.cache_creation_input_tokens,
327 })).catch(() => undefined)
328 }
329
330 return response
331 })
332
333 on('turn.complete', async ($, e, next) => {
334 if (e.agentId !== undefined) {
335 // Done for now; the engine's record, read after the turn ends, may say it waits.
336 await setAgentStatus($, e.agentId, 'done')
337 const ran = await next(e)
338 await syncAgents($).catch(() => undefined)
339 return ran
340 }
341 await update($, isWorking, () => false)
342 const usage = await $.session.usage()
343 await update($, costUsd, () => usage.cost?.usd ?? 0)
344 await update($, context, () => usage.context)
345
346 return next(e)
347 })
348
349 on('session.measure', async ($, e, next) => {
350 if (e.cost) {
351 const spent = e.cost.usd
352 await update($, costUsd, () => spent)
353 }
354 await update($, context, () => e.context)
355
356 return next(e)
357 })
358
359 on('tool.call', async ($, e, next) => {
360 // A tally that fails to write must never hold up the tool call itself.
361 const startedAt = await $.clock.now().catch(() => 0)
362 const row: CallRow = { id: e.tool_use_id, tool: e.tool, detail: callDetail(e), status: 'running', startedAt }
363 await update($, tools, t => ({
364 total: t.total + 1,
365 byName: { ...t.byName, [e.tool]: (t.byName[e.tool] ?? 0) + 1 },
366 })).catch(() => undefined)
367 await update($, calls, list => [row, ...list].slice(0, KEPT)).catch(() => undefined)
368
369 const settle = async (status: CallRow['status']) => {
370 const endedAt = await $.clock.now().catch(() => startedAt)
371 await update($, calls, list => list.map(one => (one.id === row.id ? { ...one, status, endedAt } : one))).catch(() => undefined)
372 }
373 try {
374 const ran = await next(e)
375 await settle('deny' in ran || ran.isError ? 'failed' : 'done')
376 return ran
377 } catch (error) {
378 await settle('failed')
379 throw error
380 }
381 }).catch(($, e, next) => next(e))
382
383 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
384 const { Box, Button, Text } = $.ui.resolve(e)
385 const t = await read($, promptTokens)
386 const ctx = await read($, context)
387 const began = await read($, startedAt)
388 // Read so the tick redraws the pane; the time itself is the engine's.
389 await read($, now)
390 const at = await $.clock.now()
391 const working = await read($, isWorking)
392 // Read at each draw, so a /model switch shows within the second.
393 const modelId = await $.session.model().catch(() => '')
394 const latestPrompts = (await read($, prompts)).slice(0, PROMPT_ROWS)
395 // Running calls, and those that ended in the last two minutes.
396 const latestCalls = (await read($, calls))
397 .filter(call => call.status === 'running' || (call.endedAt !== undefined && at - call.endedAt < CALL_KEEP_MS))
398 .slice(0, CALL_ROWS)
399 // How long each listed call ran (runs, while running), padded to the widest shown.
400 const callTimes = latestCalls.map(call => formatElapsed((call.endedAt ?? at) - call.startedAt))
401 const callTimeWidth = Math.max(0, ...callTimes.map(one => one.length))
402 const allAgents = await read($, agents)
403 const latestAgents = allAgents.slice(0, AGENT_ROWS)
404 const agentTokens = formatTokens(allAgents.reduce((sum, one) => sum + one.tokens, 0))
405 // Each listed subagent's tokens and how long it has run (ran, once done), padded to the
406 // widest of those shown so the two columns line up on the right.
407 const agentFigures = latestAgents.map(agent => ({
408 tokens: formatTokens(agent.tokens),
409 ran: formatElapsed((agent.endedAt ?? at) - agent.startedAt),
410 }))
411 const tokensWidth = Math.max(0, ...agentFigures.map(one => one.tokens.length))
412 const ranWidth = Math.max(0, ...agentFigures.map(one => one.ran.length))
413 const tokens = t ? t.input + t.output + t.cacheRead + t.cacheWrite : null
414 const percent = ctx?.percent
415
416 // A section's side lines and padding take 4 cells, and a card's padding 2 more, so its
417 // label lines up with the list rows below.
418 const cardRoom = e.props.bodyColumns - 6
419 // A list row's text, after the section's border and padding, the row's and the `› ` or `● `.
420 const rowRoom = Math.max(1, e.props.bodyColumns - 8)
421 // Cells a card has after its label and value, and the space after each.
422 const roomAfter = (value: string) => cardRoom - (LABEL_WIDTH + 1) - (value.length + 1)
423
424 // One card, one line: its label, the value beside it, and on the right the longest tag
425 // that fits, or what the card puts there instead (the context bar).
426 const card = (label: string, tags: string[], value: string, right?: RenderChildren) => {
427 const tag = tags.find(one => one.length <= roomAfter(value)) ?? ''
428 return (
429 <Box flexDirection="row" justifyContent="space-between" paddingX={1}>
430 <Text>
431 <Text dimColor weight="semibold">{label.padEnd(LABEL_WIDTH)} </Text>
432 <Text weight="bold">{value}</Text>
433 </Text>
434 {right ?? (tag !== '' && <Text dimColor weight="semibold">{tag}</Text>)}
435 </Box>
436 )
437 }
438
439 // The context bar sits at the right end of its card, well clear of the percent.
440 const contextValue = percent === undefined ? '—' : `${percent}%`
441 const barWidth = Math.max(1, Math.min(BAR_WIDTH, roomAfter(contextValue) + 1 - BAR_GAP))
442 const filled = filledCells(percent ?? 0, barWidth)
443
444 // Tool calls in all, beside the title; how many different tools made them, as its tag.
445 const tally = await read($, tools)
446 const callCount = String(tally.total)
447 const unique = Object.keys(tally.byName).length
448 const callTags = [`${unique} unique tools`, `${unique} unique`]
449 const promptCount = String(await read($, promptTotal))
450 const width = e.props.bodyColumns
451
452 // A section of a fixed height, framed in the accent: its title, count and tag set into
453 // the top line, its body between side lines drawn as tall as the section.
454 const section = (titles: string[], tags: string[], body: RenderChildren, count = '') => {
455 const height = SECTION_HEIGHT
456 const top = frameTop(width, titles, count, tags)
457 const side = Array.from({ length: height - 2 }, () => '│').join('\n')
458 return (
459 <Box flexDirection="column" height={height}>
460 <Text wrap="truncate-end">
461 <Text color={ACCENT}>╭─ </Text>
462 <Text color={ACCENT} weight="bold">{top.title}</Text>
463 {top.count !== '' && <Text weight="bold">{top.count}</Text>}
464 <Text color={ACCENT}> {top.fill}{top.tag === '' ? '─╮' : ''}</Text>
465 {top.tag !== '' && <Text color={TAG}> {top.tag}</Text>}
466 {top.tag !== '' && <Text color={ACCENT}> ─╮</Text>}
467 </Text>
468 <Box flexDirection="row" height={height - 2}>
469 <Text color={ACCENT}>{side}</Text>
470 <Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1} overflow="hidden">
471 {body}
472 </Box>
473 <Text color={ACCENT}>{side}</Text>
474 </Box>
475 <Text color={ACCENT}>{`╰${'─'.repeat(Math.max(0, width - 2))}╯`}</Text>
476 </Box>
477 )
478 }
479
480 // Working and waiting share the accent, waiting hollow; finished green, failed red.
481 const agentDot: Record<AgentRow['status'], { glyph: string; color: string }> = {
482 running: { glyph: '●', color: ACCENT },
483 waiting: { glyph: '○', color: ACCENT },
484 done: { glyph: '●', color: 'success' },
485 failed: { glyph: '●', color: 'error' },
486 }
487 const dot = (status: CallRow['status']) => (status === 'running' ? ACCENT : status === 'done' ? 'success' : 'error')
488
489 return (
490 <Box flexDirection="column">
491 {section(
492 USAGE_TITLES,
493 [working ? '● working' : 'idle'],
494 <Box flexDirection="column">
495 {card('MODEL', [], modelId === '' ? '—' : formatModel(modelId))}
496 {card('TOKENS', ['LAST PROMPT', 'LAST'], tokens === null ? '—' : formatTokens(tokens))}
497 {card('SPEND', ['API'], formatUsd(await read($, costUsd)))}
498 {card(
499 'CONTEXT',
500 [],
501 contextValue,
502 <Text>
503 <Text color={ACCENT}>{'━'.repeat(filled)}</Text>
504 <Text color={TRACK}>{'━'.repeat(barWidth - filled)}</Text>
505 </Text>,
506 )}
507 {card('SESSION', [], began > 0 ? formatElapsed(at - began) : '—')}
508 </Box>,
509 )}
510 {section(
511 PROMPT_TITLES,
512 ['latest'],
513 <Box flexDirection="column" paddingX={1}>
514 {latestPrompts.length === 0 && <Text dimColor>No prompts yet.</Text>}
515 {latestPrompts.map(text => {
516 const row = rowOfPrompt.get(text)
517 // Rows are plain lines until their message has been drawn in the transcript.
518 return row === undefined ? (
519 <Text wrap="truncate-end">
520 <Text color={ACCENT}>› </Text>
521 {text}
522 </Text>
523 ) : (
524 <Button key={`prompt:${row}`} plain onPress={() => void showRow($, row, 'prompt')}>
525 <Text color={ACCENT}>› </Text>
526 {clip(text, rowRoom)}
527 </Button>
528 )
529 })}
530 </Box>,
531 promptCount,
532 )}
533 {section(
534 CALL_TITLES,
535 callTags,
536 <Box flexDirection="column" paddingX={1}>
537 {latestCalls.length === 0 && (
538 <Text dimColor>{tally.total === 0 ? 'No tool calls yet.' : 'No tool calls in the last 2 minutes.'}</Text>
539 )}
540 {latestCalls.map((call, i) => {
541 const ran = (callTimes[i] ?? '').padStart(callTimeWidth)
542 // The tool and what it was about, cut to leave the time its room on the right.
543 const room = rowRoom - ran.length - 1
544 const tool = clip(call.tool, room)
545 const detailRoom = room - tool.length - 1
546 const detail = call.detail === '' || detailRoom < 2 ? '' : clip(call.detail, detailRoom)
547 return (
548 <Box flexDirection="row" justifyContent="space-between">
549 <Button key={`call:${call.id}`} plain onPress={() => void showRow($, call.id, 'tool call')}>
550 <Text color={dot(call.status)}>● </Text>
551 {tool}
552 {detail !== '' && <Text dimColor> {detail}</Text>}
553 </Button>
554 <Text dimColor>{ran}</Text>
555 </Box>
556 )
557 })}
558 </Box>,
559 callCount,
560 )}
561 {section(
562 AGENT_TITLES,
563 [`${agentTokens} tokens`, agentTokens],
564 <Box flexDirection="column" paddingX={1}>
565 {latestAgents.length === 0 && <Text dimColor>No subagents yet.</Text>}
566 {latestAgents.map((agent, i) => {
567 const figures = agentFigures[i] ?? { tokens: '', ran: '' }
568 const right = `${figures.tokens.padStart(tokensWidth)} ${figures.ran.padStart(ranWidth)}`
569 // The type and its model, cut to leave the right side its room: the model's version
570 // goes first, then the model, and the type is cut last.
571 const room = rowRoom - right.length - 1
572 const type = clip(agent.type, room)
573 const modelRoom = room - type.length - 1
574 const modelName = agent.model === '' ? '' : formatModel(agent.model)
575 const model = [modelName, modelName.split(' ')[0] ?? ''].find(one => one !== '' && one.length <= modelRoom) ?? ''
576 return (
577 <Box flexDirection="row" justifyContent="space-between">
578 <Button key={`agent:${agent.id}`} plain onPress={() => void showRow($, agent.toolUseId, 'subagent')}>
579 <Text color={agentDot[agent.status].color}>{agentDot[agent.status].glyph} </Text>
580 {type}
581 {model !== '' && <Text color={TAG}> {model}</Text>}
582 </Button>
583 <Text dimColor>{right}</Text>
584 </Box>
585 )
586 })}
587 </Box>,
588 String(allAgents.length),
589 )}
590 </Box>
591 )
592 })
593}
594types/index.d.ts 57 lines1export type PromptTokens = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6}
7
8export type ContextFill = { tokens?: number; window: number; percent?: number }
9
10export type ToolTally = { total: number; byName: Record<string, number> }
11
12// One tool call in the pane's list: what ran, what it was about, how it went, and when it
13// started and (once settled) ended, by the engine's clock.
14export type CallRow = {
15 id: string
16 tool: string
17 detail: string
18 status: 'running' | 'done' | 'failed'
19 startedAt: number
20 endedAt?: number
21}
22
23// One subagent in the pane's list: its id, the Agent call that started it, its type, task
24// and the model it runs on, the tokens its own model calls used, whether it is in a turn,
25// waiting between turns, done or failed, and when it started and (once ended) ended, by the
26// engine's clock.
27export type AgentRow = {
28 id: string
29 toolUseId: string
30 type: string
31 description: string
32 model: string
33 tokens: number
34 status: 'running' | 'waiting' | 'done' | 'failed'
35 startedAt: number
36 endedAt?: number
37}
38
39declare module 'claude-code' {
40 interface PluginState {
41 'usage-pane': {
42 promptTokens: PromptTokens | null
43 costUsd: number
44 context: ContextFill | null
45 tools: ToolTally
46 prompts: string[]
47 promptTotal: number
48 calls: CallRow[]
49 agents: AgentRow[]
50 startedAt: number
51 now: number
52 isShown: boolean
53 isWorking: boolean
54 }
55 }
56}
57