SLOPSHOPPER

what-would-it-cost

What your Claude Code session would cost on the API, as a live band and a receipt.

newpanebandtoastprocesstimer
v0.4.0MITupdated 2026-10-07Faouzielbakri/what-would-it-cost
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · what-would-it-cost
│ ┃ what-would-it-cost ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ≡ INVOICE at API prices ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ │ Session 7 days [ Cycle ] All time ⏺ Update(src/auth.ts) │ ┃ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ $0.00 ⏺ Bash(bun test) │ ┃ │ Oct 1 → Nov 1 · day 9 of 31 · on pace for ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ Nothing billed yet. Suspiciously frugal. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ ┃ │ input $0.00 output ✻ Worked for 42s · done 4:20 PM │ ┃ │ cache write $0.00 cache read │ ┃ │ │ ┃ │ last 51 days p │ ┃ │ │ ┃ │ │ ┃ │ │ ┃ │ ·········································· │ ┃ │ Aug 20 │ ┃ │ │ ┃ │ the meter is running. │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ◎ $0.00 session │ $0.00 today │ $0.00 7d │ $0.00 since Oct 1 │ ·············· ≡ invoice the meter is running. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◎ $0.00 session │ $0.00 today │ $0.00 7d │ $0.00 since Oct 1 │ ·············· ≡ invoice the meter i
Pane · what-would-it-cost
╭─────────────────────────────────────────────────────╮ │ ≡ INVOICE at API prices │ │ │ │ Session 7 days [ Cycle ] All time │ │ │ │ $0.00 │ │ Oct 1 → Nov 1 · day 9 of 31 · on pace for $0.00 │ │ │ │ Nothing billed yet. Suspiciously frugal. │ │ │ │ input $0.00 output $0.00 │ │ cache write $0.00 cache read $0.00 │ │ │ │ last 51 days peak $0.00 │ │ │ │ │ │ │ │ ··················································· │ │ Aug 20 today │ │ │ │ the meter is running. │ ╰─────────────────────────────────────────────────────╯ close
README

what-would-it-cost

A Claude Code cost tracker: what your Pro or Max subscription would cost at API prices, live above your prompt.

what-would-it-cost: a live API cost bar above the Claude Code prompt

One line, always on, updated after every model request while Claude works:

◉ $4.46 sessionthis session at API prices. The dot turns orange while Claude is working
▮▮▮▮▮▮this session's model mix: orange Opus, purple Fable, blue Sonnet, green Haiku
$16.45 todayevery Claude Code session on this machine today
$315 7dyour current weekly limit window, from Claude Code's own reset time
$438 since Sep 28your billing cycle so far, from the day your plan renews
2.2x Max 20xhow many times over the cycle has paid for your plan
▂▅█▃▁▆the last 14 days, colored from quiet to busy
Anthropic is picking up the tab…a running commentary, with a new line every ten minutes

It reads your history on install, so the numbers start with your past month instead of $0.

The invoice

Click ≡ invoice on the bar for the full bill. Tabs switch it between this session, your 7-day limit window, your billing cycle and all time, each broken down by model and token type, with a 60-day chart.

<img src="docs/invoice-cycle.jpg" alt="Claude Code cost invoice for the billing cycle, by model" width="45%"> <img src="docs/invoice-7d.jpg" alt="Claude Code cost invoice for the weekly limit window" width="45%">

Install

In a Claude Code terminal session:

/plugin install what-would-it-cost --marketplace Faouzielbakri/what-would-it-cost

Answer y to add the marketplace, then pick the user scope so it runs in every session. On first run it prices every transcript already on your disk (~/.claude/projects) and tells you the total when it's done.

What it counts

  • Every model request, subagents included, priced at Anthropic's list prices per model: input, output, cache reads and cache writes.
  • Your history from Claude Code's own transcripts, so nothing is counted twice and nothing needs to be running in the background.
  • Prompt caching savings: what the cache reads would have cost as fresh input.
  • A cross-check: the session tab shows Claude Code's own /cost total next to the mod's.
  • Every session on this machine for today, 7d and the cycle: open Claude Code in five projects and they all add to the same numbers, updated every 30 seconds.

Privacy and permissions

Everything stays on your machine.

What it sends: nothing. It makes no network requests and has no telemetry. The numbers are kept in Claude Code's own plugin storage as per-day token totals per model.

What it reads:

WhatWhy
Claude Code's transcripts in ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects)To price your history on first run. It keeps only each response's model, timestamp and token counts; message text is never stored.
~/.claude.json, two fields only: oauthAccount.*RateLimitTier and oauthAccount.subscriptionCreatedAtTo know your plan (Pro, Max 5x, Max 20x) and the day it renews. Skipped entirely when you set Your plan and Billing day yourself in /config.
The HOME and CLAUDE_CONFIG_DIR environment variablesTo find the two locations above.
Claude Code's own usage figuresFor the /cost cross-check and your weekly limit's reset time.

Programs it runs:

CommandWhen and why
dd if=<transcript> bs=1048576 skip=<n> count=3Only for transcripts over 4 MB, which is the most a mod can read in one go. It reads them in 3 MB chunks, read-only.
claude auth statusOnly when the plan can't be read from ~/.claude.json, to tell Pro, Max and API-key accounts apart.

Your plan

It reads your exact plan (Pro, Max 5x or Max 20x) from the rate-limit tier in Claude Code's own config (~/.claude.json), falling back to claude auth status. To set it yourself, go to /config → what-would-it-cost › Your plan and pick pro, max-5x, max-20x or api.

The multiplier runs over your billing cycle, not the calendar month: if you subscribed on the 15th, the cycle runs from the 15th to the 15th. The renewal day is read from when your subscription began; set Billing day in /config if yours differs (say, after a plan change).

If you're on an API key, the bill is real money. The band shows anyway.

Prices

List prices per million tokens, from Anthropic's pricing page (October 2026):

ModelInputOutputCache readCache write (5m / 1h)
Fable 5.1$10$50$0.25$12.50 / $20
Fable 5$10$50$1.00$12.50 / $20
Opus 5.5$4$20$0.20$5 / $8
Opus 5 / 4.5–4.8$5$25$0.50$6.25 / $10
Sonnet 5 / 5.5$2$10$0.20$2.50 / $4
Sonnet 4.x$3$15$0.30$3.75 / $6
Haiku 4.5$1$5$0.10$1.25 / $2

Caveats:

  • History is priced exactly, cache writes split into 5-minute (1.25x input) and 1-hour (2x) as each transcript records them. Live usage doesn't carry that split, so live writes count as 1-hour, which is what Claude Code uses.
  • Claude Code deletes transcripts after 30 days by default (cleanupPeriodDays), so history only goes back as far as your transcripts do.
  • Fast mode isn't detected and is priced as standard.
  • A model id the table doesn't know is priced as Opus 5.5 and marked with ?. PRs that add new models to hooks/pricing.ts are welcome.
  • Days are counted in your local time zone.

Develop

claude --plugin-dir ./what-would-it-cost     # run it from a checkout; saving a file hot-reloads it
claude plugin validate ./what-would-it-cost
claude plugin test ./what-would-it-cost

License

MIT. Fork it, remix it, ship it. If you build on it, a link back to Faouzi El Bakri is appreciated.

Source 5 files
hooks/register.tsx 589 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { History, Plan, Tab, Tally } from '../types'
4import { addTranscript, emptyTotals } from './history'
5import type { DayModels } from './history'
6import {
7  BARS,
8  EMPTY,
9  PLANS,
10  addDayModels,
11  addUsage,
12  billOf,
13  billingDayFromConfig,
14  colorOf,
15  cycleEnd,
16  cycleStart,
17  dayKey,
18  dayTotals,
19  heat,
20  modelsBetween,
21  planFromAuth,
22  planFromConfig,
23  priceOf,
24  sound,
25  sparkline,
26  sumOf,
27  sumSince,
28  usd,
29} from './pricing'
30import type { Usage } from './pricing'
31import { cacheQuip, emptyQuip, historyToast, readingQuip, verdict } from './quips'
32
33const PANE = 'what-would-it-cost'
34const DAYS_KEPT = 400
35const DAY = 86_400_000
36const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
37const STALE_KEYS = ['cutoff', 'history', 'days', 'liveDays', 'historyDays', 'liveModels', 'historyModels']
38const READ_LIMIT = 4_000_000
39const CHUNK_MB = 3
40
41const ORANGE = '#ff8c5a'
42const GOLD = '#fcc419'
43const TEAL = '#3bc9db'
44const AMBER = '#ffa94d'
45const GREEN = '#51cf66'
46const GREY = '#868e96'
47
48// The session's drawn values (see types/index.d.ts).
49const MODELS = { plugin: 'what-would-it-cost', key: 'models' } as const
50const DAY_MODELS = { plugin: 'what-would-it-cost', key: 'dayModels' } as const
51const PLAN = { plugin: 'what-would-it-cost', key: 'plan' } as const
52const BILLING_DAY = { plugin: 'what-would-it-cost', key: 'billingDay' } as const
53const WEEK_START = { plugin: 'what-would-it-cost', key: 'weekStart' } as const
54const LEDGER = { plugin: 'what-would-it-cost', key: 'ledgerUsd' } as const
55const HISTORY = { plugin: 'what-would-it-cost', key: 'history' } as const
56const TAB = { plugin: 'what-would-it-cost', key: 'tab' } as const
57
58type Models = Record<string, Tally>
59
60const lastDays = (byDay: Record<string, number>, now: number, count: number) =>
61  Array.from({ length: count }, (_, i) => byDay[dayKey(now - (count - 1 - i) * DAY)] ?? 0)
62
63const short = (ms: number) => `${MONTHS[new Date(ms).getMonth()]} ${new Date(ms).getDate()}`
64
65/** A bar chart `rows` tall, one column per value, colored by how busy the day was (a Raster's cells). */
66const chartCells = (values: readonly number[], rows: number): string => {
67  const top = Math.max(...values, 0)
68  const words = new Uint32Array(values.length * rows * 3)
69  for (let row = 0; row < rows; row++) {
70    const level = rows - 1 - row
71    values.forEach((v, col) => {
72      const eighths = top > 0 ? Math.round((v / top) * rows * 8) : 0
73      const fill = Math.max(0, Math.min(8, eighths - level * 8))
74      const isFloor = level === 0 && v <= 0
75      const at = (row * values.length + col) * 3
76      words[at] = isFloor ? 0x00b7 : fill === 0 ? 0x20 : BARS.charCodeAt(fill - 1)
77      words[at + 1] = isFloor ? 0x495057 : heat(top > 0 ? v / top : 0)
78      words[at + 2] = 0x01000000
79    })
80  }
81  return (new Uint8Array(words.buffer) as unknown as { toBase64: () => string }).toBase64()
82}
83
84/** Each model's share of `width` cells, the biggest first, every model with a cost getting at least one. */
85const shares = (byModel: Models, width: number) => {
86  const rows = Object.entries(byModel)
87    .map(([model, t]) => ({ model, cost: billOf(model, t).total, t: sound(t) }))
88    .filter(row => row.cost > 0)
89    .sort((a, b) => b.cost - a.cost)
90  const total = rows.reduce((sum, row) => sum + row.cost, 0)
91  let left = width
92  return rows.map((row, i) => {
93    const cells =
94      i === rows.length - 1
95        ? left
96        : Math.max(1, Math.min(left - (rows.length - 1 - i), Math.round((row.cost / total) * width)))
97    left -= cells
98    return { ...row, cells: Math.max(0, cells), share: total > 0 ? row.cost / total : 0 }
99  })
100}
101
102/** Every value the band and the invoice draw from; read while drawing, so a change redraws them. */
103async function snapshot($: EngineInterface) {
104  const [models, dayModels, plan, billingDay, weekStart, ledger, history, tab] = await Promise.all([
105    $.state.get(MODELS),
106    $.state.get(DAY_MODELS),
107    $.state.get(PLAN),
108    $.state.get(BILLING_DAY),
109    $.state.get(WEEK_START),
110    $.state.get(LEDGER),
111    $.state.get(HISTORY),
112    $.state.get(TAB),
113  ])
114  return {
115    models: models.value ?? {},
116    dayModels: dayModels.value ?? {},
117    plan: plan.value ?? null,
118    billingDay: billingDay.value ?? null,
119    weekStart: weekStart.value ?? null,
120    ledger: ledger.value ?? null,
121    history: history.value ?? null,
122    tab: tab.value ?? 'cycle',
123  }
124}
125
126/** A day-by-model map kept in the store under `key`, empty when absent. */
127async function storedDays($: EngineInterface, key: string): Promise<DayModels> {
128  const value = await $.store.get(key)
129  return (value as DayModels | undefined) ?? {}
130}
131
132/** Where Claude Code keeps its config: $CLAUDE_CONFIG_DIR, else ~/.claude. */
133async function configDir($: EngineInterface): Promise<string> {
134  const custom = await $.env.get('CLAUDE_CONFIG_DIR')
135  const home = await $.env.get('HOME')
136  return custom ?? `${home ?? '~'}/.claude`
137}
138
139/** Re-reads history and live tallies from the store into the drawn state. */
140async function refresh($: EngineInterface) {
141  const past = await storedDays($, 'v2.historyDayModels')
142  const live = await storedDays($, 'v2.liveDayModels')
143  await $.state.set(DAY_MODELS, addDayModels(past, live))
144}
145
146/** The engine's own /cost total (a cross-check) and the 7-day limit window, from the status line's figures. */
147async function readUsage($: EngineInterface) {
148  let usage
149  try {
150    usage = await $.session.usage()
151  } catch {
152    return
153  }
154  await $.state.set(LEDGER, usage.cost?.usd ?? null)
155  const resets = Date.parse(usage.rateLimits.find(limit => limit.kind === 'seven_day')?.resetsAt ?? '')
156  if (!Number.isNaN(resets)) await $.state.set(WEEK_START, resets - 7 * DAY)
157}
158
159/** One model request's usage onto the session and the day, and the engine's figures re-read. */
160async function account($: EngineInterface, usage: Usage & { model: string }) {
161  const model = usage.model || 'unknown'
162  const today = dayKey(await $.clock.now())
163
164  const session = (await $.state.get(MODELS)).value ?? {}
165  await $.state.set(MODELS, { ...session, [model]: addUsage(session[model] ?? EMPTY, usage) })
166
167  // Re-read the store so sessions running side by side add up instead of overwriting.
168  const live = await storedDays($, 'v2.liveDayModels')
169  const kept: DayModels = Object.fromEntries(
170    Object.keys(live)
171      .sort()
172      .slice(-DAYS_KEPT)
173      .map(day => [day, live[day] ?? {}]),
174  )
175  const byModel = (kept[today] ??= {})
176  byModel[model] = addUsage(byModel[model] ?? EMPTY, usage)
177  await $.store.set('v2.liveDayModels', kept)
178  await refresh($)
179  await readUsage($)
180}
181
182/** Reads one transcript into `text` pieces that end on whole lines; past the read limit, in `dd` chunks. */
183async function* linesOf($: EngineInterface, path: string, size: number): AsyncGenerator<string> {
184  if (size <= READ_LIMIT) {
185    yield String(await $.fs.read(path))
186    return
187  }
188  let carry = ''
189  for (let skip = 0; skip * 1_048_576 < size; skip += CHUNK_MB) {
190    const ran = await $.process.run(['dd', `if=${path}`, 'bs=1048576', `skip=${skip}`, `count=${CHUNK_MB}`], {
191      timeoutMs: 30_000,
192    })
193    if (ran.exitCode !== 0) return
194    const text = carry + ran.stdout
195    const end = text.lastIndexOf('\n')
196    carry = end === -1 ? text : text.slice(end + 1)
197    if (end !== -1) yield text.slice(0, end)
198  }
199  if (carry) yield carry
200}
201
202/** Every transcript under `dir`: each project's sessions and their subagents. */
203async function transcriptsIn($: EngineInterface, dir: string, depth: number): Promise<{ path: string; size: number }[]> {
204  let entries
205  try {
206    entries = await $.fs.list(dir)
207  } catch {
208    return []
209  }
210  const files: { path: string; size: number }[] = []
211  for (const entry of entries) {
212    const path = `${dir}/${entry.name}`
213    if (entry.kind === 'file' && entry.name.endsWith('.jsonl')) files.push({ path, size: entry.size })
214    else if (entry.kind === 'dir' && depth < 3) files.push(...(await transcriptsIn($, path, depth + 1)))
215  }
216  return files
217}
218
219/** First run: price every transcript already on disk, so the band has a past from the start. */
220async function readPast($: EngineInterface, cutoff: number) {
221  const reading = (filesDone: number, files: number): History => ({ status: 'reading', filesDone, files, requests: 0 })
222  await $.state.set(HISTORY, reading(0, 0))
223  try {
224    const files = await transcriptsIn($, `${await configDir($)}/projects`, 0)
225    const totals = emptyTotals()
226    const seen = new Set<string>()
227    let done = 0
228    for (const file of files) {
229      try {
230        for await (const text of linesOf($, file.path, file.size)) addTranscript(text, totals, seen, cutoff)
231      } catch {
232        // One unreadable transcript costs only itself.
233      }
234      done += 1
235      if (done % 5 === 0 || done === files.length) await $.state.set(HISTORY, reading(done, files.length))
236    }
237
238    const finished: History = { status: 'done', filesDone: 0, files: 0, requests: totals.requests }
239    await $.store.set('v2.historyDayModels', totals.dayModels)
240    await $.store.set('v2.history', finished)
241    await $.state.set(HISTORY, finished)
242    await refresh($)
243    const total = Object.values(dayTotals(totals.dayModels)).reduce((a, b) => a + b, 0)
244    $.ui.toast(historyToast(totals.requests, total))
245  } catch {
246    await $.state.set(HISTORY, { status: 'failed', filesDone: 0, files: 0, requests: 0 })
247  }
248}
249
250/** The plan and its renewal day: Claude Code's own config names both; else `claude auth status` names the plan. */
251async function findAccount($: EngineInterface): Promise<{ plan: Plan | null; day: number | null }> {
252  const custom = await $.env.get('CLAUDE_CONFIG_DIR')
253  const home = await $.env.get('HOME')
254  const path = custom ? `${custom}/.claude.json` : `${home ?? '~'}/.claude.json`
255  let text = ''
256  try {
257    text = String(await $.fs.read(path))
258  } catch {
259    // No config file: the plan comes from `claude auth status` below.
260  }
261  const day = billingDayFromConfig(text)
262  const fromConfig = planFromConfig(text)
263  if (fromConfig) return { plan: fromConfig, day }
264  try {
265    const ran = await $.process.run(['claude', 'auth', 'status'], { timeoutMs: 15_000 })
266    return { plan: ran.exitCode === 0 ? planFromAuth(ran.stdout) : null, day }
267  } catch {
268    return { plan: null, day }
269  }
270}
271
272/** Fills in the plan and renewal day the person did not set themselves. */
273async function settleAccount($: EngineInterface, isPlanAuto: boolean, isDayAuto: boolean) {
274  await readUsage($)
275  // Both set in /config: nothing to look up, and ~/.claude.json is never opened.
276  if (!isPlanAuto && !isDayAuto) return
277  const found = await findAccount($)
278  if (isPlanAuto) await $.state.set(PLAN, found.plan)
279  if (isDayAuto) await $.state.set(BILLING_DAY, found.day ?? 1)
280}
281
282async function openInvoice($: EngineInterface) {
283  await $.ui.open({ id: PANE, title: 'Invoice' })
284}
285
286const TABS: ReadonlyArray<readonly [Tab, string]> = [
287  ['session', 'Session'],
288  ['week', '7 days'],
289  ['cycle', 'Cycle'],
290  ['all', 'All time'],
291]
292
293export const register: Register = (on, options) => {
294  on('session.start', async ($, e, next) => {
295    // Responses before the first run belong to history; from then on, to the live counter.
296    let cutoff = (await $.store.get('v2.cutoff')) as number | undefined
297    if (cutoff === undefined) {
298      cutoff = await $.clock.now()
299      await $.store.set('v2.cutoff', cutoff)
300      for (const key of STALE_KEYS) await $.store.delete(key)
301    }
302    await refresh($)
303
304    const past = (await $.store.get('v2.history')) as History | undefined
305    const from = cutoff
306    if (past?.status === 'done') await $.state.set(HISTORY, past)
307    else $.clock.after(100, () => void readPast($, from))
308
309    // Other Claude Code sessions add to the same store: pick their requests up as they land.
310    $.clock.every(30_000, () => void refresh($))
311
312    const chosen = String(options.plan ?? 'auto')
313    const day = Math.round(Number(options.billingDay ?? 0))
314    const isDayAuto = !(day >= 1 && day <= 31)
315    if (chosen !== 'auto') await $.state.set(PLAN, PLANS[chosen] ?? null)
316    if (!isDayAuto) await $.state.set(BILLING_DAY, day)
317    $.clock.after(100, () => void settleAccount($, chosen === 'auto', isDayAuto))
318
319    return next(e)
320  })
321
322  // Every model request, subagents' included, the moment it ends: the band moves while Claude works.
323  on('turn.step', async function* ($, e, next) {
324    const step = yield* next(e)
325    if (step.usage) await account($, step.usage)
326
327    return step
328  })
329
330  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
331    if (e.props.hasSurvey) return next(e)
332
333    const { Box, Button, Text } = $.ui.resolve(e)
334    const now = await $.clock.now()
335    const drawn = await snapshot($)
336    const sessionModels = drawn.models
337    const byDay = dayTotals(drawn.dayModels)
338    const p = drawn.plan
339    const past = drawn.history
340    const start = cycleStart(now, drawn.billingDay ?? 1)
341    const cycle = sumSince(byDay, start, now)
342    const week = sumSince(byDay, drawn.weekStart ?? now - 6 * DAY, now)
343    const ratio = p?.usd ? cycle / p.usd : null
344    const fortnight = lastDays(byDay, now, 14)
345    const mix = shares(sessionModels, 6)
346
347    let trend
348    if (e.surface === 'terminal') {
349      const { Raster } = $.ui.resolve(e)
350      trend = <Raster key="trend" columns={fortnight.length} rows={1} cells={chartCells(fortnight, 1)} />
351    } else {
352      trend = <Text color={TEAL}>{sparkline(fortnight)}</Text>
353    }
354
355    return (
356      <Box key="band" flexDirection="row">
357        <Text wrap="truncate-end">
358          <Text color={e.props.isWorking ? ORANGE : GREY} bold>
359            {e.props.isWorking ? '◉ ' : '◎ '}
360          </Text>
361          <Text color={ORANGE} bold>
362            {usd(sumOf(sessionModels))}
363          </Text>
364          <Text dimColor> session </Text>
365          {mix.map(row => (
366            <Text color={colorOf(row.model)}>{'▮'.repeat(row.cells)}</Text>
367          ))}
368          <Text dimColor>{mix.length > 0 ? ' │ ' : '│ '}</Text>
369          <Text color={GOLD} bold>
370            {usd(byDay[dayKey(now)] ?? 0)}
371          </Text>
372          <Text dimColor> today │ </Text>
373          <Text color={AMBER} bold>
374            {usd(week)}
375          </Text>
376          <Text dimColor> 7d │ </Text>
377          <Text color={TEAL} bold>
378            {usd(cycle)}
379          </Text>
380          <Text dimColor> since {short(start)} </Text>
381          {ratio !== null && (
382            <Text color={ratio >= 1 ? GREEN : GOLD} bold>
383              {ratio.toFixed(ratio < 10 ? 1 : 0)}x{' '}
384            </Text>
385          )}
386          {p?.usd != null && <Text dimColor>{p.label} </Text>}
387          <Text dimColor>│ </Text>
388        </Text>
389        {trend}
390        <Text> </Text>
391        <Button key="invoice" label="≡ invoice" plain hover={{ color: ORANGE }} onPress={() => void openInvoice($)} />
392        <Box flexShrink={1}>
393          {past?.status === 'reading' ? (
394            <Text color={TEAL} wrap="truncate-end">
395              {'  '}⟳ {readingQuip(now)} {past.filesDone}/{past.files || '…'}
396            </Text>
397          ) : (
398            <Text dimColor italic wrap="truncate-end">
399              {'  '}
400              {verdict(cycle, p, now)}
401            </Text>
402          )}
403        </Box>
404      </Box>
405    )
406  })
407
408  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
409    const { Box, Button, Text } = $.ui.resolve(e)
410    const outer = Math.max(40, Math.min(72, (e.props.bodyColumns ?? 72) - 1))
411    const inner = outer - 4
412    const now = await $.clock.now()
413    const today = dayKey(now)
414    const drawn = await snapshot($)
415    const sessionModels = drawn.models
416    const allDays = drawn.dayModels
417    const byDay = dayTotals(allDays)
418    const p = drawn.plan
419    const ledger = drawn.ledger
420    const past = drawn.history
421    const chosen = drawn.tab
422    const renews = drawn.billingDay ?? 1
423    const start = cycleStart(now, renews)
424    const end = cycleEnd(start, renews)
425    const limitWeek = drawn.weekStart
426    const firstDay = Object.keys(allDays).sort()[0] ?? today
427
428    const scope: Models =
429      chosen === 'session'
430        ? sessionModels
431        : chosen === 'week'
432          ? modelsBetween(allDays, dayKey(limitWeek ?? now - 6 * DAY), today)
433          : chosen === 'cycle'
434            ? modelsBetween(allDays, dayKey(start), today)
435            : modelsBetween(allDays, firstDay, today)
436    const rows = shares(scope, inner)
437    const total = rows.reduce((sum, row) => sum + row.cost, 0)
438    const requests = rows.reduce((sum, row) => sum + row.t.turns, 0)
439    const lines = rows.reduce(
440      (sum, row) => {
441        const bill = billOf(row.model, row.t)
442        return {
443          input: sum.input + bill.input,
444          output: sum.output + bill.output,
445          write: sum.write + bill.cacheWrite,
446          read: sum.read + bill.cacheRead,
447          saved: sum.saved + bill.saved,
448        }
449      },
450      { input: 0, output: 0, write: 0, read: 0, saved: 0 },
451    )
452
453    const elapsed = Math.max(1, Math.ceil((now - start) / DAY))
454    const length = Math.round((end - start) / DAY)
455    const ratio = chosen === 'cycle' && p?.usd ? total / p.usd : null
456    const subtitle =
457      chosen === 'session'
458        ? `this session · ${requests.toLocaleString('en-US')} requests`
459        : chosen === 'week'
460          ? limitWeek === null
461            ? `last 7 days · ${requests.toLocaleString('en-US')} requests`
462            : `weekly limit window · ${short(limitWeek)} → ${short(limitWeek + 7 * DAY)}`
463          : chosen === 'cycle'
464            ? `${short(start)} → ${short(end)} · day ${elapsed} of ${length} · on pace for ${usd((total / elapsed) * length)}`
465            : `since ${short(new Date(`${firstDay}T12:00`).getTime())} · ${requests.toLocaleString('en-US')} requests`
466
467    const span = Math.min(inner, 60)
468    const chartDays = lastDays(byDay, now, span)
469    let chart
470    if (e.surface === 'terminal') {
471      const { Raster } = $.ui.resolve(e)
472      chart = <Raster key="chart" columns={span} rows={4} cells={chartCells(chartDays, 4)} />
473    } else {
474      chart = <Text color={TEAL}>{sparkline(chartDays)}</Text>
475    }
476
477    const split = (label: string, value: number) => (
478      <Box width={Math.floor(inner / 2)} justifyContent="space-between" paddingRight={2}>
479        <Text dimColor>{label}</Text>
480        <Text>{usd(value)}</Text>
481      </Box>
482    )
483
484    return (
485      <Box flexDirection="column">
486        <Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1} width={outer}>
487          <Box justifyContent="space-between">
488            <Text>
489              <Text color={ORANGE} bold>
490                ≡ INVOICE
491              </Text>
492              <Text dimColor> at API prices</Text>
493            </Text>
494            <Text dimColor>{p ? (p.usd ? `${p.label} · $${p.usd}/mo` : p.label) : ''}</Text>
495          </Box>
496          <Text> </Text>
497
498          <Box flexDirection="row" gap={2}>
499            {TABS.map(([id, label]) =>
500              id === chosen ? (
501                <Button key={`tab-${id}`} label={label} variant="primary" onPress={() => void $.state.set(TAB, id)} />
502              ) : (
503                <Button key={`tab-${id}`} label={label} plain dimColor onPress={() => void $.state.set(TAB, id)} />
504              ),
505            )}
506          </Box>
507          <Text> </Text>
508
509          <Box justifyContent="space-between">
510            <Text color={ORANGE} bold>
511              {usd(total)}
512            </Text>
513            {ratio !== null && (
514              <Text color={ratio >= 1 ? GREEN : GOLD} bold>
515                {ratio.toFixed(2)}x your plan
516              </Text>
517            )}
518          </Box>
519          <Text dimColor>{subtitle}</Text>
520          {past?.status === 'reading' && chosen !== 'session' && (
521            <Text color={TEAL}>
522              ⟳ {readingQuip(now)} {past.filesDone}/{past.files || '…'}
523            </Text>
524          )}
525          <Text> </Text>
526
527          {rows.length === 0 ? (
528            <Text dimColor>{emptyQuip(now)}</Text>
529          ) : (
530            <Text>
531              {rows.map(row => (
532                <Text color={colorOf(row.model)}>{'█'.repeat(row.cells)}</Text>
533              ))}
534            </Text>
535          )}
536          {rows.map(row => (
537            <Box key={row.model} justifyContent="space-between">
538              <Text>
539                <Text color={colorOf(row.model)}>● </Text>
540                <Text bold>{priceOf(row.model).label}</Text>
541                <Text dimColor>
542                  {'  '}
543                  {row.t.turns.toLocaleString('en-US')} req
544                </Text>
545              </Text>
546              <Text>
547                <Text dimColor>{Math.round(row.share * 100)}% </Text>
548                <Text bold>{usd(row.cost)}</Text>
549              </Text>
550            </Box>
551          ))}
552          <Text> </Text>
553
554          <Box flexDirection="row">
555            {split('input', lines.input)}
556            {split('output', lines.output)}
557          </Box>
558          <Box flexDirection="row">
559            {split('cache write', lines.write)}
560            {split('cache read', lines.read)}
561          </Box>
562          {lines.saved > 0 && <Text color={GREEN}>{cacheQuip(lines.saved, now)}</Text>}
563          {chosen === 'session' && ledger !== null && (
564            <Text dimColor>Claude Code's own /cost says {usd(ledger)}. We checked.</Text>
565          )}
566          <Text> </Text>
567
568          <Box justifyContent="space-between" width={span}>
569            <Text dimColor>last {span} days</Text>
570            <Text dimColor>peak {usd(Math.max(...chartDays, 0))}</Text>
571          </Box>
572          {chart}
573          <Box justifyContent="space-between" width={span}>
574            <Text dimColor>{short(now - (span - 1) * DAY)}</Text>
575            <Text dimColor>today</Text>
576          </Box>
577          <Text> </Text>
578          <Text italic color={GOLD}>
579            {verdict(sumSince(byDay, start, now), p, now)}
580          </Text>
581        </Box>
582        <Box justifyContent="flex-end" width={outer}>
583          <Button key="close" label="close" role="dismiss" plain dimColor onPress={() => $.ui.close({ id: PANE })} />
584        </Box>
585      </Box>
586    )
587  })
588}
589
hooks/history.ts 57 lines
1import type { Tally } from '../types'
2import { EMPTY, addUsage, dayKey } from './pricing'
3import type { Usage } from './pricing'
4
5/** Tallies by local day, then by model. */
6export type DayModels = Record<string, Record<string, Tally>>
7
8/** What one pass over the transcripts adds up to. */
9export type Totals = { dayModels: DayModels; requests: number }
10
11export const emptyTotals = (): Totals => ({ dayModels: {}, requests: 0 })
12
13type Row = {
14  type?: string
15  timestamp?: string
16  requestId?: string
17  message?: { id?: string; model?: string; usage?: Partial<Usage> }
18}
19
20/**
21 * Adds every billed model response in `text` (JSONL, whole lines) to `totals`.
22 * A response is written once per content block, so `seen` keeps each one to a single count.
23 * Only responses before `cutoffMs` count: from then on the live counter has them.
24 */
25export const addTranscript = (text: string, totals: Totals, seen: Set<string>, cutoffMs: number) => {
26  for (const line of text.split('\n')) {
27    if (!line.includes('"usage"') || !line.includes('"assistant"')) continue
28    let row: Row
29    try {
30      row = JSON.parse(line) as Row
31    } catch {
32      continue
33    }
34    const message = row.message
35    const u = message?.usage
36    if (row.type !== 'assistant' || !u || !message?.id || !message.model || message.model === '<synthetic>') continue
37
38    const key = `${message.id}:${row.requestId ?? ''}`
39    if (seen.has(key)) continue
40    seen.add(key)
41
42    const ms = Date.parse(row.timestamp ?? '')
43    if (!(ms < cutoffMs)) continue
44
45    const usage: Usage = {
46      input_tokens: u.input_tokens ?? 0,
47      output_tokens: u.output_tokens ?? 0,
48      cache_read_input_tokens: u.cache_read_input_tokens ?? 0,
49      cache_creation_input_tokens: u.cache_creation_input_tokens ?? 0,
50      ...(u.cache_creation ? { cache_creation: u.cache_creation } : {}),
51    }
52    const day = (totals.dayModels[dayKey(ms)] ??= {})
53    day[message.model] = addUsage(day[message.model] ?? EMPTY, usage)
54    totals.requests += 1
55  }
56}
57
hooks/pricing.ts 248 lines
1import type { Plan, Tally } from '../types'
2
3/** US dollars per million tokens. Cache writes bill at 1.25x input (5-minute) or 2x (1-hour). */
4export type Price = { input: number; output: number; cacheRead: number }
5
6// Anthropic first-party list prices, checked 2026-10. Most specific id first.
7export const PRICES: ReadonlyArray<readonly [RegExp, string, Price]> = [
8  [/fable-5-1|mythos-5-1/, 'Fable 5.1', { input: 10, output: 50, cacheRead: 0.25 }],
9  [/fable-5|mythos-5/, 'Fable 5', { input: 10, output: 50, cacheRead: 1 }],
10  [/opus-5-5/, 'Opus 5.5', { input: 4, output: 20, cacheRead: 0.2 }],
11  [/opus-5/, 'Opus 5', { input: 5, output: 25, cacheRead: 0.5 }],
12  [/opus-4-[5-8]/, 'Opus 4.x', { input: 5, output: 25, cacheRead: 0.5 }],
13  [/opus-4/, 'Opus 4', { input: 15, output: 75, cacheRead: 1.5 }],
14  [/sonnet-5/, 'Sonnet 5.x', { input: 2, output: 10, cacheRead: 0.2 }],
15  [/sonnet-4|sonnet-3-7/, 'Sonnet 4.x', { input: 3, output: 15, cacheRead: 0.3 }],
16  [/haiku-4/, 'Haiku 4.5', { input: 1, output: 5, cacheRead: 0.1 }],
17  [/haiku-3-5/, 'Haiku 3.5', { input: 0.8, output: 4, cacheRead: 0.08 }],
18]
19
20const OPUS_5_5: Price = { input: 4, output: 20, cacheRead: 0.2 }
21
22export const WRITE_5M = 1.25
23export const WRITE_1H = 2
24
25/** The price row for a model id as the API reports it (`claude-opus-5-5[1m]`, a Bedrock id, a dated id). */
26export const priceOf = (model: string): { label: string; price: Price; isKnown: boolean } => {
27  const row = PRICES.find(([pattern]) => pattern.test(model))
28  // An id this table does not know yet is billed as the current Opus, and marked.
29  return row ? { label: row[1], price: row[2], isKnown: true } : { label: model, price: OPUS_5_5, isKnown: false }
30}
31
32/** The model family's color, for bars and labels. */
33export const colorOf = (model: string): string =>
34  /fable|mythos/.test(model)
35    ? '#b197fc'
36    : /opus/.test(model)
37      ? '#ff8c5a'
38      : /sonnet/.test(model)
39        ? '#4dabf7'
40        : /haiku/.test(model)
41          ? '#69db7c'
42          : '#adb5bd'
43
44export type Usage = {
45  input_tokens: number
46  output_tokens: number
47  cache_read_input_tokens: number
48  cache_creation_input_tokens: number
49  /** The write split by cache lifetime, where the record has it (transcripts do; live usage does not). */
50  cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
51}
52
53export const EMPTY: Tally = { input: 0, output: 0, cacheRead: 0, cacheWrite5m: 0, cacheWrite1h: 0, turns: 0 }
54
55/** A tally as stored, every count a number: one saved by an older version reads as zeros where it lacks a field. */
56export const sound = (t: Partial<Tally> | undefined): Tally => ({
57  input: Number(t?.input) || 0,
58  output: Number(t?.output) || 0,
59  cacheRead: Number(t?.cacheRead) || 0,
60  cacheWrite5m: Number(t?.cacheWrite5m) || 0,
61  cacheWrite1h: (Number(t?.cacheWrite1h) || 0) + (Number((t as { cacheWrite?: number } | undefined)?.cacheWrite) || 0),
62  turns: Number(t?.turns) || 0,
63})
64
65export const addUsage = (stored: Tally, usage: Usage): Tally => {
66  const tally = sound(stored)
67  const split = usage.cache_creation
68  // Claude Code writes its prompt cache with the 1-hour lifetime, so an unsplit write counts as one.
69  const write5m = split ? (split.ephemeral_5m_input_tokens ?? 0) : 0
70  const write1h = split ? (split.ephemeral_1h_input_tokens ?? 0) : usage.cache_creation_input_tokens
71  return {
72    input: tally.input + usage.input_tokens,
73    output: tally.output + usage.output_tokens,
74    cacheRead: tally.cacheRead + usage.cache_read_input_tokens,
75    cacheWrite5m: tally.cacheWrite5m + write5m,
76    cacheWrite1h: tally.cacheWrite1h + write1h,
77    turns: tally.turns + 1,
78  }
79}
80
81export const addTallies = (x: Tally, y: Tally): Tally => {
82  const [a, b] = [sound(x), sound(y)]
83  return {
84  input: a.input + b.input,
85  output: a.output + b.output,
86  cacheRead: a.cacheRead + b.cacheRead,
87  cacheWrite5m: a.cacheWrite5m + b.cacheWrite5m,
88  cacheWrite1h: a.cacheWrite1h + b.cacheWrite1h,
89    turns: a.turns + b.turns,
90  }
91}
92
93export const mergeModels = (...maps: Record<string, Tally>[]): Record<string, Tally> => {
94  const merged: Record<string, Tally> = {}
95  for (const map of maps) {
96    for (const [model, t] of Object.entries(map)) merged[model] = addTallies(merged[model] ?? EMPTY, t)
97  }
98  return merged
99}
100
101/** Each line of the bill for one model, in dollars. */
102export const billOf = (model: string, stored: Tally) => {
103  const { price } = priceOf(model)
104  const t = sound(stored)
105  const input = (t.input * price.input) / 1e6
106  const output = (t.output * price.output) / 1e6
107  const cacheWrite = ((t.cacheWrite5m * WRITE_5M + t.cacheWrite1h * WRITE_1H) * price.input) / 1e6
108  const cacheRead = (t.cacheRead * price.cacheRead) / 1e6
109  const saved = (t.cacheRead * (price.input - price.cacheRead)) / 1e6
110  return { input, output, cacheWrite, cacheRead, total: input + output + cacheWrite + cacheRead, saved }
111}
112
113export const costOf = (model: string, usage: Usage) => billOf(model, addUsage(EMPTY, usage)).total
114
115export const sumOf = (byModel: Record<string, Tally>) =>
116  Object.entries(byModel).reduce((sum, [model, t]) => sum + billOf(model, t).total, 0)
117
118export const PLANS: Record<string, Plan> = {
119  pro: { label: 'Pro', usd: 20 },
120  'max-5x': { label: 'Max 5x', usd: 100 },
121  'max-20x': { label: 'Max 20x', usd: 200 },
122  api: { label: 'API', usd: null },
123}
124
125/** The plan from Claude Code's own config (`~/.claude.json`): its rate-limit tier names it exactly. */
126export const planFromConfig = (json: string): Plan | null => {
127  try {
128    const account = (JSON.parse(json) as { oauthAccount?: Record<string, unknown> }).oauthAccount
129    if (!account) return null
130    const tier = String(account.userRateLimitTier ?? account.organizationRateLimitTier ?? '').toLowerCase()
131    if (tier.includes('max_20x')) return PLANS['max-20x'] ?? null
132    if (tier.includes('max_5x')) return PLANS['max-5x'] ?? null
133    if (tier.includes('pro')) return PLANS.pro ?? null
134    return null
135  } catch {
136    return null
137  }
138}
139
140/** The day of the month the subscription renews, from when it began (`oauthAccount.subscriptionCreatedAt`). */
141export const billingDayFromConfig = (json: string): number | null => {
142  try {
143    const began = (JSON.parse(json) as { oauthAccount?: { subscriptionCreatedAt?: string } }).oauthAccount
144      ?.subscriptionCreatedAt
145    const ms = Date.parse(began ?? '')
146    return Number.isNaN(ms) ? null : new Date(ms).getDate()
147  } catch {
148    return null
149  }
150}
151
152/** When the billing cycle holding `now` began: the last renewal on `day`, a short month renewing on its last day. */
153export const cycleStart = (now: number, day: number): number => {
154  const at = (year: number, month: number) =>
155    new Date(year, month, Math.min(day, new Date(year, month + 1, 0).getDate())).getTime()
156  const d = new Date(now)
157  const thisMonth = at(d.getFullYear(), d.getMonth())
158  return thisMonth <= now ? thisMonth : at(d.getFullYear(), d.getMonth() - 1)
159}
160
161/** When that cycle ends: the next renewal. */
162export const cycleEnd = (start: number, day: number): number => {
163  const d = new Date(start)
164  return new Date(d.getFullYear(), d.getMonth() + 1, Math.min(day, new Date(d.getFullYear(), d.getMonth() + 2, 0).getDate())).getTime()
165}
166
167/** Dollars from the day holding `startMs` through the day holding `now`. */
168export const sumSince = (byDay: Record<string, number>, startMs: number, now: number): number => {
169  const [from, to] = [dayKey(startMs), dayKey(now)]
170  return Object.entries(byDay).reduce((sum, [day, v]) => (day >= from && day <= to ? sum + v : sum), 0)
171}
172
173/** The plan `claude auth status` reports, from its JSON (it says `max`, not which Max). */
174export const planFromAuth = (json: string): Plan | null => {
175  try {
176    const auth = JSON.parse(json) as { authMethod?: string; subscriptionType?: string | null }
177    if (auth.authMethod !== 'claude.ai') return PLANS.api ?? null
178    const kind = (auth.subscriptionType ?? '').toLowerCase()
179    if (kind === 'max') return PLANS['max-20x'] ?? null
180    return PLANS[kind] ?? (kind === '' ? null : { label: kind.charAt(0).toUpperCase() + kind.slice(1), usd: null })
181  } catch {
182    return null
183  }
184}
185
186export const usd = (n: number): string =>
187  n >= 10_000
188    ? `$${(n / 1000).toFixed(1)}k`
189    : n >= 100
190      ? `$${Math.round(n).toLocaleString('en-US')}`
191      : `$${n.toFixed(2)}`
192
193export const tokens = (n: number): string =>
194  n >= 1e9
195    ? `${(n / 1e9).toFixed(1)}B`
196    : n >= 1e6
197      ? `${(n / 1e6).toFixed(1)}M`
198      : n >= 1e3
199        ? `${(n / 1e3).toFixed(1)}k`
200        : String(n)
201
202export const dayKey = (ms: number): string => {
203  const d = new Date(ms)
204  const pad = (n: number) => String(n).padStart(2, '0')
205  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
206}
207
208export const BARS = '▁▂▃▄▅▆▇█'
209
210/** One width-1 glyph per day, oldest first, scaled to the busiest day. */
211export const sparkline = (values: readonly number[]): string => {
212  const top = Math.max(...values, 0)
213  return values.map(v => (v <= 0 ? '·' : BARS.charAt(Math.min(7, Math.floor((v / top) * 7.999))))).join('')
214}
215
216/** Teal for a quiet day through gold to hot orange for the busiest, as 0xRRGGBB. */
217export const heat = (share: number): number => {
218  const stops = [
219    [0x3b, 0xc9, 0xdb],
220    [0xfc, 0xc4, 0x19],
221    [0xff, 0x6b, 0x2c],
222  ] as const
223  const t = Math.max(0, Math.min(1, share)) * 2
224  const [a, b] = t < 1 ? [stops[0], stops[1]] : [stops[1], stops[2]]
225  const f = t < 1 ? t : t - 1
226  const mix = (i: 0 | 1 | 2) => Math.round(a[i] + (b[i] - a[i]) * f)
227  return (mix(0) << 16) | (mix(1) << 8) | mix(2)
228}
229
230export const hex = (rgb: number): string => `#${rgb.toString(16).padStart(6, '0')}`
231
232type DayModels = Record<string, Record<string, Tally>>
233
234/** Each model's tally over the days from `from` through `to` (day keys, inclusive). */
235export const modelsBetween = (dayModels: DayModels, from: string, to: string): Record<string, Tally> =>
236  mergeModels(...Object.entries(dayModels).flatMap(([day, byModel]) => (day >= from && day <= to ? [byModel] : [])))
237
238/** Dollars per day. */
239export const dayTotals = (dayModels: DayModels): Record<string, number> =>
240  Object.fromEntries(Object.entries(dayModels).map(([day, byModel]) => [day, sumOf(byModel)]))
241
242/** Two day-by-model maps added together. */
243export const addDayModels = (a: DayModels, b: DayModels): DayModels => {
244  const sum: DayModels = { ...a }
245  for (const [day, byModel] of Object.entries(b)) sum[day] = mergeModels(sum[day] ?? {}, byModel)
246  return sum
247}
248
hooks/quips.ts 107 lines
1import type { Plan } from '../types'
2import { usd } from './pricing'
3
4// `{over}` what Anthropic covers past the plan, `{left}` what is left to break even, `{x}` the multiple.
5const VERDICTS = {
6  realMoney: [
7    'this one is real money. Breathe.',
8    'every token here is a tiny invoice.',
9    'your accountant just felt a chill.',
10    'no plan, no mercy. Pay-as-you-go hits different.',
11  ],
12  idle: [
13    'paying for a gym you never visit.',
14    'the buffet is open. You ordered water.',
15    'Anthropic thanks you for your generous donation.',
16    'your plan is bored. Give it something to do.',
17  ],
18  warming: [
19    'warming up. {left} until the plan pays for itself.',
20    'Anthropic is winning this round. For now.',
21    '{left} of plan left on the table. Eat up.',
22    'still in the shallow end of the pool.',
23  ],
24  close: [
25    '{left} away from free food.',
26    'almost broke even. The plan is sweating.',
27    'so close you can smell the free tokens.',
28    'one big refactor from break-even.',
29  ],
30  paidOff: [
31    'plan paid off. Anthropic is now covering {over}.',
32    'break-even achieved. Everything else is on the house.',
33    "{x}x what you paid. The chef is getting nervous.",
34    'Anthropic is picking up the tab: {over} so far.',
35    "you've made your money back. Now it's just showing off.",
36  ],
37  heavy: [
38    'Anthropic is quietly funding your startup ({over} and counting).',
39    'a VC would call this a {x}x return.',
40    'somewhere, a GPU is filing a complaint.',
41    '{over} on the house. Someone in finance just sighed.',
42    'this plan is the best deal since free refills.',
43  ],
44  absurd: [
45    'you are why they have weekly limits.',
46    "{x}x. Anthropic's finance team knows your name.",
47    "this isn't a subscription, it's a heist.",
48    '{over} of compute, $200 of guilt. Fair trade.',
49    'the datacenter dims the lights when you log in.',
50  ],
51} as const
52
53const CACHE = [
54  '♻ caching spared you {s} of déjà vu',
55  '♻ {s} of tokens Claude remembered instead of rereading',
56  '♻ the cache knocked {s} off. Rereading is cheap, apparently.',
57  '♻ {s} saved by not reading the same files again',
58]
59
60const EMPTY = ['Nothing billed yet. Suspiciously frugal.', 'Nothing here yet. Ask Claude something expensive.']
61
62const READING = ['digging through your receipts', 'adding up your past sins', 'pricing your history']
63
64/** A stable pick from `options`: the same for ten minutes, then the next, so a redraw never flickers. */
65export const pick = (options: readonly string[], now: number, salt = 0): string => {
66  let seed = Math.floor(now / 600_000) * 31 + salt
67  seed = (seed ^ (seed >>> 13)) * 1_274_126_177
68  return options[Math.abs(seed ^ (seed >>> 16)) % options.length] ?? options[0] ?? ''
69}
70
71const fill = (line: string, values: Record<string, string>) =>
72  line.replace(/\{(\w+)\}/g, (all, name: string) => values[name] ?? all)
73
74/** How the billing cycle compares with the plan, in one line of opinion. */
75export const verdict = (cycleUsd: number, plan: Plan | null, now: number): string => {
76  if (plan === null || plan.usd === null) {
77    return plan?.label === 'API' ? pick(VERDICTS.realMoney, now) : 'the meter is running.'
78  }
79  const ratio = cycleUsd / plan.usd
80  const tier =
81    ratio < 0.1
82      ? VERDICTS.idle
83      : ratio < 0.5
84        ? VERDICTS.warming
85        : ratio < 1
86          ? VERDICTS.close
87          : ratio < 3
88            ? VERDICTS.paidOff
89            : ratio < 10
90              ? VERDICTS.heavy
91              : VERDICTS.absurd
92  return fill(pick(tier, now, 1), {
93    over: usd(Math.max(0, cycleUsd - plan.usd)),
94    left: usd(Math.max(0, plan.usd - cycleUsd)),
95    x: ratio.toFixed(ratio < 10 ? 1 : 0),
96  }).replace('$200', usd(plan.usd))
97}
98
99export const cacheQuip = (saved: number, now: number) => fill(pick(CACHE, now, 2), { s: usd(saved) })
100
101export const emptyQuip = (now: number) => pick(EMPTY, now, 3)
102
103export const readingQuip = (now: number) => pick(READING, now, 4)
104
105export const historyToast = (requests: number, total: number) =>
106  `what-would-it-cost: found ${requests.toLocaleString('en-US')} past requests. At API prices you'd owe ${usd(total)}. You don't. Enjoy.`
107
types/index.d.ts 49 lines
1/** Tokens one model used, as the API bills them. */
2export type Tally = {
3  input: number
4  output: number
5  cacheRead: number
6  /** Cache writes at the 5-minute lifetime (1.25x input). */
7  cacheWrite5m: number
8  /** Cache writes at the 1-hour lifetime (2x input), what Claude Code uses. */
9  cacheWrite1h: number
10  /** Model requests billed (one turn makes several). */
11  turns: number
12}
13
14/** What the person pays per month; `usd` null on pay-as-you-go (the bill is real). */
15export type Plan = { label: string; usd: number | null }
16
17/** The first-run read of the transcripts already on disk. */
18export type History = {
19  status: 'reading' | 'done' | 'failed'
20  filesDone: number
21  files: number
22  requests: number
23}
24
25/** Tallies by local day, then model. */
26export type DayTallies = Record<string, Record<string, Tally>>
27
28/** Which period the invoice shows. */
29export type Tab = 'session' | 'week' | 'cycle' | 'all'
30
31declare module 'claude-code' {
32  interface PluginState {
33    'what-would-it-cost': {
34      /** This session's models, live. */
35      models: Record<string, Tally>
36      /** Tallies by local day, then model: history and live together. */
37      dayModels: DayTallies
38      plan: Plan | null
39      /** Day of the month the plan renews (1-31); null until known. */
40      billingDay: number | null
41      /** When the current 7-day rate-limit window began, from its reset time; null off a subscription. */
42      weekStart: number | null
43      ledgerUsd: number | null
44      history: History | null
45      tab: Tab
46    }
47  }
48}
49