A live spend meter docked at the right: session cost with subagents and workflows, burn rate, subscription limits and where the money goes. Toggle with /spend.

A live spend meter for Claude Code. It docks at the right of the terminal and shows what the session has cost so far, as it happens: the main thread, every subagent and every workflow agent, the burn rate, your subscription limits and where the money goes.

Type this at a Claude Code prompt in a terminal:
/plugin install spend-meter --marketplace Hekzory/spend-meter
Answer y to add the marketplace, then pick a scope; the user scope loads the meter in every session. From a shell, the same is claude plugin marketplace add Hekzory/spend-meter && claude plugin install spend-meter@spend-meter.
It needs Claude Code 2.1.296 or later; showing a subagent's transcript from its row needs 2.1.300. The pane opens by itself in a wide terminal; /spend opens it at any width.
Claude Code updates plugins from marketplaces other than its own only when told to: turn it on in /plugin → Marketplaces → spend-meter → Enable auto-update, and each new version arrives when Claude Code starts. Otherwise, claude plugin update spend-meter@spend-meter fetches it.
/spend | toggle the pane (it also works mid-turn) | ||
/spend budget 25 | alerts at 50 %, 80 % and 100 % of $25; budget off clears it | ||
/spend chip | the one-line meter under the prompt while the pane is closed | ||
/spend unit | what the big number counts | ||
| `/spend milestones on\ | off` | the toasts at $1, π, $10, $42… | |
| `/spend gfx auto\ | cells\ | pixels` | how the chart is drawn |
/spend rebuild | read the session back from its transcripts again |
Press the line under the total to change what it counts. Press a row under where it goes to see its model, requests, tokens, cache hit and, for a workflow, its agents; a subagent's row can also put that agent's transcript in the main view, and bring the main conversation back. Press the ? by cache served for what the cache saved.
│ marks where the session compacted. Windows Terminal and kitty both draw block elements as geometry, so it comes out seamless.Image speaks (no Sixel, no iTerm2). /spend gfx cells|pixels|auto overrides the choice.subagents/ and subagents/workflows/), every response priced exactly with its 5-minute and 1-hour cache writes split, so a meter that starts late or after --resume shows the whole session. /spend rebuild reads them again./cost and the status line show: every priced response of the session, subagents, workflow agents and the engine's side calls (auto-mode classifier, titles, compaction) included. On a subscription it is what the session would cost at API list prices.hooks/pricing.ts). The engine does not tell a mod which response cost what, so the rows are estimates; a row marked ≥ has a model the catalog lacks. Main-thread cache writes are priced at the 1-hour rate on a subscription and subagents' at the 5-minute rate, as the CLI requests them. side calls (the classifier, titles) is the rest of the total; before the meter is what the session had spent when the meter started.+N pts since start is how far a window moved while this session ran; the pace line projects the last 15 minutes forward and warns when the window would fill before it resets./clear starts the meter over.COLORTERM=truecolor set, Claude Code uses it.When a Claude Code update changes prices or models, update hooks/pricing.ts.
The mod is plugin/, and only that folder is what an install copies. The repository around it (this README, the tools in package.json, CI, the screenshot) stays behind. Claude Code installs a plugin folder's own packages when it finds a package.json and a lockfile in it, so the dev tools must not live there.
The tools come from Bun, as Claude Code itself is built with it:
bun install # Claude Code and TypeScript, pinned in package.json
bun run validate # claude plugin validate --strict, the marketplace and the mod
bun run test # claude plugin test plugin
bun run test, not bun test: the tests run in Claude Code's own test kit, not Bun's runner.
claude --plugin-dir plugin runs a session with the working copy loaded, and reloads it as files change. That first load also writes the engine's types into plugin/.claude-plugin/types/; after it, bun run typecheck type-checks the mod.
A release is a new version in plugin/.claude-plugin/plugin.json: Claude Code compares versions, so commits under the same version never reach an install.
CI runs the validator and the tests on every push and pull request, and daily, against the Claude Code in package.json and the newest one on npm. Dependabot checks the pins daily.
docs/screenshot.png shows a made-up session: bun run screenshot (needs uv) drives docs/screenshot/scenario.tsx through the real mod under the engine's test kit and paints the pane it draws.
hooks/register.tsx 681 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { SpendAgentMeta, SpendCore, SpendLive, SpendPrefs } from '../types'
5import {
6 absorbUsage,
7 addStep,
8 addToSeries,
9 attribute,
10 EMPTY_SERIES,
11 EMPTY_SOURCES,
12 ensureRow,
13 finishAgent,
14 fmtEta,
15 fmtUsd,
16 pace,
17} from './ledger'
18import { rebuild } from './history'
19import type { TranscriptFile } from './history'
20import { outputRate, partsTotal, priceUsage } from './pricing'
21import type { Parts } from './pricing'
22import { capsOf, chipText, clockText, drawPane, OLD_ENGINE, UNITS } from './view'
23
24const PANE = 'spend'
25const TITLE = 'Spend'
26const TICK_MS = 250
27const POLL_EVERY = 4 // ticks: the engine's figures once a second
28// The ledger's format: a session whose ledger is older is read back from its transcripts again.
29const LEDGER = 2
30
31const DEFAULT_PREFS: SpendPrefs = { unit: 0, chip: true, eggs: true, budget: null, open: true, selected: null }
32const IDLE: SpendLive = { tick: 0, inflight: 0, estUsd: 0, keys: [], flash: null }
33
34const core = atom({ plugin: 'spend-meter', key: 'core' } as const, null)
35const sources = atom({ plugin: 'spend-meter', key: 'sources' } as const, EMPTY_SOURCES)
36const series = atom({ plugin: 'spend-meter', key: 'series' } as const, EMPTY_SERIES)
37const prefs = atom({ plugin: 'spend-meter', key: 'prefs' } as const, DEFAULT_PREFS)
38const live = atom({ plugin: 'spend-meter', key: 'live' } as const, IDLE)
39
40const MILESTONES: readonly [number, string][] = [
41 [1, 'First dollar. Frame it.'],
42 [Math.PI, 'π dollars. Irrational, yet here we are.'],
43 [10, 'Double digits: the meter is warming up.'],
44 [42, '$42: the answer to life, the universe, and this session.'],
45 [100, 'Triple digits. Worth a look at who is spending it.'],
46 [256, '$256: a round number, in binary.'],
47 [1000, 'Four digits. Somewhere a GPU fan spins a little faster.'],
48]
49
50const HELP = [
51 '/spend toggle the meter pane',
52 '/spend budget 25 alert at 50 %, 80 % and 100 % of $25 (budget off clears it)',
53 '/spend chip the one-line meter under the prompt while the pane is closed',
54 '/spend unit cycle what the big number counts',
55 '/spend milestones on|off a toast at $1, $10, $100 and a few rounder numbers',
56 '/spend gfx auto|cells|pixels the chart as block cells, or as a kitty-protocol picture',
57 '/spend rebuild read the session back from its transcripts again',
58 'In the pane: press the line under the total to change units, a row of "where it goes"',
59 'for its model, tokens, cache hit and agents, and the ? by "cache served" for what it means.',
60].join('\n')
61
62type Flight = { key: string; model: string; chars: number }
63type Held = { agentId?: string; model: string; usage: ModelUsage; parts: Parts | undefined; at: number }
64
65// The module's own memory: gone on a reload, rebuilt from `$.state` where it matters.
66const mem = {
67 agents: {} as Record<string, SpendAgentMeta>,
68 isSubscriber: false,
69 isPaneOpen: false,
70 chip: undefined as string | undefined,
71 ticks: 0,
72 isPolling: false,
73 /** Set when a response finished: the total is read again at the next tick. */
74 isStale: false,
75 wasBusy: false,
76 flashUntil: 0,
77 flights: new Map<string, Flight>(),
78 split: { main: 0, agents: 0, workflows: 0 },
79 /** What the terminal draws a picture with: kitty-protocol pixels, or cells. */
80 gfx: 'cells' as 'cells' | 'pixels',
81 /** Responses that finished while the transcripts were being read back. */
82 backfilling: false,
83 /** What the pane last showed that moves with the clock alone; see `clockText`. */
84 clock: undefined as string | undefined,
85 /** The subagent whose transcript the person has on screen, as the pane was last drawn. */
86 viewedAgentId: undefined as string | undefined,
87 /** What this engine draws and does, by its version. */
88 caps: OLD_ENGINE,
89 held: [] as Held[],
90}
91
92// ─── the session's past ──────────────────────────────────────────────────────
93
94async function backfill($: EngineInterface, cwd: string, force: boolean): Promise<number> {
95 const before = await read($, core)
96 if (mem.backfilling || ((before?.ledger ?? 0) >= LEDGER && !force)) return -1
97 mem.backfilling = true
98 let found = 0
99 try {
100 const cutoff = await $.clock.now()
101 const r = rebuild(await sessionFiles($, cwd), cutoff)
102 found = r.responses
103 if (r.responses > 0) {
104 const s = await update($, sources, live => ({
105 ...r.sources,
106 agents: { ...r.sources.agents, ...live.agents },
107 workflows: { ...live.workflows },
108 }))
109 mem.agents = s.agents
110 await update($, series, () => r.series)
111 }
112 await update($, core, c => {
113 if (c === null) return c
114 const turn =
115 c.turn ?? (r.turn === undefined ? null : { startedAt: r.turn.startedAt, usd0: c.usd - r.turn.usd, endedAt: r.turn.endedAt, usd1: c.usd })
116 const compactions = [...r.compactions, ...(c.compactions ?? []).filter(one => one.at > cutoff)].slice(-20)
117 return { ...c, ledger: LEDGER, usd0: r.responses > 0 ? 0 : c.usd0, turn, compactions }
118 })
119 } finally {
120 mem.backfilling = false
121 const held = mem.held.splice(0)
122 if (held.length > 0) {
123 await update($, sources, s => held.reduce((acc, one) => addStep(acc, attribute(one.agentId, acc), one.model, one.usage, one.parts, one.at), s))
124 }
125 }
126 return found
127}
128
129const CHUNK = 3 * 1024 * 1024
130
131/** A file's text, whole: one read under the read cap, else in `dd` chunks. */
132async function readText($: EngineInterface, path: string, size: number): Promise<string> {
133 if (size <= 3_900_000) return String(await $.fs.read(path))
134 let text = ''
135 for (let k = 0; k * CHUNK < size; k++) {
136 const r = await $.process.run(['dd', `if=${path}`, `bs=${CHUNK}`, `skip=${k}`, 'count=1', 'status=none'], {
137 timeoutMs: 20_000,
138 })
139 if (r.exitCode !== 0) break
140 text += r.stdout
141 }
142 return text
143}
144
145async function readMeta($: EngineInterface, path: string): Promise<Record<string, unknown>> {
146 try {
147 return JSON.parse(String(await $.fs.read(path))) as Record<string, unknown>
148 } catch {
149 return {}
150 }
151}
152
153/** Every transcript of this session: the main thread's, its subagents', its workflows'. */
154export async function sessionFiles($: EngineInterface, cwd: string): Promise<TranscriptFile[]> {
155 const id = await $.session.id()
156 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
157 const projects = `${config}/projects`
158 let main = `${projects}/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/${id}.jsonl`
159 if (!(await $.fs.exists(main))) {
160 main = ''
161 for (const dir of await $.fs.list(projects).catch(() => [])) {
162 const candidate = `${projects}/${dir.name}/${id}.jsonl`
163 if (dir.kind === 'dir' && (await $.fs.exists(candidate))) {
164 main = candidate
165 break
166 }
167 }
168 if (main === '') return []
169 }
170 const root = main.slice(0, -'.jsonl'.length)
171 const files: TranscriptFile[] = [{ text: await readText($, main, (await $.fs.stat(main)).size) }]
172
173 for (const e of await $.fs.list(`${root}/subagents`).catch(() => [])) {
174 const agentId = /^agent-(.+)\.jsonl$/.exec(e.name)?.[1]
175 if (e.kind !== 'file' || agentId === undefined) continue
176 const meta = await readMeta($, `${root}/subagents/agent-${agentId}.meta.json`)
177 const type = typeof meta['agentType'] === 'string' ? meta['agentType'] : 'agent'
178 const description = typeof meta['description'] === 'string' ? meta['description'] : agentId
179 files.push({
180 agentId,
181 meta: { label: `${type} · ${description}`, type },
182 text: await readText($, `${root}/subagents/${e.name}`, e.size),
183 })
184 }
185
186 // A run's name is in its saved script's file name: `<name>-<runId>.js`.
187 const names: Record<string, string> = {}
188 for (const e of await $.fs.list(`${root}/workflows/scripts`).catch(() => [])) {
189 const m = /^(.+)-(wf_[^.]+)\.js$/.exec(e.name)
190 if (m !== null) names[m[2]!] = m[1]!
191 }
192 for (const run of await $.fs.list(`${root}/subagents/workflows`).catch(() => [])) {
193 if (run.kind !== 'dir') continue
194 const runId = run.name
195 const label = names[runId] ?? `workflow ${runId.slice(3, 9)}`
196 for (const e of await $.fs.list(`${root}/subagents/workflows/${runId}`).catch(() => [])) {
197 const agentId = /^agent-(.+)\.jsonl$/.exec(e.name)?.[1]
198 if (e.kind !== 'file' || agentId === undefined) continue
199 const meta = await readMeta($, `${root}/subagents/workflows/${runId}/agent-${agentId}.meta.json`)
200 const type = typeof meta['description'] === 'string' ? meta['description'] : 'agent'
201 files.push({
202 agentId,
203 meta: { label, type, runId },
204 text: await readText($, `${root}/subagents/workflows/${runId}/${e.name}`, e.size),
205 })
206 }
207 }
208 return files
209}
210
211async function detectGfx($: EngineInterface): Promise<'cells' | 'pixels'> {
212 // As Claude Code decides where its Image draws pixels: kitty and Ghostty,
213 // never through a multiplexer, or wherever it is told to.
214 if ((await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')) !== undefined) return 'pixels'
215 if ((await $.env.get('TMUX')) !== undefined || (await $.env.get('STY')) !== undefined) return 'cells'
216 const term = (await $.env.get('TERM')) ?? ''
217 const program = ((await $.env.get('TERM_PROGRAM')) ?? '').toLowerCase()
218 if ((await $.env.get('KITTY_WINDOW_ID')) !== undefined || term === 'xterm-kitty' || program === 'kitty') return 'pixels'
219 if (program === 'ghostty' || term === 'xterm-ghostty') return 'pixels'
220 return 'cells'
221}
222
223// ─── the engine's figures ────────────────────────────────────────────────────
224
225async function tick($: EngineInterface) {
226 mem.ticks += 1
227 const isBusy = mem.flights.size > 0 || mem.flashUntil > 0
228 if (isBusy || mem.wasBusy) await animate($)
229 mem.wasBusy = isBusy
230 if ((mem.ticks % POLL_EVERY === 0 || mem.isStale) && !mem.isPolling) {
231 mem.isPolling = true
232 mem.isStale = false
233 try {
234 await measure($)
235 } finally {
236 mem.isPolling = false
237 }
238 }
239}
240
241async function measure($: EngineInterface) {
242 const pane = (await $.ui.panes()).find(one => one.id === PANE)
243 mem.isPaneOpen = pane !== undefined && pane.isPlaced && pane.isShown
244 const u = await $.session.usage()
245 const now = await $.clock.now()
246 const p = await read($, prefs)
247 let before = await read($, core)
248 if (before !== null && (u.cost?.usd ?? before.usd) < before.usd - 1e-9) {
249 await resetSession($)
250 before = null
251 }
252
253 // A session standing still writes nothing, so nothing is drawn again: the clock alone
254 // redraws a quiet pane, when the text it moves changes (`redrawOnClock`).
255 const isStill = before !== null && isQuiet(before, absorbUsage(before, u, now), p, now)
256 let raised: string[] = []
257 const after = isStill ? before : await update($, core, c => {
258 const n = absorbUsage(c, u, now)
259 raised = c === null ? [] : alertsFor(c, n, p, now)
260 // Milestones already behind a session the meter joins late stay quiet.
261 const quiet = c === null ? MILESTONES.filter(([m]) => n.usd >= m).map(([m]) => `m:${m}`) : []
262 return { ...n, fired: [...n.fired, ...quiet, ...raised].slice(-512) }
263 })
264 mem.isSubscriber = after?.isSubscriber ?? false
265
266 const parts = { ...mem.split }
267 mem.split.main = mem.split.agents = mem.split.workflows = 0
268 // An empty bucket is not written: the chart and the burn count the buckets since the last.
269 if (parts.main + parts.agents + parts.workflows > 0) await update($, series, s => addToSeries(s, now, parts))
270 // The context a compaction left, once a response has measured it.
271 const last = after?.compactions?.at(-1)
272 if (last !== undefined && last.to === undefined && u.context.tokens !== undefined && now - last.at > 1000) {
273 const to = u.context.tokens
274 await update($, core, c =>
275 c === null ? c : { ...c, compactions: (c.compactions ?? []).map((one, i, all) => (i === all.length - 1 ? { ...one, to } : one)) },
276 )
277 }
278
279 // A budget set below what is already spent says its highest step alone.
280 const steps = raised.filter(id => id.startsWith('b:')).map(id => Number(id.split(':')[2]))
281 const top = Math.max(0, ...steps)
282 const said = raised.filter(id => !id.startsWith('b:') || Number(id.split(':')[2]) === top)
283 if (after !== null) for (const id of said) announce($, id, after, p, now)
284 await showChip($)
285 if (mem.isPaneOpen) await redrawOnClock($, after, p, now)
286}
287
288/** Whether a reading differs from the last only in when it was taken, and raises nothing. */
289function isQuiet(prev: SpendCore, next: SpendCore, p: SpendPrefs, now: number): boolean {
290 return JSON.stringify({ ...prev, at: 0 }) === JSON.stringify({ ...next, at: 0 }) && alertsFor(prev, next, p, now).length === 0
291}
292
293/** Draws the pane again when what it shows moved with the clock alone. */
294async function redrawOnClock($: EngineInterface, c: SpendCore | null, p: SpendPrefs, now: number) {
295 const [se, so] = await Promise.all([read($, series), read($, sources)])
296 const text = clockText({ now, core: c, series: se, sources: so, prefs: p, viewedAgentId: mem.viewedAgentId })
297 if (text === mem.clock) return
298 const isFirst = mem.clock === undefined
299 mem.clock = text
300 if (!isFirst) $.ui.invalidate('ui.render')
301}
302
303function alertsFor(prev: SpendCore, next: SpendCore, p: SpendPrefs, now: number): string[] {
304 const ids: string[] = []
305 const fresh = (id: string) => !next.fired.includes(id) && !ids.includes(id)
306 if (p.eggs) {
307 for (const [m] of MILESTONES) if (prev.usd < m && next.usd >= m && fresh(`m:${m}`)) ids.push(`m:${m}`)
308 }
309 if (p.budget !== null && p.budget > 0) {
310 for (const step of [50, 80, 100]) {
311 const id = `b:${p.budget}:${step}`
312 if (next.usd >= (p.budget * step) / 100 && fresh(id)) ids.push(id)
313 }
314 }
315 const w = next.windows['five_hour']
316 const pc = w === undefined ? undefined : pace(w, now)
317 if (pc?.isShort === true && pc.fullAt !== undefined && pc.fullAt - now < 3 * 3600_000) {
318 const id = `p:${Math.floor(now / (20 * 60_000))}`
319 if (fresh(id)) ids.push(id)
320 }
321 return ids
322}
323
324function announce($: EngineInterface, id: string, c: SpendCore, p: SpendPrefs, now: number) {
325 if (id.startsWith('m:')) {
326 const line = MILESTONES.find(([m]) => `m:${m}` === id)?.[1]
327 if (line !== undefined) $.ui.toast(`${fmtUsd(c.usd)} · ${line}`)
328 return
329 }
330 if (id.startsWith('b:') && p.budget !== null) {
331 const step = Number(id.split(':')[2])
332 if (step >= 100) {
333 const text = `Budget of ${fmtUsd(p.budget)} reached: ${fmtUsd(c.usd)} spent this session.`
334 $.ui.toast(text, { timeoutMs: 8000 })
335 void $.ui.notify(text, { title: 'Spend meter' }).catch(() => undefined)
336 } else {
337 $.ui.toast(`${step} % of the ${fmtUsd(p.budget)} budget spent (${fmtUsd(c.usd)}).`)
338 }
339 return
340 }
341 if (id.startsWith('p:')) {
342 const w = c.windows['five_hour']
343 const pc = w === undefined ? undefined : pace(w, now)
344 if (pc?.fullAt === undefined) return
345 const reset = pc.resetAt === undefined ? '' : ` (it resets in ${fmtEta(pc.resetAt - now)})`
346 $.ui.toast(`⚠ 5-hour limit: full in ~${fmtEta(pc.fullAt - now)} at this pace${reset}.`, { timeoutMs: 8000 })
347 }
348}
349
350async function showChip($: EngineInterface) {
351 const p = await read($, prefs)
352 const text = !mem.isPaneOpen && p.chip ? chipText(await read($, core), await read($, live)) : undefined
353 if (text === mem.chip) return
354 mem.chip = text
355 $.ui.status(text)
356}
357
358async function resetSession($: EngineInterface) {
359 await update($, core, () => null)
360 await update($, sources, () => EMPTY_SOURCES)
361 await update($, series, () => EMPTY_SERIES)
362 await update($, prefs, p => ({ ...p, selected: null }))
363 mem.agents = {}
364 mem.flights.clear()
365}
366
367// ─── what is streaming now ───────────────────────────────────────────────────
368
369async function animate($: EngineInterface) {
370 const now = await $.clock.now()
371 if (mem.flashUntil > 0 && mem.flashUntil <= now) mem.flashUntil = 0
372 let estUsd = 0
373 for (const f of mem.flights.values()) estUsd += ((f.chars / 3.6) * outputRate(f.model)) / 1e6
374 const keys = [...new Set([...mem.flights.values()].map(f => f.key))]
375 const isBusy = mem.flights.size > 0 || mem.flashUntil > 0
376 // With the pane away, the chip only needs the count now and then.
377 if (isBusy && !mem.isPaneOpen && mem.ticks % POLL_EVERY !== 0) return
378 await update($, live, l => ({
379 tick: isBusy ? l.tick + 1 : l.tick,
380 inflight: mem.flights.size,
381 estUsd,
382 keys,
383 flash: mem.flashUntil > 0 ? { kind: 'brrr' as const, until: mem.flashUntil } : null,
384 }))
385}
386
387// ─── preferences and the pane ────────────────────────────────────────────────
388
389async function setPrefs($: EngineInterface, change: (p: SpendPrefs) => SpendPrefs): Promise<SpendPrefs> {
390 const p = await update($, prefs, change)
391 await $.store.set('prefs', { unit: p.unit, chip: p.chip, eggs: p.eggs, budget: p.budget, open: p.open, gfx: p.gfx ?? 'auto' })
392 return p
393}
394
395async function setOpen($: EngineInterface, want: boolean | undefined): Promise<string> {
396 const pane = (await $.ui.panes()).find(one => one.id === PANE)
397 // Open but waiting for room, or a tab behind another pane, counts as closed:
398 // the person asking for it then brings it up rather than putting it away.
399 const isUp = pane !== undefined && pane.isPlaced && pane.isShown
400 const open = want ?? !isUp
401 if (!open) {
402 if (pane !== undefined) await $.ui.close({ id: PANE })
403 mem.isPaneOpen = false
404 const p = await setPrefs($, x => ({ ...x, open: false }))
405 await showChip($)
406 return p.chip ? 'Spend meter closed; its chip stays under the prompt (/spend chip hides it).' : 'Spend meter closed.'
407 }
408 const placed = await $.ui.open({ id: PANE, title: TITLE, columns: 44 })
409 mem.isPaneOpen = placed.isPlaced
410 await setPrefs($, x => ({ ...x, open: true }))
411 await showChip($)
412 if ((await $.store.get('welcomed')) !== true) {
413 await $.store.set('welcomed', true)
414 $.ui.toast('Spend meter: press a row for its details · /spend help', { timeoutMs: 7000 })
415 }
416 return placed.isPlaced ? 'Spend meter open.' : `Spend meter open, waiting for room: ${placed.reason}`
417}
418
419async function runCommand($: EngineInterface, args: string): Promise<string> {
420 const [verb = '', arg] = args.trim().toLowerCase().split(/\s+/)
421 switch (verb) {
422 case '':
423 return setOpen($, undefined)
424 case 'on':
425 case 'open':
426 case 'show':
427 return setOpen($, true)
428 case 'off':
429 case 'close':
430 case 'hide':
431 return setOpen($, false)
432 case 'budget': {
433 if (arg === undefined) {
434 const p = await read($, prefs)
435 return p.budget === null ? 'No budget set; /spend budget 25 sets one.' : `Budget: ${fmtUsd(p.budget)}.`
436 }
437 if (arg === 'off' || arg === 'none' || arg === '0') {
438 await setPrefs($, p => ({ ...p, budget: null }))
439 return 'Budget cleared.'
440 }
441 const usd = Number(arg.replace(/^\$/, ''))
442 if (!Number.isFinite(usd) || usd <= 0) return `Not an amount: ${arg}. Try /spend budget 25.`
443 await setPrefs($, p => ({ ...p, budget: usd }))
444 const c = await read($, core)
445 return `Budget set: ${fmtUsd(usd)} (${fmtUsd(c?.usd ?? 0)} spent so far).`
446 }
447 case 'chip': {
448 const p = await setPrefs($, x => ({ ...x, chip: arg === 'on' ? true : arg === 'off' ? false : !x.chip }))
449 await showChip($)
450 return p.chip ? 'Chip on: shown under the prompt while the pane is closed.' : 'Chip off.'
451 }
452 case 'unit': {
453 const p = await setPrefs($, x => ({ ...x, unit: (x.unit + 1) % UNITS.length }))
454 return `The big number now counts ${UNITS[p.unit]!.label}.`
455 }
456 case 'milestones': {
457 const p = await setPrefs($, x => ({ ...x, eggs: arg === 'on' ? true : arg === 'off' ? false : !x.eggs }))
458 return p.eggs ? 'Milestone toasts on.' : 'Milestone toasts off.'
459 }
460 case 'brrr':
461 mem.flashUntil = (await $.clock.now()) + 3600
462 return 'money printer go brrr'
463 case 'gfx': {
464 const want = arg === 'cells' || arg === 'pixels' || arg === 'auto' ? arg : undefined
465 if (want === undefined) {
466 const p = await read($, prefs)
467 return `Chart drawn as ${p.gfx === 'cells' || p.gfx === 'pixels' ? p.gfx : `${mem.gfx} (auto)`}; /spend gfx auto|cells|pixels changes it.`
468 }
469 await setPrefs($, x => ({ ...x, gfx: want }))
470 return want === 'auto' ? `Chart drawn as ${mem.gfx}, as this terminal takes.` : `Chart drawn as ${want}.`
471 }
472 case 'rebuild': {
473 const found = await backfill($, await $.session.cwd(), true)
474 return found < 0 ? 'Already reading the transcripts.' : `Read ${found} responses back from the transcripts.`
475 }
476 default:
477 return HELP
478 }
479}
480
481// ─── the hooks ───────────────────────────────────────────────────────────────
482
483export const register: Register = on => {
484 on('session.start', async ($, e, next) => {
485 mem.agents = (await read($, sources)).agents
486 await $.command.register({
487 name: 'spend',
488 description: 'Toggle the live spend meter: cost with subagents and workflows, limits, burn rate',
489 argumentHint: '[budget <usd>|chip|unit|milestones|help]',
490 immediate: true,
491 })
492 mem.caps = capsOf(await $.session.version().catch(() => undefined))
493 const held = await $.state.get({ plugin: 'spend-meter', key: 'prefs' })
494 if (held.version === 0) {
495 const stored = await $.store.get('prefs')
496 const saved = typeof stored === 'object' && stored !== null ? (stored as Partial<SpendPrefs>) : {}
497 await update($, prefs, () => ({ ...DEFAULT_PREFS, ...saved, selected: null }))
498 }
499 if (e.isInteractive) {
500 mem.gfx = await detectGfx($)
501 await measure($).catch(() => undefined)
502 $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
503 // Read the session's past back after start, not before the first prompt.
504 const cwd = e.cwd
505 $.clock.after(50, () => void backfill($, cwd, false).catch(() => undefined))
506 const p = await read($, prefs)
507 if (p.open && !(await $.ui.panes()).some(pane => pane.id === PANE)) {
508 mem.isPaneOpen = (await $.ui.open({ id: PANE, title: TITLE, columns: 44 })).isPlaced
509 }
510 await showChip($)
511 }
512 return next(e)
513 })
514
515 on('session.end', async ($, e, next) => {
516 if (e.reason === 'clear') await resetSession($).catch(() => undefined)
517 return next(e)
518 })
519
520 on('ui.close', { id: PANE }, async ($, e, next) => {
521 const r = await next(e)
522 if (e.origin.kind !== 'plugin') mem.isPaneOpen = false
523 if (e.origin.kind === 'person') await setPrefs($, p => ({ ...p, open: false })).catch(() => undefined)
524 return r
525 }).catch(($, e, next) => next(e))
526
527 // A compaction is marked, never reset: the spend before it stays the session's.
528 on('session.compact', async ($, e, next) => {
529 const r = await next(e)
530 if (e.agentId === undefined && e.trigger !== 'precompute' && r.skip === undefined) {
531 const at = await $.clock.now()
532 const trigger = e.trigger
533 await update($, core, c =>
534 c === null ? c : { ...c, compactions: [...(c.compactions ?? []), { at, trigger, from: c.ctxTokens }].slice(-20) },
535 )
536 }
537 return r
538 }).catch(($, e, next) => next(e))
539
540 // Subagents' and teammates' turns start here too: only the main loop's is "this turn".
541 on('turn.start', async ($, e, next) => {
542 const r = await next(e)
543 if (e.agentId === undefined) {
544 const now = await $.clock.now()
545 await update($, core, c => (c === null ? c : { ...c, turn: { startedAt: now, usd0: c.usd } }))
546 }
547 return r
548 })
549
550 on('turn.complete', async ($, e, next) => {
551 const r = await next(e)
552 if (e.agentId === undefined) {
553 const now = await $.clock.now()
554 const u = await $.session.usage()
555 await update($, core, c =>
556 c === null || c.turn === null ? c : { ...c, turn: { ...c.turn, endedAt: now, usd1: u.cost?.usd ?? c.usd } },
557 )
558 } else {
559 const status = e.reason === 'answer' ? 'done' : 'failed'
560 const agentId = e.agentId
561 await update($, sources, s => finishAgent(s, agentId, status))
562 }
563 return r
564 })
565
566 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
567 let name = e.name
568 if (name === undefined && e.script !== undefined) name = /\bname\s*:\s*['"`]([^'"`]+)['"`]/.exec(e.script)?.[1]
569 if (name === undefined && e.scriptPath !== undefined) {
570 const text = await $.fs.read(e.scriptPath).catch(() => '')
571 name = /\bname\s*:\s*['"`]([^'"`]+)['"`]/.exec(text)?.[1]
572 }
573 if (name !== undefined) {
574 const label = name
575 await update($, sources, s => ({ ...s, workflows: { ...s.workflows, [e.tool_use_id]: label } }))
576 }
577 return next(e)
578 }).catch(($, e, next) => next(e))
579
580 on('agent.spawn', async ($, e, next) => {
581 const r = await next(e)
582 if (r.deny !== undefined || r.agentId === undefined) return r
583 const agentId = r.agentId
584 const runId = e.workflow?.runId
585 const now = await $.clock.now()
586 await update($, sources, s => {
587 const wfName = s.workflows[e.tool_use_id] ?? (runId === undefined ? undefined : `workflow ${runId.slice(3, 9)}`)
588 // A resumed agent keeps the name it was spawned with.
589 const meta: SpendAgentMeta =
590 s.agents[agentId] ??
591 (runId === undefined
592 ? { label: `${e.subagentType} · ${e.description}`, type: e.subagentType, model: r.model }
593 : { label: wfName ?? 'workflow', type: e.description || e.subagentType, runId, model: r.model })
594 const withMeta = { ...s, agents: { ...s.agents, [agentId]: meta } }
595 mem.agents = withMeta.agents
596 return ensureRow(withMeta, attribute(agentId, withMeta), now)
597 })
598 return r
599 }).catch(($, e, next) => next(e))
600
601 on('turn.step', async function* ($, e, next) {
602 const flightId = `${e.agentId ?? 'main'}:${e.turnId}:${e.index}`
603 const flight: Flight = { key: attribute(e.agentId, { ...EMPTY_SOURCES, agents: mem.agents }).key, model: e.model, chars: 0 }
604 mem.flights.set(flightId, flight)
605 const seated = $.clock
606 .now()
607 .then(now => update($, sources, s => ensureRow(s, attribute(e.agentId, s), now)))
608 .catch(() => undefined)
609 const stream = next(e)
610 try {
611 for await (const chunk of stream) {
612 if (chunk.kind === 'text' || chunk.kind === 'thinking') flight.chars += chunk.text.length
613 else if (chunk.kind === 'input') flight.chars += chunk.json.length
614 yield chunk
615 }
616 const result = await stream.result
617 await seated
618 const usage = result.usage
619 if (usage !== null) {
620 const model = usage.model || e.model
621 const parts = priceUsage(model, usage, e.agentId === undefined && mem.isSubscriber ? '1h' : '5m')
622 const now = await $.clock.now()
623 const kind = e.agentId === undefined ? 'main' : mem.agents[e.agentId]?.runId !== undefined ? 'workflows' : 'agents'
624 mem.split[kind] += parts === undefined ? 0 : partsTotal(parts)
625 mem.isStale = true
626 if (mem.backfilling) {
627 mem.held.push({ agentId: e.agentId, model, usage, parts, at: now })
628 } else {
629 await update($, sources, s => addStep(s, attribute(e.agentId, s), model, usage, parts, now))
630 }
631 }
632 return result
633 } finally {
634 mem.flights.delete(flightId)
635 }
636 })
637
638 on('command.run', { command: 'spend' }, async ($, e) => ({ text: await runCommand($, e.args) }))
639
640 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
641 const ui = $.ui.resolve(e)
642 mem.viewedAgentId = e.props.view.agentId
643 const [c, s, se, p, l, now] = await Promise.all([
644 read($, core),
645 read($, sources),
646 read($, series),
647 read($, prefs),
648 read($, live),
649 $.clock.now(),
650 ])
651 return drawPane(
652 ui,
653 e.surface === 'terminal' && 'Raster' in ui ? { Raster: ui.Raster, Image: ui.Image } : undefined,
654 {
655 now,
656 core: c,
657 sources: s,
658 series: se,
659 prefs: p,
660 live: l,
661 width: e.props.bodyColumns,
662 rows: e.props.scroll.bodyRows,
663 placement: e.props.placement,
664 surface: e.surface,
665 viewedAgentId: e.props.view.agentId,
666 gfx: p.gfx === 'cells' || p.gfx === 'pixels' ? p.gfx : mem.gfx,
667 caps: mem.caps,
668 },
669 {
670 cycleUnit: () => void setPrefs($, x => ({ ...x, unit: (x.unit + 1) % UNITS.length })),
671 select: key => void update($, prefs, x => ({ ...x, selected: key })),
672 view: agentId =>
673 void $.ui
674 .view(agentId === undefined ? {} : { agentId })
675 .then(v => v.deny !== undefined && $.ui.toast(`Not shown: ${v.deny}`))
676 .catch(() => undefined),
677 },
678 )
679 })
680}
681hooks/ledger.ts 420 lines1// The meter's arithmetic: who spent what, how fast, and how it is written.
2// Pure functions over the state contract, so the tests drive them directly.
3
4import type { ModelUsage, SessionUsage } from 'claude-code'
5
6import { addParts, NO_PARTS, partsTotal } from './pricing'
7import type { Parts } from './pricing'
8
9import type {
10 SpendCore,
11 SpendSeries,
12 SpendSource,
13 SpendSourceKind,
14 SpendSources,
15 SpendWindow,
16} from '../types'
17
18export const BUCKET_MS = 30_000
19const KEEP_BUCKETS = 240 // two hours of history
20const KEEP_SAMPLES_MS = 30 * 60_000
21const PACE_SPAN_MS = 15 * 60_000
22
23export const EMPTY_SOURCES: SpendSources = { rows: {}, agents: {}, workflows: {} }
24export const EMPTY_SERIES: SpendSeries = { bucketMs: BUCKET_MS, t0: 0, buckets: [] }
25
26// ─── the engine's figures ────────────────────────────────────────────────────
27
28/** Folds a `$.session.usage()` reading into the core, starting it when absent. */
29export function absorbUsage(prev: SpendCore | null, u: SessionUsage, now: number): SpendCore {
30 const usd = u.cost?.usd ?? prev?.usd ?? 0
31 const limits = u.rateLimits.map(l => ({ kind: l.kind, pct: l.percentUsed, resetsAt: l.resetsAt }))
32 const base: SpendCore = prev ?? {
33 usd,
34 usd0: usd,
35 startedAt: u.startedAt,
36 at: now,
37 ctxWindow: u.context.window,
38 limits: [],
39 windows: {},
40 isSubscriber: false,
41 turn: null,
42 compactions: [],
43 fired: [],
44 }
45 return {
46 ...base,
47 usd,
48 startedAt: u.startedAt,
49 at: now,
50 ctxTokens: u.context.tokens,
51 ctxWindow: u.context.window,
52 ctxPct: u.context.percent,
53 limits,
54 windows: absorbWindows(base.windows, limits, now),
55 isSubscriber: base.isSubscriber || limits.length > 0,
56 }
57}
58
59function absorbWindows(
60 prev: Record<string, SpendWindow>,
61 limits: readonly { kind: string; pct: number; resetsAt?: string }[],
62 now: number,
63): Record<string, SpendWindow> {
64 const next: Record<string, SpendWindow> = { ...prev }
65 for (const l of limits) {
66 const w = prev[l.kind]
67 if (w === undefined) {
68 next[l.kind] = { first: l.pct, last: l.pct, resetsAt: l.resetsAt, carried: 0, samples: [[now, l.pct]] }
69 continue
70 }
71 const isReset = l.resetsAt !== undefined && w.resetsAt !== undefined && l.resetsAt !== w.resetsAt && l.pct < w.last
72 const samples = isReset ? [] : keepSamples(w.samples, now)
73 // Only a move is a sample: a window standing still writes nothing, so the pane is not drawn again.
74 if (samples.at(-1)?.[1] !== l.pct) samples.push([now, l.pct])
75 next[l.kind] = isReset
76 ? { first: l.pct, last: l.pct, resetsAt: l.resetsAt, carried: w.carried + Math.max(0, w.last - w.first), samples }
77 : { ...w, last: l.pct, resetsAt: l.resetsAt ?? w.resetsAt, samples }
78 }
79 return next
80}
81
82/** The recent samples, and the newest older one: where the window stood since. */
83function keepSamples(samples: readonly [number, number][], now: number): [number, number][] {
84 const i = samples.findIndex(([at]) => now - at <= KEEP_SAMPLES_MS)
85 return samples.slice(i === -1 ? -1 : Math.max(0, i - 1))
86}
87
88/** How far a window moved while this session watched it, in percent points. */
89export function windowMoved(w: SpendWindow): number {
90 return w.carried + Math.max(0, w.last - w.first)
91}
92
93export type Pace = {
94 /** Percent points a minute over the recent span. */
95 perMin: number
96 /** When it reaches 100 % at that pace, if it climbs at all. */
97 fullAt?: number
98 resetAt?: number
99 /** Whether the pace fills it before it resets. */
100 isShort: boolean
101}
102
103/** The window holds still between its samples: each is taken when it moves. */
104export function pace(w: SpendWindow, now: number): Pace | undefined {
105 const first = w.samples[0]
106 const last = w.samples.at(-1)
107 if (first === undefined || last === undefined) return undefined
108 const from = Math.max(first[0], now - PACE_SPAN_MS)
109 const span = now - from
110 if (span < 3 * 60_000) return undefined
111 // Where the window stood when the span began.
112 const start = w.samples.findLast(([at]) => at <= from) ?? first
113 const perMin = ((last[1] - start[1]) / span) * 60_000
114 const resetAt = w.resetsAt === undefined ? undefined : Date.parse(w.resetsAt)
115 const reset = resetAt !== undefined && Number.isFinite(resetAt) ? resetAt : undefined
116 if (perMin <= 0.001) return { perMin: 0, resetAt: reset, isShort: false }
117 const fullAt = now + ((100 - last[1]) / perMin) * 60_000
118 return { perMin, fullAt, resetAt: reset, isShort: reset === undefined || fullAt < reset }
119}
120
121// ─── who spent it ────────────────────────────────────────────────────────────
122
123export type Attribution = {
124 key: string
125 kind: SpendSourceKind
126 label: string
127 type?: string
128 /** The agent inside a workflow run's row. */
129 member?: { id: string; label: string }
130}
131
132export function attribute(agentId: string | undefined, src: SpendSources): Attribution {
133 if (agentId === undefined) return { key: 'main', kind: 'main', label: 'main thread' }
134 const meta = src.agents[agentId]
135 if (meta === undefined) return { key: 'fork', kind: 'fork', label: 'engine loops' }
136 if (meta.runId !== undefined) {
137 return {
138 key: `wf:${meta.runId}`,
139 kind: 'workflow',
140 label: meta.label,
141 type: 'workflow',
142 member: { id: agentId, label: meta.type },
143 }
144 }
145 return { key: `agent:${agentId}`, kind: 'agent', label: meta.label, type: meta.type }
146}
147
148/** Seats a row for a source that has started but not yet been billed. */
149export function ensureRow(src: SpendSources, who: Attribution, now: number): SpendSources {
150 const row = src.rows[who.key]
151 if (row !== undefined && (who.member === undefined || row.members?.[who.member.id] !== undefined)) {
152 return row.status === 'running' || who.kind === 'main' || who.kind === 'fork'
153 ? src
154 : { ...src, rows: { ...src.rows, [who.key]: { ...row, status: 'running' } } }
155 }
156 return addStep(src, who, undefined, { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, NO_PARTS, now, false)
157}
158
159/** Adds one response to its source's row. */
160export function addStep(
161 src: SpendSources,
162 who: Attribution,
163 model: string | undefined,
164 usage: ModelUsage,
165 parts: Parts | undefined,
166 now: number,
167 isStep = true,
168): SpendSources {
169 const step = isStep ? 1 : 0
170 const usd = parts === undefined ? undefined : partsTotal(parts)
171 const row: SpendSource = src.rows[who.key] ?? {
172 key: who.key,
173 kind: who.kind,
174 label: who.label,
175 type: who.type,
176 usd: 0,
177 cost: NO_PARTS,
178 isPriced: true,
179 tok: { in: 0, out: 0, cr: 0, cw: 0 },
180 steps: 0,
181 status: 'running',
182 firstAt: now,
183 lastAt: now,
184 }
185 const members = who.member === undefined ? row.members : { ...row.members }
186 if (who.member !== undefined && members !== undefined) {
187 const m = members[who.member.id] ?? { label: who.member.label, usd: 0, steps: 0, status: 'running' as const }
188 members[who.member.id] = { ...m, usd: m.usd + (usd ?? 0), steps: m.steps + step, status: 'running' }
189 }
190 const next: SpendSource = {
191 ...row,
192 label: who.label,
193 model: model ?? row.model,
194 usd: row.usd + (usd ?? 0),
195 cost: addParts(row.cost ?? NO_PARTS, parts),
196 isPriced: row.isPriced && usd !== undefined,
197 tok: {
198 in: row.tok.in + usage.input_tokens,
199 out: row.tok.out + usage.output_tokens,
200 cr: row.tok.cr + usage.cache_read_input_tokens,
201 cw: row.tok.cw + usage.cache_creation_input_tokens,
202 },
203 steps: row.steps + step,
204 status: who.kind === 'main' || who.kind === 'fork' ? row.status : 'running',
205 lastAt: isStep ? now : row.lastAt,
206 members,
207 }
208 return { ...src, rows: { ...src.rows, [who.key]: next } }
209}
210
211/** Marks an agent's row (or its member of a workflow row) done or failed. */
212export function finishAgent(src: SpendSources, agentId: string, status: 'done' | 'failed'): SpendSources {
213 const who = attribute(agentId, src)
214 const row = src.rows[who.key]
215 if (row === undefined || who.kind === 'main' || who.kind === 'fork') return src
216 if (who.member === undefined) return { ...src, rows: { ...src.rows, [who.key]: { ...row, status } } }
217 const members = { ...row.members }
218 const m = members[who.member.id]
219 if (m !== undefined) members[who.member.id] = { ...m, status }
220 const isRunning = Object.values(members).some(one => one.status === 'running')
221 const isFailed = Object.values(members).some(one => one.status === 'failed')
222 return {
223 ...src,
224 rows: { ...src.rows, [who.key]: { ...row, members, status: isRunning ? 'running' : isFailed ? 'failed' : 'done' } },
225 }
226}
227
228export function sumTokens(rows: readonly SpendSource[]) {
229 const t = { in: 0, out: 0, cr: 0, cw: 0 }
230 for (const r of rows) {
231 t.in += r.tok.in
232 t.out += r.tok.out
233 t.cr += r.tok.cr
234 t.cw += r.tok.cw
235 }
236 return t
237}
238
239export function sumCost(rows: readonly SpendSource[]): Parts {
240 return rows.reduce((acc, r) => addParts(acc, r.cost), NO_PARTS)
241}
242
243export function cacheHit(t: { in: number; cr: number; cw: number }): number | undefined {
244 const prompt = t.in + t.cr + t.cw
245 return prompt === 0 ? undefined : t.cr / prompt
246}
247
248// ─── over time ───────────────────────────────────────────────────────────────
249
250/**
251 * Moves the series up to `now`, adding what each source spent to the newest
252 * bucket: the bars are the priced responses, timed as they finished.
253 */
254export function addToSeries(s: SpendSeries, now: number, split: { main: number; agents: number; workflows: number }): SpendSeries {
255 const slot = Math.floor(now / s.bucketMs) * s.bucketMs
256 let buckets = s.buckets.slice()
257 let t0 = s.t0
258 if (t0 === 0 || buckets.length === 0) {
259 t0 = slot
260 buckets = [[0, 0, 0, 0]]
261 } else if (slot > t0) {
262 const gap = Math.min(KEEP_BUCKETS, Math.round((slot - t0) / s.bucketMs))
263 for (let i = 0; i < gap; i++) buckets.push([0, 0, 0, 0])
264 t0 = slot
265 }
266 const spent = split.main + split.agents + split.workflows
267 if (spent > 0) {
268 const last = buckets[buckets.length - 1]!
269 buckets[buckets.length - 1] = [
270 last[0] + spent,
271 last[1] + split.main,
272 last[2] + split.agents,
273 last[3] + split.workflows,
274 ]
275 }
276 if (buckets.length > KEEP_BUCKETS) buckets = buckets.slice(-KEEP_BUCKETS)
277 return { ...s, t0, buckets }
278}
279
280/** USD a minute over the last `spanMs`, from the series. */
281export function burnPerMin(s: SpendSeries, now: number, spanMs = 5 * 60_000): number {
282 if (s.buckets.length === 0) return 0
283 const n = Math.max(1, Math.round(spanMs / s.bucketMs))
284 const stale = Math.max(0, Math.round((Math.floor(now / s.bucketMs) * s.bucketMs - s.t0) / s.bucketMs))
285 const take = Math.min(s.buckets.length, n - stale)
286 if (take <= 0) return 0
287 const spent = s.buckets.slice(-take).reduce((a, b) => a + b[0], 0)
288 // A young session has not had the whole span: average over what it had.
289 const watched = Math.min(spanMs, (s.buckets.length + stale) * s.bucketMs)
290 return spent / (Math.max(60_000, watched) / 60_000)
291}
292
293// ─── writing it down ─────────────────────────────────────────────────────────
294
295export function fmtUsd(n: number): string {
296 if (!Number.isFinite(n) || n <= 0) return '$0.00'
297 if (n < 0.1) return `$${n.toFixed(3)}`
298 if (n < 10_000) return `$${n.toFixed(2)}`
299 return `$${(n / 1000).toFixed(1)}k`
300}
301
302export function fmtTok(n: number): string {
303 if (n < 1000) return `${Math.round(n)}`
304 if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`
305 return `${(n / 1e6).toFixed(n < 1e7 ? 2 : 1)}M`
306}
307
308export function fmtDur(ms: number): string {
309 const s = Math.max(0, Math.floor(ms / 1000))
310 if (s < 60) return `${s}s`
311 const m = Math.floor(s / 60)
312 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
313 const h = Math.floor(m / 60)
314 if (h < 48) return `${h}h ${String(m % 60).padStart(2, '0')}m`
315 return `${Math.floor(h / 24)}d ${h % 24}h`
316}
317
318/** A duration to the minute, for what a quiet pane shows: `<1m`, `37m`, `1h 05m`, `2d 3h`. */
319export function fmtMin(ms: number): string {
320 const m = Math.max(0, Math.floor(ms / 60_000))
321 if (m < 1) return '<1m'
322 if (m < 60) return `${m}m`
323 const h = Math.floor(m / 60)
324 if (h < 48) return `${h}h ${String(m % 60).padStart(2, '0')}m`
325 return `${Math.floor(h / 24)}d ${h % 24}h`
326}
327
328/** A coarse duration for a forecast: `37m`, `1h 40m`. */
329export function fmtEta(ms: number): string {
330 const m = Math.max(1, Math.round(ms / 60_000))
331 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
332}
333
334export function fmtPct(n: number): string {
335 return n >= 10 || Number.isInteger(n) ? `${Math.round(n)}%` : `${n.toFixed(1)}%`
336}
337
338const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
339
340/** A bar `width` cells long filled to `frac`, in eighths of a cell. */
341export function blocks(width: number, frac: number): string {
342 const eighths = Math.round(Math.min(1, Math.max(0, frac)) * width * 8)
343 const full = Math.floor(eighths / 8)
344 return '█'.repeat(full) + (EIGHTHS[eighths % 8] ?? '')
345}
346
347/** A thin gauge: `━` filled, `─` the track, `[filled, track]`. */
348export function gauge(width: number, frac: number): [string, string] {
349 const f = Math.round(Math.min(1, Math.max(0, frac)) * width)
350 return ['━'.repeat(f), '─'.repeat(Math.max(0, width - f))]
351}
352
353export function clip(text: string, width: number): string {
354 if (width <= 0) return ''
355 const chars = [...text]
356 return chars.length <= width ? text : `${chars.slice(0, Math.max(0, width - 1)).join('')}…`
357}
358
359// ─── the raster ──────────────────────────────────────────────────────────────
360
361const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
362
363export function base64(bytes: Uint8Array): string {
364 let out = ''
365 for (let i = 0; i < bytes.length; i += 3) {
366 const a = bytes[i]!
367 const b = bytes[i + 1]
368 const c = bytes[i + 2]
369 out += B64[a >> 2]
370 out += B64[((a & 3) << 4) | ((b ?? 0) >> 4)]
371 out += b === undefined ? '=' : B64[((b & 15) << 2) | ((c ?? 0) >> 6)]
372 out += c === undefined ? '=' : B64[c & 63]
373 }
374 return out
375}
376
377export const DEFAULT_COLOR = 0x01000000
378
379/** Packs `[codePoint, fg, bg]` cells, row-major, as a Raster's `cells`. */
380export function packCells(cells: readonly (readonly [number, number, number])[]): string {
381 const words = new Uint32Array(cells.length * 3)
382 cells.forEach(([cp, fg, bg], i) => {
383 words[i * 3] = cp
384 words[i * 3 + 1] = fg
385 words[i * 3 + 2] = bg
386 })
387 return base64(new Uint8Array(words.buffer))
388}
389
390// Each color is one a 256-color terminal rounds the same way whether it comes
391// as a cell of the chart or as text, so the legend matches the chart there too.
392export const PALETTE = {
393 main: 0xd9774b,
394 agents: 0x4ba8f5,
395 workflows: 0xaf7fff,
396 other: 0x8a8a8a,
397 mark: 0x767676, // a compaction's guide line
398 axis: 0x3a3a3a,
399}
400
401export const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
402
403/** One bar of the chart: what each source spent in its bucket, and whether the session compacted then. */
404export type Bar = { main: number; agents: number; workflows: number; isCompacted: boolean }
405
406/** The last `columns` buckets up to `now`, oldest first. */
407export function barsOf(s: SpendSeries, now: number, columns: number, compactedAt: readonly number[] = []): Bar[] {
408 const slot = Math.floor(now / s.bucketMs) * s.bucketMs
409 const stale = s.t0 === 0 ? 0 : Math.max(0, Math.round((slot - s.t0) / s.bucketMs))
410 const series: [number, number, number, number][] = [...s.buckets]
411 for (let i = 0; i < Math.min(stale, columns); i++) series.push([0, 0, 0, 0])
412 const shown = series.slice(-columns)
413 while (shown.length < columns) shown.unshift([0, 0, 0, 0])
414 const first = slot - (columns - 1) * s.bucketMs
415 const marks = new Set(compactedAt.map(at => Math.round((Math.floor(at / s.bucketMs) * s.bucketMs - first) / s.bucketMs)))
416 return shown.map(([, main, agents, workflows], i) => ({ main, agents, workflows, isCompacted: marks.has(i) }))
417}
418
419export const barTotal = (b: Bar) => b.main + b.agents + b.workflows
420hooks/history.ts 151 lines1// What the session spent before the meter watched: read back from its
2// transcripts (the main thread's, each subagent's, each workflow agent's),
3// every response priced exactly as Claude Code prices it.
4
5import type { ModelUsage } from 'claude-code'
6
7import type { SpendAgentMeta, SpendCompaction, SpendSeries, SpendSources } from '../types'
8import { addStep, addToSeries, attribute, EMPTY_SERIES, EMPTY_SOURCES, finishAgent } from './ledger'
9import { partsTotal, priceRaw } from './pricing'
10import type { RawUsage } from './pricing'
11
12export type TranscriptFile = { agentId?: string; meta?: SpendAgentMeta; text: string }
13
14export type Rebuilt = {
15 sources: SpendSources
16 series: SpendSeries
17 usd: number
18 responses: number
19 /** The last prompt's turn: when it began and ended, and what it cost. */
20 turn?: { startedAt: number; endedAt: number; usd: number }
21 compactions: SpendCompaction[]
22}
23
24type Billed = { at: number; agentId?: string; model: string; usage: RawUsage }
25
26const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
27
28// A response is written once per content block, each row carrying its usage
29// as of then: the largest of each count is the response's.
30function merge(a: RawUsage, b: RawUsage): RawUsage {
31 return {
32 input_tokens: Math.max(num(a.input_tokens), num(b.input_tokens)),
33 output_tokens: Math.max(num(a.output_tokens), num(b.output_tokens)),
34 cache_read_input_tokens: Math.max(num(a.cache_read_input_tokens), num(b.cache_read_input_tokens)),
35 cache_creation_input_tokens: Math.max(num(a.cache_creation_input_tokens), num(b.cache_creation_input_tokens)),
36 cache_creation: {
37 ephemeral_5m_input_tokens: Math.max(
38 num(a.cache_creation?.ephemeral_5m_input_tokens),
39 num(b.cache_creation?.ephemeral_5m_input_tokens),
40 ),
41 ephemeral_1h_input_tokens: Math.max(
42 num(a.cache_creation?.ephemeral_1h_input_tokens),
43 num(b.cache_creation?.ephemeral_1h_input_tokens),
44 ),
45 },
46 server_tool_use: {
47 web_search_requests: Math.max(
48 num(a.server_tool_use?.web_search_requests),
49 num(b.server_tool_use?.web_search_requests),
50 ),
51 },
52 inference_geo: b.inference_geo ?? a.inference_geo,
53 }
54}
55
56const asModelUsage = (u: RawUsage): ModelUsage => ({
57 input_tokens: num(u.input_tokens),
58 output_tokens: num(u.output_tokens),
59 cache_read_input_tokens: num(u.cache_read_input_tokens),
60 cache_creation_input_tokens: num(u.cache_creation_input_tokens),
61})
62
63/** Whether a user row is a prompt the person sent, not a tool result or a note. */
64function isPrompt(row: { isMeta?: unknown; isSidechain?: unknown; isCompactSummary?: unknown; message?: { content?: unknown } }) {
65 if (row.isMeta === true || row.isSidechain === true || row.isCompactSummary === true) return false
66 const content = row.message?.content
67 if (typeof content === 'string') return !content.startsWith('<local-command') && !content.startsWith('Caveat:')
68 return (
69 Array.isArray(content) &&
70 content.some(b => (b as { type?: unknown }).type === 'text') &&
71 !content.some(b => (b as { type?: unknown }).type === 'tool_result')
72 )
73}
74
75/** The ledger the transcripts add up to, counting responses up to `cutoff`. */
76export function rebuild(files: readonly TranscriptFile[], cutoff: number): Rebuilt {
77 const agents: Record<string, SpendAgentMeta> = {}
78 for (const f of files) if (f.agentId !== undefined && f.meta !== undefined) agents[f.agentId] = f.meta
79 const billed: Billed[] = []
80 const compactions: SpendCompaction[] = []
81 let promptAt: number | undefined
82 for (const f of files) {
83 const byId = new Map<string, Billed>()
84 for (const line of f.text.split('\n')) {
85 const isMain = f.agentId === undefined
86 const isUser = isMain && line.includes('"type":"user"')
87 const isCompaction = isMain && line.includes('"compact_boundary"')
88 if (!isUser && !isCompaction && !line.includes('"type":"assistant"')) continue
89 let row: Record<string, unknown>
90 try {
91 row = JSON.parse(line) as Record<string, unknown>
92 } catch {
93 continue // a torn last line, or a chunk's edge
94 }
95 const at = Date.parse(String(row['timestamp']))
96 if (!Number.isFinite(at) || at > cutoff) continue
97 if (row['subtype'] === 'compact_boundary') {
98 const meta = row['compactMetadata'] as { trigger?: string; preTokens?: number; postTokens?: number } | undefined
99 compactions.push({ at, trigger: meta?.trigger ?? 'auto', from: meta?.preTokens, to: meta?.postTokens })
100 continue
101 }
102 if (row['type'] === 'user') {
103 if (isPrompt(row as Parameters<typeof isPrompt>[0])) promptAt = at
104 continue
105 }
106 if (row['type'] !== 'assistant') continue
107 const m = row['message'] as { id?: string; model?: string; usage?: RawUsage } | undefined
108 if (m?.usage === undefined || m.model === undefined || m.model === '<synthetic>') continue
109 const id = m.id ?? String(row['requestId'] ?? row['uuid'])
110 const prev = byId.get(id)
111 byId.set(
112 id,
113 prev === undefined
114 ? { at, agentId: f.agentId, model: m.model, usage: m.usage }
115 : { ...prev, at: Math.max(prev.at, at), usage: merge(prev.usage, m.usage) },
116 )
117 }
118 billed.push(...byId.values())
119 }
120 billed.sort((a, b) => a.at - b.at)
121
122 let sources: SpendSources = { ...EMPTY_SOURCES, agents }
123 let series: SpendSeries = EMPTY_SERIES
124 let usd = 0
125 let turnUsd = 0
126 let lastAt = 0
127 for (const b of billed) {
128 const parts = priceRaw(b.model, b.usage)
129 const who = attribute(b.agentId, sources)
130 sources = addStep(sources, who, b.model, asModelUsage(b.usage), parts, b.at)
131 const c = parts === undefined ? 0 : partsTotal(parts)
132 usd += c
133 series = addToSeries(series, b.at, {
134 main: who.kind === 'main' ? c : 0,
135 agents: who.kind === 'agent' || who.kind === 'fork' ? c : 0,
136 workflows: who.kind === 'workflow' ? c : 0,
137 })
138 if (promptAt !== undefined && b.at >= promptAt) turnUsd += c
139 lastAt = b.at
140 }
141 for (const id of Object.keys(agents)) sources = finishAgent(sources, id, 'done')
142 return {
143 sources,
144 series,
145 usd,
146 responses: billed.length,
147 turn: promptAt === undefined ? undefined : { startedAt: promptAt, endedAt: Math.max(lastAt, promptAt), usd: turnUsd },
148 compactions: compactions.slice(-20),
149 }
150}
151hooks/pricing.ts 180 lines1// List prices per million tokens, as Claude Code 2.1.300's own model catalog
2// has them (the same table /cost prices with), and the step pricing it does.
3// The engine hands a mod the session's total but not each response's dollars,
4// so this prices each response to say who spent what; the total stays the
5// engine's.
6
7import type { ModelUsage } from 'claude-code'
8
9type Rates = { in: number; out: number; w5m: number; w1h: number; read: number }
10type Tier = Rates & { long?: Rates & { above: number } }
11
12const tier = (input: number, output: number, read = input / 10): Tier => ({
13 in: input,
14 out: output,
15 w5m: input * 1.25,
16 w1h: input * 2,
17 read,
18})
19
20const TIERS = {
21 haiku_35: tier(0.8, 4),
22 haiku_45: tier(1, 5),
23 haiku_55: { ...tier(0.1, 0.5), long: { ...tier(0.5, 2.5), above: 100_000 } },
24 tier_2_10: tier(2, 10),
25 tier_2_10_cr_010: tier(2, 10, 0.1),
26 tier_3_15: tier(3, 15),
27 tier_4_20_cr_020: tier(4, 20, 0.2),
28 tier_5_25: tier(5, 25),
29 tier_10_50: tier(10, 50),
30 tier_10_50_cr_025: tier(10, 50, 0.25),
31 tier_15_75: tier(15, 75),
32} satisfies Record<string, Tier>
33
34const CATALOG: Record<string, { name: string; tier: keyof typeof TIERS }> = {
35 'claude-3-5-haiku': { name: 'Haiku 3.5', tier: 'haiku_35' },
36 'claude-haiku-4-5': { name: 'Haiku 4.5', tier: 'haiku_45' },
37 'claude-haiku-5-5': { name: 'Haiku 5.5', tier: 'haiku_55' },
38 'claude-3-5-sonnet': { name: 'Sonnet 3.5', tier: 'tier_3_15' },
39 'claude-3-7-sonnet': { name: 'Sonnet 3.7', tier: 'tier_3_15' },
40 'claude-sonnet-4-0': { name: 'Sonnet 4', tier: 'tier_3_15' },
41 'claude-sonnet-4-5': { name: 'Sonnet 4.5', tier: 'tier_3_15' },
42 'claude-sonnet-4-6': { name: 'Sonnet 4.6', tier: 'tier_3_15' },
43 'claude-sonnet-5': { name: 'Sonnet 5', tier: 'tier_2_10' },
44 'claude-sonnet-5-5': { name: 'Sonnet 5.5', tier: 'tier_2_10_cr_010' },
45 'claude-opus-4-0': { name: 'Opus 4', tier: 'tier_15_75' },
46 'claude-opus-4-1': { name: 'Opus 4.1', tier: 'tier_15_75' },
47 'claude-opus-4-5': { name: 'Opus 4.5', tier: 'tier_5_25' },
48 'claude-opus-4-6': { name: 'Opus 4.6', tier: 'tier_5_25' },
49 'claude-opus-4-7': { name: 'Opus 4.7', tier: 'tier_5_25' },
50 'claude-opus-4-8': { name: 'Opus 4.8', tier: 'tier_5_25' },
51 'claude-opus-5': { name: 'Opus 5', tier: 'tier_5_25' },
52 'claude-opus-5-5': { name: 'Opus 5.5', tier: 'tier_4_20_cr_020' },
53 'claude-fable-5': { name: 'Fable 5', tier: 'tier_10_50' },
54 'claude-fable-5-1': { name: 'Fable 5.1', tier: 'tier_10_50_cr_025' },
55 'claude-mythos-5': { name: 'Mythos 5', tier: 'tier_10_50' },
56 'claude-mythos-5-1': { name: 'Mythos 5.1', tier: 'tier_10_50_cr_025' },
57}
58
59// Longest first, so `claude-opus-5-5` wins over `claude-opus-5`.
60const IDS = Object.keys(CATALOG).sort((a, b) => b.length - a.length)
61
62/**
63 * The catalog id a reported model id names: a dated id
64 * (`claude-opus-5-5-20261001`), a provider's (`us.anthropic.claude-...-v1:0`)
65 * or one with a context suffix (`claude-fable-5-1[1m]`).
66 */
67export function catalogId(model: string | undefined): string | undefined {
68 if (model === undefined) return undefined
69 const id = model.toLowerCase()
70 for (const known of IDS) {
71 const at = id.indexOf(known)
72 if (at < 0) continue
73 const before = id[at - 1]
74 const rest = id.slice(at + known.length)
75 if (before !== undefined && /[a-z0-9]/.test(before)) continue
76 // `-5` after `claude-opus-5` is another model; `-20261001` is a date.
77 if (/^-\d(\D|$)/.test(rest) || /^\d/.test(rest)) continue
78 return known
79 }
80 return undefined
81}
82
83export function modelName(model: string | undefined): string {
84 const id = catalogId(model)
85 if (id !== undefined) return CATALOG[id]?.name ?? id
86 if (model === undefined || model === '') return '?'
87 return model.replace(/^claude-/, '')
88}
89
90/** USD per million output tokens, for pricing what is still streaming. */
91export function outputRate(model: string | undefined): number {
92 const id = catalogId(model)
93 return id === undefined ? 0 : (TIERS[CATALOG[id]!.tier].out ?? 0)
94}
95
96/** USD per million uncached input tokens: what input would cost without the cache. */
97export function inputRate(model: string | undefined): number {
98 const id = catalogId(model)
99 return id === undefined ? 0 : TIERS[CATALOG[id]!.tier].in
100}
101
102/** USD per million cache-read tokens: what re-reading the context costs. */
103export function readRate(model: string | undefined): number {
104 const id = catalogId(model)
105 return id === undefined ? 0 : TIERS[CATALOG[id]!.tier].read
106}
107
108/** A response's usage as the transcript records it: the API's own fields. */
109export type RawUsage = {
110 input_tokens?: number
111 output_tokens?: number
112 cache_read_input_tokens?: number
113 cache_creation_input_tokens?: number
114 cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
115 server_tool_use?: { web_search_requests?: number }
116 inference_geo?: string
117}
118
119/**
120 * What a response cost, by what was paid for: `output` the tokens the model
121 * wrote, `write` new input stored in the prompt cache, `read` input served
122 * from it, `input` input never cached (web searches count here too).
123 */
124export type Parts = { input: number; output: number; read: number; write: number }
125
126export const NO_PARTS: Parts = { input: 0, output: 0, read: 0, write: 0 }
127
128export const partsTotal = (p: Parts) => p.input + p.output + p.read + p.write
129
130export function addParts(a: Parts, b: Parts | undefined): Parts {
131 if (b === undefined) return a
132 return { input: a.input + b.input, output: a.output + b.output, read: a.read + b.read, write: a.write + b.write }
133}
134
135function rates(model: string | undefined, prompt: number): Rates | undefined {
136 const id = catalogId(model)
137 if (id === undefined) return undefined
138 const base: Tier = TIERS[CATALOG[id]!.tier]
139 return base.long !== undefined && prompt > base.long.above ? base.long : base
140}
141
142/**
143 * What a transcript's response cost, priced as Claude Code prices it: the
144 * cache writes split by their TTL, US-only inference at 1.1×, web searches
145 * at a cent each. Undefined for a model the catalog lacks.
146 */
147export function priceRaw(model: string | undefined, u: RawUsage): Parts | undefined {
148 const input = u.input_tokens ?? 0
149 const read = u.cache_read_input_tokens ?? 0
150 const written = u.cache_creation_input_tokens ?? 0
151 const r = rates(model, input + read + written)
152 if (r === undefined) return undefined
153 const w1h = Math.min(u.cache_creation?.ephemeral_1h_input_tokens ?? 0, written)
154 const k = (u.inference_geo === 'us' ? 1.1 : 1) / 1e6
155 return {
156 input: input * r.in * k + (u.server_tool_use?.web_search_requests ?? 0) * 0.01,
157 output: (u.output_tokens ?? 0) * r.out * k,
158 read: read * r.read * k,
159 write: (w1h * r.w1h + (written - w1h) * r.w5m) * k,
160 }
161}
162
163/**
164 * What one response cost at list price, or undefined for a model the catalog
165 * lacks. `ttl` is how long the prompt cache was written for: Claude Code
166 * writes the main thread's for an hour on a subscription, a subagent's for
167 * five minutes.
168 */
169export function priceUsage(model: string | undefined, usage: ModelUsage, ttl: '5m' | '1h'): Parts | undefined {
170 const prompt = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
171 const r = rates(model, prompt)
172 if (r === undefined) return undefined
173 return {
174 input: (usage.input_tokens * r.in) / 1e6,
175 output: (usage.output_tokens * r.out) / 1e6,
176 read: (usage.cache_read_input_tokens * r.read) / 1e6,
177 write: (usage.cache_creation_input_tokens * (ttl === '1h' ? r.w1h : r.w5m)) / 1e6,
178 }
179}
180hooks/view.tsx 672 lines1// The pane's drawing: a tree from the meter's state, sized to the pane body.
2
3import type { Elements, RenderElement, RenderSurface } from 'claude-code'
4
5import type { SpendCompaction, SpendCore, SpendLive, SpendPrefs, SpendSeries, SpendSource, SpendSources } from '../types'
6import { areaCells, areaPixels, areaText } from './art'
7import {
8 barsOf,
9 barTotal,
10 burnPerMin,
11 cacheHit,
12 clip,
13 fmtDur,
14 fmtEta,
15 fmtMin,
16 fmtPct,
17 fmtTok,
18 fmtUsd,
19 gauge,
20 hex,
21 pace,
22 packCells,
23 PALETTE,
24 sumCost,
25 sumTokens,
26 windowMoved,
27} from './ledger'
28import { inputRate, modelName, readRate } from './pricing'
29
30export const UNITS = [
31 { id: 'usd', label: 'this session · USD at API list price' },
32 { id: 'tok', label: 'tokens through the models this session' },
33 { id: 'coffee', label: 'flat whites at $4.50', per: 4.5, glyph: '☕ ' },
34 { id: 'banana', label: 'bananas, for scale', per: 0.3, glyph: '🍌 ' },
35 { id: 'dev', label: 'minutes of a senior engineer', per: 1.5, glyph: '' },
36] as const
37
38const KIND_COLOR = {
39 main: hex(PALETTE.main),
40 agent: hex(PALETTE.agents),
41 workflow: hex(PALETTE.workflows),
42 fork: hex(PALETTE.other),
43}
44
45const KIND_NAME = { main: 'main thread', agent: 'subagents', workflow: 'workflows' } as const
46
47const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
48
49export type ViewData = {
50 now: number
51 core: SpendCore | null
52 sources: SpendSources
53 series: SpendSeries
54 prefs: SpendPrefs
55 live: SpendLive
56 width: number
57 rows: number
58 placement: 'dock' | 'inline'
59 surface: RenderSurface
60 viewedAgentId?: string
61 /** Whether the chart may be a kitty-protocol picture rather than cells. */
62 gfx: 'cells' | 'pixels'
63 caps: Caps
64}
65
66export type ViewActions = {
67 cycleUnit: () => void
68 select: (key: string | null) => void
69 /** Puts an agent's transcript in the main view; undefined goes back to the main one. */
70 view: (agentId: string | undefined) => void
71}
72
73/** What the engine the meter runs in can draw and do, as its version says. */
74export type Caps = {
75 /** Text `weight`, `size`, `tabular` and `mono`, since 2.1.299; an older engine refuses a tree that names them. */
76 textStyles: boolean
77 /** `$.ui.view`, since 2.1.300. */
78 agentView: boolean
79}
80
81export const OLD_ENGINE: Caps = { textStyles: false, agentView: false }
82
83export function capsOf(v: { version: string; base?: string } | undefined): Caps {
84 const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v?.base ?? v?.version ?? '')
85 if (m === null) return OLD_ENGINE
86 const n = Number(m[1]) * 1e6 + Number(m[2]) * 1e3 + Number(m[3])
87 return { textStyles: n >= 2_001_299, agentView: n >= 2_001_300 }
88}
89
90/** Text styles as the engine spells them: bold was `bold` before 2.1.299, `weight` since. */
91function looksOf(caps: Caps) {
92 return caps.textStyles
93 ? ({ strong: { weight: 'semibold' }, hero: { weight: 'semibold', size: 'heading', tabular: true }, num: { tabular: true }, table: { mono: true } } as const)
94 : ({ strong: { bold: true }, hero: { bold: true }, num: {}, table: {} } as const)
95}
96type Looks = ReturnType<typeof looksOf>
97
98type Ui = Elements[RenderSurface]
99/** The terminal's own elements: cells colored one by one, and pictures. */
100export type Art = { Raster: Elements['terminal']['Raster']; Image: Elements['terminal']['Image'] }
101
102type Row = {
103 key: string
104 kind: SpendSource['kind'] | 'overhead' | 'earlier'
105 label: string
106 usd: number
107 status?: SpendSource['status']
108 isStreaming: boolean
109 source?: SpendSource
110}
111
112const pctColor = (pct: number) => (pct >= 80 ? 'error' : pct >= 50 ? 'warning' : 'success')
113
114function windowName(kind: string): string {
115 if (kind === 'five_hour') return '5h'
116 if (kind === 'seven_day') return '7d'
117 if (kind === 'spend_limit') return '$'
118 if (kind.startsWith('seven_day_')) return `7d${kind.slice(10, 11)}`
119 return kind.slice(0, 3)
120}
121
122/** How good a cache hit rate is, said in a word. */
123function hitWord(hit: number): [string, string] {
124 if (hit >= 0.95) return ['excellent', 'success']
125 if (hit >= 0.85) return ['good', 'success']
126 if (hit >= 0.7) return ['fair', 'warning']
127 return ['poor', 'error']
128}
129
130/** The rows of "where it goes", the engine's total split as far as it can be. */
131export function rowsOf(d: ViewData): Row[] {
132 const live = new Set(d.live.keys)
133 const rows: Row[] = Object.values(d.sources.rows).map(s => ({
134 key: s.key,
135 kind: s.kind,
136 label: s.kind === 'main' ? `main thread · ${modelName(s.model)}` : s.kind === 'fork' ? 'compaction & memory' : s.label,
137 usd: s.usd,
138 status: s.status,
139 isStreaming: live.has(s.key),
140 source: s,
141 }))
142 const order = (r: Row) => (r.kind === 'main' ? 0 : r.kind === 'fork' ? 3 : r.status === 'running' ? 1 : 2)
143 rows.sort((a, b) => order(a) - order(b) || b.usd - a.usd)
144 if (d.core !== null) {
145 const priced = rows.reduce((a, r) => a + r.usd, 0)
146 const overhead = d.core.usd - d.core.usd0 - priced
147 if (overhead >= 0.0005) {
148 rows.push({ key: 'overhead', kind: 'overhead', label: 'side calls: classifier, titles', usd: overhead, isStreaming: false })
149 }
150 if (d.core.usd0 >= 0.0005) {
151 rows.push({ key: 'earlier', kind: 'earlier', label: 'before the meter', usd: d.core.usd0, isStreaming: false })
152 }
153 }
154 return rows
155}
156
157function glyphOf(r: Row, tick: number): string {
158 if (r.isStreaming) return SPINNER[tick % SPINNER.length]!
159 if (r.kind === 'overhead') return '◇'
160 if (r.kind === 'earlier') return '·'
161 if (r.kind === 'fork') return '○'
162 if (r.status === 'done') return '✓'
163 if (r.status === 'failed') return '✗'
164 return r.kind === 'workflow' ? '◆' : '●'
165}
166
167function colorOf(r: Row): string {
168 if (r.kind === 'overhead' || r.kind === 'earlier') return 'subtle'
169 return KIND_COLOR[r.kind]
170}
171
172function heroValue(d: ViewData, tokens: number): { text: string; caption: string } {
173 const unit = UNITS[d.prefs.unit % UNITS.length]!
174 const usd = d.core?.usd ?? 0
175 if (unit.id === 'usd') return { text: fmtUsd(usd), caption: unit.label }
176 if (unit.id === 'tok') return { text: fmtTok(tokens), caption: unit.label }
177 const n = usd / unit.per
178 return { text: `${unit.glyph}${n >= 100 ? n.toFixed(0) : n.toFixed(1)}`, caption: `${unit.label} · ${fmtUsd(usd)}` }
179}
180
181function compactionNote(list: readonly SpendCompaction[] | undefined, now: number): string | undefined {
182 const last = list?.at(-1)
183 if (last === undefined) return undefined
184 const sizes = last.from === undefined ? '' : ` · ${fmtTok(last.from)} → ${last.to === undefined ? '…' : fmtTok(last.to)}`
185 const times = list!.length > 1 ? `compacted ${list!.length}×, last ` : 'compacted '
186 return `${times}${fmtMin(now - last.at)} ago${sizes}`
187}
188
189/**
190 * What the pane shows that moves with the clock alone, written as the pane writes it.
191 * The meter draws the pane again only when this changes: a quiet pane, once a minute.
192 */
193export function clockText(d: Pick<ViewData, 'now' | 'core' | 'series' | 'sources' | 'prefs' | 'viewedAgentId'>): string {
194 const core = d.core
195 if (core === null) return ''
196 const burn = burnPerMin(d.series, d.now)
197 const parts = [fmtMin(d.now - core.startedAt), fmtUsd(burn), fmtUsd(burn * 60)]
198 if (core.turn !== null && core.turn.endedAt === undefined) parts.push(fmtDur(d.now - core.turn.startedAt))
199 for (const l of core.limits) parts.push(l.resetsAt === undefined ? '' : fmtMin(Date.parse(l.resetsAt) - d.now))
200 for (const w of Object.values(core.windows)) {
201 const p = pace(w, d.now)
202 const full = p?.fullAt === undefined ? '' : `${fmtEta(p.fullAt - d.now)} ${p.fullAt - d.now < 60 * 60_000}`
203 parts.push(p === undefined ? '' : `${p.perMin > 0} ${p.isShort} ${full}`)
204 }
205 parts.push(compactionNote(core.compactions, d.now) ?? '')
206 // The chart moves a bucket at a time while a bar or a mark is in sight.
207 const bars = barsOf(d.series, d.now, 240, (core.compactions ?? []).map(c => c.at))
208 if (bars.some(b => barTotal(b) > 0 || b.isCompacted)) parts.push(String(Math.floor(d.now / d.series.bucketMs)))
209 const selected = d.prefs.selected ?? (d.viewedAgentId === undefined ? null : `agent:${d.viewedAgentId}`)
210 const s = selected === null ? undefined : d.sources.rows[selected]
211 if (s?.status === 'running' && s.kind !== 'main' && d.now - s.lastAt >= 5000) parts.push(fmtDur(d.now - s.lastAt))
212 return parts.join('|')
213}
214
215export function drawPane(ui: Ui, art: Art | undefined, d: ViewData, act: ViewActions): RenderElement {
216 const { Box, Text, Button } = ui
217 const W = Math.max(24, d.width - 2) // the root Box pads a cell each side
218 const core = d.core
219 const tick = d.live.tick
220 const sourceRows = Object.values(d.sources.rows)
221 const tokens = sumTokens(sourceRows)
222 const allTokens = tokens.in + tokens.out + tokens.cr + tokens.cw
223 const hero = heroValue(d, allTokens)
224 const budget = d.prefs.budget
225 const usd = core?.usd ?? 0
226 const budgetFrac = budget === null || budget <= 0 ? undefined : usd / budget
227 const heroColor = budgetFrac === undefined ? 'claude' : budgetFrac >= 1 ? 'error' : budgetFrac >= 0.8 ? 'warning' : 'claude'
228 const isBusy = d.live.inflight > 0
229 const main = d.sources.rows['main']
230 const isInline = d.placement === 'inline'
231 const look = looksOf(d.caps)
232
233 const heading = (text: string, right?: string) => (
234 <Box justifyContent="space-between" marginTop={1}>
235 <Text {...look.strong} color="subtle">
236 {text}
237 </Text>
238 {right !== undefined && <Text dimColor>{right}</Text>}
239 </Box>
240 )
241
242 // ── header ──
243 const header = (
244 <Box justifyContent="space-between">
245 <Box gap={2}>
246 <Text {...look.strong} color="claude">
247 ◆ spend
248 </Text>
249 {isBusy ? (
250 <Text color="claude">
251 {SPINNER[tick % SPINNER.length]} {d.live.inflight} {d.live.inflight === 1 ? 'request' : 'requests'} live
252 </Text>
253 ) : (
254 <Text dimColor>idle</Text>
255 )}
256 </Box>
257 <Text dimColor>{core === null ? '' : fmtMin(d.now - core.startedAt)}</Text>
258 </Box>
259 )
260
261 if (core === null) {
262 return (
263 <Box flexDirection="column" paddingX={1}>
264 {header}
265 <Text dimColor>Measuring the session…</Text>
266 </Box>
267 )
268 }
269
270 // ── the total ──
271 const brrr = d.live.flash?.kind === 'brrr' && d.live.flash.until > d.now
272 const streaming = d.live.estUsd >= 0.0005 ? `+${fmtUsd(d.live.estUsd)} streaming` : undefined
273 const total = (
274 <Box flexDirection="column" marginTop={1}>
275 {brrr ? (
276 printer(Text, tick)
277 ) : (
278 <Box justifyContent="space-between">
279 <Text {...look.hero} color={heroColor}>
280 {hero.text}
281 </Text>
282 {streaming !== undefined && (
283 <Text {...look.num} color="claude">
284 {streaming}
285 </Text>
286 )}
287 </Box>
288 )}
289 <Button key="caption" plain dimColor onPress={act.cycleUnit}>
290 {hero.caption} ⇄
291 </Button>
292 </Box>
293 )
294
295 // ── the turn, the pace, the budget ──
296 const turn = core.turn
297 const turnUsd = turn === null ? 0 : (turn.usd1 ?? core.usd) - turn.usd0
298 const turnMs = turn === null ? 0 : (turn.endedAt ?? d.now) - turn.startedAt
299 const burn = burnPerMin(d.series, d.now)
300 const statLine = (label: string, value: string, right: string, color?: string) => (
301 <Box justifyContent="space-between">
302 <Box gap={1}>
303 <Text {...look.table} dimColor>
304 {label.padEnd(10)}
305 </Text>
306 <Text {...look.num} color={color}>
307 {value}
308 </Text>
309 </Box>
310 <Text dimColor>{right}</Text>
311 </Box>
312 )
313 const budgetColor = budgetFrac === undefined ? undefined : budgetFrac >= 1 ? 'error' : budgetFrac >= 0.8 ? 'warning' : 'success'
314 const [budgetOn, budgetOff] = gauge(Math.max(6, W - 27), budgetFrac ?? 0)
315 const stats = (
316 <Box flexDirection="column" marginTop={1}>
317 {turn !== null &&
318 statLine(
319 turn.endedAt === undefined ? 'this turn' : 'last turn',
320 `+${fmtUsd(turnUsd)}`,
321 `${fmtDur(turnMs)}${turn.endedAt === undefined ? ' so far' : ''}`,
322 )}
323 {statLine('burn', burn < 0.0005 ? 'idle' : `${fmtUsd(burn)}/min`, burn < 0.0005 ? '' : `≈ ${fmtUsd(burn * 60)}/h`)}
324 {budget !== null && budgetFrac !== undefined && (
325 <Box justifyContent="space-between">
326 <Box gap={1}>
327 <Text dimColor>{'budget'.padEnd(10)}</Text>
328 <Text>
329 <Text color={budgetColor}>{budgetOn}</Text>
330 <Text dimColor>{budgetOff}</Text>
331 </Text>
332 </Box>
333 <Text color={budgetColor}>
334 {fmtPct(budgetFrac * 100)} of {fmtUsd(budget)}
335 </Text>
336 </Box>
337 )}
338 </Box>
339 )
340
341 // ── over time ──
342 const spanMin = Math.round((W * d.series.bucketMs) / 60_000)
343 const bars = barsOf(
344 d.series,
345 d.now,
346 W,
347 (core.compactions ?? []).map(c => c.at),
348 )
349 const peak = Math.max(...bars.map(barTotal))
350 const chartRows = Math.min(7, Math.max(3, Math.floor((d.rows - 30) / 3)))
351 const kinds = (['main', 'agent', 'workflow'] as const).filter(k =>
352 bars.some(b => (k === 'main' ? b.main : k === 'agent' ? b.agents : b.workflows) > 0),
353 )
354 let chart: RenderElement
355 if (peak <= 0) {
356 chart = <Text dimColor>No spend in the last {spanMin} minutes.</Text>
357 } else if (art !== undefined && d.gfx === 'pixels') {
358 chart = art.Image({ key: 'chart', source: areaPixels(bars, chartRows), columns: W, rows: chartRows, alt: areaText(bars) })
359 } else if (art !== undefined) {
360 chart = art.Raster({ key: 'chart', columns: W, rows: chartRows, cells: packCells(areaCells(bars, chartRows)) })
361 } else {
362 chart = <Text color="claude">{areaText(bars)}</Text>
363 }
364 const overTime = (
365 <Box flexDirection="column">
366 {heading('OVER TIME', peak > 0 ? `last ${spanMin} min · peak ${fmtUsd(peak)}/${Math.round(d.series.bucketMs / 1000)}s` : `last ${spanMin} min`)}
367 {chart}
368 {kinds.length > 1 && (
369 <Box flexWrap="wrap" columnGap={2}>
370 {kinds.map(k => (
371 <Text>
372 <Text color={KIND_COLOR[k]}>■ </Text>
373 <Text dimColor>{KIND_NAME[k]}</Text>
374 </Text>
375 ))}
376 </Box>
377 )}
378 </Box>
379 )
380
381 // ── limits ──
382 const gaugeW = Math.max(6, W - 21)
383 const limitLines: RenderElement[] = []
384 const note = (text: RenderElement | string) => (
385 <Box paddingLeft={4}>
386 <Text dimColor wrap="truncate-end">
387 {text}
388 </Text>
389 </Box>
390 )
391 for (const l of core.limits) {
392 const [on, off] = gauge(gaugeW, l.pct / 100)
393 const resets = l.resetsAt === undefined ? '' : `↻ ${fmtMin(Date.parse(l.resetsAt) - d.now)}`
394 limitLines.push(
395 <Box justifyContent="space-between">
396 <Box gap={1}>
397 <Text {...look.strong} {...look.table}>
398 {windowName(l.kind).padEnd(3)}
399 </Text>
400 <Text>
401 <Text color={pctColor(l.pct)}>{on}</Text>
402 <Text dimColor>{off}</Text>
403 </Text>
404 <Text {...look.table} color={pctColor(l.pct)}>
405 {fmtPct(l.pct).padStart(4)}
406 </Text>
407 </Box>
408 <Text dimColor>{resets}</Text>
409 </Box>,
410 )
411 const w = core.windows[l.kind]
412 if (w === undefined || isInline) continue
413 const moved = windowMoved(w)
414 const p = pace(w, d.now)
415 if (moved < 0.05 && (p === undefined || p.perMin <= 0)) continue
416 const parts: RenderElement[] = [<Text dimColor>+{moved.toFixed(1)} pts since start</Text>]
417 if (p !== undefined && p.perMin > 0 && p.fullAt !== undefined) {
418 parts.push(
419 p.isShort ? (
420 <Text color={p.fullAt - d.now < 60 * 60_000 ? 'error' : 'warning'}> · full in ~{fmtEta(p.fullAt - d.now)} ⚠</Text>
421 ) : (
422 <Text color="success"> · pace ok</Text>
423 ),
424 )
425 }
426 limitLines.push(note(<Text>{parts}</Text>))
427 }
428 if (core.ctxPct !== undefined) {
429 const [on, off] = gauge(gaugeW, core.ctxPct / 100)
430 limitLines.push(
431 <Box justifyContent="space-between">
432 <Box gap={1}>
433 <Text {...look.strong}>ctx</Text>
434 <Text>
435 <Text color={pctColor(core.ctxPct)}>{on}</Text>
436 <Text dimColor>{off}</Text>
437 </Text>
438 <Text {...look.table} color={pctColor(core.ctxPct)}>
439 {fmtPct(core.ctxPct).padStart(4)}
440 </Text>
441 </Box>
442 <Text dimColor>
443 {fmtTok(core.ctxTokens ?? 0)}/{fmtTok(core.ctxWindow)}
444 </Text>
445 </Box>,
446 )
447 const tax = ((core.ctxTokens ?? 0) * readRate(main?.model)) / 1e6
448 if (tax > 0 && !isInline) limitLines.push(note(`≈ ${fmtUsd(tax)} to re-read per main request`))
449 const compacted = compactionNote(core.compactions, d.now)
450 if (compacted !== undefined && !isInline) {
451 // The same mark the chart draws where the session compacted.
452 limitLines.push(
453 note(
454 <Text>
455 <Text color={hex(PALETTE.mark)}>│ </Text>
456 {compacted}
457 </Text>,
458 ),
459 )
460 }
461 }
462
463 // ── where it goes ──
464 const rows = rowsOf(d)
465 const costW = 8
466 const barW = W >= 36 ? 7 : 0
467 const labelW = Math.max(6, W - 1 - 2 - costW - barW)
468 // Shares of what the meter watched: the spend before it has no bar.
469 const watched = Math.max(0.000001, rows.reduce((a, r) => a + (r.kind === 'earlier' ? 0 : r.usd), 0))
470 const maxRows = isInline ? 3 : Math.max(4, d.rows - 34)
471 const selected = d.prefs.selected ?? (d.viewedAgentId === undefined ? null : `agent:${d.viewedAgentId}`)
472 const sourceLines: RenderElement[] = []
473 for (const r of rows.slice(0, maxRows)) {
474 const isSel = selected === r.key
475 // The engine's own calls, named in full where the row has room.
476 const label = r.kind === 'overhead' && r.label.length > labelW ? 'side calls' : r.label
477 // The row's share as a thin gauge, the limits' kind: a proportion, not a badge.
478 const [shareOn, shareOff] = barW === 0 || r.kind === 'earlier' ? ['', ''] : gauge(barW - 1, r.usd / watched)
479 const cost = r.usd === 0 && r.isStreaming ? '…' : `${r.source?.isPriced === false ? '≥' : ''}${fmtUsd(r.usd)}`
480 const children = [
481 <Text color={colorOf(r)}>{glyphOf(r, tick)}</Text>,
482 ` ${clip(label, labelW).padEnd(labelW)} `,
483 <Text {...(isSel ? look.strong : {})}>{cost.padStart(costW)}</Text>,
484 shareOn === '' && shareOff === '' ? '' : ' ',
485 <Text color={colorOf(r)}>{shareOn}</Text>,
486 <Text dimColor>{shareOff}</Text>,
487 ]
488 sourceLines.push(
489 r.source !== undefined ? (
490 <Button key={`src:${r.key}`} plain onPress={() => act.select(isSel ? null : r.key)}>
491 <Text {...look.table}>{children}</Text>
492 </Button>
493 ) : (
494 <Text {...look.table} dimColor={r.kind === 'earlier'}>
495 {children}
496 </Text>
497 ),
498 )
499 if (isSel && r.source !== undefined) sourceLines.push(details(ui, look, r.source, d, W, act.view))
500 }
501 const hidden = rows.slice(maxRows)
502 if (hidden.length > 0) {
503 sourceLines.push(
504 <Text dimColor>
505 {' '}+{hidden.length} more · {fmtUsd(hidden.reduce((a, r) => a + r.usd, 0))}
506 </Text>,
507 )
508 }
509
510 // ── what the tokens cost ──
511 const cost = sumCost(sourceRows)
512 const hit = cacheHit(tokens)
513 const tokW = 7
514 const tokenLabelW = Math.max(8, W - tokW - costW - 2)
515 const isCacheOpen = d.prefs.selected === 'cache'
516 const tokenRow = (label: string, n: number, usdOf: number, color?: string) => (
517 <Text {...look.table}>
518 <Text color={color}>{clip(label, tokenLabelW).padEnd(tokenLabelW)}</Text> {fmtTok(n).padStart(tokW)} {fmtUsd(usdOf).padStart(costW)}
519 </Text>
520 )
521 const tokenLines = (
522 <Box flexDirection="column">
523 <Box justifyContent="space-between" marginTop={1}>
524 <Text {...look.strong} color="subtle">
525 TOKENS
526 </Text>
527 <Text {...look.table} dimColor>
528 {'tokens'.padStart(tokW)} {'cost'.padStart(costW)}
529 </Text>
530 </Box>
531 {tokenRow('written by models', tokens.out, cost.output)}
532 {tokenRow('new input, cached', tokens.cw, cost.write)}
533 {tokenRow('re-read from cache', tokens.cr, cost.read)}
534 {tokens.in > 0 && tokenRow('uncached input', tokens.in, cost.input)}
535 {hit !== undefined && cacheLine(Button, Text, look, hit, W, isCacheOpen, () => act.select(isCacheOpen ? null : 'cache'))}
536 {hit !== undefined && isCacheOpen && (
537 <Box paddingLeft={2} marginBottom={1}>
538 <Text dimColor>{cacheExplained(sourceRows, tokens.cr, cost.read, main?.model)}</Text>
539 </Box>
540 )}
541 </Box>
542 )
543
544 if (isInline) {
545 return (
546 <Box flexDirection="column" paddingX={1}>
547 <Box justifyContent="space-between">
548 <Box gap={1}>
549 <Text {...look.strong} {...look.num} color={heroColor}>
550 {hero.text}
551 </Text>
552 {turn !== null && <Text dimColor>this turn +{fmtUsd(turnUsd)}</Text>}
553 </Box>
554 <Text dimColor>{burn < 0.0005 ? 'idle' : `${fmtUsd(burn)}/min`}</Text>
555 </Box>
556 {limitLines}
557 {sourceLines}
558 </Box>
559 )
560 }
561
562 return (
563 <Box flexDirection="column" paddingX={1}>
564 {header}
565 {total}
566 {stats}
567 {overTime}
568 {limitLines.length > 0 && heading(core.isSubscriber ? 'LIMITS' : 'CONTEXT', core.isSubscriber ? 'whole account' : undefined)}
569 {limitLines}
570 {heading('WHERE IT GOES')}
571 {sourceLines.length > 0 ? sourceLines : <Text dimColor>Nothing yet.</Text>}
572 {allTokens > 0 && tokenLines}
573 </Box>
574 )
575}
576
577/** "cache served 99% of input · excellent", a toggle for what that means. */
578function cacheLine(Button: Ui['Button'], Text: Ui['Text'], look: Looks, hit: number, width: number, isOpen: boolean, toggle: () => void) {
579 const lead = `cache served ${fmtPct(hit * 100)} of input · `
580 const [word, color] = hitWord(hit)
581 const gap = ' '.repeat(Math.max(1, width - lead.length - word.length - 1))
582 return (
583 <Button key="cache" plain onPress={toggle}>
584 <Text dimColor>{lead}</Text>
585 <Text color={color}>{word}</Text>
586 <Text dimColor={!isOpen} {...(isOpen ? look.strong : {})}>
587 {gap}?
588 </Text>
589 </Button>
590 )
591}
592
593/** What the cache does, in this session's own figures. */
594function cacheExplained(rows: readonly SpendSource[], reread: number, readCost: number, model: string | undefined): string {
595 const atFull = rows.reduce((a, s) => a + (s.tok.cr * inputRate(s.model)) / 1e6, 0)
596 const ratio = inputRate(model) > 0 ? readRate(model) / inputRate(model) : 0
597 const rate = ratio > 0 ? ` at ${Number(ratio.toFixed(3))}× on ${modelName(model)}` : ' for a fraction of it'
598 const saved =
599 atFull > readCost
600 ? ` Without the cache, the ${fmtTok(reread)} re-read tokens would have cost ≈ ${fmtUsd(atFull)}, not ${fmtUsd(readCost)}.`
601 : ''
602 return `Each request resends the whole conversation. New input is cached once, at 1.25–2× the input price; later requests re-read it${rate}.${saved}`
603}
604
605function details(ui: Ui, look: Looks, s: SpendSource, d: ViewData, width: number, view: ViewActions['view']): RenderElement {
606 const { Box, Text, Button } = ui
607 const now = d.now
608 const agentId = s.kind === 'agent' ? s.key.slice('agent:'.length) : undefined
609 const isViewed = agentId !== undefined && d.viewedAgentId === agentId
610 const hit = cacheHit(s.tok)
611 const perStep = s.steps === 0 ? 0 : s.usd / s.steps
612 const members = Object.values(s.members ?? {}).sort((a, b) => b.usd - a.usd)
613 // Inside the details' indent: two for the marker, a space, the name, a space, eight for the cost.
614 const nameW = Math.max(6, width - 2 - 13)
615 return (
616 <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
617 <Text dimColor wrap="truncate-end">
618 {s.type !== undefined && s.kind !== 'workflow' ? `${s.type} · ` : ''}
619 {modelName(s.model)} · {s.steps} requests
620 {s.lastAt - s.firstAt >= 1000 ? ` over ${fmtDur(s.lastAt - s.firstAt)}` : ''}
621 {s.status === 'running' && s.kind !== 'main' && now - s.lastAt >= 5000 ? ` · quiet ${fmtDur(now - s.lastAt)}` : ''}
622 </Text>
623 <Text dimColor wrap="truncate-end">
624 {fmtTok(s.tok.out)} out · {fmtTok(s.tok.cw)} cached · {fmtTok(s.tok.cr)} re-read
625 </Text>
626 <Text dimColor wrap="truncate-end">
627 {fmtUsd(perStep)} per request{hit === undefined ? '' : ` · cache served ${fmtPct(hit * 100)}`}
628 </Text>
629 {members.length > 0 && <Text dimColor>{members.length} {members.length === 1 ? 'agent' : 'agents'}:</Text>}
630 {members.slice(0, 6).map(m => (
631 <Text {...look.table} dimColor wrap="truncate-end">
632 {' '}
633 {m.status === 'running' ? '●' : m.status === 'failed' ? '✗' : '✓'} {clip(m.label, nameW).padEnd(nameW)}{' '}
634 {fmtUsd(m.usd).padStart(8)}
635 </Text>
636 ))}
637 {members.length > 6 && <Text dimColor>{' '}+{members.length - 6} more</Text>}
638 {agentId !== undefined && d.caps.agentView && (
639 <Button key={`view:${agentId}`} plain dimColor onPress={() => view(isViewed ? undefined : agentId)}>
640 {isViewed ? '← back to the main conversation' : '↗ show its transcript'}
641 </Button>
642 )}
643 </Box>
644 )
645}
646
647function printer(Text: Ui['Text'], tick: number): RenderElement {
648 const belt = ' $ ▓▓ $ ▓▓ $ ▓▓ $ ▓▓'
649 const at = tick % 8
650 const bills = belt.slice(at, at + 13).padEnd(13)
651 return (
652 <Text color="success">
653 {'╭───────────────╮\n'}
654 {`│ ${bills} │ b${'r'.repeat(2 + (tick % 5))}\n`}
655 {'╰─┬───────────┬─╯\n'}
656 {' ┷ ┷ '}
657 </Text>
658 )
659}
660
661/** The one-line status chip shown under the prompt while the pane is closed. */
662export function chipText(core: SpendCore | null, live: SpendLive): string | undefined {
663 if (core === null) return undefined
664 const parts = [`◆ ${fmtUsd(core.usd)}`]
665 if (core.turn !== null) parts.push(`turn +${fmtUsd((core.turn.usd1 ?? core.usd) - core.turn.usd0)}`)
666 for (const l of core.limits) {
667 if (l.kind === 'five_hour' || l.kind === 'seven_day') parts.push(`${windowName(l.kind)} ${fmtPct(l.pct)}`)
668 }
669 if (live.inflight > 1) parts.push(`${live.inflight} live`)
670 return `${parts.join(' · ')} (/spend)`
671}
672hooks/art.ts 146 lines1// The spend chart, stacked by who spent: in 24-bit block elements for every
2// terminal, and as an anti-aliased bitmap where the kitty graphics protocol is.
3
4import { barTotal, base64, DEFAULT_COLOR, PALETTE } from './ledger'
5import type { Bar } from './ledger'
6
7export type Cell = readonly [number, number, number]
8
9const ch = (c: number, shift: number) => (c >> shift) & 0xff
10
11export function mix(a: number, b: number, t: number): number {
12 const k = Math.min(1, Math.max(0, t))
13 const m = (s: number) => Math.round(ch(a, s) + (ch(b, s) - ch(a, s)) * k)
14 return (m(16) << 16) | (m(8) << 8) | m(0)
15}
16
17const MARK = PALETTE.mark
18const EIGHTHS = [0x20, 0x2581, 0x2582, 0x2583, 0x2584, 0x2585, 0x2586, 0x2587, 0x2588]
19const COLORS = [PALETTE.main, PALETTE.agents, PALETTE.workflows]
20
21/** The bar's stack in eighths of a cell: where each source's part ends, bottom up. */
22function stack(bar: Bar, peak: number, span: number): number[] {
23 const total = barTotal(bar)
24 if (total <= 0) return [0, 0, 0]
25 const h = Math.max(1, Math.round((total / peak) * span))
26 const at = (v: number) => Math.round((v / total) * h)
27 return [at(bar.main), at(bar.main + bar.agents), h]
28}
29
30/**
31 * The chart in cells. Each bar stacks main thread, subagents and workflows
32 * bottom up; where two parts meet inside a cell, its lower-eighths glyph
33 * takes the lower part's color and its ground the upper's, so the stack stays
34 * exact to an eighth of a cell.
35 */
36export function areaCells(bars: readonly Bar[], rows: number): Cell[] {
37 const peak = Math.max(0.0001, ...bars.map(barTotal))
38 const span = rows * 8
39 const stacks = bars.map(b => stack(b, peak, span))
40 const cells: Cell[] = []
41 for (let r = 0; r < rows; r++) {
42 const floor = (rows - 1 - r) * 8
43 const top = floor + 8
44 bars.forEach((bar, i) => {
45 const bounds = stacks[i]!
46 const h = bounds[2]!
47 if (h <= floor) {
48 if (bar.isCompacted) cells.push([0x2502, MARK, DEFAULT_COLOR])
49 else if (r === rows - 1) cells.push([0x2581, PALETTE.axis, DEFAULT_COLOR])
50 else cells.push([0x20, DEFAULT_COLOR, DEFAULT_COLOR])
51 return
52 }
53 const below = COLORS[bounds.findIndex(b => b > floor)]!
54 const edge = bounds.find(b => b > floor && b < top)
55 if (edge === undefined) {
56 cells.push([0x2588, below, DEFAULT_COLOR])
57 return
58 }
59 const above = edge >= h ? DEFAULT_COLOR : COLORS[bounds.findIndex(b => b > edge)]!
60 cells.push([EIGHTHS[edge - floor]!, below, above])
61 })
62 }
63 return cells
64}
65
66/** The chart as a line of text, the words a picture stands in for. */
67export function areaText(bars: readonly Bar[]): string {
68 const peak = Math.max(0.0001, ...bars.map(barTotal))
69 return bars.map(b => (barTotal(b) <= 0 ? '▁' : '▁▂▃▄▅▆▇█'[Math.min(7, Math.round((barTotal(b) / peak) * 7))])).join('')
70}
71
72const PX_W = 8
73const PX_H = 16
74
75// The last picture drawn: a redraw with the same bars sends the same source,
76// which the terminal is not sent again.
77const memo: { key: string; picture?: { rgba: string; width: number; height: number } } = { key: '' }
78
79/**
80 * The chart as RGBA pixels on a transparent ground: smooth stacked areas
81 * through the bars, an anti-aliased line along the top, and a dashed guide
82 * where the session compacted.
83 */
84export function areaPixels(bars: readonly Bar[], rows: number): { rgba: string; width: number; height: number } {
85 const key = `${rows}|${bars.map(b => `${b.main.toFixed(5)},${b.agents.toFixed(5)},${b.workflows.toFixed(5)},${b.isCompacted ? 1 : 0}`).join(';')}`
86 if (key === memo.key && memo.picture !== undefined) return memo.picture
87 const width = bars.length * PX_W
88 const height = rows * PX_H
89 const room = height - 4 // headroom for the line
90 const px = new Uint8Array(width * height * 4)
91 const put = (x: number, y: number, color: number, a: number) => {
92 if (a <= 0 || y < 0 || y >= height) return
93 const o = (y * width + x) * 4
94 const prior = px[o + 3]! / 255
95 const out = a + prior * (1 - a)
96 const blend = (s: number, k: number) => Math.round((ch(color, s) * a + px[o + k]! * prior * (1 - a)) / out)
97 px[o] = blend(16, 0)
98 px[o + 1] = blend(8, 1)
99 px[o + 2] = blend(0, 2)
100 px[o + 3] = Math.round(out * 255)
101 }
102 // A picture reads as a rate: each bar is softened with its neighbours.
103 const raw = (i: number) => bars[Math.min(bars.length - 1, Math.max(0, i))]!
104 const soft = bars.map((bar, i): Bar => {
105 const [l, r] = [raw(i - 1), raw(i + 1)]
106 const k = (f: (b: Bar) => number) => (f(l) + 2 * f(bar) + f(r)) / 4
107 return { main: k(b => b.main), agents: k(b => b.agents), workflows: k(b => b.workflows), isCompacted: bar.isCompacted }
108 })
109 const softPeak = Math.max(0.0001, ...soft.map(barTotal))
110 const at = (i: number) => soft[Math.min(soft.length - 1, Math.max(0, i))]!
111 const levels = (b: Bar) => [b.main, b.main + b.agents, barTotal(b)].map(v => (v / softPeak) * room)
112 for (let x = 0; x < width; x++) {
113 // Cosine interpolation between the bars' centers.
114 const f = (x + 0.5) / PX_W - 0.5
115 const i = Math.floor(f)
116 const t = (1 - Math.cos((f - i) * Math.PI)) / 2
117 const a = levels(at(i))
118 const b = levels(at(i + 1))
119 const tops = a.map((v, k) => v + (b[k]! - v) * t)
120 if (at(Math.round(f)).isCompacted && x % PX_W === PX_W / 2) {
121 for (let y = 0; y < height; y++) if (y % 4 < 2) put(x, y, MARK, 0.9)
122 }
123 if (tops[2]! < 0.01) {
124 put(x, height - 1, PALETTE.axis, 0.9)
125 continue
126 }
127 let below = 0
128 tops.forEach((top, k) => {
129 for (let y = Math.floor(height - top); y < Math.ceil(height - below); y++) {
130 const cover = Math.min(1, height - below - y) * Math.min(1, y + 1 - (height - top))
131 put(x, y, COLORS[k]!, 0.82 * Math.max(0, cover))
132 }
133 below = Math.max(below, top)
134 })
135 // The line along the top, in the color of the part it runs over.
136 const crest = tops[2]! - tops[1]! > 0.5 ? 2 : tops[1]! - tops[0]! > 0.5 ? 1 : 0
137 const y = height - tops[2]!
138 for (let py = Math.floor(y) - 2; py <= Math.ceil(y) + 1; py++) {
139 put(x, py, mix(COLORS[crest]!, 0xffffff, 0.25), Math.min(1, Math.max(0, 1.2 - Math.abs(py + 0.5 - y))))
140 }
141 }
142 memo.key = key
143 memo.picture = { rgba: base64(px), width, height }
144 return memo.picture
145}
146types/index.d.ts 124 lines1// The spend meter's state contract: every value it keeps in `$.state`.
2
3export type SpendTokens = { in: number; out: number; cr: number; cw: number }
4
5/** USD by what was paid for: written output, new input cached, input re-read from cache, input never cached. */
6export type SpendParts = { input: number; output: number; read: number; write: number }
7
8/** A compaction: when, why, and the context before and after, in tokens. */
9export type SpendCompaction = { at: number; trigger: string; from?: number; to?: number }
10
11export type SpendLimit = { kind: string; pct: number; resetsAt?: string }
12
13/** One rate-limit window as this session has watched it move. */
14export type SpendWindow = {
15 /** Percent when the session first saw this window (or since its last reset). */
16 first: number
17 last: number
18 resetsAt?: string
19 /** What earlier windows moved before they reset, in percent points. */
20 carried: number
21 /** Readings `[at, pct]`, one each time the window moved, for the pace projection. */
22 samples: [number, number][]
23}
24
25export type SpendCore = {
26 /** The engine's own total, as /cost has it (subagents and workflows included). */
27 usd: number
28 /** What the total was when the meter first looked: spend it did not watch. */
29 usd0: number
30 startedAt: number
31 at: number
32 ctxTokens?: number
33 ctxWindow: number
34 ctxPct?: number
35 limits: SpendLimit[]
36 windows: Record<string, SpendWindow>
37 isSubscriber: boolean
38 turn: { startedAt: number; usd0: number; endedAt?: number; usd1?: number } | null
39 /** Milestones and alerts already raised, so each fires once. */
40 fired: string[]
41 /** The ledger's format once the session's transcripts were read back into it. */
42 ledger?: number
43 compactions?: SpendCompaction[]
44}
45
46export type SpendSourceKind = 'main' | 'agent' | 'workflow' | 'fork'
47
48export type SpendSource = {
49 key: string
50 kind: SpendSourceKind
51 label: string
52 type?: string
53 model?: string
54 usd: number
55 cost: SpendParts
56 /** False while some of its responses came from a model the price list lacks. */
57 isPriced: boolean
58 tok: SpendTokens
59 steps: number
60 status: 'running' | 'done' | 'failed'
61 firstAt: number
62 lastAt: number
63 /** For a workflow run: its agents, by id, with what each spent. */
64 members?: Record<string, { label: string; usd: number; steps: number; status: 'running' | 'done' | 'failed' }>
65}
66
67export type SpendAgentMeta = {
68 label: string
69 type: string
70 runId?: string
71 model?: string
72}
73
74export type SpendSources = {
75 rows: Record<string, SpendSource>
76 /** agentId to what it is, from `agent.spawn`. */
77 agents: Record<string, SpendAgentMeta>
78 /** Workflow tool_use_id to the workflow's name. */
79 workflows: Record<string, string>
80}
81
82/** Spend per time bucket: the engine's total moved, split by who spent it. */
83export type SpendSeries = {
84 bucketMs: number
85 /** Start of the newest bucket. */
86 t0: number
87 /** Oldest first: `[total, main, agents, workflows]` in USD. */
88 buckets: [number, number, number, number][]
89}
90
91export type SpendPrefs = {
92 unit: number
93 chip: boolean
94 /** The milestone toasts ($1, π, $42…). */
95 eggs: boolean
96 budget: number | null
97 open: boolean
98 selected: string | null
99 /** How the chart is drawn: block-element cells, kitty-protocol pixels, or whichever the terminal takes. */
100 gfx?: 'auto' | 'cells' | 'pixels'
101}
102
103export type SpendLive = {
104 tick: number
105 inflight: number
106 /** What the responses still streaming have written so far, priced. */
107 estUsd: number
108 /** Source keys with a response streaming now. */
109 keys: string[]
110 flash: { kind: 'brrr'; until: number } | null
111}
112
113declare module 'claude-code' {
114 interface PluginState {
115 'spend-meter': {
116 core: SpendCore | null
117 sources: SpendSources
118 series: SpendSeries
119 prefs: SpendPrefs
120 live: SpendLive
121 }
122 }
123}
124