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

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.


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.
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.
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 providers | Catalog 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 tools | Edits, lint/typecheck/test, execution, git, web, browser/E2E and a SQLite codebase index — every call gated by per-tool permissions |
| 🧠 SAGE memory | Project-wide long-term memory in SQLite/FTS5, anchored to files, symbols and commits — it gets better at your codebase over time |
| 🖥️ Six surfaces | Readline REPL · Ink/React TUI (--tui) · WebUI · SimpleUI · Desktop · cross-machine HQ (--hq) |
| 🤖 Fleet orchestration | A Director fans out specialist subagents over a project mailbox; eternal & parallel goal loops run until the contract verifies |
| 🔍 Chimera & Kanban | Auto-review agents that critique your diffs with severity-ranked findings, plus durable Kanban boards with atomic verification |
| 🔐 Secure by default | Encrypted 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
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.
| Command | What it does |
|---|---|
node install.mjs | Copy and register (run it again after git pull to update) |
node install.mjs --link | Register this folder itself instead of a copy, so your edits apply live |
node install.mjs --uninstall | Unregister and remove the copy |
node install.mjs --dry-run | Show what would change without changing anything |
To try it without installing, run a single session with claude --plugin-dir /path/to/claude-usage-band.
| Command | What it does |
|---|---|
/usage-band | Refresh everything and print a one-line summary |
/usage-band settings | Open a settings pane with a checkbox for each pill (this works in the desktop app, which has no /config) |
/usage-band on git, cost-rate | Turn pills on; off turns them off. Names are loose: git, showGit and Git branch all match |
/usage-band set layout compact | Also set yellow 60, set red 85, set refresh 60 |
/usage-band hide / show | Hide or show the whole band (remembered across sessions) |
Running it headless from Git Bash on Windows? Git Bash turns
/usage-bandinto a Windows path. UseMSYS_NO_PATHCONV=1 claude -p "/usage-band", or run it from PowerShell.
Settings are stored in settings.json under pluginConfigs. They also appear in /config in a terminal session. Every pill is on by default.
| Setting | Shows |
|---|---|
show5h | 5-hour limit: usage of the 5-hour rate-limit window and time to reset |
show7d | 7-day limit: usage of the 7-day rate-limit window and time to reset |
showSpendLimit | Spend limit: a gateway's spend limit, when the account reports one |
showContext | Context window: how full the context window is |
showInput | Input tokens: uncached input plus cache writes |
showOutput | Output tokens: tokens the model generated |
showThinking | Thinking tokens: the part of output spent on extended thinking |
showCacheRead | Cache read tokens: input tokens served from the prompt cache |
showCacheHit | Cache hit rate: share of all input read from the prompt cache |
showWeb | Web searches and fetches: server-side web search and fetch requests |
showRequests | API requests: model requests, subagents included |
showSpeed | Output speed: tokens per second of the last response (hover for the average) |
showCost | Session cost: session cost at API list prices |
showCostRate | Cost per hour: average spend per hour since the session started |
showLastTurn | Last turn: duration, cost and output speed of the last turn |
showTools | Tool calls: tool calls, failures and the most used tools |
showChurn | Lines changed: lines added and removed by Edit, MultiEdit and Write |
showAgents | Running subagents: subagents running right now |
showCompactions | Compactions: how often the context was compacted and what it freed |
showPrompts | Prompts: prompts sent this session |
showSessionAge | Session age: time since the session started |
showModel | Model: the model that answered last, and the Claude Code version |
showEffort | Thinking effort: the thinking effort of the last request (low to max) |
showFolder | Folder: the working directory's name (hover for the full path) |
showGit | Git branch: branch, uncommitted files, ahead and behind |
showMemory | Machine memory: this machine's memory in use |
| Option | Default | What it does |
|---|---|---|
limitProjection | on | Project 5h/7d usage at reset from the pace so far, and warn when it would pass 100% |
contextBreakdown | on | List what fills the context in the context tooltip (estimated locally) |
alerts | on | Show a toast when a limit crosses the yellow or red threshold |
showInTerminal | on | Turn off to show the band only in the desktop app |
layout | full | compact drops icons and bars and labels each value instead |
warnAt / hotAt | 70 / 90 | The percentages at which bars turn yellow and red |
refreshSeconds | 30 | How often countdowns and totals refresh (10 to 600) |
$.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.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 ~.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.
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
| Path | What it is |
|---|---|
.claude-plugin/plugin.json | Manifest and settings (userConfig) |
hooks/register.tsx | The hooks: data, commands, settings pane, and drawing for each surface |
hooks/pills.ts | The pill model and desktop SVG drawing, with no engine calls (shared with the preview) |
types/index.d.ts | The $.state contract |
scripts/ | tokens.mjs (transcript totals) and sysinfo.mjs (memory) |
tests/ | claude plugin test suite |
hooks/register.tsx 746 lines1import { 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}
746hooks/pills.ts 1028 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
1028types/index.d.ts 111 lines1/** 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