SLOPSHOPPER

spend-ledger

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

newpanecommandtoastprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spend-ledger
│ ┃ Spend ✕ › fix the failing auth test and add an audit log call │ ┃ $ SPEND 0¢ · 0¢/h │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ────────── ⎿ Read 6 lines │ ┃ Costs appear here as the session makes ⏺ Update(src/auth.ts) │ ┃ requests. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ BUDGET none set ⎿ 3 pass, 1 fail │ ┃ [ $25 ] [ $50 ] [ $100 ] [ $250 ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /spend │ ⎿ spend-ledger: Spend panel open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Spend
$ SPEND 0¢ · 0¢/h ────────────────────────────────────────────────────── Costs appear here as the session makes requests. BUDGET none set [ $25 ] [ $50 ] [ $100 ] [ $250 ]
README

Session dashboard for Claude Code

Five side-panel mods for Claude Code (function hooks, early access API):

ModPanelCommand
agent-progressCrew: 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-taxCache: 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-ledgerSpend: 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-linkSession 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-controlMission Control: all four stacked in one scrollable panel, with buttons for every command/mission

Every panel has buttons with hotkeys for its commands.

Install

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.

Best viewed

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).

Source 3 files
hooks/register.tsx 893 lines
1import { 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}
893
hooks/stats.tsx 477 lines
1// 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}
477
types/index.d.ts 101 lines
1export 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