Pills above the prompt: 5h/7d rate limits, session tokens, live tokens/sec, cost and context window, with window stats cards (API value, pace, tokens left per…

A Claude Code mod: a row of pills above the prompt showing 5h/7d rate limits, in/out/cache tokens, live tokens per second, session cost and context fill, plus window stats cards (📈 or /usage-band stats): API value of the 5h/7d windows, pace, spend rates and tokens left per model. /usage-band switches to a plain-text status line.
See the main README (English · 中文) for screenshots, installation and details.
hooks/register.tsx 781 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Speed, StatsView, Theme, Totals } from '../types'
5import { statsAlt, statsSvg } from './cards'
6import { fmtLeft, themeCss } from './format'
7import { codeOf, listCost } from './prices'
8import { addSample, addTokens, addUsd, isRow, isSampleLog, KEEP_MS, prune, SPAN, windowStats } from './stats'
9import type { Row, SampleLog, Usage, WindowKind, WindowStats } from './stats'
10
11const ZERO: Totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
12const totals = atom({ plugin: 'usage-band', key: 'totals' } as const, ZERO)
13const speed = atom({ plugin: 'usage-band', key: 'speed' } as const, null as Speed | null)
14// Streamed characters per output token, learned from finished responses
15const charsPerToken = atom({ plugin: 'usage-band', key: 'charsPerToken' } as const, 3)
16// How often the live rate is redrawn while a response streams
17const LIVE_MS = 400
18// The stats cards above the pills, and how they draw
19const statsOpen = atom({ plugin: 'usage-band', key: 'statsOpen' } as const, false)
20const statsView = atom({ plugin: 'usage-band', key: 'statsView' } as const, 'chart' as StatsView)
21const theme = atom({ plugin: 'usage-band', key: 'theme' } as const, 'auto' as Theme)
22
23const WINDOW_MS: Record<string, number> = {
24 five_hour: 5 * 3600_000,
25 seven_day: 7 * 86400_000,
26}
27
28// ---------- formatting ----------
29
30function fmtTokens(n: number): string {
31 if (n < 1000) return String(n)
32 if (n < 1_000_000) return (n / 1000).toFixed(1) + 'k'
33 return (n / 1_000_000).toFixed(2) + 'M'
34}
35
36// 84k, 1.2M: no decimals unless they matter
37function fmtShort(n: number): string {
38 if (n < 1000) return String(n)
39 if (n < 1_000_000) return Math.round(n / 1000) + 'k'
40 const m = n / 1_000_000
41 return (Number.isInteger(m) ? m : m.toFixed(1)) + 'M'
42}
43
44type Window = { label: string; pct: number; elapsed: number | null; left: string | null; isContext?: boolean }
45
46function toWindow(r: SessionRateLimit | undefined, label: string, now: number): Window | null {
47 if (!r) return null
48 const span = WINDOW_MS[r.kind]
49 let elapsed: number | null = null
50 let left: string | null = null
51 if (r.resetsAt) {
52 const remaining = Date.parse(r.resetsAt) - now
53 left = fmtLeft(remaining)
54 if (span) elapsed = Math.min(1, Math.max(0, 1 - remaining / span))
55 }
56 return { label, pct: r.percentUsed, elapsed, left }
57}
58
59// ---------- SVG pills ----------
60
61const FONT = 'ui-monospace, SFMono-Regular, Menlo, monospace'
62// px per monospace char, by font size
63const ch = (fs: number) => fs * 0.614
64const H = 30
65const PX_PER_COL = 7.9 // desktop: CSS px per band cell, measured
66// desktop: room kept free beside the drawings, in CSS px, so the buttons stay
67// in view even when the width estimated from the band's cells runs over: the
68// 📈 button after the pills, and the Chart / By model column after the cards
69const STATS_BUTTON_PX = 60
70const TOGGLES_PX = 130
71
72type Tone = 'teal' | 'violet' | 'red' | 'green' | 'cyan' | 'blue' | 'gold' | 'slate'
73
74const BAND_LIGHT = `
75 .t{font-family:${FONT};font-size:14px;fill:#3b4440}
76 .b{font-weight:700;fill:#1f2523}
77 .d{fill:#6b7571}
78 .ic{fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
79 .track{fill:#00000018}
80 .tick{fill:#2a302d}
81 .sep{fill:#00000022}
82 .teal .bg{fill:#dcebe5}.teal .ic{stroke:#3f8f73}
83 .violet .bg{fill:#e7e2f6}.violet .ic{stroke:#7a5cc7}
84 .red .bg{fill:#f6ddd7}.red .ic{stroke:#c4553f}
85 .green .bg{fill:#dcebdb}.green .ic{stroke:#4f9a52}
86 .cyan .bg{fill:#d8eef1}.cyan .ic{stroke:#2a8fa3}
87 .blue .bg{fill:#dde3f7}.blue .ic{stroke:#4b63c9}
88 .gold .bg{fill:#f3eacf}.gold .ic{stroke:#b58a1c}.gold .coin{fill:#b58a1c}
89 .slate .bg{fill:#e3e7eb}.slate .ic{stroke:#5b6875}
90 .red .lb{fill:#c4553f}.green .lb{fill:#4f9a52}.blue .lb{fill:#4b63c9}
91`
92const BAND_DARK = `
93 .t{fill:#d6dcd9}.b{fill:#f3f6f4}.d{fill:#9aa4a0}
94 .track{fill:#ffffff22}.tick{fill:#eef2f0}.sep{fill:#ffffff26}
95 .teal .bg{fill:#1f3a31}.teal .ic{stroke:#6cc3a2}
96 .violet .bg{fill:#2e2747}.violet .ic{stroke:#a98ff0}
97 .red .bg{fill:#43241e}.red .ic{stroke:#ec8a74}
98 .green .bg{fill:#213a22}.green .ic{stroke:#7cc77f}
99 .cyan .bg{fill:#1b3a40}.cyan .ic{stroke:#6ccadc}
100 .blue .bg{fill:#232c4c}.blue .ic{stroke:#8ea0f0}
101 .gold .bg{fill:#3d3218}.gold .ic{stroke:#e0b94a}.gold .coin{fill:#e0b94a}
102 .slate .bg{fill:#2b3138}.slate .ic{stroke:#a9b5c1}
103 .red .lb{fill:#ec8a74}.green .lb{fill:#7cc77f}.blue .lb{fill:#8ea0f0}
104`
105
106const ICON = {
107 gauge: '<path d="M2.8 11.5a5.5 5.5 0 1 1 10.4 0"/><path d="M8 10.5l2.6-3"/>',
108 calendar:
109 '<rect x="2.5" y="3.5" width="11" height="10" rx="1.6"/><path d="M2.5 6.5h11M5.5 2v3M10.5 2v3"/><path d="M6.5 8.6h3l-1.8 3.4"/>',
110 clock: '<path d="M2.6 6.2A5.6 5.6 0 1 1 3.4 11"/><path d="M2.4 3.4v2.9h2.9"/><path d="M8 5.2V8l2 1.4"/>',
111 coin: '<circle cx="8" cy="8" r="5.8"/>',
112 bolt: '<path d="M9 1.8 3.6 9h4l-.8 5.2L12.4 7h-4z"/>',
113 doc: '<rect x="2.6" y="2.4" width="10.8" height="11.2" rx="2"/><path d="M5.2 5.8h5.6M5.2 8.2h5.6M5.2 10.6h3.2"/>',
114}
115
116function icon(name: keyof typeof ICON, x: number): string {
117 const extra =
118 name === 'coin'
119 ? `<text x="8" y="11.2" text-anchor="middle" class="coin" style="font:700 9px ${FONT}">$</text>`
120 : ''
121 return `<g transform="translate(${x},${(H - 16) / 2})"><g class="ic">${ICON[name]}</g>${extra}</g>`
122}
123
124function text(s: string, x: number, cls = 't'): string {
125 return `<text x="${x}" y="${H / 2 + 4.8}" class="${cls}">${s}</text>`
126}
127
128function barColor(w: Window): string {
129 if (w.isContext) return w.pct >= 80 ? '#d0573f' : w.pct >= 60 ? '#d9a23b' : '#8db36b'
130 if (w.pct >= 90) return '#d0573f'
131 if (w.elapsed !== null && w.pct / 100 > w.elapsed + 0.15) return '#d9a23b'
132 return '#8db36b'
133}
134
135// How much room each piece takes, from roomiest to tightest
136// fs: font size; ig: the gap after an icon, the others derived from it
137type Tier = { bar: number; clock: boolean; tight: boolean; pad: number; gap: number; fs: number; ig: number }
138const TIERS: Tier[] = [
139 { bar: 64, clock: true, tight: false, pad: 10, gap: 8, fs: 14, ig: 8 },
140 { bar: 52, clock: true, tight: false, pad: 9, gap: 7, fs: 14, ig: 7 },
141 { bar: 44, clock: false, tight: true, pad: 9, gap: 6, fs: 14, ig: 7 },
142 { bar: 40, clock: false, tight: true, pad: 8, gap: 5, fs: 13, ig: 6 },
143 { bar: 30, clock: false, tight: true, pad: 7, gap: 4, fs: 12.5, ig: 5 },
144 { bar: 0, clock: false, tight: true, pad: 7, gap: 4, fs: 12.5, ig: 5 },
145]
146// A tier may be up to this much wider than the room: the SVG scales it down
147const MAX_SHRINK = 0.97
148// Of the room a window pill is given, how much its bar may take
149const MAX_BAR_GROW = 90
150
151// A pill drawn at x = 0, placed later
152type Pill = { tone: Tone; width: number; body: string; alt: string }
153// A pill at a tier, `grow` px wider than its content needs
154type Spec = (t: Tier, grow: number) => Pill
155
156function windowPill(w: Window, tone: Tone, iconName: 'gauge' | 'calendar' | 'doc'): Spec {
157 return (t, grow) => {
158 const pct = `${Math.round(w.pct)}%`
159 const bar = t.bar > 0 ? t.bar + Math.min(grow, MAX_BAR_GROW) : 0
160 const pad = t.pad + (grow - (bar > 0 ? bar - t.bar : 0)) / 2
161 const CH = ch(t.fs)
162 let x = pad
163 let body = icon(iconName, x)
164 x += 16 + t.ig - 1
165 body += text(w.label, x, 't d')
166 x += w.label.length * CH + t.ig + 1
167 if (bar > 0) {
168 const fill = Math.max(0, Math.min(1, w.pct / 100)) * bar
169 body += `<rect class="track" x="${x}" y="${H / 2 - 3.5}" width="${bar}" height="7" rx="3.5"/>`
170 body += `<rect x="${x}" y="${H / 2 - 3.5}" width="${fill}" height="7" rx="3.5" fill="${barColor(w)}"/>`
171 if (w.elapsed !== null) {
172 body += `<rect class="tick" x="${x + w.elapsed * bar - 1}" y="${H / 2 - 8}" width="2" height="16" rx="1"/>`
173 }
174 x += bar + t.ig + 2
175 }
176 body += text(pct, x, 't b')
177 x += pct.length * CH
178 // when tight, the context pill keeps its percentage alone
179 const left = w.isContext && t.tight ? null : w.left && t.tight ? w.left.replace(' ', '') : w.left
180 if (left) {
181 x += t.ig + 1
182 body += `<rect class="sep" x="${x}" y="8" width="1" height="${H - 16}"/>`
183 x += 1 + t.ig + 1
184 if (t.clock && !w.isContext) {
185 body += icon('clock', x)
186 x += 16 + t.ig - 1
187 }
188 body += text(left, x, 't d')
189 x += left.length * CH
190 }
191 x += pad + 2
192 const alt = w.isContext
193 ? `Context window ${pct} full${w.left ? ` (${w.left} tokens)` : ''}`
194 : `${w.label} limit ${pct} used${w.left ? `, resets in ${w.left}` : ''}`
195 return { tone, width: x, body, alt }
196 }
197}
198
199// minChars reserves room for that many characters, so a changing label keeps its width
200function simplePill(iconName: keyof typeof ICON, tone: Tone, label: string, alt: string, minChars = 0): Spec {
201 return (t, grow) => {
202 const CH = ch(t.fs)
203 const slot = Math.max(label.length, minChars) * CH
204 const pad = t.pad + grow / 2
205 let x = pad
206 let body = icon(iconName, x)
207 x += 16 + t.ig
208 body += text(label, x + (slot - label.length * CH) / 2)
209 x += slot + pad + 2
210 return { tone, width: x, body, alt }
211 }
212}
213
214// A dim word, then the value: a pill that names itself instead of an icon
215function labelPill(label: string, tone: Tone, value: string, alt: string): Spec {
216 return (t, grow) => {
217 const CH = ch(t.fs)
218 const pad = t.pad + 2 + grow / 2
219 let x = pad
220 let body = `<text x="${x}" y="${H / 2 + 4.8}" class="t lb">${label}</text>`
221 x += label.length * CH + t.ig
222 body += text(value, x)
223 x += value.length * CH + pad
224 return { tone, width: x, body, alt }
225 }
226}
227
228// One row of pills tiled across `room` px: the tightest tier that fits, the
229// spare width shared out among the pills; if even the last tier is wider,
230// the SVG keeps its viewBox and the surface scales it down.
231function band(specs: Spec[], room: number, th: Theme): { svg: string; width: number; alt: string } {
232 let tier = TIERS[0]!
233 let need = 0
234 for (const t of TIERS) {
235 tier = t
236 need = specs.reduce((s, spec) => s + spec(t, 0).width, 0) + (specs.length - 1) * t.gap
237 if (need * MAX_SHRINK <= room) break
238 }
239 const grow = Math.max(0, room - need) / specs.length
240 const pills = specs.map(spec => spec(tier, grow))
241 const width = Math.ceil(Math.max(room, need))
242
243 let x = 0
244 let body = ''
245 for (const p of pills) {
246 body +=
247 `<g class="${p.tone}" transform="translate(${x.toFixed(1)},0)">` +
248 `<rect class="bg" width="${p.width.toFixed(1)}" height="${H}" rx="${H / 2}"/>${p.body}</g>`
249 x += p.width + tier.gap
250 }
251 const alt = pills.map(p => p.alt).join('; ')
252 const svg =
253 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${H}" viewBox="0 0 ${width} ${H}">` +
254 `<style>${themeCss(BAND_LIGHT, BAND_DARK, th)}.t{font-size:${tier.fs}px}</style>` + body + `</svg>`
255 return { svg, width, alt }
256}
257
258// ---------- the data both views draw ----------
259
260type Snapshot = {
261 five: Window | null
262 seven: Window | null
263 input: number
264 output: number
265 cache: number
266 usd: number | undefined
267 tps: Speed | null
268 ctx: Window | null
269 isEmpty: boolean
270}
271
272// The window /context measures against: the model's, or a smaller auto-compact
273// window. A local estimate (no API call) but not free, so read now and then.
274let compactWindow: number | null = null
275let compactWindowAt = 0
276const COMPACT_WINDOW_TTL = 5 * 60_000
277
278async function loadCompactWindow($: EngineInterface): Promise<void> {
279 const now = await $.clock.now()
280 if (compactWindow !== null && now - compactWindowAt < COMPACT_WINDOW_TTL) return
281 compactWindowAt = now
282 try {
283 const u = await $.session.usage({ breakdown: 'summary' })
284 compactWindow = u.context.breakdown?.rawMaxTokens ?? null
285 } catch {
286 compactWindow = null
287 }
288}
289
290async function snapshot($: EngineInterface): Promise<Snapshot> {
291 const usage = await $.session.usage()
292 const t = await read($, totals)
293 const raw = await read($, speed)
294 // a value an older version of this mod left behind is not a Speed
295 const tps = raw && typeof raw === 'object' && Number.isFinite(raw.tps) ? raw : null
296 const now = await $.clock.now()
297 const five = toWindow(usage.rateLimits.find(r => r.kind === 'five_hour'), '5h', now)
298 const seven = toWindow(usage.rateLimits.find(r => r.kind === 'seven_day'), '7d', now)
299 const usd = usage.cost?.usd
300 const hasTokens = t.input + t.output + t.cacheRead + t.cacheWrite > 0
301 const c = usage.context
302 // measured against the auto-compact window (autoCompactWindow) when one is set
303 const limit = compactWindow ?? c.window
304 const ctx: Window | null =
305 c.tokens === undefined
306 ? null
307 : {
308 label: 'ctx',
309 pct: Math.round((c.tokens / limit) * 1000) / 10,
310 elapsed: null,
311 left: `${fmtShort(c.tokens)}/${fmtShort(limit)}`,
312 isContext: true,
313 }
314 return {
315 five,
316 seven,
317 input: t.input + t.cacheWrite,
318 output: t.output,
319 cache: t.cacheRead,
320 usd,
321 tps,
322 ctx,
323 isEmpty: !five && !seven && usd === undefined && !hasTokens,
324 }
325}
326
327const tpsText = (s: Speed | null) => (s ? `${s.isLive ? '~' : ''}${Math.round(s.tps)} t/s` : '– t/s')
328
329// ---------- the status line ----------
330
331function miniBar(pct: number): string {
332 const n = Math.round(Math.max(0, Math.min(1, pct / 100)) * 5)
333 return '▰'.repeat(n) + '▱'.repeat(5 - n)
334}
335
336function statusLine(s: Snapshot): string {
337 const parts: string[] = []
338 for (const w of [s.five, s.seven]) {
339 if (w) parts.push(`${w.label} ${miniBar(w.pct)} ${Math.round(w.pct)}%${w.left ? ` ↻${w.left.replace(' ', '')}` : ''}`)
340 }
341 let tokens = `in ${fmtTokens(s.input)} out ${fmtTokens(s.output)}`
342 tokens += ` ⚡${tpsText(s.tps)}`
343 tokens += ` cache ${fmtTokens(s.cache)}`
344 parts.push(tokens)
345 if (s.usd !== undefined) parts.push('$' + s.usd.toFixed(2))
346 if (s.ctx) parts.push(`ctx ${miniBar(s.ctx.pct)} ${Math.round(s.ctx.pct)}%`)
347 return parts.join(' · ')
348}
349
350// ---------- the request ledger ----------
351//
352// Every request's tokens and dollars, kept in the plugin's store so all the
353// sessions that share a rate-limit window count toward it. Each session
354// writes only its own ledger key; the percentage samples are merged on
355// write. The module's copy is rebuilt from the store on every load.
356
357const PREFIX = 'ledger:'
358const FLUSH_MS = 20_000
359const OTHERS_TTL = 60_000
360// how often an unchanged reading is still written down
361const SAMPLE_GAP: Record<WindowKind, number> = { five_hour: 10 * 60_000, seven_day: 30 * 60_000 }
362
363let ownKey: string | null = null
364let own: Row[] = []
365let others: Row[] = []
366let othersAt = 0
367let since = 0
368let isDirty = false
369// the session's cost when last seen, to turn it into per-request dollars
370let lastUsd: number | null = null
371const samples: Partial<Record<WindowKind, SampleLog>> = {}
372
373async function loadLedger($: EngineInterface): Promise<void> {
374 const now = await $.clock.now()
375 ownKey = PREFIX + (await $.session.id())
376 const stored = (await $.store.get(ownKey)) as { rows?: unknown[] } | undefined
377 own = prune((stored?.rows ?? []).filter(isRow), now)
378 const s = await $.store.get('since')
379 since = typeof s === 'number' ? s : now
380 if (typeof s !== 'number') await $.store.set('since', since)
381 lastUsd = (await $.session.usage()).cost?.usd ?? null
382 await loadOthers($, true)
383}
384
385// Every other session's rows, re-read now and then; stale keys are dropped
386async function loadOthers($: EngineInterface, force = false): Promise<void> {
387 const now = await $.clock.now()
388 if (!force && now - othersAt < OTHERS_TTL) return
389 othersAt = now
390 const rows: Row[] = []
391 for (const key of await $.store.keys()) {
392 if (key === ownKey) continue
393 if (key.startsWith(PREFIX)) {
394 const v = (await $.store.get(key)) as { rows?: unknown[] } | undefined
395 const kept = prune((v?.rows ?? []).filter(isRow), now)
396 if (!kept.length) await $.store.delete(key)
397 else rows.push(...kept)
398 } else if (key.startsWith('pct:')) {
399 const kind = key.slice(4) as WindowKind
400 const v = await $.store.get(key)
401 if (kind in SPAN && isSampleLog(v)) samples[kind] = v
402 }
403 }
404 others = rows
405}
406
407function allRows(): Row[] {
408 return own.length ? [...others, ...own] : others
409}
410
411async function recordTokens($: EngineInterface, code: string, u: Usage): Promise<void> {
412 const now = await $.clock.now()
413 const row = addTokens(own, now, code, u)
414 const usd = (await $.session.usage()).cost?.usd
415 // no cost ledger on this host: price the request ourselves
416 if (usd === undefined) addUsd(own, now, listCost(code, u), row)
417 else accrue(usd, now, row)
418 isDirty = true
419}
420
421// The session's cost moved: what it grew by goes to the latest row
422function accrue(usd: number, now: number, row?: Row): void {
423 if (lastUsd === null || usd < lastUsd - Math.max(0.5, lastUsd * 0.2)) {
424 // first reading, or the session's cost started over (/clear)
425 lastUsd = usd
426 return
427 }
428 if (usd <= lastUsd) return
429 addUsd(own, now, usd - lastUsd, row)
430 lastUsd = usd
431 isDirty = true
432}
433
434async function flush($: EngineInterface): Promise<void> {
435 if (!isDirty || !ownKey) return
436 isDirty = false
437 const now = await $.clock.now()
438 own = prune(own, now)
439 try {
440 await $.store.set(ownKey, { rows: own })
441 } catch {
442 // the store is full: keep this session's last day at full detail
443 own = own.filter(r => r[0] * 60_000 > now - KEEP_MS / 8)
444 await $.store.set(ownKey, { rows: own }).catch(() => undefined)
445 }
446}
447
448function flushEvery($: EngineInterface): void {
449 $.clock.every(FLUSH_MS, () => void flush($))
450}
451
452// A rate-limit reading: written to the shared log when it says something new
453async function recordSamples($: EngineInterface, limits: readonly SessionRateLimit[]): Promise<void> {
454 const now = await $.clock.now()
455 for (const r of limits) {
456 const kind = r.kind as WindowKind
457 if (!(kind in SPAN) || !r.resetsAt) continue
458 const resetsAt = Date.parse(r.resetsAt)
459 if (!Number.isFinite(resetsAt)) continue
460 const key = `pct:${kind}`
461 const stored = await $.store.get(key)
462 const base = isSampleLog(stored) ? stored : samples[kind]
463 const next = addSample(base, resetsAt, now, r.percentUsed, SAMPLE_GAP[kind])
464 if (base) samples[kind] = base
465 if (!next) continue
466 samples[kind] = next
467 await $.store.set(key, next).catch(() => undefined)
468 }
469}
470
471// ---------- the stats cards ----------
472
473const KINDS: WindowKind[] = ['five_hour', 'seven_day']
474
475async function windowsNow($: EngineInterface): Promise<WindowStats[]> {
476 const usage = await $.session.usage()
477 const now = await $.clock.now()
478 const rows = allRows()
479 const out: WindowStats[] = []
480 for (const kind of KINDS) {
481 const r = usage.rateLimits.find(x => x.kind === kind)
482 const resetsAt = r?.resetsAt ? Date.parse(r.resetsAt) : NaN
483 if (!r || !Number.isFinite(resetsAt)) continue
484 out.push(windowStats({ kind, pct: r.percentUsed, resetsAt, now, rows, since, samples: samples[kind] }))
485 }
486 return out
487}
488
489async function toggleStats($: EngineInterface): Promise<boolean> {
490 const next = !(await read($, statsOpen))
491 await update($, statsOpen, () => next)
492 await $.store.set('statsOpen', next)
493 if (next) {
494 await loadOthers($, true)
495 $.ui.invalidate('ui.render')
496 }
497 return next
498}
499
500// ---------- the mod ----------
501
502type Mode = 'band' | 'status'
503let mode: Mode = 'band'
504
505// the status line is pushed, not drawn: refresh it whenever a figure moves
506async function refresh($: EngineInterface): Promise<void> {
507 if (mode === 'status') {
508 const s = await snapshot($)
509 $.ui.status(s.isEmpty ? undefined : statusLine(s))
510 } else {
511 $.ui.invalidate('ui.render')
512 }
513}
514
515export const register: Register = on => {
516
517 on('session.start', async ($, e, next) => {
518 mode = (await $.store.get('mode')) === 'status' ? 'status' : 'band'
519 const open = (await $.store.get('statsOpen')) === true
520 const view = (await $.store.get('statsView')) === 'model' ? 'model' : 'chart'
521 const th = await $.store.get('theme')
522 await update($, statsOpen, () => open)
523 await update($, statsView, () => view)
524 await update($, theme, () => (th === 'light' || th === 'dark' ? th : 'auto'))
525 await $.command.register({
526 name: 'usage-band',
527 description: 'Switch the usage display: above the prompt (band) or the status line (status); stats opens the window cards',
528 argumentHint: '[band|status|stats]',
529 immediate: true,
530 })
531 await loadLedger($)
532 flushEvery($)
533 // keep the reset countdowns fresh, and the other sessions' requests
534 $.clock.every(60_000, async () => {
535 if (await read($, statsOpen)) await loadOthers($)
536 await recordSamples($, (await $.session.usage()).rateLimits)
537 await refresh($)
538 })
539 void loadCompactWindow($).then(() => refresh($))
540 return next(e)
541 })
542
543 on('command.run', { command: 'usage-band' }, async ($, e) => {
544 const arg = e.args.trim().toLowerCase()
545 if (arg === 'stats') {
546 if (mode !== 'band') {
547 mode = 'band'
548 await $.store.set('mode', mode)
549 $.ui.status(undefined)
550 }
551 const isOpen = await toggleStats($)
552 return { text: isOpen ? 'Window stats now show above the pills.' : 'Window stats hidden.' }
553 }
554 const want: Mode | null =
555 arg === '' ? (mode === 'band' ? 'status' : 'band') : arg === 'band' || arg === 'status' ? arg : null
556 if (!want) return { text: 'Usage: /usage-band [band|status|stats] (no argument toggles band and status)' }
557 mode = want
558 await $.store.set('mode', mode)
559 if (mode === 'band') $.ui.status(undefined)
560 $.ui.invalidate('ui.render')
561 await refresh($)
562 return {
563 text: mode === 'band' ? 'Usage now shows as pills above the prompt.' : 'Usage now shows on the status line.',
564 }
565 })
566
567 // rate limits or cost moved
568 on('session.measure', async ($, e, next) => {
569 if (e.changed.includes('context')) await loadCompactWindow($)
570 if (e.changed.includes('cost') && e.cost) accrue(e.cost.usd, await $.clock.now())
571 if (e.changed.includes('rateLimits')) await recordSamples($, e.rateLimits)
572 await refresh($)
573 return next(e)
574 })
575
576 on('session.end', async ($, e, next) => {
577 await flush($)
578 return next(e)
579 })
580
581 // every model request (main and subagents): add its tokens;
582 // on the main thread, also show its tokens per second as it streams
583 on('turn.step', async function* ($, e, next) {
584 const isMain = !e.agentId
585 const stream = next(e)
586 let firstAt = 0
587 let chars = 0
588 let ticker: { cancel: () => void } | null = null
589 try {
590 for await (const chunk of stream) {
591 if (chunk.kind === 'text' || chunk.kind === 'thinking') chars += chunk.text.length
592 else if (chunk.kind === 'input') chars += chunk.json.length
593 if (!firstAt && chunk.kind !== 'engine') {
594 firstAt = await $.clock.now()
595 if (isMain) {
596 const ratio = await read($, charsPerToken)
597 // live estimate: the characters so far over the learned ratio
598 ticker = $.clock.every(LIVE_MS, async () => {
599 const seconds = ((await $.clock.now()) - firstAt) / 1000
600 if (seconds < 0.5 || chars === 0) return
601 await update($, speed, () => ({ tps: chars / ratio / seconds, isLive: true }))
602 if (mode === 'status') await refresh($)
603 })
604 }
605 }
606 yield chunk
607 }
608 } finally {
609 ticker?.cancel()
610 }
611 const r = await stream.result
612 const u = r.usage
613 if (u) {
614 await update($, totals, t => ({
615 input: t.input + u.input_tokens,
616 output: t.output + u.output_tokens,
617 cacheRead: t.cacheRead + u.cache_read_input_tokens,
618 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
619 }))
620 await recordTokens($, codeOf(u.model), {
621 input: u.input_tokens,
622 output: u.output_tokens,
623 cacheRead: u.cache_read_input_tokens,
624 cacheWrite: u.cache_creation_input_tokens,
625 })
626 }
627 if (isMain) {
628 const seconds = firstAt ? ((await $.clock.now()) - firstAt) / 1000 : 0
629 // too short a response gives a meaningless rate: keep the last one, settled
630 if (u && u.output_tokens >= 20 && seconds >= 0.3) {
631 await update($, speed, () => ({ tps: u.output_tokens / seconds, isLive: false }))
632 if (chars > 0 && u.output_tokens >= 50) {
633 const seen = chars / u.output_tokens
634 await update($, charsPerToken, old => old * 0.6 + seen * 0.4)
635 }
636 } else {
637 await update($, speed, old => (old ? { ...old, isLive: false } : old))
638 }
639 }
640 if (mode === 'status') await refresh($)
641 return r
642 })
643
644 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
645 if (mode !== 'band' || e.props.hasSurvey) return next(e)
646
647 const s = await snapshot($)
648 if (s.isEmpty) return next(e)
649 const tpsLabel = tpsText(s.tps)
650
651 // the stats cards: open, and only with a rate-limit window to describe
652 const isOpen = await read($, statsOpen)
653 const view = await read($, statsView)
654 const th = await read($, theme)
655 const hasWindows = !!(s.five || s.seven)
656 const windows = isOpen && hasWindows ? await windowsNow($) : []
657
658 const setView = (v: StatsView) => async () => {
659 await update($, statsView, () => v)
660 await $.store.set('statsView', v)
661 }
662 const nextTheme = async () => {
663 const order: Theme[] = ['auto', 'light', 'dark']
664 const v = order[(order.indexOf(await read($, theme)) + 1) % order.length] ?? 'auto'
665 await update($, theme, () => v)
666 await $.store.set('theme', v)
667 $.ui.toast(`usage-band theme: ${v === 'auto' ? 'follow the system' : v}`)
668 }
669
670 if (e.surface === 'terminal') {
671 const { Box, Text, Button } = $.ui.resolve(e)
672 const bar = (w: Window) => {
673 const n = Math.round(Math.min(1, w.pct / 100) * 8)
674 return '█'.repeat(n) + '░'.repeat(8 - n)
675 }
676 const win = (w: Window | null, color: string) =>
677 w && (
678 <Text color={color}>
679 {w.label} {bar(w)} <Text bold>{Math.round(w.pct)}%</Text>
680 {w.left ? ` ↻ ${w.left}` : ''}
681 {' '}
682 </Text>
683 )
684 return (
685 <Box flexDirection="column">
686 {windows.length > 0 && (
687 <Box flexDirection="column" marginBottom={1}>
688 {windows.map(w => (
689 <Text key={w.kind} color={w.kind === 'five_hour' ? 'green' : 'magenta'} wrap="wrap">
690 {statsAlt(w, view)}
691 </Text>
692 ))}
693 <Box flexDirection="row" gap={1}>
694 <Button key="view-chart" label="Pace" variant={view === 'chart' ? 'primary' : 'secondary'} onPress={setView('chart')} />
695 <Button key="view-model" label="By model" variant={view === 'model' ? 'primary' : 'secondary'} onPress={setView('model')} />
696 </Box>
697 </Box>
698 )}
699 <Box flexWrap="wrap">
700 {win(s.five, 'green')}
701 {win(s.seven, 'magenta')}
702 <Text color="red">in {fmtTokens(s.input)} </Text>
703 <Text color="green">out {fmtTokens(s.output)} </Text>
704 <Text color="cyan">⚡ {tpsLabel} </Text>
705 <Text color="blue">cache {fmtTokens(s.cache)} </Text>
706 {s.usd !== undefined && <Text color="yellow">$ {s.usd.toFixed(2)} </Text>}
707 {s.ctx && <Text dimColor>ctx {Math.round(s.ctx.pct)}% </Text>}
708 {hasWindows && <Button key="stats" label="stats" plain dimColor={!isOpen} onPress={() => void toggleStats($)} />}
709 </Box>
710 </Box>
711 )
712 }
713
714 if (e.surface !== 'desktop' && e.surface !== 'vscode' && e.surface !== 'mobile') return next(e)
715 const { Box, Svg, Button } = $.ui.resolve(e)
716
717 // Room for the row, in CSS px, estimated from the band's cell width
718 const room = Math.max(320, e.props.bodyColumns * PX_PER_COL)
719
720 const specs: Spec[] = [
721 ...(s.five ? [windowPill(s.five, 'teal', 'gauge')] : []),
722 ...(s.seven ? [windowPill(s.seven, 'violet', 'calendar')] : []),
723 labelPill('in', 'red', fmtTokens(s.input), `Input tokens ${s.input} (incl. cache writes)`),
724 labelPill('out', 'green', fmtTokens(s.output), `Output tokens ${s.output}`),
725 simplePill(
726 'bolt',
727 'cyan',
728 tpsLabel,
729 !s.tps ? 'No response timed yet' : s.tps.isLive ? `Streaming at about ${tpsLabel}` : `Last response: ${tpsLabel}`,
730 8,
731 ),
732 labelPill('cache', 'blue', fmtTokens(s.cache), `Cache read tokens ${s.cache}`),
733 ...(s.usd !== undefined
734 ? [simplePill('coin', 'gold', '$' + s.usd.toFixed(2), `Session cost $${s.usd.toFixed(2)}`)]
735 : []),
736 ...(s.ctx ? [windowPill(s.ctx, 'slate', 'doc')] : []),
737 ]
738 const bandRoom = hasWindows ? room - STATS_BUTTON_PX : room
739 const row = band(specs, bandRoom, th)
740 // a row wider than its room is scaled down to it, not left to push 📈 out
741 const bandWidth = Math.min(row.width, bandRoom)
742
743 // The SVGs sit in boxes that may shrink, so a narrower slot than the
744 // estimate scales them down instead of pushing the buttons out of view
745 // The cards always side by side, scaled down with the band
746 let cards = null
747 if (windows.length > 0) {
748 const c = statsSvg(windows, view, th)
749 const width = Math.min(c.width, Math.max(240, room - TOGGLES_PX))
750 cards = (
751 <Box flexDirection="row" alignItems="flex-start" marginBottom={1}>
752 <Box flexShrink={1} minWidth={0}>
753 <Svg source={c.svg} alt={c.alt} width={width} height={Math.round((c.height * width) / c.width)} />
754 </Box>
755 <Box flexDirection="column" flexShrink={0} marginLeft={1} gap={1}>
756 <Button key="view-chart" label="Chart" variant={view === 'chart' ? 'primary' : 'secondary'} onPress={setView('chart')} />
757 <Button key="view-model" label="By model" variant={view === 'model' ? 'primary' : 'secondary'} onPress={setView('model')} />
758 <Button key="theme" label="◐" plain dimColor onPress={() => void nextTheme()} />
759 </Box>
760 </Box>
761 )
762 }
763
764 return (
765 <Box flexDirection="column">
766 {cards}
767 <Box flexDirection="row" alignItems="center">
768 <Box flexShrink={1} minWidth={0}>
769 <Svg source={row.svg} alt={row.alt} width={bandWidth} height={Math.round((30 * bandWidth) / row.width)} />
770 </Box>
771 {hasWindows && (
772 <Box flexShrink={0} marginLeft={1}>
773 <Button key="stats" label="📈" plain dimColor={!isOpen} onPress={() => void toggleStats($)} />
774 </Box>
775 )}
776 </Box>
777 </Box>
778 )
779 })
780}
781hooks/cards.ts 272 lines1// The stats cards as one SVG: a card per rate-limit window, drawn as a
2// chart of the window or as what is left of it per model.
3
4import { FAMILIES, MODEL_NAME, MODEL_SHORT } from './prices'
5import type { Family } from './prices'
6import { tokensFor } from './stats'
7import type { WindowStats } from './stats'
8import { esc, fmtBig, fmtLeft, fmtRate, fmtUsd, fmtUsdWhole, themeCss } from './format'
9import type { Theme } from './format'
10import type { StatsView } from '../types'
11
12export type View = StatsView
13
14export const CARD_W = 540
15export const CARD_H = 334
16export const CARD_GAP = 16
17
18const SANS = `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif`
19
20const MODEL_COLOR: Record<Family, string> = {
21 fable: '#7b6ee6',
22 opus: '#dd5f32',
23 sonnet: '#1d9e6f',
24 haiku: '#8e8e93',
25}
26
27const LIGHT = `
28 text{font-family:${SANS}}
29 .card{fill:#fff;stroke:#d4d4d8;stroke-width:1.5}
30 .t1{fill:#1d1d1f}.t2{fill:#6e6e73}
31 .rule{stroke:#e4e4e7;stroke-width:1.5}
32 .red{fill:#c62828}.reds{stroke:#c62828}.redf{fill:#c62828}
33 .ok{fill:#2e8b57}
34 .track{fill:#ececef}
35 .prev{stroke:#a1a1aa;fill:none}
36 .ring{stroke:#fff}
37 .w5 .af{fill:#2e8b57}.w5 .as{stroke:#2e8b57}.w5 .tint{fill:#eaf4ee}.w5 .area{fill:#2e8b57;fill-opacity:.14}
38 .w7 .af{fill:#6a55d8}.w7 .as{stroke:#6a55d8}.w7 .tint{fill:#efecfb}.w7 .area{fill:#6a55d8;fill-opacity:.14}
39`
40const DARK = `
41 .card{fill:#232427;stroke:#3f3f46}
42 .t1{fill:#f4f4f5}.t2{fill:#a1a1aa}
43 .rule{stroke:#3a3a40}
44 .red{fill:#f47171}.reds{stroke:#f47171}.redf{fill:#f47171}
45 .ok{fill:#5cc48a}
46 .track{fill:#3a3a40}
47 .prev{stroke:#71717a}
48 .ring{stroke:#232427}
49 .w5 .af{fill:#5cc48a}.w5 .as{stroke:#5cc48a}.w5 .tint{fill:#1f3329}.w5 .area{fill:#5cc48a;fill-opacity:.16}
50 .w7 .af{fill:#a594ff}.w7 .as{stroke:#a594ff}.w7 .tint{fill:#2b2745}.w7 .area{fill:#a594ff;fill-opacity:.18}
51`
52
53function t(x: number, y: number, s: string, cls: string, size: number, extra = ''): string {
54 return `<text x="${x}" y="${y}" class="${cls}" style="font-size:${size}px" ${extra}>${s}</text>`
55}
56
57const unitLabel = (w: WindowStats) => (w.unit === 'h' ? '/h' : '/day')
58
59function header(w: WindowStats): string {
60 const W = CARD_W
61 let out = `<rect class="card" x="1" y="1" width="${W - 2}" height="${CARD_H - 2}" rx="16"/>`
62 const title =
63 w.value === null ? `${w.label} window` : `${w.label} window ≈ ${fmtUsdWhole(w.value)} at API prices`
64 out += t(28, 46, esc(title), 't1', 21, 'font-weight="500"')
65 const parts = [`used ${Math.round(w.pct)}%`]
66 if (w.spent !== null) parts.push((w.isPartial ? '≈' : '') + fmtUsd(w.spent))
67 if (w.tokens > 0) parts.push(`${fmtBig(w.tokens)} tokens`)
68 if (w.isPartial) parts.push(`tracked ${fmtLeft(w.now - w.trackedSince)}`)
69 if (w.value === null) parts.push('measuring API value')
70 out += t(28, 80, esc(parts.join(' · ')), 't2', 16.5)
71 return out
72}
73
74// Why there is no value yet, in a few words
75export function measuring(w: WindowStats): string {
76 return w.pct >= 100
77 ? 'Limit reached before tracking began; value comes with the next window'
78 : 'Measuring the API value: needs about 1% more use of this window'
79}
80
81// 16% · 2.4%
82function fmtPct(p: number): string {
83 return (p >= 10 ? Math.round(p) : Math.round(p * 10) / 10) + '%'
84}
85
86// ---------- chart view ----------
87
88function chartCard(w: WindowStats): string {
89 const W = CARD_W
90 let out = header(w)
91 const s = w.status
92 const reset = fmtLeft(w.remaining)
93
94 // the verdict
95 let left: string
96 let right: string
97 if (s.kind === 'pace') {
98 left = t(28, 124, `On pace to finish at <tspan class="ok">${Math.round(s.projected)}%</tspan>`, 't1', 17.5, 'font-weight="600"')
99 right = t(W - 26, 124, `reset in ${reset}`, 'ok', 15.5, 'text-anchor="end" font-weight="600"')
100 } else if (s.kind === 'hit') {
101 left = t(28, 124, `Limit hit in ${fmtLeft(s.hitIn)}`, 'red', 17.5, 'font-weight="600"')
102 right = t(W - 26, 124, `${fmtLeft(s.beforeReset)} before reset`, 'red', 15.5, 'text-anchor="end" font-weight="600"')
103 } else if (s.kind === 'reached') {
104 left = t(28, 124, 'Limit reached', 'red', 17.5, 'font-weight="600"')
105 right = t(W - 26, 124, `reset in ${reset}`, 't2', 15.5, 'text-anchor="end" font-weight="600"')
106 } else {
107 left = t(28, 124, 'Too early to tell the pace', 't1', 17.5, 'font-weight="600"')
108 right = t(W - 26, 124, `reset in ${reset}`, 't2', 15.5, 'text-anchor="end" font-weight="600"')
109 }
110 out += left + right
111 out += `<line class="rule" x1="16" y1="136" x2="${W - 16}" y2="136"/>`
112
113 // the two rates: in dollars once the value is known, else in points of the limit
114 const hasUsd = w.value !== null
115 const equiv = (rate: number | null) =>
116 !hasUsd ? 'of the limit' : rate === null ? '' : `≈ ${fmtBig(tokensFor(rate, w.ref, w.mix))} ${MODEL_SHORT[w.ref]}`
117 const rateText = (usd: number | null, pct: number | null) =>
118 hasUsd ? (usd === null ? '—' : fmtRate(usd) + unitLabel(w)) : pct === null ? '—' : fmtPct(pct) + unitLabel(w)
119 out += t(28, 162, 'Average so far', 't1', 16.5)
120 out += t(W - 165, 162, rateText(w.avgRate, w.avgPctRate), 't1', 18, 'text-anchor="end" font-weight="500"')
121 out += t(W - 26, 162, esc(equiv(w.avgRate)), 't2', 15, 'text-anchor="end"')
122 const isHit = s.kind === 'hit'
123 out += `<rect class="tint" x="16" y="178" width="${W - 32}" height="34" rx="9"/>`
124 out += t(28, 201, isHit ? 'Slow down to' : 'Spend up to', 't1', 16.5)
125 out += t(W - 165, 201, rateText(w.allowedRate, w.allowedPctRate), isHit ? 'red' : 't1', 18.5, 'text-anchor="end" font-weight="600"')
126 out += t(W - 26, 201, esc(equiv(w.allowedRate)), 't2', 15, 'text-anchor="end"')
127
128 out += chart(w)
129
130 // the window before
131 if (w.prev) {
132 const name = w.kind === 'seven_day' ? 'Last week' : 'Last 5h window'
133 out += `<line class="prev" x1="28" y1="310" x2="48" y2="310" stroke-width="2"/>`
134 out += t(58, 316, `${name}: ${Math.round(w.prev.atNow)}% by now, ${Math.round(w.prev.atEnd)}% at reset`, 't2', 15.5)
135 } else {
136 out += t(28, 316, `No previous ${w.label} window to compare`, 't2', 15.5)
137 }
138 return out
139}
140
141function chart(w: WindowStats): string {
142 const x0 = 28
143 const x1 = CARD_W - 78
144 const yTop = 224
145 const yBot = 286
146 const yMax = 108
147 const X = (f: number) => (x0 + f * (x1 - x0)).toFixed(1)
148 const Y = (p: number) => (yBot - (Math.min(p, yMax) / yMax) * (yBot - yTop)).toFixed(1)
149 const yLimit = Y(100)
150 const line = (pts: ReadonlyArray<[number, number]>) => pts.map(([f, p]) => `${X(f)},${Y(p)}`).join(' ')
151 let out = ''
152
153 out += `<line class="rule" x1="${x0}" y1="${yBot}" x2="${x1}" y2="${yBot}"/>`
154 out += `<line class="reds" x1="${x0}" y1="${yLimit}" x2="${x1}" y2="${yLimit}" stroke-width="1.6" stroke-dasharray="3 4"/>`
155 out += t(x1 + 10, Number(yLimit) + 5, 'limit', 'red', 15)
156
157 if (w.prev) out += `<polyline class="prev" points="${line(w.prev.curve)}" stroke-width="1.8" stroke-linejoin="round"/>`
158
159 const c = w.curve
160 if (c.length >= 2) {
161 const area = `${X(c[0]![0])},${yBot} ${line(c)} ${X(c[c.length - 1]![0])},${yBot}`
162 out += `<polygon class="area" points="${area}"/>`
163 out += `<polyline class="as" points="${line(c)}" fill="none" stroke-width="2.6" stroke-linejoin="round" stroke-linecap="round"/>`
164 }
165 if (w.untracked) {
166 out += `<polyline class="as" points="${line(w.untracked)}" fill="none" stroke-width="1.6" stroke-dasharray="2 4" opacity=".6"/>`
167 }
168
169 const nf = w.frac
170 const np = w.pct
171 const s = w.status
172 // the budget: from now to the limit at the reset
173 if (np < 100) {
174 out += `<polyline class="as" points="${X(nf)},${Y(np)} ${X(1)},${yLimit}" fill="none" stroke-width="1.8" opacity=".4"/>`
175 }
176 // the projection at the pace so far
177 if (s.kind === 'pace') {
178 out += `<polyline class="as" points="${X(nf)},${Y(np)} ${X(1)},${Y(s.projected)}" fill="none" stroke-width="2.4" stroke-dasharray="6 4"/>`
179 } else if (s.kind === 'hit') {
180 const fh = nf + s.hitIn / w.span
181 out += `<polyline class="reds" points="${X(nf)},${Y(np)} ${X(fh)},${yLimit}" fill="none" stroke-width="2.4" stroke-dasharray="6 4"/>`
182 out += `<circle class="redf" cx="${X(fh)}" cy="${yLimit}" r="4.5"/>`
183 }
184 out += `<circle class="af ring" cx="${X(nf)}" cy="${Y(np)}" r="5" stroke-width="2"/>`
185 return out
186}
187
188// ---------- by-model view ----------
189
190function modelCard(w: WindowStats): string {
191 const W = CARD_W
192 let out = header(w)
193 const colC = W - 148
194 out += `<rect class="tint" x="${colC - 60}" y="102" width="120" height="190" rx="10"/>`
195 out += t(28, 128, 'If you use only…', 't1', 16.5)
196 out += t(colC, 128, 'tokens left', 'af', 16.5, 'text-anchor="middle" font-weight="600"')
197 out += t(W - 26, 128, 'used', 't2', 16.5, 'text-anchor="end"')
198 out += `<line class="rule" x1="16" y1="140" x2="${W - 16}" y2="140"/>`
199
200 const left = FAMILIES.map(f => (w.left === null ? null : tokensFor(w.left, f, w.mix)))
201 const max = Math.max(1, ...left.map(v => v ?? 0))
202 const barX = 152
203 const barW = colC - 60 - 22 - barX
204 FAMILIES.forEach((f, i) => {
205 const y = 168 + i * 36
206 const v = left[i] ?? null
207 out += `<circle cx="36" cy="${y - 6}" r="6.5" fill="${MODEL_COLOR[f]}"/>`
208 out += t(52, y, MODEL_NAME[f], 't1', 16.5)
209 out += `<rect class="track" x="${barX}" y="${y - 10}" width="${barW}" height="8" rx="4"/>`
210 if (v !== null && v > 0) {
211 out += `<rect x="${barX}" y="${y - 10}" width="${Math.max(4, (v / max) * barW).toFixed(1)}" height="8" rx="4" fill="${MODEL_COLOR[f]}"/>`
212 }
213 out += t(colC, y + 1, v === null ? '…' : fmtBig(v), v === null ? 't2' : 't1', 18.5, 'text-anchor="middle" font-weight="600"')
214 out += t(W - 26, y, w.used[f] > 0 ? fmtBig(w.used[f]) : 'none', 't2', 15.5, 'text-anchor="end"')
215 })
216
217 const foot = w.left === null ? measuring(w) : `Same ${fmtUsd(w.left)} left, spent at each model’s price`
218 out += t(28, 318, esc(foot), 't2', 15.5)
219 return out
220}
221
222// ---------- the drawing ----------
223
224// The cards side by side, as one drawing the surface scales to its slot
225export function statsSvg(
226 windows: readonly WindowStats[],
227 view: View,
228 theme: Theme,
229): { svg: string; width: number; height: number; alt: string } {
230 const n = windows.length
231 const width = n * CARD_W + Math.max(0, n - 1) * CARD_GAP
232 const height = CARD_H
233 let body = ''
234 windows.forEach((w, i) => {
235 const cls = w.kind === 'five_hour' ? 'w5' : 'w7'
236 const x = i * (CARD_W + CARD_GAP)
237 body += `<g class="${cls}" transform="translate(${x},0)">${view === 'chart' ? chartCard(w) : modelCard(w)}</g>`
238 })
239 const svg =
240 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
241 `<style>${themeCss(LIGHT, DARK, theme)}</style>${body}</svg>`
242 return { svg, width, height, alt: windows.map(w => statsAlt(w, view)).join(' ') }
243}
244
245// The cards in words: the alt text, and the terminal's lines
246export function statsAlt(w: WindowStats, view: View): string {
247 const head =
248 w.value === null
249 ? `${w.label} window: ${Math.round(w.pct)}% used. ${measuring(w)}.`
250 : `${w.label} window ≈ ${fmtUsdWhole(w.value)} at API prices, ${Math.round(w.pct)}% used.`
251 if (view === 'model') {
252 if (w.left === null) return head
253 const per = FAMILIES.map(f => `${MODEL_SHORT[f]} ${fmtBig(tokensFor(w.left!, f, w.mix))}`).join(', ')
254 return `${head} Tokens left if you use only: ${per}.`
255 }
256 return `${head} ${verdict(w)}.`
257}
258
259export function verdict(w: WindowStats): string {
260 const s = w.status
261 const u = unitLabel(w)
262 if (s.kind === 'reached') return `Limit reached, resets in ${fmtLeft(w.remaining)}`
263 if (s.kind === 'early') return `Too early to tell, resets in ${fmtLeft(w.remaining)}`
264 const avg = w.avgRate === null ? '' : `, average ${fmtRate(w.avgRate)}${u}`
265 if (s.kind === 'pace') {
266 const up = w.allowedRate === null ? '' : `, spend up to ${fmtRate(w.allowedRate)}${u}`
267 return `On pace to finish at ${Math.round(s.projected)}%${avg}${up}`
268 }
269 const down = w.allowedRate === null ? '' : `, slow down to ${fmtRate(w.allowedRate)}${u}`
270 return `Limit hit in ${fmtLeft(s.hitIn)}, ${fmtLeft(s.beforeReset)} before reset${avg}${down}`
271}
272hooks/format.ts 53 lines1import type { Theme } from '../types'
2
3export type { Theme }
4
5// A stylesheet that follows the theme: the system's under `auto`, else forced.
6// The SVG is drawn as an image, so `auto` follows the OS, not the app.
7export function themeCss(light: string, dark: string, theme: Theme): string {
8 if (theme === 'light') return light
9 if (theme === 'dark') return light + dark
10 return `${light}@media (prefers-color-scheme: dark){${dark}}`
11}
12
13export function fmtLeft(ms: number): string {
14 const mins = Math.max(0, Math.round(ms / 60_000))
15 const d = Math.floor(mins / 1440)
16 const h = Math.floor((mins % 1440) / 60)
17 const m = mins % 60
18 if (d > 0) return `${d}d ${h}h`
19 if (h > 0) return `${h}h ${m}m`
20 return `${m}m`
21}
22
23function grouped(n: number): string {
24 return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
25}
26
27// $1,256 · $177 · $90.19
28export function fmtUsd(n: number): string {
29 return n >= 100 ? '$' + grouped(n) : '$' + n.toFixed(2)
30}
31
32// The headline figure: whole dollars
33export function fmtUsdWhole(n: number): string {
34 return n >= 10 ? '$' + grouped(n) : '$' + n.toFixed(2)
35}
36
37// $24 · $3.4
38export function fmtRate(n: number): string {
39 return n >= 10 ? '$' + grouped(n) : '$' + n.toFixed(1)
40}
41
42// 7.24B · 269.5M · 32.1k
43export function fmtBig(n: number): string {
44 if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'
45 if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
46 if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
47 return String(Math.round(n))
48}
49
50export function esc(s: string): string {
51 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
52}
53hooks/prices.ts 69 lines1// API list prices, US dollars per million tokens. Cache writes are the
2// 5-minute rate (1.25x input).
3
4export type Family = 'fable' | 'opus' | 'sonnet' | 'haiku'
5
6export const FAMILIES: readonly Family[] = ['fable', 'opus', 'sonnet', 'haiku']
7
8export const MODEL_NAME: Record<Family, string> = {
9 fable: 'Fable 5.1',
10 opus: 'Opus 5.5',
11 sonnet: 'Sonnet 5.5',
12 haiku: 'Haiku 4.5',
13}
14
15// Short names for the "≈ 65.5M Opus" equivalents
16export const MODEL_SHORT: Record<Family, string> = {
17 fable: 'Fable',
18 opus: 'Opus',
19 sonnet: 'Sonnet',
20 haiku: 'Haiku',
21}
22
23export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
24
25export const PRICE: Record<Family, Price> = {
26 fable: { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5 },
27 opus: { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 },
28 sonnet: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
29 haiku: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
30}
31
32// One letter per family in the ledger; `x` for a model we cannot place
33export type Code = 'f' | 'o' | 's' | 'h' | 'x'
34
35export const CODE: Record<Family, Code> = { fable: 'f', opus: 'o', sonnet: 's', haiku: 'h' }
36
37export function familyOfCode(c: string): Family | null {
38 return c === 'f' ? 'fable' : c === 'o' ? 'opus' : c === 's' ? 'sonnet' : c === 'h' ? 'haiku' : null
39}
40
41export function codeOf(model: string): Code {
42 const m = model.toLowerCase()
43 if (m.includes('fable') || m.includes('mythos')) return 'f'
44 if (m.includes('opus')) return 'o'
45 if (m.includes('sonnet')) return 's'
46 if (m.includes('haiku')) return 'h'
47 return 'x'
48}
49
50export type Mix = { input: number; output: number; cacheRead: number; cacheWrite: number }
51
52// When nothing is tracked yet: roughly what a Claude Code session sends
53export const DEFAULT_MIX: Mix = { input: 0.005, output: 0.03, cacheRead: 0.925, cacheWrite: 0.04 }
54
55// Dollars per token for a family, at a given split of token kinds
56export function usdPerToken(f: Family, mix: Mix): number {
57 const total = mix.input + mix.output + mix.cacheRead + mix.cacheWrite
58 const m = total > 0 ? mix : DEFAULT_MIX
59 const t = total > 0 ? total : 1
60 const p = PRICE[f]
61 return (m.input * p.input + m.output * p.output + m.cacheRead * p.cacheRead + m.cacheWrite * p.cacheWrite) / t / 1e6
62}
63
64// What a request cost at list price, for hosts that keep no cost ledger
65export function listCost(code: string, u: Mix): number {
66 const p = PRICE[familyOfCode(code) ?? 'opus']
67 return (u.input * p.input + u.output * p.output + u.cacheRead * p.cacheRead + u.cacheWrite * p.cacheWrite) / 1e6
68}
69hooks/stats.ts 346 lines1// The window statistics behind the stats cards: pure functions over the
2// request ledger and the percentage samples, no engine calls.
3
4import { CODE, FAMILIES, familyOfCode, usdPerToken } from './prices'
5import type { Family, Mix } from './prices'
6
7// ---------- the ledger ----------
8
9// One minute of one model family's requests in one session:
10// [minute since epoch, family code, input, output, cache read, cache write, usd]
11export type Row = [number, string, number, number, number, number, number]
12
13export const MINUTE = 60_000
14// Rows older than this are dropped: a 7d window and a day to spare
15export const KEEP_MS = 8 * 86400_000
16
17export type Usage = { input: number; output: number; cacheRead: number; cacheWrite: number }
18
19// Adds a request's tokens to the ledger, in the row of its minute and family
20export function addTokens(rows: Row[], now: number, code: string, u: Usage): Row {
21 const min = Math.floor(now / MINUTE)
22 const row = findRow(rows, min, code)
23 row[2] += u.input
24 row[3] += u.output
25 row[4] += u.cacheRead
26 row[5] += u.cacheWrite
27 return row
28}
29
30// Adds dollars to the given row, else the latest if it is recent, else a new
31// one for this minute
32export function addUsd(rows: Row[], now: number, usd: number, row?: Row): void {
33 if (!(usd > 0)) return
34 const min = Math.floor(now / MINUTE)
35 const last = rows[rows.length - 1]
36 const into = row ?? (last && last[0] >= min - 1 ? last : findRow(rows, min, last ? last[1] : 'x'))
37 into[6] = Math.round((into[6] + usd) * 1e6) / 1e6
38}
39
40function findRow(rows: Row[], min: number, code: string): Row {
41 for (let i = rows.length - 1; i >= 0 && rows[i]![0] >= min; i--) {
42 const r = rows[i]!
43 if (r[0] === min && r[1] === code) return r
44 }
45 const row: Row = [min, code, 0, 0, 0, 0, 0]
46 rows.push(row)
47 return row
48}
49
50export function prune(rows: Row[], now: number): Row[] {
51 const oldest = Math.floor((now - KEEP_MS) / MINUTE)
52 return rows.filter(r => r[0] >= oldest)
53}
54
55export function isRow(r: unknown): r is Row {
56 return (
57 Array.isArray(r) &&
58 r.length === 7 &&
59 typeof r[1] === 'string' &&
60 [0, 2, 3, 4, 5, 6].every(i => typeof r[i] === 'number' && Number.isFinite(r[i]))
61 )
62}
63
64// ---------- percentage samples ----------
65
66// [seconds since epoch, percent used]
67export type Sample = [number, number]
68export type SampleLog = { resetsAt: number; pts: Sample[]; prev?: { resetsAt: number; pts: Sample[] } }
69
70// Two readings of one window may disagree on its reset by a few seconds
71const SAME_WINDOW_MS = 10 * MINUTE
72const MAX_SAMPLES = 400
73
74// The log with a new reading in it, or null when the reading adds nothing
75export function addSample(
76 log: SampleLog | undefined,
77 resetsAt: number,
78 now: number,
79 pct: number,
80 minGapMs: number,
81): SampleLog | null {
82 let next: SampleLog
83 if (!log || Math.abs(log.resetsAt - resetsAt) > SAME_WINDOW_MS) {
84 // a reading of an older window, from a session that has not caught up
85 if (log && log.resetsAt > resetsAt) return null
86 next = {
87 resetsAt,
88 pts: [],
89 ...(log && log.pts.length ? { prev: { resetsAt: log.resetsAt, pts: log.pts } } : log?.prev ? { prev: log.prev } : {}),
90 }
91 } else {
92 next = { ...log, pts: [...log.pts] }
93 }
94 const sec = Math.round(now / 1000)
95 const last = next.pts[next.pts.length - 1]
96 if (last && (sec <= last[0] || (last[1] === pct && (sec - last[0]) * 1000 < minGapMs))) return null
97 next.pts.push([sec, pct])
98 if (next.pts.length > MAX_SAMPLES) next.pts = next.pts.filter((_, i) => i % 2 === 0 || i === next.pts.length - 1)
99 return next
100}
101
102export function isSampleLog(v: unknown): v is SampleLog {
103 const o = v as SampleLog | null
104 return !!o && typeof o === 'object' && typeof o.resetsAt === 'number' && Array.isArray(o.pts)
105}
106
107// ---------- one window ----------
108
109export type WindowKind = 'five_hour' | 'seven_day'
110
111export const SPAN: Record<WindowKind, number> = { five_hour: 5 * 3600_000, seven_day: 7 * 86400_000 }
112
113export type WindowInput = {
114 kind: WindowKind
115 pct: number
116 resetsAt: number
117 now: number
118 rows: readonly Row[]
119 // when the ledger began: a window that started earlier is only partly tracked
120 since: number
121 samples?: SampleLog
122}
123
124export type Status =
125 | { kind: 'early' }
126 | { kind: 'pace'; projected: number }
127 | { kind: 'hit'; hitIn: number; beforeReset: number }
128 | { kind: 'reached' }
129
130export type WindowStats = {
131 kind: WindowKind
132 label: '5h' | '7d'
133 span: number
134 start: number
135 now: number
136 end: number
137 pct: number
138 // how far through the window, 0 to 1
139 frac: number
140 remaining: number
141 // the ledger began after the window did, at this time
142 isPartial: boolean
143 trackedSince: number
144 // dollars and tokens the ledger saw in the window
145 usd: number
146 tokens: number
147 used: Record<Family, number>
148 mix: Mix
149 // the whole window at API prices, once there is enough to tell
150 value: number | null
151 spent: number | null
152 left: number | null
153 // per hour for 5h, per day for 7d
154 unit: 'h' | 'day'
155 avgRate: number | null
156 allowedRate: number | null
157 // the same two in points of the limit, known without dollars
158 avgPctRate: number | null
159 allowedPctRate: number | null
160 // the family most of the dollars went to
161 ref: Family
162 status: Status
163 // [fraction of the window, percent], oldest first, ending now
164 curve: Array<[number, number]>
165 // the stretch before the ledger began, drawn faint
166 untracked: Array<[number, number]> | null
167 prev: { curve: Array<[number, number]>; atNow: number; atEnd: number } | null
168}
169
170// The pace needs this much of the window behind it
171const MIN_PACE_FRAC = 0.03
172// and the value this many points of the limit
173const MIN_VALUE_PCT = 1
174
175export function windowStats(w: WindowInput): WindowStats {
176 const span = SPAN[w.kind]
177 const end = w.resetsAt
178 const start = end - span
179 const now = Math.min(Math.max(w.now, start), end)
180 const elapsed = now - start
181 const remaining = end - now
182 const frac = elapsed / span
183 const pct = w.pct
184 const isPartial = w.since > start
185
186 // the ledger's view of the window
187 const used: Record<Family, number> = { fable: 0, opus: 0, sonnet: 0, haiku: 0 }
188 const usdBy: Record<string, number> = {}
189 const mix: Mix = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
190 const startMin = Math.floor(start / MINUTE)
191 const inWindow = w.rows.filter(r => r[0] >= startMin).sort((a, b) => a[0] - b[0])
192 let usd = 0
193 for (const r of inWindow) {
194 const tokens = r[2] + r[3] + r[4] + r[5]
195 const f = familyOfCode(r[1])
196 if (f) used[f] += tokens
197 mix.input += r[2]
198 mix.output += r[3]
199 mix.cacheRead += r[4]
200 mix.cacheWrite += r[5]
201 usd += r[6]
202 usdBy[r[1]] = (usdBy[r[1]] ?? 0) + r[6]
203 }
204 const tokens = mix.input + mix.output + mix.cacheRead + mix.cacheWrite
205 const ref =
206 FAMILIES.map(f => [f, usdBy[CODE[f]] ?? 0] as const).sort((a, b) => b[1] - a[1]).find(([, v]) => v > 0)?.[0] ??
207 'opus'
208
209 // the value: dollars over the share of the limit they used. A partly
210 // tracked window counts from the first reading after the ledger began.
211 let value: number | null = null
212 let base: [number, number] = [start, 0]
213 if (isPartial) {
214 const first = (w.samples && Math.abs(w.samples.resetsAt - end) < SAME_WINDOW_MS ? w.samples.pts : []).find(
215 p => p[0] * 1000 >= w.since,
216 )
217 base = first ? [first[0] * 1000, first[1]] : [now, pct]
218 }
219 const baseMin = Math.floor(base[0] / MINUTE)
220 const usdSinceBase = isPartial ? inWindow.filter(r => r[0] >= baseMin).reduce((s, r) => s + r[6], 0) : usd
221 const pctSinceBase = pct - base[1]
222 if (pctSinceBase >= MIN_VALUE_PCT && usdSinceBase > 0) value = usdSinceBase / (pctSinceBase / 100)
223
224 const spent = value === null ? null : (value * pct) / 100
225 const left = value === null ? null : Math.max(0, (value * (100 - pct)) / 100)
226 const unitMs = w.kind === 'five_hour' ? 3600_000 : 86400_000
227 const avgRate = spent === null || elapsed <= 0 ? null : (spent / elapsed) * unitMs
228 const allowedRate = left === null || remaining <= 0 ? null : (left / remaining) * unitMs
229 const avgPctRate = elapsed <= 0 ? null : (pct / elapsed) * unitMs
230 const allowedPctRate = remaining <= 0 ? null : (Math.max(0, 100 - pct) / remaining) * unitMs
231
232 let status: Status
233 if (pct >= 100) status = { kind: 'reached' }
234 else if (frac < MIN_PACE_FRAC || pct <= 0) status = { kind: 'early' }
235 else {
236 const rate = pct / elapsed
237 const projected = pct + rate * remaining
238 if (projected <= 100) status = { kind: 'pace', projected }
239 else {
240 const hitIn = (100 - pct) / rate
241 status = { kind: 'hit', hitIn, beforeReset: remaining - hitIn }
242 }
243 }
244
245 // the curve: the ledger's spending shape scaled to the readings
246 const toFrac = (t: number) => Math.min(1, Math.max(0, (t - start) / span))
247 let curve: Array<[number, number]> = []
248 const from = base[0]
249 const span0 = pct - base[1]
250 const sinceBase = inWindow.filter(r => r[0] >= baseMin)
251 const total = sinceBase.reduce((s, r) => s + r[6], 0)
252 if (total > 0 && span0 > 0) {
253 curve.push([toFrac(from), base[1]])
254 let cum = 0
255 for (const r of sinceBase) {
256 cum += r[6]
257 const t = Math.max(from, (r[0] + 1) * MINUTE)
258 curve.push([toFrac(Math.min(t, now)), base[1] + (span0 * cum) / total])
259 }
260 } else {
261 // no dollars to shape it: the readings themselves
262 const pts = w.samples && Math.abs(w.samples.resetsAt - end) < SAME_WINDOW_MS ? w.samples.pts : []
263 curve = pts.filter(p => p[0] * 1000 >= from).map(p => [toFrac(p[0] * 1000), p[1]] as [number, number])
264 if (!curve.length || curve[0]![0] > toFrac(from)) curve.unshift([toFrac(from), base[1]])
265 }
266 curve.push([frac, pct])
267 curve = thin(curve)
268 const untracked: Array<[number, number]> | null = isPartial && from > start ? [[0, 0], [toFrac(from), base[1]]] : null
269
270 // the window before, by the same fraction of its time
271 let prev: WindowStats['prev'] = null
272 const p = w.samples?.prev
273 if (p && p.pts.length >= 2 && p.resetsAt < end - SAME_WINDOW_MS) {
274 const pStart = p.resetsAt - span
275 const pc = p.pts
276 .map(([t, v]) => [(t * 1000 - pStart) / span, v] as [number, number])
277 .filter(([f]) => f >= 0 && f <= 1)
278 if (pc.length >= 2) {
279 prev = { curve: thin([[0, 0], ...pc]), atNow: valueAt([[0, 0], ...pc], frac), atEnd: pc[pc.length - 1]![1] }
280 }
281 }
282
283 return {
284 kind: w.kind,
285 label: w.kind === 'five_hour' ? '5h' : '7d',
286 span,
287 start,
288 now,
289 end,
290 pct,
291 frac,
292 remaining,
293 isPartial,
294 trackedSince: Math.max(start, w.since),
295 usd,
296 tokens,
297 used,
298 mix,
299 value,
300 spent,
301 left,
302 unit: w.kind === 'five_hour' ? 'h' : 'day',
303 avgRate,
304 allowedRate,
305 avgPctRate,
306 allowedPctRate,
307 ref,
308 status,
309 curve,
310 untracked,
311 prev,
312 }
313}
314
315// Linear between the points either side; the last value past the end
316export function valueAt(pts: ReadonlyArray<[number, number]>, f: number): number {
317 if (!pts.length) return 0
318 if (f <= pts[0]![0]) return pts[0]![1]
319 for (let i = 1; i < pts.length; i++) {
320 const [f1, v1] = pts[i]!
321 if (f <= f1) {
322 const [f0, v0] = pts[i - 1]!
323 return f1 === f0 ? v1 : v0 + ((v1 - v0) * (f - f0)) / (f1 - f0)
324 }
325 }
326 return pts[pts.length - 1]![1]
327}
328
329// At most one point per 1/200 of the window, keeping the last
330function thin(pts: Array<[number, number]>): Array<[number, number]> {
331 const out: Array<[number, number]> = []
332 for (const p of pts) {
333 const last = out[out.length - 1]
334 if (last && p[0] - last[0] < 0.005) out[out.length - 1] = [last[0], p[1]]
335 else out.push(p)
336 }
337 const lastPt = pts[pts.length - 1]
338 if (lastPt && out[out.length - 1] !== lastPt) out[out.length - 1] = lastPt
339 return out
340}
341
342// Tokens a sum of dollars buys of one family, at the window's split of kinds
343export function tokensFor(usd: number, f: Family, mix: Mix): number {
344 return usd / usdPerToken(f, mix)
345}
346types/index.d.ts 29 lines1export type Totals = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6}
7
8// Output tokens per second: estimated while a response streams, exact once it ends
9export type Speed = { tps: number; isLive: boolean }
10
11// What the stats cards show: the window's pace as a chart, or what is left per model
12export type StatsView = 'chart' | 'model'
13
14// The SVGs' colors: the system's, or forced light or dark
15export type Theme = 'auto' | 'light' | 'dark'
16
17declare module 'claude-code' {
18 interface PluginState {
19 'usage-band': {
20 totals: Totals
21 speed: Speed | null
22 charsPerToken: number
23 statsOpen: boolean
24 statsView: StatsView
25 theme: Theme
26 }
27 }
28}
29