SLOPSHOPPER

spend-meter

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.

newpaneguardcommandtoaststatus
v0.1.3MITupdated 2026-10-14Hekzory/spend-meter/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spend-meter
│ ┃ Spend ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ◆ spend idle 30m │ spend-meter │ │ ┃ ⏺ Read(src/auth.ts) │ Spend meter: press a row for its details · │ │ ┃ $0.42 ⎿ Read 6 lines │ /spend help │ │ ┃ this session · USD at API list price ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ last turn +$0.00 0s ⏺ Bash(bun test) │ ┃ burn idle ⎿ 3 pass, 1 fail │ ┃ │ ┃ OVER TIME last 27 min ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ No spend in the last 27 minutes. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ LIMITS whole account │ ┃ 5h ━━━━━━━━━━─────────────────────── 31%↻ › /spend │ ┃ ctx ━━━━━━━━━━━━━━━━───────────────── 49%9 ⎿ spend-meter: Spend meter open. │ ┃ │ ┃ WHERE IT GOES │ ┃ · before the meter │ ┃ $0.42 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Spend
◆ spend idle 30m $0.42 this session · USD at API list price last turn +$0.00 0s burn idle OVER TIME last 27 min No spend in the last 27 minutes. LIMITS whole account 5h ━━━━━━━━━━─────────────────────── 31% ↻ NaNd NaNh ctx ━━━━━━━━━━━━━━━━───────────────── 49% 97k/200k WHERE IT GOES · before the meter $0.42
README

spend-meter

CI License: MIT

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.

The spend meter docked beside a Claude Code session: the total, the turn, burn rate and budget, a chart of spend stacked by main thread, subagents and workflows, the 5-hour, 7-day and context gauges, the sources with an open workflow, and the token split

Install

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.

Using it

/spendtoggle the pane (it also works mid-turn)
/spend budget 25alerts at 50 %, 80 % and 100 % of $25; budget off clears it
/spend chipthe one-line meter under the prompt while the pane is closed
/spend unitwhat 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 rebuildread 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.

What it draws with

  • The chart stacks what the main thread, subagents and workflows spent per 30 seconds over the last ~20 minutes, in 24-bit block elements exact to an eighth of a cell; a │ marks where the session compacted. Windows Terminal and kitty both draw block elements as geometry, so it comes out seamless.
  • kitty and Ghostty also get the chart as an anti-aliased picture through the kitty graphics protocol, the one picture protocol Claude Code's Image speaks (no Sixel, no iTerm2). /spend gfx cells|pixels|auto overrides the choice.

What the numbers are

  • History is read back from the session's transcripts when the meter starts (the main thread's, 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.
  • The big total is the engine's own ledger, the figure /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.
  • Where it goes prices each response with Claude Code 2.1.300's own catalog (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.
  • Limits are the account's 5-hour and 7-day windows from the last response. They are account-wide: other sessions move them too. +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.
  • ctx is the context window; "≈ $X to re-read per main request" is that context priced at the main model's cache-read rate, the floor of every request. A compaction is noted under it (and marked on the chart); nothing resets. /clear starts the meter over.
  • Tokens are split by what is paid for: what the models wrote (output), new input stored in the prompt cache (1.25× the input price for 5 minutes, 2× for an hour), input re-read from the cache (0.1× of input, 0.05× on Opus 5.5, 0.025× on Fable 5.1), and input never cached.
  • Colors are ones a 256-color terminal rounds the same way in the chart and in text. Windows Terminal draws 24-bit color but does not say so; with COLORTERM=truecolor set, Claude Code uses it.

When a Claude Code update changes prices or models, update hooks/pricing.ts.

Development

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.

License

MIT

Source 7 files
hooks/register.tsx 681 lines
1import { 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}
681
hooks/ledger.ts 420 lines
1// 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
420
hooks/history.ts 151 lines
1// 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}
151
hooks/pricing.ts 180 lines
1// 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}
180
hooks/view.tsx 672 lines
1// 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}
672
hooks/art.ts 146 lines
1// 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}
146
types/index.d.ts 124 lines
1// 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