A Spend panel: what each prompt and each sub-agent cost, burn rate, a budget bar and running insights, reconciled to /cost

Five side-panel mods for Claude Code (function hooks, early access API):
| Mod | Panel | Command | |
|---|---|---|---|
| agent-progress | Crew: each sub-agent as a little Claude crab dressed for its job (builder with a hammer, inspector with a monocle, scout with a compass, architect with a set square, librarian with a book), model, live activity, steps, elapsed time, cache hit bar, tokens, and a warning when a worker goes quiet | /crew | |
| cache-tax | Cache: prompt cache countdown gauge, hit-rate sparkline, cold-start warnings, keep-warm pings, and what caching cost: spent, saved by reads, the cold-start tax, what pings cost and what going cold now would add (estimated from /cost with the published cache multipliers) | /keepwarm, /keepwarm now, `/keepwarm ttl 5\ | 60, /keepwarm panel` |
| spend-ledger | Spend: what each prompt and each sub-agent cost, a STATS dashboard modelled on /usage stats: views Overview, Tokens, Cost, Cache and In/Out; ranges This session, 7 days, 30 days and All time; a model filter; an activity heatmap, charts in four styles (line, area, bars, dots) with axes, stat tiles (favorite model, total tokens, sessions, active days, longest session, streaks) and per-model cards. Past days come from Claude Code's own history (~/.claude/stats-cache.json, the file /usage reads), with their in/out/cache split and cost estimated from each model's totals; today is exact, from the mod's own tracking. An active-hours chart shows when you work, a by-model breakdown (cost, share, requests, tokens, cache hit rate), burn rate, budget, running insights. On an API key it budgets in dollars (/spend budget 50, warnings at 80% and 100%). On a Claude subscription it shows the plan's 5-hour and weekly windows with reset times and how long the 5-hour window lasts at your pace, and budgets as a share of it (/spend budget 80%); the dollar figures are then the API-price value of the work, not a charge | /spend, /spend budget 50, /spend budget 80%, add hard to refuse new prompts once reached (/spend budget 50 hard), `/spend hard on\ | off, /spend budget off` |
| session-link | Session Link: the live Claude sessions on this machine, each with its own label (the session sets it itself early in its first turn; until then its first prompt stands in; /link name overrides) and its own Claude crab badge (a pose and colour no two live sessions share), its directory, branch and the files it edited; the messages the sessions send each other; files two sessions both edited. It also stops one session overwriting another: an edit to a file another live session edited in the last six hours, or a broad git command (add -A, commit -a, reset --hard, checkout, switch, stash, rebase, merge, pull) in a repository another session works in, is refused once with the other session's name so the model messages it first; the same call again goes through | /link, /link name A: finish UA, /link release; the model's set_session_label tool | |
| mission-control | Mission Control: all four stacked in one scrollable panel, with buttons for every command | /mission |
Every panel has buttons with hotkeys for its commands.
At the prompt of a terminal session:
/plugin install mission-control --marketplace kimathinjoki/claude-session-dashboard
Answer y to add the marketplace, choose the user scope, then install the others the same way (agent-progress, cache-tax, spend-ledger, session-link). Mission Control reads their state, so it needs all four.
Side panels dock beside the transcript in fullscreen mode ("tui": "fullscreen" in ~/.claude/settings.json, or /config) from 110 columns. They open on their own at session start from 144 columns; at any width the commands above open them. With several open they share the dock as tabs.
The Cache panel follows your promptCacheTtl setting ("1h" keeps the cache for an hour; 1-hour cache writes cost more than 5-minute ones).
hooks/register.tsx 893 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { Ledger, SpendBucket, SpendEntry, StatsChart, StatsRange, StatsView } from '../types'
5import {
6 CHARTS, CHART_HUE, CHART_LABEL, LEVEL_HUE, RANGES, RANGE_HUE, RANGE_LABEL, SHADES, TAB_GAP, VIEWS, VIEW_HUE, VIEW_LABEL, addToDay, barChart, mergeHistory, parseClaudeStats, colourRuns, columnPoints, compact, drawChart, trendOf, TREND_HUE, dayKey, durationText,
7 emptyHistory, funFact, heatmap, pointsFor, seriesFor, tilesFor,
8} from './stats'
9
10const initial: Ledger = { entries: [], totalUsd: 0, samples: [], budgetUsd: null, warned: [], limits: [], planBudgetPercent: null, limitSamples: [], expanded: false, models: {}, buckets: [], history: emptyHistory(), statsView: 'overview', statsRange: 'session', statsModel: null, statsChart: 'line', hardStop: false, sessionStartedAt: null, claudeStats: null, usdBaseline: null, weightedSince: 0 }
11const ledger = atom({ plugin: 'spend-ledger', key: 'ledger' } as const, initial)
12
13const PANE = 'spend'
14const POLL_MS = 2000
15const BURN_WINDOW_MS = 15 * 60_000
16const SHOWN = 8
17const HISTORY_KEY = 'history'
18const SAVE_EVERY_MS = 15_000
19let lastSaved = 0
20const HELPERS = 'helpers'
21
22// The session's cost is exact (it is /cost); how it splits between prompts and workers is
23// estimated. Each poll's increase is shared out over the requests made since the last poll,
24// weighted by their tokens at the published price ratios (output 5x input, cache write 1.25x,
25// cache read 0.1x) and by the model's relative price. So the rows always add up to /cost.
26const MODEL_FACTOR: Array<[RegExp, number]> = [[/opus/i, 5], [/sonnet/i, 3], [/haiku/i, 1], [/fable/i, 5]]
27export const modelFactor = (model: string) => MODEL_FACTOR.find(([test]) => test.test(model))?.[1] ?? 3
28
29export const weight = (u: ModelUsage, model: string) =>
30 (u.input_tokens + 1.25 * u.cache_creation_input_tokens + 0.1 * u.cache_read_input_tokens + 5 * u.output_tokens) *
31 modelFactor(model)
32
33// Share `delta` across pending weights; nothing pending goes to the helpers row.
34export const apportion = (delta: number, pending: Record<string, number>) => {
35 const total = Object.values(pending).reduce((sum, w) => sum + w, 0)
36 if (total <= 0) return { [HELPERS]: delta }
37 return Object.fromEntries(Object.entries(pending).map(([id, w]) => [id, (delta * w) / total]))
38}
39
40export const modelName = (id: string) => id.replace(/^claude-/, '').replace(/-\d{8}$/, '')
41const MODEL_HUES: Array<[RegExp, string]> = [[/opus/i, '#c084fc'], [/sonnet/i, '#60a5fa'], [/haiku/i, '#34d399'], [/fable/i, '#f9a8d4']]
42export const modelHue = (id: string) => MODEL_HUES.find(([test]) => test.test(id))?.[1] ?? '#cbd5e1'
43const shortTokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
44
45export const BUCKET_MS = 5 * 60_000
46const MAX_BUCKETS = 288
47const EIGHTHS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
48const SPARK = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
49
50// Add to the bucket `now` falls in, opening it (and any empty ones between) when needed.
51export const intoBucket = (
52 buckets: SpendBucket[],
53 now: number,
54 change: (b: SpendBucket) => SpendBucket,
55): SpendBucket[] => {
56 const start = Math.floor(now / BUCKET_MS) * BUCKET_MS
57 const list = [...buckets]
58 let last = list[list.length - 1]
59 if (!last) {
60 list.push({ start, usd: 0, inputTokens: 0, cacheReadTokens: 0, outputTokens: 0, planPercent: null })
61 } else {
62 for (let at = last.start + BUCKET_MS; at <= start; at += BUCKET_MS) {
63 list.push({ start: at, usd: 0, inputTokens: 0, cacheReadTokens: 0, outputTokens: 0, planPercent: last.planPercent })
64 }
65 }
66 last = list[list.length - 1]!
67 list[list.length - 1] = change(last)
68 return list.slice(-MAX_BUCKETS)
69}
70
71// A column chart `height` rows tall, one column per value, as text rows top first.
72export const columns = (values: number[], height: number) => {
73 const max = Math.max(...values, 0)
74 const rows: string[] = []
75 for (let row = height - 1; row >= 0; row -= 1) {
76 rows.push(values.map(v => {
77 const eighths = max > 0 ? Math.round((v / max) * height * 8) : 0
78 const fill = eighths - row * 8
79 return fill >= 8 ? '█' : fill <= 0 ? ' ' : EIGHTHS[fill]!
80 }).join(''))
81 }
82 return rows
83}
84
85export const sparkline = (values: number[], max = Math.max(...values, 0)) =>
86 values.map(v => (max > 0 ? SPARK[Math.min(SPARK.length - 1, Math.round((v / max) * (SPARK.length - 1)))] : SPARK[0])).join('')
87
88const hhmm = (ms: number) => {
89 const d = new Date(ms)
90 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
91}
92
93export const usd = (n: number) => (n >= 100 ? `$${n.toFixed(0)}` : n >= 1 ? `$${n.toFixed(2)}` : `${Math.round(n * 100)}¢`)
94
95export const burnPerHour = (samples: Array<{ at: number; usd: number }>, now: number) => {
96 const recent = samples.filter(s => now - s.at <= BURN_WINDOW_MS)
97 if (recent.length < 2) return 0
98 const first = recent[0]!
99 const last = recent[recent.length - 1]!
100 const hours = (last.at - first.at) / 3_600_000
101 return hours > 0 ? (last.usd - first.usd) / hours : 0
102}
103
104const ROLES: Array<[RegExp, string, string]> = [
105 [/re-?review|review|verify|audit/i, 'inspecting', '#22d3ee'],
106 [/implement|build|fix|write|add|create/i, 'building', '#fb923c'],
107 [/explore|map|find|search|survey/i, 'scouting', '#4ade80'],
108 [/plan|design|architect|spec/i, 'planning', '#a78bfa'],
109]
110export const activityOf = (entry: Pick<SpendEntry, 'kind' | 'label'>): [string, string] => {
111 if (entry.kind === 'prompt') return ['conversation', '#93c5fd']
112 if (entry.kind === 'helpers') return ['helpers', '#64748b']
113 const hit = ROLES.find(([test]) => test.test(entry.label))
114 return hit ? [hit[1], hit[2]] : ['other work', '#cbd5e1']
115}
116
117const bar = (share: number, width: number) => {
118 const cells = Math.max(0, Math.min(width, Math.round(share * width)))
119 return '█'.repeat(cells) + '░'.repeat(width - cells)
120}
121
122// Request weights since the last poll, by entry id. Module state: a reload starts it over.
123let pending: Record<string, number> = {}
124// The same weights keyed by model, so each poll's increase also splits across models.
125let pendingByModel: Record<string, number> = {}
126const promptText = new Map<string, string>()
127
128// State saved by an earlier version may lack fields added since; fill them in.
129async function readLedger($: EngineInterface): Promise<Ledger> {
130 return { ...initial, ...(await read($, ledger)) }
131}
132
133export const windowLabel = (kind: string) =>
134 kind === 'five_hour' ? '5-hour window' : kind === 'seven_day' ? 'weekly' : kind.replace(/_/g, ' ')
135
136export const resetsIn = (resetsAt: string | undefined, now: number) => {
137 if (!resetsAt) return ''
138 const ms = Date.parse(resetsAt) - now
139 if (!(ms > 0)) return 'resetting'
140 const h = Math.floor(ms / 3_600_000)
141 const m = Math.floor((ms % 3_600_000) / 60_000)
142 return h >= 24 ? `resets in ${Math.floor(h / 24)}d ${h % 24}h` : `resets in ${h}h ${String(m).padStart(2, '0')}m`
143}
144
145export const usageHue = (percent: number) => (percent >= 85 ? '#ef4444' : percent >= 60 ? '#f97316' : '#22c55e')
146
147async function ensureEntry($: EngineInterface, entry: SpendEntry) {
148 await update($, ledger, l => ((l.entries ?? []).some(one => one.id === entry.id) ? l : { ...l, entries: [...l.entries, entry] }))
149}
150
151// Whether the budget in force is used up: dollars this session, or the plan line on the 5-hour window.
152export const budgetReached = (l: Pick<Ledger, 'budgetUsd' | 'planBudgetPercent' | 'totalUsd' | 'limits'>) => {
153 const fiveHour = (l.limits ?? []).find(w => w.kind === 'five_hour')
154 if (l.planBudgetPercent != null && fiveHour && fiveHour.percentUsed >= l.planBudgetPercent) {
155 return `the 5-hour plan window is at ${Math.round(fiveHour.percentUsed)}%, past your ${l.planBudgetPercent}% line`
156 }
157 if (l.budgetUsd != null && l.totalUsd >= l.budgetUsd) {
158 return `${usd(l.totalUsd)} spent this session, past your ${usd(l.budgetUsd)} budget`
159 }
160 return null
161}
162
163async function warnOnBudget($: EngineInterface) {
164 const l = await readLedger($)
165 const fiveHour = l.limits.find(w => w.kind === 'five_hour')
166 if (l.planBudgetPercent && fiveHour) {
167 const level = fiveHour.percentUsed >= 100 ? 100 : fiveHour.percentUsed >= l.planBudgetPercent ? l.planBudgetPercent : null
168 if (level !== null && !l.warned.includes(1000 + level)) {
169 $.ui.toast(level === 100
170 ? 'The 5-hour plan window is used up.'
171 : `${Math.round(fiveHour.percentUsed)}% of the 5-hour plan window used (your line is ${l.planBudgetPercent}%).`, { timeoutMs: 10_000 })
172 await update($, ledger, current => ({ ...initial, ...current, warned: [...current.warned, 1000 + level] }))
173 }
174 }
175 if (!l.budgetUsd) return
176 const share = l.totalUsd / l.budgetUsd
177 for (const level of [0.8, 1]) {
178 if (share >= level && !l.warned.includes(level)) {
179 $.ui.toast(level === 1
180 ? `Budget reached: ${usd(l.totalUsd)} of ${usd(l.budgetUsd)} spent this session.`
181 : `80% of the budget spent: ${usd(l.totalUsd)} of ${usd(l.budgetUsd)}.`, { timeoutMs: 10_000 })
182 await update($, ledger, current => ({ ...current, warned: [...current.warned, level] }))
183 }
184 }
185}
186
187export const register: Register = on => {
188 on('session.start', async ($, e, next) => {
189 const saved = (await $.store.get(HISTORY_KEY)) as Ledger['history'] | undefined
190 const startedAt = await $.clock.now()
191 const history = saved && typeof saved === 'object' && saved.days ? saved : emptyHistory()
192 const counted = addToDay({ ...history, sessions: history.sessions + 1, firstSeen: history.firstSeen ?? startedAt }, startedAt, d => ({ ...d, sessions: d.sessions + 1 }))
193 await update($, ledger, l => ({ ...initial, ...l, history: counted, sessionStartedAt: l?.sessionStartedAt ?? startedAt }))
194 await $.store.set(HISTORY_KEY, counted)
195 const budget = await $.store.get('budgetUsd')
196 const planBudget = await $.store.get('planBudgetPercent')
197 const hardStop = await $.store.get('hardStop')
198 await update($, ledger, l => ({
199 ...initial,
200 ...l,
201 budgetUsd: typeof budget === 'number' ? budget : l?.budgetUsd ?? null,
202 planBudgetPercent: typeof planBudget === 'number' ? planBudget : l?.planBudgetPercent ?? null,
203 hardStop: hardStop === true,
204 }))
205 await $.command.register({ name: 'spend', description: 'Spend panel: /spend opens it; /spend budget 50 (dollars) or /spend budget 80% (plan window), add hard to refuse prompts once reached; /spend hard on|off; /spend budget off clears it' })
206 void $.ui.open({ id: PANE, title: 'Spend' })
207
208 // Claude Code's own usage history, the file /usage reads: the 7-day, 30-day and all-time views.
209 const loadClaudeStats = async () => {
210 try {
211 const home = await $.env.get('HOME')
212 if (!home) return
213 const parsed = parseClaudeStats(await $.fs.read(`${home}/.claude/stats-cache.json`))
214 if (parsed) await update($, ledger, l => ({ ...initial, ...l, claudeStats: parsed }))
215 } catch {
216 // No file yet (a new install) or unreadable: the charts use this mod's own days only.
217 }
218 }
219 await loadClaudeStats()
220 $.clock.every(10 * 60_000, () => void loadClaudeStats())
221
222 $.clock.every(POLL_MS, async () => {
223 const usage = await $.session.usage()
224 const now = await $.clock.now()
225 const limits = (usage.rateLimits ?? []).map(w => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt }))
226 const fiveHour = limits.find(w => w.kind === 'five_hour')
227 await update($, ledger, current => ({
228 ...initial,
229 ...current,
230 limits,
231 limitSamples: fiveHour
232 ? [...(current.limitSamples ?? []), { at: now, usd: fiveHour.percentUsed }].filter(s => now - s.at <= BURN_WINDOW_MS + POLL_MS)
233 : current.limitSamples ?? [],
234 }))
235 const total = usage.cost?.usd
236 if (total === undefined) {
237 await warnOnBudget($)
238 return
239 }
240 const l = await readLedger($)
241 const delta = total - l.totalUsd
242 const weighedNow = Object.values(pending).reduce((sum, w) => sum + w, 0)
243 await update($, ledger, current => ({
244 ...initial,
245 ...current,
246 usdBaseline: current.usdBaseline ?? total,
247 weightedSince: current.usdBaseline == null ? 0 : (current.weightedSince ?? 0) + weighedNow,
248 }))
249 const shares = delta > 0.000001 ? apportion(delta, pending) : {}
250 const modelShares = delta > 0.000001 && Object.keys(pendingByModel).length > 0 ? apportion(delta, pendingByModel) : {}
251 pending = {}
252 pendingByModel = {}
253
254 if (shares[HELPERS] !== undefined) {
255 await ensureEntry($, { id: HELPERS, kind: 'helpers', label: 'Engine helpers (titles, compaction, forks)', model: '', usd: 0, steps: 0, startedAt: now })
256 }
257 await update($, ledger, current => ({
258 ...initial,
259 ...current,
260 history: addToDay(
261 {
262 ...(current.history ?? emptyHistory()),
263 longestSessionMs: Math.max(current.history?.longestSessionMs ?? 0, now - (current.sessionStartedAt ?? now)),
264 },
265 now,
266 d => {
267 const models = { ...d.models }
268 for (const [id, share] of Object.entries(modelShares)) {
269 const m = models[id] ?? { usd: 0, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
270 models[id] = { ...m, usd: m.usd + share }
271 }
272 return { ...d, usd: d.usd + Math.max(0, delta), models }
273 },
274 ),
275 buckets: intoBucket(current.buckets ?? [], now, b => ({
276 ...b,
277 usd: b.usd + Math.max(0, delta),
278 planPercent: (current.limits ?? []).find(w => w.kind === 'five_hour')?.percentUsed ?? b.planPercent,
279 })),
280 totalUsd: total,
281 entries: current.entries.map(entry => (shares[entry.id] ? { ...entry, usd: entry.usd + shares[entry.id]! } : entry)),
282 models: Object.fromEntries(
283 Object.entries(current.models ?? {}).map(([id, m]) => [id, { ...m, usd: m.usd + (modelShares[id] ?? 0) }]),
284 ),
285 samples: [...current.samples, { at: now, usd: total }].filter(s => now - s.at <= BURN_WINDOW_MS + POLL_MS),
286 }))
287 await warnOnBudget($)
288 if (now - lastSaved > SAVE_EVERY_MS) {
289 lastSaved = now
290 await $.store.set(HISTORY_KEY, (await readLedger($)).history)
291 }
292 })
293
294 return next(e)
295 })
296
297 // The hard stop: a prompt you type is refused once the budget is reached. Slash commands go
298 // through so /spend can raise or clear it, and anything that is not your own typing (an agent's
299 // result arriving, a scheduled wake-up) is left alone.
300 on('prompt.submit', async ($, e, next) => {
301 const isOwn = !e.origin || e.origin.kind === 'composer'
302 if (!isOwn || e.text.trim().startsWith('/')) return next(e)
303 const l = await readLedger($)
304 if (!l.hardStop) return next(e)
305 const reason = budgetReached(l)
306 if (!reason) return next(e)
307 return { drop: `Budget reached: ${reason}. Raise it (/spend budget 100), clear it (/spend budget off) or turn the hard stop off (/spend hard off) to continue.` }
308 }).catch(($, e, next) => next(e))
309
310 on('turn.start', async ($, e, next) => {
311 promptText.set(e.turnId, e.text)
312 return next(e)
313 })
314
315 on('agent.spawn', async ($, e, next) => {
316 const started = await next(e)
317 if (started.deny || !started.agentId) return started
318 await ensureEntry($, { id: started.agentId, kind: 'agent', label: e.description || e.subagentType, model: started.model, usd: 0, steps: 0, startedAt: await $.clock.now() })
319 return started
320 }).catch(($, e, next) => next(e))
321
322 on('turn.step', async function* ($, e, next) {
323 const result = yield* next(e)
324 if (!result.usage) return result
325
326 const model = result.usage.model || e.model
327 const id = e.agentId ?? `prompt:${e.turnId}`
328 if (!e.agentId) {
329 const text = (promptText.get(e.turnId) ?? '').replace(/\s+/g, ' ').trim()
330 await ensureEntry($, { id, kind: 'prompt', label: text ? text.slice(0, 60) : 'Follow-up (agent results, wake-ups)', model, usd: 0, steps: 0, startedAt: await $.clock.now() })
331 }
332 const w = weight(result.usage, model)
333 pending[id] = (pending[id] ?? 0) + w
334 if (model) pendingByModel[model] = (pendingByModel[model] ?? 0) + w
335 const u = result.usage
336 await update($, ledger, current => {
337 const models = { ...(current.models ?? {}) }
338 const m = models[model] ?? { usd: 0, steps: 0, inputTokens: 0, cacheReadTokens: 0, outputTokens: 0 }
339 models[model] = {
340 ...m,
341 steps: m.steps + 1,
342 inputTokens: m.inputTokens + u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens,
343 cacheReadTokens: m.cacheReadTokens + u.cache_read_input_tokens,
344 outputTokens: m.outputTokens + u.output_tokens,
345 }
346 const at = Date.now()
347 return {
348 ...initial,
349 ...current,
350 models,
351 history: addToDay(current.history ?? emptyHistory(), at, d => {
352 const dm = d.models[model] ?? { usd: 0, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
353 const fresh = u.input_tokens
354 return {
355 ...d,
356 requests: d.requests + 1,
357 input: d.input + fresh + u.cache_creation_input_tokens + u.cache_read_input_tokens,
358 output: d.output + u.output_tokens,
359 cacheRead: d.cacheRead + u.cache_read_input_tokens,
360 cacheWrite: d.cacheWrite + u.cache_creation_input_tokens,
361 models: {
362 ...d.models,
363 [model]: {
364 ...dm,
365 requests: dm.requests + 1,
366 input: dm.input + fresh + u.cache_creation_input_tokens + u.cache_read_input_tokens,
367 output: dm.output + u.output_tokens,
368 cacheRead: dm.cacheRead + u.cache_read_input_tokens,
369 cacheWrite: dm.cacheWrite + u.cache_creation_input_tokens,
370 },
371 },
372 }
373 }),
374 buckets: intoBucket(current.buckets ?? [], at, b => ({
375 ...b,
376 inputTokens: b.inputTokens + u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens,
377 cacheReadTokens: b.cacheReadTokens + u.cache_read_input_tokens,
378 outputTokens: b.outputTokens + u.output_tokens,
379 })),
380 }
381 })
382 await update($, ledger, l => ({ ...initial, ...l, entries: l.entries.map(entry => (entry.id === id ? { ...entry, steps: entry.steps + 1, model } : entry)) }))
383 return result
384 })
385
386 on('command.run', { command: 'spend' }, async ($, e) => {
387 const [word, value, extra] = e.args.trim().split(/\s+/)
388 if (word === 'hard') {
389 const enabled = value !== 'off'
390 await $.store.set('hardStop', enabled)
391 await update($, ledger, l => ({ ...initial, ...l, hardStop: enabled }))
392 return { text: enabled ? 'Hard stop on: once the budget is reached, new prompts are refused until you raise or clear it.' : 'Hard stop off: the budget only warns.' }
393 }
394 if (word === 'budget' && extra === 'hard') {
395 await $.store.set('hardStop', true)
396 await update($, ledger, l => ({ ...initial, ...l, hardStop: true }))
397 }
398 if (word === 'budget') {
399 if (value === 'off') {
400 await $.store.delete('budgetUsd')
401 await $.store.delete('planBudgetPercent')
402 await $.store.delete('hardStop')
403 await update($, ledger, l => ({ ...initial, ...l, budgetUsd: null, planBudgetPercent: null, warned: [], hardStop: false }))
404 return { text: 'Budget cleared.' }
405 }
406 if (value?.endsWith('%')) {
407 const percent = Number(value.slice(0, -1))
408 if (!(percent > 0 && percent <= 100)) return { text: 'Give the plan line as a share of the 5-hour window: /spend budget 80%' }
409 await $.store.set('planBudgetPercent', percent)
410 await update($, ledger, l => ({ ...initial, ...l, planBudgetPercent: percent, warned: [] }))
411 return { text: `You will be told when the 5-hour plan window passes ${percent}%.` }
412 }
413 const amount = Number(value)
414 if (!(amount > 0)) return { text: 'Give the budget in dollars: /spend budget 50' }
415 await $.store.set('budgetUsd', amount)
416 await update($, ledger, l => ({ ...initial, ...l, budgetUsd: amount, warned: [] }))
417 return { text: `Session budget set to ${usd(amount)}. You will be told at 80% and at 100%.` }
418 }
419 await $.ui.open({ id: PANE, title: 'Spend', focus: true })
420 return { text: 'Spend panel open.' }
421 })
422
423 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
424 const { Box, Text, Button } = $.ui.resolve(e)
425 const l = await readLedger($)
426 const now = await $.clock.now()
427 const onPlan = l.limits.length > 0
428 const fiveHour = l.limits.find(w => w.kind === 'five_hour')
429 const planBurn = burnPerHour(l.limitSamples ?? [], now)
430 const width = Math.max(24, (e.props.bodyColumns ?? 40) - 2)
431 const barWidth = Math.max(6, Math.min(16, width - 30))
432 const burn = burnPerHour(l.samples, now)
433 const spentTracked = l.entries.reduce((sum, entry) => sum + entry.usd, 0)
434 const ranked = [...l.entries].sort((a, b) => b.usd - a.usd)
435 const top = l.expanded ? ranked : ranked.slice(0, SHOWN)
436 const hidden = ranked.slice(top.length)
437 const subtotal = (kind: string) => l.entries.filter(entry => entry.kind === kind).reduce((sum, entry) => sum + entry.usd, 0)
438 const count = (kind: string) => l.entries.filter(entry => entry.kind === kind).length
439 const biggest = ranked[0]?.usd ?? 0
440
441 const byActivity = new Map<string, { usd: number; hue: string }>()
442 for (const entry of l.entries) {
443 const [name, hue] = activityOf(entry)
444 byActivity.set(name, { usd: (byActivity.get(name)?.usd ?? 0) + entry.usd, hue })
445 }
446 const activities = [...byActivity.entries()].sort((a, b) => b[1].usd - a[1].usd)
447
448 const byModel = new Map<string, number>()
449 for (const entry of l.entries) {
450 if (!entry.model) continue
451 const name = entry.model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
452 byModel.set(name, (byModel.get(name) ?? 0) + entry.usd)
453 }
454 const models = [...byModel.entries()].sort((a, b) => b[1] - a[1])
455
456 const average = l.samples.length > 1 && l.totalUsd > 0 ? burn : 0
457 const budgetShare = l.budgetUsd ? l.totalUsd / l.budgetUsd : 0
458 const budgetHue = budgetShare >= 1 ? '#ef4444' : budgetShare >= 0.8 ? '#f97316' : '#22c55e'
459 const insights: Array<[string, string]> = []
460 if (activities[0] && spentTracked > 0) insights.push([`${activities[0][0]} is ${Math.round((activities[0][1].usd / spentTracked) * 100)}% of the spend`, activities[0][1].hue])
461 if (models[0] && spentTracked > 0 && models.length > 1) insights.push([`${models[0][0]} runs ${Math.round((models[0][1] / spentTracked) * 100)}% of it`, '#c084fc'])
462 if (ranked[0] && ranked[0].usd > 0) insights.push([`priciest: ${ranked[0].label.slice(0, 34)} (${usd(ranked[0].usd)})`, '#fb923c'])
463 if (l.budgetUsd && burn > 0 && l.totalUsd < l.budgetUsd) {
464 const hours = (l.budgetUsd - l.totalUsd) / burn
465 insights.push([`at this rate the budget lasts ${hours >= 1 ? `${hours.toFixed(1)}h` : `${Math.round(hours * 60)}m`}`, hours < 0.5 ? '#f97316' : '#93c5fd'])
466 }
467
468 const setStats = (change: Partial<Pick<Ledger, 'statsView' | 'statsRange' | 'statsModel' | 'statsChart'>>) =>
469 void update($, ledger, current => ({ ...initial, ...current, ...change }))
470 const cycle = <T,>(list: T[], value: T) => list[(list.indexOf(value) + 1) % list.length]!
471
472 // A row of clickable tabs, each with its colour underneath: a thin rule at rest, a bold bar
473 // under the chosen one. The labels are plain buttons, so the rule lines up character for
474 // character beneath them.
475 const tabs = <T extends string,>(
476 list: T[], chosen: T, label: (t: T) => string, hue: (t: T) => string, choose: (t: T) => void, prefix: string,
477 ) => (
478 <Box flexDirection="column">
479 <Box flexDirection="row" columnGap={TAB_GAP}>
480 {list.map(t => (
481 <Button key={`${prefix}-${t}`} plain dimColor={t !== chosen} label={label(t)} onPress={() => choose(t)} />
482 ))}
483 </Box>
484 <Text>
485 {list.map((t, i) => (
486 <Text key={`${prefix}-rule-${t}`}>
487 <Text color={hue(t)} bold={t === chosen} dimColor={t !== chosen}>{(t === chosen ? '▀' : '─').repeat(label(t).length)}</Text>
488 {i < list.length - 1 ? ' '.repeat(TAB_GAP) : ''}
489 </Text>
490 ))}
491 </Text>
492 </Box>
493 )
494
495 const renderStats = () => {
496 const view: StatsView = l.statsView ?? 'overview'
497 const range: StatsRange = l.statsRange ?? 'session'
498 const chartStyle: StatsChart = l.statsChart ?? 'line'
499 const history = mergeHistory(l.history ?? emptyHistory(), l.claudeStats ?? null)
500 // On a subscription the dollar figures are what the tokens would cost at API prices, not a
501 // charge: say so wherever a dollar figure is shown.
502 const asValue = onPlan
503 const costWord = asValue ? 'API value' : 'Cost'
504 const modelIds = Object.keys(l.models ?? {})
505 const model = l.statsModel && modelIds.includes(l.statsModel) ? l.statsModel : null
506 const chartWidth = Math.max(16, width - 10)
507
508 const header = (
509 <Box flexDirection="column" marginTop={1}>
510 <Text>
511 <Text bold color="#fb923c">▤ STATS </Text>
512 <Text bold color={VIEW_HUE[view]}>{VIEW_LABEL[view]}</Text>
513 <Text dimColor> · </Text>
514 <Text bold color={RANGE_HUE[range]}>{RANGE_LABEL[range]}</Text>
515 {model ? <Text><Text dimColor> · </Text><Text bold color={modelHue(model)}>{modelName(model)}</Text></Text> : null}
516 </Text>
517 {tabs(VIEWS, view, v => VIEW_LABEL[v], v => VIEW_HUE[v], v => setStats({ statsView: v }), 'view')}
518 {tabs(RANGES, range, r => RANGE_LABEL[r], r => RANGE_HUE[r], r => setStats({ statsRange: r }), 'range')}
519 {view !== 'overview' && tabs(CHARTS, chartStyle, c => CHART_LABEL[c], c => CHART_HUE[c], c => setStats({ statsChart: c }), 'chart')}
520 <Box gap={1} flexWrap="wrap">
521 <Button key="cycle-view" hotkey="v" label="Next view" onPress={() => setStats({ statsView: cycle(VIEWS, view) })} />
522 <Button key="cycle-range" hotkey="r" label="Next range" onPress={() => setStats({ statsRange: cycle(RANGES, range) })} />
523 {view !== 'overview' && (
524 <Button key="cycle-chart" hotkey="c" label="Next chart" onPress={() => setStats({ statsChart: cycle(CHARTS, chartStyle) })} />
525 )}
526 {modelIds.length > 1 && (
527 <Button
528 key="cycle-model"
529 hotkey="m"
530 label={model ? `Model: ${modelName(model)}` : 'Model: all'}
531 onPress={() => setStats({ statsModel: cycle([null, ...modelIds], model) })}
532 />
533 )}
534 </Box>
535 </Box>
536 )
537
538 if (view === 'overview') {
539 const today = history.days[dayKey(now)] ?? null
540 const tiles = tilesFor(history, range, now, today)
541 const weeks = Math.max(8, Math.min(52, Math.floor((width - 6) / 1)))
542 const map = heatmap(history, now, weeks)
543 const dayNames = ['Mon', ' ', 'Wed', ' ', 'Fri', ' ', 'Sun']
544 const tile = (label: string, value: string, hue = '#fdba74') => (
545 <Text key={label}><Text dimColor>{label}: </Text><Text bold color={hue}>{value}</Text></Text>
546 )
547 return (
548 <Box flexDirection="column">
549 {header}
550 <Box flexDirection="column" marginTop={1}>
551 <Text dimColor>{' '}{map.months}</Text>
552 {map.levels.map((row, d) => (
553 <Text key={`hm-${d}`}>
554 <Text dimColor>{dayNames[d]} </Text>
555 {row.map((lvl, w) => (
556 <Text key={`c-${d}-${w}`} color={lvl < 0 ? '#1e293b' : LEVEL_HUE[lvl]}>{lvl < 0 ? ' ' : SHADES[lvl]}</Text>
557 ))}
558 </Text>
559 ))}
560 <Text>
561 <Text dimColor>{' '}Less </Text>
562 {LEVEL_HUE.map((hue, i) => <Text key={`lg-${i}`} color={hue}>{SHADES[i]}</Text>)}
563 <Text dimColor> More</Text>
564 </Text>
565 </Box>
566 {l.claudeStats && l.claudeStats.hourCounts.some(n => n > 0) && (() => {
567 const hours = l.claudeStats!.hourCounts
568 const rows = barChart(hours, Math.min(48, Math.max(24, width - 6)), 3, Math.max(...hours))
569 const busiest = hours.indexOf(Math.max(...hours))
570 return (
571 <Box flexDirection="column" marginTop={1}>
572 <Text><Text bold dimColor>ACTIVE HOURS </Text><Text dimColor>busiest </Text><Text bold color="#fdba74">{String(busiest).padStart(2, '0')}:00</Text></Text>
573 {rows.map((row, i) => <Text key={`hr-${i}`} color="#fb923c">{' '}{row}</Text>)}
574 <Text dimColor>{' '}{'00'.padEnd(Math.floor(rows[0]!.length / 2))}{'12'.padEnd(Math.ceil(rows[0]!.length / 2) - 2)}23</Text>
575 </Box>
576 )
577 })()}
578 <Box flexDirection="column" marginTop={1}>
579 {tile('Favorite model', tiles.favorite ? modelName(tiles.favorite) : '-', tiles.favorite ? modelHue(tiles.favorite) : '#94a3b8')}
580 {tile('Total tokens', compact(tiles.total.input + tiles.total.output))}
581 {tile('Requests', compact(tiles.total.requests))}
582 {tile(costWord, usd(tiles.total.usd), '#4ade80')}
583 {range !== 'session' && tile('Sessions', String(tiles.total.sessions))}
584 {range !== 'session' && (
585 <Text><Text dimColor>Active days: </Text><Text bold color="#fdba74">{tiles.activeDays}</Text><Text dimColor>/{tiles.spanDays}</Text></Text>
586 )}
587 {tiles.busiest && range !== 'session' && tile('Most active day', tiles.busiest)}
588 {tile('Longest session', durationText(tiles.longestSessionMs))}
589 {tile('Longest streak', `${tiles.longestStreak} ${tiles.longestStreak === 1 ? 'day' : 'days'}`)}
590 {tile('Current streak', `${tiles.currentStreak} ${tiles.currentStreak === 1 ? 'day' : 'days'}`)}
591 <Text dimColor>
592 Input {compact(tiles.total.input - tiles.total.cacheRead - tiles.total.cacheWrite)} · Output {compact(tiles.total.output)} · Cache read {compact(tiles.total.cacheRead)} · Cache write {compact(tiles.total.cacheWrite)}
593 </Text>
594 <Text color="#93c5fd">{funFact(tiles.total.input + tiles.total.output)}</Text>
595 {l.claudeStats && range !== 'session' && (
596 <Text dimColor>past days from Claude Code's own history, priced at list prices; their in/out split is estimated</Text>
597 )}
598 </Box>
599 </Box>
600 )
601 }
602
603 const points = pointsFor(range, l.buckets ?? [], history, range === 'session' ? null : model, now)
604 const series = seriesFor(view, points)
605 const percent = view === 'cache'
606 const max = percent ? 100 : Math.max(...series.flatMap(s => s.values).filter(Number.isFinite), 0)
607 const height = 8
608 const fmt = (v: number) => (view === 'cost' ? usd(v) : percent ? `${Math.round(v)}%` : compact(v))
609 const axis = [max, (max * 2) / 3, max / 3, 0].map(fmt)
610 const labelWidth = Math.max(...axis.map(a => a.length)) + 1
611
612 return (
613 <Box flexDirection="column">
614 {header}
615 {points.length < 2 ? (
616 <Text dimColor>{'\n'}Not enough {range === 'session' ? 'of this session' : 'history'} yet for a chart. It fills in as you work.</Text>
617 ) : (
618 <Box flexDirection="column" marginTop={1}>
619 <Text bold>{view === 'cost' ? costWord : VIEW_LABEL[view]} {range === 'session' ? 'per 5 minutes' : 'per day'}{model && range !== 'session' ? `, ${modelName(model)}` : ''}</Text>
620 {view === 'cost' && asValue && (
621 <Text color="#93c5fd">› what these tokens would cost at API prices; on a plan you pay the plan, not this</Text>
622 )}
623 {series.map(s => {
624 const rows = drawChart(chartStyle, s.values, chartWidth - labelWidth, height, max)
625 // Each column takes the colour of its point's change: rising, falling or level.
626 const plotWidth = chartWidth - labelWidth
627 const trendHue = TREND_HUE[view]
628 const columnHues = columnPoints(chartStyle, s.values.length, plotWidth).map(i => {
629 const t = i < 0 ? 'flat' : trendOf(s.values, i)
630 return t === 'up' ? trendHue.up : t === 'down' ? trendHue.down : s.hue
631 })
632 return (
633 <Box key={`chart-${s.name}`} flexDirection="column">
634 {rows.map((row, i) => (
635 <Text key={`r-${s.name}-${i}`}>
636 <Text dimColor>{((i === 0 ? axis[0] : i === Math.floor(height / 3) ? axis[1] : i === Math.floor((2 * height) / 3) ? axis[2] : i === height - 1 ? axis[3] : '') ?? '').padStart(labelWidth - 1)} {i === 0 || i === height - 1 || i === Math.floor(height / 3) || i === Math.floor((2 * height) / 3) ? '┤' : '│'}</Text>
637 {chartStyle === 'line' ? (
638 <Text color={s.hue}>{row}</Text>
639 ) : (
640 colourRuns(row, columnHues).map((run, k) => (
641 <Text key={`run-${s.name}-${i}-${k}`} color={run.hue}>{run.text}</Text>
642 ))
643 )}
644 </Text>
645 ))}
646 </Box>
647 )
648 })}
649 {(() => {
650 const plot = chartWidth - labelWidth
651 const first = points[0]!.label
652 const middle = points[Math.floor(points.length / 2)]!.label
653 const last = points[points.length - 1]!.label
654 const gap = Math.max(1, Math.floor((plot - first.length - middle.length - last.length) / 2))
655 return (
656 <Text dimColor>
657 {' '.repeat(labelWidth + 1)}{first}{' '.repeat(gap)}{middle}{' '.repeat(Math.max(1, plot - first.length - gap - middle.length - last.length))}{last}
658 </Text>
659 )
660 })()}
661 {range !== 'session' && !l.claudeStats && points.filter(p => p.input + p.output > 0).length <= 1 && (
662 <Text color="#93c5fd">› daily history started recently; This session shows today in 5-minute slices</Text>
663 )}
664 <Text>
665 {series.map(s => (
666 <Text key={`lg-${s.name}`}><Text color={s.hue}>● </Text><Text dimColor>{s.name} </Text></Text>
667 ))}
668 {chartStyle !== 'line' && (
669 <Text>
670 <Text color={TREND_HUE[view].up}>▲ </Text><Text dimColor>{view === 'cache' ? 'more from cache ' : 'rising '}</Text>
671 <Text color={TREND_HUE[view].down}>▼ </Text><Text dimColor>{view === 'cache' ? 'less from cache' : 'falling'}</Text>
672 </Text>
673 )}
674 </Text>
675 {view === 'tokens' && Object.keys(l.models ?? {}).length > 0 && (
676 <Box flexDirection="column" marginTop={1}>
677 {Object.entries(l.models).sort((a, b) => b[1].inputTokens + b[1].outputTokens - (a[1].inputTokens + a[1].outputTokens)).map(([id, m]) => {
678 const all = Object.values(l.models).reduce((t, x) => t + x.inputTokens + x.outputTokens, 0)
679 return (
680 <Box key={`mc-${id}`} flexDirection="column">
681 <Text><Text color={modelHue(id)}>● </Text><Text bold>{modelName(id)}</Text><Text dimColor> ({all > 0 ? (((m.inputTokens + m.outputTokens) / all) * 100).toFixed(1) : 0}%)</Text></Text>
682 <Text dimColor> In: {compact(m.inputTokens - m.cacheReadTokens)} · Out: {compact(m.outputTokens)} · Cache: {compact(m.cacheReadTokens)} read</Text>
683 </Box>
684 )
685 })}
686 </Box>
687 )}
688 </Box>
689 )}
690 </Box>
691 )
692 }
693
694 return (
695 <Box flexDirection="column">
696 <Box justifyContent="space-between">
697 <Text bold color="#4ade80">{onPlan ? '◆ USAGE' : '$ SPEND'}</Text>
698 <Text>
699 {onPlan && fiveHour ? (
700 <Text bold color={usageHue(fiveHour.percentUsed)}>{Math.round(fiveHour.percentUsed)}% of 5h</Text>
701 ) : (
702 <Text bold color="#4ade80">{usd(l.totalUsd)}</Text>
703 )}
704 <Text dimColor> · </Text>
705 {onPlan ? (
706 <Text bold color={planBurn > 0 ? '#fb923c' : '#64748b'}>{planBurn.toFixed(1)}%/h</Text>
707 ) : (
708 <Text bold color={burn > 0 ? '#fb923c' : '#64748b'}>{usd(burn)}/h</Text>
709 )}
710 </Text>
711 </Box>
712 <Text dimColor>{'─'.repeat(width)}</Text>
713
714 {onPlan && (
715 <Box flexDirection="column" marginBottom={1}>
716 <Text bold dimColor>PLAN WINDOWS</Text>
717 {l.limits.map(w => (
718 <Text key={w.kind} wrap="truncate-end">
719 <Text dimColor>{windowLabel(w.kind).padEnd(14)}</Text>
720 <Text color={usageHue(w.percentUsed)}>{bar(Math.min(1, w.percentUsed / 100), Math.max(8, width - 34))}</Text>
721 <Text bold color={usageHue(w.percentUsed)}> {Math.round(w.percentUsed)}%</Text>
722 <Text dimColor> {resetsIn(w.resetsAt, now)}</Text>
723 </Text>
724 ))}
725 {fiveHour && planBurn > 0 && fiveHour.percentUsed < 100 && (
726 <Text color={(100 - fiveHour.percentUsed) / planBurn < 1 ? '#f97316' : '#93c5fd'}>
727 › at this pace the 5-hour window lasts {(() => { const h = (100 - fiveHour.percentUsed) / planBurn; return h >= 1 ? `${h.toFixed(1)}h` : `${Math.round(h * 60)}m` })()}
728 </Text>
729 )}
730 {l.planBudgetPercent !== null && fiveHour && fiveHour.percentUsed >= l.planBudgetPercent && (
731 <Text color={fiveHour.percentUsed >= 100 ? '#ef4444' : '#f97316'}>⚠ past your {l.planBudgetPercent}% line</Text>
732 )}
733 <Text dimColor>dollar figures below are the API-price value of this work, not a charge</Text>
734 </Box>
735 )}
736
737 {l.budgetUsd !== null && (
738 <Box flexDirection="column" marginBottom={1}>
739 <Text>
740 <Text dimColor>budget </Text>
741 <Text color={budgetHue}>{bar(Math.min(1, budgetShare), Math.max(10, width - 24))}</Text>
742 <Text bold color={budgetHue}> {Math.round(budgetShare * 100)}%</Text>
743 <Text dimColor> of {usd(l.budgetUsd)}</Text>
744 </Text>
745 {budgetShare >= 1 && <Text color="#ef4444">✗ over budget by {usd(l.totalUsd - l.budgetUsd)}</Text>}
746 {budgetShare >= 0.8 && budgetShare < 1 && <Text color="#f97316">⚠ {usd(l.budgetUsd - l.totalUsd)} left</Text>}
747 </Box>
748 )}
749
750 {l.entries.length === 0 ? (
751 <Text dimColor>Costs appear here as the session makes requests.</Text>
752 ) : (
753 <Box flexDirection="column">
754 <Text bold dimColor>WHERE IT WENT</Text>
755 {top.map(entry => {
756 const [, hue] = activityOf(entry)
757 const glyph = entry.kind === 'prompt' ? '❯' : entry.kind === 'helpers' ? '⚙' : '◆'
758 return (
759 <Text key={entry.id} wrap="truncate-end">
760 <Text color={hue}>{glyph} </Text>
761 <Text color={hue}>{bar(biggest > 0 ? entry.usd / biggest : 0, barWidth)}</Text>
762 <Text bold> {usd(entry.usd).padStart(6)}</Text>
763 <Text dimColor> {entry.label}</Text>
764 </Text>
765 )
766 })}
767 {hidden.length > 0 && (
768 <Text dimColor> + {hidden.length} more, {usd(hidden.reduce((sum, x) => sum + x.usd, 0))}</Text>
769 )}
770 {ranked.length > SHOWN && (
771 <Box marginTop={1}>
772 <Button
773 key="expand"
774 hotkey="a"
775 label={l.expanded ? `Show top ${SHOWN}` : `Show all ${ranked.length}`}
776 onPress={() => void update($, ledger, current => ({ ...initial, ...current, expanded: !current.expanded }))}
777 />
778 </Box>
779 )}
780
781 <Box flexDirection="column" marginTop={1}>
782 <Text bold dimColor>TOTAL</Text>
783 <Text>
784 <Text color="#93c5fd">❯ </Text>
785 <Text dimColor>{`your prompts (${count('prompt')})`.padEnd(22)}</Text>
786 <Text bold>{usd(subtotal('prompt')).padStart(7)}</Text>
787 </Text>
788 <Text>
789 <Text color="#fb923c">◆ </Text>
790 <Text dimColor>{`sub-agents (${count('agent')})`.padEnd(22)}</Text>
791 <Text bold>{usd(subtotal('agent')).padStart(7)}</Text>
792 </Text>
793 {count('helpers') > 0 && (
794 <Text>
795 <Text color="#64748b">⚙ </Text>
796 <Text dimColor>{'engine helpers'.padEnd(22)}</Text>
797 <Text bold>{usd(subtotal('helpers')).padStart(7)}</Text>
798 </Text>
799 )}
800 <Text dimColor>{'─'.repeat(Math.min(width, 33))}</Text>
801 <Text>
802 <Text> </Text>
803 <Text bold>{'session total'.padEnd(22)}</Text>
804 <Text bold color="#4ade80">{usd(l.totalUsd).padStart(7)}</Text>
805 {onPlan ? <Text dimColor> API-price value</Text> : null}
806 </Text>
807 </Box>
808
809 {Object.keys(l.models ?? {}).length > 0 && (() => {
810 const models = Object.entries(l.models).sort((a, b) => b[1].usd - a[1].usd)
811 const modelTotal = models.reduce((sum, [, m]) => sum + m.usd, 0)
812 return (
813 <Box flexDirection="column" marginTop={1}>
814 <Text bold dimColor>BY MODEL</Text>
815 {models.map(([id, m]) => {
816 const share = modelTotal > 0 ? m.usd / modelTotal : 0
817 const hit = m.inputTokens > 0 ? m.cacheReadTokens / m.inputTokens : 0
818 return (
819 <Box key={id} flexDirection="column">
820 <Text wrap="truncate-end">
821 <Text color={modelHue(id)} bold>● {modelName(id).padEnd(16)}</Text>
822 <Text color={modelHue(id)}>{bar(share, Math.max(6, Math.min(14, width - 36)))}</Text>
823 <Text bold> {usd(m.usd).padStart(6)}</Text>
824 <Text dimColor> {Math.round(share * 100)}%</Text>
825 </Text>
826 <Text dimColor wrap="truncate-end">
827 {' '}{m.steps} requests · in {shortTokens(m.inputTokens)} ({Math.round(hit * 100)}% cached) · out {shortTokens(m.outputTokens)}
828 </Text>
829 </Box>
830 )
831 })}
832 </Box>
833 )
834 })()}
835
836 <Box flexDirection="column" marginTop={1}>
837 <Text bold dimColor>BY ACTIVITY</Text>
838 <Text wrap="truncate-end">
839 {activities.map(([name, info]) => (
840 <Text key={name}>
841 <Text color={info.hue}>■ </Text>
842 <Text>{name} </Text>
843 <Text bold>{usd(info.usd)} </Text>
844 </Text>
845 ))}
846 </Text>
847 </Box>
848
849 {insights.length > 0 && (
850 <Box flexDirection="column" marginTop={1}>
851 <Text bold dimColor>INSIGHTS</Text>
852 {insights.map(([text, hue]) => (
853 <Text key={text} wrap="truncate-end"><Text color={hue}>› </Text>{text}</Text>
854 ))}
855 {average > 0 && burn > 0 && l.samples.length > 10 && burn > 3 * (l.totalUsd / Math.max(1, (now - (l.samples[0]?.at ?? now)) / 3_600_000)) && (
856 <Text color="#f97316">⚠ spending faster than earlier in the session</Text>
857 )}
858 </Box>
859 )}
860 <Text dimColor>split estimated by tokens; the total is /cost</Text>
861 {renderStats()}
862 </Box>
863 )}
864 <Box flexDirection="column" marginTop={1}>
865 <Text>
866 <Text bold dimColor>BUDGET </Text>
867 {l.planBudgetPercent !== null && <Text color="#fb923c">warn at {l.planBudgetPercent}% of the 5-hour window</Text>}
868 {l.budgetUsd !== null && <Text color="#fb923c">{usd(l.budgetUsd)} this session</Text>}
869 {l.planBudgetPercent === null && l.budgetUsd === null && <Text dimColor>none set</Text>}
870 {(l.planBudgetPercent !== null || l.budgetUsd !== null) && (
871 l.hardStop
872 ? <Text color={budgetReached(l) ? '#ef4444' : '#fb923c'} bold>{budgetReached(l) ? ' ■ HARD STOP: prompts refused' : ' ■ hard stop on'}</Text>
873 : <Text dimColor> warn only</Text>
874 )}
875 </Text>
876 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
877 {(onPlan
878 ? [['p50', '1', 'Warn at 50%', 'budget 50%'], ['p75', '2', '75%', 'budget 75%'], ['p90', '3', '90%', 'budget 90%']]
879 : [['b25', '1', '$25', 'budget 25'], ['b50', '2', '$50', 'budget 50'], ['b100', '3', '$100', 'budget 100'], ['b250', '4', '$250', 'budget 250']]
880 )
881 .concat(l.budgetUsd !== null || l.planBudgetPercent !== null
882 ? [['hard', 'h', l.hardStop ? 'Hard stop: on' : 'Hard stop: off', l.hardStop ? 'hard off' : 'hard on'], ['boff', '0', 'Clear', 'budget off']]
883 : [])
884 .map(([key, hotkey, label, args]) => (
885 <Button key={key!} hotkey={hotkey!} label={label!} onPress={() => void $.command.run({ command: 'spend', args: args! })} />
886 ))}
887 </Box>
888 </Box>
889 </Box>
890 )
891 })
892}
893hooks/stats.tsx 477 lines1// The STATS dashboard: pure helpers for the history the mod keeps across sessions, the series
2// each view charts, a braille line chart, a day heatmap and the stat tiles.
3
4import type { ClaudeStats, DayStat, History, SpendBucket, StatsChart, StatsRange, StatsView } from '../types'
5
6export const VIEWS: StatsView[] = ['overview', 'tokens', 'cost', 'cache', 'inout']
7export const RANGES: StatsRange[] = ['session', '7d', '30d', 'all']
8export const VIEW_LABEL: Record<StatsView, string> = {
9 overview: 'Overview', tokens: 'Tokens', cost: 'Cost', cache: 'Cache', inout: 'In / Out',
10}
11export const RANGE_LABEL: Record<StatsRange, string> = {
12 session: 'Session', '7d': '7 days', '30d': '30 days', all: 'All time',
13}
14// Each tab keeps its own colour: the thin rule under it at rest, a bold bar when chosen, and the
15// chart drawn in the chosen view's colour.
16export const VIEW_HUE: Record<StatsView, string> = {
17 overview: '#fb923c', tokens: '#93c5fd', cost: '#4ade80', cache: '#22d3ee', inout: '#c084fc',
18}
19export const RANGE_HUE: Record<StatsRange, string> = {
20 session: '#f472b6', '7d': '#34d399', '30d': '#60a5fa', all: '#c084fc',
21}
22export const TAB_GAP = 2
23export const CHARTS: StatsChart[] = ['line', 'area', 'bars', 'dots']
24export const CHART_LABEL: Record<StatsChart, string> = { line: 'Line', area: 'Area', bars: 'Bars', dots: 'Dots' }
25export const CHART_HUE: Record<StatsChart, string> = { line: '#93c5fd', area: '#2dd4bf', bars: '#fb923c', dots: '#f472b6' }
26
27export const emptyDay = (): DayStat => ({
28 usd: 0, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, sessions: 0, models: {},
29})
30export const emptyHistory = (): History => ({ days: {}, sessions: 0, longestSessionMs: 0, firstSeen: null })
31
32// The local calendar day a moment falls on, as YYYY-MM-DD.
33export const dayKey = (ms: number) => {
34 const d = new Date(ms)
35 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
36}
37
38const DAY_MS = 86_400_000
39
40export const addToDay = (history: History, at: number, change: (d: DayStat) => DayStat): History => {
41 const key = dayKey(at)
42 return { ...history, days: { ...history.days, [key]: change(history.days[key] ?? emptyDay()) } }
43}
44
45export const compact = (n: number) => {
46 const abs = Math.abs(n)
47 if (abs >= 1e9) return `${(n / 1e9).toFixed(1)}b`
48 if (abs >= 1e6) return `${(n / 1e6).toFixed(1)}m`
49 if (abs >= 1e3) return `${(n / 1e3).toFixed(1)}k`
50 return `${Math.round(n)}`
51}
52
53// The days a range covers, oldest first, filled with empty days where nothing happened.
54export const daysIn = (history: History, range: StatsRange, now: number): Array<[string, DayStat]> => {
55 const keys = Object.keys(history.days).sort()
56 const span = range === '7d' ? 7 : range === '30d' ? 30 : null
57 const firstKey = span ? dayKey(now - (span - 1) * DAY_MS) : keys[0] ?? dayKey(now)
58 const out: Array<[string, DayStat]> = []
59 for (let t = Date.parse(`${firstKey}T12:00:00`); dayKey(t) <= dayKey(now); t += DAY_MS) {
60 const key = dayKey(t)
61 out.push([key, history.days[key] ?? emptyDay()])
62 if (out.length > 400) break
63 }
64 return out
65}
66
67type Point = { label: string; usd: number; input: number; output: number; cacheRead: number; cacheWrite: number }
68
69const fromDay = (key: string, d: DayStat, model: string | null): Point => {
70 const m = model ? d.models[model] : null
71 const src = model ? m ?? { usd: 0, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : d
72 return { label: key.slice(5), usd: src.usd, input: src.input, output: src.output, cacheRead: src.cacheRead, cacheWrite: src.cacheWrite }
73}
74
75const hhmm = (ms: number) => {
76 const d = new Date(ms)
77 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
78}
79
80// The points a view charts: five-minute slices for this session, days otherwise.
81export const pointsFor = (range: StatsRange, buckets: SpendBucket[], history: History, model: string | null, now: number): Point[] => {
82 if (range === 'session') {
83 return buckets.map(b => ({
84 label: hhmm(b.start), usd: b.usd, input: b.inputTokens, output: b.outputTokens,
85 cacheRead: b.cacheReadTokens, cacheWrite: 0,
86 }))
87 }
88 return daysIn(history, range, now).map(([key, d]) => fromDay(key, d, model))
89}
90
91export type Series = { name: string; hue: string; values: number[] }
92
93export const seriesFor = (view: StatsView, points: Point[]): Series[] => {
94 switch (view) {
95 case 'cost':
96 return [{ name: 'cost', hue: VIEW_HUE.cost, values: points.map(p => p.usd) }]
97 case 'cache':
98 return [{
99 name: 'read from cache',
100 hue: VIEW_HUE.cache,
101 values: points.map(p => (p.input > 0 ? (p.cacheRead / p.input) * 100 : Number.NaN)),
102 }]
103 case 'inout':
104 return [
105 { name: 'in', hue: '#60a5fa', values: points.map(p => p.input) },
106 { name: 'out', hue: '#fb923c', values: points.map(p => p.output) },
107 ]
108 default:
109 return [{ name: 'tokens', hue: VIEW_HUE.tokens, values: points.map(p => p.input + p.output) }]
110 }
111}
112
113// Fit `values` to `width` columns: shorter series are kept, longer ones averaged into buckets.
114export const resample = (values: number[], width: number) => {
115 if (values.length <= width) return values
116 const out: number[] = []
117 for (let i = 0; i < width; i += 1) {
118 const from = Math.floor((i * values.length) / width)
119 const to = Math.max(from + 1, Math.floor(((i + 1) * values.length) / width))
120 const slice = values.slice(from, to).filter(Number.isFinite)
121 out.push(slice.length ? slice.reduce((a, b) => a + b, 0) / slice.length : Number.NaN)
122 }
123 return out
124}
125
126// Spread `values` across exactly `width` columns: each point holds its share of the width,
127// so 30 days fill the chart rather than its left half. Longer series are averaged down.
128export const stretch = (values: number[], width: number) => {
129 if (values.length === 0) return Array(width).fill(0)
130 if (values.length >= width) return resample(values, width)
131 return Array.from({ length: width }, (_, i) => values[Math.min(values.length - 1, Math.floor((i * values.length) / width))]!)
132}
133
134// Spread `values` across `width` columns joining neighbouring points with straight slopes, so a
135// rise between two days climbs across the columns between them instead of jumping in one. A gap
136// stays a gap. Longer series are averaged down first.
137export const interpolate = (values: number[], width: number) => {
138 if (values.length === 0) return Array(width).fill(0)
139 if (values.length >= width) return resample(values, width)
140 if (values.length === 1) return Array(width).fill(values[0]!)
141 return Array.from({ length: width }, (_, x) => {
142 const at = (x * (values.length - 1)) / (width - 1)
143 const i = Math.floor(at)
144 const a = values[i]!
145 const b = values[Math.min(values.length - 1, i + 1)]!
146 if (!Number.isFinite(a) || !Number.isFinite(b)) return Number.isFinite(a) && at - i < 0.5 ? a : Number.isFinite(b) && at - i >= 0.5 ? b : Number.NaN
147 return a + (b - a) * (at - i)
148 })
149}
150
151// One continuous line in box-drawing characters, the way /usage draws it: flat runs as ─,
152// rises and falls as rounded corners joined by │. `height` rows, top first.
153export const lineChart = (values: number[], width: number, height: number, max = Math.max(...values.filter(Number.isFinite), 0)) => {
154 const pts = interpolate(values, width)
155 const top = height - 1
156 const rowOf = (v: number) => (max > 0 ? Math.max(0, Math.min(top, Math.round((v / max) * top))) : 0)
157 const grid: string[][] = Array.from({ length: height }, () => Array(width).fill(' '))
158 const put = (level: number, x: number, ch: string) => {
159 grid[top - level]![x] = ch
160 }
161 for (let x = 0; x < width; x += 1) {
162 // No data here (a day with no requests on a ratio view): leave the column empty.
163 if (!Number.isFinite(pts[x]!)) continue
164 const y0 = rowOf(pts[x]!)
165 const nextKnown = x + 1 < width && Number.isFinite(pts[x + 1]!)
166 const y1 = nextKnown ? rowOf(pts[x + 1]!) : y0
167 if (y0 === y1) {
168 put(y0, x, '─')
169 continue
170 }
171 // Leaving y0 at this column, arriving at y1 in the same column, the run between as │.
172 put(y0, x, y1 > y0 ? '╯' : '╮')
173 put(y1, x, y1 > y0 ? '╭' : '╰')
174 for (let k = Math.min(y0, y1) + 1; k < Math.max(y0, y1); k += 1) put(k, x, '│')
175 }
176 return grid.map(row => row.join(''))
177}
178
179const EIGHTHS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
180
181// The level of each column in eighths of a row, or -1 for a gap.
182const eighthsOf = (pts: number[], height: number, max: number) =>
183 pts.map(v => (!Number.isFinite(v) ? -1 : max > 0 ? Math.round((Math.max(0, v) / max) * height * 8) : 0))
184
185// Filled under the curve, its top edge smoothed to an eighth of a row. Rows top first.
186export const areaChart = (values: number[], width: number, height: number, max = Math.max(...values.filter(Number.isFinite), 0)) => {
187 const levels = eighthsOf(interpolate(values, width), height, max)
188 const rows: string[] = []
189 for (let row = height - 1; row >= 0; row -= 1) {
190 rows.push(levels.map(l => (l < 0 ? ' ' : l - row * 8 >= 8 ? '█' : l - row * 8 <= 0 ? (row === 0 ? '▁' : ' ') : EIGHTHS[l - row * 8]!)).join(''))
191 }
192 return rows
193}
194
195// One bar per point with a gap between, as wide as the room allows. Rows top first.
196export const barChart = (values: number[], width: number, height: number, max = Math.max(...values.filter(Number.isFinite), 0)) => {
197 if (values.length === 0) return Array(height).fill(' '.repeat(width))
198 const pts = values.length > width ? resample(values, Math.floor(width / 2)) : values
199 const slot = Math.max(1, Math.floor(width / pts.length))
200 const barWidth = slot >= 3 ? slot - 1 : slot
201 const levels = eighthsOf(pts, height, max)
202 const rows: string[] = []
203 for (let row = height - 1; row >= 0; row -= 1) {
204 let line = ''
205 for (const l of levels) {
206 const fill = l - row * 8
207 const ch = l < 0 ? ' ' : fill >= 8 ? '█' : fill <= 0 ? ' ' : EIGHTHS[fill]!
208 line += ch.repeat(barWidth) + ' '.repeat(slot - barWidth)
209 }
210 rows.push(line.padEnd(width).slice(0, width))
211 }
212 return rows
213}
214
215// A point per column in braille, two across and four down per cell, nothing joining them.
216const DOT = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]] as const
217export const dotChart = (values: number[], width: number, height: number, max = Math.max(...values.filter(Number.isFinite), 0)) => {
218 const pts = stretch(values, width * 2)
219 const levels = height * 4
220 const grid: number[][] = Array.from({ length: height }, () => Array(width).fill(0))
221 pts.forEach((v, x) => {
222 if (!Number.isFinite(v)) return
223 const y = max > 0 ? Math.min(levels - 1, Math.round((Math.max(0, v) / max) * (levels - 1))) : 0
224 const row = height - 1 - Math.floor(y / 4)
225 grid[row]![Math.floor(x / 2)]! |= DOT[3 - (y % 4)]![x % 2]!
226 })
227 return grid.map(r => r.map(bits => String.fromCharCode(0x2800 + bits)).join(''))
228}
229
230// Which point each column of a chart shows, so a column can be coloured by that point's change.
231export const columnPoints = (style: StatsChart, count: number, width: number) => {
232 if (count === 0) return Array(width).fill(-1)
233 if (style === 'bars') {
234 const n = count > width ? Math.floor(width / 2) : count
235 const slot = Math.max(1, Math.floor(width / n))
236 return Array.from({ length: width }, (_, x) => Math.min(count - 1, Math.floor((Math.floor(x / slot) * count) / n)))
237 }
238 return Array.from({ length: width }, (_, x) => Math.min(count - 1, Math.floor((x * count) / width)))
239}
240
241export type Trend = 'up' | 'down' | 'flat'
242// A point's change from the last point that had data; a gap or the first point is flat.
243export const trendOf = (values: number[], i: number): Trend => {
244 const v = values[i]
245 if (v === undefined || !Number.isFinite(v)) return 'flat'
246 for (let j = i - 1; j >= 0; j -= 1) {
247 const prev = values[j]!
248 if (!Number.isFinite(prev)) continue
249 const delta = v - prev
250 const scale = Math.max(Math.abs(prev), Math.abs(v), 1e-9)
251 return Math.abs(delta) / scale < 0.02 ? 'flat' : delta > 0 ? 'up' : 'down'
252 }
253 return 'flat'
254}
255
256// Rising and falling colours per view: more spend is warm and less is cool; for the cache, more
257// read from cache is good (green) and less is a slip (deep orange).
258export const TREND_HUE: Record<StatsView, { up: string; down: string }> = {
259 overview: { up: '#fb923c', down: '#60a5fa' },
260 tokens: { up: '#fb923c', down: '#60a5fa' },
261 cost: { up: '#fb923c', down: '#60a5fa' },
262 inout: { up: '#fb923c', down: '#60a5fa' },
263 cache: { up: '#22c55e', down: '#f97316' },
264}
265
266// A row split into runs of one colour, ready to draw as Text spans.
267export const colourRuns = (row: string, hues: string[]) => {
268 const runs: Array<{ text: string; hue: string }> = []
269 const chars = Array.from(row)
270 chars.forEach((ch, x) => {
271 const hue = hues[x] ?? hues[hues.length - 1] ?? ''
272 const last = runs[runs.length - 1]
273 if (last && last.hue === hue) last.text += ch
274 else runs.push({ text: ch, hue })
275 })
276 return runs
277}
278
279export const drawChart = (style: StatsChart, values: number[], width: number, height: number, max: number) =>
280 style === 'area' ? areaChart(values, width, height, max)
281 : style === 'bars' ? barChart(values, width, height, max)
282 : style === 'dots' ? dotChart(values, width, height, max)
283 : lineChart(values, width, height, max)
284
285// GitHub-style activity: 7 rows (Mon to Sun) by `weeks` columns, each day shaded by tokens.
286export const SHADES = ['·', '░', '▒', '▓', '█'] as const
287export const heatmap = (history: History, now: number, weeks: number) => {
288 const today = new Date(now)
289 const mondayOffset = (today.getDay() + 6) % 7
290 const lastMonday = now - mondayOffset * DAY_MS
291 const start = lastMonday - (weeks - 1) * 7 * DAY_MS
292 const values: number[][] = Array.from({ length: 7 }, () => Array(weeks).fill(-1))
293 let max = 0
294 for (let w = 0; w < weeks; w += 1) {
295 for (let d = 0; d < 7; d += 1) {
296 const t = start + (w * 7 + d) * DAY_MS
297 if (t > now) continue
298 const day = history.days[dayKey(t)]
299 const v = day ? day.input + day.output : 0
300 values[d]![w] = v
301 if (v > max) max = v
302 }
303 }
304 const level = (v: number) => (v < 0 ? -1 : v === 0 ? 0 : Math.max(1, Math.min(4, Math.ceil((v / max) * 4))))
305 const months: string[] = []
306 let lastMonth = -1
307 for (let w = 0; w < weeks; w += 1) {
308 const m = new Date(start + w * 7 * DAY_MS).getMonth()
309 months.push(m !== lastMonth ? 'JFMAMJJASOND'[m]! : ' ')
310 lastMonth = m
311 }
312 return { levels: values.map(row => row.map(level)), months: months.join('') }
313}
314
315export const LEVEL_HUE = ['#334155', '#7c2d12', '#c2410c', '#f97316', '#fdba74'] as const
316
317// Headline figures for a range, as the /usage stats screen lists them.
318export const tilesFor = (history: History, range: StatsRange, now: number, sessionDay: DayStat | null) => {
319 const days = range === 'session' ? (sessionDay ? [[dayKey(now), sessionDay] as [string, DayStat]] : []) : daysIn(history, range, now)
320 const total = days.reduce(
321 (t, [, d]) => ({
322 usd: t.usd + d.usd, requests: t.requests + d.requests, input: t.input + d.input, output: t.output + d.output,
323 cacheRead: t.cacheRead + d.cacheRead, cacheWrite: t.cacheWrite + d.cacheWrite, sessions: t.sessions + d.sessions,
324 }),
325 { usd: 0, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, sessions: 0 },
326 )
327 const byModel: Record<string, number> = {}
328 for (const [, d] of days) for (const [id, m] of Object.entries(d.models)) byModel[id] = (byModel[id] ?? 0) + m.input + m.output
329 const favorite = Object.entries(byModel).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
330 const active = days.filter(([, d]) => d.requests > 0)
331 const busiest = [...active].sort((a, b) => b[1].input + b[1].output - (a[1].input + a[1].output))[0]?.[0] ?? null
332
333 // Streaks over every day ever recorded, ending today for the current one.
334 const all = daysIn(history, 'all', now)
335 let longest = 0
336 let run = 0
337 for (const [, d] of all) {
338 run = d.requests > 0 ? run + 1 : 0
339 longest = Math.max(longest, run)
340 }
341 let current = 0
342 for (let i = all.length - 1; i >= 0 && all[i]![1].requests > 0; i -= 1) current += 1
343
344 return {
345 total,
346 favorite,
347 activeDays: active.length,
348 spanDays: days.length,
349 busiest,
350 longestStreak: longest,
351 currentStreak: current,
352 longestSessionMs: history.longestSessionMs,
353 }
354}
355
356// One line of perspective on the token count, like the /usage screen's.
357export const funFact = (tokens: number) => {
358 const novels = tokens / 750_000 // roughly War and Peace, in tokens
359 if (novels >= 2) return `That is about ${Math.round(novels).toLocaleString()} copies of War and Peace.`
360 const pages = tokens / 500
361 if (pages >= 1) return `That is about ${Math.round(pages).toLocaleString()} printed pages of text.`
362 return 'Quiet so far.'
363}
364
365export const durationText = (ms: number) => {
366 const m = Math.floor(ms / 60_000)
367 const d = Math.floor(m / 1440)
368 const h = Math.floor((m % 1440) / 60)
369 return d > 0 ? `${d}d ${h}h ${m % 60}m` : h > 0 ? `${h}h ${m % 60}m` : `${m}m`
370}
371
372
373// ── Claude Code's own history ───────────────────────────────────────────────────────────────
374
375// Parse ~/.claude/stats-cache.json into the shape the charts use. Unknown shapes give null.
376export const parseClaudeStats = (text: string): ClaudeStats | null => {
377 try {
378 const raw = JSON.parse(text)
379 const days: ClaudeStats['days'] = {}
380 for (const row of raw.dailyModelTokens ?? []) {
381 if (!row?.date) continue
382 days[row.date] = { tokensByModel: row.tokensByModel ?? {}, messages: 0, sessions: 0, toolCalls: 0 }
383 }
384 for (const row of raw.dailyActivity ?? []) {
385 if (!row?.date) continue
386 const day = days[row.date] ?? { tokensByModel: {}, messages: 0, sessions: 0, toolCalls: 0 }
387 days[row.date] = { ...day, messages: row.messageCount ?? 0, sessions: row.sessionCount ?? 0, toolCalls: row.toolCallCount ?? 0 }
388 }
389 const models: ClaudeStats['models'] = {}
390 for (const [id, m] of Object.entries(raw.modelUsage ?? {}) as Array<[string, any]>) {
391 models[id] = {
392 input: m.inputTokens ?? 0, output: m.outputTokens ?? 0,
393 cacheRead: m.cacheReadInputTokens ?? 0, cacheWrite: m.cacheCreationInputTokens ?? 0,
394 }
395 }
396 const hours = Array(24).fill(0)
397 for (const [h, n] of Object.entries(raw.hourCounts ?? {})) hours[Number(h)] = Number(n) || 0
398 return {
399 days,
400 models,
401 totalSessions: raw.totalSessions ?? 0,
402 longestSessionMs: raw.longestSession?.duration ?? 0,
403 firstSessionDate: raw.firstSessionDate ?? null,
404 hourCounts: hours,
405 computedOn: raw.lastComputedDate ?? null,
406 recordsCost: Object.values(raw.modelUsage ?? {}).some((m: any) => (m?.costUSD ?? 0) > 0),
407 }
408 } catch {
409 return null
410 }
411}
412
413// Anthropic list prices per million tokens: input, output, cache read. A cache write is 1.25x
414// input (the 5-minute cache, Claude Code's default before promptCacheTtl). Most specific first.
415// Organisation-negotiated rates are not known here, so these are list-price figures.
416export const PRICES: Array<[RegExp, { input: number; output: number; cacheRead: number }]> = [
417 [/fable-5-1|mythos-5-1/, { input: 10, output: 50, cacheRead: 0.25 }],
418 [/fable-5|mythos-5/, { input: 10, output: 50, cacheRead: 1 }],
419 [/opus-5-5/, { input: 4, output: 20, cacheRead: 0.2 }],
420 [/opus-5|opus-4-[5-8]/, { input: 5, output: 25, cacheRead: 0.5 }],
421 [/opus-4/, { input: 15, output: 75, cacheRead: 1.5 }],
422 [/sonnet-5/, { input: 2, output: 10, cacheRead: 0.2 }],
423 [/sonnet-4|sonnet-3/, { input: 3, output: 15, cacheRead: 0.3 }],
424 [/haiku-5/, { input: 0.1, output: 0.5, cacheRead: 0.01 }],
425 [/haiku-4/, { input: 1, output: 5, cacheRead: 0.1 }],
426 [/haiku-3/, { input: 0.8, output: 4, cacheRead: 0.08 }],
427]
428export const priceOf = (model: string) => PRICES.find(([test]) => test.test(model))?.[1] ?? null
429
430// Dollars for a split of tokens at a model's list price; null for a model with no known price.
431export const listCost = (model: string, t: { input: number; output: number; cacheRead: number; cacheWrite: number }) => {
432 const p = priceOf(model)
433 if (!p) return null
434 const fresh = Math.max(0, t.input - t.cacheRead - t.cacheWrite)
435 return (fresh * p.input + t.output * p.output + t.cacheRead * p.cacheRead + t.cacheWrite * p.input * 1.25) / 1_000_000
436}
437
438// A past day from Claude Code's file, split into input, output and cache by that model's all-time
439// ratios (the file keeps only a daily total per model) and priced at that model's list price.
440// Marked estimated.
441export const dayFromClaude = (day: ClaudeStats['days'][string], models: ClaudeStats['models']): DayStat => {
442 const out: DayStat = { ...emptyDay(), sessions: day.sessions, requests: day.messages, estimated: true }
443 for (const [id, total] of Object.entries(day.tokensByModel)) {
444 const m = models[id]
445 const all = m ? m.input + m.output + m.cacheRead + m.cacheWrite : 0
446 const share = (part: number) => (all > 0 ? (total * part) / all : 0)
447 const input = m ? share(m.input) + share(m.cacheRead) + share(m.cacheWrite) : total
448 const output = m ? share(m.output) : 0
449 const cacheRead = m ? share(m.cacheRead) : 0
450 const cacheWrite = m ? share(m.cacheWrite) : 0
451 const usd = listCost(id, { input, output, cacheRead, cacheWrite }) ?? 0
452 out.models[id] = { usd, requests: 0, input, output, cacheRead, cacheWrite }
453 out.input += input
454 out.output += output
455 out.cacheRead += cacheRead
456 out.cacheWrite += cacheWrite
457 out.usd += usd
458 }
459 return out
460}
461
462// Our own days win (they are exact); every other day comes from Claude Code's file.
463export const mergeHistory = (history: History, claude: ClaudeStats | null): History => {
464 if (!claude) return history
465 const days = { ...history.days }
466 for (const [key, day] of Object.entries(claude.days)) {
467 if (!days[key]) days[key] = dayFromClaude(day, claude.models)
468 }
469 return {
470 ...history,
471 days,
472 sessions: Math.max(history.sessions, claude.totalSessions),
473 longestSessionMs: Math.max(history.longestSessionMs, claude.longestSessionMs),
474 firstSeen: claude.firstSessionDate ? Math.min(Date.parse(claude.firstSessionDate), history.firstSeen ?? Infinity) : history.firstSeen,
475 }
476}
477types/index.d.ts 101 lines1export type SpendEntry = {
2 id: string
3 kind: 'prompt' | 'agent' | 'helpers'
4 label: string
5 model: string
6 usd: number
7 steps: number
8 startedAt: number
9}
10
11export type SpendSample = { at: number; usd: number }
12
13// Five-minute slices of the session, oldest first, for the charts.
14export type SpendBucket = {
15 start: number
16 usd: number
17 inputTokens: number
18 cacheReadTokens: number
19 outputTokens: number
20 planPercent: number | null
21}
22
23export type ModelSpend = {
24 usd: number
25 steps: number
26 inputTokens: number
27 cacheReadTokens: number
28 outputTokens: number
29}
30
31export type DayModel = { usd: number; requests: number; input: number; output: number; cacheRead: number; cacheWrite: number }
32
33export type DayStat = DayModel & { sessions: number; models: Record<string, DayModel>; estimated?: boolean }
34
35// Kept across sessions in the plugin's store: one row per local calendar day.
36export type History = {
37 days: Record<string, DayStat>
38 sessions: number
39 longestSessionMs: number
40 firstSeen: number | null
41}
42
43// What Claude Code keeps in ~/.claude/stats-cache.json (the file /usage reads), reduced to
44// what the charts need. Read only; refreshed every few minutes.
45export type ClaudeModelTotals = { input: number; output: number; cacheRead: number; cacheWrite: number }
46export type ClaudeStats = {
47 days: Record<string, { tokensByModel: Record<string, number>; messages: number; sessions: number; toolCalls: number }>
48 models: Record<string, ClaudeModelTotals>
49 totalSessions: number
50 longestSessionMs: number
51 firstSessionDate: string | null
52 hourCounts: number[]
53 computedOn: string | null
54 // False on a subscription, where Claude Code records every model's cost as 0.
55 recordsCost: boolean
56}
57
58export type StatsView = 'overview' | 'tokens' | 'cost' | 'cache' | 'inout'
59export type StatsRange = 'session' | '7d' | '30d' | 'all'
60export type StatsChart = 'line' | 'area' | 'bars' | 'dots'
61
62export type PlanWindow = { kind: string; percentUsed: number; resetsAt?: string }
63
64export type Ledger = {
65 entries: SpendEntry[]
66 // Filled on a Claude subscription: the plan's usage windows, as the API last reported them.
67 limits: PlanWindow[]
68 // A plan budget: warn when the five-hour window passes this share (0 to 100).
69 planBudgetPercent: number | null
70 limitSamples: SpendSample[]
71 // The WHERE IT WENT list shows every row instead of the top ones.
72 expanded: boolean
73 // By model id: cost (estimated, as the rows are) and what it processed.
74 models: Record<string, ModelSpend>
75 buckets: SpendBucket[]
76 history: History
77 statsView: StatsView
78 statsRange: StatsRange
79 // Charts one model only when set; null charts them all.
80 statsModel: string | null
81 statsChart: StatsChart
82 sessionStartedAt: number | null
83 claudeStats: ClaudeStats | null
84 // Session cost and weighted requests since the mod began measuring, to price a past day's tokens.
85 usdBaseline: number | null
86 weightedSince: number
87 totalUsd: number
88 samples: SpendSample[]
89 budgetUsd: number | null
90 // When on, a prompt you type is refused once the budget is reached (the dollar one, or the
91 // plan line). Work already running carries on; slash commands always go through.
92 hardStop: boolean
93 warned: number[]
94}
95
96declare module 'claude-code' {
97 interface PluginState {
98 'spend-ledger': { ledger: Ledger }
99 }
100}
101