SLOPSHOPPER

usage-pane

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

newpanerowsguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-pane
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─ Usage ──────────────────────────────────… │ ┃ │ │ │ │ │ │ │ │ ⏺ Read(src/auth.ts) │ ┃ MODEL Opus 5.5 ⎿ Read 6 lines │ ┃ TOKENS 0 LAST ⏺ Update(src/auth.ts) │ ┃ SPEND $0.42 ⎿ Added 2 lines, removed 1 line │ ┃ CONTEXT 49% ━━━━━ ⏺ Bash(bun test) │ ┃ SESSION 30:00 ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ┃ ───────────╯ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭─ Prompts 1 ──────────────────────────────… │ ┃ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ › /usage-pane │ ┃ prompt:msg-1 ⎿ usage-pane: Usage pane shown. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ╰─────────────────────────────────────────── │ ┃ ───────────╯ │ ┃ ╭─ Tool calls 9 ────────────────────── 5 un… │ ┃ │ │ │ │ │ │ │ │ │ ┃ call:toolu_09 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Usage
╭─ Usage ─────────────────────────────────────── idle ─╮ │ │ │ │ │ │ │ │ MODEL Opus 5.5 TOKENS 0 LAST SPEND $0.42 CONTEXT 49% ━━━━━ SESSION 30:00 ╰──────────────────────────────────────────────────────╯ ╭─ Prompts 1 ───────────────────────────────── latest ─╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ prompt:msg-1 ╰──────────────────────────────────────────────────────╯ ╭─ Tool calls 9 ────────────────────── 5 unique tools ─╮ │ │ │ │ │ │ │ │ call:toolu_09 call:toolu_08 call:toolu_07 call:toolu_06 call:toolu_05 call:toolu_04 ╰──────────────────────────────────────────────────────╯ ╭─ Subagents 0 ───────────────────────────── 0 tokens ─╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ No subagents yet. ╰──────────────────────────────────────────────────────╯
README

usage-pane

Claude Code mod License: MIT

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

  • Usage: model, tokens of the last prompt, API-equivalent spend, context fill, session clock
  • Prompts: the latest prompts; select one to scroll the transcript to it
  • Tool calls: running and recent calls with how long each took
  • Subagents: every subagent with its model, tokens and clock; agents waiting on background work show a hollow dot and keep their clock running

usage pane tracking background subagents

<img src="../../docs/media/usage-pane.png" alt="usage pane: usage, prompts, tool calls and subagents" width="300">

Install

/plugin install usage-pane --marketplace lambdamax/claude-code-mods

Use

/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.

Notes

  • Spend is the API-equivalent price of the tokens used, not what a subscription plan bills.
  • The pane keeps its state in the session ($.state); nothing is written to disk and nothing leaves your machine.

Develop

claude plugin validate .
claude plugin test .

MIT

Source 2 files
hooks/register.tsx 594 lines
1import { 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}
594
types/index.d.ts 57 lines
1export 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