SLOPSHOPPER

usage-band

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…

newbandcommandtoaststatustimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /usage-band ⎿ usage-band: Usage now shows on the status line. 5h ██░░░░░░ 31% ↻ NaNm in 0 out 0 ⚡ – t/s cache 0 $ 0.42 ctx 49% stats ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ usage-band: 5h ▰▰▱▱▱ 31% ↻NaNm · in 0 out 0 ⚡– t/s cache 0 · $0.42 · ctx ▰▰▱▱▱ 49%

Draws

Band
5h ██░░░░░░ 31% ↻ NaNm in 0 out 0 ⚡ – t/s cache 0 $ 0.42 ctx 49% stats
README

usage-band

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.

Source 6 files
hooks/register.tsx 781 lines
1import { 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}
781
hooks/cards.ts 272 lines
1// 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}
272
hooks/format.ts 53 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
52}
53
hooks/prices.ts 69 lines
1// 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}
69
hooks/stats.ts 346 lines
1// 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}
346
types/index.d.ts 29 lines
1export 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