SLOPSHOPPER

usage-meter

Usage meter above the prompt; its popup shows this session's tokens and cost, or the last 24 hours to 90 days of Claude Code and Codex use on this computer

newpanebandcommandprocesstimer
★ 1v1.0.0MITupdated 2026-10-22anthonyamaro15/claude-code-plugins/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ 1: cost 2: tokens 3: limits │ ┃ range-session range-24h range-7d range-30 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ $ ▄▀▀▀▄ ▄█ ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ ⏺ Update(src/auth.ts) │ ┃ █ █ ▄▀ █ ▄▀ █ █ █ █ ⎿ Added 2 lines, removed 1 line │ ┃ █ █ ▀▀▀█▀ ▄▀ █ █ █ █ ⏺ Bash(bun test) │ ┃ ▀▀▀ ▀ ▀ ▀▀▀▀▀ ▀▀▀ ▀▀▀ ⎿ 3 pass, 1 fail │ ┃ 1 turn · 98.9k tokens · $0.84/hr │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Cache read Cache write Input Ou │ ┃ 91.0k 4.30k 2.10k 1. ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Models › /usage-meter │ ┃ # Model S ⎿ usage-meter: Usage popup showing cost for this session. │ ┃ 1 opus-5-5 ━━━━━━━━━━━━━━━━━━━━━━━━━ 10 │ ┃ │ ┃ At API list prices │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ usage ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
usage
Pane · Usage
1: cost 2: tokens 3: limits range-session range-24h range-7d range-30d range-90d $ ▄▀▀▀▄ ▄█ ▄▀▀▀▄ ▄▀▀▀▄ ▄▀▀▀▄ this session █ █ ▄▀ █ ▄▀ █ █ █ █ █ █ ▀▀▀█▀ ▄▀ █ █ █ █ ▀▀▀ ▀ ▀ ▀▀▀▀▀ ▀▀▀ ▀▀▀ 1 turn · 98.9k tokens · $0.84/hr Cache read Cache write Input Output Last 91.0k 4.30k 2.10k 1.48k +$0. Models # Model Share Tokens 1 opus-5-5 ━━━━━━━━━━━━━━━━━━━━━━━━━ 100.0% 98.9k At API list prices
README

Claude Code plugins

English · Español

Two mods for the Claude Code terminal.

  • usage-meter: a token and cost line above the prompt, plus a popup with this session's usage and your last 24 hours to 90 days of Claude Code and Codex use.
  • paste-viewer: hover previews for images you paste, and a popup that shows long pasted prompts in full.

usage-meter's popup docked beside a Claude Code session, switching between 30-day cost, tokens, 90-day activity and limits

Install

At the prompt of a Claude Code terminal session:

/plugin install usage-meter --marketplace anthonyamaro15/claude-code-plugins
/plugin install paste-viewer --marketplace anthonyamaro15/claude-code-plugins

Answer y to add the marketplace, then pick a scope (user scope loads the mod in every session). The mod starts working right away.

From a shell:

claude plugin install usage-meter --marketplace anthonyamaro15/claude-code-plugins
claude plugin install paste-viewer --marketplace anthonyamaro15/claude-code-plugins

To update later: claude plugin update usage-meter (or paste-viewer), then /reload-plugins.

Requirements

  • A recent Claude Code version that supports mods. These were built and tested on 2.1.304 and 2.1.305.
  • The fullscreen terminal UI (tui: fullscreen) is recommended. It docks the usage popup on the right and turns on the hover previews.
  • python3 on your PATH, for usage-meter's 24h to 90d history only. The meter line and the Session view work without it.

usage-meter

A line above the prompt shows this session's tokens and cost. /usage-meter opens the popup:

  • Session: cost, tokens, a per-turn chart and cost by type (input, output, cache reads, cache writes).
  • 24h, 7d, 30d, 90d: history from the session logs on this computer, broken down by model or by day, split between Claude Code and Codex. 90d adds a daily activity calendar.
  • Limits: your 5-hour and weekly rate-limit use, with the pace you're on.

Commands:

CommandWhat it does
/usage-meterOpen or close the popup
/usage-meter cost 30dOpen on a view: cost, tokens or limits, and session, 24h, 7d, 30d or 90d
/usage-meter font blockyHeadline font: smooth, rounded, bold or blocky
/usage-meter bg #0d1117Popup background: a hex color, none, or default

Font and background choices are saved across sessions.

Privacy: history is read only from local files: ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects) and, if Codex is installed, ~/.codex/sessions (or $CODEX_HOME/sessions). Nothing is sent anywhere. Sums are cached in ~/.cache/usage-meter/.

Pricing: costs use the API list prices built into usage-meter/scripts/scan.py, so they show what the same use would cost on the API, not what a subscription charges. Models without a known price are counted as tokens and noted as unpriced.

paste-viewer

  • Pasted images show as a dim "1 image · hover to preview" line under your message. Hover it to see the image.
  • A long pasted prompt (more than 12 lines or 1,200 characters) gets a "View N lines" link under it that opens a popup with the full text. /pastes opens the latest one, and the popup steps through every long prompt pasted this session.

Development

Each mod is a folder with .claude-plugin/plugin.json and a hooks module in hooks/register.tsx. To run one from a local copy:

claude --plugin-dir ./usage-meter

Checks:

claude plugin validate ./usage-meter
claude plugin test ./usage-meter
python3 -I usage-meter/scripts/scan_check.py

License

MIT

Source 2 files
hooks/register.tsx 1025 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Amount, Breakdown, FontName, History, Mode, Provider, RangeId, RangeStats, Scan, Spend, Tokens } from '../types'
5
6const PANE = 'usage-meter'
7const MAX_WIDTH = 72
8const CHART_ROWS = 4
9const POPUP = { id: PANE, title: 'Usage', focus: true, closeOnEscape: true, rows: 30, columns: MAX_WIDTH + 4 } as const
10/** How old a read of the transcripts may get before showing a history range reads them again. */
11const HISTORY_TTL = 5 * 60_000
12/** The `version` scripts/scan.py prints; see OUTPUT_VERSION there. */
13const SCAN_VERSION = 4
14
15const ZERO_TOKENS: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byModel: {} }
16const ZERO_SPEND: Spend = { usd: 0, rateLimits: [] }
17
18const mode = atom({ plugin: 'usage-meter', key: 'mode' } as const, 'cost' as Mode)
19const range = atom({ plugin: 'usage-meter', key: 'range' } as const, 'session' as RangeId)
20const breakdown = atom({ plugin: 'usage-meter', key: 'breakdown' } as const, 'model' as Breakdown)
21const font = atom({ plugin: 'usage-meter', key: 'font' } as const, 'smooth' as FontName)
22const theme = atom({ plugin: 'usage-meter', key: 'theme' } as const, 'dark' as 'dark' | 'light')
23const background = atom({ plugin: 'usage-meter', key: 'background' } as const, '')
24const tokens = atom({ plugin: 'usage-meter', key: 'tokens' } as const, ZERO_TOKENS)
25const spend = atom({ plugin: 'usage-meter', key: 'spend' } as const, ZERO_SPEND)
26const turnMark = atom({ plugin: 'usage-meter', key: 'turnMark' } as const, { usd: 0, tokens: 0 } as Amount)
27const turns = atom({ plugin: 'usage-meter', key: 'turnLog' } as const, [] as Amount[])
28const turnCount = atom({ plugin: 'usage-meter', key: 'turnCount' } as const, 0)
29const startedAt = atom({ plugin: 'usage-meter', key: 'startedAt' } as const, 0)
30const now = atom({ plugin: 'usage-meter', key: 'now' } as const, 0)
31const history = atom({ plugin: 'usage-meter', key: 'history' } as const, null as History | null)
32const scan = atom({ plugin: 'usage-meter', key: 'scan' } as const, { isRunning: false } as Scan)
33
34const MODES: { id: Mode; label: string; hotkey: string }[] = [
35  { id: 'cost', label: 'Cost', hotkey: '1' },
36  { id: 'tokens', label: 'Tokens', hotkey: '2' },
37  { id: 'limits', label: 'Limits', hotkey: '3' },
38]
39const RANGES: { id: RangeId; label: string; title: string; days: number }[] = [
40  { id: 'session', label: 'Session', title: 'this session', days: 0 },
41  { id: '24h', label: '24h', title: 'last 24 hours', days: 1 },
42  { id: '7d', label: '7d', title: 'last 7 days', days: 7 },
43  { id: '30d', label: '30d', title: 'last 30 days', days: 30 },
44  { id: '90d', label: '90d', title: 'last 90 days', days: 90 },
45]
46const LIMIT_NAMES: Record<string, string> = { five_hour: '5-hour limit', seven_day: '7-day limit' }
47const SHORT_LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
48const LIMIT_WINDOWS_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
49const KINDS = [
50  { key: 'cacheRead', label: 'Cache read' },
51  { key: 'cacheWrite', label: 'Cache write' },
52  { key: 'output', label: 'Output' },
53  { key: 'input', label: 'Input' },
54] as const
55const UNITS = { hour: ['Hour', 'Hourly'], day: ['Day', 'Daily'], week: ['Week', 'Weekly'] } as const
56const PROVIDER_NAMES: Record<Provider, string> = { claude: 'Claude Code', codex: 'Codex' }
57/** Claude Code in Claude's orange, Codex in the text color, as T3 draws them; theme keys, so they follow the theme. */
58const PROVIDER_COLORS: Record<Provider, string> = { claude: 'claude', codex: 'text' }
59/**
60 * Raw colors, by theme, where no theme key fits. The dark surface sits near black, below the engine's
61 * pane gray; light keeps the engine's. Kinds of token are neutral steps, output the strongest, so they
62 * never pass for a provider or a status; the calendar is one blue ramp from idle to busiest. Status
63 * colors stay with the limits.
64 */
65const PALETTES = {
66  dark: {
67    surface: '#141414',
68    kinds: { input: '#5e5e5e', cacheRead: '#8c8c8c', cacheWrite: '#b8b8b8', output: '#ececec' },
69    calendar: ['#363636', '#1f4677', '#2a69bd', '#4b93ee', '#a2ccfb'],
70  },
71  light: {
72    surface: undefined,
73    kinds: { input: '#c9c9c9', cacheRead: '#9b9b9b', cacheWrite: '#6b6b6b', output: '#2e2e2e' },
74    calendar: ['#ebedf0', '#b7d3f6', '#6da7ec', '#2a78d6', '#184f95'],
75  },
76} as const
77/** The activity calendar's rows, Sunday first, labeled like GitHub's. */
78const WEEKDAYS = ['', 'Mon', '', 'Wed', '', 'Fri', '']
79/**
80 * The headline fonts, for digits, `,` and `.`. A pixel font's glyphs are rows of `#`, drawn two rows to
81 * a line in half blocks; a line font's glyphs are their lines as drawn.
82 */
83const FONTS: Record<FontName, { pixels: Record<string, string[]> } | { lines: Record<string, string[]> }> = {
84  smooth: {
85    pixels: {
86      '0': ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
87      '1': ['..#..', '.##..', '..#..', '..#..', '..#..', '..#..', '.###.'],
88      '2': ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'],
89      '3': ['#####', '...#.', '..#..', '...#.', '....#', '#...#', '.###.'],
90      '4': ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'],
91      '5': ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'],
92      '6': ['..##.', '.#...', '#....', '####.', '#...#', '#...#', '.###.'],
93      '7': ['#####', '....#', '...#.', '..#..', '.#...', '.#...', '.#...'],
94      '8': ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'],
95      '9': ['.###.', '#...#', '#...#', '.####', '....#', '...#.', '.##..'],
96      ',': ['..', '..', '..', '..', '..', '.#', '#.'],
97      '.': ['.', '.', '.', '.', '.', '.', '#'],
98    },
99  },
100  rounded: {
101    lines: {
102      '0': ['╭─╮', '│ │', '╰─╯'],
103      '1': ['╶╮ ', ' │ ', '╶┴╴'],
104      '2': ['╶─╮', '╭─╯', '╰─╴'],
105      '3': ['╶─╮', ' ─┤', '╶─╯'],
106      '4': ['╷ ╷', '╰─┤', '  ╵'],
107      '5': ['╭─╴', '╰─╮', '╶─╯'],
108      '6': ['╭─╴', '├─╮', '╰─╯'],
109      '7': ['╶─╮', '  │', '  ╵'],
110      '8': ['╭─╮', '├─┤', '╰─╯'],
111      '9': ['╭─╮', '╰─┤', '╶─╯'],
112      ',': [' ', ' ', '╯'],
113      '.': [' ', ' ', '•'],
114    },
115  },
116  bold: {
117    lines: {
118      '0': ['┏━┓', '┃ ┃', '┗━┛'],
119      '1': ['╺┓ ', ' ┃ ', '╺┻╸'],
120      '2': ['╺━┓', '┏━┛', '┗━╸'],
121      '3': ['╺━┓', ' ━┫', '╺━┛'],
122      '4': ['╻ ╻', '┗━┫', '  ╹'],
123      '5': ['┏━╸', '┗━┓', '╺━┛'],
124      '6': ['┏━╸', '┣━┓', '┗━┛'],
125      '7': ['╺━┓', '  ┃', '  ╹'],
126      '8': ['┏━┓', '┣━┫', '┗━┛'],
127      '9': ['┏━┓', '┗━┫', '╺━┛'],
128      ',': [' ', ' ', '┛'],
129      '.': [' ', ' ', '•'],
130    },
131  },
132  blocky: {
133    pixels: {
134      '0': ['###', '#.#', '#.#', '#.#', '###'],
135      '1': ['.#.', '##.', '.#.', '.#.', '###'],
136      '2': ['###', '..#', '###', '#..', '###'],
137      '3': ['###', '..#', '###', '..#', '###'],
138      '4': ['#.#', '#.#', '###', '..#', '..#'],
139      '5': ['###', '#..', '###', '..#', '###'],
140      '6': ['###', '#..', '###', '#.#', '###'],
141      '7': ['###', '..#', '..#', '..#', '..#'],
142      '8': ['###', '#.#', '###', '#.#', '###'],
143      '9': ['###', '#.#', '###', '..#', '###'],
144      ',': ['..', '..', '..', '.#', '#.'],
145      '.': ['.', '.', '.', '.', '#'],
146    },
147  },
148}
149const HALF_BLOCKS = [' ', '▄', '▀', '█']
150const BLOCKS = ' ▁▂▃▄▅▆▇█'
151const SCALES = [[1e9, 'B'], [1e6, 'M'], [1e3, 'k']] as const
152
153/** Three significant digits: `52.1k`, `114M`, `6.00B`. */
154function count(n: number): string {
155  const scale = SCALES.find(([size]) => n >= size)
156  if (!scale) return String(Math.round(n))
157
158  const v = n / scale[0]
159  return `${v.toFixed(Math.max(0, 2 - Math.floor(Math.log10(v))))}${scale[1]}`
160}
161
162function dollars(n: number): string {
163  if (n < 1) return `$${n.toFixed(4)}`
164  return `$${n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
165}
166
167function limitColor(percent: number): string {
168  if (percent >= 80) return 'error'
169  if (percent >= 50) return 'warning'
170  return 'success'
171}
172
173function sum(t: Omit<Tokens, 'byModel'>): number {
174  return t.input + t.output + t.cacheRead + t.cacheWrite
175}
176
177/** `claude-haiku-4-5-20251001` as `haiku-4-5`. */
178function modelName(model: string): string {
179  return model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
180}
181
182/** `$0.50`, `$210`, `$2.0k`: short enough for a chart's scale. */
183function shortDollars(n: number): string {
184  if (n >= 1000) return `$${(n / 1000).toFixed(1)}k`
185  if (n >= 10) return `$${Math.round(n)}`
186  return `$${n.toFixed(2)}`
187}
188
189/** A `theme` config value as the palette to draw with: `light`, `light-daltonized` and the like are light. */
190function themeOf(value: unknown): 'dark' | 'light' {
191  return String(value ?? '').startsWith('light') ? 'light' : 'dark'
192}
193
194/** One glyph's lines: a line font's as drawn, a pixel font's two pixel rows to a line in half blocks. */
195function glyphLines(name: FontName, ch: string): string[] | undefined {
196  const face = FONTS[name]
197  if ('lines' in face) return face.lines[ch]
198
199  const rows = face.pixels[ch]
200  if (!rows) return undefined
201
202  const pixel = (row: number, x: number) => rows[row]?.[x] === '#'
203  return Array.from({ length: Math.ceil(rows.length / 2) }, (_, line) =>
204    [...(rows[0] ?? '')].map((_, x) => HALF_BLOCKS[(pixel(line * 2, x) ? 2 : 0) + (pixel(line * 2 + 1, x) ? 1 : 0)]).join(''),
205  )
206}
207
208/** `text` (digits, `,` and `.`) in a headline font, a column apart; undefined when a character has no glyph. */
209function bigText(text: string, name: FontName): string[] | undefined {
210  const glyphs: string[][] = []
211  for (const ch of text) {
212    const glyph = glyphLines(name, ch)
213    if (!glyph) return undefined
214    glyphs.push(glyph)
215  }
216
217  const height = Math.max(0, ...glyphs.map(g => g.length))
218  return Array.from({ length: height }, (_, line) => glyphs.map(g => g[line] ?? ' '.repeat(g[0]?.length ?? 0)).join(' '))
219}
220
221/** `n` as a percent of `total`, one decimal; `<0.1%` for a sliver. */
222function share(n: number, total: number): string {
223  if (total <= 0 || n <= 0) return '0%'
224  const percent = (n / total) * 100
225  return percent < 0.1 ? '<0.1%' : `${percent.toFixed(1)}%`
226}
227
228/** Splits `cells` among `values` (largest first) in proportion, every non-zero value getting at least one. */
229function allot(values: number[], cells: number): number[] {
230  const total = values.reduce((a, v) => a + v, 0)
231  const out = values.map(v => (v > 0 ? Math.max(1, Math.floor((v / total) * cells)) : 0))
232  out[0] = (out[0] ?? 0) + cells - out.reduce((a, n) => a + n, 0)
233  return out
234}
235
236/** A line of `width` with each label at its column, dropping any that would touch the one before. */
237function ticks(marks: { at: number; text: string }[], width: number): string {
238  let line = ''
239  for (const { at, text } of marks) {
240    const col = Math.max(0, Math.min(width - text.length, at))
241    if (line === '' || col >= line.length + 2) line = line.padEnd(col) + text
242  }
243  return line
244}
245
246/** `3d 4h`, `2h 14m` or `9m`. */
247function duration(ms: number): string {
248  const mins = Math.max(0, Math.round(ms / 60_000))
249  if (mins >= 48 * 60) return `${Math.floor(mins / 1440)}d ${Math.floor((mins % 1440) / 60)}h`
250  if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`
251  return `${mins}m`
252}
253
254/** How long until `resetsAt`; undefined when unknown. */
255function until(resetsAt: string | undefined, nowMs: number): string | undefined {
256  const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
257  if (Number.isNaN(at) || nowMs === 0) return undefined
258
259  return duration(at - nowMs)
260}
261
262/**
263 * Where a limit lands by its reset if use keeps the rate it has had this window, and how long until
264 * it runs out at that rate; undefined for an unknown window or one too young to tell.
265 */
266function pace(limit: Spend['rateLimits'][number], nowMs: number): { percent: number; outInMs: number } | undefined {
267  const windowMs = LIMIT_WINDOWS_MS[limit.kind]
268  const at = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
269  if (windowMs === undefined || Number.isNaN(at) || nowMs === 0) return undefined
270
271  const elapsed = windowMs - (at - nowMs)
272  if (elapsed < windowMs * 0.05 || limit.percentUsed <= 0) return undefined
273
274  const perMs = limit.percentUsed / elapsed
275  return { percent: perMs * windowMs, outInMs: (100 - limit.percentUsed) / perMs }
276}
277
278/** Sums this computer's transcripts through `scripts/scan.py`, unless a fresh read is in hand or under way. */
279async function refreshHistory($: EngineInterface): Promise<void> {
280  const last = await read($, history)
281  const isFresh = last?.version === SCAN_VERSION && (await $.clock.now()) - last.scannedAt < HISTORY_TTL
282  if (isFresh || (await read($, scan)).isRunning) return
283
284  await update($, scan, () => ({ isRunning: true }))
285  try {
286    const run = await $.process.run(['python3', '-I', `${$.plugin.root}/scripts/scan.py`], { timeoutMs: 120_000 })
287    if (run.exitCode !== 0) throw new Error(run.stderr.trim().split('\n').pop() || `scan.py exited with ${run.exitCode}`)
288
289    const result = JSON.parse(run.stdout) as History
290    await update($, history, () => result)
291    await update($, scan, () => ({ isRunning: false }))
292  } catch (err) {
293    await update($, scan, () => ({ isRunning: false, error: err instanceof Error ? err.message : String(err) }))
294  }
295}
296
297async function pickRange($: EngineInterface, id: RangeId): Promise<void> {
298  await update($, range, () => id)
299  if (id !== 'session') await refreshHistory($)
300}
301
302/** Opens the popup; a history range on show gets a fresh read in the background. */
303async function openPopup($: EngineInterface): Promise<void> {
304  await $.ui.open(POPUP)
305  if ((await read($, range)) !== 'session') void refreshHistory($)
306}
307
308/** Opens the popup, or closes it when it is already open; resolves whether it is open now. */
309async function togglePopup($: EngineInterface): Promise<boolean> {
310  if ((await $.ui.panes()).some(p => p.id === PANE)) {
311    await $.ui.close({ id: PANE })
312    return false
313  }
314  await openPopup($)
315  return true
316}
317
318export const register: Register = on => {
319  on('session.start', async ($, e, next) => {
320    await $.command.register({
321      name: 'usage-meter',
322      description: 'Toggle the usage popup; pass cost, tokens or limits and session, 24h, 7d, 30d or 90d to open on that view; font smooth, rounded, bold or blocky; bg #hex, none or default',
323    })
324
325    // Raw colors follow the theme by hand, and the headline font is kept across sessions.
326    const rows = await $.config.list().catch(() => [])
327    await update($, theme, () => themeOf(rows.find(row => row.key === 'theme')?.value))
328    const saved = await $.store.get('font').catch(() => undefined)
329    if (typeof saved === 'string' && saved in FONTS) await update($, font, () => saved as FontName)
330    const savedBackground = await $.store.get('background').catch(() => undefined)
331    if (typeof savedBackground === 'string') await update($, background, () => savedBackground)
332
333    // Seed cost from the ledger so a resumed session isn't $0 until its next turn.
334    const usage = await $.session.usage()
335    await update($, spend, s => ({ ...s, usd: usage.cost?.usd ?? s.usd, context: usage.context, rateLimits: usage.rateLimits }))
336    await update($, startedAt, () => usage.startedAt)
337    const total = sum(await read($, tokens))
338    await update($, turnMark, () => ({ usd: usage.cost?.usd ?? 0, tokens: total }))
339
340    // A minute tick keeps the reset countdowns and the spend rate current while nothing else redraws.
341    const t = await $.clock.now()
342    await update($, now, () => t)
343    $.clock.every(60_000, () => void $.clock.now().then(ms => update($, now, () => ms)))
344
345    return next(e)
346  })
347
348  on('config.set', { key: 'theme' }, async ($, e, next) => {
349    const result = await next(e)
350    if (result.deny === undefined) await update($, theme, () => themeOf(result.value))
351    return result
352  })
353
354  on('command.run', { command: 'usage-meter' }, async ($, e) => {
355    const words = e.args.trim().split(/\s+/)
356    if (words[0] === 'font') {
357      const picked = words[1]
358      if (picked === undefined || !(picked in FONTS)) return { text: `Fonts: ${Object.keys(FONTS).join(', ')}. Usage: /usage-meter font rounded` }
359
360      await update($, font, () => picked as FontName)
361      await $.store.set('font', picked).catch(() => {})
362      await openPopup($)
363      return { text: `Headline font: ${picked}.` }
364    }
365    if (words[0] === 'bg') {
366      const picked = words[1]?.toLowerCase() ?? ''
367      if (!/^(#[0-9a-f]{6}|none|default)$/.test(picked)) {
368        return { text: 'Usage: /usage-meter bg #141414 (any hex), none for Claude Code\'s own gray, or default.' }
369      }
370
371      const value = picked === 'default' ? '' : picked
372      await update($, background, () => value)
373      await $.store.set('background', value).catch(() => {})
374      await openPopup($)
375      return { text: `Background: ${picked}.` }
376    }
377
378    const pickedMode = MODES.find(m => words.includes(m.id))
379    const pickedRange = RANGES.find(r => words.includes(r.id))
380
381    if (pickedMode || pickedRange) {
382      if (pickedMode) await update($, mode, () => pickedMode.id)
383      if (pickedRange) await update($, range, () => pickedRange.id)
384      await openPopup($)
385    } else if (!(await togglePopup($))) {
386      return { text: 'Usage popup closed.' }
387    }
388
389    const shown = await read($, range)
390    return { text: `Usage popup showing ${await read($, mode)} for ${RANGES.find(r => r.id === shown)?.title ?? shown}.` }
391  })
392
393  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
394    if (e.props.hasSurvey) return next(e)
395
396    const { Button, Text } = $.ui.resolve(e)
397    const t = await read($, tokens)
398    const s = await read($, spend)
399    const nowMs = await read($, now)
400    const percent = s.context?.percent
401    const sep = () => <Text dimColor>{'  ·  '}</Text>
402    const items = [
403      <Text color="claude">{'◆ '}</Text>,
404      <Text>{count(sum(t))}</Text>,
405      <Text dimColor> tokens</Text>,
406      sep(),
407      <Text color="success">{dollars(s.usd)}</Text>,
408    ]
409
410    if (percent !== undefined) {
411      const filled = Math.round(Math.min(100, percent) / 10)
412      items.push(
413        sep(),
414        <Text dimColor>ctx </Text>,
415        <Text color={limitColor(percent)}>{'━'.repeat(filled)}</Text>,
416        <Text dimColor>{'─'.repeat(10 - filled)}</Text>,
417        <Text>{` ${percent}%`}</Text>,
418      )
419    }
420    for (const r of s.rateLimits) {
421      const left = until(r.resetsAt, nowMs)
422      items.push(sep(), <Text dimColor>{`${SHORT_LIMIT_NAMES[r.kind] ?? r.kind} `}</Text>)
423      items.push(<Text color={limitColor(r.percentUsed)}>{`${r.percentUsed}%`}</Text>)
424      if (left) items.push(<Text dimColor>{` ${left}`}</Text>)
425    }
426
427    return (
428      <Button key="usage" plain onPress={() => void togglePopup($)}>
429        {items}
430      </Button>
431    )
432  })
433
434  on('turn.complete', async ($, e, next) => {
435    // Every loop's end is heard here, subagents' too, so the sum covers them.
436    const u = e.usage
437    if (u) {
438      const all = u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
439      await update($, tokens, t => ({
440        input: t.input + u.input_tokens,
441        output: t.output + u.output_tokens,
442        cacheRead: t.cacheRead + u.cache_read_input_tokens,
443        cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
444        byModel: { ...t.byModel, [u.model]: (t.byModel[u.model] ?? 0) + all },
445      }))
446    }
447    if (e.agentId === undefined) {
448      await update($, turnCount, n => n + 1)
449
450      // The ledger reports cost after every step; a turn's share is what it grew by since the last
451      // turn ended, its tool calls and subagents included. A total below the mark is a fresh ledger.
452      const usd = (await $.session.usage()).cost?.usd ?? (await read($, spend)).usd
453      const total = sum(await read($, tokens))
454      const mark = await read($, turnMark)
455      const turn = { usd: usd >= mark.usd ? usd - mark.usd : usd, tokens: Math.max(0, total - mark.tokens) }
456      await update($, turns, list => [...list, turn].slice(-MAX_WIDTH))
457      await update($, turnMark, () => ({ usd, tokens: total }))
458    }
459
460    return next(e)
461  })
462
463  on('session.measure', async ($, e, next) => {
464    await update($, spend, s => ({ usd: e.cost?.usd ?? s.usd, context: e.context, rateLimits: e.rateLimits }))
465    return next(e)
466  })
467
468  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
469    const { Box, Text, Button } = $.ui.resolve(e)
470    const metric = await read($, mode)
471    const picked = await read($, range)
472    const listing = await read($, breakdown)
473    const t = await read($, tokens)
474    const s = await read($, spend)
475    const turnList = await read($, turns)
476    const turnTotal = await read($, turnCount)
477    const began = await read($, startedAt)
478    const nowMs = await read($, now)
479    const kept = await read($, history)
480    const last = kept?.version === SCAN_VERSION ? kept : null
481    const scanState = await read($, scan)
482    const width = Math.max(24, Math.min(MAX_WIDTH, e.props.bodyColumns))
483    const isCost = metric === 'cost'
484    const show = isCost ? dollars : count
485    const pick = (a: Amount) => (isCost ? a.usd : a.tokens)
486    const palette = PALETTES[await read($, theme)]
487    // `/usage-meter bg` wins over the palette; `none` leaves the engine's own gray.
488    const chosen = await read($, background)
489    let surface: string | undefined = chosen
490    if (chosen === '') surface = palette.surface
491    else if (chosen === 'none') surface = undefined
492    const face = await read($, font)
493    const providerColor = (provider?: Provider) => PROVIDER_COLORS[provider ?? 'claude']
494    const span = RANGES.find(r => r.id === picked)
495    const stats = picked === 'session' ? undefined : last?.ranges[picked]
496
497    const heading = (label: string, right?: string) => (
498      <Box justifyContent="space-between">
499        <Text weight="bold">{label}</Text>
500        {right && <Text dimColor>{right}</Text>}
501      </Box>
502    )
503
504    const tab = (key: string, label: string, isActive: boolean, onPress: () => void, hotkey?: string) => (
505      <Button key={key} plain {...(hotkey ? { hotkey } : {})} onPress={onPress}>
506        <Text weight={isActive ? 'bold' : 'regular'} dimColor={!isActive}>{label}</Text>
507      </Button>
508    )
509
510    /** Used so far in `color`, then up to `projected` in a dashed line, then the rest. */
511    const meter = (percent: number, color: string, projected = 0) => {
512      const cells = (p: number) => Math.round((Math.min(100, Math.max(0, p)) / 100) * width)
513      const filled = cells(percent)
514      const ahead = Math.max(0, cells(projected) - filled)
515
516      return (
517        <Text>
518          <Text color={color}>{'━'.repeat(filled)}</Text>
519          <Text color={color} dimColor>{'╌'.repeat(ahead)}</Text>
520          <Text dimColor>{'─'.repeat(width - filled - ahead)}</Text>
521        </Text>
522      )
523    }
524
525    /**
526     * The headline in the picked font when it fits beside `right`, in the text color as T3 draws it: a
527     * price's `$` small at the top like a price tag, `suffix` small on the baseline; captions under.
528     */
529    const hero = (big: string, suffix: string, right: string, caption: string, extra?: RenderChildren) => {
530      const isPrice = big.startsWith('$')
531      const lines = bigText(isPrice ? big.slice(1) : big, face)
532      const lead = isPrice ? 2 : 0
533      const fits = lines !== undefined && lead + (lines[0] ?? '').length + suffix.length + right.length + 2 <= width
534
535      return (
536        <Box flexDirection="column">
537          <Box justifyContent="space-between">
538            {fits ? (
539              <Box>
540                {isPrice && <Text dimColor>$ </Text>}
541                <Box flexDirection="column">
542                  {lines.map((line, i) => (
543                    <Text>
544                      <Text weight="bold">{line}</Text>
545                      {i === lines.length - 1 && suffix && <Text dimColor>{suffix}</Text>}
546                    </Text>
547                  ))}
548                </Box>
549              </Box>
550            ) : (
551              <Text>
552                <Text weight="bold">{big}</Text>
553                {suffix && <Text dimColor>{suffix}</Text>}
554              </Text>
555            )}
556            <Text dimColor>{right}</Text>
557          </Box>
558          <Text dimColor>{caption}</Text>
559          {extra}
560        </Box>
561      )
562    }
563
564    /**
565     * Bars oldest to newest across the full width, on a scale from 0 to the peak; hovering a bar shows
566     * its value above it. Bars keep a gap, so a long list shows only its newest that fit.
567     */
568    const chart = (items: (Amount & { label?: string; byProvider?: Record<Provider, Amount> })[], title: string, ends?: [string, string]) => {
569      if (items.length < 2) return null
570
571      const scale = (n: number) => (isCost ? shortDollars(n) : count(n))
572      const gutter = scale(Math.max(...items.map(pick))).length
573      const room = width - gutter - 2
574      const shown = items.slice(-Math.floor((room + 1) / 2))
575      const values = shown.map(pick)
576      const peak = Math.max(...values)
577      const top = scale(peak)
578      const barWidth = Math.max(1, Math.floor((room + 1) / shown.length) - 1)
579      const left = (i: number) => Math.round((i * (room - barWidth)) / (shown.length - 1))
580      const mid = Math.floor((shown.length - 1) / 2)
581      const midLabel = shown[mid]?.label ?? ''
582      const oldest = shown.length < items.length ? `${shown.length - 1} turns ago` : ends?.[0] ?? ''
583      const marks = ends
584        ? [{ at: 0, text: oldest }, { at: room, text: ends[1] }]
585        : [
586            { at: 0, text: shown[0]?.label ?? '' },
587            { at: Math.round(left(mid) + (barWidth - midLabel.length) / 2), text: midLabel },
588            { at: room, text: shown.at(-1)?.label ?? '' },
589          ]
590
591      return (
592        <Box flexDirection="column">
593          {heading(title, 'hover for values')}
594          <Box>
595            <Box flexDirection="column">
596              {Array.from({ length: CHART_ROWS }, (_, r) => (
597                <Text dimColor>{`${(r === 0 ? top : '').padStart(gutter)} ${r === 0 ? '┤' : '│'}`}</Text>
598              ))}
599            </Box>
600            <Box width={room} justifyContent="space-between">
601              {shown.map((item, i) => {
602                const v = values[i] ?? 0
603                const codex = item.byProvider ? pick(item.byProvider.codex) : 0
604                // In eighths of a row: anything above zero gets at least a sliver, so small values stay visible.
605                const eighths = (n: number) => (peak > 0 && n > 0 ? Math.max(1, Math.round((n / peak) * CHART_ROWS * 8)) : 0)
606                const level = eighths(v)
607                const below = codex > 0 ? Math.min(level, eighths(v - codex)) : level
608                const isRightHalf = i >= shown.length / 2
609                let hover = item.label ? ` ${item.label} · ${show(v)} ` : ` ${show(v)} `
610                if (codex > 0) hover += `· Codex ${show(codex)} `
611
612                return (
613                  <Box key={`bar-${i}`} flexDirection="column">
614                    {Array.from({ length: CHART_ROWS }, (_, r) => {
615                      const floor = (CHART_ROWS - 1 - r) * 8
616                      const claude = Math.min(8, Math.max(0, below - floor))
617                      const total = Math.min(8, Math.max(0, level - floor))
618                      // Claude Code fills from the bottom; Codex stacks on top, behind a partial cell as its background.
619                      if (claude === 0) return <Text color={providerColor('codex')}>{(BLOCKS[total] ?? ' ').repeat(barWidth)}</Text>
620
621                      return (
622                        <Text color={providerColor('claude')} {...(total > claude ? { backgroundColor: providerColor('codex') } : {})}>
623                          {(BLOCKS[claude] ?? ' ').repeat(barWidth)}
624                        </Text>
625                      )
626                    })}
627                    <Box
628                      position="absolute"
629                      top={-1}
630                      {...(isRightHalf ? { right: 0 } : { left: 0 })}
631                      display="none"
632                      hover={{ display: 'flex' }}
633                    >
634                      <Text inverse>{hover}</Text>
635                    </Box>
636                  </Box>
637                )
638              })}
639            </Box>
640          </Box>
641          <Text dimColor>{`${'0'.padStart(gutter)} └${'─'.repeat(room)}`}</Text>
642          <Text dimColor>{' '.repeat(gutter + 2) + ticks(marks, room)}</Text>
643        </Box>
644      )
645    }
646
647    /** A row of label-over-value cells that wraps on a narrow pane. */
648    const statRow = (cells: [string, string][]) => {
649      const perRow = Math.max(1, Math.min(cells.length, Math.floor(width / 12)))
650
651      return (
652        <Box flexWrap="wrap">
653          {cells.map(([label, value]) => (
654            <Box width={Math.floor(width / perRow)} flexDirection="column">
655              <Text dimColor>{label}</Text>
656              <Text weight="bold">{value}</Text>
657            </Box>
658          ))}
659        </Box>
660      )
661    }
662
663    /** One bar across the pane split by kind of token, with a legend under it. */
664    const byType = (title: string, amounts: Omit<Tokens, 'byModel'>, format: (n: number) => string) => {
665      const parts = KINDS.map(k => ({ ...k, n: amounts[k.key], color: palette.kinds[k.key] }))
666        .filter(p => p.n > 0)
667        .sort((a, b) => b.n - a.n)
668      if (parts.length === 0) return null
669
670      // A column of surface between segments keeps neighboring steps apart.
671      const cells = allot(parts.map(p => p.n), width - (parts.length - 1))
672
673      return (
674        <Box flexDirection="column">
675          {heading(title)}
676          <Text>
677            {parts.map((p, i) => (
678              <Text color={p.color}>{(i > 0 ? ' ' : '') + '█'.repeat(cells[i] ?? 0)}</Text>
679            ))}
680          </Text>
681          <Box flexWrap="wrap">
682            {parts.map(p => (
683              <Box paddingRight={3}>
684                <Text>
685                  <Text color={p.color}>■ </Text>
686                  <Text dimColor>{`${p.label} `}</Text>
687                  <Text>{format(p.n)}</Text>
688                </Text>
689              </Box>
690            ))}
691          </Box>
692        </Box>
693      )
694    }
695
696    type Row = { name: string; tokens: number; usd?: number; provider?: Provider }
697
698    /** `#  name  ━━━━━━  $2,097.00  80.7%  4.91B`; the line is relative to the largest row. */
699    const table = (label: string, rows: Row[], isRanked: boolean) => {
700      const hasCost = rows.some(r => r.usd !== undefined)
701      const amount = (r: Row) => (isCost && hasCost ? (r.usd ?? 0) : r.tokens)
702      const ordered = isRanked ? [...rows].sort((a, b) => amount(b) - amount(a)) : rows
703      const total = ordered.reduce((a, r) => a + amount(r), 0)
704      const top = Math.max(0, ...ordered.map(amount))
705      const lead = isRanked ? 4 : 0
706      const nameWidth = Math.min(19, Math.max(label.length, ...ordered.map(r => r.name.length)) + 2)
707      const costWidth = hasCost ? 11 : 0
708      const lineWidth = width - lead - nameWidth - costWidth - 7 - 8
709
710      return (
711        <Box flexDirection="column">
712          <Text dimColor>
713            {(isRanked ? ' #  ' : '') +
714              label.padEnd(nameWidth) +
715              ' '.repeat(Math.max(0, lineWidth)) +
716              (hasCost ? 'Cost'.padStart(costWidth) : '') +
717              'Share'.padStart(7) +
718              'Tokens'.padStart(8)}
719          </Text>
720          {ordered.map((r, i) => {
721            const cells = top > 0 ? Math.max(1, Math.round((amount(r) / top) * (lineWidth - 2))) : 0
722            let cost = ''
723            if (r.usd !== undefined) cost = r.usd > 0 || r.tokens === 0 ? dollars(r.usd) : 'unpriced'
724
725            return (
726              <Text>
727                {isRanked && <Text dimColor>{`${String(i + 1).padStart(2)}  `}</Text>}
728                <Text>{r.name.slice(0, nameWidth - 1).padEnd(nameWidth)}</Text>
729                {lineWidth > 2 && <Text color={providerColor(r.provider)}>{'━'.repeat(amount(r) > 0 ? cells : 0).padEnd(lineWidth)}</Text>}
730                {hasCost && <Text>{cost.padStart(costWidth)}</Text>}
731                <Text dimColor>{share(amount(r), total).padStart(7)}</Text>
732                <Text>{count(r.tokens).padStart(8)}</Text>
733              </Text>
734            )
735          })}
736        </Box>
737      )
738    }
739
740    const breakdownOf = (st: RangeStats) => {
741      const [unitName] = UNITS[st.unit]
742      const byTime = listing === 'time'
743      const rows = byTime
744        ? [...st.series].reverse().filter(b => b.tokens > 0).map(b => ({ name: b.label, tokens: b.tokens, usd: b.usd }))
745        : st.models.map(m => ({ name: modelName(m.model), tokens: m.tokens, usd: m.usd, provider: m.provider }))
746
747      return (
748        <Box flexDirection="column">
749          <Box justifyContent="space-between">
750            <Text weight="bold">Breakdown</Text>
751            <Box gap={2}>
752              {tab('by-model', 'Model', !byTime, () => void update($, breakdown, () => 'model'))}
753              {tab('by-time', unitName, byTime, () => void update($, breakdown, () => 'time'))}
754            </Box>
755          </Box>
756          {table(byTime ? unitName : 'Model', rows, !byTime)}
757        </Box>
758      )
759    }
760
761    /** One line a provider, T3-style, once more than one has use in the span. */
762    const providerRows = (st: RangeStats) => {
763      const used = st.providers.filter(p => p.tokens > 0)
764      if (used.length < 2) return null
765
766      const total = used.reduce((a, p) => a + pick(p), 0)
767
768      return (
769        <Box flexDirection="column" marginTop={1}>
770          {used.map(p => (
771            <Box justifyContent="space-between">
772              <Text>
773                <Text color={providerColor(p.id)}>● </Text>
774                <Text>{PROVIDER_NAMES[p.id].padEnd(13)}</Text>
775                <Text dimColor>{`${p.sessions} ${p.sessions === 1 ? 'session' : 'sessions'}`.padEnd(14)}</Text>
776                <Text dimColor>{`${share(pick(p), total).padStart(5)} of ${isCost ? `cost · ${count(p.tokens)} tokens` : `tokens · ${dollars(p.usd)}`}`}</Text>
777              </Text>
778              <Text weight="bold">{show(pick(p))}</Text>
779            </Box>
780          ))}
781        </Box>
782      )
783    }
784
785    /**
786     * GitHub-style: a column a week, a row a weekday, each day a square shaded by its quartile among
787     * active days. A square is two cells wide and one row tall; odd weekdays straddle two rows in half
788     * blocks, so the gaps run half a row and a cell, even both ways. Its highlights sit beside it.
789     */
790    const activity = (days: (Amount & { label: string })[]) => {
791      const weeks = Math.ceil(days.length / 7)
792      const values = days.map(pick)
793      const active = values.filter(v => v > 0).sort((a, b) => a - b)
794      const cuts = [0.25, 0.5, 0.75].map(q => active[Math.floor(q * active.length)] ?? 0)
795      const shade = (v: number) => (v > 0 ? 1 + cuts.filter(c => v > c).length : 0)
796      const square = (d: number, color: string, isBlank = false) => {
797        const draw = (glyph: string) => (isBlank ? <Text>{'  '}</Text> : <Text color={color}>{glyph.repeat(2)}</Text>)
798        return d % 2 === 0 ? [draw('█')] : [draw('▄'), draw('▀')]
799      }
800      // A month's name goes over its first week to start on one of its Sundays, as GitHub's does; the
801      // first column's month, when it is only a tail, gives way to the next one's.
802      const months = Array.from({ length: weeks }, (_, w) => {
803        const [month = '', date = ''] = days[w * 7]?.label.split(' ') ?? []
804        return Number(date) <= 7 || (w === 0 && Number(days[7]?.label.split(' ')[1]) > 7) ? { at: w * 3, text: month } : undefined
805      }).filter(m => m !== undefined)
806
807      const peak = Math.max(0, ...values)
808      const busiest = days[values.indexOf(peak)]
809      let streak = 0
810      // Today still at zero doesn't break a streak that ran through yesterday.
811      for (let i = values.length - ((values.at(-1) ?? 0) > 0 ? 1 : 2); i >= 0 && (values[i] ?? 0) > 0; i--) streak++
812      const highlights: [string, string][] = [
813        ['Busiest day', busiest && peak > 0 ? `${busiest.label} · ${show(peak)}` : '—'],
814        ['Active days', `${active.length} of ${days.length}`],
815        ['Streak', `${streak} ${streak === 1 ? 'day' : 'days'}`],
816        ['Average active day', active.length > 0 ? show(active.reduce((a, v) => a + v, 0) / active.length) : '—'],
817      ]
818
819      return (
820        <Box flexDirection="column">
821          <Box justifyContent="space-between">
822            <Text weight="bold">Activity</Text>
823            <Text>
824              <Text dimColor>less </Text>
825              {palette.calendar.map(color => (
826                <Text color={color}>{'██ '}</Text>
827              ))}
828              <Text dimColor>more</Text>
829            </Text>
830          </Box>
831          <Text dimColor>{'    ' + ticks(months, weeks * 3)}</Text>
832          <Box flexWrap="wrap" columnGap={4}>
833            <Box>
834              <Box flexDirection="column" width={4}>
835                {WEEKDAYS.flatMap((name, d) => {
836                  // A blank Text draws no row, so every row holds at least a space.
837                  const label = <Text dimColor>{name || ' '}</Text>
838                  return d % 2 === 0 ? [label] : [label, <Text> </Text>]
839                })}
840              </Box>
841              <Box gap={1}>
842                {Array.from({ length: weeks }, (_, w) => (
843                  <Box flexDirection="column">
844                    {WEEKDAYS.map((_, d) => {
845                      const i = w * 7 + d
846                      const day = days[i]
847                      if (!day) return <Box flexDirection="column">{square(d, '', true)}</Box>
848
849                      const v = pick(day)
850
851                      return (
852                        <Box key={`day-${i}`} flexDirection="column">
853                          {square(d, palette.calendar[shade(v)] ?? '')}
854                          <Box
855                            position="absolute"
856                            top={-1}
857                            {...(w >= weeks / 2 ? { right: 0 } : { left: 0 })}
858                            display="none"
859                            hover={{ display: 'flex' }}
860                          >
861                            <Text inverse>{` ${day.label} · ${show(v)} `}</Text>
862                          </Box>
863                        </Box>
864                      )
865                    })}
866                  </Box>
867                ))}
868              </Box>
869            </Box>
870            <Box flexDirection="column" gap={1}>
871              {highlights.map(([label, value]) => (
872                <Box flexDirection="column">
873                  <Text dimColor>{label}</Text>
874                  <Text>{value}</Text>
875                </Box>
876              ))}
877            </Box>
878          </Box>
879        </Box>
880      )
881    }
882
883    const minutesOld = last ? Math.max(0, Math.round((nowMs - last.scannedAt) / 60_000)) : 0
884    let status: string | undefined
885    if (picked !== 'session') {
886      if (scanState.isRunning) status = 'reading…'
887      else if (scanState.error) status = 'refresh failed'
888      else if (last) status = minutesOld === 0 ? 'updated just now' : `updated ${minutesOld}m ago`
889    }
890
891    const tabs = (
892      <Box flexDirection="column">
893        <Box justifyContent="space-between">
894          <Box gap={3}>{MODES.map(m => tab(m.id, m.label, metric === m.id, () => void update($, mode, () => m.id), m.hotkey))}</Box>
895          {e.props.placement === 'inline' && <Text dimColor>esc to close</Text>}
896        </Box>
897        {metric !== 'limits' && (
898          <Box justifyContent="space-between">
899            <Box gap={2}>{RANGES.map(r => tab(`range-${r.id}`, r.label, picked === r.id, () => void pickRange($, r.id)))}</Box>
900            {status && <Text dimColor>{status}</Text>}
901          </Box>
902        )}
903      </Box>
904    )
905
906    // The surface fills the whole body when docked; inline, the frame fits the tree anyway.
907    const isDocked = e.props.placement === 'dock'
908    const frame = (...sections: RenderChildren[]) => (
909      <Box
910        width={e.props.bodyColumns}
911        {...(isDocked ? { minHeight: e.props.scroll.bodyRows } : {})}
912        {...(surface ? { backgroundColor: surface } : {})}
913      >
914        <Box flexDirection="column" width={width} gap={1}>
915          {tabs}
916          {sections}
917        </Box>
918      </Box>
919    )
920
921    /** `9.84B` as the big `9.84` and a small `B tokens`. */
922    const tokenHeadline = (n: number): [string, string] => {
923      const [, digits = '', unit = ''] = /^([\d.,]+)(\D*)$/.exec(count(n)) ?? []
924      return [digits, ` ${unit} tokens`.replace('  ', ' ')]
925    }
926
927    const cacheHit = (k: Omit<Tokens, 'byModel'>) => {
928      const inputSide = k.input + k.cacheRead + k.cacheWrite
929      return `${inputSide > 0 ? Math.round((k.cacheRead / inputSide) * 100) : 0}%`
930    }
931
932    const totals = (k: Omit<Tokens, 'byModel'>, lastCell: [string, string]) =>
933      statRow([
934        ['Cache read', count(k.cacheRead)],
935        ['Cache write', count(k.cacheWrite)],
936        ['Input', count(k.input)],
937        ['Output', count(k.output)],
938        lastCell,
939      ])
940
941    if (metric === 'limits') {
942      const { context } = s
943
944      return frame(
945        context?.tokens !== undefined && (
946          <Box flexDirection="column">
947            {heading('Context window', `${context.percent ?? 0}%`)}
948            {meter(context.percent ?? 0, limitColor(context.percent ?? 0))}
949            <Text dimColor>{`${count(context.tokens)} of ${count(context.window)} · this session`}</Text>
950          </Box>
951        ),
952        ...s.rateLimits.map(r => {
953          const left = until(r.resetsAt, nowMs)
954          const ahead = pace(r, nowMs)
955          let outlook = ''
956          if (ahead) outlook = ahead.percent < 100 ? ` · on pace for ${Math.round(ahead.percent)}%` : ` · at this pace, out in ${duration(ahead.outInMs)}`
957
958          return (
959            <Box flexDirection="column">
960              {heading(LIMIT_NAMES[r.kind] ?? r.kind.replaceAll('_', ' '), `${r.percentUsed}%`)}
961              {meter(r.percentUsed, limitColor(Math.max(r.percentUsed, ahead?.percent ?? 0)), ahead?.percent)}
962              {left && <Text dimColor>{`resets in ${left}${outlook}`}</Text>}
963            </Box>
964          )
965        }),
966        s.rateLimits.length === 0 && context?.tokens === undefined && <Text dimColor>No limits reported yet</Text>,
967      )
968    }
969
970    if (picked === 'session') {
971      const elapsedHours = began > 0 && nowMs > began ? (nowMs - began) / 3_600_000 : 0
972      const rate = elapsedHours >= 1 / 60 ? `$${(s.usd / elapsedHours).toFixed(2)}/hr` : undefined
973      const total = sum(t)
974      const turnsText = `${turnTotal} ${turnTotal === 1 ? 'turn' : 'turns'}`
975      const caption = isCost ? [turnsText, `${count(total)} tokens`, rate].filter(Boolean).join(' · ') : `${turnsText} · ${dollars(s.usd)}`
976      const models = Object.entries(t.byModel).map(([model, n]) => ({ name: modelName(model), tokens: n }))
977
978      return frame(
979        isCost ? hero(dollars(s.usd), '', 'this session', caption) : hero(...tokenHeadline(total), 'this session', caption),
980        chart(turnList, isCost ? 'Cost per turn' : 'Tokens per turn', ['oldest', 'now']),
981        totals(t, isCost ? ['Last turn', `+${dollars(turnList.at(-1)?.usd ?? 0)}`] : ['Cache hit', cacheHit(t)]),
982        !isCost && byType('Tokens by type', t, count),
983        models.length > 0 && (
984          <Box flexDirection="column">
985            {heading('Models')}
986            {table('Model', models, true)}
987          </Box>
988        ),
989        isCost && <Text dimColor>At API list prices</Text>,
990      )
991    }
992
993    if (!stats) {
994      return frame(
995        scanState.error && !scanState.isRunning ? (
996          <Text color="error">{`Couldn't read your history: ${scanState.error}`}</Text>
997        ) : (
998          <Text dimColor>Reading your history…</Text>
999        ),
1000      )
1001    }
1002
1003    const days = span?.days ?? 1
1004    const rate = days === 1 ? `${dollars(stats.usd / 24)}/hr` : `${dollars(stats.usd / days)}/day`
1005    const sessions = `${stats.sessions} ${stats.sessions === 1 ? 'session' : 'sessions'}`
1006    const total = sum(stats.tokens)
1007    const [, unitTitle] = UNITS[stats.unit]
1008
1009    const hasCodex = stats.providers.some(p => p.id === 'codex' && p.tokens > 0)
1010
1011    return frame(
1012      isCost
1013        ? hero(dollars(stats.usd), '', stats.when, `${sessions} · ${count(total)} tokens · ${rate} · API list prices`, providerRows(stats))
1014        : hero(...tokenHeadline(total), stats.when, `${sessions} · ${dollars(stats.usd)} at API list prices`, providerRows(stats)),
1015      chart(stats.series, `${unitTitle} ${isCost ? 'cost' : 'tokens'}`),
1016      picked === '90d' && activity(last?.calendar ?? []),
1017      totals(stats.tokens, isCost ? ['Cache saved', dollars(stats.saved)] : ['Cache hit', cacheHit(stats.tokens)]),
1018      isCost ? byType('Cost by type', stats.costs, dollars) : byType('Tokens by type', stats.tokens, count),
1019      breakdownOf(stats),
1020      stats.unpriced > 0 && <Text dimColor>{`${count(stats.unpriced)} tokens from models without a known price aren't in the total`}</Text>,
1021      <Text dimColor>{hasCodex ? 'Claude Code and Codex on this computer' : 'Claude Code on this computer'}</Text>,
1022    )
1023  })
1024}
1025
types/index.d.ts 92 lines
1export type Mode = 'cost' | 'tokens' | 'limits'
2
3/** What the breakdown table lists: models, or the chart's hours, days or weeks. */
4export type Breakdown = 'model' | 'time'
5
6/** What the popup covers: this session, or a span of this computer's Claude Code history. */
7export type RangeId = 'session' | '24h' | '7d' | '30d' | '90d'
8
9/** The headline number's typeface, picked with `/usage-meter font <name>`. */
10export type FontName = 'smooth' | 'rounded' | 'bold' | 'blocky'
11
12/** Whose logs a span of history came from. */
13export type Provider = 'claude' | 'codex'
14
15/** What one turn added (its subagents' and every step's), for the per-turn chart. */
16export type Amount = { usd: number; tokens: number }
17
18export type Tokens = {
19  input: number
20  output: number
21  cacheRead: number
22  cacheWrite: number
23  byModel: Record<string, number>
24}
25
26export type Spend = {
27  usd: number
28  context?: { tokens?: number; window: number; percent?: number }
29  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
30}
31
32/** One span of local history as `scripts/scan.py` sums it, cost at API list prices. */
33export type RangeStats = {
34  unit: 'hour' | 'day' | 'week'
35  /** `Sep 21 – Oct 20`, or `last 24 hours`. */
36  when: string
37  sessions: number
38  providers: ({ id: Provider; sessions: number } & Amount)[]
39  tokens: { input: number; output: number; cacheRead: number; cacheWrite: number }
40  usd: number
41  /** `usd` split by the kind of token it paid for. */
42  costs: { input: number; output: number; cacheRead: number; cacheWrite: number }
43  /** What cache reads saved over full input price, less what writing the cache cost. */
44  saved: number
45  /** Tokens from models without a known price, left out of `usd`. */
46  unpriced: number
47  models: ({ model: string; provider: Provider } & Amount)[]
48  /** One bar per hour, day or week, oldest first. */
49  series: (Amount & { label: string; byProvider: Record<Provider, Amount> })[]
50}
51
52export type History = {
53  /** scan.py's output shape; a read of another shape is re-read, never drawn. */
54  version: number
55  /** When the transcripts were read, ms since the epoch. */
56  scannedAt: number
57  ranges: Record<Exclude<RangeId, 'session'>, RangeStats>
58  /** One cell per day for the activity calendar, oldest first: a Sunday 12 weeks back through today. */
59  calendar: (Amount & { label: string })[]
60}
61
62export type Scan = { isRunning: boolean; error?: string }
63
64declare module 'claude-code' {
65  interface PluginState {
66    'usage-meter': {
67      mode: Mode
68      range: RangeId
69      breakdown: Breakdown
70      font: FontName
71      /** The panel's background from `/usage-meter bg`: a hex, `none` for the engine's own, '' for the palette's. */
72      background: string
73      /** The person's theme, which picks the raw colors that don't follow it on their own. */
74      theme: 'dark' | 'light'
75      tokens: Tokens
76      spend: Spend
77      /** The ledger's cost and the token total when the last turn ended, to make each turn's share. */
78      turnMark: Amount
79      /** One entry a turn, the newest last; `turnLog` because `turns` once held one a cost update. */
80      turnLog: Amount[]
81      /** Main-thread turns this session, uncapped (`turnLog` keeps only the chart's last few). */
82      turnCount: number
83      /** When the session began, ms since the epoch; 0 until known. */
84      startedAt: number
85      now: number
86      /** The last read of the transcripts; null until a history range is first opened. */
87      history: History | null
88      scan: Scan
89    }
90  }
91}
92