(superseded by rd-ledger) A live cost chip in the prompt footer and a /spend Usage pane: the session's cost, context by category, rate limits, tokens and cache…

Superseded on 2026-10-13 by rd-ledger's Usage pane; kept for reference. The Usage pane,
/spendand the footer cost chip now live in the rd-ledger mod (/rd usage,/spend, and one footer chip reading$123 ⧗2.4hthat opens Usage with the R&D ledger as the tab beside it), so the two panes can sit side by side. Its estimates price cache writes by the TTL in force rather than a fixed 1.25×. Disable this mod when rd-ledger is enabled, or the footer shows two cost chips and both register/spend.
What this session has cost and used, live, without typing /usage and getting a card dropped into the transcript. A small chip in the prompt footer reads $123; click it (or type /spend) and a Usage pane opens with the cost, time, context window, rate limits, per-model tokens and cache hit, what the subagents spent, and the lines the session changed. Nothing lands in the transcript, nothing is written to disk, and nothing is sent anywhere.
$123, the session's cost (cents under $100, $12.34; whole dollars from there). With the chipContext option on it adds how full the context window is: $123 · ctx 12%. Before the first cost reading it reads $ --; on a host with no cost ledger it is not drawn (with chipContext on, ctx 12% alone). On the terminal it is the same chip on the hint line under the prompt, after the engine's own hint./spend):/cost and the status line show), Session (how long since the session began), Active (time the main loop spent answering), API (every model request's wall time, across every loop, so parallel subagents add up past the session's length), Turns, Requests, and Agent turns once a subagent has run.120k of 1.0M (12%), then, once the pane has read the breakdown (on open and on Refresh), tokens left until compaction and up to 8 categories (Messages, System prompt, ...) with their tokens and share of the window; free space and the compaction buffer come last, dim.5-hour, 7-day, spend limit), its percent used and when it resets in local time; none reported off a subscription.--, never a guess.main loop 40% · subagents 60% by estimated cost (by tokens when nothing is priced), then the top five subagent types (builder 35% · verifier 15% · scout 5%).+4,321 −321 lines · 42 edits, counted from the Edit, Write and NotebookEdit results (a refused or failed edit counts nothing).r) re-reads the figures and the breakdown; Close (c, or Esc).From the marketplace (terminal session):
/plugin install usage-gauge --marketplace Joseph-Ayoub/claude-mods
While developing, load it from this folder:
claude --plugin-dir /absolute/path/to/claude-mods/usage-gauge
or install it at user scope from the local marketplace (claude plugin install usage-gauge@joseph-mods --scope user) so every session the desktop app or the terminal starts loads it (see mods/README.md).
| Option | Default | What it does |
|---|---|---|
footerButton | true | Draw the footer figure as a button (a grey chip on the desktop) that opens the Usage pane; off, it is dim text and /spend opens the pane. |
chipContext | false | Also show the context fill in the chip ($123 · ctx 12%). Off by default so the footer strip stays short (see Limits); the figure is in the pane. |
| Hook | Why |
|---|---|
session.start | registers /spend; reads $.session.usage() (start time, context, rate limits, cost); records when counting began, keeping an earlier value so a reload does not reset the counts |
session.measure | keeps the latest context, rate limits and cost as the engine pushes them |
turn.step | one model request in any loop: passes the response through untouched (yield* next(e), called once) and then counts its tokens by model and by loop, and its wall time; a subagent's type is asked of $.agent.list() once per new agent id |
turn.complete | main loop: one turn and its duration (Active); a subagent: one agent turn |
tool.call Edit, Write, NotebookEdit | after the tool ran, counts the added and removed lines of its result's patch; the result is returned as the tool gave it, and the tool never runs twice |
command.run /spend | opens the focused pane for your own origins (typed at the prompt, the Remote Control bridge, the SDK host behind the desktop app); where no pane can be placed (claude -p) answers a short plain table; a peer session, a plugin, a schedule or an unknown origin gets one summary line and no pane |
ui.render SessionMode (desktop), PromptHint (terminal) | the chip |
ui.render Pane usage-gauge | the pane, drawn from the plugin's state only |
Every accounting hook only observes: it hands the event on unchanged and keeps its bookkeeping inside a try/catch. The counts live in the plugin's state, which a hot reload keeps. No prompt, answer, file path or tool argument is ever stored or drawn; model ids, agent types and category names are drawn as one capped line (control characters, escape codes and line breaks become spaces; bidi, zero-width and tag characters are dropped), so none can forge lines in the pane or the /spend table.
claude plugin validate mods/usage-gauge and claude plugin test mods/usage-gauge. The tests cover:
footerButton and chipContext off, and a press opening the pane;/spend from the person, from a plugin, headless and unplaced;/usage) are drawn by the desktop app itself, not by the engine, and no mod can draw inside them. So the chip sits right beside that icon in the footer instead, and opens its own pane. /usage and /cost are left alone.overflow: hidden; text-overflow: ellipsis) beside its own controls; once the chips overflow it, the end of the row is cut to … (the full text shows in a tooltip). Plugins loaded earlier draw first, so with mem-gauge's memory and disk chips ahead of this one, this chip is the first to go. That is why the label is short and the context is off by default. If … still shows: widen the window, keep chipContext off, or set mem-gauge's footerButtons off (plain text takes no chip padding)./spend is the way in. That a click reaches the chip is proven by the test kit; confirm it with a live click.hooks/register.tsx 565 lines1// usage-gauge: what this session has cost and used, live, without /usage
2// dropping a card into the transcript. A chip in the prompt footer (beside the
3// desktop's context-window icon, which is the app's own and no mod can draw
4// inside) reads `$123.45 · ctx 12%` and opens a Usage pane: the engine's cost,
5// session and API time, the context window by category, rate limits, tokens
6// and cache hit per model with list-price estimates, the subagents' share, and
7// the lines the session's edits changed.
8//
9// Every accounting hook only observes: it passes the event on unchanged, keeps
10// its bookkeeping inside try/catch, and writes $.state, which a hot reload
11// keeps. Drawings read $.state only. Nothing is written to disk, run, fetched
12// or sent; no prompt, answer, path or tool argument is stored.
13//
14// The engine requires `$` to be handed only to functions declared at the top of
15// this file, so every function that touches `$` lives here; the formatting and
16// the arithmetic are pure and live in format.ts and prices.ts.
17
18import { atom, read, update } from 'claude-code'
19import type { EngineInterface, Register, SessionUsageArgs, TurnStepResult } from 'claude-code'
20
21import type { View } from './format'
22import {
23 CAP,
24 PRICE_NOTE,
25 addRequest,
26 BAR_CELLS,
27 BAR_HEIGHT_PX,
28 BAR_WIDTH_PX,
29 barParts,
30 boundedKey,
31 breakdownFrom,
32 categoryRows,
33 changesLine,
34 chipLabel,
35 compactReport,
36 contextLine,
37 contributionLines,
38 countPatch,
39 emptyCounts,
40 emptyTotals,
41 formatCount,
42 formatDuration,
43 formatPercent,
44 formatTokens,
45 formatUsd,
46 keepLatest,
47 modelLines,
48 oneLine,
49 ownGet,
50 rateLine,
51 pillSvg,
52 sessionMs,
53 summaryLine,
54 unknownUsage,
55 untilCompaction,
56 usageFrom,
57 viewNow,
58} from './format'
59import type { ModelLine } from './format'
60import { estimateUsd } from './prices'
61
62type Dollar = EngineInterface
63
64// $.state values, one literal plugin/key reference each, so `claude plugin
65// validate` holds them to types/index.d.ts. Written only from hooks and
66// handlers, never while drawing.
67const usageRef = atom({ plugin: 'usage-gauge', key: 'usage' } as const, null)
68const breakdownRef = atom({ plugin: 'usage-gauge', key: 'breakdown' } as const, null)
69const totalsRef = atom({ plugin: 'usage-gauge', key: 'totals' } as const, emptyTotals())
70const modelsRef = atom({ plugin: 'usage-gauge', key: 'models' } as const, {})
71const loopsRef = atom({ plugin: 'usage-gauge', key: 'loops' } as const, { main: emptyCounts(), agents: {} })
72const agentTypesRef = atom({ plugin: 'usage-gauge', key: 'agentTypes' } as const, {})
73const refreshingRef = atom({ plugin: 'usage-gauge', key: 'refreshing' } as const, false)
74
75const PANE = 'usage-gauge'
76/** The agent ids whose type is remembered; older ones are looked up again if they ever step. */
77const MAX_AGENT_IDS = 500
78/** Distinct models and agent types kept apart; any more fold into `other`. */
79const MAX_MODELS = 50
80const MAX_AGENT_TYPES = 50
81/** Category rows the pane lists. */
82const MAX_CATEGORIES = 8
83/**
84 * The origins that count as the person: their own Enter, the Remote Control
85 * bridge, and the SDK host (the desktop Code tab drives the engine through
86 * it). Only these open (and focus) the pane. Any other origin, and any kind
87 * this list does not know, gets one summary line, so no peer session, plugin
88 * or schedule can pop a pane in front of the person.
89 */
90const PANE_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
91
92// Per-load options, not state: register() sets them and a reload re-reads them.
93let config = { footerButton: true, chipContext: false }
94// The context percent the bar last drew, so the next drawing animates from it (a load starts from empty).
95let lastBarPercent: number | null = null
96
97/** An error's kind only: a message could carry a path or a value. */
98const kindOf = (err: unknown): string => (err instanceof Error && err.name ? err.name : 'error')
99
100/**
101 * Reads the status line's figures (and, with `breakdown`, the window by
102 * category) into $.state. A refused read leaves every figure unknown rather
103 * than absent, so the pane draws `--` instead of waiting forever.
104 */
105async function readUsage($: Dollar, args?: SessionUsageArgs): Promise<void> {
106 const now = await $.clock.now()
107 let reading
108 try {
109 reading = await $.session.usage(args)
110 } catch {
111 await update($, usageRef, prev => prev ?? unknownUsage(now))
112 return
113 }
114 await update($, usageRef, prev => usageFrom(reading, prev, now, true))
115 if (args?.breakdown) await update($, breakdownRef, () => breakdownFrom(reading.context.breakdown))
116}
117
118/** Pane open and Refresh: the figures and the breakdown (`summary`: estimated locally, nothing is sent). */
119async function refresh($: Dollar, columns?: number): Promise<void> {
120 if (await read($, refreshingRef)) return
121 await update($, refreshingRef, () => true)
122 try {
123 await readUsage($, columns === undefined ? { breakdown: 'summary' } : { breakdown: 'summary', columns })
124 } finally {
125 await update($, refreshingRef, () => false).catch(() => undefined)
126 }
127}
128
129/** Opens the Usage pane (from the chip or /spend) and reads the breakdown behind it. */
130async function openPane($: Dollar) {
131 const opened = await $.ui.open({ id: PANE, title: 'Usage', focus: true, closeOnEscape: true })
132 if (opened.isPlaced) void refresh($).catch(() => undefined)
133 return opened
134}
135
136/** The type an agent id was spawned as, looked up once and remembered; `agent` when it is not listed. */
137async function agentTypeOf($: Dollar, agentId: string): Promise<string> {
138 const id = agentId.slice(0, 128)
139 const known = ownGet(await read($, agentTypesRef), id)
140 if (known !== undefined) return known
141 let type = 'agent'
142 try {
143 const hit = (await $.agent.list()).find(a => a.id === agentId)
144 if (hit && typeof hit.type === 'string') type = oneLine(hit.type, CAP.agentType) || 'agent'
145 } catch {
146 // Unknown stays `agent`; it is remembered so the list is not asked again for it.
147 }
148 await update($, agentTypesRef, m => keepLatest({ ...m, [id]: type }, MAX_AGENT_IDS))
149 return type
150}
151
152/** One model request, counted under its model and its loop. */
153async function recordStep($: Dollar, agentId: string | undefined, model: string, r: TurnStepResult | undefined, t0: number | null): Promise<void> {
154 const now = await $.clock.now()
155 const apiMs = t0 === null ? 0 : Math.max(0, now - t0)
156 const usage = r?.usage ?? null
157 const rawModel = typeof usage?.model === 'string' && usage.model ? usage.model : model
158 const counts = usage
159 ? { input: usage.input_tokens, output: usage.output_tokens, cacheRead: usage.cache_read_input_tokens, cacheWrite: usage.cache_creation_input_tokens }
160 : null
161 // A request with no usage (failed, interrupted) has no tokens to price.
162 const est = counts ? estimateUsd(rawModel, counts) : 0
163 const modelKey = oneLine(rawModel, CAP.model) || 'unknown'
164 const type = agentId === undefined ? null : await agentTypeOf($, agentId)
165
166 await update($, modelsRef, m => {
167 const key = boundedKey(m, modelKey, MAX_MODELS)
168 return { ...m, [key]: addRequest(ownGet(m, key), counts, apiMs, est) }
169 })
170 await update($, loopsRef, l => {
171 if (type === null) return { ...l, main: addRequest(l.main, counts, apiMs, est) }
172 const key = boundedKey(l.agents, type, MAX_AGENT_TYPES)
173 return { ...l, agents: { ...l.agents, [key]: addRequest(ownGet(l.agents, key), counts, apiMs, est) } }
174 })
175 await update($, totalsRef, t => ({ ...t, loadedAt: t.loadedAt || now, updatedAt: now, requests: t.requests + 1, apiMs: t.apiMs + apiMs }))
176}
177
178/** A turn's end: the main loop's count and wall time, or a subagent's count. */
179async function recordTurn($: Dollar, agentId: string | undefined, durationMs: number): Promise<void> {
180 const now = await $.clock.now()
181 const ms = Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 0
182 await update($, totalsRef, t =>
183 agentId === undefined
184 ? { ...t, loadedAt: t.loadedAt || now, updatedAt: now, turns: t.turns + 1, activeMs: t.activeMs + ms }
185 : { ...t, loadedAt: t.loadedAt || now, updatedAt: now, agentTurns: t.agentTurns + 1 },
186 )
187}
188
189/** An Edit / Write / NotebookEdit result's lines; a denied or failed call counts nothing. */
190async function recordEdit($: Dollar, ran: unknown): Promise<void> {
191 const counted = countPatch(ran)
192 if (!counted) return
193 const now = await $.clock.now()
194 await update($, totalsRef, t => ({
195 ...t,
196 loadedAt: t.loadedAt || now,
197 updatedAt: now,
198 linesAdded: t.linesAdded + counted.added,
199 linesRemoved: t.linesRemoved + counted.removed,
200 edits: t.edits + 1,
201 }))
202}
203
204/** Everything the pane and /spend draw. */
205async function readView($: Dollar): Promise<View> {
206 return {
207 usage: await read($, usageRef),
208 breakdown: await read($, breakdownRef),
209 totals: await read($, totalsRef),
210 models: await read($, modelsRef),
211 loops: await read($, loopsRef),
212 }
213}
214
215export const register: Register = (on, options) => {
216 config = {
217 footerButton: options['footerButton'] !== false,
218 chipContext: options['chipContext'] === true,
219 }
220
221 on('session.start', async ($, e, next) => {
222 try {
223 await $.command.register({
224 name: 'spend',
225 description: 'Open the Usage pane: cost, context, rate limits, models, subagents, code changes',
226 immediate: true,
227 })
228 } catch (err) {
229 $.ui.log(`usage-gauge: /spend not registered (${kindOf(err)})`, { to: 'debug' })
230 }
231 const result = await next(e)
232 try {
233 const now = await $.clock.now()
234 // A reload (or a second start) keeps the accumulators and when they began.
235 await update($, totalsRef, t => (t.loadedAt > 0 ? t : { ...t, loadedAt: now, updatedAt: Math.max(t.updatedAt, now) }))
236 // A load starts with no Refresh in flight: a reload mid-Refresh must not leave the button at Refreshing.
237 await update($, refreshingRef, () => false)
238 await readUsage($)
239 } catch {
240 // Observing only.
241 }
242 return result
243 })
244
245 on('session.measure', async ($, e, next) => {
246 try {
247 const now = await $.clock.now()
248 await update($, usageRef, prev => usageFrom(e, prev, now, false))
249 } catch {
250 // Observing only.
251 }
252 return next(e)
253 })
254
255 // One model request in any loop. Observing a stream: the chunks pass through
256 // untouched (`yield* next(e)`), `next(e)` is called exactly once, and nothing
257 // before it can throw (a hook that fails before calling it would be skipped,
258 // but one that fails after would cut the stream). Its wall time counts the
259 // request from send to its last chunk.
260 on('turn.step', async function* ($, e, next) {
261 let t0: number | null = null
262 try {
263 t0 = await $.clock.now()
264 } catch {
265 t0 = null
266 }
267 const result = yield* next(e)
268 try {
269 await recordStep($, e.agentId, e.model, result, t0)
270 } catch {
271 // Observing only.
272 }
273 return result
274 })
275
276 on('turn.complete', async ($, e, next) => {
277 try {
278 await recordTurn($, e.agentId, e.durationMs)
279 } catch {
280 // Observing only.
281 }
282 return next(e)
283 })
284
285 // The tool runs once, beneath, whatever this hook does: it counts after
286 // `next(e)` settled and returns that result unchanged.
287 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
288 const ran = await next(e)
289 try {
290 await recordEdit($, ran)
291 } catch {
292 // Observing only.
293 }
294 return ran
295 }).catch(($, e, next) =>
296 // Should the hook overrun anyway: inside a catch `next` is replay-safe, so
297 // once called it answers what the tool already returned without running it
298 // again, and uncalled it runs the tool once. Never a deny: an observer
299 // must not refuse a tool.
300 next(e),
301 )
302
303 // Read-only, so any origin may run it, but only the person's own origins open
304 // the focused pane. Where none can be placed (`claude -p`), the person gets
305 // the short table; any other origin gets one summary line.
306 on('command.run', { command: 'spend' }, async ($, e) => {
307 const surfaces = await $.session.surfaces().catch(() => [])
308 const isPerson = PANE_ORIGINS.has(e.origin.kind)
309 if (surfaces.length > 0 && isPerson) {
310 let isPlaced = false
311 try {
312 isPlaced = (await openPane($)).isPlaced
313 } catch {
314 isPlaced = false
315 }
316 // The pane is the answer: no transcript row (CommandRunResult.text is a transcript line).
317 if (isPlaced) return {}
318 }
319 const view = await readView($)
320 return { text: isPerson ? compactReport(view) : summaryLine(view) }
321 }).catch(async ($, e) => {
322 // Past its budget or failed: answer from the state rather than leave a
323 // registered command with no output.
324 const view = await readView($).catch(() => null)
325 if (!view) return { text: 'usage-gauge: no reading yet.' }
326 return { text: PANE_ORIGINS.has(e.origin.kind) ? compactReport(view) : summaryLine(view) }
327 })
328
329 // The chip on the terminal: the hint line under the prompt, after the
330 // engine's own line so its pills stay live. Only the terminal raises it.
331 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
332 const engine = await next(e)
333 if (e.surface !== 'terminal') return engine
334 const label = chipLabel(await read($, usageRef), config.chipContext)
335 if (!label) return engine
336 const { Box, Text, Button } = $.ui.resolve(e)
337 const open = () => void openPane($).catch(() => undefined)
338 return (
339 <Box flexDirection="row">
340 {engine}
341 <Box marginLeft={2} flexDirection="row">
342 {config.footerButton ? <Button key="usage-gauge" plain dimColor label={label} onPress={open} /> : <Text dimColor>{label}</Text>}
343 </Box>
344 </Box>
345 )
346 })
347
348 // The chip on the desktop: the prompt footer's mode row, right beside the
349 // context-window icon, the one site under the composer the desktop raises.
350 // That strip draws text and native chips only (no vector element, no Box
351 // hover or display) and lays every Button before every Text whatever the
352 // tree's order, so nothing here relies on order.
353 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
354 const engine = await next(e)
355 if (e.surface !== 'desktop') return engine
356 const label = chipLabel(await read($, usageRef), config.chipContext)
357 if (!label) return engine
358 const { Box, Text, Button } = $.ui.resolve(e)
359 const open = () => void openPane($).catch(() => undefined)
360 return (
361 <Box flexDirection="row" alignItems="center" gap={1}>
362 {config.footerButton ? <Button key="usage-gauge" plain label={label} onPress={open} /> : <Text dimColor>{label}</Text>}
363 {engine}
364 </Box>
365 )
366 })
367
368 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
369 const { Box, Text, Button } = $.ui.resolve(e)
370 const v = await readView($)
371 const isRefreshing = await read($, refreshingRef)
372 const cols = Math.max(24, Math.floor(e.props.bodyColumns))
373 const isNarrow = cols < 60
374
375 const controls = (
376 <Box key="controls" flexDirection="row" gap={2} marginTop={1}>
377 <Button
378 key="refresh"
379 label={isRefreshing ? 'Refreshing' : 'Refresh'}
380 hotkey="r"
381 dimColor={isRefreshing}
382 onPress={() => void refresh($, cols).catch(() => undefined)}
383 />
384 <Button key="close" label="Close" hotkey="c" role="dismiss" onPress={() => void $.ui.close({ id: PANE }).catch(() => undefined)} />
385 </Box>
386 )
387 if (!v.usage && v.totals.requests === 0) {
388 return (
389 <Box flexDirection="column">
390 <Text dimColor>Reading usage…</Text>
391 {controls}
392 </Box>
393 )
394 }
395
396 const now = viewNow(v)
397 const pct = v.usage?.context.percent ?? null
398 const t = v.totals
399
400 // Label left in a fixed column, figure flush with the pane's right edge: the
401 // value box grows to the rest of the row, so the figures line up on the
402 // right whatever the pane's width.
403 type Pair = { key: string; label: string; value: string; isDim?: boolean }
404 const headline: Pair[] = [
405 { key: 'cost', label: 'Cost', value: formatUsd(v.usage?.costUsd ?? null) },
406 { key: 'session', label: 'Session', value: formatDuration(sessionMs(v)) },
407 { key: 'active', label: 'Active', value: formatDuration(t.activeMs) },
408 { key: 'api', label: 'API', value: formatDuration(t.apiMs) },
409 { key: 'turns', label: 'Turns', value: formatCount(t.turns) },
410 { key: 'requests', label: 'Requests', value: formatCount(t.requests) },
411 ]
412 if (t.agentTurns > 0) headline.push({ key: 'agent-turns', label: 'Agent turns', value: formatCount(t.agentTurns) })
413 if (v.usage && !v.usage.hasCost) headline[0] = { key: 'cost', label: 'Cost', value: 'no ledger here', isDim: true }
414
415 const context: Pair[] = [{ key: 'window', label: 'Window', value: contextLine(v.usage) }]
416 const left = untilCompaction(v.breakdown)
417 if (left !== null) context.push({ key: 'until', label: 'Until compaction', value: formatTokens(left) })
418 for (const c of categoryRows(v.breakdown, MAX_CATEGORIES)) {
419 context.push({ key: `cat:${c.name}`, label: c.name, value: `${formatTokens(c.tokens)} · ${formatPercent(c.percent)}`, isDim: c.isDim })
420 }
421
422 const limits: Pair[] = (v.usage?.rateLimits ?? []).map((r, i) => {
423 const line = rateLine(r, now)
424 return { key: `rate:${i}`, label: line.label, value: line.value }
425 })
426
427 const labelWidth = isNarrow ? 14 : 20
428 const pairRows = (pairs: readonly Pair[]) =>
429 pairs.map(p => (
430 <Box key={p.key} flexDirection="row">
431 <Box width={labelWidth}>
432 <Text wrap="truncate" dimColor>
433 {p.label}
434 </Text>
435 </Box>
436 <Box flexGrow={1} justifyContent="flex-end">
437 <Text wrap="truncate" dimColor={p.isDim === true}>
438 {p.value}
439 </Text>
440 </Box>
441 </Box>
442 ))
443
444 const header = (
445 <Box key="header" flexDirection="column">
446 {pairRows(headline)}
447 </Box>
448 )
449
450 // The context fill as a small thin bar at the right of the Context title,
451 // flush with the pane's edge, so it sits just above the Window figure: on
452 // the terminal two runs of cells (the track dim); elsewhere a 2 px pill
453 // SVG that animates from the fill it last drew.
454 let contextBar
455 if (e.surface === 'terminal') {
456 const b = barParts(pct, Math.min(BAR_CELLS, cols - 10))
457 contextBar = (
458 <Box key="context-bar" flexDirection="row">
459 {b.filled !== '' && <Text>{b.filled}</Text>}
460 {b.track !== '' && <Text dimColor>{b.track}</Text>}
461 </Box>
462 )
463 } else {
464 const { Svg } = $.ui.resolve(e)
465 const shown = pct === null ? '--' : `${Math.round(pct)}%`
466 contextBar = <Svg source={pillSvg(pct, lastBarPercent)} alt={`Context ${shown} used`} width={BAR_WIDTH_PX} height={BAR_HEIGHT_PX} />
467 lastBarPercent = pct
468 }
469
470 // The model table: columns sized to the body; on a narrow pane only
471 // output, est. $ and share stay, and on a middling one the least telling
472 // columns drop until the model name has room.
473 type Col = { key: string; title: string; width: number; value: (l: ModelLine) => string }
474 const allCols: Col[] = [
475 { key: 'req', title: 'Req', width: 7, value: l => formatCount(l.requests) },
476 { key: 'in', title: 'Input', width: 9, value: l => formatTokens(l.input) },
477 { key: 'out', title: 'Output', width: 9, value: l => formatTokens(l.output) },
478 { key: 'cr', title: 'Cache rd', width: 10, value: l => formatTokens(l.cacheRead) },
479 { key: 'cw', title: 'Cache wr', width: 10, value: l => formatTokens(l.cacheWrite) },
480 { key: 'hit', title: 'Hit', width: 6, value: l => formatPercent(l.hit) },
481 // A total with an unpriced model in it is a floor, marked `+`, never a complete figure.
482 { key: 'est', title: 'Est. $', width: 11, value: l => (l.estUsd === null ? '--' : `${formatUsd(l.estUsd)}${l.isPartial ? '+' : ''}`) },
483 { key: 'share', title: 'Share', width: 7, value: l => (l.share === null ? '--' : formatPercent(l.share)) },
484 ]
485 let cols2 = isNarrow ? allCols.filter(c => c.key === 'out' || c.key === 'est' || c.key === 'share') : allCols
486 for (const drop of ['req', 'cw', 'in', 'cr']) {
487 if (isNarrow || cols - cols2.reduce((s, c) => s + c.width, 0) >= 14) break
488 cols2 = cols2.filter(c => c.key !== drop)
489 }
490 const nameWidth = Math.max(8, cols - cols2.reduce((s, c) => s + c.width, 0))
491 const { rows: modelRows, total } = modelLines(v.models)
492 const tableRow = (key: string, name: string, values: string[], isDim: boolean, isBold: boolean) => (
493 <Box key={key} flexDirection="row">
494 <Box width={nameWidth}>
495 <Text wrap="truncate" dimColor={isDim} bold={isBold}>
496 {name}
497 </Text>
498 </Box>
499 {cols2.map((c, i) => (
500 <Box key={`${key}:${c.key}`} width={c.width} justifyContent="flex-end">
501 <Text wrap="truncate" dimColor={isDim} bold={isBold}>
502 {values[i] ?? ''}
503 </Text>
504 </Box>
505 ))}
506 </Box>
507 )
508
509 const contributing = contributionLines(v.loops)
510 const section = (title: string) => <Text bold>{title}</Text>
511
512 return (
513 <Box flexDirection="column">
514 {header}
515
516 <Box key="context" flexDirection="column" marginTop={1}>
517 <Box key="context-title" flexDirection="row" justifyContent="space-between" alignItems="center">
518 {section('Context')}
519 {contextBar}
520 </Box>
521 {pairRows(context)}
522 </Box>
523
524 <Box key="limits" flexDirection="column" marginTop={1}>
525 {section('Rate limits')}
526 {limits.length === 0 ? <Text dimColor>none reported</Text> : pairRows(limits)}
527 </Box>
528
529 <Box key="models" flexDirection="column" marginTop={1}>
530 {section('Models')}
531 {modelRows.length === 0 ? (
532 <Text dimColor>No requests counted yet.</Text>
533 ) : (
534 <Box flexDirection="column">
535 {tableRow('model-head', 'Model', cols2.map(c => c.title), true, false)}
536 {modelRows.map(l => tableRow(`model:${l.model}`, l.name, cols2.map(c => c.value(l)), false, false))}
537 {modelRows.length > 1 && tableRow('model-total', 'Total', cols2.map(c => c.value(total)), false, true)}
538 <Text dimColor wrap="wrap">
539 {PRICE_NOTE}
540 </Text>
541 </Box>
542 )}
543 </Box>
544
545 <Box key="contributing" flexDirection="column" marginTop={1}>
546 {section("What's contributing")}
547 {contributing.length === 0 ? <Text dimColor>Nothing counted yet.</Text> : contributing.map((line, i) => <Text key={`contrib:${i}`}>{line}</Text>)}
548 </Box>
549
550 <Box key="changes" flexDirection="column" marginTop={1}>
551 {section('Code changes')}
552 <Text>{changesLine(t)}</Text>
553 </Box>
554
555 <Box marginTop={1}>
556 <Text dimColor wrap="wrap">
557 Per-model, per-agent and code figures count from when usage-gauge loaded; the cost is the engine's own, from the session's start.
558 </Text>
559 </Box>
560 {controls}
561 </Box>
562 )
563 })
564}
565hooks/format.ts 543 lines1// usage-gauge: pure helpers, everything the chip, the pane and /spend draw
2// from $.state. Nothing here touches `$` (the engine's rule keeps every
3// function that does at the top of register.tsx), so all of it is unit-tested
4// in format.test.ts.
5
6import type { Breakdown, CategoryRow, Counts, RateLimitRow, Totals, Usage } from '../types'
7import { CACHE_WRITE_MULTIPLIER, PRICES_AS_OF } from './prices'
8
9/** Caps on drawn text, so nothing an id or a name holds can widen a row past these. */
10export const CAP = { model: 32, agentType: 24, category: 40, rateKind: 24 } as const
11
12/**
13 * Text that may hold anything (a model id, an agent type, a category name)
14 * made safe to draw as one line: control characters, escape codes and line
15 * breaks become spaces; format characters (bidi overrides, zero-width,
16 * Unicode tags) are dropped; runs of whitespace collapse; the result is cut
17 * to `max` code points. Without this a crafted name could forge extra lines
18 * in the /spend table the model reads. Copied from mem-gauge's parse.ts.
19 */
20export const oneLine = (text: string, max: number): string =>
21 [
22 ...text
23 .replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ')
24 .replace(/[\p{Cf}\p{Cs}\p{Co}]/gu, '')
25 .replace(/\s+/g, ' ')
26 .trim(),
27 ]
28 .slice(0, max)
29 .join('')
30 .trim()
31
32const group = (digits: string): string => digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
33
34const isNum = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)
35
36/** A whole count with thousands separators: `1,203`. */
37export const formatCount = (n: number): string => (isNum(n) ? `${n < 0 ? '-' : ''}${group(String(Math.round(Math.abs(n))))}` : '--')
38
39/** Dollars with two decimals and thousands separators (`$123.45`, `$1,204.10`); `$ --` when unknown. */
40export function formatUsd(usd: number | null): string {
41 if (usd === null || !isNum(usd)) return '$ --'
42 const [whole = '0', cents = '00'] = Math.abs(usd).toFixed(2).split('.')
43 return `${usd < 0 ? '-' : ''}$${group(whole)}.${cents}`
44}
45
46/**
47 * The chip's dollars, as short as the footer strip needs: cents under $100
48 * (`$12.34`), whole dollars from there (`$123`, `$1,234`); `$ --` unknown.
49 */
50export function formatUsdChip(usd: number | null): string {
51 if (usd === null || !isNum(usd)) return '$ --'
52 if (Math.abs(usd) < 100) return formatUsd(usd)
53 return `${usd < 0 ? '-' : ''}$${group(String(Math.round(Math.abs(usd))))}`
54}
55
56/** Tokens, short: `572`, `345.3k`, `152.3M`; `digits` is the decimals under a million (`152k` with 0). */
57export function formatTokens(n: number | null, digits = 1): string {
58 if (n === null || !isNum(n)) return '--'
59 const abs = Math.max(0, n)
60 if (abs < 1000) return String(Math.round(abs))
61 const k = (abs / 1000).toFixed(digits)
62 if (Number(k) < 1000) return `${k}k`
63 return `${(abs / 1_000_000).toFixed(1)}M`
64}
65
66/** `4s`, `8m 4s`, `2h 14m`, `31h 24m`; `--` when unknown or negative. */
67export function formatDuration(ms: number | null): string {
68 if (ms === null || !isNum(ms) || ms < 0) return '--'
69 const s = Math.floor(ms / 1000)
70 if (s < 60) return `${s}s`
71 const m = Math.floor(s / 60)
72 if (m < 60) return `${m}m ${s % 60}s`
73 return `${Math.floor(m / 60)}h ${m % 60}m`
74}
75
76/** A share as a whole percent; a positive share under one percent reads `<1%`. */
77export const formatPercent = (p: number | null): string =>
78 p === null || !isNum(p) ? '--' : p > 0 && p < 1 ? '<1%' : `${Math.round(p)}%`
79
80/** Pads or cuts `text` to exactly `width` columns (with an ellipsis when cut). */
81export function fit(text: string, width: number, align: 'left' | 'right' = 'left'): string {
82 if (width <= 0) return ''
83 const chars = [...text]
84 if (chars.length > width) return width === 1 ? '…' : `${chars.slice(0, width - 1).join('')}…`
85 const pad = ' '.repeat(width - chars.length)
86 return align === 'left' ? text + pad : pad + text
87}
88
89/** A text gauge for surfaces with no Svg: "███████░░░░░ 62%". */
90/**
91 * The terminal's context bar: a run of filled cells and a run of track cells,
92 * drawn as two spans (the track dim, so it reads as a plain gray; no hatching).
93 */
94export function barParts(percent: number | null, width: number): { filled: string; track: string; label: string } {
95 const cells = Math.max(4, Math.floor(width))
96 if (percent === null || !isNum(percent)) return { filled: '', track: '█'.repeat(cells), label: '--' }
97 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells)
98 return { filled: '█'.repeat(filled), track: '█'.repeat(cells - filled), label: `${Math.round(percent)}%` }
99}
100
101/** Apple system gray: the pill's ink where a surface hands the SVG no theme colour. */
102const FALLBACK_INK = '#8E8E93'
103
104const xmlText = (text: string): string => text.replace(/[<>&"']/g, c => `&#${c.charCodeAt(0)};`)
105
106/** The context bar on the desktop: a 2 px pill about as wide as the Window figure under it, at the right of the Context title. */
107export const BAR_WIDTH_PX = 120
108export const BAR_HEIGHT_PX = 2
109/** The same bar on the terminal, in cells. */
110export const BAR_CELLS = 16
111
112const clampPct = (p: number | null): number => (p === null || !isNum(p) ? 0 : Math.min(100, Math.max(0, p)))
113
114/**
115 * The context bar: a small thin pill (`width` x `height` px, drawn 1:1), a
116 * muted track and a fill in the surface's own ink, that grows from the last
117 * drawn percent to this one. The desktop draws a plugin SVG as an image and
118 * stamps the theme's text colour on its root, so `currentColor` is white on a
119 * dark theme and near-black on a light one; a surface that stamps none gets
120 * the system gray. The growth is a CSS animation on the fill's width (off
121 * under reduced motion; a renderer that cannot animate it still draws the
122 * final width). No script, no external refs.
123 */
124export function pillSvg(percent: number | null, from: number | null, width = BAR_WIDTH_PX, height = BAR_HEIGHT_PX, title?: string): string {
125 const to = clampPct(percent)
126 const start = clampPct(from)
127 const label = percent === null || !isNum(percent) ? '--' : `${Math.round(percent)}%`
128 const aria = xmlText(title ?? `Context ${label} used`)
129 const r = height / 2
130 const toW = ((to / 100) * width).toFixed(1)
131 const fromW = ((start / 100) * width).toFixed(1)
132 return (
133 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="${aria}">` +
134 `<style>svg:not([color]){color:${FALLBACK_INK}}` +
135 `.fill{animation:grow 480ms cubic-bezier(0.2,0,0,1) both}` +
136 `@keyframes grow{from{width:${fromW}px}to{width:${toW}px}}` +
137 `@media (prefers-reduced-motion: reduce){.fill{animation:none}}</style>` +
138 `<rect x="0" y="0" width="${width}" height="${height}" rx="${r}" fill="currentColor" fill-opacity="0.22"/>` +
139 `<rect class="fill" x="0" y="0" width="${toW}" height="${height}" rx="${r}" fill="currentColor"/>` +
140 '</svg>'
141 )
142}
143
144const capital = (word: string): string => (word ? word[0]?.toUpperCase() + word.slice(1) : word)
145
146/**
147 * A model id as people say it: `claude-opus-5-5` -> `Opus 5.5`,
148 * `claude-fable-5-1[1m]` -> `Fable 5.1`, `claude-3-5-sonnet-20241022` ->
149 * `Sonnet 3.5`, `claude-opus-4-20250514` -> `Opus 4`. Anything else is the
150 * id without `claude-`, as one capped line.
151 */
152export function shortModel(model: string): string {
153 const id = oneLine(model, 120).toLowerCase()
154 const named = /^claude-([a-z]+)-(\d{1,2})(?:-(\d{1,2}))?(?!\d)/.exec(id)
155 if (named?.[1] && named[2]) return oneLine(`${capital(named[1])} ${named[2]}${named[3] ? `.${named[3]}` : ''}`, CAP.model)
156 const legacy = /^claude-(\d{1,2})(?:-(\d{1,2}))?-([a-z]+)/.exec(id)
157 if (legacy?.[1] && legacy[3]) return oneLine(`${capital(legacy[3])} ${legacy[1]}${legacy[2] ? `.${legacy[2]}` : ''}`, CAP.model)
158 return oneLine(model.replace(/^claude-/, ''), CAP.model) || '?'
159}
160
161/** Cache read over everything the requests were answered over, as a percent; null with no input at all. */
162export function cacheHitPercent(input: number, cacheRead: number, cacheWrite: number): number | null {
163 const total = Math.max(0, input) + Math.max(0, cacheRead) + Math.max(0, cacheWrite)
164 return total > 0 ? (Math.max(0, cacheRead) / total) * 100 : null
165}
166
167const lineCount = (text: string): number => (text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0))
168
169/**
170 * The lines one Edit / Write / NotebookEdit call changed, from its result's
171 * `structuredPatch` hunks (`+` lines added, `-` removed); a Write that created
172 * a file with no hunks counts its content's lines. Null (count nothing) for a
173 * denied call, an error result, or a change held for review (`staged`).
174 */
175export function countPatch(ran: unknown): { added: number; removed: number } | null {
176 if (!ran || typeof ran !== 'object') return null
177 const o = ran as Record<string, unknown>
178 if (typeof o['deny'] === 'string' || o['isError'] === true) return null
179 const result = o['result']
180 if (!result || typeof result !== 'object') return null
181 const r = result as Record<string, unknown>
182 if (r['staged'] === true) return null
183 let added = 0
184 let removed = 0
185 const hunks = Array.isArray(r['structuredPatch']) ? (r['structuredPatch'] as unknown[]) : []
186 for (const hunk of hunks) {
187 const lines = hunk && typeof hunk === 'object' ? (hunk as Record<string, unknown>)['lines'] : null
188 if (!Array.isArray(lines)) continue
189 for (const line of lines) {
190 if (typeof line !== 'string') continue
191 if (line.startsWith('+')) added += 1
192 else if (line.startsWith('-')) removed += 1
193 }
194 }
195 if (hunks.length === 0 && r['type'] === 'create' && typeof r['content'] === 'string') added = lineCount(r['content'])
196 return { added, removed }
197}
198
199/** Each entry's share of the positive total, biggest first; empty when the total is zero. */
200export function shares(entries: readonly (readonly [string, number])[]): { key: string; percent: number }[] {
201 const positive = entries.filter(([, v]) => isNum(v) && v > 0)
202 const total = positive.reduce((sum, [, v]) => sum + v, 0)
203 if (total <= 0) return []
204 return positive.map(([key, v]) => ({ key, percent: (v / total) * 100 })).sort((a, b) => b.percent - a.percent)
205}
206
207export const emptyCounts = (): Counts => ({ requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, apiMs: 0, estUsd: 0, unpriced: 0 })
208
209export const emptyTotals = (loadedAt = 0): Totals => ({
210 loadedAt,
211 updatedAt: loadedAt,
212 turns: 0,
213 agentTurns: 0,
214 requests: 0,
215 activeMs: 0,
216 apiMs: 0,
217 linesAdded: 0,
218 linesRemoved: 0,
219 edits: 0,
220})
221
222const tokensOf = (c: Counts): number => c.input + c.output + c.cacheRead + c.cacheWrite
223
224/** One request added to a row: its four counts, its wall time and its estimate (null: unpriced). */
225export function addRequest(
226 row: Counts | undefined,
227 usage: { input: number; output: number; cacheRead: number; cacheWrite: number } | null,
228 apiMs: number,
229 estUsd: number | null,
230): Counts {
231 const c = { ...emptyCounts(), ...row }
232 const n = (v: number) => (isNum(v) && v > 0 ? v : 0)
233 c.requests += 1
234 c.apiMs += n(apiMs)
235 if (usage) {
236 c.input += n(usage.input)
237 c.output += n(usage.output)
238 c.cacheRead += n(usage.cacheRead)
239 c.cacheWrite += n(usage.cacheWrite)
240 }
241 if (estUsd === null) c.unpriced += 1
242 else c.estUsd += n(estUsd)
243 return c
244}
245
246/** The record's own value at `key` (never one inherited, so a model named `constructor` is just a model). */
247export const ownGet = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
248 Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined
249
250/** A record keyed by `key` with at most `max` keys: a new key past the cap folds into `other`. */
251export function boundedKey(record: Readonly<Record<string, unknown>>, key: string, max: number): string {
252 // `__proto__` as a computed key is an own property here, but it does not survive the
253 // state's JSON round-trip as one, so that one name is spelled out instead.
254 const safe = key === '__proto__' ? '(__proto__)' : key
255 return ownGet(record, safe) !== undefined || Object.keys(record).length < max ? safe : 'other'
256}
257
258/** The latest `max` entries of an insertion-ordered record. */
259export function keepLatest<T>(record: Readonly<Record<string, T>>, max: number): Record<string, T> {
260 const entries = Object.entries(record)
261 return entries.length <= max ? { ...record } : Object.fromEntries(entries.slice(entries.length - max))
262}
263
264/** A rate-limit window's kind spelled out: `5-hour`, `7-day`, `spend limit`. */
265export function rateKind(kind: string): string {
266 return (
267 oneLine(kind, CAP.rateKind)
268 .toLowerCase()
269 .replace(/^five_hour/, '5-hour')
270 .replace(/^seven_day/, '7-day')
271 .replace(/_/g, ' ') || 'limit'
272 )
273}
274
275const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
276
277/** When a window resets, in local time: `3:40 PM` within a day, `Tue 3:40 PM` past it; null when unreadable. */
278export function resetText(iso: string | null, now: number): string | null {
279 if (!iso) return null
280 const at = Date.parse(iso)
281 if (!isNum(at)) return null
282 const d = new Date(at)
283 const h = d.getHours()
284 const time = `${h % 12 === 0 ? 12 : h % 12}:${String(d.getMinutes()).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`
285 return at - now < 24 * 60 * 60_000 ? time : `${WEEKDAYS[d.getDay()] ?? ''} ${time}`.trim()
286}
287
288/** One rate-limit row for the pane and /spend: `5-hour` and `23.5%`, `resets 3:40 PM`. */
289export function rateLine(r: RateLimitRow, now: number): { label: string; value: string } {
290 const reset = resetText(r.resetsAt, now)
291 return { label: rateKind(r.kind), value: `${r.percentUsed}%${reset ? ` · resets ${reset}` : ''}` }
292}
293
294/** What a usage reading or a measurement holds, as the engine gives it. */
295export type Reading = {
296 startedAt?: unknown
297 context?: { tokens?: unknown; window?: unknown; percent?: unknown } | null
298 rateLimits?: readonly { kind?: unknown; percentUsed?: unknown; resetsAt?: unknown }[] | null
299 cost?: { usd?: unknown } | null
300}
301
302const numOrNull = (v: unknown): number | null => (isNum(v) ? v : null)
303
304/**
305 * The `usage` atom from a reading. `isFull` is `$.session.usage()` (its
306 * missing cost means the host keeps no ledger); a `session.measure` reading
307 * keeps the last cost when it carries none, and keeps `startedAt` always.
308 */
309export function usageFrom(r: Reading, prev: Usage | null, at: number, isFull: boolean): Usage {
310 const ctx = r.context ?? {}
311 const tokens = numOrNull(ctx.tokens)
312 const window = numOrNull(ctx.window)
313 let percent = numOrNull(ctx.percent)
314 if (percent === null && tokens !== null && window !== null && window > 0) percent = (tokens / window) * 100
315 const rateLimits: RateLimitRow[] = []
316 for (const row of (Array.isArray(r.rateLimits) ? r.rateLimits : []).slice(0, 8)) {
317 if (!row || typeof row.kind !== 'string' || !isNum(row.percentUsed)) continue
318 const resetsAt = typeof row.resetsAt === 'string' && row.resetsAt.length <= 64 ? row.resetsAt : null
319 rateLimits.push({ kind: oneLine(row.kind, CAP.rateKind), percentUsed: row.percentUsed, resetsAt })
320 }
321 const usd = r.cost && typeof r.cost === 'object' ? numOrNull(r.cost.usd) : null
322 const costUsd = usd !== null ? usd : isFull ? null : (prev?.costUsd ?? null)
323 const hasCost = usd !== null ? true : isFull ? false : (prev?.hasCost ?? true)
324 return {
325 startedAt: isFull ? numOrNull(r.startedAt) : (prev?.startedAt ?? null),
326 context: { tokens, window, percent },
327 rateLimits,
328 costUsd,
329 hasCost,
330 at,
331 }
332}
333
334/** The usage atom when the figures cannot be read: every figure unknown, so the pane draws `--`. */
335export const unknownUsage = (at: number): Usage => ({
336 startedAt: null,
337 context: { tokens: null, window: null, percent: null },
338 rateLimits: [],
339 costUsd: null,
340 hasCost: true,
341 at,
342})
343
344const KINDS: readonly CategoryRow['kind'][] = ['used', 'free', 'buffer', 'deferred']
345
346/** The breakdown atom from `context.breakdown`, names made one line; null when it is not one. */
347export function breakdownFrom(raw: unknown): Breakdown | null {
348 if (!raw || typeof raw !== 'object') return null
349 const b = raw as Record<string, unknown>
350 if (!isNum(b['totalTokens']) || !isNum(b['rawMaxTokens'])) return null
351 const categories: CategoryRow[] = []
352 for (const c of (Array.isArray(b['categories']) ? (b['categories'] as unknown[]) : []).slice(0, 40)) {
353 if (!c || typeof c !== 'object') continue
354 const row = c as Record<string, unknown>
355 if (typeof row['name'] !== 'string' || !isNum(row['tokens'])) continue
356 const kind = KINDS.includes(row['kind'] as CategoryRow['kind']) ? (row['kind'] as CategoryRow['kind']) : 'used'
357 categories.push({ name: oneLine(row['name'], CAP.category) || '?', kind, tokens: Math.max(0, row['tokens']) })
358 }
359 return {
360 totalTokens: b['totalTokens'],
361 rawMaxTokens: b['rawMaxTokens'],
362 percentage: isNum(b['percentage']) ? b['percentage'] : 0,
363 autoCompactThreshold: numOrNull(b['autoCompactThreshold']),
364 isAutoCompactEnabled: b['isAutoCompactEnabled'] === true,
365 model: typeof b['model'] === 'string' ? oneLine(b['model'], CAP.model) : '',
366 categories,
367 }
368}
369
370/** The chip's label: `$123.45 · ctx 12%`, `$ -- · ctx 12%`, `ctx 12%`; null with neither figure. */
371export function chipLabel(usage: Usage | null, withContext: boolean): string | null {
372 if (!usage) return null
373 const pct = usage.context.percent
374 const ctx = withContext && pct !== null ? `ctx ${Math.round(pct)}%` : null
375 // The desktop strip truncates the whole row to `…` once its chips overflow, so the
376 // label stays short: whole dollars from $100, and the context only when asked.
377 // Drawn once any figure is known: a reading with neither draws nothing.
378 const cost = usage.hasCost && (usage.costUsd !== null || pct !== null) ? formatUsdChip(usage.costUsd) : null
379 if (cost === null && ctx === null) return null
380 return [cost, ctx].filter(Boolean).join(' · ')
381}
382
383/** `120k of 1.0M (12%)`. */
384export function contextLine(usage: Usage | null): string {
385 const c = usage?.context
386 if (!c || (c.tokens === null && c.window === null)) return '--'
387 return `${formatTokens(c.tokens, 0)} of ${formatTokens(c.window, 0)} (${c.percent === null ? '--' : `${Math.round(c.percent)}%`})`
388}
389
390/** Tokens left before auto-compaction runs; null when it is off or the threshold is unknown. */
391export function untilCompaction(b: Breakdown | null): number | null {
392 if (!b || !b.isAutoCompactEnabled || b.autoCompactThreshold === null) return null
393 return Math.max(0, b.autoCompactThreshold - b.totalTokens)
394}
395
396/** Up to `max` category rows: what is used, biggest first, then free space and the buffer, dim. */
397export function categoryRows(b: Breakdown | null, max = 8): { name: string; tokens: number; percent: number | null; isDim: boolean }[] {
398 if (!b) return []
399 const rank = (c: CategoryRow) => (c.kind === 'used' ? 0 : 1)
400 return [...b.categories]
401 .sort((x, y) => rank(x) - rank(y) || y.tokens - x.tokens)
402 .slice(0, max)
403 .map(c => ({ name: c.name, tokens: c.tokens, percent: b.rawMaxTokens > 0 ? (c.tokens / b.rawMaxTokens) * 100 : null, isDim: c.kind !== 'used' }))
404}
405
406export type ModelLine = {
407 model: string
408 name: string
409 requests: number
410 input: number
411 output: number
412 cacheRead: number
413 cacheWrite: number
414 hit: number | null
415 /** Null when the price table does not know the model. */
416 estUsd: number | null
417 /** Of the priced total; null when unpriced. */
418 share: number | null
419 /** True on a total that leaves an unpriced model out: its est. $ is a floor, its share unknown. */
420 isPartial: boolean
421}
422
423/** The model rows, the priciest first, and their total. */
424export function modelLines(models: Readonly<Record<string, Counts>>): { rows: ModelLine[]; total: ModelLine } {
425 const entries = Object.entries(models)
426 const priced = entries.reduce((sum, [, c]) => sum + (c.unpriced > 0 ? 0 : c.estUsd), 0)
427 const rows = entries.map(([model, c]): ModelLine => {
428 const estUsd = c.unpriced > 0 ? null : c.estUsd
429 return {
430 model,
431 name: shortModel(model),
432 requests: c.requests,
433 input: c.input,
434 output: c.output,
435 cacheRead: c.cacheRead,
436 cacheWrite: c.cacheWrite,
437 hit: cacheHitPercent(c.input, c.cacheRead, c.cacheWrite),
438 estUsd,
439 share: estUsd !== null && priced > 0 ? (estUsd / priced) * 100 : null,
440 isPartial: false,
441 }
442 })
443 rows.sort((a, b) => (b.estUsd ?? -1) - (a.estUsd ?? -1) || b.output - a.output)
444 const sum = (f: (l: ModelLine) => number) => rows.reduce((s, l) => s + f(l), 0)
445 const isPartial = rows.some(l => l.estUsd === null)
446 const total: ModelLine = {
447 model: 'total',
448 name: 'Total',
449 requests: sum(l => l.requests),
450 input: sum(l => l.input),
451 output: sum(l => l.output),
452 cacheRead: sum(l => l.cacheRead),
453 cacheWrite: sum(l => l.cacheWrite),
454 hit: cacheHitPercent(sum(l => l.input), sum(l => l.cacheRead), sum(l => l.cacheWrite)),
455 estUsd: priced > 0 || rows.some(l => l.estUsd !== null) ? priced : null,
456 share: priced > 0 && !isPartial ? 100 : null,
457 isPartial,
458 }
459 return { rows, total }
460}
461
462/** Main loop against subagents, and the top subagent types, by est. cost (by tokens when nothing is priced). */
463export function contributions(loops: { main: Counts; agents: Readonly<Record<string, Counts>> }): {
464 basis: 'cost' | 'tokens'
465 main: number
466 agents: number
467 types: { key: string; percent: number }[]
468} | null {
469 const agents = Object.entries(loops.agents)
470 const isCost = loops.main.estUsd + agents.reduce((s, [, c]) => s + c.estUsd, 0) > 0
471 const of = (c: Counts) => (isCost ? c.estUsd : tokensOf(c))
472 const all = shares([['main', of(loops.main)], ...agents.map(([type, c]): [string, number] => [`agent:${type}`, of(c)])])
473 if (all.length === 0) return null
474 const main = all.find(s => s.key === 'main')?.percent ?? 0
475 const types = all.filter(s => s.key.startsWith('agent:')).map(s => ({ key: s.key.slice(6), percent: s.percent }))
476 return { basis: isCost ? 'cost' : 'tokens', main, agents: 100 - main, types: types.slice(0, 5) }
477}
478
479/** `main loop 40% · subagents 60%`, and `builder 35% · verifier 15%`. */
480export function contributionLines(loops: { main: Counts; agents: Readonly<Record<string, Counts>> }): string[] {
481 const c = contributions(loops)
482 if (!c) return []
483 const lines = [`main loop ${formatPercent(c.main)} · subagents ${formatPercent(c.agents)}`]
484 if (c.types.length > 0) lines.push(c.types.map(t => `${oneLine(t.key, CAP.agentType)} ${formatPercent(t.percent)}`).join(' · '))
485 return lines
486}
487
488/** `+4,321 −321 lines · 42 edits`. */
489export const changesLine = (t: Totals): string =>
490 `+${formatCount(t.linesAdded)} −${formatCount(t.linesRemoved)} lines · ${formatCount(t.edits)} edit${t.edits === 1 ? '' : 's'}`
491
492export const PRICE_NOTE = `Estimates at list price (as of ${PRICES_AS_OF}, cache writes at ${CACHE_WRITE_MULTIPLIER}×); the headline cost is the engine's own ledger.`
493
494/** Everything the pane and /spend draw, read from $.state. */
495export type View = {
496 usage: Usage | null
497 breakdown: Breakdown | null
498 totals: Totals
499 models: Readonly<Record<string, Counts>>
500 loops: { main: Counts; agents: Readonly<Record<string, Counts>> }
501}
502
503/** The latest moment the state knows of: the pane's "now", so drawing makes no clock call. */
504export const viewNow = (v: View): number => Math.max(v.usage?.at ?? 0, v.totals.updatedAt, v.totals.loadedAt)
505
506/** How long the session has run, from the engine's start to the latest reading. */
507export const sessionMs = (v: View): number | null => (v.usage?.startedAt == null ? null : Math.max(0, viewNow(v) - v.usage.startedAt))
508
509/** One line for an origin that is not the person: cost, context and requests. */
510export function summaryLine(v: View): string {
511 const parts = [formatUsd(v.usage?.costUsd ?? null)]
512 if (v.usage?.context.percent != null) parts.push(`ctx ${Math.round(v.usage.context.percent)}%`)
513 parts.push(`${formatCount(v.totals.requests)} request${v.totals.requests === 1 ? '' : 's'}`)
514 return `Usage: ${parts.join(' · ')}`
515}
516
517/** The /spend answer where no pane can be placed (`claude -p`): a short plain table. */
518export function compactReport(v: View): string {
519 const now = viewNow(v)
520 const label = (text: string) => fit(text, 13)
521 const lines = [
522 'Usage (usage-gauge)',
523 `${label('Cost')}${formatUsd(v.usage?.costUsd ?? null)}${v.usage && !v.usage.hasCost ? ' (no cost ledger on this host)' : ''}`,
524 `${label('Session')}${formatDuration(sessionMs(v))} · active ${formatDuration(v.totals.activeMs)} · API ${formatDuration(v.totals.apiMs)}`,
525 `${label('Turns')}${formatCount(v.totals.turns)} · ${formatCount(v.totals.requests)} requests`,
526 `${label('Context')}${contextLine(v.usage)}`,
527 ]
528 const limits = v.usage?.rateLimits ?? []
529 lines.push(`${label('Rate limits')}${limits.length === 0 ? 'none reported' : limits.map(r => { const l = rateLine(r, now); return `${l.label} ${l.value}` }).join(' · ')}`)
530 const { rows } = modelLines(v.models)
531 for (const row of rows) {
532 lines.push(
533 `${label(row === rows[0] ? 'Models' : '')}${row.name}: ${formatCount(row.requests)} req, out ${formatTokens(row.output)}, ` +
534 `cache hit ${formatPercent(row.hit)}, est ${row.estUsd === null ? '--' : formatUsd(row.estUsd)}${row.share === null ? '' : ` (${formatPercent(row.share)})`}`,
535 )
536 }
537 const contributing = contributionLines(v.loops)
538 if (contributing.length > 0) lines.push(`${label('Contributing')}${contributing.join(' · ')}`)
539 lines.push(`${label('Code changes')}${changesLine(v.totals)}`)
540 if (rows.length > 0) lines.push(PRICE_NOTE)
541 return lines.join('\n')
542}
543hooks/prices.ts 78 lines1// usage-gauge: list-price estimates for the pane's per-model rows. Pure.
2//
3// The table, the as-of date, the cache-write multiplier and the first-prefix
4// rule are copied from mods/rd-ledger/hooks/prices.ts (prices as of
5// 2026-10-06). Mods are independent plugin folders, so this is a copy, not an
6// import: change both together. The headline cost is never priced here; it is
7// the engine's own ledger (`cost.usd`).
8
9/** `[prefix, input $/MTok, output $/MTok, cacheRead $/MTok]`, longer prefixes first, first match wins. */
10export const PRICES: readonly (readonly [string, number, number, number])[] = [
11 ['claude-fable-5-1', 10, 50, 0.25],
12 ['claude-mythos-5-1', 10, 50, 0.25],
13 ['claude-fable-5', 10, 50, 1.0],
14 ['claude-mythos', 10, 50, 1.0],
15 ['claude-opus-5-5', 4, 20, 0.2],
16 ['claude-opus-5', 5, 25, 0.5],
17 ['claude-opus-4-8', 5, 25, 0.5],
18 ['claude-opus-4-7', 5, 25, 0.5],
19 ['claude-opus-4-6', 5, 25, 0.5],
20 ['claude-opus-4-5', 5, 25, 0.5],
21 ['claude-opus-4-1', 15, 75, 1.5],
22 ['claude-opus-4', 15, 75, 1.5],
23 ['claude-sonnet-5-5', 2, 10, 0.2],
24 ['claude-sonnet-5', 2, 10, 0.2],
25 ['claude-sonnet-4-6', 3, 15, 0.3],
26 ['claude-sonnet-4-5', 3, 15, 0.3],
27 ['claude-sonnet-4', 3, 15, 0.3],
28 ['claude-3-7-sonnet', 3, 15, 0.3],
29 ['claude-3-5-sonnet', 3, 15, 0.3],
30 ['claude-haiku-5-5', 0.1, 0.5, 0.01],
31 ['claude-haiku-4-5', 1, 5, 0.1],
32 ['claude-3-5-haiku', 0.8, 4, 0.08],
33 ['claude-3-haiku', 0.25, 1.25, 0.025],
34 ['claude-3-opus', 15, 75, 1.5],
35]
36export const PRICES_AS_OF = '2026-10-06'
37/**
38 * The engine reports cache writes without their TTL, so every write is priced
39 * at the 5-minute rate (1.25x input); a 1-hour write (2x) reads low.
40 */
41export const CACHE_WRITE_MULTIPLIER = 1.25
42
43export type PriceRow = { prefix: string; input: number; output: number; cacheRead: number }
44
45/**
46 * The price row for a model id (first prefix match), or null. The prefix must
47 * end the id or be followed by a character that is not a letter or digit
48 * (`-`, `[`, `@`, `.`), so `claude-opus-50` is unknown rather than priced as
49 * `claude-opus-5`: an unknown model reads `--`, never a guess.
50 */
51export const priceRow = (model: string | null): PriceRow | null => {
52 if (typeof model !== 'string' || model.length === 0) return null
53 for (const [prefix, input, output, cacheRead] of PRICES) {
54 if (!model.startsWith(prefix)) continue
55 const next = model.charAt(prefix.length)
56 if (next === '' || !/[A-Za-z0-9]/.test(next)) return { prefix, input, output, cacheRead }
57 }
58 return null
59}
60
61const tokens = (n: number): number => (Number.isFinite(n) && n > 0 ? n : 0)
62
63export type TokenCounts = { input: number; output: number; cacheRead: number; cacheWrite: number }
64
65/** What `counts` cost at list price for `model`, in USD; null for a model the table does not know. */
66export const estimateUsd = (model: string | null, counts: TokenCounts): number | null => {
67 const row = priceRow(model)
68 if (!row) return null
69 const cacheWrite = row.input * CACHE_WRITE_MULTIPLIER
70 return (
71 (tokens(counts.input) * row.input +
72 tokens(counts.output) * row.output +
73 tokens(counts.cacheRead) * row.cacheRead +
74 tokens(counts.cacheWrite) * cacheWrite) /
75 1_000_000
76 )
77}
78types/index.d.ts 90 lines1// The $.state contract of usage-gauge. Every key here is named by a literal
2// plugin/key reference in hooks/register.tsx, and every value is JSON-safe
3// (null, never undefined). The file exports types so it is a module: without a
4// top-level export, `declare module` below would declare a new ambient module
5// instead of augmenting 'claude-code'.
6
7/** One rate-limit window as the engine reported it; `kind` is the engine's (`five_hour`), sanitized. */
8export type RateLimitRow = { kind: string; percentUsed: number; resetsAt: string | null }
9
10/** The status line's figures: `$.session.usage()` at start and on Refresh, `session.measure` in between. */
11export type Usage = {
12 /** When the session's figures count from (ms since the epoch); null when usage could not be read. */
13 startedAt: number | null
14 context: { tokens: number | null; window: number | null; percent: number | null }
15 rateLimits: RateLimitRow[]
16 /** The engine's own ledger, `cost.usd`; null before the first reading. */
17 costUsd: number | null
18 /** False once a reading said the host keeps no cost ledger (the chip then shows the context alone). */
19 hasCost: boolean
20 /** When these figures were read (ms since the epoch). */
21 at: number
22}
23
24/** One row of the context breakdown, its name sanitized to one line. */
25export type CategoryRow = { name: string; kind: 'used' | 'free' | 'buffer' | 'deferred'; tokens: number }
26
27/** The window by category (`$.session.usage({ breakdown: 'summary' })`), read on pane open and Refresh. */
28export type Breakdown = {
29 totalTokens: number
30 rawMaxTokens: number
31 percentage: number
32 /** Absent when auto-compaction is off. */
33 autoCompactThreshold: number | null
34 isAutoCompactEnabled: boolean
35 model: string
36 categories: CategoryRow[]
37}
38
39/** The session's running totals since the mod loaded (`loadedAt`); a hot reload keeps them. */
40export type Totals = {
41 loadedAt: number
42 /** The last time an accounting hook wrote (ms): the pane's "now" for the session length. */
43 updatedAt: number
44 /** Main-loop turns. */
45 turns: number
46 /** Subagent turns. */
47 agentTurns: number
48 /** Model requests (`turn.step`) in every loop. */
49 requests: number
50 /** Main-loop turn wall time. */
51 activeMs: number
52 /** Every request's wall time, summed across every loop (overlaps count twice). */
53 apiMs: number
54 linesAdded: number
55 linesRemoved: number
56 edits: number
57}
58
59/** Token counts and time for one model or one loop. */
60export type Counts = {
61 requests: number
62 input: number
63 output: number
64 cacheRead: number
65 cacheWrite: number
66 apiMs: number
67 /** List-price estimate of the requests the price table knows (USD). */
68 estUsd: number
69 /** Requests whose model the price table does not know (not in `estUsd`). */
70 unpriced: number
71}
72
73declare module 'claude-code' {
74 interface PluginState {
75 'usage-gauge': {
76 usage: Usage | null
77 breakdown: Breakdown | null
78 totals: Totals
79 /** By sanitized model id. */
80 models: Record<string, Counts>
81 /** The main loop, and the subagents by their agent type. */
82 loops: { main: Counts; agents: Record<string, Counts> }
83 /** agentId -> agent type, the latest 500 ids. */
84 agentTypes: Record<string, string>
85 /** A Refresh is in flight: the button reads Refreshing. */
86 refreshing: boolean
87 }
88 }
89}
90