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

English · Español
Two mods for the Claude Code terminal.

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.
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.A line above the prompt shows this session's tokens and cost. /usage-meter opens the popup:
Commands:
| Command | What it does |
|---|---|
/usage-meter | Open or close the popup |
/usage-meter cost 30d | Open on a view: cost, tokens or limits, and session, 24h, 7d, 30d or 90d |
/usage-meter font blocky | Headline font: smooth, rounded, bold or blocky |
/usage-meter bg #0d1117 | Popup 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.
/pastes opens the latest one, and the popup steps through every long prompt pasted this session.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
MIT
hooks/register.tsx 1025 lines1import { 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}
1025types/index.d.ts 92 lines1export 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