A pixel-art card with the session's API spend, plan usage and subagents, plus a status line total

Mods for Claude Code from SpeakingInBits. Mods work in the terminal and in the desktop app's Code tab. This repo is a plugin marketplace, and each mod in it installs on its own.
| Mod | What it does |
|---|---|
| session-spend | A card above the prompt shows Bit, a pixel-art critter who hops while Claude works and stacks coins as the spend grows. The card also has the session's API spend with a per-turn chart, plan usage meters (5-hour and 7-day windows) on a subscription, a pixel character for each subagent, and an ice cube that melts as the prompt cache nears expiry, with what the next prompt costs while the cache is warm and after it expires. The status line shows the running total (API spend $1.30 (+$0.08 last turn)). /spend gives a turn-by-turn breakdown, /spend all totals every session (by day, project and model), and /spend card hides or shows the card. |
At a Claude Code prompt:
/plugin install session-spend --marketplace SpeakingInBits/claude-mods
Answer y to add the marketplace, then choose a scope (user scope enables it in every session).
The spend figure is the same estimate /cost reports, at list price or at your organization's own pricing if it has set one. On a Pro or Max subscription it shows what the usage would cost on the API, not what you're billed.
/spend all adds up every session, and the card shows an "All sessions" line. Its Breakdown button (or /spend conversations) opens a dialog listing each conversation by title with its project, models and cost, sorted by cost or by date; Esc closes it. Two sources feed these:
/cost reports, in ~/.claude/session-spend/ledger/.~/.claude/projects/ at list price, so sessions from before the mod was installed count too. It reads them when a session starts, at most every 6 hours, re-reading only transcripts that changed. /spend import reads them all again. The results are kept in ~/.claude/session-spend/history/, so the totals outlive Claude Code's cleanup of old transcripts.Where a session has a ledger, its exact figure is used; otherwise the transcript estimate.
session-spend has two settings, in /config under the plugin:
auto, 5m, 1h): auto assumes 1h on a subscription plan and 5m on per-token billing.Cache costs are estimates at list price for the session's model.
Each mod lives in mods/<name>/ and is listed in .claude-plugin/marketplace.json.
claude plugin validate mods/session-spend
claude plugin test mods/session-spend
To run mods from your working copy, add this folder as a marketplace (claude plugin marketplace add .), install from it, and run /reload-plugins after each edit.
To add a mod, create mods/<name>/, add an entry to plugins in .claude-plugin/marketplace.json, and add a row to the table above.
hooks/register.tsx 593 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentState, AllTime, CacheWindow, SpendStep, Subagent } from '../types'
5import { meltStage, nextCost, promptTokens, remainingMs, timeLeft, ttlMs, warnBeforeMs } from './cache'
6import type { CacheView, CardData } from './card'
7import { BREAKDOWN_PANE, drawBreakdown } from './breakdown'
8import { drawDesktop, drawTerminal, usd } from './card'
9import type { ImportEntry, ImportReport, LedgerEntry } from './history'
10import {
11 conversationRows,
12 createTally,
13 dayKey,
14 formatReport,
15 lineSplitter,
16 mergeSpend,
17 nextLedger,
18 othersOf,
19 projectName,
20 summarize,
21} from './history'
22
23const total = atom({ plugin: 'session-spend', key: 'total' } as const, 0)
24const steps = atom({ plugin: 'session-spend', key: 'steps' } as const, [])
25const rateLimits = atom({ plugin: 'session-spend', key: 'rateLimits' } as const, [])
26const agents = atom({ plugin: 'session-spend', key: 'agents' } as const, [])
27const isWorking = atom({ plugin: 'session-spend', key: 'isWorking' } as const, false)
28const frame = atom({ plugin: 'session-spend', key: 'frame' } as const, 0)
29const isCardHidden = atom({ plugin: 'session-spend', key: 'isCardHidden' } as const, false)
30const cache = atom({ plugin: 'session-spend', key: 'cache' } as const, null)
31const cacheTick = atom({ plugin: 'session-spend', key: 'cacheTick' } as const, 0)
32const cacheWarnedFor = atom({ plugin: 'session-spend', key: 'cacheWarnedFor' } as const, 0)
33const allTime = atom({ plugin: 'session-spend', key: 'allTime' } as const, null)
34const breakdown = atom({ plugin: 'session-spend', key: 'breakdown' } as const, null)
35const breakdownSort = atom({ plugin: 'session-spend', key: 'breakdownSort' } as const, 'cost')
36const isBreakdownRefreshing = atom(
37 { plugin: 'session-spend', key: 'isBreakdownRefreshing' } as const,
38 false,
39)
40
41const FRAME_MS = 500
42
43function show($: EngineInterface, now: number, last?: number) {
44 const delta = last !== undefined && last > 0 ? ` (+${usd(last)} last turn)` : ''
45 $.ui.status(`API spend ${usd(now)}${delta}`)
46}
47
48const stateOf = (reason: string): AgentState =>
49 reason === 'answer' ? 'done' : 'failed'
50
51// The animation ticks only while Claude or a subagent is working.
52let ticker: Timer | undefined
53
54async function animate($: EngineInterface) {
55 const isBusy =
56 (await read($, isWorking)) || (await read($, agents)).some(a => a.state === 'running')
57 if (isBusy && ticker === undefined) {
58 ticker = $.clock.every(FRAME_MS, () => void update($, frame, n => (n + 1) % 2))
59 } else if (!isBusy && ticker !== undefined) {
60 ticker.cancel()
61 ticker = undefined
62 await update($, frame, () => 0)
63 }
64}
65
66// Spend across sessions lives in files of its own under the Claude config
67// directory: ledger/<session>.json, written by that session alone as it
68// runs, and history/<session>.json, what its transcript prices at.
69const IMPORT_EVERY_MS = 6 * 3_600_000
70const REFRESH_EVERY_MS = 10 * 60_000
71const READ_LIMIT = 4_000_000
72let ownLedger: LedgerEntry | undefined
73let isImporting = false
74let refreshedAt = 0
75
76async function configDir($: EngineInterface): Promise<string> {
77 const set = await $.env.get('CLAUDE_CONFIG_DIR')
78 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
79 return (set ?? `${home}/.claude`).replaceAll('\\', '/')
80}
81
82async function readJson<T>($: EngineInterface, path: string): Promise<T | undefined> {
83 try {
84 return JSON.parse(String(await $.fs.read(path))) as T
85 } catch {
86 return undefined
87 }
88}
89
90async function listDir($: EngineInterface, path: string) {
91 try {
92 return await $.fs.list(path)
93 } catch {
94 return []
95 }
96}
97
98async function readAll<T>($: EngineInterface, dir: string): Promise<T[]> {
99 const found: T[] = []
100 for (const f of await listDir($, dir)) {
101 if (f.kind !== 'file' || !f.name.endsWith('.json')) continue
102 const value = await readJson<T>($, `${dir}/${f.name}`)
103 if (value !== undefined) found.push(value)
104 }
105 return found
106}
107
108async function writeOwnLedger($: EngineInterface, spent: number) {
109 if (spent <= 0) return
110 const id = await $.session.id()
111 const dir = `${await configDir($)}/session-spend/ledger`
112 if (ownLedger?.id !== id) {
113 ownLedger = await readJson<LedgerEntry>($, `${dir}/${id}.json`)
114 }
115 const now = await $.clock.now()
116 ownLedger = nextLedger(ownLedger, {
117 id,
118 project: projectName(await $.session.cwd()),
119 startedAt: (await $.session.usage()).startedAt,
120 usd: spent,
121 now,
122 })
123 await $.fs.write(`${dir}/${id}.json`, JSON.stringify(ownLedger))
124 const ownToday = ownLedger.days[dayKey(now)] ?? 0
125 await update($, allTime, a => (a === null ? null : { ...a, ownToday }))
126 if (now - refreshedAt > REFRESH_EVERY_MS) await refreshAllTime($)
127}
128
129async function loadMerged($: EngineInterface) {
130 const dir = `${await configDir($)}/session-spend`
131 return mergeSpend(
132 await readAll<LedgerEntry>($, `${dir}/ledger`),
133 await readAll<ImportEntry>($, `${dir}/history`),
134 )
135}
136
137async function refreshAllTime($: EngineInterface) {
138 const now = await $.clock.now()
139 refreshedAt = now
140 const id = await $.session.id()
141 const others = othersOf(await loadMerged($), id, now)
142 const ownToday = ownLedger?.id === id ? (ownLedger.days[dayKey(now)] ?? 0) : 0
143 const next: AllTime = {
144 othersTotal: others.total,
145 othersToday: others.today,
146 othersSessions: others.sessions,
147 date: others.date,
148 ownToday,
149 }
150 await update($, allTime, () => next)
151}
152
153/** Feeds a file's lines to `onLine`: read whole when small, streamed when not. */
154async function readLines(
155 $: EngineInterface,
156 path: string,
157 size: number,
158 onLine: (line: string) => void,
159) {
160 if (size < READ_LIMIT) {
161 String(await $.fs.read(path)).split('\n').forEach(onLine)
162 return
163 }
164 const isWindows = /^[A-Za-z]:\//.test(path)
165 const argv = isWindows ? ['cmd', '/d', '/c', 'type', path.replaceAll('/', '\\')] : ['cat', path]
166 const split = lineSplitter(onLine)
167 for await (const piece of $.process.spawn({ argv })) {
168 if (piece.stream === 'stdout') split.push(piece.text)
169 }
170 split.end()
171}
172
173type ImportMeta = ImportReport & { importedAt: number; sessions: number }
174
175/**
176 * Prices every saved transcript (each session's file and its subagents'),
177 * skipping sessions whose files have not changed since the last import.
178 */
179async function importTranscripts($: EngineInterface, isForced: boolean): Promise<ImportMeta | undefined> {
180 if (isImporting) return undefined
181 isImporting = true
182 try {
183 const config = await configDir($)
184 const out = `${config}/session-spend/history`
185 const meta: ImportMeta = { importedAt: 0, sessions: 0, unpriced: 0, failed: [] }
186 for (const project of await listDir($, `${config}/projects`)) {
187 if (project.kind !== 'dir') continue
188 const base = `${config}/projects/${project.name}`
189 const entries = await listDir($, base)
190 for (const f of entries) {
191 if (f.kind !== 'file' || !f.name.endsWith('.jsonl')) continue
192 const id = f.name.slice(0, -'.jsonl'.length)
193 const files = [{ path: `${base}/${f.name}`, size: f.size, mtimeMs: f.mtimeMs }]
194 if (entries.some(x => x.kind === 'dir' && x.name === id)) {
195 for (const sub of await listDir($, `${base}/${id}/subagents`)) {
196 if (sub.kind === 'file' && sub.name.endsWith('.jsonl')) {
197 files.push({ path: `${base}/${id}/subagents/${sub.name}`, size: sub.size, mtimeMs: sub.mtimeMs })
198 }
199 }
200 }
201 const source = {
202 size: files.reduce((a, x) => a + x.size, 0),
203 mtimeMs: Math.max(...files.map(x => x.mtimeMs)),
204 }
205 meta.sessions += 1
206 const prev = await readJson<ImportEntry>($, `${out}/${id}.json`)
207 if (!isForced && prev?.source.size === source.size && prev.source.mtimeMs === source.mtimeMs) {
208 meta.unpriced += prev.unpriced
209 continue
210 }
211 const tally = createTally(id)
212 try {
213 for (const file of files) await readLines($, file.path, file.size, l => tally.line(l))
214 } catch {
215 meta.failed.push(`${project.name}/${f.name}`)
216 continue
217 }
218 const entry = tally.result(source)
219 meta.unpriced += entry.unpriced
220 if (entry.usd > 0 || entry.unpriced > 0) {
221 await $.fs.write(`${out}/${id}.json`, JSON.stringify(entry))
222 }
223 }
224 }
225 meta.importedAt = await $.clock.now()
226 await $.fs.write(`${config}/session-spend/import.json`, JSON.stringify(meta))
227 return meta
228 } finally {
229 isImporting = false
230 }
231}
232
233async function loadBreakdown($: EngineInterface) {
234 const rows = conversationRows(await loadMerged($), await $.session.id(), 'cost')
235 await update($, breakdown, () => rows)
236}
237
238/** Brings in what changed since the last import, then shows it. */
239async function refreshBreakdown($: EngineInterface) {
240 if (isImporting) return
241 await update($, isBreakdownRefreshing, () => true)
242 try {
243 await importTranscripts($, false)
244 await loadBreakdown($)
245 await refreshAllTime($)
246 } catch {
247 // The rows already shown stand.
248 } finally {
249 await update($, isBreakdownRefreshing, () => false)
250 }
251}
252
253/** Opens the breakdown as a dialog: it takes the keys and Esc closes it. */
254async function openBreakdown($: EngineInterface) {
255 await $.ui.open({
256 id: BREAKDOWN_PANE,
257 title: 'Spending by conversation',
258 focus: true,
259 closeOnEscape: true,
260 holdToasts: true,
261 })
262 await loadBreakdown($).catch(() => undefined)
263 void refreshBreakdown($)
264}
265
266/** At a session's start: import if the last import is old, then total up. */
267async function catchUpHistory($: EngineInterface) {
268 try {
269 const meta = await readJson<ImportMeta>($, `${await configDir($)}/session-spend/import.json`)
270 if (meta === undefined || (await $.clock.now()) - meta.importedAt > IMPORT_EVERY_MS) {
271 await importTranscripts($, false)
272 }
273 await refreshAllTime($)
274 } catch {
275 // History is a nicety; the session's own figures stand without it.
276 }
277}
278
279// The cache countdown ticks each second while the cache is warm, but the card
280// redraws only when what it shows changes.
281let cacheTicker: Timer | undefined
282let cacheShown = ''
283// Model requests seen since this module loaded, for /spend's cache line.
284let stepsSeen = 0
285
286async function cacheView($: EngineInterface, ttlSetting: string): Promise<CacheView | undefined> {
287 const c = await read($, cache)
288 if (c === null) return undefined
289 const ttl = ttlMs(ttlSetting, (await read($, rateLimits)).length > 0)
290 const remaining = remainingMs(c, await $.clock.now(), ttl)
291 const isInUse = await read($, isWorking)
292 return {
293 isInUse,
294 remaining,
295 ttl,
296 melt: isInUse ? 0 : meltStage(remaining, ttl),
297 tokens: promptTokens(c),
298 cost: nextCost(c, ttl),
299 }
300}
301
302async function tickCache($: EngineInterface, ttlSetting: string, isWarning: boolean) {
303 const view = await cacheView($, ttlSetting)
304 if (view === undefined) return
305 const shown = `${view.isInUse}:${view.melt}:${timeLeft(view.remaining)}`
306 if (shown !== cacheShown) {
307 cacheShown = shown
308 await update($, cacheTick, n => n + 1)
309 }
310 const c = await read($, cache)
311 const isDue =
312 !view.isInUse && view.remaining > 0 && view.remaining <= warnBeforeMs(view.ttl)
313 if (isWarning && isDue && c !== null && (await read($, cacheWarnedFor)) !== c.startedAt) {
314 await update($, cacheWarnedFor, () => c.startedAt)
315 $.ui.toast(
316 view.cost
317 ? `Prompt cache expires in ${timeLeft(view.remaining).replace(' left', '')}. ` +
318 `Next prompt ~${usd(view.cost.warm)} now, ~${usd(view.cost.cold)} after it expires.`
319 : `Prompt cache expires in ${timeLeft(view.remaining).replace(' left', '')}; ` +
320 `after that the next prompt re-caches the whole conversation.`,
321 )
322 }
323 if (view.remaining <= 0 && !view.isInUse && cacheTicker !== undefined) {
324 cacheTicker.cancel()
325 cacheTicker = undefined
326 }
327}
328
329async function watchCache($: EngineInterface, ttlSetting: string, isWarning: boolean) {
330 if (cacheTicker === undefined) {
331 cacheTicker = $.clock.every(1000, () => void tickCache($, ttlSetting, isWarning))
332 }
333 await tickCache($, ttlSetting, isWarning)
334}
335
336export const register: Register = (on, options) => {
337 const ttlSetting = String(options.cacheTtl ?? 'auto')
338 const isWarning = options.cacheWarning !== false
339
340 on('session.start', async ($, e, next) => {
341 await $.command.register({
342 name: 'spend',
343 description:
344 "Show this session's API spend; /spend all totals every session, /spend conversations breaks it down, /spend import re-reads transcripts, /spend card shows or hides the card",
345 argumentHint: '[all|conversations|import|card]',
346 })
347 const ran = await next(e)
348 const usage = await $.session.usage()
349 await update($, rateLimits, () => usage.rateLimits)
350 if (usage.cost !== undefined) {
351 const cost = usage.cost.usd
352 await update($, total, () => cost)
353 show($, cost, (await read($, steps)).at(-1)?.usd)
354 await writeOwnLedger($, cost).catch(() => undefined)
355 }
356 $.clock.after(3000, () => void catchUpHistory($))
357 await animate($)
358 if ((await read($, cache)) !== null) {
359 await watchCache($, ttlSetting, isWarning)
360 }
361
362 return ran
363 })
364
365 // Each main-thread model request reads and refreshes the prompt cache; its
366 // lifetime counts from the request's start.
367 on('turn.step', async function* ($, e, next) {
368 const startedAt = await $.clock.now()
369 const step = yield* next(e)
370 stepsSeen += 1
371 if (e.agentId === undefined) {
372 const u = step.usage
373 // Without the step's own counts, the session's live window says the same.
374 const prefixTokens = u
375 ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
376 : ((await $.session.usage()).context.tokens ?? 0)
377 if (prefixTokens > 0) {
378 const window: CacheWindow = {
379 startedAt,
380 model: u?.model || e.model,
381 prefixTokens,
382 outputTokens: u?.output_tokens ?? 0,
383 }
384 await update($, cache, () => window)
385 await watchCache($, ttlSetting, isWarning)
386 }
387 }
388
389 return step
390 })
391
392 on('session.measure', async ($, e, next) => {
393 if (e.changed.includes('rateLimits')) {
394 await update($, rateLimits, () => e.rateLimits)
395 }
396 if (e.cost !== undefined && e.changed.includes('cost')) {
397 const before = await read($, total)
398 const now = e.cost.usd
399 const step: SpendStep = {
400 at: await $.clock.now(),
401 usd: Math.max(0, now - before),
402 total: now,
403 }
404 await update($, total, () => now)
405 await update($, steps, list => [...list, step].slice(-500))
406 show($, now, step.usd)
407 await writeOwnLedger($, now).catch(() => undefined)
408 }
409
410 return next(e)
411 })
412
413 on('prompt.submit', async ($, e, next) => {
414 await update($, agents, list => list.filter(a => a.state === 'running'))
415
416 return next(e)
417 }).catch(($, e, next) => next(e))
418
419 on('turn.start', async ($, e, next) => {
420 await update($, isWorking, () => true)
421 await animate($)
422
423 return next(e)
424 })
425
426 on('agent.spawn', async ($, e, next) => {
427 const started = await next(e)
428 if (started.agentId !== undefined) {
429 const agent: Subagent = {
430 id: started.agentId,
431 type: e.subagentType,
432 description: e.description,
433 state: 'running',
434 tokens: 0,
435 }
436 await update($, agents, list => [...list.filter(a => a.id !== agent.id), agent].slice(-24))
437 await animate($)
438 }
439
440 return started
441 }).catch(($, e, next) => next(e))
442
443 on('turn.complete', async ($, e, next) => {
444 const done = await next(e)
445 const { agentId, usage } = e
446 if (agentId !== undefined) {
447 const spent = usage
448 ? usage.input_tokens +
449 usage.output_tokens +
450 usage.cache_read_input_tokens +
451 usage.cache_creation_input_tokens
452 : 0
453 await update($, agents, list =>
454 list.map(a =>
455 a.id === agentId ? { ...a, state: stateOf(e.reason), tokens: a.tokens + spent } : a,
456 ),
457 )
458 } else {
459 await update($, isWorking, () => false)
460 cacheShown = ''
461 // An agent whose end raised no turn (killed, dropped) is settled by the roster.
462 const roster = new Map((await $.agent.list()).map(a => [a.id, a.status]))
463 await update($, agents, list =>
464 list.map(a => {
465 const status = roster.get(a.id)
466 if (a.state !== 'running' || status === undefined) return a
467 if (status === 'completed' || status === 'idle') return { ...a, state: 'done' as const }
468 if (status === 'failed' || status === 'killed') return { ...a, state: 'failed' as const }
469 return a
470 }),
471 )
472 }
473 await animate($)
474
475 return done
476 })
477
478 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
479 if (e.props.hasSurvey || (await read($, isCardHidden))) {
480 return next(e)
481 }
482 const data: CardData = {
483 total: await read($, total),
484 turns: (await read($, steps)).map(s => s.usd),
485 rateLimits: await read($, rateLimits),
486 agents: await read($, agents),
487 isWorking: e.props.isWorking || (await read($, isWorking)),
488 frame: await read($, frame),
489 now: await $.clock.now(),
490 columns: e.props.bodyColumns,
491 cache: (await read($, cacheTick)) >= 0 ? await cacheView($, ttlSetting) : undefined,
492 }
493 data.onBreakdown = () => void openBreakdown($)
494 const all = await read($, allTime)
495 if (all !== null) {
496 const isSameDay = all.date === dayKey(data.now)
497 data.allTime = {
498 total: all.othersTotal + data.total,
499 today: (isSameDay ? all.othersToday : 0) + all.ownToday,
500 }
501 }
502
503 return e.surface === 'terminal'
504 ? drawTerminal($.ui.resolve(e), data)
505 : drawDesktop($.ui.resolve(e), data)
506 })
507
508 on('ui.render', { component: 'Pane', requestId: BREAKDOWN_PANE }, async ($, e) =>
509 drawBreakdown(
510 $.ui.resolve(e),
511 {
512 rows: await read($, breakdown),
513 sort: await read($, breakdownSort),
514 isRefreshing: await read($, isBreakdownRefreshing),
515 columns: e.props.bodyColumns,
516 },
517 {
518 onSort: value => void update($, breakdownSort, () => value),
519 onClose: () => void $.ui.close({ id: BREAKDOWN_PANE }),
520 },
521 ),
522 )
523
524 on('command.run', { command: 'spend' }, async ($, e) => {
525 if (e.args.trim() === 'card') {
526 const isHidden = await update($, isCardHidden, v => !v)
527
528 return { text: isHidden ? 'Spend card hidden.' : 'Spend card shown above the prompt.' }
529 }
530 const word = e.args.trim()
531 if (word === 'conversations') {
532 await openBreakdown($)
533
534 return { text: 'Opened the spending breakdown by conversation.' }
535 }
536 if (word === 'all' || word === 'import') {
537 const dir = `${await configDir($)}/session-spend`
538 let meta = await readJson<ImportMeta>($, `${dir}/import.json`)
539 if (word === 'import' || meta === undefined) {
540 meta = (await importTranscripts($, word === 'import')) ?? meta
541 }
542 await refreshAllTime($)
543 const report = formatReport(summarize(await loadMerged($), await $.clock.now()), {
544 importedAt: meta?.importedAt,
545 unpriced: meta?.unpriced ?? 0,
546 failed: meta?.failed ?? [],
547 })
548 const lead =
549 word === 'import' && meta
550 ? `Read ${meta.sessions} session transcript(s) from ${await configDir($)}/projects.\n\n`
551 : ''
552 return { text: lead + report }
553 }
554 const { cost, startedAt } = await $.session.usage()
555 if (cost === undefined) {
556 return { text: 'This host keeps no cost ledger for the session.' }
557 }
558 const list = await read($, steps)
559 const minutes = Math.max(1, Math.round(((await $.clock.now()) - startedAt) / 60000))
560 const lines = [
561 `Session API spend: ${usd(cost.usd)} over ${minutes} min`,
562 `Rate: ${usd((cost.usd / minutes) * 60)}/hour`,
563 ]
564 const recentSteps = list.slice(-10)
565 if (recentSteps.length > 0) {
566 lines.push('', `Last ${recentSteps.length} measured step(s):`)
567 for (const s of recentSteps) {
568 lines.push(` +${usd(s.usd).padEnd(9)} → ${usd(s.total)}`)
569 }
570 const priciest = list.reduce((a, b) => (b.usd > a.usd ? b : a))
571 lines.push('', `Priciest step: ${usd(priciest.usd)}`)
572 }
573 const view = await cacheView($, ttlSetting)
574 const ttlName = (ms: number) => (ms >= 3_600_000 ? '1h' : '5m')
575 lines.push(
576 '',
577 view === undefined
578 ? `Prompt cache: no main-thread request seen yet (${stepsSeen} model requests since the mod loaded)`
579 : `Prompt cache: ${view.isInUse ? 'warm, in use' : timeLeft(view.remaining)} ` +
580 `(${ttlName(view.ttl)} TTL, setting ${ttlSetting}), ${view.tokens.toLocaleString('en-US')} tokens` +
581 (view.cost
582 ? `; next prompt ~${usd(view.cost.warm)} warm, ~${usd(view.cost.cold)} if expired`
583 : ''),
584 )
585 lines.push(
586 '',
587 'Estimated at list price (or your managed pricing); subscription plans are not billed per token.',
588 )
589
590 return { text: lines.join('\n') }
591 })
592}
593hooks/cache.ts 50 lines1// Prompt-cache timing and what the next main-thread request would cost with
2// the cache warm or expired. Prices are list prices per million tokens.
3
4import type { CacheWindow } from '../types'
5import { ratesFor } from './pricing'
6
7export type TtlSetting = 'auto' | '5m' | '1h'
8
9/** The TTL in ms: as set, or for `auto`, 1h on a subscription and 5m otherwise. */
10export const ttlMs = (setting: string, isSubscriber: boolean): number =>
11 setting === '1h' || (setting !== '5m' && isSubscriber) ? 3_600_000 : 300_000
12
13export const promptTokens = (c: CacheWindow): number => c.prefixTokens + c.outputTokens
14
15export type NextCost = { warm: number; cold: number }
16
17/**
18 * The next request re-sends the last prompt plus the last reply. Warm, the
19 * prompt is read from cache and the reply is written to it; expired, all of
20 * it is written again at the TTL's write rate (1.25x input, 2x for 1h).
21 */
22export const nextCost = (c: CacheWindow, ttl: number): NextCost | undefined => {
23 const rates = ratesFor(c.model, promptTokens(c))
24 if (rates === undefined) return undefined
25 const write = rates.input * (ttl >= 3_600_000 ? 2 : 1.25)
26 return {
27 warm: (c.prefixTokens * rates.read + c.outputTokens * write) / 1e6,
28 cold: (promptTokens(c) * write) / 1e6,
29 }
30}
31
32export const remainingMs = (c: CacheWindow, now: number, ttl: number): number =>
33 Math.max(0, c.startedAt + ttl - now)
34
35/** 0 a full cube, 1 melting, 2 nearly gone, 3 a puddle (expired). */
36export const meltStage = (remaining: number, ttl: number): number => {
37 const f = remaining / ttl
38 return f <= 0 ? 3 : f < 0.2 ? 2 : f < 0.5 ? 1 : 0
39}
40
41export const timeLeft = (ms: number): string => {
42 if (ms <= 0) return 'expired'
43 const s = Math.ceil(ms / 1000)
44 if (s < 120) return `${s}s left`
45 return `${Math.ceil(s / 60)}m left`
46}
47
48/** When to warn: a minute before a 5m cache expires, five before a 1h one. */
49export const warnBeforeMs = (ttl: number): number => (ttl >= 3_600_000 ? 300_000 : 60_000)
50hooks/card.tsx 299 lines1import type { ElementTable } from 'claude-code'
2
3import type { RateLimit, Subagent } from '../types'
4import type { NextCost } from './cache'
5import { timeLeft } from './cache'
6import {
7 agentPalette,
8 agentSprite,
9 BIT_PALETTE,
10 bitWithCoins,
11 coinsFor,
12 meterColor,
13 meterSvg,
14 meterText,
15 hex,
16 sparkSvg,
17 sparkText,
18 toRaster,
19 toSvg,
20} from './sprites'
21
22export type CardData = {
23 total: number
24 turns: number[]
25 rateLimits: RateLimit[]
26 agents: Subagent[]
27 isWorking: boolean
28 frame: number
29 now: number
30 columns: number
31 cache?: CacheView
32 /** Every session's spend with this one's, and today's. */
33 allTime?: { total: number; today: number }
34 /** Opens the spending breakdown by conversation. */
35 onBreakdown?: () => void
36}
37
38/** The prompt cache as the card shows it, worked out by the module. */
39export type CacheView = {
40 isInUse: boolean
41 remaining: number
42 ttl: number
43 melt: number
44 tokens: number
45 cost?: NextCost
46}
47
48const cacheColor = (c: CacheView): string => {
49 const f = c.remaining / c.ttl
50 return c.isInUse || f >= 0.5 ? 'success' : f >= 0.2 ? 'warning' : 'error'
51}
52
53const cacheStatus = (c: CacheView): string => (c.isInUse ? 'warm, in use' : timeLeft(c.remaining))
54
55export const cacheLine = (c: CacheView): string => {
56 if (c.cost === undefined) return `Next prompt re-sends ${tokens(c.tokens)} tok`
57 if (c.remaining <= 0 && !c.isInUse) {
58 return `Next prompt re-caches ${tokens(c.tokens)} tok ~${usd(c.cost.cold)}`
59 }
60 return `Next prompt ~${usd(c.cost.warm)}, ~${usd(c.cost.cold)} if expired`
61}
62
63const cacheColorHex = (c: CacheView): number => {
64 const f = c.isInUse ? 1 : c.remaining / c.ttl
65 return f >= 0.5 ? 0x46a758 : f >= 0.2 ? 0xe0b341 : 0xe5484d
66}
67
68const cacheFraction = (c: CacheView): number => (c.isInUse ? 1 : c.remaining / c.ttl)
69
70export const usd = (n: number): string => (n < 1 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`)
71
72const tokens = (n: number): string =>
73 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
74
75const KIND_LABELS: Record<string, string> = {
76 five_hour: '5h',
77 seven_day: '7d',
78 seven_day_opus: '7d Opus',
79 seven_day_sonnet: '7d Sonnet',
80 spend_limit: 'Spend',
81}
82
83export const kindLabel = (kind: string): string => KIND_LABELS[kind] ?? kind.replaceAll('_', ' ')
84
85export const resetsIn = (iso: string | undefined, now: number): string => {
86 const ms = iso === undefined ? NaN : Date.parse(iso) - now
87 if (!(ms > 0)) return ''
88 const m = Math.round(ms / 60000)
89 if (m < 60) return `resets in ${m}m`
90 const h = Math.floor(m / 60)
91 if (h < 24) return `resets in ${h}h ${m % 60}m`
92 return `resets in ${Math.floor(h / 24)}d ${h % 24}h`
93}
94
95const agentCaption = (a: Subagent): string =>
96 a.state === 'running' ? 'working' : a.state === 'failed' ? 'stopped' : `${tokens(a.tokens)} tok`
97
98const agentAlt = (a: Subagent): string =>
99 `${a.type} subagent, ${a.state === 'running' ? 'working' : a.state}: ${a.description}`
100
101/** Running agents first, then the newest, as many as `room` allows. */
102export const pickAgents = (agents: Subagent[], room: number): Subagent[] => {
103 const running = agents.filter(a => a.state === 'running')
104 const ended = agents.filter(a => a.state !== 'running').reverse()
105 return [...running, ...ended].slice(0, Math.max(0, room))
106}
107
108const STATS_COLUMNS = 46
109const AGENT_COLUMNS = 10
110
111const recent = (turns: number[]) => turns.slice(-20)
112
113export const drawTerminal = (ui: ElementTable<'terminal'>, d: CardData) => {
114 const { Box, Text, Raster, Button } = ui
115 const bit = toRaster(
116 bitWithCoins(d.isWorking, d.frame, coinsFor(d.total), d.cache?.melt),
117 BIT_PALETTE,
118 )
119 const showBit = d.columns >= bit.columns + 2 + 30
120 const room = Math.floor(
121 (d.columns - (showBit ? bit.columns + 2 : 0) - STATS_COLUMNS - 2) / AGENT_COLUMNS,
122 )
123 const shown = pickAgents(d.agents, room)
124 const hidden = d.agents.length - shown.length
125 const turns = recent(d.turns)
126 const cache = d.cache
127 const [cacheLit, cacheUnlit] = cache ? meterText(cacheFraction(cache), 10) : ['', '']
128
129 return (
130 <Box flexDirection="row" gap={2}>
131 {showBit && <Raster key="bit" {...bit} />}
132 <Box flexDirection="column" width={Math.min(STATS_COLUMNS, d.columns)}>
133 <Text>
134 <Text bold color="claude">
135 {usd(d.total)}
136 </Text>
137 <Text dimColor> API spend this session</Text>
138 </Text>
139 {(d.allTime || d.onBreakdown) && (
140 <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
141 {d.allTime && (
142 <Text dimColor>
143 All sessions {usd(d.allTime.total)} · today {usd(d.allTime.today)}
144 </Text>
145 )}
146 {d.onBreakdown && (
147 <Button
148 key="breakdown"
149 label="Breakdown"
150 hotkey="b"
151 onPress={() => d.onBreakdown?.()}
152 />
153 )}
154 </Box>
155 )}
156 {turns.length > 0 ? (
157 <Text>
158 <Text color="claude">{sparkText(turns)}</Text>
159 <Text dimColor> last {turns.length} turns</Text>
160 </Text>
161 ) : (
162 <Text dimColor>No turns measured yet</Text>
163 )}
164 {cache && (
165 <Text>
166 <Text>Cache </Text>
167 <Text color={cacheColor(cache)}>{cacheLit}</Text>
168 <Text dimColor>{cacheUnlit}</Text>
169 <Text color={cacheColor(cache)}> {cacheStatus(cache)}</Text>
170 </Text>
171 )}
172 {cache && <Text dimColor>{cacheLine(cache)}</Text>}
173 {d.rateLimits.map(limit => {
174 const [lit, unlit] = meterText(limit.percentUsed / 100, 14)
175 return (
176 <Text key={limit.kind}>
177 <Text>{kindLabel(limit.kind).padEnd(4)} </Text>
178 <Text color={hex(meterColor(limit.percentUsed))}>{lit}</Text>
179 <Text dimColor>{unlit}</Text>
180 <Text> {`${Math.round(limit.percentUsed)}%`.padStart(4)}</Text>
181 <Text dimColor> {resetsIn(limit.resetsAt, d.now)}</Text>
182 </Text>
183 )
184 })}
185 {hidden > 0 && (
186 <Text dimColor>
187 +{hidden} more subagent{hidden === 1 ? '' : 's'}
188 </Text>
189 )}
190 </Box>
191 {shown.map(a => (
192 <Box key={`agent-${a.id}`} flexDirection="column" width={AGENT_COLUMNS - 1}>
193 <Raster
194 key={`sprite-${a.id}`}
195 {...toRaster(agentSprite(a.state, d.frame), agentPalette(a.type, a.state))}
196 />
197 <Text wrap="truncate-end" dimColor={a.state !== 'running'}>
198 {a.type}
199 </Text>
200 <Text wrap="truncate-end" dimColor>
201 {agentCaption(a)}
202 </Text>
203 </Box>
204 ))}
205 </Box>
206 )
207}
208
209export const drawDesktop = (
210 ui: Pick<ElementTable<'mobile'>, 'Box' | 'Text' | 'Svg' | 'Button'>,
211 d: CardData,
212) => {
213 const { Box, Text, Svg, Button } = ui
214 const shown = pickAgents(d.agents, 8)
215 const hidden = d.agents.length - shown.length
216 const turns = recent(d.turns)
217 const coins = coinsFor(d.total)
218 const cache = d.cache
219 const melt = ['a full ice cube', 'a melting ice cube', 'a nearly melted ice cube', 'a puddle']
220
221 return (
222 <Box flexDirection="row" gap={2} flexWrap="wrap" alignItems="center">
223 <Svg
224 source={toSvg(bitWithCoins(d.isWorking, d.frame, coins, cache?.melt), BIT_PALETTE, 4)}
225 alt={`Bit, ${d.isWorking ? 'hopping while Claude works' : 'standing by'}, beside ${coins} coin${coins === 1 ? '' : 's'}${cache ? ` and ${melt[cache.melt]}` : ''}`}
226 />
227 <Box flexDirection="column" gap={0}>
228 <Text>
229 <Text bold color="claude">
230 {usd(d.total)}
231 </Text>
232 <Text dimColor> API spend this session</Text>
233 </Text>
234 {(d.allTime || d.onBreakdown) && (
235 <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
236 {d.allTime && (
237 <Text dimColor>
238 All sessions {usd(d.allTime.total)} · today {usd(d.allTime.today)}
239 </Text>
240 )}
241 {d.onBreakdown && (
242 <Button
243 key="breakdown"
244 label="Breakdown"
245 hotkey="b"
246 onPress={() => d.onBreakdown?.()}
247 />
248 )}
249 </Box>
250 )}
251 {turns.length > 0 ? (
252 <Box flexDirection="row" gap={1} alignItems="flex-end">
253 <Svg
254 source={sparkSvg(turns, 0xd97757)}
255 alt={`Spend per turn over the last ${turns.length} turns, the latest ${usd(turns.at(-1) ?? 0)}`}
256 />
257 <Text dimColor>last {turns.length} turns</Text>
258 </Box>
259 ) : (
260 <Text dimColor>No turns measured yet</Text>
261 )}
262 {cache && (
263 <Box flexDirection="row" gap={1} alignItems="center">
264 <Text>Cache</Text>
265 <Svg
266 source={meterSvg(cacheFraction(cache), cacheColorHex(cache), 10)}
267 alt={`Prompt cache ${cacheStatus(cache)}`}
268 />
269 <Text color={cacheColor(cache)}>{cacheStatus(cache)}</Text>
270 </Box>
271 )}
272 {cache && <Text dimColor>{cacheLine(cache)}</Text>}
273 {d.rateLimits.map(limit => (
274 <Box key={limit.kind} flexDirection="row" gap={1} alignItems="center">
275 <Text>{kindLabel(limit.kind)}</Text>
276 <Svg
277 source={meterSvg(limit.percentUsed / 100, meterColor(limit.percentUsed))}
278 alt={`${kindLabel(limit.kind)} plan usage ${Math.round(limit.percentUsed)}%`}
279 />
280 <Text>{Math.round(limit.percentUsed)}%</Text>
281 <Text dimColor>{resetsIn(limit.resetsAt, d.now)}</Text>
282 </Box>
283 ))}
284 </Box>
285 {shown.map(a => (
286 <Box key={`agent-${a.id}`} flexDirection="column" alignItems="center">
287 <Svg
288 source={toSvg(agentSprite(a.state, d.frame), agentPalette(a.type, a.state), 4)}
289 alt={agentAlt(a)}
290 />
291 <Text dimColor={a.state !== 'running'}>{a.type}</Text>
292 <Text dimColor>{agentCaption(a)}</Text>
293 </Box>
294 ))}
295 {hidden > 0 && <Text dimColor>+{hidden} more</Text>}
296 </Box>
297 )
298}
299hooks/breakdown.tsx 108 lines1import type { ElementTable } from 'claude-code'
2
3import type { ConversationRow } from '../types'
4import { usd } from './card'
5import { dayKey } from './history'
6
7export const BREAKDOWN_PANE = 'spend-breakdown'
8
9export type BreakdownData = {
10 rows: ConversationRow[] | null
11 sort: string
12 isRefreshing: boolean
13 columns: number
14}
15
16export type BreakdownActions = {
17 onSort: (value: string) => void
18 onClose: () => void
19}
20
21const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
22
23export const shortDay = (ms: number): string => {
24 const d = dayKey(ms)
25 return `${MONTHS[Number(d.slice(5, 7)) - 1]} ${d.slice(8, 10)}`
26}
27
28const SORTS = [
29 { value: 'cost', label: 'Most expensive' },
30 { value: 'recent', label: 'Most recent' },
31]
32
33const shareBar = (v: number, max: number, width = 10) =>
34 '█'.repeat(max > 0 ? Math.max(1, Math.round((v / max) * width)) : 0)
35
36type Table = Pick<ElementTable<'mobile'>, 'Box' | 'Text' | 'Button'> &
37 Partial<Pick<ElementTable<'desktop'>, 'Select'>>
38
39/** Every conversation's spend, one row each, in a pane that acts as a dialog. */
40export const drawBreakdown = (ui: Table, d: BreakdownData, act: BreakdownActions) => {
41 const { Box, Text, Button, Select } = ui
42 const rows = [...(d.rows ?? [])].sort((a, b) =>
43 d.sort === 'recent' ? b.updatedAt - a.updatedAt : b.usd - a.usd,
44 )
45 const total = rows.reduce((a, r) => a + r.usd, 0)
46 const max = Math.max(...rows.map(r => r.usd), 0)
47 const estimates = rows.filter(r => !r.isExact).length
48
49 return (
50 <Box flexDirection="column" gap={1}>
51 <Box flexDirection="row" gap={2} flexWrap="wrap" alignItems="center">
52 <Text bold>
53 {rows.length} conversation{rows.length === 1 ? '' : 's'} · {usd(total)}
54 </Text>
55 {Select && (
56 <Select
57 key="sort"
58 label="Sort"
59 options={SORTS}
60 value={d.sort}
61 onSelect={value => act.onSort(value)}
62 />
63 )}
64 <Button key="close" label="Close" role="dismiss" onPress={() => act.onClose()} />
65 </Box>
66 {d.isRefreshing && <Text dimColor>Reading changed transcripts…</Text>}
67 {d.rows === null && <Text dimColor>Loading…</Text>}
68 {d.rows !== null && rows.length === 0 && (
69 <Text dimColor>No spend recorded yet. /spend import reads your saved transcripts.</Text>
70 )}
71 <Box flexDirection="column">
72 {rows.map(r => (
73 <Box key={`row-${r.id}`} flexDirection="column" marginBottom={1}>
74 <Box flexDirection="row" gap={2}>
75 <Box width={6} flexShrink={0}>
76 <Text dimColor>{shortDay(r.updatedAt)}</Text>
77 </Box>
78 <Box width={9} flexShrink={0} justifyContent="flex-end">
79 <Text bold={r.isCurrent}>{`${r.isExact ? '' : '~'}${usd(r.usd)}`}</Text>
80 </Box>
81 <Box width={10} flexShrink={0}>
82 <Text color="claude">{shareBar(r.usd, max)}</Text>
83 </Box>
84 <Text wrap="truncate-end" color={r.isCurrent ? 'claude' : undefined}>
85 {r.title}
86 {r.isCurrent ? ' (this conversation)' : ''}
87 </Text>
88 </Box>
89 <Box flexDirection="row" paddingLeft={8}>
90 <Text dimColor wrap="truncate-end">
91 {[r.project, r.models.join(', '), r.isExact ? 'exact' : 'estimate']
92 .filter(Boolean)
93 .join(' · ')}
94 </Text>
95 </Box>
96 </Box>
97 ))}
98 </Box>
99 {estimates > 0 && (
100 <Text dimColor>
101 ~ marks an estimate at list price from a transcript; the rest are exact, as /cost
102 reports them.
103 </Text>
104 )}
105 </Box>
106 )
107}
108hooks/history.ts 396 lines1// Spend across sessions: each session's own ledger (the exact figure /cost
2// reports) and what its transcript says it cost at list price, merged so the
3// ledger wins wherever a session has one.
4
5import type { ConversationRow } from '../types'
6import { costOfUsage } from './pricing'
7import type { RecordedUsage } from './pricing'
8
9export type SessionSpend = {
10 id: string
11 project: string
12 startedAt: number
13 updatedAt: number
14 usd: number
15 /** Spend by local date, `YYYY-MM-DD`. */
16 days: Record<string, number>
17}
18
19/** A session's own record, written as it runs. */
20export type LedgerEntry = SessionSpend & {
21 /** The day the session last spent on, and its total when that day began. */
22 dayBase: { date: string; usd: number }
23}
24
25/** A session as its transcript files price it. */
26export type ImportEntry = SessionSpend & {
27 /** The conversation's title, else its first prompt. */
28 title?: string
29 models: Record<string, number>
30 /** Responses on models the price table does not know. */
31 unpriced: number
32 /** The files' total size and newest change, to skip them when unchanged. */
33 source: { size: number; mtimeMs: number }
34}
35
36export type MergedSpend = SessionSpend & {
37 title?: string
38 models: Record<string, number>
39 isExact: boolean
40}
41
42const pad = (n: number) => String(n).padStart(2, '0')
43
44export const dayKey = (ms: number): string => {
45 const d = new Date(ms)
46 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
47}
48
49export const projectName = (cwd: string): string =>
50 cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || cwd
51
52const sum = (r: Record<string, number>) => Object.values(r).reduce((a, b) => a + b, 0)
53
54const add = (r: Record<string, number>, key: string, usd: number) => {
55 r[key] = (r[key] ?? 0) + usd
56}
57
58/**
59 * The session's ledger after it reported `usd` in all: the day's share is the
60 * total less what it stood at when the day began, so a write repeated or
61 * raced by a second copy of the mod lands on the same figures.
62 */
63export const nextLedger = (
64 prev: LedgerEntry | undefined,
65 s: { id: string; project: string; startedAt: number; usd: number; now: number },
66): LedgerEntry => {
67 const today = dayKey(s.now)
68 const startedAt = prev?.startedAt ?? s.startedAt
69 const carried =
70 prev === undefined
71 ? { date: today, usd: s.usd }
72 : prev.dayBase.date === today
73 ? prev.dayBase
74 : { date: today, usd: prev.usd }
75 // On the session's first day, all it spent before the ledger began was
76 // spent that day too; on a later day, mergeSpend spreads it as the
77 // transcript does.
78 const base = carried.date === dayKey(startedAt) ? { date: carried.date, usd: 0 } : carried
79 const days = { ...prev?.days }
80 const spentToday = s.usd - base.usd
81 if (spentToday > 0) days[today] = spentToday
82 return {
83 id: s.id,
84 project: s.project,
85 startedAt,
86 updatedAt: s.now,
87 usd: s.usd,
88 days,
89 dayBase: base,
90 }
91}
92
93type TranscriptLine = {
94 type?: string
95 timestamp?: string
96 cwd?: string
97 requestId?: string
98 isMeta?: boolean
99 customTitle?: string
100 message?: {
101 id?: string
102 model?: string
103 usage?: RecordedUsage
104 content?: string | { type?: string; text?: string }[]
105 }
106}
107
108/** A prompt the person typed, not a tool result, command or reminder. */
109const promptText = (row: TranscriptLine): string | undefined => {
110 if (row.type !== 'user' || row.isMeta) return undefined
111 const c = row.message?.content
112 const text = typeof c === 'string' ? c : c?.find(b => b.type === 'text')?.text
113 const t = text?.trim().replace(/\s+/g, ' ')
114 return t && !t.startsWith('<') ? t.slice(0, 120) : undefined
115}
116
117const parse = (text: string): TranscriptLine | undefined => {
118 try {
119 return JSON.parse(text) as TranscriptLine
120 } catch {
121 return undefined
122 }
123}
124
125/**
126 * Prices a session's transcript lines as they come: each assistant response
127 * once (a response is written as one line per content block, all carrying
128 * its usage), by model and by day.
129 */
130export const createTally = (id: string) => {
131 const seen = new Set<string>()
132 const entry: ImportEntry = {
133 id,
134 project: '',
135 startedAt: Number.POSITIVE_INFINITY,
136 updatedAt: 0,
137 usd: 0,
138 days: {},
139 models: {},
140 unpriced: 0,
141 source: { size: 0, mtimeMs: 0 },
142 }
143 let title: string | undefined
144 let firstPrompt: string | undefined
145
146 return {
147 line(text: string) {
148 if (text.includes('"custom-title"')) {
149 const row = parse(text)
150 if (row?.type === 'custom-title' && row.customTitle) title = row.customTitle
151 return
152 }
153 if (firstPrompt === undefined && text.includes('"type":"user"')) {
154 const row = parse(text)
155 if (row) firstPrompt = promptText(row)
156 return
157 }
158 if (!text.includes('"usage"')) return
159 const row = parse(text)
160 if (row === undefined) return
161 const m = row.message
162 if (row.type !== 'assistant' || m?.usage === undefined || m.model === undefined) return
163 if (m.model === '<synthetic>') return
164 const key = `${m.id ?? ''}:${row.requestId ?? ''}`
165 if (key !== ':' && seen.has(key)) return
166 seen.add(key)
167 const at = row.timestamp ? Date.parse(row.timestamp) : NaN
168 if (!Number.isNaN(at)) {
169 entry.startedAt = Math.min(entry.startedAt, at)
170 entry.updatedAt = Math.max(entry.updatedAt, at)
171 }
172 if (entry.project === '' && row.cwd) entry.project = projectName(row.cwd)
173 const usd = costOfUsage(m.model, m.usage)
174 if (usd === undefined) {
175 entry.unpriced += 1
176 return
177 }
178 entry.usd += usd
179 add(entry.models, m.model, usd)
180 add(entry.days, dayKey(Number.isNaN(at) ? entry.updatedAt : at), usd)
181 },
182 result(source: { size: number; mtimeMs: number }): ImportEntry {
183 return {
184 ...entry,
185 startedAt: Number.isFinite(entry.startedAt) ? entry.startedAt : source.mtimeMs,
186 updatedAt: entry.updatedAt || source.mtimeMs,
187 project: entry.project || 'unknown',
188 title: title ?? firstPrompt,
189 source,
190 }
191 },
192 }
193}
194
195/** Splits streamed text into lines, holding a partial line until the next piece. */
196export const lineSplitter = (onLine: (line: string) => void) => {
197 let rest = ''
198 return {
199 push(text: string) {
200 const parts = (rest + text).split('\n')
201 rest = parts.pop() ?? ''
202 parts.forEach(onLine)
203 },
204 end() {
205 if (rest !== '') onLine(rest)
206 rest = ''
207 },
208 }
209}
210
211const scale = (r: Record<string, number>, f: number): Record<string, number> =>
212 Object.fromEntries(Object.entries(r).map(([k, v]) => [k, v * f]))
213
214/**
215 * One record per session. A ledger's total is exact; spend it holds from
216 * before it began (a resumed session) is spread over days as the transcript
217 * spreads it, else on the day the session began. Models come from the
218 * transcript, scaled to the ledger's total.
219 */
220export const mergeSpend = (ledgers: LedgerEntry[], imports: ImportEntry[]): MergedSpend[] => {
221 const byId = new Map<string, MergedSpend>()
222 for (const h of imports) {
223 byId.set(h.id, { ...h, isExact: false })
224 }
225 for (const l of ledgers) {
226 const h = imports.find(i => i.id === l.id)
227 const days = { ...l.days }
228 const earlier = l.usd - sum(days)
229 if (earlier > 1e-9) {
230 const hTotal = h ? sum(h.days) : 0
231 if (h && hTotal > 0) {
232 for (const [day, usd] of Object.entries(h.days)) add(days, day, (usd / hTotal) * earlier)
233 } else {
234 add(days, dayKey(l.startedAt), earlier)
235 }
236 }
237 byId.set(l.id, {
238 id: l.id,
239 project: l.project,
240 startedAt: Math.min(l.startedAt, h?.startedAt ?? l.startedAt),
241 updatedAt: Math.max(l.updatedAt, h?.updatedAt ?? 0),
242 usd: l.usd,
243 days,
244 title: h?.title,
245 models: h && h.usd > 0 ? scale(h.models, l.usd / h.usd) : { 'not recorded': l.usd },
246 isExact: true,
247 })
248 }
249 return [...byId.values()]
250}
251
252export type Summary = {
253 total: number
254 sessions: number
255 exact: number
256 since: number
257 today: number
258 week: number
259 month: number
260 byDay: [string, number][]
261 byProject: [string, { usd: number; sessions: number }][]
262 byModel: [string, number][]
263}
264
265export const summarize = (all: MergedSpend[], now: number): Summary => {
266 const days: Record<string, number> = {}
267 const projects: Record<string, { usd: number; sessions: number }> = {}
268 const models: Record<string, number> = {}
269 for (const s of all) {
270 for (const [d, usd] of Object.entries(s.days)) add(days, d, usd)
271 for (const [m, usd] of Object.entries(s.models)) add(models, m, usd)
272 const p = (projects[s.project] ??= { usd: 0, sessions: 0 })
273 p.usd += s.usd
274 p.sessions += 1
275 }
276 const today = dayKey(now)
277 const weekStart = dayKey(now - 6 * 86_400_000)
278 const month = today.slice(0, 7)
279 const desc = <T,>(r: Record<string, T>, by: (v: T) => number) =>
280 Object.entries(r).sort((a, b) => by(b[1]) - by(a[1]))
281 return {
282 total: all.reduce((a, s) => a + s.usd, 0),
283 sessions: all.length,
284 exact: all.filter(s => s.isExact).length,
285 since: Math.min(...all.map(s => s.startedAt), now),
286 today: days[today] ?? 0,
287 week: Object.entries(days).filter(([d]) => d >= weekStart).reduce((a, [, v]) => a + v, 0),
288 month: Object.entries(days).filter(([d]) => d.startsWith(month)).reduce((a, [, v]) => a + v, 0),
289 byDay: Object.entries(days).sort((a, b) => (a[0] < b[0] ? 1 : -1)),
290 byProject: desc(projects, p => p.usd),
291 byModel: desc(models, v => v),
292 }
293}
294
295/** Everything but the running session, for the card to add the session to. */
296export const othersOf = (all: MergedSpend[], ownId: string, now: number) => {
297 const others = all.filter(s => s.id !== ownId)
298 const today = dayKey(now)
299 return {
300 total: others.reduce((a, s) => a + s.usd, 0),
301 today: others.reduce((a, s) => a + (s.days[today] ?? 0), 0),
302 sessions: others.length,
303 date: today,
304 }
305}
306
307const money = (n: number): string => (n < 1 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`)
308
309const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
310
311const shortDate = (day: string): string =>
312 `${MONTHS[Number(day.slice(5, 7)) - 1]} ${day.slice(8, 10)}`
313
314const bar = (v: number, max: number, width = 20) =>
315 '█'.repeat(max > 0 ? Math.max(v > 0 ? 1 : 0, Math.round((v / max) * width)) : 0)
316
317export type ImportReport = {
318 importedAt?: number
319 unpriced: number
320 failed: string[]
321}
322
323export const formatReport = (s: Summary, imp: ImportReport): string => {
324 if (s.sessions === 0) {
325 return 'No sessions recorded yet. Run /spend import to read your saved transcripts.'
326 }
327 const lines = [
328 `All sessions: ${money(s.total)} across ${s.sessions} session${s.sessions === 1 ? '' : 's'} since ${shortDate(dayKey(s.since))}`,
329 `Today ${money(s.today)} · last 7 days ${money(s.week)} · this month ${money(s.month)}`,
330 '',
331 'By day (last 14)',
332 ]
333 const days = s.byDay.slice(0, 14)
334 const dayMax = Math.max(...days.map(([, v]) => v), 0)
335 for (const [day, usd] of days) {
336 lines.push(` ${shortDate(day)} ${money(usd).padStart(9)} ${bar(usd, dayMax)}`)
337 }
338 lines.push('', 'By project')
339 const width = Math.min(28, Math.max(...s.byProject.map(([p]) => p.length), 8))
340 for (const [project, p] of s.byProject.slice(0, 12)) {
341 const name = project.length > width ? `${project.slice(0, width - 1)}…` : project.padEnd(width)
342 lines.push(` ${name} ${money(p.usd).padStart(9)} ${p.sessions} session${p.sessions === 1 ? '' : 's'}`)
343 }
344 lines.push('', 'By model (from transcripts)')
345 for (const [model, usd] of s.byModel.slice(0, 8)) {
346 lines.push(` ${model.padEnd(width)} ${money(usd).padStart(9)}`)
347 }
348 lines.push(
349 '',
350 `${s.exact} session${s.exact === 1 ? '' : 's'} from the ledger (exact, as /cost reports), ` +
351 `${s.sessions - s.exact} from transcripts (estimates at list price).`,
352 )
353 if (imp.importedAt !== undefined) {
354 const d = new Date(imp.importedAt)
355 lines.push(`Transcripts last read ${shortDate(dayKey(imp.importedAt))} at ${pad(d.getHours())}:${pad(d.getMinutes())}; /spend import reads them again.`)
356 }
357 if (imp.unpriced > 0) lines.push(`${imp.unpriced} responses on models without a price were not counted.`)
358 if (imp.failed.length > 0) lines.push(`Could not read ${imp.failed.length} transcript(s): ${imp.failed.slice(0, 3).join(', ')}`)
359 lines.push('On a subscription plan these are API-equivalent amounts, not what you are billed.')
360 return lines.join('\n')
361}
362
363/** `claude-opus-5-5` as `Opus 5.5`; a dated id loses its date. */
364export const modelName = (id: string): string => {
365 const parts = id.replace(/^claude-/, '').split('-').filter(p => !/^\d{8}$/.test(p))
366 if (parts.length < 2 || !/^[a-z]+$/.test(parts[0]!)) return id
367 const [family, ...version] = parts
368 return `${family![0]!.toUpperCase()}${family!.slice(1)} ${version.join('.')}`
369}
370
371export type ConversationSort = 'cost' | 'recent'
372
373/** Conversations for the breakdown: most expensive or most recent first. */
374export const conversationRows = (
375 all: MergedSpend[],
376 ownId: string,
377 sort: ConversationSort,
378): ConversationRow[] =>
379 all
380 .filter(s => s.usd > 0)
381 .sort((a, b) => (sort === 'recent' ? b.updatedAt - a.updatedAt : b.usd - a.usd))
382 .slice(0, 200)
383 .map(s => ({
384 id: s.id,
385 title: s.title ?? (s.id === ownId ? 'This conversation' : 'Untitled'),
386 project: s.project,
387 updatedAt: s.updatedAt,
388 usd: s.usd,
389 isExact: s.isExact,
390 isCurrent: s.id === ownId,
391 models: Object.entries(s.models)
392 .sort((a, b) => b[1] - a[1])
393 .slice(0, 2)
394 .map(([m]) => modelName(m)),
395 }))
396hooks/pricing.ts 73 lines1// List prices per million tokens, and what one API response cost at them.
2
3export type Rates = { input: number; read: number; output: number }
4
5// Most specific first: `opus-5-5` must match before `opus-5`.
6const RATES: [string, Rates][] = [
7 ['fable-5-1', { input: 10, read: 0.25, output: 50 }],
8 ['mythos-5-1', { input: 10, read: 0.25, output: 50 }],
9 ['fable-5', { input: 10, read: 1, output: 50 }],
10 ['mythos-5', { input: 10, read: 1, output: 50 }],
11 ['opus-5-5', { input: 4, read: 0.2, output: 20 }],
12 ['opus-5', { input: 5, read: 0.5, output: 25 }],
13 ['opus-4-8', { input: 5, read: 0.5, output: 25 }],
14 ['opus-4-7', { input: 5, read: 0.5, output: 25 }],
15 ['opus-4-6', { input: 5, read: 0.5, output: 25 }],
16 ['opus-4-5', { input: 5, read: 0.5, output: 25 }],
17 ['opus-4-1', { input: 15, read: 1.5, output: 75 }],
18 ['opus-4', { input: 15, read: 1.5, output: 75 }],
19 ['sonnet-5-5', { input: 2, read: 0.2, output: 10 }],
20 ['sonnet-5', { input: 2, read: 0.2, output: 10 }],
21 ['sonnet-4-6', { input: 3, read: 0.3, output: 15 }],
22 ['sonnet-4-5', { input: 3, read: 0.3, output: 15 }],
23 ['sonnet-4', { input: 3, read: 0.3, output: 15 }],
24 ['haiku-5-5', { input: 0.1, read: 0.01, output: 0.5 }],
25 ['haiku-4-5', { input: 1, read: 0.1, output: 5 }],
26]
27
28// Claude Haiku 5.5 bills prompts over 100K tokens at a higher rate.
29const HAIKU_5_5_LONG: Rates = { input: 0.5, read: 0.05, output: 2.5 }
30
31export const ratesFor = (model: string, promptTokens: number): Rates | undefined => {
32 const id = model.toLowerCase()
33 const hit = RATES.find(([key]) => new RegExp(`claude-${key}(?![0-9])`).test(id))
34 if (hit === undefined) return undefined
35 return hit[0] === 'haiku-5-5' && promptTokens > 100_000 ? HAIKU_5_5_LONG : hit[1]
36}
37
38/** An API response's usage as a transcript records it. */
39export type RecordedUsage = {
40 input_tokens?: number
41 output_tokens?: number
42 cache_read_input_tokens?: number
43 cache_creation_input_tokens?: number
44 cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
45 server_tool_use?: { web_search_requests?: number }
46 speed?: string
47}
48
49const WEB_SEARCH_USD = 0.01
50
51/**
52 * What one response cost at list price, or undefined for a model the table
53 * does not price. Cache writes are 1.25x input for 5 minutes and 2x for an
54 * hour; fast mode doubles the token prices; a web search is $0.01.
55 */
56export const costOfUsage = (model: string, u: RecordedUsage): number | undefined => {
57 const input = u.input_tokens ?? 0
58 const read = u.cache_read_input_tokens ?? 0
59 const written = u.cache_creation_input_tokens ?? 0
60 const rates = ratesFor(model, input + read + written)
61 if (rates === undefined) return undefined
62 const oneHour = u.cache_creation?.ephemeral_1h_input_tokens ?? 0
63 const fiveMinutes = u.cache_creation?.ephemeral_5m_input_tokens ?? written - oneHour
64 const tokens =
65 input * rates.input +
66 fiveMinutes * rates.input * 1.25 +
67 oneHour * rates.input * 2 +
68 read * rates.read +
69 (u.output_tokens ?? 0) * rates.output
70 const speed = u.speed === 'fast' ? 2 : 1
71 return (tokens / 1e6) * speed + (u.server_tool_use?.web_search_requests ?? 0) * WEB_SEARCH_USD
72}
73hooks/sprites.ts 312 lines1// Pixel art for the spend card: drawn as SVG on the desktop, VS Code and
2// mobile, and as half-block cells (two pixels to a cell) in the terminal.
3
4export type Sprite = string[]
5export type Palette = Record<string, number>
6
7const OUTLINE = 0x2b2b3a
8
9const BIT_IDLE: Sprite = [
10 '............',
11 '...KKKKKK...',
12 '..KSSSSSSK..',
13 '.KSHSSSSSSK.',
14 '.KSSWESWESK.',
15 '.KSSWESWESK.',
16 '.KSSSSSSSSK.',
17 '.KSSSKKSSSK.',
18 '.KSSSSSSSSK.',
19 '..KSSSSSSK..',
20 '..KSK..KSK..',
21 '..KK....KK..',
22]
23
24const BIT_HOP: Sprite = [
25 '...KKKKKK...',
26 '..KSSSSSSK..',
27 '.KSHSSSSSSK.',
28 '.KSSSWESWEK.',
29 '.KSSSWESWEK.',
30 '.KSSSSSSSSK.',
31 '.KSSSSKKSSK.',
32 '.KSSSSKKSSK.',
33 '..KSSSSSSK..',
34 '.KSK....KSK.',
35 '.KK......KK.',
36 '............',
37]
38
39export const BIT_PALETTE: Palette = {
40 K: OUTLINE,
41 S: 0xd97757,
42 H: 0xf2a07b,
43 W: 0xffffff,
44 E: 0x1d1d28,
45 G: 0xf5c542,
46 Y: 0xfff1a8,
47 g: 0xb8860b,
48 h: 0xe6f7ff,
49 i: 0x9ad8f5,
50 d: 0x6bb8e0,
51 w: 0x5aa9e6,
52}
53
54// Bit's ice cube melts as the prompt cache nears expiry: stage 0 to 3.
55const ICE: Sprite[] = [
56 [
57 '..........',
58 '..........',
59 '.KKKKKKKK.',
60 '.KhhiiiiK.',
61 '.KhiiiiiK.',
62 '.KiiiiiiK.',
63 '.KiiiiidK.',
64 '.KiiiiddK.',
65 '.KKKKKKKK.',
66 '..........',
67 ],
68 [
69 '..........',
70 '..........',
71 '..........',
72 '..........',
73 '.KKKKKKKK.',
74 '.KhhiiiiK.',
75 '.KiiiiidK.',
76 '.KKKKKKKK.',
77 '..w.......',
78 '.wwww.....',
79 ],
80 [
81 '..........',
82 '..........',
83 '..........',
84 '..........',
85 '..........',
86 '..........',
87 '...KKKK...',
88 '...KhdK...',
89 '...KKKK...',
90 '.wwwwwwww.',
91 ],
92 [
93 '..........',
94 '..........',
95 '..........',
96 '..........',
97 '..........',
98 '..........',
99 '..........',
100 '..........',
101 '..wwwwww..',
102 'wwwwwwwwww',
103 ],
104]
105
106const COIN_TOP = '.GYG.'
107const COIN_EDGE = 'ggggg'
108
109/** How many coins Bit stands beside: one more each time the spend passes a tier. */
110export const coinsFor = (usd: number): number =>
111 usd <= 0 ? 0 : [0.05, 0.25, 1, 5, 20].filter(t => usd >= t).length + 1
112
113/**
114 * Bit, a gap, and a stack of up to six coins (18 by 12 pixels), then the ice
115 * cube at its melt stage when there is a cache to show (29 by 12).
116 */
117export const bitWithCoins = (
118 isWorking: boolean,
119 frame: number,
120 coins: number,
121 melt?: number,
122): Sprite => {
123 const body = isWorking && frame % 2 === 1 ? BIT_HOP : BIT_IDLE
124 const stack = Array.from({ length: 12 }, () => '.....')
125 for (let i = 0; i < Math.min(coins, 6); i++) {
126 stack[11 - i * 2] = COIN_EDGE
127 stack[10 - i * 2] = COIN_TOP
128 }
129 const ice = melt === undefined ? undefined : ICE[Math.max(0, Math.min(3, melt))]!
130 return body.map((row, y) => {
131 const base = `${row}.${stack[y]}`
132 return ice === undefined ? base : `${base}.${ice[y - 2] ?? '..........'}`
133 })
134}
135
136const AGENT_A: Sprite = [
137 '...KK...',
138 '..KCCK..',
139 '.KCCCCK.',
140 'KCWCCWCK',
141 'KCCCCCCK',
142 '.KCDDCK.',
143 '.KC..CK.',
144 '.KK..KK.',
145]
146
147const AGENT_B: Sprite = [
148 '...KK...',
149 '..KCCK..',
150 '.KCCCCK.',
151 'KCWCCWCK',
152 'KCCCCCCK',
153 '.KCDDCK.',
154 '..KCCK..',
155 '..KKKK..',
156]
157
158const AGENT_DONE: Sprite = AGENT_A.map((row, y) => (y === 3 ? 'KCKCCKCK' : row))
159const AGENT_FAILED: Sprite = AGENT_A.map((row, y) => (y === 3 ? 'KCRCCRCK' : row))
160
161const TYPE_COLORS: Record<string, number> = {
162 'general-purpose': 0x5b8def,
163 Explore: 0x3fb6a8,
164 Plan: 0xa77bdb,
165}
166const SPARE_COLORS = [0xe0b341, 0xe06c75, 0x7fc97f, 0xf08a4b, 0x4fc1e9, 0xd16ba5]
167
168export const colorForType = (type: string): number => {
169 const known = TYPE_COLORS[type]
170 if (known !== undefined) return known
171 let hash = 0
172 for (const ch of type) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
173 return SPARE_COLORS[hash % SPARE_COLORS.length]!
174}
175
176const darken = (c: number, f: number): number =>
177 (Math.round(((c >> 16) & 255) * f) << 16) |
178 (Math.round(((c >> 8) & 255) * f) << 8) |
179 Math.round((c & 255) * f)
180
181const toGray = (c: number): number => {
182 const l = Math.round(((c >> 16) & 255) * 0.3 + ((c >> 8) & 255) * 0.59 + (c & 255) * 0.11)
183 return (l << 16) | (l << 8) | l
184}
185
186type AgentLook = 'running' | 'done' | 'failed'
187
188export const agentSprite = (state: AgentLook, frame: number): Sprite =>
189 state === 'done'
190 ? AGENT_DONE
191 : state === 'failed'
192 ? AGENT_FAILED
193 : frame % 2 === 1
194 ? AGENT_B
195 : AGENT_A
196
197export const agentPalette = (type: string, state: AgentLook): Palette => {
198 const base = colorForType(type)
199 const body = state === 'running' ? base : state === 'done' ? darken(base, 0.75) : toGray(base)
200 return { K: OUTLINE, C: body, D: darken(body, 0.6), W: 0xffffff, R: 0xe5484d }
201}
202
203export const hex = (c: number): string => `#${c.toString(16).padStart(6, '0')}`
204
205const svg = (w: number, h: number, scale: number, body: string): string =>
206 `<svg xmlns="http://www.w3.org/2000/svg" width="${w * scale}" height="${h * scale}" ` +
207 `viewBox="0 0 ${w} ${h}" shape-rendering="crispEdges">${body}</svg>`
208
209/** The sprite as SVG, `scale` CSS pixels to a sprite pixel, runs merged. */
210export const toSvg = (sprite: Sprite, palette: Palette, scale: number): string => {
211 const w = sprite[0]!.length
212 const rects: string[] = []
213 sprite.forEach((row, y) => {
214 let x = 0
215 while (x < w) {
216 const ch = row[x]!
217 const color = palette[ch]
218 let run = 1
219 while (x + run < w && row[x + run] === ch) run++
220 if (color !== undefined) {
221 rects.push(`<rect x="${x}" y="${y}" width="${run}" height="1" fill="${hex(color)}"/>`)
222 }
223 x += run
224 }
225 })
226 return svg(w, sprite.length, scale, rects.join(''))
227}
228
229const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
230
231export const base64 = (bytes: Uint8Array): string => {
232 let out = ''
233 for (let i = 0; i < bytes.length; i += 3) {
234 const b = bytes[i + 1]
235 const c = bytes[i + 2]
236 const n = (bytes[i]! << 16) | ((b ?? 0) << 8) | (c ?? 0)
237 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
238 out += b === undefined ? '=' : B64[(n >> 6) & 63]!
239 out += c === undefined ? '=' : B64[n & 63]!
240 }
241 return out
242}
243
244const DEFAULT_COLOR = 0x01000000
245const UPPER_HALF = 0x2580
246const LOWER_HALF = 0x2584
247
248export type RasterCells = { columns: number; rows: number; cells: string }
249
250/**
251 * The sprite as a terminal Raster: each cell holds two pixels, the upper as
252 * an upper-half block's foreground and the lower as its background.
253 */
254export const toRaster = (sprite: Sprite, palette: Palette): RasterCells => {
255 const columns = sprite[0]!.length
256 const rows = Math.ceil(sprite.length / 2)
257 const words = new Uint32Array(columns * rows * 3)
258 for (let r = 0; r < rows; r++) {
259 for (let x = 0; x < columns; x++) {
260 const top = palette[sprite[r * 2]?.[x] ?? '.']
261 const bottom = palette[sprite[r * 2 + 1]?.[x] ?? '.']
262 const i = (r * columns + x) * 3
263 if (top === undefined && bottom === undefined) {
264 words.set([0x20, DEFAULT_COLOR, DEFAULT_COLOR], i)
265 } else if (top === undefined) {
266 words.set([LOWER_HALF, bottom!, DEFAULT_COLOR], i)
267 } else {
268 words.set([UPPER_HALF, top, bottom ?? DEFAULT_COLOR], i)
269 }
270 }
271 }
272 return { columns, rows, cells: base64(new Uint8Array(words.buffer)) }
273}
274
275/** A row of pixel blocks: `fraction` of `segments` lit in `color`. */
276export const meterSvg = (fraction: number, color: number, segments = 20): string => {
277 const lit = Math.round(Math.max(0, Math.min(1, fraction)) * segments)
278 const rects = Array.from({ length: segments }, (_, i) => {
279 const fill = i < lit ? hex(color) : '#88888855'
280 return `<rect x="${i * 3}" y="0" width="2" height="2" fill="${fill}"/>`
281 })
282 return svg(segments * 3 - 1, 2, 4, rects.join(''))
283}
284
285export const meterText = (fraction: number, segments = 20): [string, string] => {
286 const lit = Math.round(Math.max(0, Math.min(1, fraction)) * segments)
287 return ['█'.repeat(lit), '░'.repeat(segments - lit)]
288}
289
290/** Per-turn spend as pixel columns, the tallest the priciest turn shown. */
291export const sparkSvg = (values: number[], color: number): string => {
292 const h = 6
293 const max = Math.max(...values, 0)
294 const rects = values.map((v, i) => {
295 const bar = max > 0 ? Math.max(1, Math.round((v / max) * h)) : 1
296 return `<rect x="${i * 2}" y="${h - bar}" width="1" height="${bar}" fill="${hex(color)}"/>`
297 })
298 return svg(Math.max(1, values.length * 2 - 1), h, 4, rects.join(''))
299}
300
301const SPARK = '▁▂▃▄▅▆▇█'
302
303export const sparkText = (values: number[]): string => {
304 const max = Math.max(...values, 0)
305 return values
306 .map(v => SPARK[max > 0 ? Math.min(7, Math.floor((v / max) * 7.999)) : 0])
307 .join('')
308}
309
310export const meterColor = (percent: number): number =>
311 percent >= 90 ? 0xe5484d : percent >= 70 ? 0xe0b341 : 0x46a758
312types/index.d.ts 68 lines1export type SpendStep = { at: number; usd: number; total: number }
2
3/** One plan usage window, as `$.session.usage()` reports it. */
4export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
5
6/** The main thread's last model request: when it started and what it carried. */
7export type CacheWindow = {
8 startedAt: number
9 model: string
10 prefixTokens: number
11 outputTokens: number
12}
13
14/** Spend in every other session, and the running session's share of today. */
15export type AllTime = {
16 othersTotal: number
17 othersToday: number
18 othersSessions: number
19 /** The day `othersToday` counts, `YYYY-MM-DD`. */
20 date: string
21 ownToday: number
22}
23
24/** One conversation in the spending breakdown. */
25export type ConversationRow = {
26 id: string
27 title: string
28 project: string
29 updatedAt: number
30 usd: number
31 /** From the ledger (as /cost reports) rather than a transcript estimate. */
32 isExact: boolean
33 isCurrent: boolean
34 /** The models that cost the most in it, at most two. */
35 models: string[]
36}
37
38export type AgentState = 'running' | 'done' | 'failed'
39
40export type Subagent = {
41 id: string
42 type: string
43 description: string
44 state: AgentState
45 tokens: number
46}
47
48declare module 'claude-code' {
49 interface PluginState {
50 'session-spend': {
51 total: number
52 steps: SpendStep[]
53 rateLimits: RateLimit[]
54 agents: Subagent[]
55 isWorking: boolean
56 frame: number
57 isCardHidden: boolean
58 cache: CacheWindow | null
59 cacheTick: number
60 cacheWarnedFor: number
61 allTime: AllTime | null
62 breakdown: ConversationRow[] | null
63 breakdownSort: string
64 isBreakdownRefreshing: boolean
65 }
66 }
67}
68