SLOPSHOPPER

usage-band

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

newbandguardcommandtoastprocess
v0.3.0MITupdated 2026-10-03ersinkoc/claude-usage-band-cli
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /usage-band ⎿ usage-band: 5h 31% · ctx 97.4k/200.0k · in ~6.4k · out ~1.5k · cache ~91.0k · $0.42 · 9 tools · Opus 5.5 ███░░░░░░░ 31% █████░░░░░ 49% · 97.4k/200.0k $0.42 · $0.84/h  ~6.4k  ~1.5k  ~91.0k · 93%  ~9  Opus 5.5  app no git  30m  1 turns · 42s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
███░░░░░░░ 31% █████░░░░░ 49% · 97.4k/200.0k $0.42 · $0.84/h  ~6.4k  ~1.5k  ~91.0k · 93%  ~9  Opus 5.5  app no git  30m  1 turns · 42s
README

claude-usage-band-cli

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.

Desktop pills, light theme Desktop pills, dark theme


⚡ Sponsored by WrongStack

Built on the wrong stack. Shipped anyway.

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

Website   GitHub   Stars

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

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

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


What it shows

LinePills
15h 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)
2input (uncached + cache writes) · output · cache reads (and hit rate) · output speed in t/s · tool calls · lines changed (+/−)
3model · thinking effort · folder · git branch and changed files · session duration · turns (and the last turn's length) · machine memory
  • Bars are green, turning yellow from 70% and red from 90% (both adjustable).
  • A toast appears when a limit crosses the yellow or red threshold.
  • Every pill has a hover tooltip (desktop) with the breakdown: input split, context, request count, top tools, files touched, the full path…
  • In the terminal the three lines stay fixed; if the window is too narrow, the same pills are rebalanced over three lines, and only on a very narrow terminal does it use more.
  • Limits show up after the first model response of a session, as Claude Code reads them from the API.
  • Cost is the session's cost at API list prices, not what a subscription pays.

Requirements

  • Claude Code with function hooks (validated and tested against 2.1.286 and 2.1.288).
  • Node.js on 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.
  • A Nerd Font in the terminal for the icons and the rounded powerline caps; without one, set nerdFont to false (text labels) or terminalStyle to plain.

Install

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 another usage-band plugin: they'd claim the same command and state.

Commands

CommandDoes
/usage-bandRefreshes everything and prints a one-line summary
/usage-band hide · /usage-band showHides or shows the band
/usage-band settingsLists 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).

Settings

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.

SettingTypeDefaultWhat it does
show5hbooleantruePill with the 5-hour window's usage, elapsed marker and reset countdown
show7dbooleantruePill with the 7-day window's usage, elapsed marker and reset countdown
showSpendLimitbooleantruePill for a gateway's spend limit, when the account reports one
showPacebooleantrueOn limit pills, where usage lands at reset if the current pace holds
showContextbooleantruePill with the context window's fill
showInputbooleantrueUncached input + cache writes
showOutputbooleantrueTokens the model generated
showCacheReadbooleantrueInput tokens served from the prompt cache
showCacheHitbooleantrueOn the cache pill, the share of input served from the cache
showSpeedbooleantrueTokens per second of the last response (hover for the average)
showCostbooleantrueSession cost at API list prices
showBurnRatebooleantrueOn the cost pill, the session's cost per hour so far
showToolsbooleantruePill with the number of tool calls (hover for the top tools)
showChurnbooleantrueLines added and removed by Edit, MultiEdit and Write
showModelbooleantruePill with the model that answered last
showEffortbooleantrueThe thinking effort of the last request (low … max)
showFolderbooleantrueThe working directory's name (hover for the full path)
showGitbooleantrueThe current branch and how many files changed
showMemorybooleantrueThis machine's memory in use
showDurationbooleantruePill with how long the session has run
showTurnsbooleantruePill with the number of turns and the last turn's length
showInTerminalbooleantrueOff to show the band only in the desktop app (when a status line already covers the terminal)
terminalStylepowerline / plainpowerlineColored powerline segments, or colored text
nerdFontbooleantrueIcons and rounded powerline caps from a Nerd Font; off for text labels
layoutfull / compactfullIcons and bars, or text only
warnAtnumber70Bars turn yellow at this percent
hotAtnumber90Bars turn red at this percent
alertsbooleantrueA toast when a limit crosses the yellow or red threshold
refreshSecondsnumber30How often countdowns and totals refresh

Where the numbers come from

  • Limits, context, cost: $.session.usage() and the session.measure event Claude Code raises after each turn.
  • Tokens, tool calls, lines changed, model: 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 ~.
  • Effort and speed: read from each main-loop model request as it streams; speed is output tokens over the time from the first streamed piece to the end of the response.
  • Git: git rev-parse, git branch --show-current and git status --porcelain in the session's directory.
  • Memory: scripts/sysinfo.mjs (os.totalmem() and os.freemem()).
  • Countdowns and totals refresh every 30 seconds (adjustable).

Layout

.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

Development

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.

License

MIT

Source 3 files
hooks/register.tsx 1123 lines
1import { 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}
1123
hooks/pills.ts 719 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
719
types/index.d.ts 74 lines
1/** 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