SLOPSHOPPER

session-spend

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

newpanebandcommandtoaststatus
v0.4.1no licenseupdated 2026-10-08SpeakingInBits/claude-mods/mods/session-spend
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-spend
│ ┃ spend-breakdown ✕ › fix the failing auth test and add an audit log call │ ┃ 0 conversations · $0.0000 Sort: Most expens │ ┃ ⏺ Read(src/auth.ts) │ ┃ Loading… ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /spend │ ⎿ session-spend: Session API spend: $0.4200 over 30 min │ ⎿ session-spend: Rate: $0.8400/hour │ ⎿ session-spend: │ ⎿ session-spend: Last 1 measured step(s): │ ⎿ session-spend: +$0.0000 → $0.4200 │ ⎿ session-spend: │ │ ▄▄▄▄▄▄ $0.4200 API spend this session ▄▀▀▀▀▀▀▀▀▄ All sessions $0.4200 · today $0.4200 [ Breakdown ] ▀▀▀▀▀▀▀▀▀▀ ▁ last 1 turns ▀▀▀▀▀▀▀▀▀▀ ▄▀▀▀▄ 5h ████░░░░░░░░░░ 31% ▀▀▀▀▀▀▀▀▀▀ ▄▀▀▀▄ ▀▀▀ ▀▀▀ ▄▀▀▀▄ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ session-spend: API spend $0.4200

Draws

Band
▄▄▄▄▄▄ $0.4200 API spend this session ▄▀▀▀▀▀▀▀▀▄ All sessions $0.4200 · today $0.4200 [ Breakdown ] ▀▀▀▀▀▀▀▀▀▀ ▁ last 1 turns ▀▀▀▀▀▀▀▀▀▀ ▄▀▀▀▄ 5h ████░░░░░░░░░░ 31% ▀▀▀▀▀▀▀▀▀▀ ▄▀▀▀▄ ▀▀▀ ▀▀▀ ▄▀▀▀▄
Pane · spend-breakdown
0 conversations · $0.0000 Sort: Most expensive ▾ [ Close ] Loading…
README

claude-mods

Mods for Claude Code from SpeakingInBits. Mods work in the terminal and in the desktop app's Code tab. This repo is a plugin marketplace, and each mod in it installs on its own.

Mods

ModWhat it does
session-spendA card above the prompt shows Bit, a pixel-art critter who hops while Claude works and stacks coins as the spend grows. The card also has the session's API spend with a per-turn chart, plan usage meters (5-hour and 7-day windows) on a subscription, a pixel character for each subagent, and an ice cube that melts as the prompt cache nears expiry, with what the next prompt costs while the cache is warm and after it expires. The status line shows the running total (API spend $1.30 (+$0.08 last turn)). /spend gives a turn-by-turn breakdown, /spend all totals every session (by day, project and model), and /spend card hides or shows the card.

Install

At a Claude Code prompt:

/plugin install session-spend --marketplace SpeakingInBits/claude-mods

Answer y to add the marketplace, then choose a scope (user scope enables it in every session).

The spend figure is the same estimate /cost reports, at list price or at your organization's own pricing if it has set one. On a Pro or Max subscription it shows what the usage would cost on the API, not what you're billed.

Spend across sessions

/spend all adds up every session, and the card shows an "All sessions" line. Its Breakdown button (or /spend conversations) opens a dialog listing each conversation by title with its project, models and cost, sorted by cost or by date; Esc closes it. Two sources feed these:

  • The ledger: each session the mod runs in records its own spend, the same figure /cost reports, in ~/.claude/session-spend/ledger/.
  • Transcripts: the mod prices the transcripts Claude Code saves under ~/.claude/projects/ at list price, so sessions from before the mod was installed count too. It reads them when a session starts, at most every 6 hours, re-reading only transcripts that changed. /spend import reads them all again. The results are kept in ~/.claude/session-spend/history/, so the totals outlive Claude Code's cleanup of old transcripts.

Where a session has a ledger, its exact figure is used; otherwise the transcript estimate.

Settings

session-spend has two settings, in /config under the plugin:

  • Prompt cache TTL (auto, 5m, 1h): auto assumes 1h on a subscription plan and 5m on per-token billing.
  • Warn before the cache expires (on by default): a toast a minute before a 5m cache expires, or five minutes before a 1h cache.

Cache costs are estimates at list price for the session's model.

Developing

Each mod lives in mods/<name>/ and is listed in .claude-plugin/marketplace.json.

claude plugin validate mods/session-spend
claude plugin test mods/session-spend

To run mods from your working copy, add this folder as a marketplace (claude plugin marketplace add .), install from it, and run /reload-plugins after each edit.

To add a mod, create mods/<name>/, add an entry to plugins in .claude-plugin/marketplace.json, and add a row to the table above.

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