Usage band above the prompt: limits, context, tokens, speed, cost, tools, churn, model, effort, folder, git, memory and timing

A usage band for Claude Code, drawn right above the prompt: rate limits, context, tokens, speed, cost, tool calls, churn and session details at a glance. It's a Claude Code mod (a plugin of function hooks): in the terminal it draws three powerline lines with Nerd Font icons; in the desktop app's Code tab, rounded SVG pills that follow the light and dark theme.

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
| Line | Pills |
|---|---|
| 1 | 5h and 7d limits (bar, a marker for how much of the window has passed, → the projected usage at reset if the pace holds, reset countdown) · spend limit (gateways) · ctx context fill · session cost (and cost per hour) |
| 2 | input (uncached + cache writes) · output · cache reads (and hit rate) · output speed in t/s · tool calls · lines changed (+/−) |
| 3 | model · thinking effort · folder · git branch and changed files · session duration · turns (and the last turn's length) · machine memory |
PATH (or at /usr/local/bin/node, /opt/homebrew/bin/node), used by the token and memory scripts.git on PATH for the git pill.nerdFont to false (text labels) or terminalStyle to plain.Clone it anywhere, then load it as a plugin folder.
git clone https://github.com/ersinkoc/claude-usage-band-cli.git
For one session:
claude --plugin-dir /path/to/claude-usage-band-cli
For every session (the desktop app included), add it to the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-usage-band-cli",
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
If CLAUDE_CODE_PLUGIN_DIRS already has folders, append this one with the platform's path-list separator (: on macOS/Linux, ; on Windows). Start a new session after the change.
The plugin is named
usage-band. Don't load it together with anotherusage-bandplugin: they'd claim the same command and state.
| Command | Does | |
|---|---|---|
/usage-band | Refreshes everything and prints a one-line summary | |
/usage-band hide · /usage-band show | Hides or shows the band | |
/usage-band settings | Lists every setting with its current value | |
/usage-band on <name…> · /usage-band off <name…> | Turns pills on or off (all for every one) | |
| `/usage-band set layout full\ | compact` | Icons and bars, or text only |
| `/usage-band set style powerline\ | plain` | Terminal style |
/usage-band set warn <1-100> · set hot <1-100> | Yellow and red thresholds | |
/usage-band set refresh <10-600> | Refresh interval in seconds |
Names for on/off: 5h, 7d, spend, pace, context, input, output, cache, hit, speed, cost, burn, tools, churn, model, effort, folder, git, memory, duration, turns, terminal, nerd, alerts (aliases: ctx, in, out, tps, lines, thinking, cwd, branch, mem, time, weekly).
All settings are also rows in /config (search "Usage band"), stored under pluginConfigs["usage-band"].options in ~/.claude/settings.json. Changing one reloads the mod with the new value.
| Setting | Type | Default | What it does |
|---|---|---|---|
show5h | boolean | true | Pill with the 5-hour window's usage, elapsed marker and reset countdown |
show7d | boolean | true | Pill with the 7-day window's usage, elapsed marker and reset countdown |
showSpendLimit | boolean | true | Pill for a gateway's spend limit, when the account reports one |
showPace | boolean | true | On limit pills, where usage lands at reset if the current pace holds |
showContext | boolean | true | Pill with the context window's fill |
showInput | boolean | true | Uncached input + cache writes |
showOutput | boolean | true | Tokens the model generated |
showCacheRead | boolean | true | Input tokens served from the prompt cache |
showCacheHit | boolean | true | On the cache pill, the share of input served from the cache |
showSpeed | boolean | true | Tokens per second of the last response (hover for the average) |
showCost | boolean | true | Session cost at API list prices |
showBurnRate | boolean | true | On the cost pill, the session's cost per hour so far |
showTools | boolean | true | Pill with the number of tool calls (hover for the top tools) |
showChurn | boolean | true | Lines added and removed by Edit, MultiEdit and Write |
showModel | boolean | true | Pill with the model that answered last |
showEffort | boolean | true | The thinking effort of the last request (low … max) |
showFolder | boolean | true | The working directory's name (hover for the full path) |
showGit | boolean | true | The current branch and how many files changed |
showMemory | boolean | true | This machine's memory in use |
showDuration | boolean | true | Pill with how long the session has run |
showTurns | boolean | true | Pill with the number of turns and the last turn's length |
showInTerminal | boolean | true | Off to show the band only in the desktop app (when a status line already covers the terminal) |
terminalStyle | powerline / plain | powerline | Colored powerline segments, or colored text |
nerdFont | boolean | true | Icons and rounded powerline caps from a Nerd Font; off for text labels |
layout | full / compact | full | Icons and bars, or text only |
warnAt | number | 70 | Bars turn yellow at this percent |
hotAt | number | 90 | Bars turn red at this percent |
alerts | boolean | true | A toast when a limit crosses the yellow or red threshold |
refreshSeconds | number | 30 | How often countdowns and totals refresh |
$.session.usage() and the session.measure event Claude Code raises after each turn.scripts/tokens.mjs reads the session's transcript (~/.claude/projects/*/<session-id>.jsonl) and its subagents' transcripts. Each API response is written once per content block, so it counts each (message.id, requestId) once, taking the largest value of every usage field. It is re-run only when the transcript's size or modification time changes. If it can't run, the band falls back to the per-turn usage Claude Code reports and marks those numbers with ~.git rev-parse, git branch --show-current and git status --porcelain in the session's directory.scripts/sysinfo.mjs (os.totalmem() and os.freemem())..claude-plugin/plugin.json manifest and userConfig (the settings)
hooks/hooks.json names the hooks module
hooks/register.tsx hooks, state, settings, command, terminal drawing
hooks/pills.ts formatting and the SVG pills (no engine calls)
scripts/tokens.mjs transcript totals
scripts/sysinfo.mjs machine memory
types/index.d.ts the $.state contract
tests/band.test.tsx claude plugin test suite
claude plugin validate . # what the engine would load or refuse
claude plugin test . # tests/band.test.tsx against the engine
Once Claude Code has loaded the folder it lays the API types into .claude-plugin/types/ (git-ignored), and npx -p typescript tsc -p . type-checks the mod.
hooks/register.tsx 1123 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 PluginOptions,
5 Register,
6 SessionContextUsage,
7 SessionCost,
8 SessionRateLimit,
9} from 'claude-code'
10
11import type {
12 UsageBandAlerted,
13 UsageBandChurn,
14 UsageBandContext,
15 UsageBandGit,
16 UsageBandLimit,
17 UsageBandMemory,
18 UsageBandSpeed,
19 UsageBandTokens,
20 UsageBandTools,
21 UsageBandTurns,
22} from '../types'
23import {
24 POWERLINE_BG,
25 POWERLINE_FG,
26 TERMINAL_TONES,
27 barColor,
28 baseName,
29 cacheHitPercent,
30 churnPill,
31 contextPill,
32 costPill,
33 durationPill,
34 effortPill,
35 elapsedFraction,
36 folderPill,
37 formatGiB,
38 formatLeft,
39 formatPercent,
40 formatShort,
41 formatTokens,
42 formatUsd,
43 gitPill,
44 level,
45 limitPill,
46 memoryPill,
47 modelPill,
48 prettyModel,
49 projectedPercent,
50 speedPill,
51 terminalBar,
52 tokenPills,
53 toolsPill,
54 turnsPill,
55 windowOf,
56} from './pills'
57import type { LimitKind, Look, Pill, Tone } from './pills'
58
59// ---------------------------------------------------------------- state
60
61const EMPTY_TOKENS: UsageBandTokens = { input: 0, cacheWrite: 0, output: 0, cacheRead: 0, requests: 0, isEstimate: true }
62const EMPTY_TOOLS: UsageBandTools = { total: 0, byTool: {}, isEstimate: true }
63const EMPTY_TURNS: UsageBandTurns = { count: 0, lastMs: 0, totalMs: 0, timed: 0 }
64const NO_ALERTS: UsageBandAlerted = { fiveHour: 0, sevenDay: 0, spend: 0 }
65
66const fiveHour = atom({ plugin: 'usage-band', key: 'fiveHour' } as const, null)
67const sevenDay = atom({ plugin: 'usage-band', key: 'sevenDay' } as const, null)
68const spendLimit = atom({ plugin: 'usage-band', key: 'spendLimit' } as const, null)
69const contextAtom = atom({ plugin: 'usage-band', key: 'context' } as const, null)
70const costUsd = atom({ plugin: 'usage-band', key: 'costUsd' } as const, null)
71const startedAtAtom = atom({ plugin: 'usage-band', key: 'startedAt' } as const, null)
72const tokensAtom = atom({ plugin: 'usage-band', key: 'tokens' } as const, null)
73const estimate = atom({ plugin: 'usage-band', key: 'estimate' } as const, EMPTY_TOKENS)
74const toolsAtom = atom({ plugin: 'usage-band', key: 'tools' } as const, null)
75const liveTools = atom({ plugin: 'usage-band', key: 'liveTools' } as const, EMPTY_TOOLS)
76const churnAtom = atom({ plugin: 'usage-band', key: 'churn' } as const, null)
77const turnsAtom = atom({ plugin: 'usage-band', key: 'turns' } as const, EMPTY_TURNS)
78const modelAtom = atom({ plugin: 'usage-band', key: 'model' } as const, null)
79const effortAtom = atom({ plugin: 'usage-band', key: 'effort' } as const, null)
80const cwdAtom = atom({ plugin: 'usage-band', key: 'cwd' } as const, null)
81const gitAtom = atom({ plugin: 'usage-band', key: 'git' } as const, null)
82const speedAtom = atom({ plugin: 'usage-band', key: 'speed' } as const, null)
83const memoryAtom = atom({ plugin: 'usage-band', key: 'memory' } as const, null)
84const alertedAtom = atom({ plugin: 'usage-band', key: 'alerted' } as const, NO_ALERTS)
85const nowAtom = atom({ plugin: 'usage-band', key: 'now' } as const, 0)
86const isHidden = atom({ plugin: 'usage-band', key: 'isHidden' } as const, false)
87
88// ---------------------------------------------------------------- settings
89
90/** Each pill (or pill extra) and the userConfig field that turns it on, by the name /usage-band on|off takes. */
91const TOGGLES = {
92 '5h': 'show5h',
93 '7d': 'show7d',
94 spend: 'showSpendLimit',
95 pace: 'showPace',
96 context: 'showContext',
97 input: 'showInput',
98 output: 'showOutput',
99 cache: 'showCacheRead',
100 hit: 'showCacheHit',
101 speed: 'showSpeed',
102 cost: 'showCost',
103 burn: 'showBurnRate',
104 tools: 'showTools',
105 churn: 'showChurn',
106 model: 'showModel',
107 effort: 'showEffort',
108 folder: 'showFolder',
109 git: 'showGit',
110 memory: 'showMemory',
111 duration: 'showDuration',
112 turns: 'showTurns',
113 terminal: 'showInTerminal',
114 nerd: 'nerdFont',
115 alerts: 'alerts',
116} as const
117
118type Toggle = keyof typeof TOGGLES
119type ToggleField = (typeof TOGGLES)[Toggle]
120
121const ALIASES: Record<string, Toggle> = {
122 ctx: 'context',
123 in: 'input',
124 out: 'output',
125 cacheread: 'cache',
126 cachehit: 'hit',
127 tps: 'speed',
128 rate: 'burn',
129 lines: 'churn',
130 thinking: 'effort',
131 cwd: 'folder',
132 dir: 'folder',
133 branch: 'git',
134 mem: 'memory',
135 time: 'duration',
136 session: 'duration',
137 weekly: '7d',
138}
139
140const NUMBERS = { warn: 'warnAt', hot: 'hotAt', refresh: 'refreshSeconds' } as const
141
142type Settings = {
143 on: Record<ToggleField, boolean>
144 look: Look
145 refreshMs: number
146 terminalStyle: 'powerline' | 'plain'
147 nerd: boolean
148}
149
150function readSettings(options: PluginOptions): Settings {
151 const on = {} as Record<ToggleField, boolean>
152 for (const field of Object.values(TOGGLES)) on[field] = options[field] !== false
153 // nerd is a toggle for the command; Settings.nerd is what the drawing reads
154 const num = (key: string, fallback: number, min: number, max: number) => {
155 const v = Number(options[key] ?? fallback)
156 return Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : fallback
157 }
158 const warnAt = num('warnAt', 70, 1, 100)
159 const hotAt = Math.max(warnAt, num('hotAt', 90, 1, 100))
160 return {
161 on,
162 look: { warnAt, hotAt, isCompact: options.layout === 'compact' },
163 refreshMs: num('refreshSeconds', 30, 10, 600) * 1000,
164 terminalStyle: options.terminalStyle === 'plain' ? 'plain' : 'powerline',
165 nerd: options.nerdFont !== false,
166 }
167}
168
169// Module state: lost on reload, which only costs one recount.
170let settings: Settings = readSettings({})
171let nodePath: string | null = null
172let isScriptBroken = false
173let counted: { path: string; size: number; mtimeMs: number } | null = null
174let counting: Promise<void> | null = null
175
176const NODE_CANDIDATES = ['node', '/usr/local/bin/node', '/opt/homebrew/bin/node']
177
178type Engine = EngineInterface
179
180// ---------------------------------------------------------------- reading the engine
181
182function toLimit(windows: SessionRateLimit[], kind: string): UsageBandLimit | null {
183 const found = windows.find(w => w.kind === kind)
184 if (found === undefined) return null
185 const resetsAtMs = found.resetsAt === undefined ? NaN : Date.parse(found.resetsAt)
186 return { percentUsed: found.percentUsed, resetsAtMs: Number.isNaN(resetsAtMs) ? null : resetsAtMs }
187}
188
189function toContext(context: SessionContextUsage): UsageBandContext {
190 return { tokens: context.tokens ?? null, window: context.window, percent: context.percent ?? null }
191}
192
193const ALERT_NAMES: Record<keyof UsageBandAlerted, string> = {
194 fiveHour: '5-hour limit',
195 sevenDay: '7-day limit',
196 spend: 'Spend limit',
197}
198
199async function alertOnCross($: Engine, which: keyof UsageBandAlerted, limit: UsageBandLimit, now: number): Promise<void> {
200 const reached = level(limit.percentUsed, settings.look)
201 const before = (await read($, alertedAtom))[which]
202 if (reached === before) return
203 await update($, alertedAtom, prev => ({ ...prev, [which]: reached }))
204 if (!settings.on.alerts || reached < before) return
205 const left = limit.resetsAtMs === null ? '' : `, resets in ${formatLeft(limit.resetsAtMs - now)}`
206 $.ui.toast(`${reached === 2 ? '⚠ ' : ''}${ALERT_NAMES[which]} at ${formatPercent(limit.percentUsed)}${left}`, {
207 timeoutMs: reached === 2 ? 10_000 : 6000,
208 })
209}
210
211async function applyMeasure(
212 $: Engine,
213 rateLimits: SessionRateLimit[],
214 context: SessionContextUsage,
215 cost: SessionCost | undefined,
216): Promise<void> {
217 const now = await $.clock.now()
218 // Limits appear after the first model response; until then the pills stay hidden.
219 const five = toLimit(rateLimits, 'five_hour')
220 const seven = toLimit(rateLimits, 'seven_day')
221 const spend = toLimit(rateLimits, 'spend_limit')
222 if (five !== null) {
223 await update($, fiveHour, () => five)
224 await alertOnCross($, 'fiveHour', five, now)
225 }
226 if (seven !== null) {
227 await update($, sevenDay, () => seven)
228 await alertOnCross($, 'sevenDay', seven, now)
229 }
230 if (spend !== null) {
231 await update($, spendLimit, () => spend)
232 await alertOnCross($, 'spend', spend, now)
233 }
234 await update($, contextAtom, () => toContext(context))
235 if (cost !== undefined) await update($, costUsd, () => cost.usd)
236}
237
238async function runNode($: Engine, script: string, args: string[]): Promise<string | null> {
239 const path = `${$.plugin.root}/scripts/${script}`
240 const candidates = nodePath === null ? NODE_CANDIDATES : [nodePath]
241 for (const node of candidates) {
242 try {
243 const ran = await $.process.run([node, path, ...args], { timeoutMs: 20_000 })
244 if (ran.exitCode === 0) {
245 nodePath = node
246 return ran.stdout
247 }
248 } catch {
249 // not found here; try the next one
250 }
251 }
252 return null
253}
254
255function lastJsonLine(stdout: string): unknown {
256 try {
257 return JSON.parse(stdout.trim().split('\n').pop() ?? '{}')
258 } catch {
259 return null
260 }
261}
262
263type ScriptResult = {
264 ok: boolean
265 path?: string
266 size?: number
267 mtimeMs?: number
268 input?: number
269 cacheWrite?: number
270 output?: number
271 cacheRead?: number
272 requests?: number
273 tools?: { total: number; byTool: Record<string, number> }
274 churn?: UsageBandChurn
275 model?: string | null
276}
277
278async function countTokens($: Engine): Promise<void> {
279 if (isScriptBroken) return
280 if (counted !== null) {
281 const stat = await $.fs.stat(counted.path).catch(() => null)
282 if (stat !== null && stat.size === counted.size && stat.mtimeMs === counted.mtimeMs) return
283 }
284 const sessionId = await $.session.id()
285 const stdout = await runNode($, 'tokens.mjs', [sessionId])
286 if (stdout === null) {
287 isScriptBroken = true
288 return
289 }
290 const parsed = lastJsonLine(stdout) as ScriptResult | null
291 // A transcript not written yet (a brand-new session) is no failure: try again later.
292 if (parsed === null || !parsed.ok || parsed.path === undefined) return
293 counted = { path: parsed.path, size: parsed.size ?? 0, mtimeMs: parsed.mtimeMs ?? 0 }
294 const tokens: UsageBandTokens = {
295 input: parsed.input ?? 0,
296 cacheWrite: parsed.cacheWrite ?? 0,
297 output: parsed.output ?? 0,
298 cacheRead: parsed.cacheRead ?? 0,
299 requests: parsed.requests ?? 0,
300 isEstimate: false,
301 }
302 await update($, tokensAtom, () => tokens)
303 const tools = parsed.tools
304 if (tools !== undefined) await update($, toolsAtom, () => ({ ...tools, isEstimate: false }))
305 const churn = parsed.churn
306 if (churn !== undefined) await update($, churnAtom, () => churn)
307 const model = parsed.model
308 if (typeof model === 'string' && (await read($, modelAtom)) === null) await update($, modelAtom, () => model)
309}
310
311function refreshTokens($: Engine): Promise<void> {
312 if (counting === null) {
313 counting = countTokens($)
314 .catch(() => undefined)
315 .finally(() => {
316 counting = null
317 })
318 }
319 return counting
320}
321
322async function readGit($: Engine, cwd: string): Promise<UsageBandGit> {
323 const notRepo: UsageBandGit = { isRepo: false, branch: null, dirty: 0 }
324 try {
325 const inside = await $.process.run(['git', 'rev-parse', '--is-inside-work-tree'], { cwd, timeoutMs: 8000 })
326 if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') return notRepo
327 let branch: string | null = (await $.process.run(['git', 'branch', '--show-current'], { cwd, timeoutMs: 8000 })).stdout.trim()
328 if (branch === '') {
329 const sha = await $.process.run(['git', 'rev-parse', '--short', 'HEAD'], { cwd, timeoutMs: 8000 })
330 branch = sha.exitCode === 0 ? `@${sha.stdout.trim()}` : null
331 }
332 const status = await $.process.run(['git', 'status', '--porcelain'], { cwd, timeoutMs: 8000 })
333 const dirty = status.exitCode === 0 ? status.stdout.split('\n').filter(l => l.trim() !== '').length : 0
334 return { isRepo: true, branch, dirty }
335 } catch {
336 return notRepo
337 }
338}
339
340async function readMemory($: Engine): Promise<UsageBandMemory | null> {
341 const stdout = await runNode($, 'sysinfo.mjs', [])
342 const parsed = stdout === null ? null : (lastJsonLine(stdout) as { total?: number; free?: number } | null)
343 if (parsed === null || typeof parsed.total !== 'number' || typeof parsed.free !== 'number') return null
344 return { total: parsed.total, free: parsed.free }
345}
346
347async function refreshSession($: Engine): Promise<void> {
348 const cwd = await $.session.cwd()
349 await update($, cwdAtom, () => cwd)
350 const count = await $.session.turns()
351 await update($, turnsAtom, prev => ({ ...prev, count }))
352 if ((await read($, modelAtom)) === null) {
353 const model = await $.session.model()
354 await update($, modelAtom, () => model)
355 }
356 if (settings.on.showGit) {
357 const git = await readGit($, cwd)
358 await update($, gitAtom, () => git)
359 }
360 if (settings.on.showMemory) {
361 const memory = await readMemory($)
362 if (memory !== null) await update($, memoryAtom, () => memory)
363 }
364}
365
366async function refreshAll($: Engine): Promise<void> {
367 const now = await $.clock.now()
368 await update($, nowAtom, () => now)
369 const usage = await $.session.usage()
370 await update($, startedAtAtom, () => usage.startedAt)
371 await applyMeasure($, usage.rateLimits, usage.context, usage.cost)
372 await Promise.all([refreshTokens($), refreshSession($).catch(() => undefined)])
373}
374
375async function recordSpeed($: Engine, tokens: number, ms: number): Promise<void> {
376 await update($, speedAtom, prev => ({
377 lastTps: (tokens / ms) * 1000,
378 tokens: (prev?.tokens ?? 0) + tokens,
379 ms: (prev?.ms ?? 0) + ms,
380 requests: (prev?.requests ?? 0) + 1,
381 }))
382}
383
384async function countTool($: Engine, tool: string): Promise<void> {
385 await update($, liveTools, prev => ({
386 total: prev.total + 1,
387 byTool: { ...prev.byTool, [tool]: (prev.byTool[tool] ?? 0) + 1 },
388 isEstimate: true,
389 }))
390}
391
392// ---------------------------------------------------------------- the command
393
394async function setOption($: Engine, field: string, value: boolean | number | string): Promise<string | null> {
395 const rows = await $.config.list()
396 const row =
397 rows.find(r => r.key === `usage-band.${field}`) ??
398 rows.find(r => r.key.startsWith('usage-band') && r.key.endsWith(`.${field}`))
399 if (row === undefined) {
400 return `No settings row for ${field}; set pluginConfigs["usage-band"].options.${field} in ~/.claude/settings.json.`
401 }
402 const done = await $.config.set({ key: row.key, value })
403 return done.deny === undefined ? null : `${field}: ${done.deny}`
404}
405
406const SETTINGS_HELP = [
407 'Pills: /usage-band on|off <name…> (or "all")',
408 ` names: ${Object.keys(TOGGLES).join(', ')}`,
409 'Layout: /usage-band set layout full|compact',
410 'Terminal: /usage-band set style powerline|plain · /usage-band off nerd (text labels instead of Nerd Font icons)',
411 'Thresholds: /usage-band set warn <1-100> · /usage-band set hot <1-100>',
412 'Refresh: /usage-band set refresh <10-600 seconds>',
413 'Or open /config and search "Usage band".',
414].join('\n')
415
416function describeSettings(): string {
417 const on = Object.entries(TOGGLES)
418 .map(([name, field]) => `${settings.on[field] ? '●' : '○'} ${name}`)
419 .join(' ')
420 const { look } = settings
421 return [
422 on,
423 `layout ${look.isCompact ? 'compact' : 'full'} · terminal ${settings.terminalStyle}${settings.nerd ? ' + Nerd Font' : ''} · yellow ${look.warnAt}% · red ${look.hotAt}% · refresh ${settings.refreshMs / 1000}s`,
424 '',
425 SETTINGS_HELP,
426 ].join('\n')
427}
428
429async function runSettingsCommand($: Engine, verb: string, rest: string[]): Promise<string> {
430 if (verb === 'on' || verb === 'off') {
431 const names = rest.length === 1 && rest[0] === 'all' ? Object.keys(TOGGLES) : rest
432 if (names.length === 0) return `Name what to turn ${verb}.\n${SETTINGS_HELP}`
433 const unknown: string[] = []
434 const fields: ToggleField[] = []
435 for (const raw of names) {
436 const name = ALIASES[raw] ?? raw
437 const field = name in TOGGLES ? TOGGLES[name as Toggle] : undefined
438 if (field === undefined) unknown.push(raw)
439 else fields.push(field)
440 }
441 if (unknown.length > 0) return `Unknown: ${unknown.join(', ')}.\n${SETTINGS_HELP}`
442 for (const field of fields) {
443 const failed = await setOption($, field, verb === 'on')
444 if (failed !== null) return failed
445 }
446 return `Turned ${verb}: ${names.join(', ')}. The band reloads with the new settings.`
447 }
448 // verb === 'set'
449 const [name, value] = rest
450 if (name === 'layout') {
451 if (value !== 'full' && value !== 'compact') return 'Layout is full or compact.'
452 return (await setOption($, 'layout', value)) ?? `Layout set to ${value}.`
453 }
454 if (name === 'style') {
455 if (value !== 'powerline' && value !== 'plain') return 'Style is powerline or plain.'
456 return (await setOption($, 'terminalStyle', value)) ?? `Terminal style set to ${value}.`
457 }
458 const field = name !== undefined && name in NUMBERS ? NUMBERS[name as keyof typeof NUMBERS] : undefined
459 const n = Number(value)
460 if (field === undefined || value === undefined || !Number.isFinite(n)) return SETTINGS_HELP
461 return (await setOption($, field, n)) ?? `${name} set to ${n}.`
462}
463
464async function summary($: Engine): Promise<string> {
465 const now = await read($, nowAtom)
466 const parts: string[] = []
467 const limits: [string, UsageBandLimit | null][] = [
468 ['5h', await read($, fiveHour)],
469 ['7d', await read($, sevenDay)],
470 ['spend', await read($, spendLimit)],
471 ]
472 for (const [name, limit] of limits) {
473 if (limit === null) continue
474 parts.push(
475 `${name} ${formatPercent(limit.percentUsed)}` +
476 (limit.resetsAtMs === null ? '' : ` (${formatLeft(limit.resetsAtMs - now)})`),
477 )
478 }
479 const context = await read($, contextAtom)
480 if (context !== null && context.tokens !== null) {
481 parts.push(`ctx ${formatTokens(context.tokens)}/${formatTokens(context.window)}`)
482 }
483 const tokens = (await read($, tokensAtom)) ?? (await read($, estimate))
484 if (tokens.requests > 0 || !tokens.isEstimate) {
485 const mark = tokens.isEstimate ? '~' : ''
486 parts.push(
487 `in ${mark}${formatTokens(tokens.input + tokens.cacheWrite)}`,
488 `out ${mark}${formatTokens(tokens.output)}`,
489 `cache ${mark}${formatTokens(tokens.cacheRead)}`,
490 )
491 }
492 const cost = await read($, costUsd)
493 if (cost !== null) parts.push(formatUsd(cost))
494 const tools = (await read($, toolsAtom)) ?? (await read($, liveTools))
495 if (tools.total > 0) parts.push(`${tools.total} tools`)
496 const churn = await read($, churnAtom)
497 if (churn !== null && churn.added + churn.removed > 0) parts.push(`+${churn.added} −${churn.removed}`)
498 const model = await read($, modelAtom)
499 if (model !== null) parts.push(prettyModel(model))
500 const effort = await read($, effortAtom)
501 if (effort !== null) parts.push(`thinking ${effort}`)
502 return parts.length === 0 ? 'No usage data yet (it arrives after the first model response).' : parts.join(' · ')
503}
504
505// ---------------------------------------------------------------- the band's content
506
507type Band = {
508 now: number
509 limits: { kind: LimitKind; limit: UsageBandLimit }[]
510 context: UsageBandContext | null
511 tokens: UsageBandTokens | null
512 speed: UsageBandSpeed | null
513 cost: number | null
514 perHour: number | null
515 tools: UsageBandTools | null
516 churn: UsageBandChurn | null
517 model: string | null
518 effort: string | null
519 cwd: string | null
520 git: UsageBandGit | null
521 sessionMs: number | null
522 turns: UsageBandTurns | null
523 memory: UsageBandMemory | null
524}
525
526async function readBand($: Engine): Promise<Band> {
527 const { on } = settings
528 const now = (await read($, nowAtom)) || (await $.clock.now())
529 const limits: Band['limits'] = []
530 const five = await read($, fiveHour)
531 const seven = await read($, sevenDay)
532 const spend = await read($, spendLimit)
533 if (on.show5h && five !== null) limits.push({ kind: '5h', limit: five })
534 if (on.show7d && seven !== null) limits.push({ kind: '7d', limit: seven })
535 if (on.showSpendLimit && spend !== null) limits.push({ kind: 'spend', limit: spend })
536
537 const counted = await read($, tokensAtom)
538 const est = await read($, estimate)
539 const tokens = counted ?? (est.requests > 0 ? est : null)
540 const anyToken = on.showInput || on.showOutput || on.showCacheRead
541
542 const startedAt = await read($, startedAtAtom)
543 const sessionMs = startedAt === null ? null : Math.max(0, now - startedAt)
544 const cost = await read($, costUsd)
545 const perHour =
546 on.showBurnRate && cost !== null && sessionMs !== null && sessionMs >= 5 * 60_000
547 ? cost / (sessionMs / 3_600_000)
548 : null
549
550 const tools = (await read($, toolsAtom)) ?? (await read($, liveTools))
551 const churn = await read($, churnAtom)
552 const turns = await read($, turnsAtom)
553
554 return {
555 now,
556 limits,
557 context: on.showContext ? await read($, contextAtom) : null,
558 tokens: anyToken ? tokens : null,
559 speed: on.showSpeed ? await read($, speedAtom) : null,
560 cost: on.showCost ? cost : null,
561 perHour,
562 tools: on.showTools && tools.total > 0 ? tools : null,
563 churn: on.showChurn && churn !== null && churn.added + churn.removed > 0 ? churn : null,
564 model: on.showModel ? await read($, modelAtom) : null,
565 effort: on.showEffort ? await read($, effortAtom) : null,
566 cwd: on.showFolder ? await read($, cwdAtom) : null,
567 git: on.showGit ? await read($, gitAtom) : null,
568 sessionMs: on.showDuration ? sessionMs : null,
569 turns: on.showTurns && turns.count > 0 ? turns : null,
570 memory: on.showMemory ? await read($, memoryAtom) : null,
571 }
572}
573
574function present<T>(list: (T | null)[]): T[] {
575 return list.filter((x): x is T => x !== null)
576}
577
578/** The SVG pills in their groups: limits · context · tokens · cost · work · session. */
579function pillGroups(band: Band): Pill[][] {
580 const { on, look } = settings
581 return [
582 band.limits.map(({ kind, limit }) => limitPill(kind, limit, band.now, look, on.showPace)),
583 present([band.context === null ? null : contextPill(band.context, look)]),
584 [
585 ...(band.tokens === null
586 ? []
587 : tokenPills(band.tokens, band.context, look, {
588 input: on.showInput,
589 output: on.showOutput,
590 cacheRead: on.showCacheRead,
591 cacheHit: on.showCacheHit,
592 })),
593 ...present([band.speed === null ? null : speedPill(band.speed, look)]),
594 ],
595 present([band.cost === null ? null : costPill(band.cost, band.context, look, band.perHour)]),
596 present([
597 band.tools === null ? null : toolsPill(band.tools, look),
598 band.churn === null ? null : churnPill(band.churn, look),
599 ]),
600 present([
601 band.model === null ? null : modelPill(band.model, look),
602 band.effort === null ? null : effortPill(band.effort, look),
603 band.cwd === null ? null : folderPill(band.cwd, look),
604 band.git === null ? null : gitPill(band.git, look),
605 band.sessionMs === null ? null : durationPill(band.sessionMs, look),
606 band.turns === null ? null : turnsPill(band.turns, look),
607 band.memory === null ? null : memoryPill(band.memory, look),
608 ]),
609 ].filter(group => group.length > 0)
610}
611
612/** One run of text inside a terminal pill, with its own color and weight. */
613type Run = { text: string; color?: string; bold?: boolean; dim?: boolean }
614
615/** One terminal pill: its tone, its Nerd Font icon, the label used without one, and its runs. */
616type TermPill = { tone: Tone; icon: string; label: string; runs: Run[]; line?: Line }
617
618/** The terminal band's three lines: limits, context and cost · tokens and work · the session. */
619type Line = 1 | 2 | 3
620
621/** Nerd Font glyphs (Font Awesome, Octicons and Devicons ranges of the Basic Multilingual Plane). */
622const NERD = {
623 '5h': '',
624 '7d': '',
625 spend: '',
626 reset: '',
627 ctx: '',
628 in: '',
629 out: '',
630 cache: '',
631 speed: '',
632 cost: '',
633 tools: '',
634 churn: '',
635 model: '',
636 effort: '',
637 folder: '',
638 git: '',
639 up: '',
640 turns: '',
641 mem: '',
642} as const
643
644/** Powerline glyphs: the rounded caps that open and close a chain, the arrow and the thin arrow between segments. */
645const PL = { open: '', close: '', arrow: '', thin: '' } as const
646
647function termBar(percent: number, elapsed: number | null, markerColor: string): Run[] {
648 if (settings.look.isCompact) return []
649 const runs = terminalBar(percent, elapsed).map(
650 (run): Run =>
651 run.kind === 'fill'
652 ? { text: run.text, color: barColor(percent, settings.look) }
653 : run.kind === 'marker'
654 ? { text: run.text, color: markerColor }
655 : { text: run.text, dim: true },
656 )
657 return [...runs, { text: ' ' }]
658}
659
660function termGroups(band: Band): TermPill[][] {
661 const { on } = settings
662 const isPowerline = settings.terminalStyle === 'powerline'
663 // On a colored segment the text is light; on the plain band it takes the tone's hue.
664 const ink = (t: Tone) => (isPowerline ? POWERLINE_FG : TERMINAL_TONES[t])
665 const groups: TermPill[][] = []
666 const push = (line: Line, list: TermPill[]) => groups.push(list.map(pill => ({ ...pill, line })))
667
668 push(1,
669 band.limits.map((one): TermPill => {
670 const { kind, limit } = one
671 const t: Tone = kind === '5h' ? 'teal' : kind === '7d' ? 'purple' : 'rose'
672 const windowMs = windowOf(kind)
673 const elapsed = windowMs === null ? null : elapsedFraction(limit, windowMs, band.now)
674 const projected = on.showPace ? projectedPercent(limit.percentUsed, elapsed) : null
675 const runs: Run[] = [
676 ...termBar(limit.percentUsed, elapsed, ink(t)),
677 { text: formatPercent(limit.percentUsed), bold: true, color: ink(t) },
678 ]
679 if (projected !== null) {
680 runs.push({ text: ` →${formatPercent(projected)}`, color: barColor(projected, settings.look) })
681 }
682 if (limit.resetsAtMs !== null) {
683 const gap = settings.nerd ? ` ${NERD.reset} ` : ' · '
684 runs.push({ text: `${gap}${formatLeft(limit.resetsAtMs - band.now)}`, color: ink(t), dim: true })
685 }
686 return { tone: t, icon: NERD[kind], label: kind, runs }
687 }),
688 )
689
690 const context = band.context
691 if (context !== null && context.tokens !== null) {
692 const percent = context.percent ?? (context.tokens / context.window) * 100
693 push(1, [
694 {
695 tone: 'indigo',
696 icon: NERD.ctx,
697 label: 'ctx',
698 runs: [
699 ...termBar(percent, null, ink('indigo')),
700 { text: formatPercent(percent), bold: true, color: ink('indigo') },
701 { text: ` · ${formatTokens(context.tokens)}/${formatTokens(context.window)}`, color: ink('indigo'), dim: true },
702 ],
703 },
704 ])
705 }
706
707 const tokenRow: TermPill[] = []
708 if (band.tokens !== null) {
709 const t = band.tokens
710 const mark = t.isEstimate ? '~' : ''
711 if (on.showInput) {
712 tokenRow.push({
713 tone: 'red',
714 icon: NERD.in,
715 label: '↑',
716 runs: [{ text: `${mark}${formatTokens(t.input + t.cacheWrite)}`, color: ink('red') }],
717 })
718 }
719 if (on.showOutput) {
720 tokenRow.push({
721 tone: 'green',
722 icon: NERD.out,
723 label: '↓',
724 runs: [{ text: `${mark}${formatTokens(t.output)}`, color: ink('green') }],
725 })
726 }
727 if (on.showCacheRead) {
728 const hit = on.showCacheHit ? cacheHitPercent(t) : null
729 tokenRow.push({
730 tone: 'blue',
731 icon: NERD.cache,
732 label: '≡',
733 runs: [
734 { text: `${mark}${formatTokens(t.cacheRead)}`, color: ink('blue') },
735 ...(hit === null ? [] : [{ text: ` · ${formatPercent(hit)}`, color: ink('blue'), dim: true }]),
736 ],
737 })
738 }
739 }
740 if (band.speed !== null) {
741 tokenRow.push({
742 tone: 'lime',
743 icon: NERD.speed,
744 label: '»',
745 runs: [{ text: `${band.speed.lastTps.toFixed(1)} t/s`, color: ink('lime') }],
746 })
747 }
748 push(2, tokenRow)
749
750 if (band.cost !== null) {
751 push(1, [
752 {
753 tone: 'gold',
754 icon: NERD.cost,
755 label: '',
756 runs: [
757 { text: formatUsd(band.cost), color: ink('gold'), bold: isPowerline },
758 ...(band.perHour === null ? [] : [{ text: ` · ${formatUsd(band.perHour)}/h`, color: ink('gold'), dim: true }]),
759 ],
760 },
761 ])
762 }
763
764 const work: TermPill[] = []
765 if (band.tools !== null) {
766 work.push({
767 tone: 'orange',
768 icon: NERD.tools,
769 label: 'tools',
770 runs: [{ text: `${band.tools.isEstimate ? '~' : ''}${band.tools.total}`, color: ink('orange') }],
771 })
772 }
773 if (band.churn !== null) {
774 work.push({
775 tone: 'slate',
776 icon: NERD.churn,
777 label: '',
778 runs: [
779 { text: `+${band.churn.added}`, color: isPowerline ? '#9be39f' : TERMINAL_TONES.green },
780 { text: ` −${band.churn.removed}`, color: isPowerline ? '#f5a49c' : TERMINAL_TONES.red },
781 ],
782 })
783 }
784 push(2, work)
785
786 const meta: TermPill[] = []
787 if (band.model !== null) {
788 meta.push({ tone: 'slate', icon: NERD.model, label: '', runs: [{ text: prettyModel(band.model), color: ink('slate') }] })
789 }
790 if (band.effort !== null) {
791 meta.push({ tone: 'pink', icon: NERD.effort, label: 'think', runs: [{ text: band.effort, color: ink('pink') }] })
792 }
793 if (band.cwd !== null) {
794 meta.push({ tone: 'slate', icon: NERD.folder, label: '', runs: [{ text: baseName(band.cwd), color: ink('slate') }] })
795 }
796 if (band.git !== null) {
797 const git = band.git
798 meta.push({
799 tone: 'slate',
800 icon: NERD.git,
801 label: '⎇',
802 runs: git.isRepo
803 ? [
804 { text: git.branch ?? 'detached', color: ink('slate') },
805 ...(git.dirty > 0 ? [{ text: ` ±${git.dirty}`, color: isPowerline ? '#f3d27a' : TERMINAL_TONES.gold }] : []),
806 ]
807 : [{ text: 'no git', color: ink('slate'), dim: true }],
808 })
809 }
810 if (band.sessionMs !== null) {
811 meta.push({ tone: 'slate', icon: NERD.up, label: 'up', runs: [{ text: formatLeft(band.sessionMs), color: ink('slate') }] })
812 }
813 if (band.turns !== null) {
814 const turns = band.turns
815 meta.push({
816 tone: 'slate',
817 icon: NERD.turns,
818 label: '',
819 runs: [
820 { text: `${turns.count} turns`, color: ink('slate') },
821 ...(turns.timed > 0 ? [{ text: ` · ${formatShort(turns.lastMs)}`, color: ink('slate'), dim: true }] : []),
822 ],
823 })
824 }
825 if (band.memory !== null) {
826 const used = band.memory.total - band.memory.free
827 const percent = band.memory.total === 0 ? 0 : (used / band.memory.total) * 100
828 meta.push({
829 tone: 'cyan',
830 icon: NERD.mem,
831 label: 'mem',
832 runs: [
833 ...termBar(percent, null, ink('cyan')),
834 { text: `${formatGiB(used)}/${formatGiB(band.memory.total)}`, color: ink('cyan'), dim: !isPowerline },
835 ],
836 })
837 }
838 push(3, meta)
839
840 return groups.filter(group => group.length > 0)
841}
842
843/** A pill's head: its icon with a Nerd Font, else its text label (nothing when it has none). */
844function head(pill: TermPill): string {
845 if (settings.nerd) return `${pill.icon} `
846 return pill.label === '' ? '' : `${pill.label} `
847}
848
849const PLAIN_GAP = ' '
850
851/** Cells a string takes: one per code point (the band's glyphs, Nerd Font icons included, are single width). */
852function cells(text: string): number {
853 return [...text].length
854}
855
856/** Cells one pill takes in a row: its joint, padding, head and runs (powerline), or its gap, head and runs (plain). */
857function pillCells(pill: TermPill, isPowerline: boolean, isFirst: boolean): number {
858 const body = cells(head(pill)) + pill.runs.reduce((sum, run) => sum + cells(run.text), 0)
859 if (isPowerline) return 1 + 1 + body + 1
860 return (isFirst ? 0 : PLAIN_GAP.length) + body
861}
862
863/** Cells a whole row takes, the closing cap included. */
864function rowCells(row: TermPill[], isPowerline: boolean): number {
865 return row.reduce((sum, pill, i) => sum + pillCells(pill, isPowerline, i === 0), 0) + (isPowerline ? 1 : 0)
866}
867
868/** Splits the pills, in order, into `k` rows so the widest row is as narrow as it can be. */
869function balance(pills: TermPill[], k: number, isPowerline: boolean): TermPill[][] {
870 const n = pills.length
871 if (n <= k) return pills.map(pill => [pill])
872 const width = (from: number, to: number) => rowCells(pills.slice(from, to), isPowerline)
873 // best[j][i]: the narrowest widest row splitting the first i pills into j rows; cut[j][i]: where its last row starts.
874 const best: number[][] = Array.from({ length: k + 1 }, () => Array<number>(n + 1).fill(Infinity))
875 const cut: number[][] = Array.from({ length: k + 1 }, () => Array<number>(n + 1).fill(0))
876 best[0]![0] = 0
877 for (let j = 1; j <= k; j++) {
878 for (let i = 1; i <= n; i++) {
879 for (let m = j - 1; m < i; m++) {
880 const cost = Math.max(best[j - 1]![m]!, width(m, i))
881 if (cost < best[j]![i]!) {
882 best[j]![i] = cost
883 cut[j]![i] = m
884 }
885 }
886 }
887 }
888 const rows: TermPill[][] = []
889 let end = n
890 for (let j = k; j >= 1; j--) {
891 const start = cut[j]![end]!
892 rows.unshift(pills.slice(start, end))
893 end = start
894 }
895 return rows.filter(row => row.length > 0)
896}
897
898/**
899 * The band's rows: its three fixed lines (limits, context, cost · tokens, speed, tools, churn ·
900 * the session) while each fits `columns`; else the same pills balanced over three rows; else,
901 * on a terminal too narrow for that, as many full rows as it takes.
902 */
903function layoutRows(pills: TermPill[], columns: number, isPowerline: boolean): TermPill[][] {
904 const room = Math.max(20, columns - 1)
905 const fits = (rows: TermPill[][]) => rows.every(row => rowCells(row, isPowerline) <= room)
906 const fixed = ([1, 2, 3] as const).map(line => pills.filter(pill => pill.line === line)).filter(row => row.length > 0)
907 if (fits(fixed)) return fixed
908 const balanced = balance(pills, 3, isPowerline)
909 if (fits(balanced)) return balanced
910 return packRows(pills, columns, isPowerline)
911}
912
913/**
914 * Lays the pills, in order, into as few rows as fit `columns`: each row is filled until the next
915 * pill would not fit (the closing cap counted), and a pill wider than a whole row stands alone.
916 */
917function packRows(pills: TermPill[], columns: number, isPowerline: boolean): TermPill[][] {
918 const room = Math.max(20, columns - 1)
919 const closing = isPowerline ? 1 : 0
920 const rows: TermPill[][] = []
921 let row: TermPill[] = []
922 let used = 0
923 for (const pill of pills) {
924 const width = pillCells(pill, isPowerline, row.length === 0)
925 if (row.length > 0 && used + width + closing > room) {
926 rows.push(row)
927 row = []
928 used = 0
929 }
930 used += pillCells(pill, isPowerline, row.length === 0)
931 row.push(pill)
932 }
933 if (row.length > 0) rows.push(row)
934 return rows
935}
936
937// ---------------------------------------------------------------- hooks
938
939export const register: Register = (on, options) => {
940 settings = readSettings(options)
941
942 on('session.start', async ($, e, next) => {
943 await $.command.register({
944 name: 'usage-band',
945 description: 'Usage band: refresh and summarize · hide/show · settings · on/off <pill> · set <option> <value>',
946 argumentHint: '[hide|show|settings|on <pill>|off <pill>|set <option> <value>]',
947 })
948 $.clock.every(settings.refreshMs, () => {
949 void refreshAll($).catch(() => undefined)
950 })
951 void refreshAll($).catch(() => undefined)
952 return next(e)
953 })
954
955 on('session.measure', async ($, e, next) => {
956 await applyMeasure($, e.rateLimits, e.context, e.cost)
957 const now = await $.clock.now()
958 await update($, nowAtom, () => now)
959 void refreshTokens($)
960 return next(e)
961 })
962
963 on('turn.step', async function* ($, e, next) {
964 if (e.agentId !== undefined) return yield* next(e)
965 if (e.effort !== undefined) {
966 const effort = String(e.effort)
967 void update($, effortAtom, () => effort).catch(() => undefined)
968 }
969 // Output speed: from the first streamed piece to the end of the response.
970 let firstAt: number | null = null
971 for await (const chunk of next(e)) {
972 if (firstAt === null && chunk.kind !== 'engine' && chunk.kind !== 'stop') firstAt = Date.now()
973 if (chunk.kind === 'stop' && chunk.usage !== null && firstAt !== null) {
974 const ms = Date.now() - firstAt
975 const tokens = chunk.usage.output_tokens
976 if (ms >= 250 && tokens > 0) void recordSpeed($, tokens, ms).catch(() => undefined)
977 }
978 yield chunk
979 }
980 })
981
982 on('tool.call', async ($, e, next) => {
983 void countTool($, String(e.tool)).catch(() => undefined)
984 return next(e)
985 })
986
987 on('turn.complete', async ($, e, next) => {
988 const usage = e.usage
989 if (usage !== undefined) {
990 await update($, estimate, prev => ({
991 input: prev.input + usage.input_tokens,
992 cacheWrite: prev.cacheWrite + usage.cache_creation_input_tokens,
993 output: prev.output + usage.output_tokens,
994 cacheRead: prev.cacheRead + usage.cache_read_input_tokens,
995 requests: prev.requests + 1,
996 isEstimate: true,
997 }))
998 }
999 if (e.agentId === undefined) {
1000 const ms = e.durationMs
1001 await update($, turnsAtom, prev => ({ ...prev, lastMs: ms, totalMs: prev.totalMs + ms, timed: prev.timed + 1 }))
1002 const model = usage?.model
1003 if (model !== undefined && model !== '' && !model.startsWith('<')) await update($, modelAtom, () => model)
1004 }
1005 const done = await next(e)
1006 void refreshAll($).catch(() => undefined)
1007 return done
1008 })
1009
1010 on('command.run', { command: 'usage-band' }, async ($, e) => {
1011 const [verb = '', ...rest] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
1012 if (verb === 'hide') {
1013 await update($, isHidden, () => true)
1014 return { text: 'Usage band hidden. Bring it back with /usage-band show' }
1015 }
1016 if (verb === 'show') {
1017 await update($, isHidden, () => false)
1018 return { text: 'Usage band shown.' }
1019 }
1020 if (verb === 'settings' || verb === 'config' || verb === 'help') return { text: describeSettings() }
1021 if (verb === 'on' || verb === 'off' || verb === 'set') return { text: await runSettingsCommand($, verb, rest) }
1022 counted = null
1023 isScriptBroken = false
1024 await refreshAll($)
1025 return { text: await summary($) }
1026 })
1027
1028 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1029 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
1030 if (e.surface === 'terminal' && !settings.on.showInTerminal) return next(e)
1031
1032 const band = await readBand($)
1033
1034 if (e.surface === 'terminal') {
1035 const groups = termGroups(band)
1036 if (groups.length === 0) return next(e)
1037 const { Box, Text } = $.ui.resolve(e)
1038 const isPowerline = settings.terminalStyle === 'powerline'
1039 const rows = layoutRows(groups.flat(), e.props.bodyColumns, isPowerline)
1040
1041 if (!isPowerline) {
1042 return (
1043 <Box flexDirection="column">
1044 {rows.map(row => (
1045 <Text wrap="truncate-end">
1046 {row.map((pill, i) => (
1047 <Text>
1048 {i === 0 ? '' : PLAIN_GAP}
1049 <Text color={TERMINAL_TONES[pill.tone]}>{head(pill)}</Text>
1050 {pill.runs.map(run => (
1051 <Text color={run.color} bold={run.bold} dimColor={run.dim}>
1052 {run.text}
1053 </Text>
1054 ))}
1055 </Text>
1056 ))}
1057 </Text>
1058 ))}
1059 </Box>
1060 )
1061 }
1062
1063 // Powerline: each row is one chain of colored segments, rounded at both ends. Two segments of
1064 // one color meet at a thin arrow, two colors at a solid one drawn in the left segment's color.
1065 return (
1066 <Box flexDirection="column">
1067 {rows.map(row => (
1068 <Text wrap="truncate-end">
1069 {row.map((pill, i) => {
1070 const bg = POWERLINE_BG[pill.tone]
1071 const prev = i === 0 ? undefined : row[i - 1]
1072 const prevBg = prev === undefined ? undefined : POWERLINE_BG[prev.tone]
1073 const joint =
1074 prevBg === undefined ? (
1075 <Text color={bg}>{settings.nerd ? PL.open : ' '}</Text>
1076 ) : prevBg === bg ? (
1077 <Text color={POWERLINE_FG} backgroundColor={bg} dimColor>
1078 {settings.nerd ? PL.thin : '│'}
1079 </Text>
1080 ) : (
1081 <Text color={prevBg} backgroundColor={bg}>
1082 {settings.nerd ? PL.arrow : ' '}
1083 </Text>
1084 )
1085 return (
1086 <Text>
1087 {joint}
1088 <Text color={POWERLINE_FG} backgroundColor={bg}>
1089 {` ${head(pill)}`}
1090 </Text>
1091 {pill.runs.map(run => (
1092 <Text color={run.color} backgroundColor={bg} bold={run.bold} dimColor={run.dim}>
1093 {run.text}
1094 </Text>
1095 ))}
1096 <Text backgroundColor={bg}> </Text>
1097 </Text>
1098 )
1099 })}
1100 <Text color={POWERLINE_BG[row[row.length - 1]?.tone ?? 'slate']}>{settings.nerd ? PL.close : ' '}</Text>
1101 </Text>
1102 ))}
1103 </Box>
1104 )
1105 }
1106
1107 const groups = pillGroups(band)
1108 if (groups.length === 0) return next(e)
1109 const { Box, Svg } = $.ui.resolve(e)
1110 return (
1111 <Box flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1}>
1112 {groups.map(group => (
1113 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
1114 {group.map(p => (
1115 <Svg source={p.source} alt={p.alt} width={p.width} height={p.height} isInteractive />
1116 ))}
1117 </Box>
1118 ))}
1119 </Box>
1120 )
1121 })
1122}
1123hooks/pills.ts 719 lines1// Pure helpers: number/time formatting and the SVG markup of each pill.
2// No `$` here, so the preview script can import this file with plain Node.
3import type {
4 UsageBandChurn,
5 UsageBandContext,
6 UsageBandGit,
7 UsageBandLimit,
8 UsageBandMemory,
9 UsageBandSpeed,
10 UsageBandTokens,
11 UsageBandTools,
12 UsageBandTurns,
13} from '../types'
14
15export const FIVE_HOURS_MS = 5 * 60 * 60 * 1000
16export const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
17
18/** How the pills are drawn: the bar thresholds and whether icons and bars are left out. */
19export type Look = { warnAt: number; hotAt: number; isCompact: boolean }
20
21export const DEFAULT_LOOK: Look = { warnAt: 70, hotAt: 90, isCompact: false }
22
23export type Tone =
24 | 'teal'
25 | 'purple'
26 | 'rose'
27 | 'indigo'
28 | 'red'
29 | 'green'
30 | 'blue'
31 | 'lime'
32 | 'gold'
33 | 'orange'
34 | 'pink'
35 | 'cyan'
36 | 'slate'
37
38/** Light background, light text, dark background, dark text. */
39const TONES: Record<Tone, [string, string, string, string]> = {
40 teal: ['#d3f4ee', '#0d4b42', '#10332e', '#a4ebdf'],
41 purple: ['#ebe2fb', '#3d2869', '#2a2042', '#d5c4fa'],
42 rose: ['#fbe0ec', '#7a1f48', '#3a1a28', '#f6b6d2'],
43 indigo: ['#e0e4fb', '#28327a', '#1d2142', '#c0c8f7'],
44 red: ['#fbe0dd', '#7a241e', '#3b1c1a', '#f5b8b1'],
45 green: ['#dbf2da', '#1d5a22', '#19331d', '#b2e6b6'],
46 blue: ['#dce8fb', '#1b437e', '#18283f', '#b4cef5'],
47 lime: ['#ecf6cf', '#435a0b', '#2a3214', '#d4ec9a'],
48 gold: ['#faefc9', '#694e06', '#372d0f', '#f1d98b'],
49 orange: ['#fde6d2', '#7a3a0c', '#3a2414', '#f7c39a'],
50 pink: ['#f9e0f7', '#6e1f68', '#361b34', '#f0b9eb'],
51 cyan: ['#d6f1fa', '#0f4a5e', '#13303a', '#a6dff0'],
52 slate: ['#e6e9ee', '#2f3a48', '#252a31', '#ccd4de'],
53}
54
55/** The same hues for the terminal's Text colors. */
56export const TERMINAL_TONES: Record<Tone, string> = {
57 teal: '#2fb8a5',
58 purple: '#a580ea',
59 rose: '#e2689f',
60 indigo: '#7d8cf0',
61 red: '#e2766e',
62 green: '#62bd62',
63 blue: '#5f9eec',
64 lime: '#a3c94a',
65 gold: '#d6a92e',
66 orange: '#e8904a',
67 pink: '#d77bd0',
68 cyan: '#4cbfe0',
69 slate: '#8e9aab',
70}
71
72/** Segment backgrounds for the terminal's powerline band: mid tones that light text reads on. */
73export const POWERLINE_BG: Record<Tone, string> = {
74 teal: '#1d6b60',
75 purple: '#5a3f96',
76 rose: '#8a2d5a',
77 indigo: '#3a4899',
78 red: '#8c3a33',
79 green: '#2e6a33',
80 blue: '#2a5590',
81 lime: '#55702a',
82 gold: '#7c6213',
83 orange: '#8a4a1c',
84 pink: '#7c3376',
85 cyan: '#1c6580',
86 slate: '#465060',
87}
88
89/** Text on a powerline segment. */
90export const POWERLINE_FG = '#f1f3f5'
91
92export const BAR_COLORS = { ok: '#22a55a', warn: '#e0a400', hot: '#e5484d' }
93
94export function level(percent: number, look: Look): 0 | 1 | 2 {
95 return percent >= look.hotAt ? 2 : percent >= look.warnAt ? 1 : 0
96}
97
98export function barColor(percent: number, look: Look = DEFAULT_LOOK): string {
99 return [BAR_COLORS.ok, BAR_COLORS.warn, BAR_COLORS.hot][level(percent, look)] ?? BAR_COLORS.ok
100}
101
102export function formatTokens(n: number): string {
103 if (n < 1000) return String(Math.round(n))
104 if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
105 return `${(n / 1_000_000).toFixed(2)}M`
106}
107
108export function formatPercent(p: number): string {
109 return `${Math.round(p)}%`
110}
111
112export function formatGiB(bytes: number): string {
113 return `${(bytes / 1024 ** 3).toFixed(1)}G`
114}
115
116/** A span of time: "1d 7h", "2h 40m", "12m". */
117export function formatLeft(ms: number): string {
118 const minutes = Math.max(0, Math.round(ms / 60_000))
119 const days = Math.floor(minutes / 1440)
120 const hours = Math.floor((minutes % 1440) / 60)
121 const mins = minutes % 60
122 if (days > 0) return `${days}d ${hours}h`
123 if (hours > 0) return `${hours}h ${mins}m`
124 return `${mins}m`
125}
126
127/** A short span, seconds under a minute: "34s", "2h 40m". */
128export function formatShort(ms: number): string {
129 return ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : formatLeft(ms)
130}
131
132export function formatUsd(usd: number): string {
133 return `$${usd.toFixed(2)}`
134}
135
136/** "claude-opus-5-5" → "Opus 5.5"; "claude-haiku-4-5-20251001" → "Haiku 4.5"; anything else as given. */
137export function prettyModel(model: string): string {
138 const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[[^\]]*\])?$/.exec(model)
139 if (m === null) return model.replace(/^claude-/, '')
140 const name = (m[1] ?? '').charAt(0).toUpperCase() + (m[1] ?? '').slice(1)
141 return `${name} ${m[2]}${m[3] === undefined ? '' : `.${m[3]}`}${m[4] ?? ''}`
142}
143
144/** The last part of a path, either separator. */
145export function baseName(path: string): string {
146 const parts = path.replace(/[\\/]+$/, '').split(/[\\/]/)
147 return parts[parts.length - 1] || path
148}
149
150/** How much of the window has passed, 0..1: 1 − time left / window length. */
151export function elapsedFraction(limit: UsageBandLimit, windowMs: number, now: number): number | null {
152 if (limit.resetsAtMs === null) return null
153 const left = Math.min(windowMs, Math.max(0, limit.resetsAtMs - now))
154 return 1 - left / windowMs
155}
156
157/** Where usage lands at reset if the pace so far holds; null too early in the window to say. */
158export function projectedPercent(percentUsed: number, elapsed: number | null): number | null {
159 if (elapsed === null || elapsed < 0.05) return null
160 return percentUsed / elapsed
161}
162
163/** Share of input served from the cache, 0..100. */
164export function cacheHitPercent(tokens: UsageBandTokens): number | null {
165 const all = tokens.input + tokens.cacheWrite + tokens.cacheRead
166 return all === 0 ? null : (tokens.cacheRead / all) * 100
167}
168
169export type LimitKind = '5h' | '7d' | 'spend'
170
171export function windowOf(kind: LimitKind): number | null {
172 return kind === '5h' ? FIVE_HOURS_MS : kind === '7d' ? SEVEN_DAYS_MS : null
173}
174
175// ---------------------------------------------------------------- SVG
176
177const FONT_SIZE = 12
178const CHAR_W = 7.2
179const HEIGHT = 22
180const PAD_X = 8
181const ICON = 12
182const BAR_W = 38
183const BAR_H = 5
184
185const ICONS = {
186 gauge:
187 '<path d="M1 9.5a5 5 0 0 1 10 0"/><path d="M6 9.5 8.6 5.6"/><circle cx="6" cy="9.5" r="0.9" class="dot"/>',
188 calendar:
189 '<rect x="1.3" y="2.3" width="9.4" height="8.6" rx="1.6"/><path d="M1.3 5.2h9.4M3.9 1v2.4M8.1 1v2.4"/>',
190 wallet: '<rect x="1" y="2.6" width="10" height="7.8" rx="1.6"/><path d="M1 5h10M7.6 7.6h1.4"/>',
191 hourglass:
192 '<path d="M3 1.2h6M3 10.8h6M3.6 1.2c0 2.8 4.8 2.7 4.8 4.8S3.6 8.4 3.6 10.8M8.4 1.2c0 2.8-4.8 2.7-4.8 4.8s4.8 2.4 4.8 4.8"/>',
193 window: '<rect x="1" y="1.5" width="10" height="9" rx="1.6"/><path d="M1 7.2h10"/><path d="M2.6 9h6.8" class="fat"/>',
194 up: '<path d="M6 10.6V1.6M2.6 5 6 1.6 9.4 5"/>',
195 down: '<path d="M6 1.4v9M2.6 7 6 10.4 9.4 7"/>',
196 layers:
197 '<path d="M6 1.2 11 3.8 6 6.4 1 3.8Z"/><path d="M1 6.4 6 9 11 6.4"/><path d="M1 8.8l5 2.6 5-2.6"/>',
198 bolt: '<path d="M6.8 1 2.4 6.8h3.4L5.2 11l4.4-5.8H6.2Z"/>',
199 dollar:
200 '<circle cx="6" cy="6" r="5.3"/><path d="M7.9 4.1c-.4-.6-1.1-.9-1.9-.9-1 0-1.8.6-1.8 1.4 0 1.9 3.7.9 3.7 2.8 0 .8-.8 1.4-1.9 1.4-.8 0-1.6-.3-2-.9M6 2.2v1M6 8.8v1"/>',
201 wrench: '<path d="M7.6 1.3a3 3 0 0 0-2.9 3.9L1.4 8.5a1.2 1.2 0 0 0 1.7 1.7l3.3-3.3a3 3 0 0 0 3.9-2.9L8.6 5.2 7 3.6Z"/>',
202 diff: '<path d="M3.5 1.5v5M1 4h5M6.5 9.5h4.5"/><path d="M8.5 1.5 3.5 10.5"/>',
203 chip:
204 '<rect x="2.5" y="2.5" width="7" height="7" rx="1.2"/><path d="M4.5 1v1.5M7.5 1v1.5M4.5 9.5V11M7.5 9.5V11M1 4.5h1.5M1 7.5h1.5M9.5 4.5H11M9.5 7.5H11"/>',
205 bulb: '<path d="M4 8.2C2.9 7.4 2.2 6.3 2.2 5a3.8 3.8 0 0 1 7.6 0c0 1.3-.7 2.4-1.8 3.2V9.4H4Z"/><path d="M4.4 11h3.2"/>',
206 folder: '<path d="M1 3a1 1 0 0 1 1-1h2.6l1.2 1.4H10a1 1 0 0 1 1 1V9.6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1Z"/>',
207 branch:
208 '<circle cx="3.2" cy="2.4" r="1.3"/><circle cx="3.2" cy="9.6" r="1.3"/><circle cx="8.8" cy="3.6" r="1.3"/><path d="M3.2 3.7v4.6M8.8 4.9c0 2.4-5.6 1.6-5.6 3.4"/>',
209 memory:
210 '<rect x="1" y="3" width="10" height="5.6" rx="1"/><path d="M3.4 5h1M5.5 5h1M7.6 5h1M2.5 8.6V10M4.5 8.6V10M6.5 8.6V10M8.5 8.6V10"/>',
211 stopwatch: '<circle cx="6" cy="6.8" r="4.4"/><path d="M6 6.8V4.4M4.6 1h2.8M9.4 3.4l.9-.9"/>',
212 bubble:
213 '<path d="M1.2 2.6c0-.8.6-1.4 1.4-1.4h6.8c.8 0 1.4.6 1.4 1.4v4.6c0 .8-.6 1.4-1.4 1.4H5L2.6 10.6V8.6c-.8 0-1.4-.6-1.4-1.4Z"/>',
214} as const
215
216export type IconName = keyof typeof ICONS
217
218/** Text accents with fixed hues: added, deleted, warning. */
219type Accent = 'add' | 'del' | 'warn'
220
221const ACCENTS: Record<Accent, [string, string]> = {
222 add: ['#1f7a35', '#7ee08f'],
223 del: ['#b3261e', '#f59a93'],
224 warn: ['#8a5a00', '#f3c969'],
225}
226
227type Segment =
228 | { kind: 'icon'; name: IconName }
229 | { kind: 'text'; text: string; isBold?: boolean; isDim?: boolean; accent?: Accent }
230 | { kind: 'bar'; percent: number; elapsed: number | null }
231 | { kind: 'sep' }
232 | { kind: 'gap'; w: number }
233
234function escapeXml(s: string): string {
235 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
236}
237
238function segmentWidth(seg: Segment): number {
239 switch (seg.kind) {
240 case 'icon':
241 return ICON
242 case 'text':
243 return seg.text.length * CHAR_W
244 case 'bar':
245 return BAR_W
246 case 'sep':
247 return 1
248 case 'gap':
249 return seg.w
250 }
251}
252
253/** Compact drops icons, bars and separators, keeping one gap between what is left. */
254function compact(segments: Segment[]): Segment[] {
255 const kept: Segment[] = []
256 for (const seg of segments) {
257 if (seg.kind === 'icon' || seg.kind === 'bar' || seg.kind === 'sep') continue
258 const last = kept[kept.length - 1]
259 if (seg.kind === 'gap') {
260 if (last === undefined) continue
261 if (last.kind === 'gap') last.w = Math.max(last.w, seg.w)
262 else kept.push({ ...seg })
263 continue
264 }
265 kept.push(seg)
266 }
267 while (kept[kept.length - 1]?.kind === 'gap') kept.pop()
268 return kept
269}
270
271export type Pill = { source: string; width: number; height: number; alt: string }
272
273const GAP_S = { kind: 'gap', w: 5 } as const
274const GAP_M = { kind: 'gap', w: 7 } as const
275
276/**
277 * One pill's SVG. `label` stands in for the icon in the compact layout, where a bare number
278 * would not say what it counts.
279 */
280function pill(tone: Tone, raw: Segment[], title: string, alt: string, look: Look, label?: string): Pill {
281 const segments = look.isCompact
282 ? [...(label === undefined ? [] : [{ kind: 'text', text: label, isDim: true } as Segment, GAP_S]), ...compact(raw)]
283 : raw
284 const [lightBg, lightFg, darkBg, darkFg] = TONES[tone]
285 const width = Math.ceil(PAD_X * 2 + segments.reduce((sum, seg) => sum + segmentWidth(seg), 0))
286 const mid = HEIGHT / 2
287 let x = PAD_X
288 const body: string[] = []
289 for (const seg of segments) {
290 const w = segmentWidth(seg)
291 switch (seg.kind) {
292 case 'icon':
293 body.push(`<g class="ic" transform="translate(${x} ${mid - ICON / 2})">${ICONS[seg.name]}</g>`)
294 break
295 case 'text': {
296 const cls = ['t', seg.isBold ? 'b' : '', seg.isDim ? 'd' : '', seg.accent ?? ''].filter(Boolean).join(' ')
297 body.push(
298 `<text x="${x}" y="${mid + 4.2}" class="${cls}" textLength="${w.toFixed(1)}" lengthAdjust="spacingAndGlyphs">${escapeXml(seg.text)}</text>`,
299 )
300 break
301 }
302 case 'bar': {
303 const y = mid - BAR_H / 2
304 const fill = (Math.min(100, Math.max(0, seg.percent)) / 100) * BAR_W
305 body.push(`<rect x="${x}" y="${y}" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}" class="track"/>`)
306 if (fill > 0) {
307 body.push(
308 `<rect x="${x}" y="${y}" width="${Math.max(fill, BAR_H).toFixed(1)}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${barColor(seg.percent, look)}"/>`,
309 )
310 }
311 if (seg.elapsed !== null) {
312 const tx = (x + seg.elapsed * BAR_W).toFixed(1)
313 body.push(`<line x1="${tx}" x2="${tx}" y1="${y - 3}" y2="${y + BAR_H + 3}" class="tick"/>`)
314 }
315 break
316 }
317 case 'sep':
318 body.push(`<line x1="${x + 0.5}" x2="${x + 0.5}" y1="${mid - 6}" y2="${mid + 6}" class="sep"/>`)
319 break
320 case 'gap':
321 break
322 }
323 x += w
324 }
325
326 // Rules are scoped to the pill's tone so several pills inlined in one page keep their own colors.
327 const k = `.ub-${tone}`
328 const accentRules = (i: 0 | 1) =>
329 (Object.keys(ACCENTS) as Accent[]).map(a => `${k} .t.${a}{fill:${ACCENTS[a][i]}}`).join('')
330 const style = [
331 `${k} .bg{fill:${lightBg}}`,
332 `${k} .t{fill:${lightFg};font:${FONT_SIZE}px ui-monospace,SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace}`,
333 `${k} .b{font-weight:700}`,
334 `${k} .d{opacity:.78}`,
335 accentRules(0),
336 `${k} .ic{fill:none;stroke:${lightFg};stroke-width:1.3;stroke-linecap:round;stroke-linejoin:round}`,
337 `${k} .ic .dot{fill:${lightFg};stroke:none}`,
338 `${k} .ic .fat{stroke-width:2}`,
339 `${k} .track{fill:${lightFg};fill-opacity:.16}`,
340 `${k} .tick{stroke:${lightFg};stroke-width:1.6;stroke-linecap:round}`,
341 `${k} .sep{stroke:${lightFg};stroke-opacity:.35}`,
342 `@media (prefers-color-scheme: dark){`,
343 `${k} .bg{fill:${darkBg}}${k} .t{fill:${darkFg}}${k} .ic{stroke:${darkFg}}${k} .ic .dot{fill:${darkFg}}`,
344 accentRules(1),
345 `${k} .track{fill:${darkFg};fill-opacity:.2}${k} .tick{stroke:${darkFg}}${k} .sep{stroke:${darkFg}}`,
346 `}`,
347 ].join('')
348
349 const source =
350 `<svg xmlns="http://www.w3.org/2000/svg" class="ub-${tone}" width="${width}" height="${HEIGHT}" viewBox="0 0 ${width} ${HEIGHT}">` +
351 `<title>${escapeXml(title)}</title>` +
352 `<style>${style}</style>` +
353 `<rect class="bg" x="0" y="0" width="${width}" height="${HEIGHT}" rx="7"/>` +
354 body.join('') +
355 `</svg>`
356
357 return { source, width, height: HEIGHT, alt }
358}
359
360function lines(...parts: string[]): string {
361 return parts.filter(Boolean).join('\n')
362}
363
364// ---------------------------------------------------------------- limits and context
365
366const LIMIT_STYLE: Record<LimitKind, { tone: Tone; icon: IconName; name: string }> = {
367 '5h': { tone: 'teal', icon: 'gauge', name: '5-hour window' },
368 '7d': { tone: 'purple', icon: 'calendar', name: '7-day window' },
369 spend: { tone: 'rose', icon: 'wallet', name: 'Spend limit' },
370}
371
372export function limitPill(
373 kind: LimitKind,
374 limit: UsageBandLimit,
375 now: number,
376 look: Look = DEFAULT_LOOK,
377 showPace = false,
378): Pill {
379 const { tone, icon, name } = LIMIT_STYLE[kind]
380 const windowMs = windowOf(kind)
381 const elapsed = windowMs === null ? null : elapsedFraction(limit, windowMs, now)
382 const leftMs = limit.resetsAtMs === null ? null : Math.max(0, limit.resetsAtMs - now)
383 const left = leftMs === null ? null : formatLeft(leftMs)
384 const pct = formatPercent(limit.percentUsed)
385 const projected = showPace ? projectedPercent(limit.percentUsed, elapsed) : null
386 const paceLevel = projected === null ? 0 : level(projected, look)
387
388 const segments: Segment[] = [
389 { kind: 'icon', name: icon },
390 GAP_S,
391 { kind: 'text', text: kind },
392 GAP_M,
393 { kind: 'bar', percent: limit.percentUsed, elapsed },
394 GAP_M,
395 { kind: 'text', text: pct, isBold: true },
396 ]
397 if (projected !== null) {
398 segments.push(
399 { kind: 'gap', w: 4 },
400 {
401 kind: 'text',
402 text: `→${formatPercent(projected)}`,
403 isDim: paceLevel === 0,
404 ...(paceLevel === 2 ? { accent: 'del' as const } : paceLevel === 1 ? { accent: 'warn' as const } : {}),
405 },
406 )
407 }
408 if (left !== null) {
409 segments.push(GAP_M, { kind: 'sep' }, GAP_M, { kind: 'icon', name: 'hourglass' }, { kind: 'gap', w: 4 }, {
410 kind: 'text',
411 text: left,
412 isDim: true,
413 })
414 }
415
416 const title = lines(
417 `${name}: ${limit.percentUsed}% used`,
418 left === null ? (windowMs === null ? '' : 'Reset time unknown') : `Resets in ${left}`,
419 elapsed === null ? '' : `${Math.round(elapsed * 100)}% of the window has passed (marker)`,
420 projected === null ? '' : `At this pace: ~${formatPercent(projected)} by reset`,
421 )
422 return pill(tone, segments, title, `${kind} ${pct} used${left === null ? '' : `, resets in ${left}`}`, look)
423}
424
425export function contextPill(context: UsageBandContext, look: Look = DEFAULT_LOOK): Pill | null {
426 if (context.tokens === null) return null
427 const percent = context.percent ?? (context.tokens / context.window) * 100
428 const pct = formatPercent(percent)
429 return pill(
430 'indigo',
431 [
432 { kind: 'icon', name: 'window' },
433 GAP_S,
434 { kind: 'text', text: 'ctx' },
435 GAP_M,
436 { kind: 'bar', percent, elapsed: null },
437 GAP_M,
438 { kind: 'text', text: pct, isBold: true },
439 GAP_M,
440 { kind: 'sep' },
441 GAP_M,
442 { kind: 'text', text: `${formatTokens(context.tokens)}/${formatTokens(context.window)}`, isDim: true },
443 ],
444 `Context window: ${formatTokens(context.tokens)} of ${formatTokens(context.window)} tokens (${pct})`,
445 `Context ${pct} full`,
446 look,
447 )
448}
449
450function contextLine(context: UsageBandContext | null): string {
451 if (context === null || context.tokens === null) return ''
452 const pct = context.percent === null ? '' : ` (${context.percent}%)`
453 return `Context: ${formatTokens(context.tokens)} / ${formatTokens(context.window)}${pct}`
454}
455
456// ---------------------------------------------------------------- tokens, speed and cost
457
458export type TokenPillSet = { input: boolean; output: boolean; cacheRead: boolean; cacheHit: boolean }
459
460export function tokenPills(
461 tokens: UsageBandTokens,
462 context: UsageBandContext | null,
463 look: Look = DEFAULT_LOOK,
464 show: TokenPillSet = { input: true, output: true, cacheRead: true, cacheHit: false },
465): Pill[] {
466 const mark = tokens.isEstimate ? '~' : ''
467 const input = tokens.input + tokens.cacheWrite
468 const source = tokens.isEstimate
469 ? 'Source: per-turn usage (transcript unreadable, approximate)'
470 : 'Source: session transcript + subagents'
471 const requests = tokens.isEstimate ? `Turns: ${tokens.requests}` : `Requests: ${tokens.requests}`
472 const hit = cacheHitPercent(tokens)
473 const tail = [contextLine(context), requests, source]
474 const pills: Pill[] = []
475 if (show.input) {
476 pills.push(
477 pill(
478 'red',
479 [{ kind: 'icon', name: 'up' }, GAP_S, { kind: 'text', text: mark + formatTokens(input) }],
480 lines(
481 `Input: ${mark}${formatTokens(input)} tokens`,
482 ` uncached input: ${formatTokens(tokens.input)}`,
483 ` cache write: ${formatTokens(tokens.cacheWrite)}`,
484 ...tail,
485 ),
486 `Input ${mark}${formatTokens(input)} tokens`,
487 look,
488 'in',
489 ),
490 )
491 }
492 if (show.output) {
493 pills.push(
494 pill(
495 'green',
496 [{ kind: 'icon', name: 'down' }, GAP_S, { kind: 'text', text: mark + formatTokens(tokens.output) }],
497 lines(`Output: ${mark}${formatTokens(tokens.output)} tokens`, ...tail),
498 `Output ${mark}${formatTokens(tokens.output)} tokens`,
499 look,
500 'out',
501 ),
502 )
503 }
504 if (show.cacheRead) {
505 const segments: Segment[] = [
506 { kind: 'icon', name: 'layers' },
507 GAP_S,
508 { kind: 'text', text: mark + formatTokens(tokens.cacheRead) },
509 ]
510 if (show.cacheHit && hit !== null) {
511 segments.push(GAP_M, { kind: 'sep' }, GAP_M, { kind: 'text', text: formatPercent(hit), isDim: true })
512 }
513 pills.push(
514 pill(
515 'blue',
516 segments,
517 lines(
518 `Cache read: ${mark}${formatTokens(tokens.cacheRead)} tokens`,
519 hit === null ? '' : `Cache hit rate: ${formatPercent(hit)} of input`,
520 ...tail,
521 ),
522 `Cache read ${mark}${formatTokens(tokens.cacheRead)} tokens`,
523 look,
524 'cache',
525 ),
526 )
527 }
528 return pills
529}
530
531export function speedPill(speed: UsageBandSpeed, look: Look = DEFAULT_LOOK): Pill {
532 const last = `${speed.lastTps.toFixed(1)} t/s`
533 const avg = speed.ms === 0 ? 0 : (speed.tokens / speed.ms) * 1000
534 return pill(
535 'lime',
536 [{ kind: 'icon', name: 'bolt' }, GAP_S, { kind: 'text', text: last }],
537 lines(
538 `Output speed, last request: ${last}`,
539 `Average this session: ${avg.toFixed(1)} t/s over ${speed.requests} requests`,
540 'Measured from the first streamed token to the end of each response.',
541 ),
542 `Output ${last}`,
543 look,
544 'speed',
545 )
546}
547
548export function costPill(
549 usd: number,
550 context: UsageBandContext | null,
551 look: Look = DEFAULT_LOOK,
552 perHour: number | null = null,
553): Pill {
554 const segments: Segment[] = [{ kind: 'icon', name: 'dollar' }, { kind: 'gap', w: 4 }, { kind: 'text', text: formatUsd(usd) }]
555 if (perHour !== null) {
556 segments.push(GAP_M, { kind: 'sep' }, GAP_M, { kind: 'text', text: `${formatUsd(perHour)}/h`, isDim: true })
557 }
558 return pill(
559 'gold',
560 segments,
561 lines(
562 `Session cost: ${formatUsd(usd)}`,
563 'At API list prices (not what a subscription actually pays).',
564 perHour === null ? '' : `Rate so far: ${formatUsd(perHour)} per hour`,
565 contextLine(context),
566 ),
567 `Session cost ${formatUsd(usd)}`,
568 look,
569 )
570}
571
572// ---------------------------------------------------------------- work
573
574export function toolsPill(tools: UsageBandTools, look: Look = DEFAULT_LOOK): Pill {
575 const mark = tools.isEstimate ? '~' : ''
576 const top = Object.entries(tools.byTool)
577 .sort((a, b) => b[1] - a[1])
578 .slice(0, 8)
579 .map(([name, n]) => ` ${name.replace(/^mcp__/, '')}: ${n}`)
580 return pill(
581 'orange',
582 [{ kind: 'icon', name: 'wrench' }, GAP_S, { kind: 'text', text: `${mark}${tools.total}` }],
583 lines(
584 `Tool calls: ${mark}${tools.total}`,
585 ...top,
586 tools.isEstimate ? 'Counted since the band loaded (transcript unreadable)' : 'Source: session transcript + subagents',
587 ),
588 `${mark}${tools.total} tool calls`,
589 look,
590 'tools',
591 )
592}
593
594export function churnPill(churn: UsageBandChurn, look: Look = DEFAULT_LOOK): Pill {
595 return pill(
596 'orange',
597 [
598 { kind: 'icon', name: 'diff' },
599 GAP_S,
600 { kind: 'text', text: `+${churn.added}`, accent: 'add' },
601 { kind: 'gap', w: 6 },
602 { kind: 'text', text: `−${churn.removed}`, accent: 'del' },
603 ],
604 lines(
605 `Lines changed: +${churn.added} −${churn.removed}`,
606 `Files touched: ${churn.files}`,
607 'Edit, MultiEdit and Write calls that succeeded; a Write counts every line it wrote.',
608 ),
609 `${churn.added} lines added, ${churn.removed} removed`,
610 look,
611 'lines',
612 )
613}
614
615// ---------------------------------------------------------------- session
616
617export function modelPill(model: string, look: Look = DEFAULT_LOOK): Pill {
618 const name = prettyModel(model)
619 return pill('slate', [{ kind: 'icon', name: 'chip' }, GAP_S, { kind: 'text', text: name }], `Model: ${model}`, `Model ${name}`, look)
620}
621
622export function effortPill(effort: string, look: Look = DEFAULT_LOOK): Pill {
623 return pill(
624 'pink',
625 [{ kind: 'icon', name: 'bulb' }, GAP_S, { kind: 'text', text: effort }],
626 `Thinking effort of the last request: ${effort}`,
627 `Thinking ${effort}`,
628 look,
629 'think',
630 )
631}
632
633export function folderPill(cwd: string, look: Look = DEFAULT_LOOK): Pill {
634 const name = baseName(cwd)
635 return pill('slate', [{ kind: 'icon', name: 'folder' }, GAP_S, { kind: 'text', text: name }], `Working directory:\n${cwd}`, `Folder ${name}`, look)
636}
637
638export function gitPill(git: UsageBandGit, look: Look = DEFAULT_LOOK): Pill {
639 if (!git.isRepo) {
640 return pill('slate', [{ kind: 'icon', name: 'branch' }, GAP_S, { kind: 'text', text: 'no git', isDim: true }], 'Not a git repository', 'No git', look)
641 }
642 const branch = git.branch ?? 'detached'
643 const segments: Segment[] = [{ kind: 'icon', name: 'branch' }, GAP_S, { kind: 'text', text: branch }]
644 if (git.dirty > 0) segments.push({ kind: 'gap', w: 6 }, { kind: 'text', text: `±${git.dirty}`, accent: 'warn' })
645 return pill(
646 'slate',
647 segments,
648 lines(`Git branch: ${branch}`, git.dirty > 0 ? `${git.dirty} changed or untracked files` : 'Working tree clean'),
649 `Branch ${branch}`,
650 look,
651 'git',
652 )
653}
654
655export function durationPill(ms: number, look: Look = DEFAULT_LOOK): Pill {
656 const text = formatLeft(ms)
657 return pill('slate', [{ kind: 'icon', name: 'stopwatch' }, GAP_S, { kind: 'text', text }], `Session running for ${text}`, `Session ${text}`, look, 'up')
658}
659
660export function turnsPill(turns: UsageBandTurns, look: Look = DEFAULT_LOOK): Pill {
661 const segments: Segment[] = [{ kind: 'icon', name: 'bubble' }, GAP_S, { kind: 'text', text: String(turns.count) }]
662 if (turns.timed > 0) {
663 segments.push(GAP_M, { kind: 'sep' }, GAP_M, { kind: 'text', text: formatShort(turns.lastMs), isDim: true })
664 }
665 const avg = turns.timed === 0 ? 0 : turns.totalMs / turns.timed
666 return pill(
667 'slate',
668 segments,
669 lines(
670 `Prompts this session: ${turns.count}`,
671 turns.timed === 0 ? '' : `Last turn: ${formatShort(turns.lastMs)}`,
672 turns.timed === 0 ? '' : `Average turn: ${formatShort(avg)} (${turns.timed} timed)`,
673 ),
674 `${turns.count} turns`,
675 look,
676 'turns',
677 )
678}
679
680export function memoryPill(memory: UsageBandMemory, look: Look = DEFAULT_LOOK): Pill {
681 const used = memory.total - memory.free
682 const percent = memory.total === 0 ? 0 : (used / memory.total) * 100
683 return pill(
684 'cyan',
685 [
686 { kind: 'icon', name: 'memory' },
687 GAP_S,
688 { kind: 'text', text: 'mem' },
689 GAP_M,
690 { kind: 'bar', percent, elapsed: null },
691 GAP_M,
692 { kind: 'text', text: `${formatGiB(used)}/${formatGiB(memory.total)}`, isDim: true },
693 ],
694 `Machine memory: ${formatGiB(used)} of ${formatGiB(memory.total)} used (${formatPercent(percent)})`,
695 `Memory ${formatPercent(percent)} used`,
696 look,
697 )
698}
699
700// ---------------------------------------------------------------- terminal bar
701
702export type BarRun = { text: string; kind: 'fill' | 'empty' | 'marker' }
703
704/** Ten cells of █ and ░ with │ where the elapsed part of the window ends. */
705export function terminalBar(percent: number, elapsed: number | null): BarRun[] {
706 const cells = 10
707 const filled = Math.round(Math.min(100, Math.max(0, percent)) / 10)
708 const marker = elapsed === null ? -1 : Math.min(cells - 1, Math.floor(elapsed * cells))
709 const runs: BarRun[] = []
710 for (let i = 0; i < cells; i++) {
711 const kind: BarRun['kind'] = i === marker ? 'marker' : i < filled ? 'fill' : 'empty'
712 const ch = kind === 'marker' ? '│' : kind === 'fill' ? '█' : '░'
713 const last = runs[runs.length - 1]
714 if (last !== undefined && last.kind === kind && kind !== 'marker') last.text += ch
715 else runs.push({ text: ch, kind })
716 }
717 return runs
718}
719types/index.d.ts 74 lines1/** One rate-limit window as last read: percent used and reset time (ms since epoch). */
2export type UsageBandLimit = { percentUsed: number; resetsAtMs: number | null }
3
4/** Token totals of the session (main transcript + subagents). */
5export type UsageBandTokens = {
6 /** Uncached input tokens. */
7 input: number
8 /** Tokens written to the prompt cache. */
9 cacheWrite: number
10 output: number
11 cacheRead: number
12 /** Unique API requests counted. */
13 requests: number
14 /** True when counted from turn.complete instead of the transcript (shown with "~"). */
15 isEstimate: boolean
16}
17
18export type UsageBandContext = { tokens: number | null; window: number; percent: number | null }
19
20/** Tool calls by tool name, and their total. */
21export type UsageBandTools = { total: number; byTool: Record<string, number>; isEstimate: boolean }
22
23/** Lines added and removed by Edit, MultiEdit and Write calls that did not fail. */
24export type UsageBandChurn = { added: number; removed: number; files: number }
25
26/** Prompts this session ($.session.turns()), and the length of the main-loop turns timed since the band loaded. */
27export type UsageBandTurns = { count: number; lastMs: number; totalMs: number; timed: number }
28
29/** The working directory's git state; `isRepo` false outside a repository. */
30export type UsageBandGit = { isRepo: boolean; branch: string | null; dirty: number }
31
32/** Output speed: the last main-loop request's tokens per second, and the sums for the average. */
33export type UsageBandSpeed = { lastTps: number; tokens: number; ms: number; requests: number }
34
35/** Machine memory in bytes. */
36export type UsageBandMemory = { total: number; free: number }
37
38/** The highest alert level already toasted per window: 0 none, 1 warn, 2 hot. */
39export type UsageBandAlerted = { fiveHour: number; sevenDay: number; spend: number }
40
41declare module 'claude-code' {
42 interface PluginState {
43 'usage-band': {
44 fiveHour: UsageBandLimit | null
45 sevenDay: UsageBandLimit | null
46 spendLimit: UsageBandLimit | null
47 context: UsageBandContext | null
48 costUsd: number | null
49 /** When the session began, ms since epoch ($.session.usage().startedAt). */
50 startedAt: number | null
51 tokens: UsageBandTokens | null
52 /** Fallback totals summed from turn.complete usage. */
53 estimate: UsageBandTokens
54 /** Tool calls counted from the transcript. */
55 tools: UsageBandTools | null
56 /** Tool calls counted live by the tool.call hook (fallback). */
57 liveTools: UsageBandTools
58 churn: UsageBandChurn | null
59 turns: UsageBandTurns
60 model: string | null
61 /** Thinking effort of the last main-loop request. */
62 effort: string | null
63 cwd: string | null
64 git: UsageBandGit | null
65 speed: UsageBandSpeed | null
66 memory: UsageBandMemory | null
67 alerted: UsageBandAlerted
68 /** Last refresh time, ms since epoch: what the countdowns count from. */
69 now: number
70 isHidden: boolean
71 }
72 }
73}
74