SLOPSHOPPER

usage-band

Usage band above the prompt: limits with projection, context, tokens, speed, cost, turns, tools, churn, agents, model, effort, folder, git and memory, each one…

newpanebandguardcommandtoast
★ 1v0.3.0MITupdated 2026-10-03ersinkoc/claude-usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
│ ┃ usage-band-settings ✕ › fix the failing auth test and add an audit log call │ ┃ Pills │ ┃ [x] 5-hour limit ⏺ Read(src/auth.ts) │ ┃ [x] 7-day limit ⎿ Read 6 lines │ ┃ [x] Spend limit ⏺ Update(src/auth.ts) │ ┃ [x] Context window ⎿ Added 2 lines, removed 1 line │ ┃ [x] Input tokens ⏺ Bash(bun test) │ ┃ [x] Output tokens ⎿ 3 pass, 1 fail │ ┃ [x] Thinking tokens │ ┃ [x] Cache read tokens ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [x] Cache hit rate │ ┃ [x] Web searches and fetches ✻ Worked for 42s · done 4:20 PM │ ┃ [x] API requests │ ┃ [x] Output speed › /usage-band │ ┃ [x] Session cost ⎿ usage-band: 5h 31% · ctx 49% | in ~6.4k · out ~1.5k · cache ~91. │ ┃ [x] Cost per hour │ ┃ [x] Last turn │ ┃ [x] Tool calls │ ┃ [x] Lines changed │ ┃ [x] Running subagents │ ┃ [x] Compactions │ ┃ [x] Prompts │ ┃ [x] Session age │ ┃ [x] Model │ ┃ [x] Thinking effort │ ┃ [x] Folder ◔ 5h ███░░░░░░░ 31% ◑ ctx █████░░░░░ 49% ↑ ~6.4k ↓ ~1.5k ≋ ~91.0k ◎ 93% ⇅ ~1 $ $0.42 ↗ $0.84/h ⧗ 42s · $ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◔ 5h ███░░░░░░░ 31% ◑ ctx █████░░░░░ 49% ↑ ~6.4k ↓ ~1.5k ≋ ~91.0k ◎ 93% ⇅ ~1 $ $0.42 ↗ $0.
Pane · usage-band-settings
Pills [x] 5-hour limit [x] 7-day limit [x] Spend limit [x] Context window [x] Input tokens [x] Output tokens [x] Thinking tokens [x] Cache read tokens [x] Cache hit rate [x] Web searches and fetches [x] API requests [x] Output speed [x] Session cost [x] Cost per hour [x] Last turn [x] Tool calls [x] Lines changed [x] Running subagents [x] Compactions [x] Prompts [x] Session age [x] Model [x] Thinking effort [x] Folder [x] Git branch [x] Machine memory Options [x] Limit projection [x] Context breakdown [x] Limit alerts [x] Show in the terminal Layout: full (press for compact) Yellow from 70% (press for 80%) Red from 90% (press for 95%) Refresh every 30s (press for 60s) Each change is saved to your settings and reloads the band. [ Close ]
README

claude-usage-band

A Claude Code mod that puts a usage band right above the prompt, in the desktop app's Code tab and in the terminal. It shows your 5-hour and 7-day limits with time to reset, how full the context is, token totals, cost, speed, the last turn, tool calls, git state and more. Each pill can be switched on or off.

The band in the dark theme

The band in the light theme

Hover any pill for its breakdown: input split into uncached and cache writes (with the 1-hour/5-minute TTL split), how much of the context each category fills, the most used tools, and where your pace puts a limit at reset.


⚡ Sponsored by WrongStack

Built on the wrong stack. Shipped anyway.

WrongStack is a free, open-source AI coding agent with a Brain, a Memory, and a full toolbox. It reads your code, edits files, runs commands, and coordinates specialist agents — across six surfaces, from a plain terminal REPL to a cross-machine HQ dashboard. No subscription required, and you keep your hand on every permission.

Website   GitHub   Stars

curl -fsSL https://wrongstack.com/install.sh | sh   # macOS / Linux — self-contained binary
irm https://wrongstack.com/install.ps1 | iex        # Windows (PowerShell) — no Node.js needed
What you get
🌐 200+ LLM providersCatalog pulled live from models.dev — Anthropic, OpenAI, Google, plus OAuth sign-in for Claude Pro/Max, ChatGPT and Copilot, and any OpenAI-compatible endpoint (Ollama, vLLM, LM Studio)
🛠️ 67 built-in toolsEdits, lint/typecheck/test, execution, git, web, browser/E2E and a SQLite codebase index — every call gated by per-tool permissions
🧠 SAGE memoryProject-wide long-term memory in SQLite/FTS5, anchored to files, symbols and commits — it gets better at your codebase over time
🖥️ Six surfacesReadline REPL · Ink/React TUI (--tui) · WebUI · SimpleUI · Desktop · cross-machine HQ (--hq)
🤖 Fleet orchestrationA Director fans out specialist subagents over a project mailbox; eternal & parallel goal loops run until the contract verifies
🔍 Chimera & KanbanAuto-review agents that critique your diffs with severity-ranked findings, plus durable Kanban boards with atomic verification
🔐 Secure by defaultEncrypted secrets at rest, project-root containment, opt-in YOLO mode — MIT licensed, TypeScript-strict

📊 The perfect pairing: This band tells you exactly where your Claude limits stand — and WrongStack keeps you moving when they close in. It reads plan windows for Claude, ChatGPT, Copilot, Z.AI and more right in its own statusline and quota page, and when one provider runs dry, fallback chains rotate you onto the next model automatically. Watch the band, dodge the wall.

🔗 wrongstack.com  ·  github.com/WrongStack/WrongStack  ·  ⭐ Star it on GitHub


Install

You need Claude Code 2.1.286 or newer (mods are early access) and Node 18.3 or newer.

git clone https://github.com/ersinkoc/claude-usage-band.git
cd claude-usage-band
node install.mjs

Then start a new session, or restart the desktop app. The band appears after the first model response, once the limits have a reading.

install.mjs copies the mod to ~/.claude/mods/usage-band and adds two entries to the env block of ~/.claude/settings.json. It honors CLAUDE_CONFIG_DIR if you set it, and it backs up settings.json and leaves every other setting alone:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/home/you/.claude/mods/usage-band",
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

If CLAUDE_CODE_PLUGIN_DIRS already lists other folders, they are kept. Entries are separated by : on macOS and Linux and by ; on Windows.

CommandWhat it does
node install.mjsCopy and register (run it again after git pull to update)
node install.mjs --linkRegister this folder itself instead of a copy, so your edits apply live
node install.mjs --uninstallUnregister and remove the copy
node install.mjs --dry-runShow what would change without changing anything

To try it without installing, run a single session with claude --plugin-dir /path/to/claude-usage-band.

Commands

CommandWhat it does
/usage-bandRefresh everything and print a one-line summary
/usage-band settingsOpen a settings pane with a checkbox for each pill (this works in the desktop app, which has no /config)
/usage-band on git, cost-rateTurn pills on; off turns them off. Names are loose: git, showGit and Git branch all match
/usage-band set layout compactAlso set yellow 60, set red 85, set refresh 60
/usage-band hide / showHide or show the whole band (remembered across sessions)

Running it headless from Git Bash on Windows? Git Bash turns /usage-band into a Windows path. Use MSYS_NO_PATHCONV=1 claude -p "/usage-band", or run it from PowerShell.

Settings

Settings are stored in settings.json under pluginConfigs. They also appear in /config in a terminal session. Every pill is on by default.

SettingShows
show5h5-hour limit: usage of the 5-hour rate-limit window and time to reset
show7d7-day limit: usage of the 7-day rate-limit window and time to reset
showSpendLimitSpend limit: a gateway's spend limit, when the account reports one
showContextContext window: how full the context window is
showInputInput tokens: uncached input plus cache writes
showOutputOutput tokens: tokens the model generated
showThinkingThinking tokens: the part of output spent on extended thinking
showCacheReadCache read tokens: input tokens served from the prompt cache
showCacheHitCache hit rate: share of all input read from the prompt cache
showWebWeb searches and fetches: server-side web search and fetch requests
showRequestsAPI requests: model requests, subagents included
showSpeedOutput speed: tokens per second of the last response (hover for the average)
showCostSession cost: session cost at API list prices
showCostRateCost per hour: average spend per hour since the session started
showLastTurnLast turn: duration, cost and output speed of the last turn
showToolsTool calls: tool calls, failures and the most used tools
showChurnLines changed: lines added and removed by Edit, MultiEdit and Write
showAgentsRunning subagents: subagents running right now
showCompactionsCompactions: how often the context was compacted and what it freed
showPromptsPrompts: prompts sent this session
showSessionAgeSession age: time since the session started
showModelModel: the model that answered last, and the Claude Code version
showEffortThinking effort: the thinking effort of the last request (low to max)
showFolderFolder: the working directory's name (hover for the full path)
showGitGit branch: branch, uncommitted files, ahead and behind
showMemoryMachine memory: this machine's memory in use
OptionDefaultWhat it does
limitProjectiononProject 5h/7d usage at reset from the pace so far, and warn when it would pass 100%
contextBreakdownonList what fills the context in the context tooltip (estimated locally)
alertsonShow a toast when a limit crosses the yellow or red threshold
showInTerminalonTurn off to show the band only in the desktop app
layoutfullcompact drops icons and bars and labels each value instead
warnAt / hotAt70 / 90The percentages at which bars turn yellow and red
refreshSeconds30How often countdowns and totals refresh (10 to 600)

How it works

  • Limits, context and cost come from the engine ($.session.usage(), and the session.measure event after every turn). The limit pills stay hidden until the first model response, because only then are the limits known. The bar turns yellow and red at your thresholds. The vertical line marks how much of the window has passed.
  • Token totals, tool calls and lines changed are not in the API, so scripts/tokens.mjs reads the session's transcript (~/.claude/projects/*/<session>.jsonl, plus its subagents' files). The transcript writes a message once per content block, so rows are de-duplicated by message id and request id. The script runs again only when the transcript's size or modification time changes. If Node can't run it, the token pills fall back to summing each turn's usage and show a ~.
  • Speed, model and effort come from each streamed response. Last turn, running subagents and compactions come from engine events.
  • Cost is at API list prices. On a subscription, that is not what you pay.

In the desktop app each pill is an SVG with a <title> tooltip that follows the light or dark theme. In the terminal the band is colored text, with █░ bars and a │ for elapsed time.

Development

node install.mjs --link   # load this folder in every new session; edits reload live
npm test                  # claude plugin test .  (10 tests, terminal and desktop)
npm run validate          # claude plugin validate .
npm run typecheck         # needs the API types: run /plugin-types once in a session here
npm run preview           # docs/preview.html: every pill, both themes (Node 22.18+)
npm run manifest          # regenerate plugin.json's settings from hooks/pills.ts
PathWhat it is
.claude-plugin/plugin.jsonManifest and settings (userConfig)
hooks/register.tsxThe hooks: data, commands, settings pane, and drawing for each surface
hooks/pills.tsThe pill model and desktop SVG drawing, with no engine calls (shared with the preview)
types/index.d.tsThe $.state contract
scripts/tokens.mjs (transcript totals) and sysinfo.mjs (memory)
tests/claude plugin test suite

License

MIT

Source 3 files
hooks/register.tsx 746 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  EngineInterface,
4  Register,
5  SessionContextUsage,
6  SessionCost,
7  SessionRateLimit,
8} from 'claude-code'
9
10import type {
11  UsageActivity,
12  UsageAlerted,
13  UsageGit,
14  UsageLimit,
15  UsageSnapshot,
16  UsageTokens,
17  UsageTranscript,
18} from '../types'
19import {
20  barLevel,
21  buildPills,
22  compactText,
23  formatPercent,
24  isBar,
25  LAYOUTS,
26  lookOf,
27  pillSvg,
28  readSettings,
29  SWITCHES,
30  terminalBar,
31  TERMINAL_BAR,
32  TERMINAL_COLORS,
33  TERMINAL_ICONS,
34  TOGGLES,
35} from './pills'
36import type { Pill, Settings } from './pills'
37
38const EMPTY_ACTIVITY: UsageActivity = {
39  lastTurn: null,
40  toolCalls: 0,
41  toolFailures: 0,
42  byTool: {},
43  agentsRunning: 0,
44  compactions: 0,
45  compactionFreed: 0,
46}
47const NO_ALERTS: UsageAlerted = { fiveHour: 0, sevenDay: 0, spendLimit: 0 }
48
49const snapshot = atom({ plugin: 'usage-band', key: 'snapshot' } as const, null)
50const tokens = atom({ plugin: 'usage-band', key: 'tokens' } as const, null)
51const fallback = atom({ plugin: 'usage-band', key: 'fallback' } as const, null)
52const transcript = atom({ plugin: 'usage-band', key: 'transcript' } as const, null)
53const activity = atom({ plugin: 'usage-band', key: 'activity' } as const, EMPTY_ACTIVITY)
54const stream = atom({ plugin: 'usage-band', key: 'stream' } as const, null)
55const session = atom({ plugin: 'usage-band', key: 'session' } as const, null)
56const alerted = atom({ plugin: 'usage-band', key: 'alerted' } as const, NO_ALERTS)
57const isHidden = atom({ plugin: 'usage-band', key: 'isHidden' } as const, false)
58const now = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
59
60const HIDDEN_KEY = 'usage-band.isHidden'
61const SETTINGS_PANE = 'usage-band-settings'
62const REFRESH_CHOICES = [15, 30, 60, 120]
63const WARN_CHOICES = [50, 60, 70, 80]
64const HOT_CHOICES = [80, 85, 90, 95]
65const NODES = ['node', '/usr/local/bin/node', '/opt/homebrew/bin/node', 'C:\\Program Files\\nodejs\\node.exe']
66const AGENT_TOOLS = new Set(['Agent', 'Task'])
67const ALERT_NAMES: Record<keyof UsageAlerted, string> = {
68  fiveHour: '5-hour limit',
69  sevenDay: '7-day limit',
70  spendLimit: 'Spend limit',
71}
72
73type Measured = { context: SessionContextUsage; rateLimits: SessionRateLimit[]; cost?: SessionCost }
74
75type ScriptResult =
76  | { found: false }
77  | ({ found: true; path: string; size: number; mtimeMs: number } & Omit<UsageTokens, 'isEstimate'> &
78      UsageTranscript)
79
80// Module state: a reload starts these over; what the band draws lives in $.state.
81let settings: Settings = readSettings({})
82let counted: { path: string; size: number; mtimeMs: number } | null = null
83let node: string | null = null
84let counting: Promise<void> | null = null
85/** Session cost when each turn started, by turn id, until the main turn ends. */
86const turnStarts = new Map<string, number | null>()
87
88function nextOf<T>(choices: readonly T[], current: T): T {
89  return choices[(choices.indexOf(current) + 1) % choices.length] ?? choices[0]!
90}
91
92function toSnapshot(m: Measured, previous: UsageSnapshot | null): UsageSnapshot {
93  const limit = (kind: string): UsageLimit | null => {
94    const found = m.rateLimits.find(one => one.kind === kind)
95
96    return found ? { percent: found.percentUsed, resetsAt: found.resetsAt ?? null } : null
97  }
98  const breakdown = m.context.breakdown
99  const categories = breakdown
100    ? breakdown.categories
101        .filter(one => one.tokens > 0)
102        .sort((a, b) => b.tokens - a.tokens)
103        .map(one => ({ name: one.name, tokens: one.tokens }))
104    : (previous?.contextCategories ?? null)
105
106  return {
107    fiveHour: limit('five_hour'),
108    sevenDay: limit('seven_day'),
109    spendLimit: limit('spend_limit'),
110    contextTokens: m.context.tokens ?? null,
111    contextWindow: m.context.window || null,
112    contextPercent: m.context.percent ?? null,
113    contextCategories: settings.contextBreakdown ? categories : null,
114    autoCompactAt: breakdown ? (breakdown.autoCompactThreshold ?? null) : (previous?.autoCompactAt ?? null),
115    costUsd: m.cost?.usd ?? null,
116  }
117}
118
119/** Toasts once when a limit climbs into the yellow or red band; stays quiet on the way down. */
120async function alertLimits($: EngineInterface, held: UsageSnapshot): Promise<void> {
121  const look = lookOf(settings)
122  const time = await read($, now)
123  for (const which of ['fiveHour', 'sevenDay', 'spendLimit'] as const) {
124    const limit = held[which]
125    if (!limit) {
126      continue
127    }
128    const levels = { ok: 0, warn: 1, high: 2 } as const
129    const reached = levels[barLevel(limit.percent, look)]
130    const before = (await read($, alerted))[which]
131    if (reached === before) {
132      continue
133    }
134    await update($, alerted, prev => ({ ...prev, [which]: reached }))
135    if (!settings.alerts || reached < before) {
136      continue
137    }
138    const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
139    const left = Number.isFinite(resetsAt) && time > 0 ? `, resets in ${formatLeft(resetsAt - time)}` : ''
140    $.ui.toast(`${reached === 2 ? '⚠ ' : ''}${ALERT_NAMES[which]} at ${formatPercent(limit.percent)}${left}`, {
141      timeoutMs: reached === 2 ? 10_000 : 6000,
142    })
143  }
144}
145
146function formatLeft(ms: number): string {
147  const minutes = Math.max(0, Math.floor(ms / 60_000))
148
149  return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`
150}
151
152async function measure($: EngineInterface, m: Measured): Promise<void> {
153  // A measurement is a fresh moment: countdowns count from it.
154  const time = await $.clock.now()
155  await update($, now, () => time)
156  const held = await update($, snapshot, previous => toSnapshot(m, previous))
157  if (held) {
158    await alertLimits($, held)
159  }
160}
161
162/** Runs one of the mod's scripts with the first node that starts; null when none ran it cleanly. */
163async function runNode($: EngineInterface, script: string, args: string[]): Promise<string | null> {
164  const path = `${$.plugin.root}/scripts/${script}`
165  for (const candidate of node ? [node] : NODES) {
166    try {
167      const run = await $.process.run([candidate, path, ...args], { timeoutMs: 20_000 })
168      if (run.exitCode !== 0) {
169        return null
170      }
171      node = candidate
172
173      return run.stdout
174    } catch {
175      // This node did not start; try the next one.
176    }
177  }
178
179  return null
180}
181
182async function recount($: EngineInterface, force: boolean): Promise<void> {
183  if (!force && counted) {
184    const stat = await $.fs.stat(counted.path).catch(() => null)
185    if (stat && stat.size === counted.size && stat.mtimeMs === counted.mtimeMs) {
186      return
187    }
188  }
189  const stdout = await runNode($, 'tokens.mjs', [await $.session.id()])
190  let result: ScriptResult | null = null
191  try {
192    result = stdout === null ? null : (JSON.parse(stdout) as ScriptResult)
193  } catch {
194    result = null
195  }
196  if (result === null || !result.found) {
197    return
198  }
199  counted = { path: result.path, size: result.size, mtimeMs: result.mtimeMs }
200  const totals: UsageTokens = {
201    uncached: result.uncached,
202    cacheWrite: result.cacheWrite,
203    cacheWrite1h: result.cacheWrite1h ?? 0,
204    cacheWrite5m: result.cacheWrite5m ?? 0,
205    output: result.output,
206    thinking: result.thinking ?? 0,
207    cacheRead: result.cacheRead,
208    webSearches: result.webSearches ?? 0,
209    webFetches: result.webFetches ?? 0,
210    requests: result.requests,
211    isEstimate: false,
212  }
213  const edits: UsageTranscript = {
214    toolCalls: result.toolCalls ?? 0,
215    toolFailures: result.toolFailures ?? 0,
216    byTool: result.byTool ?? {},
217    linesAdded: result.linesAdded ?? 0,
218    linesRemoved: result.linesRemoved ?? 0,
219    filesChanged: result.filesChanged ?? 0,
220  }
221  await update($, tokens, () => totals)
222  await update($, transcript, () => edits)
223}
224
225/** Counts the transcript's tokens unless its size and mtime are unchanged. */
226async function countTokens($: EngineInterface, force = false): Promise<void> {
227  if (!counting) {
228    counting = recount($, force).finally(() => {
229      counting = null
230    })
231  }
232
233  return counting
234}
235
236/** Branch, uncommitted files, ahead and behind, from one `git status`. */
237async function readGit($: EngineInterface): Promise<UsageGit | null> {
238  if (!(await $.session.repo())) {
239    return null
240  }
241  const run = await $.process.run(['git', 'status', '--porcelain=v1', '--branch'], { timeoutMs: 10_000 }).catch(() => null)
242  if (!run || run.exitCode !== 0) {
243    return null
244  }
245  const [head = '', ...rest] = run.stdout.split('\n')
246  const name = head.replace(/^## (No commits yet on )?/, '')
247  const branch = name.split('...')[0]?.split(' ')[0] || 'HEAD'
248
249  return {
250    branch,
251    dirty: rest.filter(line => line.trim() !== '').length,
252    ahead: Number(/ahead (\d+)/.exec(head)?.[1] ?? 0),
253    behind: Number(/behind (\d+)/.exec(head)?.[1] ?? 0),
254  }
255}
256
257async function readMemory($: EngineInterface): Promise<{ total: number; free: number } | null> {
258  const stdout = await runNode($, 'sysinfo.mjs', [])
259  try {
260    const parsed = stdout === null ? null : (JSON.parse(stdout) as { total: number; free: number })
261
262    return parsed && parsed.total > 0 ? parsed : null
263  } catch {
264    return null
265  }
266}
267
268async function refresh($: EngineInterface, force = false): Promise<void> {
269  const time = await $.clock.now()
270  await update($, now, () => time)
271  const wantsBreakdown = settings.showContext && settings.contextBreakdown
272  const usage = await $.session.usage(wantsBreakdown ? { breakdown: 'summary' } : undefined)
273  await measure($, usage)
274  const [model, version, prompts, cwd, git, memory] = await Promise.all([
275    settings.showModel ? $.session.model() : Promise.resolve(null),
276    settings.showModel ? $.session.version().then(v => v.version) : Promise.resolve(null),
277    settings.showPrompts ? $.session.turns() : Promise.resolve(null),
278    settings.showFolder ? $.session.cwd() : Promise.resolve(null),
279    settings.showGit ? readGit($) : Promise.resolve(null),
280    settings.showMemory ? readMemory($) : Promise.resolve(null),
281  ])
282  await update($, session, () => ({ startedAt: usage.startedAt, model, version, prompts, cwd, git, memory }))
283  await countTokens($, force)
284}
285
286/** The pills as the band and /usage-band draw them, from the values held now. */
287async function currentPills($: EngineInterface): Promise<Pill[][]> {
288  // Until the transcript has been counted (or when it cannot be), turn usage stands in, marked "~".
289  const counts = (await read($, tokens)) ?? (await read($, fallback))
290
291  return buildPills({
292    snapshot: await read($, snapshot),
293    tokens: counts,
294    transcript: await read($, transcript),
295    activity: await read($, activity),
296    stream: await read($, stream),
297    session: await read($, session),
298    now: await read($, now),
299    settings,
300  })
301}
302
303async function recordStream($: EngineInterface, model: string, effort: string | null, tokens: number, ms: number): Promise<void> {
304  await update($, stream, prev => {
305    const isTimed = ms >= 250 && tokens > 0
306
307    return {
308      model,
309      effort: effort ?? prev?.effort ?? null,
310      lastTps: isTimed ? (tokens / ms) * 1000 : (prev?.lastTps ?? null),
311      tokens: (prev?.tokens ?? 0) + (isTimed ? tokens : 0),
312      ms: (prev?.ms ?? 0) + (isTimed ? ms : 0),
313      responses: (prev?.responses ?? 0) + (isTimed ? 1 : 0),
314    }
315  })
316}
317
318/** Writes one of this plugin's settings, as the /config menu would. */
319async function setOption($: EngineInterface, field: string, value: boolean | number | string): Promise<string | null> {
320  const rows = await $.config.list()
321  const row = rows.find(one => one.key === `usage-band.${field}` || (one.key.startsWith('usage-band') && one.key.endsWith(`.${field}`)))
322  if (!row) {
323    return `no setting named ${field}`
324  }
325  const result = await $.config.set({ key: row.key, value })
326
327  return result.deny ?? null
328}
329
330/** The setting a word names: `git`, `cost-rate`, `showGit`, `Cost per hour`. */
331function findSetting(word: string): string | null {
332  const squash = (text: string) => text.toLowerCase().replace(/[^a-z0-9]/g, '')
333  const wanted = squash(word)
334  const found = [...TOGGLES, ...SWITCHES].find(
335    one => squash(one.field) === wanted || squash(one.field.replace(/^show/, '')) === wanted || squash(one.title) === wanted,
336  )
337
338  return found?.field ?? null
339}
340
341/** `/usage-band set <option> <value>` for the settings that are not on/off. */
342async function setValue($: EngineInterface, name: string, value: string): Promise<string> {
343  const option = name.toLowerCase().replace(/[^a-z]/g, '')
344  if (option === 'layout') {
345    if (value !== 'full' && value !== 'compact') {
346      return 'Layout is full or compact.'
347    }
348
349    return (await setOption($, 'layout', value)) ?? `Layout set to ${value}.`
350  }
351  const fields: Record<string, [string, number, number]> = {
352    yellow: ['warnAt', 1, 100],
353    warn: ['warnAt', 1, 100],
354    warnat: ['warnAt', 1, 100],
355    red: ['hotAt', 1, 100],
356    hot: ['hotAt', 1, 100],
357    hotat: ['hotAt', 1, 100],
358    refresh: ['refreshSeconds', 10, 600],
359    refreshseconds: ['refreshSeconds', 10, 600],
360  }
361  const target = fields[option]
362  const n = Number(value)
363  if (!target) {
364    return 'Options: layout full|compact, yellow <percent>, red <percent>, refresh <seconds>'
365  }
366  if (!Number.isFinite(n) || n < target[1] || n > target[2]) {
367    return `${name} takes a number from ${target[1]} to ${target[2]}.`
368  }
369
370  return (await setOption($, target[0], Math.round(n))) ?? `${name} set to ${Math.round(n)}.`
371}
372
373function describeSettings(): string {
374  const on = TOGGLES.filter(one => settings[one.field]).map(one => one.title)
375  const off = TOGGLES.filter(one => !settings[one.field]).map(one => one.title)
376  const switches = SWITCHES.map(one => `${one.title} ${settings[one.field] ? 'on' : 'off'}`)
377
378  return [
379    `Shown: ${on.join(', ') || 'nothing'}`,
380    `Hidden: ${off.join(', ') || 'nothing'}`,
381    switches.join(', '),
382    `Layout ${settings.layout} · yellow from ${settings.warnAt}% · red from ${settings.hotAt}% · refresh every ${settings.refreshSeconds}s`,
383  ].join('\n')
384}
385
386const USAGE = 'Usage: /usage-band [settings|hide|show|on <pill>|off <pill>|set <option> <value>]'
387
388export const register: Register = (on, options) => {
389  settings = readSettings(options)
390
391  on('session.start', async ($, e, next) => {
392    await $.command.register({
393      name: 'usage-band',
394      description: 'Usage band: refresh and summarize · settings · hide/show · on/off <pill> · set <option> <value>',
395      argumentHint: '[settings|hide|show|on <pill>|off <pill>|set <option> <value>]',
396    })
397    const stored = await $.store.get(HIDDEN_KEY)
398    await update($, isHidden, () => stored === true)
399    $.clock.every(settings.refreshSeconds * 1000, () => {
400      void refresh($).catch(() => undefined)
401    })
402    void refresh($).catch(() => undefined)
403
404    return next(e)
405  })
406
407  on('session.measure', async ($, e, next) => {
408    await measure($, e)
409    const cost = e.cost?.usd
410    if (cost !== undefined && turnStarts.size === 0) {
411      await update($, activity, held =>
412        held.lastTurn ? { ...held, lastTurn: { ...held.lastTurn, endCost: cost } } : held,
413      )
414    }
415
416    return next(e)
417  })
418
419  on('turn.start', async ($, e, next) => {
420    const held = await read($, snapshot)
421    turnStarts.set(e.turnId, held?.costUsd ?? null)
422
423    return next(e)
424  })
425
426  // Model, effort and output speed of each main-loop response, timed from its first streamed piece.
427  on('turn.step', async function* ($, e, next) {
428    if (e.agentId !== undefined) {
429      return yield* next(e)
430    }
431    const effort = e.effort === undefined ? null : String(e.effort)
432    let firstAt: number | null = null
433    const chunks = next(e)
434    while (true) {
435      const step = await chunks.next()
436      if (step.done) {
437        return step.value
438      }
439      const chunk = step.value
440      if (firstAt === null && chunk.kind !== 'engine' && chunk.kind !== 'stop') {
441        firstAt = Date.now()
442      }
443      if (chunk.kind === 'stop') {
444        const ms = firstAt === null ? 0 : Date.now() - firstAt
445        void recordStream($, chunk.usage?.model || e.model, effort, chunk.usage?.output_tokens ?? 0, ms).catch(() => undefined)
446      }
447      yield chunk
448    }
449  })
450
451  on('turn.complete', async ($, e, next) => {
452    const result = await next(e)
453    const usage = result.usage ?? e.usage
454    if (usage) {
455      await update($, fallback, held => ({
456        uncached: (held?.uncached ?? 0) + usage.input_tokens,
457        cacheWrite: (held?.cacheWrite ?? 0) + usage.cache_creation_input_tokens,
458        cacheWrite1h: 0,
459        cacheWrite5m: 0,
460        output: (held?.output ?? 0) + usage.output_tokens,
461        thinking: 0,
462        cacheRead: (held?.cacheRead ?? 0) + usage.cache_read_input_tokens,
463        webSearches: 0,
464        webFetches: 0,
465        requests: (held?.requests ?? 0) + 1,
466        isEstimate: true,
467      }))
468    }
469    if (e.agentId === undefined) {
470      const startCost = turnStarts.get(e.turnId) ?? null
471      turnStarts.clear()
472      await update($, activity, held => ({
473        ...held,
474        lastTurn: { durationMs: e.durationMs, output: usage?.output_tokens ?? 0, startCost, endCost: null },
475      }))
476    }
477    void countTokens($).catch(() => undefined)
478
479    return result
480  })
481
482  on('tool.call', async ($, e, next) => {
483    const isAgent = AGENT_TOOLS.has(e.tool)
484    if (isAgent) {
485      await update($, activity, held => ({ ...held, agentsRunning: held.agentsRunning + 1 }))
486    }
487    try {
488      const result = await next(e)
489      const isFailed = result.deny !== undefined || result.isError === true
490      await update($, activity, held => ({
491        ...held,
492        toolCalls: held.toolCalls + 1,
493        toolFailures: held.toolFailures + (isFailed ? 1 : 0),
494        byTool: { ...held.byTool, [e.tool]: (held.byTool[e.tool] ?? 0) + 1 },
495      }))
496
497      return result
498    } finally {
499      if (isAgent) {
500        await update($, activity, held => ({ ...held, agentsRunning: Math.max(0, held.agentsRunning - 1) }))
501      }
502    }
503  })
504
505  on('session.compact', async ($, e, next) => {
506    const result = await next(e)
507    if (e.agentId === undefined && result.messages) {
508      const freed =
509        result.tokensBefore !== undefined && result.tokensAfter !== undefined
510          ? Math.max(0, result.tokensBefore - result.tokensAfter)
511          : 0
512      await update($, activity, held => ({
513        ...held,
514        compactions: held.compactions + 1,
515        compactionFreed: held.compactionFreed + freed,
516      }))
517    }
518
519    return result
520  })
521
522  on('command.run', { command: 'usage-band' }, async ($, e) => {
523    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
524    const arg = verb.toLowerCase()
525    if (arg === 'hide') {
526      await update($, isHidden, () => true)
527      await $.store.set(HIDDEN_KEY, true)
528
529      return { text: 'Usage band hidden. Bring it back with /usage-band show' }
530    }
531    if (arg === 'show') {
532      await update($, isHidden, () => false)
533      await $.store.set(HIDDEN_KEY, false)
534
535      return { text: 'Usage band shown.' }
536    }
537    if (arg === 'settings') {
538      await $.ui.open({ id: SETTINGS_PANE, title: 'Usage band settings' })
539
540      return { text: describeSettings() }
541    }
542    if (arg === 'set') {
543      const [name = '', value = ''] = rest
544
545      return { text: await setValue($, name, value.toLowerCase()) }
546    }
547    if (arg === 'on' || arg === 'off') {
548      const names = rest.join(' ').split(',').map(word => word.trim()).filter(Boolean)
549      if (names.length === 0) {
550        return { text: `Usage: /usage-band ${arg} <pill>[, <pill>...]  (e.g. git, cost-rate, thinking)` }
551      }
552      const lines: string[] = []
553      for (const name of names) {
554        const field = findSetting(name)
555        const problem = field ? await setOption($, field, arg === 'on') : `unknown pill "${name}"`
556        lines.push(problem ? `${name}: ${problem}` : `${name}: ${arg}`)
557      }
558
559      return { text: lines.join('\n') }
560    }
561    if (arg !== '') {
562      return { text: USAGE }
563    }
564
565    await refresh($, true)
566    const groups = await currentPills($)
567    const parts = groups.map(group =>
568      group
569        .map(one =>
570          isBar(one)
571            ? `${one.label} ${formatPercent(one.percent)}${one.remaining ? ` (${one.remaining})` : ''}${one.isAtRisk ? ' !' : ''}`
572            : compactText(one),
573        )
574        .join(' · '),
575    )
576    const text = parts.length > 0 ? parts.join(' | ') : 'No usage data yet (it arrives with the first model response).'
577    const note = groups.some(group => group.some(one => one.kind === 'cost')) ? ' (cost at API list prices)' : ''
578
579    return { text: `${text}${note}` }
580  })
581
582  on('ui.render', { component: 'Pane', requestId: SETTINGS_PANE }, async ($, e) => {
583    const { Box, Button, Text } = $.ui.resolve(e)
584    const mark = (isOn: boolean) => (isOn ? '[x]' : '[ ]')
585    const refreshNext = nextOf(REFRESH_CHOICES, settings.refreshSeconds)
586    const warnNext = nextOf(WARN_CHOICES, settings.warnAt)
587    const hotNext = nextOf(HOT_CHOICES, settings.hotAt)
588    const layoutNext = nextOf(LAYOUTS, settings.layout)
589
590    return (
591      <Box flexDirection="column">
592        <Text bold>Pills</Text>
593        {TOGGLES.map(one => (
594          <Button
595            key={one.field}
596            plain
597            label={`${mark(settings[one.field])} ${one.title}`}
598            onPress={() => setOption($, one.field, !settings[one.field])}
599          />
600        ))}
601        <Text bold>Options</Text>
602        {SWITCHES.map(one => (
603          <Button
604            key={one.field}
605            plain
606            label={`${mark(settings[one.field])} ${one.title}`}
607            onPress={() => setOption($, one.field, !settings[one.field])}
608          />
609        ))}
610        <Button
611          key="layout"
612          plain
613          label={`Layout: ${settings.layout} (press for ${layoutNext})`}
614          onPress={() => setOption($, 'layout', layoutNext)}
615        />
616        <Button
617          key="warnAt"
618          plain
619          label={`Yellow from ${settings.warnAt}% (press for ${warnNext}%)`}
620          onPress={() => setOption($, 'warnAt', warnNext)}
621        />
622        <Button
623          key="hotAt"
624          plain
625          label={`Red from ${settings.hotAt}% (press for ${hotNext}%)`}
626          onPress={() => setOption($, 'hotAt', hotNext)}
627        />
628        <Button
629          key="refreshSeconds"
630          plain
631          label={`Refresh every ${settings.refreshSeconds}s (press for ${refreshNext}s)`}
632          onPress={() => setOption($, 'refreshSeconds', refreshNext)}
633        />
634        <Text dimColor>Each change is saved to your settings and reloads the band.</Text>
635        <Button key="close" role="dismiss" label="Close" onPress={() => $.ui.close({ id: SETTINGS_PANE })} />
636      </Box>
637    )
638  })
639
640  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
641    if (e.props.hasSurvey || (await read($, isHidden))) {
642      return next(e)
643    }
644    if (e.surface === 'terminal' && !settings.showInTerminal) {
645      return next(e)
646    }
647    const groups = await currentPills($)
648    if (groups.length === 0) {
649      return next(e)
650    }
651    const look = lookOf(settings)
652
653    if (e.surface === 'terminal') {
654      const { Box, Text } = $.ui.resolve(e)
655      const pill = (one: Pill) => {
656        const color = TERMINAL_COLORS[one.kind]
657        if (isBar(one)) {
658          const level = TERMINAL_BAR[barLevel(one.percent, look)]
659          const { cells, mark } = terminalBar(one.percent, one.elapsed)
660
661          return (
662            <Box key={one.key} flexDirection="row">
663              <Text color={color}>
664                {look.isCompact ? '' : `${TERMINAL_ICONS[one.kind]} `}
665                {one.label}{' '}
666              </Text>
667              {!look.isCompact &&
668                cells.map((cell, i) =>
669                  i === mark ? (
670                    <Text key={`c${i}`} bold>
671                      │
672                    </Text>
673                  ) : (
674                    <Text key={`c${i}`} color={cell === '█' ? level : undefined} dimColor={cell !== '█'}>
675                      {cell}
676                    </Text>
677                  ),
678                )}
679              <Text color={look.isCompact ? level : color} bold>
680                {look.isCompact ? '' : ' '}
681                {formatPercent(one.percent)}
682              </Text>
683              {one.isAtRisk && (
684                <Text color={TERMINAL_BAR.high} bold>
685                  {' '}!
686                </Text>
687              )}
688              {one.remaining !== null && (
689                <Text dimColor>
690                  {' · '}
691                  {look.isCompact ? '' : '⏱ '}
692                  {one.remaining}
693                </Text>
694              )}
695            </Box>
696          )
697        }
698
699        return (
700          <Box key={one.key} flexDirection="row">
701            {!look.isCompact && <Text color={color}>{TERMINAL_ICONS[one.kind]} </Text>}
702            <Text color={color} bold>
703              {look.isCompact ? compactText(one) : one.value}
704            </Text>
705          </Box>
706        )
707      }
708
709      return (
710        <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
711          {groups.map((group, i) => (
712            <Box key={`g${i}`} flexDirection="row" flexWrap="wrap" columnGap={2}>
713              {group.map(pill)}
714            </Box>
715          ))}
716        </Box>
717      )
718    }
719
720    const { Box, Svg } = $.ui.resolve(e)
721
722    return (
723      <Box flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1}>
724        {groups.map((group, i) => (
725          <Box key={`g${i}`} flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
726            {group.map(one => {
727              const drawing = pillSvg(one, look)
728
729              return (
730                <Svg
731                  key={drawing.key}
732                  source={drawing.svg}
733                  alt={drawing.alt}
734                  width={drawing.width}
735                  height={24}
736                  isInteractive
737                />
738              )
739            })}
740          </Box>
741        ))}
742      </Box>
743    )
744  })
745}
746
hooks/pills.ts 1028 lines
1// The band's model and its desktop drawing: plain functions, no `$`, so the
2// same code draws the pills in the engine and in a preview page.
3import type {
4  UsageActivity,
5  UsageLimit,
6  UsageSession,
7  UsageSnapshot,
8  UsageStream,
9  UsageTokens,
10  UsageTranscript,
11} from '../types'
12
13// ---- Settings ----
14
15export type Toggle =
16  | 'show5h'
17  | 'show7d'
18  | 'showSpendLimit'
19  | 'showContext'
20  | 'showInput'
21  | 'showOutput'
22  | 'showThinking'
23  | 'showCacheRead'
24  | 'showCacheHit'
25  | 'showWeb'
26  | 'showRequests'
27  | 'showSpeed'
28  | 'showCost'
29  | 'showCostRate'
30  | 'showLastTurn'
31  | 'showTools'
32  | 'showChurn'
33  | 'showAgents'
34  | 'showCompactions'
35  | 'showPrompts'
36  | 'showSessionAge'
37  | 'showModel'
38  | 'showEffort'
39  | 'showFolder'
40  | 'showGit'
41  | 'showMemory'
42
43export type Switch = 'limitProjection' | 'contextBreakdown' | 'alerts' | 'showInTerminal'
44
45export type Layout = 'full' | 'compact'
46
47/** What the drawing needs from the settings: thresholds and layout. */
48export type Look = { warnAt: number; hotAt: number; isCompact: boolean }
49
50export type Settings = Record<Toggle | Switch, boolean> & {
51  refreshSeconds: number
52  warnAt: number
53  hotAt: number
54  layout: Layout
55}
56
57export type SettingInfo<F extends string> = { field: F; title: string; description: string }
58
59/** Every pill's switch, in the band's order; the manifest's userConfig mirrors it. */
60export const TOGGLES: readonly SettingInfo<Toggle>[] = [
61  { field: 'show5h', title: '5-hour limit', description: 'Usage of the 5-hour rate-limit window and time to reset' },
62  { field: 'show7d', title: '7-day limit', description: 'Usage of the 7-day rate-limit window and time to reset' },
63  { field: 'showSpendLimit', title: 'Spend limit', description: "A gateway's spend limit, when the account reports one" },
64  { field: 'showContext', title: 'Context window', description: 'How full the context window is' },
65  { field: 'showInput', title: 'Input tokens', description: 'Uncached input plus cache writes' },
66  { field: 'showOutput', title: 'Output tokens', description: 'Tokens the model generated' },
67  { field: 'showThinking', title: 'Thinking tokens', description: 'The part of output spent on extended thinking' },
68  { field: 'showCacheRead', title: 'Cache read tokens', description: 'Input tokens served from the prompt cache' },
69  { field: 'showCacheHit', title: 'Cache hit rate', description: 'Share of all input read from the prompt cache' },
70  { field: 'showWeb', title: 'Web searches and fetches', description: 'Server-side web search and fetch requests' },
71  { field: 'showRequests', title: 'API requests', description: 'Model requests, subagents included' },
72  { field: 'showSpeed', title: 'Output speed', description: 'Tokens per second of the last response (hover for the average)' },
73  { field: 'showCost', title: 'Session cost', description: 'Session cost at API list prices' },
74  { field: 'showCostRate', title: 'Cost per hour', description: 'Average spend per hour since the session started' },
75  { field: 'showLastTurn', title: 'Last turn', description: 'Duration, cost and output speed of the last turn' },
76  { field: 'showTools', title: 'Tool calls', description: 'Tool calls, failures and the most used tools' },
77  { field: 'showChurn', title: 'Lines changed', description: 'Lines added and removed by Edit, MultiEdit and Write' },
78  { field: 'showAgents', title: 'Running subagents', description: 'Subagents running right now' },
79  { field: 'showCompactions', title: 'Compactions', description: 'How often the context was compacted and what it freed' },
80  { field: 'showPrompts', title: 'Prompts', description: 'Prompts sent this session' },
81  { field: 'showSessionAge', title: 'Session age', description: 'Time since the session started' },
82  { field: 'showModel', title: 'Model', description: 'The model that answered last, and the Claude Code version' },
83  { field: 'showEffort', title: 'Thinking effort', description: 'The thinking effort of the last request (low to max)' },
84  { field: 'showFolder', title: 'Folder', description: "The working directory's name (hover for the full path)" },
85  { field: 'showGit', title: 'Git branch', description: 'Branch, uncommitted files, ahead and behind' },
86  { field: 'showMemory', title: 'Machine memory', description: "This machine's memory in use" },
87]
88
89export const SWITCHES: readonly SettingInfo<Switch>[] = [
90  {
91    field: 'limitProjection',
92    title: 'Limit projection',
93    description: 'Project 5h/7d usage at reset from the pace so far and warn when it would pass 100%',
94  },
95  {
96    field: 'contextBreakdown',
97    title: 'Context breakdown',
98    description: 'List what fills the context in the context tooltip (estimated locally)',
99  },
100  { field: 'alerts', title: 'Limit alerts', description: 'A toast when a limit crosses the yellow or red threshold' },
101  {
102    field: 'showInTerminal',
103    title: 'Show in the terminal',
104    description: 'Off to show the band only in the desktop app',
105  },
106]
107
108export const LAYOUTS: readonly Layout[] = ['full', 'compact']
109
110function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
111  const n = Number(value)
112
113  return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : fallback
114}
115
116export function readSettings(options: Readonly<Record<string, unknown>>): Settings {
117  const settings = {
118    refreshSeconds: clampNumber(options.refreshSeconds, 30, 10, 600),
119    warnAt: clampNumber(options.warnAt, 70, 1, 100),
120    hotAt: clampNumber(options.hotAt, 90, 1, 100),
121    layout: options.layout === 'compact' ? 'compact' : 'full',
122  } as Settings
123  for (const { field } of [...TOGGLES, ...SWITCHES]) {
124    settings[field] = options[field] !== false
125  }
126
127  return settings
128}
129
130export function lookOf(settings: Settings): Look {
131  return { warnAt: settings.warnAt, hotAt: settings.hotAt, isCompact: settings.layout === 'compact' }
132}
133
134// ---- Pills ----
135
136export type BarKind = 'fiveHour' | 'sevenDay' | 'spendLimit' | 'context'
137
138export type ValueKind =
139  | 'input'
140  | 'output'
141  | 'thinking'
142  | 'cache'
143  | 'cacheHit'
144  | 'web'
145  | 'requests'
146  | 'speed'
147  | 'cost'
148  | 'costRate'
149  | 'lastTurn'
150  | 'tools'
151  | 'churn'
152  | 'agents'
153  | 'compactions'
154  | 'prompts'
155  | 'age'
156  | 'model'
157  | 'effort'
158  | 'folder'
159  | 'git'
160  | 'memory'
161
162export type PillKind = BarKind | ValueKind
163
164export type BarPill = {
165  kind: BarKind
166  key: string
167  label: string
168  percent: number
169  /** 0..1: how much of the window has passed; null without a reset time. */
170  elapsed: number | null
171  remaining: string | null
172  /** The pace so far would pass 100% before the window resets. */
173  isAtRisk: boolean
174  title: string
175}
176
177export type ValuePill = {
178  kind: ValueKind
179  key: string
180  value: string
181  title: string
182}
183
184export type Pill = BarPill | ValuePill
185
186export function isBar(pill: Pill): pill is BarPill {
187  return pill.kind === 'fiveHour' || pill.kind === 'sevenDay' || pill.kind === 'spendLimit' || pill.kind === 'context'
188}
189
190export type BandData = {
191  snapshot: UsageSnapshot | null
192  tokens: UsageTokens | null
193  transcript: UsageTranscript | null
194  activity: UsageActivity | null
195  stream: UsageStream | null
196  session: UsageSession | null
197  now: number
198  settings: Settings
199}
200
201const MINUTE = 60_000
202const HOUR = 60 * MINUTE
203const DAY = 24 * HOUR
204const WINDOWS: Partial<Record<BarKind, number>> = { fiveHour: 5 * HOUR, sevenDay: 7 * DAY }
205
206export function formatTokens(n: number): string {
207  if (n < 1000) {
208    return String(Math.round(n))
209  }
210  if (n < 999_950) {
211    return `${(n / 1000).toFixed(1)}k`
212  }
213
214  return `${(n / 1_000_000).toFixed(2)}M`
215}
216
217/** Time to a reset: `2h 40m`, `1d 7h`, `12m`. */
218export function formatRemaining(ms: number): string {
219  const minutes = Math.max(0, Math.floor(ms / MINUTE))
220  const days = Math.floor(minutes / 1440)
221  const hours = Math.floor((minutes % 1440) / 60)
222  if (days > 0) {
223    return `${days}d ${hours}h`
224  }
225  if (hours > 0) {
226    return `${hours}h ${minutes % 60}m`
227  }
228
229  return `${minutes}m`
230}
231
232/** A span that may be short: `42s`, `3m 12s`, `1h 5m`. */
233export function formatDuration(ms: number): string {
234  const seconds = Math.max(0, Math.round(ms / 1000))
235  if (seconds < 60) {
236    return `${seconds}s`
237  }
238  if (seconds < 3600) {
239    return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
240  }
241
242  return formatRemaining(ms)
243}
244
245export function formatPercent(n: number): string {
246  return `${Math.round(n)}%`
247}
248
249export function formatUsd(n: number): string {
250  return `$${n.toFixed(2)}`
251}
252
253function formatGiB(bytes: number): string {
254  return `${(bytes / 1024 ** 3).toFixed(1)}G`
255}
256
257export function barLevel(percent: number, look: Look): 'ok' | 'warn' | 'high' {
258  if (percent >= look.hotAt) {
259    return 'high'
260  }
261
262  return percent >= look.warnAt ? 'warn' : 'ok'
263}
264
265/** `mcp__plugin_playwright_playwright__browser_click` reads as `browser_click (mcp)`. */
266function toolName(name: string): string {
267  return name.startsWith('mcp__') ? `${name.split('__').at(-1)} (mcp)` : name
268}
269
270function baseName(path: string): string {
271  return path.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1) || path
272}
273
274const LIMIT_NAMES = { fiveHour: '5-hour window', sevenDay: '7-day window', spendLimit: 'Spend limit' } as const
275const LIMIT_LABELS = { fiveHour: '5h', sevenDay: '7d', spendLimit: 'spend' } as const
276
277function limitPill(kind: 'fiveHour' | 'sevenDay' | 'spendLimit', limit: UsageLimit, now: number, isProjected: boolean): BarPill {
278  const span = WINDOWS[kind] ?? null
279  const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
280  const left = Number.isFinite(resetsAt) && now > 0 ? Math.max(0, resetsAt - now) : null
281  const elapsed = left === null || span === null ? null : Math.min(1, Math.max(0, 1 - left / span))
282  const remaining = left === null ? null : formatRemaining(left)
283  const lines = [`${LIMIT_NAMES[kind]}: ${formatPercent(limit.percent)} used`]
284  let isAtRisk = false
285  if (remaining !== null) {
286    lines.push(`Resets in: ${remaining}`)
287  }
288  if (elapsed !== null && span !== null) {
289    lines.push(`${formatPercent(elapsed * 100)} of the window has passed (vertical line)`)
290    if (isProjected && elapsed >= 0.05 && limit.percent > 0) {
291      const atReset = limit.percent / elapsed
292      isAtRisk = atReset >= 100 && limit.percent < 100
293      const perMs = limit.percent / (elapsed * span)
294      const toFull = (100 - limit.percent) / perMs
295      lines.push(
296        isAtRisk
297          ? `At this pace: 100% in ${formatRemaining(toFull)}, before the reset`
298          : `At this pace: about ${formatPercent(atReset)} by the reset`,
299      )
300    }
301  }
302
303  return {
304    kind,
305    key: kind,
306    label: LIMIT_LABELS[kind],
307    percent: limit.percent,
308    elapsed,
309    remaining,
310    isAtRisk,
311    title: lines.join('\n'),
312  }
313}
314
315function contextPill(snapshot: UsageSnapshot): BarPill | null {
316  if (!snapshot.contextWindow) {
317    return null
318  }
319  const used = snapshot.contextTokens ?? 0
320  const percent = snapshot.contextPercent ?? (used / snapshot.contextWindow) * 100
321  const lines = [`Context: ${formatTokens(used)} / ${formatTokens(snapshot.contextWindow)} (${formatPercent(percent)})`]
322  if (snapshot.autoCompactAt) {
323    lines.push(`Auto-compact at ${formatTokens(snapshot.autoCompactAt)}`)
324  }
325  if (snapshot.contextCategories && snapshot.contextCategories.length > 0) {
326    lines.push('What fills it (estimate):')
327    for (const one of snapshot.contextCategories.slice(0, 8)) {
328      lines.push(`  ${one.name}: ${formatTokens(one.tokens)}`)
329    }
330  }
331
332  return {
333    kind: 'context',
334    key: 'context',
335    label: 'ctx',
336    percent,
337    elapsed: null,
338    remaining: null,
339    isAtRisk: false,
340    title: lines.join('\n'),
341  }
342}
343
344function shortModel(model: string): string {
345  return model.replace(/^claude-/, '').replace(/-\d{8}$/, '').replace(/\[1m\]$/, ' 1M')
346}
347
348/** The pills to draw, in their groups; an empty group is left out. */
349export function buildPills(band: BandData): Pill[][] {
350  const { snapshot, tokens, transcript, activity, stream, session, now, settings: on } = band
351
352  const limits: Pill[] = []
353  if (on.show5h && snapshot?.fiveHour) {
354    limits.push(limitPill('fiveHour', snapshot.fiveHour, now, on.limitProjection))
355  }
356  if (on.show7d && snapshot?.sevenDay) {
357    limits.push(limitPill('sevenDay', snapshot.sevenDay, now, on.limitProjection))
358  }
359  if (on.showSpendLimit && snapshot?.spendLimit) {
360    limits.push(limitPill('spendLimit', snapshot.spendLimit, now, false))
361  }
362  const context = on.showContext && snapshot ? contextPill(snapshot) : null
363  if (context) {
364    limits.push(context)
365  }
366
367  const counts: Pill[] = []
368  if (tokens && tokens.requests > 0) {
369    const mark = tokens.isEstimate ? '~' : ''
370    const input = tokens.uncached + tokens.cacheWrite
371    const allInput = input + tokens.cacheRead
372    const footer: string[] = []
373    if (snapshot?.contextWindow) {
374      const used = snapshot.contextTokens ?? 0
375      const percent = snapshot.contextPercent ?? (used / snapshot.contextWindow) * 100
376      footer.push(`Context: ${formatTokens(used)} / ${formatTokens(snapshot.contextWindow)} (${formatPercent(percent)})`)
377    }
378    footer.push(
379      tokens.isEstimate
380        ? `${tokens.requests} turns (estimate: transcript not readable)`
381        : `${tokens.requests} requests (subagents included)`,
382    )
383    if (on.showInput) {
384      const ttl =
385        tokens.cacheWrite1h + tokens.cacheWrite5m > 0
386          ? [`    1-hour TTL: ${formatTokens(tokens.cacheWrite1h)}`, `    5-minute TTL: ${formatTokens(tokens.cacheWrite5m)}`]
387          : []
388      counts.push({
389        kind: 'input',
390        key: 'input',
391        value: `${mark}${formatTokens(input)}`,
392        title: [
393          `Input: ${formatTokens(input)}`,
394          `  uncached: ${formatTokens(tokens.uncached)}`,
395          `  cache write: ${formatTokens(tokens.cacheWrite)}`,
396          ...ttl,
397          ...footer,
398        ].join('\n'),
399      })
400    }
401    if (on.showOutput) {
402      const thinking =
403        tokens.thinking > 0
404          ? [`  thinking: ${formatTokens(tokens.thinking)}`, `  visible: ${formatTokens(tokens.output - tokens.thinking)}`]
405          : []
406      counts.push({
407        kind: 'output',
408        key: 'output',
409        value: `${mark}${formatTokens(tokens.output)}`,
410        title: [`Output: ${formatTokens(tokens.output)}`, ...thinking, ...footer].join('\n'),
411      })
412    }
413    if (on.showThinking && tokens.thinking > 0) {
414      const share = tokens.output > 0 ? (tokens.thinking / tokens.output) * 100 : 0
415      counts.push({
416        kind: 'thinking',
417        key: 'thinking',
418        value: `${mark}${formatTokens(tokens.thinking)}`,
419        title: `Thinking: ${formatTokens(tokens.thinking)} (${formatPercent(share)} of output)`,
420      })
421    }
422    if (on.showCacheRead) {
423      counts.push({
424        kind: 'cache',
425        key: 'cache',
426        value: `${mark}${formatTokens(tokens.cacheRead)}`,
427        title: [`Cache read: ${formatTokens(tokens.cacheRead)}`, ...footer].join('\n'),
428      })
429    }
430    if (on.showCacheHit && allInput > 0) {
431      const hit = (tokens.cacheRead / allInput) * 100
432      counts.push({
433        kind: 'cacheHit',
434        key: 'cacheHit',
435        value: formatPercent(hit),
436        title: `Cache hit rate: ${formatPercent(hit)}\n${formatTokens(tokens.cacheRead)} of ${formatTokens(allInput)} input tokens came from the cache`,
437      })
438    }
439    const web = tokens.webSearches + tokens.webFetches
440    if (on.showWeb && web > 0) {
441      counts.push({
442        kind: 'web',
443        key: 'web',
444        value: String(web),
445        title: `Web searches: ${tokens.webSearches}\nWeb fetches: ${tokens.webFetches}`,
446      })
447    }
448    if (on.showRequests) {
449      counts.push({
450        kind: 'requests',
451        key: 'requests',
452        value: `${mark}${tokens.requests}`,
453        title: [
454          tokens.isEstimate ? `Turns: ${tokens.requests} (estimate)` : `API requests: ${tokens.requests} (subagents included)`,
455          `Average input: ${formatTokens(allInput / tokens.requests)}`,
456          `Average output: ${formatTokens(tokens.output / tokens.requests)}`,
457        ].join('\n'),
458      })
459    }
460  }
461  if (on.showSpeed && stream?.lastTps != null) {
462    const average = stream.ms > 0 ? (stream.tokens / stream.ms) * 1000 : stream.lastTps
463    counts.push({
464      kind: 'speed',
465      key: 'speed',
466      value: `${Math.round(stream.lastTps)} t/s`,
467      title: [
468        `Last response: ${stream.lastTps.toFixed(1)} tokens/s (from its first streamed piece)`,
469        `Average: ${average.toFixed(1)} tokens/s over ${stream.responses} responses since the band loaded`,
470      ].join('\n'),
471    })
472  }
473
474  const money: Pill[] = []
475  if (snapshot?.costUsd != null) {
476    const cost = snapshot.costUsd
477    if (on.showCost) {
478      const perRequest =
479        tokens && tokens.requests > 0 && !tokens.isEstimate ? [`Average per request: $${(cost / tokens.requests).toFixed(3)}`] : []
480      money.push({
481        kind: 'cost',
482        key: 'cost',
483        value: formatUsd(cost),
484        title: [`Session cost: $${cost.toFixed(4)}`, ...perRequest, 'At API list prices; not what a subscription actually pays'].join('\n'),
485      })
486    }
487    const age = session?.startedAt && now > session.startedAt ? now - session.startedAt : 0
488    if (on.showCostRate && age >= 5 * MINUTE) {
489      money.push({
490        kind: 'costRate',
491        key: 'costRate',
492        value: `${formatUsd(cost / (age / HOUR))}/h`,
493        title: `Average spend per hour since the session started (${formatRemaining(age)} ago)\nAt API list prices`,
494      })
495    }
496  }
497
498  const work: Pill[] = []
499  const turn = activity?.lastTurn
500  if (on.showLastTurn && turn) {
501    const cost = turn.startCost !== null && turn.endCost !== null ? Math.max(0, turn.endCost - turn.startCost) : null
502    const speed = turn.durationMs > 0 ? turn.output / (turn.durationMs / 1000) : 0
503    work.push({
504      kind: 'lastTurn',
505      key: 'lastTurn',
506      value: cost === null ? formatDuration(turn.durationMs) : `${formatDuration(turn.durationMs)} · ${formatUsd(cost)}`,
507      title: [
508        `Last turn: ${formatDuration(turn.durationMs)}`,
509        `Output: ${formatTokens(turn.output)} tokens (${Math.round(speed)} tokens/s over the whole turn)`,
510        ...(cost === null ? [] : [`Cost: $${cost.toFixed(4)} (subagents included, API list prices)`]),
511      ].join('\n'),
512    })
513  }
514  // The transcript counts the whole session; the live hook only since the band loaded.
515  const tools = transcript ?? (activity ? { ...activity, isLive: true } : null)
516  if (on.showTools && tools && tools.toolCalls > 0) {
517    const top = Object.entries(tools.byTool)
518      .sort((a, b) => b[1] - a[1])
519      .slice(0, 6)
520      .map(([name, count]) => `  ${toolName(name)}: ${count}`)
521    work.push({
522      kind: 'tools',
523      key: 'tools',
524      value: tools.toolFailures > 0 ? `${tools.toolCalls} · ${tools.toolFailures}✗` : String(tools.toolCalls),
525      title: [
526        `Tool calls: ${tools.toolCalls} (${transcript ? 'this session' : 'since the band loaded'}, subagents included)`,
527        `Failed or denied: ${tools.toolFailures}`,
528        'Most used:',
529        ...top,
530      ].join('\n'),
531    })
532  }
533  if (on.showChurn && transcript && transcript.linesAdded + transcript.linesRemoved > 0) {
534    work.push({
535      kind: 'churn',
536      key: 'churn',
537      value: `+${transcript.linesAdded} −${transcript.linesRemoved}`,
538      title: [
539        `Lines added: ${transcript.linesAdded}`,
540        `Lines removed: ${transcript.linesRemoved}`,
541        `Files touched: ${transcript.filesChanged}`,
542        'By Edit, MultiEdit and Write this session; failed calls left out',
543      ].join('\n'),
544    })
545  }
546  if (on.showAgents && activity && activity.agentsRunning > 0) {
547    work.push({
548      kind: 'agents',
549      key: 'agents',
550      value: String(activity.agentsRunning),
551      title: `Subagents running now: ${activity.agentsRunning}`,
552    })
553  }
554  if (on.showCompactions && activity && activity.compactions > 0) {
555    work.push({
556      kind: 'compactions',
557      key: 'compactions',
558      value: String(activity.compactions),
559      title: `Compactions: ${activity.compactions} (since the band loaded)\nTokens freed: ${formatTokens(activity.compactionFreed)}`,
560    })
561  }
562  if (on.showPrompts && session?.prompts) {
563    work.push({ kind: 'prompts', key: 'prompts', value: String(session.prompts), title: `Prompts sent this session: ${session.prompts}` })
564  }
565
566  const about: Pill[] = []
567  if (on.showSessionAge && session?.startedAt && now > session.startedAt) {
568    about.push({
569      kind: 'age',
570      key: 'age',
571      value: formatRemaining(now - session.startedAt),
572      title: `Session started ${formatRemaining(now - session.startedAt)} ago`,
573    })
574  }
575  const model = stream?.model ?? session?.model ?? null
576  if (on.showModel && model) {
577    about.push({
578      kind: 'model',
579      key: 'model',
580      value: shortModel(model),
581      title: [`Model that answered last: ${model}`, ...(session?.version ? [`Claude Code ${session.version}`] : [])].join('\n'),
582    })
583  }
584  if (on.showEffort && stream?.effort) {
585    about.push({ kind: 'effort', key: 'effort', value: stream.effort, title: `Thinking effort of the last request: ${stream.effort}` })
586  }
587  if (on.showFolder && session?.cwd) {
588    about.push({ kind: 'folder', key: 'folder', value: baseName(session.cwd), title: `Working directory: ${session.cwd}` })
589  }
590  if (on.showGit && session?.git) {
591    const git = session.git
592    const marks = [git.dirty > 0 ? `±${git.dirty}` : '', git.ahead > 0 ? `↑${git.ahead}` : '', git.behind > 0 ? `↓${git.behind}` : '']
593      .filter(Boolean)
594      .join(' ')
595    about.push({
596      kind: 'git',
597      key: 'git',
598      value: marks ? `${git.branch} ${marks}` : git.branch,
599      title: [`Branch: ${git.branch}`, `Uncommitted files: ${git.dirty}`, `Ahead: ${git.ahead}, behind: ${git.behind}`].join('\n'),
600    })
601  }
602  if (on.showMemory && session?.memory && session.memory.total > 0) {
603    const used = session.memory.total - session.memory.free
604    const percent = (used / session.memory.total) * 100
605    about.push({
606      kind: 'memory',
607      key: 'memory',
608      value: `${formatGiB(used)}/${formatGiB(session.memory.total)}`,
609      title: `Machine memory in use: ${formatGiB(used)} of ${formatGiB(session.memory.total)} (${formatPercent(percent)})`,
610    })
611  }
612
613  return [limits, counts, money, work, about].filter(group => group.length > 0)
614}
615
616// ---- Desktop: one SVG document per pill ----
617
618type Palette = { bg: string; fg: string; darkBg: string; darkFg: string }
619
620const PALETTE = {
621  teal: { bg: '#d3f4ec', fg: '#0b4f45', darkBg: '#113d37', darkFg: '#9eecd9' },
622  purple: { bg: '#ebe2fb', fg: '#47247d', darkBg: '#2f2249', darkFg: '#d7c5fb' },
623  orange: { bg: '#fde5d0', fg: '#7a3808', darkBg: '#44280f', darkFg: '#f7bf8c' },
624  red: { bg: '#fbe0dd', fg: '#7d2018', darkBg: '#46211d', darkFg: '#f6b4ac' },
625  green: { bg: '#d9f2da', fg: '#1d5c22', darkBg: '#1c3b20', darkFg: '#a6e2a9' },
626  lime: { bg: '#e8f5cf', fg: '#3d5208', darkBg: '#2c3610', darkFg: '#cfe89a' },
627  pink: { bg: '#f8def0', fg: '#6e1d57', darkBg: '#3f1c36', darkFg: '#f0b2dd' },
628  blue: { bg: '#dce8fb', fg: '#1b407c', darkBg: '#1b2d49', darkFg: '#a8c7f6' },
629  sky: { bg: '#d7eff9', fg: '#0c4a63', darkBg: '#113240', darkFg: '#9ad7ef' },
630  gold: { bg: '#f9eec6', fg: '#6c4e00', darkBg: '#3e3211', darkFg: '#f1d37b' },
631  slate: { bg: '#e2e7ef', fg: '#2a3850', darkBg: '#252f41', darkFg: '#c0cce0' },
632  indigo: { bg: '#e2e4fb', fg: '#2e3480', darkBg: '#23264a', darkFg: '#bcc1f7' },
633  neutral: { bg: '#ebe9e3', fg: '#3b3a36', darkBg: '#34332f', darkFg: '#d8d6cd' },
634} satisfies Record<string, Palette>
635
636const PALETTES: Record<PillKind, Palette> = {
637  fiveHour: PALETTE.teal,
638  sevenDay: PALETTE.purple,
639  spendLimit: PALETTE.gold,
640  context: PALETTE.orange,
641  input: PALETTE.red,
642  output: PALETTE.green,
643  thinking: PALETTE.pink,
644  cache: PALETTE.blue,
645  cacheHit: PALETTE.blue,
646  web: PALETTE.sky,
647  requests: PALETTE.neutral,
648  speed: PALETTE.lime,
649  cost: PALETTE.gold,
650  costRate: PALETTE.gold,
651  lastTurn: PALETTE.slate,
652  tools: PALETTE.slate,
653  churn: PALETTE.green,
654  agents: PALETTE.indigo,
655  compactions: PALETTE.orange,
656  prompts: PALETTE.neutral,
657  age: PALETTE.neutral,
658  model: PALETTE.neutral,
659  effort: PALETTE.pink,
660  folder: PALETTE.neutral,
661  git: PALETTE.slate,
662  memory: PALETTE.neutral,
663}
664
665const BAR = {
666  ok: { light: '#1f9d55', dark: '#4cc97f' },
667  warn: { light: '#d49a00', dark: '#f0c23b' },
668  high: { light: '#d93b40', dark: '#ff6b6f' },
669}
670
671const ICONS: Record<PillKind, string> = {
672  // gauge: an arc and its needle
673  fiveHour:
674    '<path class="st" d="M2 10.5a5 5 0 0 1 10 0"/><path class="st" d="M7 10.5l2.6-3.4"/><circle class="fg" cx="7" cy="10.5" r="1.1"/>',
675  sevenDay:
676    '<rect class="st" x="1.8" y="2.8" width="10.4" height="9.4" rx="1.6"/><path class="st" d="M1.8 5.8h10.4M4.6 1.5v2.6M9.4 1.5v2.6"/><path class="st" d="M4.4 8.3h1M6.5 8.3h1M8.6 8.3h1M4.4 10.2h1M6.5 10.2h1"/>',
677  // wallet
678  spendLimit:
679    '<rect class="st" x="1.8" y="3.4" width="10.4" height="8" rx="1.6"/><path class="st" d="M1.8 5.6h8.6M9.2 8.4h1.2"/>',
680  // a half-filled circle
681  context: '<circle class="st" cx="7" cy="7" r="5.2"/><path class="fg" d="M7 1.8a5.2 5.2 0 0 1 0 10.4Z"/>',
682  input: '<path class="st" d="M7 12V2.4M3 6.2 7 2.2l4 4"/>',
683  output: '<path class="st" d="M7 2v9.6M3 7.8l4 4 4-4"/>',
684  // light bulb
685  thinking:
686    '<path class="st" d="M5.2 10.4h3.6M5.8 12.4h2.4M5 8.9C4 8.2 3.3 7.1 3.3 5.8 3.3 3.8 5 2.1 7 2.1s3.7 1.7 3.7 3.7c0 1.3-.7 2.4-1.7 3.1"/>',
687  // layers
688  cache:
689    '<path class="st" d="M7 1.8 12.4 4.6 7 7.4 1.6 4.6Z"/><path class="st" d="M1.6 7.2 7 10l5.4-2.8M1.6 9.6 7 12.4l5.4-2.8"/>',
690  // target
691  cacheHit: '<circle class="st" cx="7" cy="7" r="5.2"/><circle class="st" cx="7" cy="7" r="2.4"/><circle class="fg" cx="7" cy="7" r=".9"/>',
692  // globe
693  web: '<circle class="st" cx="7" cy="7" r="5.2"/><path class="st" d="M1.8 7h10.4M7 1.8c1.6 1.5 2.4 3.3 2.4 5.2S8.6 10.7 7 12.2M7 1.8C5.4 3.3 4.6 5.1 4.6 7s.8 3.7 2.4 5.2"/>',
694  // two opposing arrows
695  requests: '<path class="st" d="M2.5 4.6h8.5M8.6 2.2 11 4.6 8.6 7M11.5 9.4H3M5.4 7 3 9.4l2.4 2.4"/>',
696  // lightning bolt
697  speed: '<path class="st" d="M8 1.6 3.4 7.8h3.6L6 12.4l4.6-6.2H7Z"/>',
698  cost: '<path class="st" d="M10 4.3c-.5-.9-1.6-1.4-2.9-1.4-1.6 0-2.8.8-2.8 2 0 2.6 5.8 1.4 5.8 4.2 0 1.2-1.3 2-2.9 2-1.4 0-2.6-.6-3.1-1.6M7 1.2v11.6"/>',
699  // a rising line
700  costRate: '<path class="st" d="M1.8 10.8 5.2 7.4l2.4 2.2 4.6-5M9.2 4.6h3v3"/>',
701  // hourglass
702  lastTurn:
703    '<path class="st" d="M3.5 1.8h7M3.5 12.2h7M4.3 1.8c0 2.6 2.7 3.6 2.7 5.2S4.3 9.6 4.3 12.2M9.7 1.8c0 2.6-2.7 3.6-2.7 5.2s2.7 2.6 2.7 5.2"/>',
704  // wrench
705  tools: '<path class="st" d="M8.6 2.2a3 3 0 0 0-3.2 4.1L1.9 9.8a1.3 1.3 0 0 0 1.8 1.8l3.5-3.5a3 3 0 0 0 4.1-3.2L9.6 6.6 7.9 6.1 7.4 4.4Z"/>',
706  // plus over minus
707  churn: '<path class="st" d="M4.4 2.4v4.4M2.2 4.6h4.4M7.6 10h4.2"/><path class="st" d="M11.6 2.4 2.4 11.6" opacity=".45"/>',
708  // robot head
709  agents:
710    '<rect class="st" x="2.4" y="4.4" width="9.2" height="7.2" rx="2"/><path class="st" d="M7 4.4V2.6M5.3 7.8v.6M8.7 7.8v.6"/><circle class="fg" cx="7" cy="2" r=".9"/>',
711  // two arrows pressing inward
712  compactions: '<path class="st" d="M2 2l3.6 3.6M5.6 2.6v3H2.6M12 12 8.4 8.4M8.4 11.4v-3h3"/>',
713  // speech bubble
714  prompts:
715    '<path class="st" d="M3.6 2.2h6.8a1.4 1.4 0 0 1 1.4 1.4v4.8a1.4 1.4 0 0 1-1.4 1.4H6.2l-2.6 2.2V9.8a1.4 1.4 0 0 1-1.4-1.4V3.6a1.4 1.4 0 0 1 1.4-1.4Z"/>',
716  // clock
717  age: '<circle class="st" cx="7" cy="7" r="5.2"/><path class="st" d="M7 4v3.2l2.2 1.4"/>',
718  // chip
719  model:
720    '<rect class="st" x="3.6" y="3.6" width="6.8" height="6.8" rx="1.2"/><path class="st" d="M5.6 1.6v2M8.4 1.6v2M5.6 10.4v2M8.4 10.4v2M1.6 5.6h2M1.6 8.4h2M10.4 5.6h2M10.4 8.4h2"/>',
721  // three rising bars
722  effort: '<path class="st" d="M3 11.6V8.8M7 11.6V5.6M11 11.6V2.4"/>',
723  // folder
724  folder: '<path class="st" d="M1.8 4.2a1 1 0 0 1 1-1h2.8l1.2 1.4h4.4a1 1 0 0 1 1 1v5.2a1 1 0 0 1-1 1H2.8a1 1 0 0 1-1-1Z"/>',
725  // branch
726  git: '<circle class="st" cx="4" cy="3" r="1.4"/><circle class="st" cx="4" cy="11" r="1.4"/><circle class="st" cx="10" cy="5" r="1.4"/><path class="st" d="M4 4.4v5.2M10 6.4c0 2.4-6 1.6-6 3.2"/>',
727  // memory stick
728  memory:
729    '<rect class="st" x="1.6" y="3.8" width="10.8" height="5.6" rx="1"/><path class="st" d="M4 9.4v1.8M7 9.4v1.8M10 9.4v1.8M4.2 6.6h1.2M8.6 6.6h1.2"/>',
730}
731
732// stopwatch: the countdown to the reset
733const COUNTDOWN =
734  '<circle class="st" cx="6" cy="7" r="4.3"/><path class="st" d="M6 4.6V7l1.6 1.2M4.6 1.3h2.8M10 3.4l.9-.9"/>'
735
736// a warning triangle with its mark
737const WARNING = '<path class="wr" d="M6 1.4 11.2 10.8H.8Z"/><path class="wx" d="M6 4.8v3M6 9.2v.1"/>'
738
739const FONT = `ui-monospace,SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace`
740const SIZE = 12
741const CHAR = SIZE * 0.61
742const HEIGHT = 24
743const BASELINE = 16.2
744
745function escapeXml(text: string): string {
746  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
747}
748
749function textWidth(text: string): number {
750  return [...text].length * CHAR
751}
752
753/** The class a pill's root carries: every rule of its style is scoped under it. */
754function scopeOf(kind: PillKind, bar?: keyof typeof BAR): string {
755  return bar ? `ub-${kind}-${bar}` : `ub-${kind}`
756}
757
758/**
759 * The pill's colors, every rule scoped under its root's class, so pills drawn
760 * into one document (a preview page, a test) never restyle each other.
761 */
762function style(kind: PillKind, bar?: keyof typeof BAR): string {
763  const p = PALETTES[kind]
764  const fill = bar ? BAR[bar] : null
765  const scope = `.${scopeOf(kind, bar)}`
766  const rules = (list: [string, string][]) => list.map(([sel, body]) => `${scope} ${sel}{${body}}`).join('')
767  const light = rules([
768    ['text', `font-family:${FONT};font-size:${SIZE}px`],
769    ['.b', 'font-weight:700'],
770    ['.bg', `fill:${p.bg}`],
771    ['.fg', `fill:${p.fg}`],
772    ['.st', `stroke:${p.fg};fill:none;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round`],
773    ['.tx', `fill:${p.fg}`],
774    ['.tr', `fill:${p.fg};opacity:.18`],
775    ['.sep', `stroke:${p.fg};opacity:.32`],
776    ['.wr', `fill:${BAR.high.light}`],
777    ['.wx', 'stroke:#fff;stroke-width:1.5;stroke-linecap:round'],
778    ...(fill
779      ? ([
780          ['.bar', `fill:${fill.light}`],
781          ['.mk', `stroke:${p.fg}`],
782          ['.lv', `fill:${fill.light}`],
783        ] as [string, string][])
784      : []),
785  ])
786  const dark = rules([
787    ['.bg', `fill:${p.darkBg}`],
788    ['.fg', `fill:${p.darkFg}`],
789    ['.st', `stroke:${p.darkFg}`],
790    ['.tx', `fill:${p.darkFg}`],
791    ['.tr', `fill:${p.darkFg};opacity:.2`],
792    ['.sep', `stroke:${p.darkFg}`],
793    ['.wr', `fill:${BAR.high.dark}`],
794    ['.wx', `stroke:${p.darkBg}`],
795    ...(fill
796      ? ([
797          ['.bar', `fill:${fill.dark}`],
798          ['.mk', `stroke:${p.darkFg}`],
799          ['.lv', `fill:${fill.dark}`],
800        ] as [string, string][])
801      : []),
802  ])
803
804  return `<style>${light}@media (prefers-color-scheme: dark){${dark}}</style>`
805}
806
807function frame(kind: PillKind, width: number, title: string, body: string, bar?: keyof typeof BAR): string {
808  const w = Math.ceil(width)
809
810  return (
811    `<svg xmlns="http://www.w3.org/2000/svg" class="${scopeOf(kind, bar)}" width="${w}" height="${HEIGHT}" viewBox="0 0 ${w} ${HEIGHT}">` +
812    `<title>${escapeXml(title)}</title>${style(kind, bar)}` +
813    `<rect class="bg" x="0" y="0" width="${w}" height="${HEIGHT}" rx="${HEIGHT / 2}"/>` +
814    `${body}</svg>`
815  )
816}
817
818const icon = (svg: string, x: number, y: number) => `<g transform="translate(${x.toFixed(1)} ${y})">${svg}</g>`
819const text = (value: string, x: number, className = 'tx') =>
820  `<text class="${className}" x="${x.toFixed(1)}" y="${BASELINE}">${escapeXml(value)}</text>`
821
822const NAMES: Record<ValueKind, string> = {
823  input: 'Input',
824  output: 'Output',
825  thinking: 'Thinking',
826  cache: 'Cache read',
827  cacheHit: 'Cache hit rate',
828  web: 'Web requests',
829  requests: 'API requests',
830  speed: 'Speed',
831  cost: 'Cost',
832  costRate: 'Cost per hour',
833  lastTurn: 'Last turn',
834  tools: 'Tool calls',
835  churn: 'Lines changed',
836  agents: 'Running subagents',
837  compactions: 'Compactions',
838  prompts: 'Prompts',
839  age: 'Session age',
840  model: 'Model',
841  effort: 'Effort',
842  folder: 'Folder',
843  git: 'Git branch',
844  memory: 'Memory',
845}
846
847/** Short words for the compact layout, where no icon says what a value is. */
848const SHORT: Record<ValueKind, string> = {
849  input: 'in',
850  output: 'out',
851  thinking: 'think',
852  cache: 'cache',
853  cacheHit: 'hit',
854  web: 'web',
855  requests: 'req',
856  speed: '',
857  cost: '',
858  costRate: '',
859  lastTurn: 'turn',
860  tools: 'tools',
861  churn: '',
862  agents: 'agents',
863  compactions: 'compact',
864  prompts: 'prompts',
865  age: 'age',
866  model: '',
867  effort: 'effort',
868  folder: 'dir',
869  git: 'git',
870  memory: 'mem',
871}
872
873export function pillName(pill: Pill): string {
874  return isBar(pill) ? pill.label : NAMES[pill.kind]
875}
876
877/** The words a value pill shows in the compact layout and the terminal summary. */
878export function compactText(pill: ValuePill): string {
879  const word = SHORT[pill.kind]
880
881  return word ? `${word} ${pill.value}` : pill.value
882}
883
884export type PillDrawing = { key: string; svg: string; width: number; alt: string }
885
886export function pillSvg(pill: Pill, look: Look): PillDrawing {
887  const parts: string[] = []
888  let x = look.isCompact ? 10 : 9
889  if (!look.isCompact) {
890    parts.push(icon(ICONS[pill.kind], x, 5))
891    x += 14 + 5
892  }
893
894  if (isBar(pill)) {
895    const level = barLevel(pill.percent, look)
896    parts.push(text(pill.label, x))
897    x += textWidth(pill.label) + 6
898    if (!look.isCompact) {
899      const barWidth = 36
900      const filled = (Math.min(100, Math.max(0, pill.percent)) / 100) * barWidth
901      parts.push(`<rect class="tr" x="${x.toFixed(1)}" y="9.5" width="${barWidth}" height="5" rx="2.5"/>`)
902      if (filled > 0) {
903        parts.push(
904          `<rect class="bar" x="${x.toFixed(1)}" y="9.5" width="${Math.max(filled, 2.5).toFixed(1)}" height="5" rx="2.5"/>`,
905        )
906      }
907      if (pill.elapsed !== null) {
908        const mx = x + pill.elapsed * barWidth
909        parts.push(
910          `<line class="mk" x1="${mx.toFixed(1)}" y1="6" x2="${mx.toFixed(1)}" y2="18" stroke-width="1.5" stroke-linecap="round"/>`,
911        )
912      }
913      x += barWidth + 7
914    }
915    const percent = formatPercent(pill.percent)
916    // Compact has no bar, so the percent carries the level's color.
917    parts.push(text(percent, x, look.isCompact && level !== 'ok' ? 'lv b' : 'tx b'))
918    x += textWidth(percent)
919    if (pill.isAtRisk) {
920      x += 5
921      parts.push(icon(WARNING, x, 5.5))
922      x += 12
923    }
924    if (pill.remaining !== null) {
925      x += 7
926      parts.push(`<line class="sep" x1="${x.toFixed(1)}" y1="6.5" x2="${x.toFixed(1)}" y2="17.5" stroke-width="1"/>`)
927      x += 7
928      if (!look.isCompact) {
929        parts.push(icon(COUNTDOWN, x, 5))
930        x += 12 + 4
931      }
932      parts.push(text(pill.remaining, x))
933      x += textWidth(pill.remaining)
934    }
935    x += 10
936
937    return {
938      key: pill.key,
939      width: Math.ceil(x),
940      alt: `${pill.label} ${percent}${pill.remaining ? `, ${pill.remaining}` : ''}${pill.isAtRisk ? ', at risk' : ''}`,
941      svg: frame(pill.kind, x, pill.title, parts.join(''), level),
942    }
943  }
944
945  const shown = look.isCompact ? compactText(pill) : pill.value
946  parts.push(text(shown, x, 'tx b'))
947  x += textWidth(shown) + 10
948
949  return {
950    key: pill.key,
951    width: Math.ceil(x),
952    alt: `${NAMES[pill.kind]} ${pill.value}`,
953    svg: frame(pill.kind, x, pill.title, parts.join('')),
954  }
955}
956
957// ---- Terminal: the same pills as colored text ----
958
959export const TERMINAL_COLORS: Record<PillKind, string> = {
960  fiveHour: '#2bb3a0',
961  sevenDay: '#a27ee8',
962  spendLimit: '#d4a72c',
963  context: '#e8893a',
964  input: '#e2665d',
965  output: '#4cb860',
966  thinking: '#d76bb4',
967  cache: '#5b95e6',
968  cacheHit: '#5b95e6',
969  web: '#3fb0d8',
970  requests: '#a8a69c',
971  speed: '#9ccc3c',
972  cost: '#d4a72c',
973  costRate: '#d4a72c',
974  lastTurn: '#8a9bbd',
975  tools: '#8a9bbd',
976  churn: '#4cb860',
977  agents: '#8c93ea',
978  compactions: '#e8893a',
979  prompts: '#a8a69c',
980  age: '#a8a69c',
981  model: '#a8a69c',
982  effort: '#d76bb4',
983  folder: '#a8a69c',
984  git: '#8a9bbd',
985  memory: '#a8a69c',
986}
987
988export const TERMINAL_BAR = { ok: '#3fb96b', warn: '#e0b030', high: '#ea4d52' }
989
990export const TERMINAL_ICONS: Record<PillKind, string> = {
991  fiveHour: '◔',
992  sevenDay: '▦',
993  spendLimit: '$',
994  context: '◑',
995  input: '↑',
996  output: '↓',
997  thinking: '✻',
998  cache: '≋',
999  cacheHit: '◎',
1000  web: '⌕',
1001  requests: '⇅',
1002  speed: '»',
1003  cost: '$',
1004  costRate: '↗',
1005  lastTurn: '⧗',
1006  tools: '⚒',
1007  churn: '±',
1008  agents: '⧉',
1009  compactions: '⇲',
1010  prompts: '›',
1011  age: '◷',
1012  model: '◆',
1013  effort: '▁▃▅',
1014  folder: '▭',
1015  git: '⎇',
1016  memory: '▤',
1017}
1018
1019/** A 10-cell bar: filled cells, empty cells, and the elapsed mark's cell. */
1020export function terminalBar(percent: number, elapsed: number | null): { cells: string[]; mark: number } {
1021  const size = 10
1022  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * size)
1023  const cells = Array.from({ length: size }, (_, i) => (i < filled ? '█' : '░'))
1024  const mark = elapsed === null ? -1 : Math.min(size - 1, Math.floor(elapsed * size))
1025
1026  return { cells, mark }
1027}
1028
types/index.d.ts 111 lines
1/** One rate-limit window: how much of it is used, and when it resets. */
2export type UsageLimit = { percent: number; resetsAt: string | null }
3
4/** One category of the context window, as /context names it. */
5export type UsageContextCategory = { name: string; tokens: number }
6
7/** What `$.session.usage()` and `session.measure` report, kept for the band. */
8export type UsageSnapshot = {
9  fiveHour: UsageLimit | null
10  sevenDay: UsageLimit | null
11  /** A Claude gateway's spend limit, when the account reports one. */
12  spendLimit: UsageLimit | null
13  contextTokens: number | null
14  contextWindow: number | null
15  contextPercent: number | null
16  /** Largest categories first; null unless the breakdown setting is on. */
17  contextCategories: UsageContextCategory[] | null
18  autoCompactAt: number | null
19  costUsd: number | null
20}
21
22/** Token totals of the session; `isEstimate` when summed from turn.complete. */
23export type UsageTokens = {
24  uncached: number
25  cacheWrite: number
26  cacheWrite1h: number
27  cacheWrite5m: number
28  output: number
29  thinking: number
30  cacheRead: number
31  webSearches: number
32  webFetches: number
33  requests: number
34  isEstimate: boolean
35}
36
37/** What the transcript says about tools and edits over the whole session. */
38export type UsageTranscript = {
39  toolCalls: number
40  toolFailures: number
41  byTool: Record<string, number>
42  linesAdded: number
43  linesRemoved: number
44  filesChanged: number
45}
46
47/** The last main-loop turn: how long it took, what it wrote and cost. */
48export type UsageTurn = {
49  durationMs: number
50  output: number
51  startCost: number | null
52  endCost: number | null
53}
54
55/** What the session's hooks counted since the mod loaded. */
56export type UsageActivity = {
57  lastTurn: UsageTurn | null
58  toolCalls: number
59  toolFailures: number
60  byTool: Record<string, number>
61  agentsRunning: number
62  compactions: number
63  compactionFreed: number
64}
65
66/** The main loop's requests as they streamed: model, effort and output speed. */
67export type UsageStream = {
68  model: string | null
69  effort: string | null
70  /** Tokens per second of the last response, from its first streamed piece. */
71  lastTps: number | null
72  /** Sums for the average speed since the band loaded. */
73  tokens: number
74  ms: number
75  responses: number
76}
77
78/** The git working copy the session runs in. */
79export type UsageGit = { branch: string; dirty: number; ahead: number; behind: number }
80
81/** Facts about the session and the machine. */
82export type UsageSession = {
83  startedAt: number | null
84  model: string | null
85  version: string | null
86  prompts: number | null
87  cwd: string | null
88  git: UsageGit | null
89  memory: { total: number; free: number } | null
90}
91
92/** The highest alert level already toasted per window: 0 none, 1 yellow, 2 red. */
93export type UsageAlerted = { fiveHour: number; sevenDay: number; spendLimit: number }
94
95declare module 'claude-code' {
96  interface PluginState {
97    'usage-band': {
98      snapshot: UsageSnapshot | null
99      tokens: UsageTokens | null
100      fallback: UsageTokens | null
101      transcript: UsageTranscript | null
102      activity: UsageActivity
103      stream: UsageStream | null
104      session: UsageSession | null
105      alerted: UsageAlerted
106      isHidden: boolean
107      now: number
108    }
109  }
110}
111