A light, framed live dashboard above the prompt, in the terminal and in the desktop app: model and effort, context and account-wide plan-limit meters (5-hour…

A Claude Code mod that puts your session's vitals in a framed live dashboard above the prompt — in the terminal and in the desktop app's Code tab. Light by design: it draws what Claude Code already hands it and runs anything slow rarely, in the background.
Model and reasoning effort, context and plan limits (account-wide, as claude.ai counts them), cost and burn rate, tokens and cache per turn and session, auto-compaction, every subagent with its own model and effort, background shells, the tool running right now, and your weekly and monthly spend.

Every part has its own box. ◆ VITALS sits on top across the whole width: the header, four bars, ⛽ CTX (context window), 🗜 COMPACT (how far the context is on its way to auto-compaction), ⏳ 5H LIMIT and 📅 WEEKLY (plan limits; a model's own weekly limit and 💳 CREDITS join them when they count), and the 🔮 FORECAST of both limits at your current pace. Under it, on a terminal 150 columns wide or more, two columns: 🔥 TOKENS, 🧩 CONTEXT and 📊 USAGE on the left, 🤖 AGENTS, 🔧 TOOLS and 🐚 SHELLS on the right, agents on top. Narrower, one column: tokens, agents, tools, shells, context, usage.
The band never scrolls (at most 40 rows). Every section gets one line first, then the most important ones grow to their full box while they fit: tokens, agents, tools, context, usage, shells. A short terminal, or one where Claude's progress takes the space, gets one line each instead of losing sections off the bottom.
Commands:
| Command | Shows |
|---|---|
/vitals-high | Everything: the vitals box, tokens, context, usage, agents, tools, shells. The default |
/vitals-medium | The vitals box, 🧩 context and 🤖 agents |
/vitals-low | The vitals box alone: model, effort, session, cost, the bars and the forecast |
/vitals | The next level down: high → medium → low → high |
/vitals pane | A pane with every section in full: every agent, every shell, every tool |
/vitals report | The usage report: 🎯 your account's plan limits (each model's own weekly limit, usage credits, and this week's limit by product: Claude Code, chats, Cowork), then the last 14 days, the last 6 weeks, this month and the last, and this month's models, each with bars |
The level you pick stays for the sessions that follow.
/vitals-low: the vitals box alone, in a terminal 200 columns wide.

/vitals-medium: the vitals box, what fills the context, and the agents side by side.

/vitals-high: everything. With the rows this terminal had, 📊 usage got one line; a taller one gets its full table.

/vitals report: the usage report in a pane. Since 0.9 it opens with 🎯 your account's plan limits, read from claude.ai; below them, from ccusage: the last 14 days, the last 6 weeks, this month and the last, and this month's models.
<img src="docs/screenshots/vitals-report.png" alt="vitals report: cost, tokens and top model per day for 14 days with bars; cost per week for 6 weeks with the change against the week before; this month and last; this month's models with their share" width="600">
/vitals pane: every section in full, in a pane beside the conversation: every agent, every shell, every tool.
<img src="docs/screenshots/vitals-pane.png" alt="vitals pane: the vitals box, tokens, context, agents, tools and usage stacked in full, in a narrow side pane" width="600">
| Section | Contents |
|---|---|
| Header | 🧠 model, ⚡ the reasoning effort the last request used (pips out of five), 📁 folder, 🌿 branch (🌳 in a worktree), ahead/behind, changed files, ⏳ session age, prompts, 💸 cost, 🔥 burn rate per hour |
| Meters | ⛽ CTX, the context window (against the auto-compact window when one is set, as /context does); 🗜 COMPACT, the context against the auto-compact threshold, tokens left and compactions so far; ⏳ 5H LIMIT and 📅 WEEKLY plan limits with reset countdowns, as claude.ai's usage page counts them: every session, machine and claude.ai chat on the account, not only this session; ▸ marks the one the server names as the limit that counts now. A model's own weekly limit (📅 FABLE) once it is used, graded above normal or the one that counts now, and 💳 CREDITS, spent against the month's limit, while usage credits are on or spent; /vitals pane shows them always. Bars at an eighth of a cell, two to a row when the terminal is narrow |
| 🔮 Forecast | Each plan limit at the pace you spend it: the last hour's pace once there are ten minutes of it, else the window's average. Either when it runs out before its reset (⚠), or where it will stand at the reset (✓) |
| 🧩 Context | What fills the context, as /context breaks it down: one bar in its colours (system prompt, tools, memory, skills, messages, ░ free, ▒ autocompact buffer) and a legend with tokens and shares. An estimate, read every 5 minutes |
| 🔥 Tokens | Last main turn and whole session (subagents included): in, out, cache read, cache write, hit rate, total; idle time, 🧊 cache warm with the time until it expires, or 🥶 cold past the prompt-cache TTL. An interrupted turn keeps the last counted one on show |
| 🤖 Agents | The main loop and every subagent: status (spinner while it runs, ✓ ✗ ■ when it ended), task, type, the model and effort its requests actually used, tokens, hit rate, tool calls, time. The band shows the ones running; one that ends keeps its ✓ or ✗ three seconds, then leaves. /vitals pane keeps every one of the session |
| 🔧 Tools | One row per tool the session called: a spinner while one runs, calls, errors, a bar of its share of the calls, and what runs now (elapsed, how many at once, which agent). MCP tools by their short name |
| 📊 Usage | Today, this week (Monday first) and this month across every Claude Code session on the machine, each against the same days of the period before, the top models, a 14-day sparkline |
| 🎯 Plan limits (report) | Your account as claude.ai reports it: the 5-hour and weekly windows, each model's own weekly limit with the share of the weekly it may use, usage credits, and 🧭 who spent this week's limit by product (Claude Code, chats, Cowork, other). Read with your Claude login; absent with an API key or a cloud provider |
| 🐚 Shells | Background shells: id, command, which agent started it, status, time. Ended by the task notification or a TaskStop; the band shows the running ones and drops an ended one after three seconds, /vitals pane keeps them all |
Meters turn amber at 80% and red at 95%, or sooner when the server grades a plan limit warning or critical; a cache hit rate under 50% and a cold cache are flagged. A toast pops up when a plan limit crosses 80% and again at 95%, once per window. Narrow windows drop the least useful table columns first.
Hit rate is cache read over everything the request sent: read / (in + read + write).
| Work | How often |
|---|---|
| Context, plan limits, cost | As Claude Code measures them: the figures come with the event, no call is made |
Plan limits, usage credits and the weekly limit by product, across the account (/api/oauth/usage, the endpoint /usage reads) | Every 5 minutes and after a turn, at most once a minute; held off as long as the endpoint asks after a refusal. A read of usage, not a model request: it spends no tokens and no plan usage |
| Model, effort, tokens, tools, agents, shells | From the events that already happen (each request, turn, tool call, notification) |
git status and rev-parse | At most every 20 seconds |
The /context estimate (auto-compact threshold, context breakdown) | Every 5 minutes and after a compaction |
ccusage claude daily | In the background, at most every 15 minutes, kept across sessions so a new one draws it at once |
| Redraw | Once a second only while something runs (spinners, elapsed times); idle, only when a value changes |
Requirements:
| What | Why | Install |
|---|---|---|
| Claude Code v2.1.287 or later | Mods (function-hook plugins) | claude update |
ccusage on the PATH | The 📊 usage section and /vitals report (today, week, month, models) | npm i -g ccusage |
git | The folder and branch in the header | already there on most machines |
Without ccusage the band still works and the usage section says ccusage not found.
npm i -g ccusage
ccusage claude daily --since $(date +%Y%m01)
/plugin marketplace add naicud/cc-vitals
/plugin install vitals@naicud
/reload-plugins
Or from a shell:
claude plugin marketplace add naicud/cc-vitals
claude plugin install vitals@naicud
/plugin, open Marketplaces, pick naicud, choose Enable auto-update. Or set it in ~/.claude/settings.json: {
"extraKnownMarketplaces": {
"naicud": { "source": { "source": "github", "repo": "naicud/cc-vitals" }, "autoUpdate": true }
}
}
Claude Code then checks the marketplace a few minutes into each interactive session, updates the plugin on disk and says Plugin updated: vitals · Run /reload-plugins to apply; the next session starts on the new version.
~/.claude plugins, so it shows up in its Code tab too (start a new session there).The band replaces most of what a statusLine script shows, so you can drop yours (statusLine in ~/.claude/settings.json) or keep it for other things.
To update by hand (without auto-update): claude plugin marketplace update naicud && claude plugin update vitals@naicud, then restart or /reload-plugins.
| Symptom | Cause and fix |
|---|---|
📊 USAGE no history: ccusage not found | ccusage is not on the PATH Claude Code was started with: install it, restart Claude Code |
Usage report shows no data for older days | Claude Code deletes transcripts after cleanupPeriodDays (30 by default), so ccusage, and the report, only reach that far back. Raise it in ~/.claude/settings.json ("cleanupPeriodDays": 365) to keep a longer history, at the cost of disk space. Periods that start before the first day on record are not compared against |
| Usage costs look too low | ccusage could not reach its price list and priced new models at zero: run ccusage claude daily once online |
| One line per section instead of tables | The band has few rows (a short terminal, or Claude's progress is taking them): make the terminal taller, or /vitals pane for everything in full |
| No band at all | Claude Code older than v2.1.287, or the plugin disabled (claude plugin list, then claude plugin enable vitals@naicud) |
| ⏳ 5H LIMIT or 📅 WEEKLY a point behind claude.ai | The account's usage is read every 5 minutes and after each turn: usage elsewhere shows within 5 minutes. Signed in with an API key, Bedrock or Vertex there is no account usage to read, and the limits are the ones this session's last response reported |
| No 📅 model limit or 💳 CREDITS bar | The band shows a model's own weekly limit only once it counts (used, graded above normal, or the active one) and credits only while they are on or spent: /vitals pane and /vitals report show them always. With an API key or a cloud provider there are none to read |
| Less than you expect | /vitals-low or /vitals-medium is on, and the level stays across sessions: /vitals-high brings everything back |
One option, cache_ttl: how long the main conversation's prompt cache lives, 1h (default, Claude subscription within plan usage) or 5m (API billing, cloud providers, usage credits). It only drives the (cache cold) warning. Change it in /plugin or /config.
git, two fixed read-only commands in the session's folder with a 5-second timeout: git status --porcelain=v2 --branch and git rev-parse --git-dir --git-common-dir; neither contacts a remote. And ccusage claude daily --json --since <first of last month> for the usage section, when ccusage is installed (npm i -g ccusage): it reads Claude Code's local transcripts and may fetch model prices; without it the usage section says so and everything else works./config until the first request reports one. It never reads response text, files, environment variables or credentials.GET https://api.anthropic.com/api/oauth/usage for the plan limits, usage credits and the weekly limit by product, through Claude Code with the session's own Claude login: the plugin holds an opaque handle, never the token. Not sent with an API key or a cloud provider, nor while CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set. It writes no files. Session state lives in $.state; the last ccusage report is kept in the plugin's $.store.git clone https://github.com/naicud/cc-vitals
claude --plugin-dir cc-vitals/plugins/vitals # try it for one session
claude plugin validate cc-vitals/plugins/vitals
claude plugin test cc-vitals/plugins/vitals
Layout:
.claude-plugin/marketplace.json the "naicud" marketplace
plugins/vitals/
.claude-plugin/plugin.json manifest and the cache_ttl option
hooks/register.tsx hooks, state atoms, refresh cadence, /vitals, render entries
hooks/collect.ts pure folds: place, meters, agents, shells, tool counts
hooks/band.tsx the vitals box, the meters and the row-budget layout
hooks/sections.tsx the section boxes: tokens, agents, tools, shells, usage
hooks/report.ts ccusage parsing and day/week/month folds
hooks/forecast.ts plan-limit pace and forecast
hooks/report-view.tsx the /vitals report pane
hooks/ui.tsx boxes, rules, ruled tables, meters, bars, sparklines
hooks/format.ts number, model, status and token formatting
hooks/vitals.test.tsx tests against the engine's test kit
types/index.d.ts $.state contract
Built on desktop-statusline by George Liu (MIT): the desktop band, limit meters and git row come from there. cc-vitals adds the terminal surface, the framed dashboard, reasoning effort, the token and cache tables, subagent and shell tracking, live tools, context composition, limit forecasts, account-wide limits per model and product with usage credits, compaction, usage reports, the three detail levels and the pane.
MIT. See LICENSE.
hooks/register.tsx 552 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register, SessionUsage } from 'claude-code'
3
4import type { AgentStat, ContextPart, Limit, LiveTool, RunStatus, ShellStat, Snapshot, Tokens, View } from '../types'
5import { drawAll, drawLevel } from './band'
6import type { BandInput } from './band'
7import {
8 addShell,
9 agentStep,
10 agentTokens,
11 agentTool,
12 compactionOf,
13 contextPartsOf,
14 configuredEffort,
15 countError,
16 countTool,
17 endRun,
18 isBusy,
19 limitWarnings,
20 mergeLimits,
21 mergeRoster,
22 metersOf,
23 parseNotification,
24 parsePlanUsage,
25 placeOf,
26 startLive,
27} from './collect'
28import { addSample, forecast } from './forecast'
29import type { Sample } from './forecast'
30import { NO_TOKENS, addTokens, toTokens } from './format'
31import { historySince, localDate, parseDaily } from './report'
32import { drawReport } from './report-view'
33import type { Canvas } from './ui'
34
35// The session's values, declared in ../types: kept by the host across reloads, gone with the session.
36const snapshot = atom({ plugin: 'vitals', key: 'snap' } as const, null)
37const warned = atom({ plugin: 'vitals', key: 'warned' } as const, [])
38const lastTurn = atom({ plugin: 'vitals', key: 'lastTurn' } as const, null)
39const totals = atom({ plugin: 'vitals', key: 'totals' } as const, null)
40const compactions = atom({ plugin: 'vitals', key: 'compactions' } as const, null)
41const effort = atom({ plugin: 'vitals', key: 'effort' } as const, null)
42const view = atom({ plugin: 'vitals', key: 'view' } as const, 'high')
43const agents = atom({ plugin: 'vitals', key: 'agents' } as const, [])
44const shells = atom({ plugin: 'vitals', key: 'shells' } as const, [])
45const live = atom({ plugin: 'vitals', key: 'live' } as const, [])
46const tools = atom({ plugin: 'vitals', key: 'tools' } as const, null)
47const history = atom({ plugin: 'vitals', key: 'history' } as const, null)
48const historyProblem = atom({ plugin: 'vitals', key: 'historyProblem' } as const, null)
49const plan = atom({ plugin: 'vitals', key: 'plan' } as const, null)
50
51// What each kind of work costs decides how often it runs: a measurement is free and drawn at
52// once; git is two processes; the /context estimate walks the context; ccusage reads every
53// transcript on the machine; the account's usage endpoint is one request, read sparingly.
54const REFRESH_MS = 60_000
55const GIT_EVERY_MS = 20_000
56const BREAKDOWN_EVERY_MS = 5 * 60_000
57const HISTORY_EVERY_MS = 15 * 60_000
58const PLAN_EVERY_MS = 5 * 60_000
59const PLAN_GAP_MS = 60_000
60const PLAN_TIMEOUT_MS = 30_000
61const PLAN_PAUSE_MS = 5 * 60_000
62const PLAN_PAUSE_MAX_MS = 60 * 60_000
63// The plan limits as claude.ai's usage page reads them, every session and surface of the account
64// counted: the session's own Claude login, through the host. It reads usage, it spends none.
65const PLAN_URL = 'https://api.anthropic.com/api/oauth/usage'
66const OAUTH_BETA = 'oauth-2025-04-20'
67const TICK_MS = 1000
68const MAX_BAND_ROWS = 40
69const PANE_ID = 'vitals'
70const REPORT_ID = 'vitals-report'
71const HISTORY_KEY = 'history'
72const LEVEL_KEY = 'level'
73const LEVEL_NAMES: Record<View, string> = { low: 'low · the vitals box', medium: 'medium · vitals, context, agents', high: 'high · everything' }
74const NEXT_LEVEL: Record<View, View> = { high: 'medium', medium: 'low', low: 'high' }
75
76/** A level read back from state or the store: anything else, an older value included, is high. */
77const asLevel = (value: unknown): View => (value === 'low' || value === 'medium' ? value : 'high')
78
79// Module memory: what the slow reads returned last, and when. A reload starts it over.
80let gitAt = 0
81let gitPlace: ReturnType<typeof placeOf> | null = null
82let breakdownAt = 0
83let compaction: ReturnType<typeof compactionOf> = { compactWindow: null, autoCompactAt: null }
84let contextParts: ContextPart[] | null = null
85// The plan limits' recent readings, for the pace of the forecast.
86const limitSamples = new Map<string, Sample[]>()
87let isReadingHistory = false
88let planAt = 0
89let planPausedUntil = 0
90let planReadingSince: number | null = null
91
92// Each command is written out in full at its call; this only reads the result.
93async function output(run: Promise<ProcessRunResult>) {
94 try {
95 const ran = await run
96 return ran.exitCode === 0 ? ran.stdout.trim() : null
97 } catch {
98 return null
99 }
100}
101
102async function setAgents($: EngineInterface, fn: (list: AgentStat[]) => AgentStat[]) {
103 const was = await read($, agents)
104 const next = fn(was)
105 if (JSON.stringify(next) !== JSON.stringify(was)) await update($, agents, () => next)
106}
107
108async function setShells($: EngineInterface, fn: (list: ShellStat[]) => ShellStat[]) {
109 const was = await read($, shells)
110 const next = fn(was)
111 if (JSON.stringify(next) !== JSON.stringify(was)) await update($, shells, () => next)
112}
113
114/** A measurement's meters, its plan limits joined with the usage endpoint's last reading. */
115async function metersNow($: EngineInterface, measured: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>, now: number) {
116 const meters = metersOf(measured, compaction.compactWindow)
117 return { ...meters, limits: mergeLimits(meters.limits, (await read($, plan))?.limits ?? [], now) }
118}
119
120/** The plan limits on show: a sample for each forecast, a toast for each threshold crossed. */
121async function noteLimits($: EngineInterface, limits: Limit[], now: number) {
122 for (const l of limits) limitSamples.set(l.kind, addSample(limitSamples.get(l.kind) ?? [], { at: now, percent: l.percent }))
123 const warnings = limitWarnings(limits, await read($, warned), now)
124 for (const w of warnings) $.ui.toast(w.text, { timeoutMs: 8000 })
125 if (warnings.length > 0) await update($, warned, s => [...s, ...warnings.map(w => w.key)].slice(-50))
126}
127
128/** Draws a measurement into the meters at once: no call, the figures came with the event. */
129async function applyMeasure($: EngineInterface, measured: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>, now: number) {
130 const meters = await metersNow($, measured, now)
131 await update($, snapshot, s => (s === null ? s : { ...s, ...meters, autoCompactAt: compaction.autoCompactAt, contextParts, at: now }))
132 await noteLimits($, meters.limits, now)
133}
134
135/** How long a refusal (401, 403, 429) holds the usage endpoint off: its Retry-After, else 5 minutes. */
136const pauseOf = (retryAfter: string | undefined, now: number) => {
137 const seconds = Number(retryAfter)
138 const until = retryAfter === undefined || retryAfter.trim() === '' ? NaN : Number.isFinite(seconds) ? now + seconds * 1000 : Date.parse(retryAfter)
139 return Math.min(Number.isFinite(until) && until > now ? until - now : PLAN_PAUSE_MS, PLAN_PAUSE_MAX_MS)
140}
141
142/**
143 * The plan limits from the account's usage endpoint, at most once a minute and never while it
144 * holds Vitals off. With no Claude login (an API key, a cloud provider) nothing is asked, and a
145 * failed read changes nothing: the last API response's figures stay.
146 */
147async function refreshPlan($: EngineInterface) {
148 const now = await $.clock.now()
149 const isReading = planReadingSince !== null && now - planReadingSince < PLAN_TIMEOUT_MS
150 if (isReading || now < planPausedUntil || now - planAt < PLAN_GAP_MS) return
151 planAt = now
152 planReadingSince = now
153 try {
154 const auth = await $.session.authorize()
155 if (auth === null || auth.kind !== 'bearer') return
156 const answer = await $.http.fetch(PLAN_URL, { auth: auth.handle, headers: { 'anthropic-beta': OAUTH_BETA } })
157 if (answer.status === 401 || answer.status === 403 || answer.status === 429) {
158 planPausedUntil = now + pauseOf(answer.headers['retry-after'], now)
159 return
160 }
161 const usage = answer.ok ? parsePlanUsage(answer.text) : null
162 if (usage === null) return
163 // A read that hung past its timeout may land after a later one: the later stays.
164 const held = await read($, plan)
165 if (held !== null && held.at > now) return
166 await update($, plan, () => ({ at: now, ...usage }))
167 const snap = await update($, snapshot, s => (s === null ? s : { ...s, limits: mergeLimits(s.limits, usage.limits, now) }))
168 if (snap !== null) await noteLimits($, snap.limits, now)
169 } catch {
170 // Offline, nonessential traffic turned off, or a policy refused it: the figures on show stay.
171 } finally {
172 if (planReadingSince === now) planReadingSince = null
173 }
174}
175
176/** The whole snapshot: the cheap reads every time, git and the /context estimate when due. */
177async function refresh($: EngineInterface) {
178 const now = await $.clock.now()
179 const isBreakdownDue = now - breakdownAt >= BREAKDOWN_EVERY_MS
180 const [usage, cwd, prompts, model, roster] = await Promise.all([
181 isBreakdownDue ? $.session.usage({ breakdown: 'summary' }) : $.session.usage(),
182 $.session.cwd(),
183 $.session.turns(),
184 $.session.model(),
185 $.agent.list(),
186 ])
187 if (isBreakdownDue) {
188 breakdownAt = now
189 compaction = compactionOf(usage.context.breakdown)
190 contextParts = contextPartsOf(usage.context.breakdown) ?? contextParts
191 }
192 if (gitPlace === null || now - gitAt >= GIT_EVERY_MS) {
193 gitAt = now
194 const [gitStatus, gitDirs] = await Promise.all([
195 output($.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd, timeoutMs: 5000 })),
196 output($.process.run(['git', 'rev-parse', '--git-dir', '--git-common-dir'], { cwd, timeoutMs: 5000 })),
197 ])
198 gitPlace = placeOf(cwd, gitStatus, gitDirs)
199 }
200 if ((await read($, effort)) === null) {
201 const configured = await $.config.list().then(configuredEffort, () => null)
202 if (configured !== null) await update($, effort, () => configured)
203 }
204 const snap: Snapshot = {
205 at: now,
206 startedAt: usage.startedAt,
207 prompts,
208 model,
209 ...gitPlace,
210 ...(await metersNow($, usage, now)),
211 autoCompactAt: compaction.autoCompactAt,
212 contextParts,
213 }
214 await update($, snapshot, () => snap)
215 await setAgents($, list => mergeRoster(list, roster, now))
216 await applyMeasure($, usage, now)
217}
218
219/** Usage across every session from ccusage, kept in the store so a new session draws it at once. */
220async function refreshHistory($: EngineInterface, isForced: boolean) {
221 if (isReadingHistory) return
222 const now = await $.clock.now()
223 const held = await read($, history)
224 if (!isForced && held !== null && now - held.at < HISTORY_EVERY_MS) return
225
226 isReadingHistory = true
227 $.ui.invalidate('ui.render')
228 try {
229 const ran = await $.process.run(['ccusage', 'claude', 'daily', '--json', '--since', historySince(localDate(now))], { timeoutMs: 60_000 })
230 const days = ran.exitCode === 0 ? parseDaily(ran.stdout) : null
231 if (days === null) {
232 await update($, historyProblem, () => ({ at: now, reason: ran.exitCode === 0 ? 'unreadable ccusage output' : `ccusage exited ${ran.exitCode}` }))
233 return
234 }
235 await update($, history, () => ({ at: now, days }))
236 await $.store.set(HISTORY_KEY, { at: now, text: ran.stdout })
237 } catch {
238 await update($, historyProblem, () => ({ at: now, reason: 'ccusage not found (npm i -g ccusage)' }))
239 } finally {
240 isReadingHistory = false
241 }
242}
243
244/** The history the last session kept, if the store holds a readable one. */
245async function restoreHistory($: EngineInterface) {
246 if ((await read($, history)) !== null) return
247 const saved: unknown = await $.store.get(HISTORY_KEY)
248 if (typeof saved !== 'object' || saved === null) return
249 const at: unknown = Reflect.get(saved, 'at')
250 const text: unknown = Reflect.get(saved, 'text')
251 if (typeof at !== 'number' || typeof text !== 'string') return
252 const days = parseDaily(text)
253 if (days !== null) await update($, history, () => ({ at, days }))
254}
255
256/** Shows `level` now and in the sessions to come. */
257async function setLevel($: EngineInterface, level: View) {
258 await update($, view, () => level)
259 await $.store.set(LEVEL_KEY, level)
260 return { text: `Vitals: ${LEVEL_NAMES[level]}.` }
261}
262
263/** A model request: the main loop's sets the session's effort, a subagent's its own model and effort. */
264async function noteStep($: EngineInterface, agentId: string | undefined, model: string, level: string | null) {
265 if (agentId === undefined) {
266 if (level !== null && (await read($, effort)) !== level) await update($, effort, () => level)
267 return
268 }
269 const now = await $.clock.now()
270 await setAgents($, list => agentStep(list, agentId, model, level, now))
271}
272
273/** A finished turn: the main loop's is the last turn; every loop adds to the session's totals. */
274async function noteTurn($: EngineInterface, agentId: string | undefined, durationMs: number, model: string | null, used: Tokens | null) {
275 const [now, snap] = await Promise.all([$.clock.now(), read($, snapshot)])
276 const startedAt = snap?.startedAt ?? 0
277 if (agentId === undefined) {
278 // An interrupted turn reports no usage: the last counted one stays on show.
279 await update($, lastTurn, t => ({ at: now, durationMs, model, tokens: used ?? t?.tokens ?? null }))
280 } else if (used !== null) {
281 await setAgents($, list => agentTokens(list, agentId, used, now))
282 }
283 if (used === null) return
284 await update($, totals, t => {
285 const isSame = t?.since === startedAt
286 return {
287 since: startedAt,
288 turns: (isSame ? t.turns : 0) + (agentId === undefined ? 1 : 0),
289 tokens: addTokens(isSame ? t.tokens : NO_TOKENS, used),
290 }
291 })
292}
293
294/** A tool call starts: it shows live, counts for the session and for its agent. */
295async function noteToolStart($: EngineInterface, call: LiveTool) {
296 const snap = await read($, snapshot)
297 await update($, live, list => startLive(list, call))
298 await update($, tools, counts => countTool(counts, call.tool, snap?.startedAt ?? 0))
299 const agentId = call.agentId
300 if (agentId !== null) await setAgents($, list => agentTool(list, agentId, call.startedAt))
301}
302
303/** Ends a background shell or agent by its id, as a notification or a TaskStop reports it. */
304async function noteEnded($: EngineInterface, id: string, status: RunStatus) {
305 const now = await $.clock.now()
306 await setShells($, list => endRun(list, id, status, now))
307 await setAgents($, list => endRun(list, id, status, now))
308}
309
310/** Redraws once a second while something runs, so spinners and elapsed times move; idle, nothing. */
311async function tick($: EngineInterface) {
312 const [running, agentList, shellList, now] = await Promise.all([read($, live), read($, agents), read($, shells), $.clock.now()])
313 if (isBusy(running, agentList, shellList, now)) $.ui.invalidate('ui.render')
314}
315
316/** Everything a drawing reads, at one moment. */
317async function gather($: EngineInterface, canvas: Canvas, room: number, rows: number, isWorking: boolean, cacheTtlMs: number) {
318 const snap = await read($, snapshot)
319 if (snap === null) return null
320 const [now, turn, sessionTotals, compacted, level, agentList, shellList, running, toolCounts, past, problem, account] = await Promise.all([
321 $.clock.now(),
322 read($, lastTurn),
323 read($, totals),
324 read($, compactions),
325 read($, effort),
326 read($, agents),
327 read($, shells),
328 read($, live),
329 read($, tools),
330 read($, history),
331 read($, historyProblem),
332 read($, plan),
333 ])
334 const input: BandInput = {
335 canvas,
336 room,
337 rows,
338 isWorking,
339 now,
340 snap,
341 turn,
342 totals: sessionTotals,
343 compactions: compacted,
344 effort: level,
345 agents: agentList,
346 shells: shellList,
347 live: running,
348 tools: toolCounts,
349 history: past,
350 historyProblem: problem,
351 plan: account,
352 forecasts: snap.limits.flatMap(l => forecast(l, limitSamples.get(l.kind) ?? [], now) ?? []),
353 cacheTtlMs,
354 }
355 return input
356}
357
358/** A string field of a tool's result, which the hook sees untyped. */
359const field = (result: unknown, name: string) => {
360 if (typeof result !== 'object' || result === null || !(name in result)) return undefined
361 const value: unknown = Reflect.get(result, name)
362 return typeof value === 'string' ? value : undefined
363}
364
365export const register: Register = (on, options) => {
366 // The main conversation's prompt-cache TTL (the `cache_ttl` option): 1 hour on a Claude
367 // subscription within plan usage, 5 minutes with API billing, a cloud provider or usage credits.
368 const cacheTtlMs = options.cache_ttl === '5m' ? 5 * 60_000 : 60 * 60_000
369
370 on('session.start', async ($, e, next) => {
371 const started = await next(e)
372 await $.command.register({
373 name: 'vitals',
374 description: 'Vitals: next detail level (high → medium → low) · "/vitals pane" every agent and shell · "/vitals report" weekly and monthly usage',
375 })
376 await $.command.register({ name: 'vitals-low', description: 'Vitals: the vitals box alone (model, effort, cost, context, compaction, limits, forecast)' })
377 await $.command.register({ name: 'vitals-medium', description: 'Vitals: the vitals box, what fills the context, and the agents' })
378 await $.command.register({ name: 'vitals-high', description: 'Vitals: everything (tokens, context, usage, agents, tools, shells)' })
379 const saved: unknown = await $.store.get(LEVEL_KEY)
380 await update($, view, () => asLevel(saved))
381 await restoreHistory($)
382 await refresh($)
383 void refreshPlan($)
384 void refreshHistory($, false)
385 $.clock.every(REFRESH_MS, () => void refresh($))
386 $.clock.every(PLAN_EVERY_MS, () => void refreshPlan($))
387 $.clock.every(HISTORY_EVERY_MS, () => void refreshHistory($, false))
388 $.clock.every(TICK_MS, () => void tick($))
389
390 return started
391 })
392
393 on('command.run', { command: 'vitals' }, async ($, e) => {
394 const arg = e.args.trim()
395 if (arg === 'pane') {
396 await $.ui.open({ id: PANE_ID, title: 'Vitals · agents, shells, tokens' })
397 return { text: 'Vitals pane opened.' }
398 }
399 if (arg === 'report') {
400 void refreshHistory($, true)
401 void refreshPlan($)
402 await $.ui.open({ id: REPORT_ID, title: 'Vitals · usage report' })
403 return { text: 'Vitals usage report opened; refreshing from ccusage.' }
404 }
405 return setLevel($, NEXT_LEVEL[asLevel(await read($, view))])
406 })
407
408 on('command.run', { command: 'vitals-low' }, async $ => setLevel($, 'low'))
409 on('command.run', { command: 'vitals-medium' }, async $ => setLevel($, 'medium'))
410 on('command.run', { command: 'vitals-high' }, async $ => setLevel($, 'high'))
411
412 on('session.attach', async ($, e, next) => {
413 const attached = await next(e)
414 await refresh($)
415 void refreshPlan($)
416
417 return attached
418 })
419
420 // A measurement carries the context, the limits and the cost: drawn as they come, no call made.
421 on('session.measure', async ($, e, next) => {
422 const measured = await next(e)
423 await applyMeasure($, e, await $.clock.now())
424
425 return measured
426 })
427
428 // Each model request names its model and effort: the main loop's and every subagent's.
429 on('turn.step', async function* ($, e, next) {
430 await noteStep($, e.agentId, e.model, e.effort === undefined ? null : `${e.effort}`)
431
432 return yield* next(e)
433 })
434
435 on('turn.complete', async ($, e, next) => {
436 const completed = await next(e)
437 await noteTurn($, e.agentId, e.durationMs, e.usage?.model ?? null, e.usage ? toTokens(e.usage) : null)
438 if (!e.agentId) {
439 await refresh($)
440 void refreshPlan($)
441 }
442
443 return completed
444 })
445
446 // Every tool call shows live while it runs and counts for the session.
447 on('tool.call', async ($, e, next) => {
448 await noteToolStart($, { id: e.tool_use_id, tool: e.tool, agentId: e.agentId ?? null, startedAt: await $.clock.now() })
449 try {
450 const ran = await next(e)
451 if (ran.isError === true) {
452 const snap = await read($, snapshot)
453 await update($, tools, counts => countError(counts, e.tool, snap?.startedAt ?? 0))
454 }
455 return ran
456 } finally {
457 await update($, live, list => list.filter(t => t.id !== e.tool_use_id))
458 }
459 }).catch(($, e, next) => next(e))
460
461 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
462 const ran = await next(e)
463 const id = field(ran.result, 'backgroundTaskId')
464 if (id !== undefined) {
465 const shell: ShellStat = {
466 id,
467 command: e.command,
468 description: e.description ?? null,
469 agentId: e.agentId ?? null,
470 status: 'running',
471 startedAt: await $.clock.now(),
472 endedAt: null,
473 }
474 await update($, shells, list => addShell(list, shell))
475 }
476
477 return ran
478 }).catch(($, e, next) => next(e))
479
480 // A foreground agent ends with its call; a background one shows in the roster shortly after.
481 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
482 $.clock.after(2000, () => void refresh($))
483 const ran = await next(e)
484 const agentId = field(ran.result, 'agentId')
485 if (agentId !== undefined && field(ran.result, 'status') === 'completed') await noteEnded($, agentId, 'completed')
486
487 return ran
488 }).catch(($, e, next) => next(e))
489
490 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
491 const ran = await next(e)
492 const id = e.task_id ?? e.shell_id
493 if (id !== undefined && ran.isError !== true && ran.deny === undefined) await noteEnded($, id, 'killed')
494
495 return ran
496 }).catch(($, e, next) => next(e))
497
498 // A background shell or agent reports its end to the model as a task notification.
499 on('prompt.submit', async ($, e, next) => {
500 if (e.origin.kind === 'task-notification') {
501 for (const ended of parseNotification(e.text)) await noteEnded($, ended.id, ended.status)
502 }
503
504 return next(e)
505 }).catch(($, e, next) => next(e))
506
507 on('session.compact', async ($, e, next) => {
508 const result = await next(e)
509 if (e.trigger === 'precompute' || e.agentId || result.messages === undefined) return result
510
511 const [now, snap] = await Promise.all([$.clock.now(), read($, snapshot)])
512 const startedAt = snap?.startedAt ?? 0
513 await update($, compactions, c => ({
514 since: startedAt,
515 count: (c?.since === startedAt ? c.count : 0) + 1,
516 before: result.tokensBefore ?? null,
517 after: result.tokensAfter ?? null,
518 at: now,
519 trigger: e.trigger,
520 }))
521 // The window and the threshold may have moved: read them again on the next refresh.
522 breakdownAt = 0
523
524 return result
525 }).catch(($, e, next) => next(e))
526
527 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
528 if (e.props.hasSurvey) return next(e)
529
530 const canvas = { els: $.ui.resolve(e), surface: e.surface }
531 const input = await gather($, canvas, e.props.bodyColumns, e.props.maxRows, e.props.isWorking, cacheTtlMs)
532 if (input === null) return next(e)
533
534 return drawLevel(input, asLevel(await read($, view)), MAX_BAND_ROWS)
535 })
536
537 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
538 const canvas = { els: $.ui.resolve(e), surface: e.surface }
539 const input = await gather($, canvas, e.props.bodyColumns, 1000, false, cacheTtlMs)
540 if (input === null) return <canvas.els.Text dimColor>{'Waiting for the first measurement…'}</canvas.els.Text>
541
542 return drawAll({ ...input, isWorking: input.live.length > 0 })
543 })
544
545 on('ui.render', { component: 'Pane', requestId: REPORT_ID }, async ($, e) => {
546 const canvas = { els: $.ui.resolve(e), surface: e.surface }
547 const [now, past, problem, account] = await Promise.all([$.clock.now(), read($, history), read($, historyProblem), read($, plan)])
548
549 return drawReport(canvas, e.props.bodyColumns, now, past, problem, isReadingHistory, account)
550 })
551}
552hooks/band.tsx 324 lines1import type { RenderChildren } from 'claude-code'
2
3import type { View } from '../types'
4
5import { ago, cash, count, effortPips, limitShortLabel, money, prettyModel, until } from './format'
6import type { Part } from './format'
7import type { Forecast } from './forecast'
8import { agentsSection, contextSection, effortCell, shellsSection, tokensSection, toolsSection, usageSection } from './sections'
9import type { BandInput, Section } from './sections'
10import { ACCENT, CARD_CELLS, cellWidth, meter, meterChrome, parts } from './ui'
11
12export type { BandInput } from './sections'
13
14const LIMIT_ICONS: Record<string, string> = { five_hour: '⏳', seven_day: '📅' }
15const LIMIT_LABELS: Record<string, string> = { five_hour: '5H LIMIT', seven_day: 'WEEKLY' }
16
17/** A meter's bar never gets narrower than this; below it the meters go two to a row. */
18const MIN_BAR = 8
19const MAX_BAR = 24
20const METER_GAP = 3
21
22/** How soon a section gets rows when they are short: lower first. */
23const RANK = { tokens: 0, agents: 1, tools: 2, context: 3, usage: 4, shells: 5 }
24
25/** A terminal this wide splits the sections in two columns: numbers left, agents and work right. */
26const TWO_COLUMNS_FROM = 150
27const COLUMN_GAP = 2
28
29type Item = { label: string; percent: number | null; detail: string; severity?: string }
30
31/** The mark of the limit the server names as the one a single-value indicator shows. */
32const ACTIVE = '▸'
33
34/**
35 * The account's other usage meters: each of the server's other rows (a model's own weekly limit)
36 * and usage credits while they are on or spent. With `isEvery` false, the band's, a row shows only
37 * once it counts: used, graded above normal, or the active one.
38 */
39const accountItems = (input: BandInput, isEvery: boolean): Item[] => {
40 const plan = input.plan
41 if (plan === null) return []
42 const rows = plan.rows
43 .filter(r => isEvery || r.percent > 0 || r.isActive || r.severity !== 'normal')
44 .map(r => ({
45 label: `${r.isActive ? ACTIVE : ''}${r.kind.startsWith('weekly') ? '📅' : '⏳'} ${r.label.toUpperCase()}`,
46 percent: r.percent,
47 detail: r.resetsAt ? `↻ ${until(r.resetsAt, input.now)}` : '',
48 severity: r.severity,
49 }))
50 const c = plan.credits
51 if (c === null) return rows
52 const spent = c.limit === null ? `${cash(c.used, c.currency)} · no cap` : `${cash(c.used, c.currency)}/${cash(c.limit, c.currency)}`
53 const percent = c.limit === null || c.limit <= 0 ? null : Math.round((c.used / c.limit) * 100)
54 return [...rows, { label: '💳 CREDITS', percent, detail: `${spent}${c.isOn ? '' : ' · off'}` }]
55}
56
57/** Context, the road to auto-compaction, and the plan limits: every one a bar. */
58const meterItems = (input: BandInput, isEvery: boolean): Item[] => {
59 const { snap, compactions: c } = input
60 const done = c !== null && c.since === snap.startedAt ? c.count : 0
61 const compactAt = snap.autoCompactAt
62 const toCompact =
63 compactAt === null || snap.contextTokens === null
64 ? null
65 : { percent: Math.min(100, Math.round((snap.contextTokens / compactAt) * 100)), left: compactAt - snap.contextTokens }
66 const compactDetail =
67 compactAt === null ? 'auto off' : toCompact === null ? `at ${count(compactAt)}` : toCompact.left > 0 ? `${count(toCompact.left)} to go` : 'due'
68 return [
69 {
70 label: '⛽ CTX',
71 percent: snap.contextPercent,
72 detail: snap.contextTokens === null ? '' : `${count(snap.contextTokens)}/${count(snap.contextWindow)}`,
73 },
74 { label: '🗜 COMPACT', percent: toCompact?.percent ?? null, detail: `${compactDetail}${done > 0 ? ` · ×${done}` : ''}` },
75 ...snap.limits.map(l => ({
76 label: `${l.isActive === true ? ACTIVE : ''}${LIMIT_ICONS[l.kind] ?? '⏳'} ${LIMIT_LABELS[l.kind] ?? limitShortLabel(l.kind)}`,
77 percent: l.percent,
78 detail: l.resetsAt ? `↻ ${until(l.resetsAt, input.now)}` : '',
79 severity: l.severity,
80 })),
81 ...accountItems(input, isEvery),
82 ]
83}
84
85/** The meters in as few rows as keep every bar at least MIN_BAR wide: all in one, or two a row. */
86const meterRows = (items: Item[], room: number) => {
87 const fit = (perRow: number) => {
88 const rows = Array.from({ length: Math.ceil(items.length / perRow) }, (_, i) => items.slice(i * perRow, (i + 1) * perRow))
89 const widest = Math.max(...rows.map(r => r.reduce((s, m) => s + meterChrome(m.label, m.detail), 0) + METER_GAP * (r.length - 1)))
90 return { rows, bar: Math.max(4, Math.min(MAX_BAR, Math.floor((room - widest) / perRow))) }
91 }
92 const all = fit(items.length)
93 return all.bar >= MIN_BAR || items.length <= 2 ? all : fit(2)
94}
95
96const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
97
98/** A moment as a glance reads it: `14:05` today, `Thu 14:05` further off. */
99const clockTime = (ms: number, now: number) => {
100 const d = new Date(ms)
101 const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
102 return ms - now < 20 * 3_600_000 && d.getDate() === new Date(now).getDate() ? hhmm : `${WEEKDAYS[d.getDay()] ?? ''} ${hhmm}`
103}
104
105/** Each limit at this pace: when it runs out before its reset, or where it stands at the reset. */
106const forecastParts = (forecasts: Forecast[], now: number): Part[] =>
107 forecasts.map(f => {
108 const name = LIMIT_LABELS[f.kind] ?? limitShortLabel(f.kind)
109 const pace = f.pace === 'recent' ? 'last hour' : 'window avg'
110 return f.outAt === null
111 ? { text: `${name} ≈ ${f.atReset}% at reset ✓ (${pace})`, color: f.atReset >= 80 ? 'warning' : 'success' }
112 : { text: `${name} out at ${clockTime(f.outAt, now)}, reset ${clockTime(f.resetAt, now)} ⚠ (${pace})`, emphasis: 'warning' as const }
113 })
114
115/** The top box: model, effort, where, session, cost; then the meters, every one of them with `isEvery`. */
116const vitals = (input: BandInput, room: number, isEvery = false) => {
117 const { Box, Text } = input.canvas.els
118 const { snap } = input
119 const where =
120 `📁 ${snap.dir}` +
121 (snap.branch === null ? '' : ` 🌿 ${snap.branch}${snap.isWorktree ? ' 🌳' : ''}`) +
122 `${snap.ahead ? ` ↑${snap.ahead}` : ''}${snap.behind ? ` ↓${snap.behind}` : ''}${snap.changed ? ` ●${snap.changed}` : ''}`
123 const ageMs = input.now - snap.startedAt
124 const burn = snap.costUsd !== null && ageMs >= 5 * 60_000 ? ` 🔥 ${money(snap.costUsd / (ageMs / 3_600_000))}/h` : ''
125 const level = effortCell(input.effort)
126 const inner = room - CARD_CELLS
127 const { rows, bar } = meterRows(meterItems(input, isEvery), inner)
128 const ahead = forecastParts(input.forecasts, input.now)
129 const pips = input.effort === null ? '' : ` ${effortPips(input.effort)}`
130 const title = `◆ VITALS 🧠 ${prettyModel(snap.model)} ⚡ ${level.text.toUpperCase()}${pips}`
131 const session = `${where} ⏳ ${ago(ageMs)} · ${snap.prompts} prompt${snap.prompts === 1 ? '' : 's'} `
132 const cost = snap.costUsd === null ? '' : `💸 ${money(snap.costUsd)}`
133 // Where the title and the session do not fit side by side, the session takes a row of its own.
134 const isStacked = cellWidth(title) + 2 + cellWidth(session + cost + burn) > inner
135 // The forecast wraps under its label; count the rows it takes.
136 const aheadWidth = ahead.reduce((sum, a, i) => sum + cellWidth(a.text) + (i > 0 ? 3 : 0), 0)
137 const aheadRows = ahead.length === 0 ? 0 : Math.max(1, Math.ceil(aheadWidth / Math.max(1, inner - 13)))
138 const sessionRow = (
139 <Box flexDirection="row" marginLeft={isStacked ? 0 : 2} flexShrink={1}>
140 <Box flexShrink={1}>
141 <Text dimColor wrap="truncate-end">{session}</Text>
142 </Box>
143 {cost !== '' && (
144 <Box flexShrink={0} marginLeft={1}>
145 <Text bold>{cost}</Text>
146 </Box>
147 )}
148 {burn !== '' && (
149 <Box flexShrink={0}>
150 <Text dimColor>{burn}</Text>
151 </Box>
152 )}
153 </Box>
154 )
155 return {
156 rows: 2 + (isStacked ? 2 : 1) + rows.length + aheadRows,
157 box: (
158 <Box flexDirection="column" width={room} borderStyle="round" borderColor={ACCENT} paddingX={1}>
159 <Box flexDirection={isStacked ? 'column' : 'row'} justifyContent="space-between">
160 <Box flexDirection="row" flexShrink={0}>
161 <Text bold color={ACCENT}>{'◆ VITALS'}</Text>
162 <Text>{' 🧠 '}</Text>
163 <Text bold>{prettyModel(snap.model)}</Text>
164 <Text>{' ⚡ '}</Text>
165 <Text bold color={level.color} dimColor={level.dim}>{level.text.toUpperCase()}</Text>
166 {pips !== '' && <Text color={level.color}>{pips}</Text>}
167 </Box>
168 {sessionRow}
169 </Box>
170 {rows.map(row => (
171 <Box flexDirection="row">
172 {row.map((m, i) => (
173 <Box flexDirection="row">
174 {i > 0 && <Text dimColor>{' │ '}</Text>}
175 {meter(input.canvas, m.label, m.percent, m.detail, bar, m.severity)}
176 </Box>
177 ))}
178 </Box>
179 ))}
180 {ahead.length > 0 && (
181 <Box flexDirection="row">
182 <Box width={13} flexShrink={0}>
183 <Text bold color={ACCENT}>{'🔮 FORECAST'}</Text>
184 </Box>
185 {parts(input.canvas.els, ahead)}
186 </Box>
187 )}
188 </Box>
189 ),
190 }
191}
192
193type Placed = { section: Section | null; rank: number }
194
195/**
196 * Lays sections out in the rows there are, never scrolling. In rank order, every section gets
197 * one line first, then grows to its box while it fits; a section that does not get even its line
198 * is left out. They are drawn in the order given, whatever their rank.
199 */
200const layout = (input: BandInput, placed: Placed[], budget: number) => {
201 const { Box } = input.canvas.els
202 const present = placed.filter((p): p is { section: Section; rank: number } => p.section !== null)
203 const byRank = [...present].sort((a, b) => a.rank - b.rank)
204 const kept = new Set<Section>(byRank.slice(0, Math.max(0, budget)).map(p => p.section))
205 let left = budget - kept.size
206 const isFull = new Set<Section>()
207 for (const p of byRank) {
208 const extra = p.section.fullRows - 1
209 if (!kept.has(p.section) || extra > left) continue
210 isFull.add(p.section)
211 left -= extra
212 }
213 return (
214 <Box flexDirection="column">
215 {present.filter(p => kept.has(p.section)).map(p => (isFull.has(p.section) ? p.section.full() : p.section.mini()))}
216 </Box>
217 )
218}
219
220/** The numbers: tokens, what fills the context, and usage. */
221const numbers = (input: BandInput, room: number): Placed[] => [
222 { section: tokensSection(input, room), rank: RANK.tokens },
223 { section: contextSection(input, room), rank: RANK.context },
224 { section: usageSection(input, room), rank: RANK.usage },
225]
226
227/** The work: agents on top, then the tools and the shells; tables ask for what they could show. */
228const work = (input: BandInput, room: number, rows: number): Placed[] => [
229 { section: agentsSection(input, room, Math.max(1, rows - 12), 'running'), rank: RANK.agents },
230 { section: toolsSection(input, room, Math.max(1, Math.min(8, rows - 12))), rank: RANK.tools },
231 { section: shellsSection(input, room, Math.max(1, rows - 16), 'running'), rank: RANK.shells },
232]
233
234/** The dashboard: the vitals box on top, then the section boxes, sized to the rows the band has. */
235export const drawBand = (input: BandInput, maxBandRows: number) => {
236 const { Box } = input.canvas.els
237 const top = vitals(input, input.room)
238 const budget = Math.min(input.rows, maxBandRows) - top.rows
239 let body: RenderChildren
240 if (input.room < TWO_COLUMNS_FROM) {
241 const [tokens, context, usage] = numbers(input, input.room)
242 const none = { section: null, rank: 0 }
243 body = layout(input, [tokens ?? none, ...work(input, input.room, budget - 10), context ?? none, usage ?? none], budget)
244 } else {
245 const leftRoom = Math.floor((input.room - COLUMN_GAP) / 2)
246 const rightRoom = input.room - COLUMN_GAP - leftRoom
247 body = (
248 <Box flexDirection="row" columnGap={COLUMN_GAP}>
249 <Box width={leftRoom} flexShrink={0}>
250 {layout(input, numbers(input, leftRoom), budget)}
251 </Box>
252 <Box width={rightRoom} flexShrink={0}>
253 {layout(input, work(input, rightRoom, budget), budget)}
254 </Box>
255 </Box>
256 )
257 }
258 return (
259 <Box flexDirection="column">
260 {top.box}
261 {body}
262 </Box>
263 )
264}
265
266/** Every section in its box, for the pane, which scrolls. */
267export const drawAll = (input: BandInput) => {
268 const { Box } = input.canvas.els
269 const sections = [
270 tokensSection(input, input.room),
271 contextSection(input, input.room),
272 agentsSection(input, input.room, 40, 'all'),
273 toolsSection(input, input.room, 40),
274 shellsSection(input, input.room, 40, 'all'),
275 usageSection(input, input.room),
276 ]
277 return (
278 <Box flexDirection="column">
279 {vitals(input, input.room, true).box}
280 {sections.map(s => (s === null ? null : s.full()))}
281 </Box>
282 )
283}
284
285/** The medium dashboard: the vitals box, what fills the context, and the agents. */
286const drawMedium = (input: BandInput, maxBandRows: number) => {
287 const { Box } = input.canvas.els
288 const top = vitals(input, input.room)
289 const budget = Math.min(input.rows, maxBandRows) - top.rows
290 const placed = (contextRoom: number, agentsRoom: number) => ({
291 context: { section: contextSection(input, contextRoom), rank: 1 },
292 agents: { section: agentsSection(input, agentsRoom, Math.max(1, budget - 6), 'running'), rank: 0 },
293 })
294 let body: RenderChildren
295 if (input.room < TWO_COLUMNS_FROM) {
296 const { context, agents } = placed(input.room, input.room)
297 body = layout(input, [context, agents], budget)
298 } else {
299 const leftRoom = Math.floor((input.room - COLUMN_GAP) / 2)
300 const rightRoom = input.room - COLUMN_GAP - leftRoom
301 const { context, agents } = placed(leftRoom, rightRoom)
302 body = (
303 <Box flexDirection="row" columnGap={COLUMN_GAP}>
304 <Box width={leftRoom} flexShrink={0}>
305 {layout(input, [context], budget)}
306 </Box>
307 <Box width={rightRoom} flexShrink={0}>
308 {layout(input, [agents], budget)}
309 </Box>
310 </Box>
311 )
312 }
313 return (
314 <Box flexDirection="column">
315 {top.box}
316 {body}
317 </Box>
318 )
319}
320
321/** The band at its detail level: low the vitals box alone, medium with context and agents, high everything. */
322export const drawLevel = (input: BandInput, level: View, maxBandRows: number) =>
323 level === 'low' ? vitals(input, input.room).box : level === 'medium' ? drawMedium(input, maxBandRows) : drawBand(input, maxBandRows)
324hooks/collect.ts 312 lines1import type { AgentInfo, ConfigRow, SessionContextBreakdown, SessionUsage } from 'claude-code'
2
3import type { AgentStat, Breakdown, ContextPart, Credits, Limit, LiveTool, PlanRow, PlanUsage, RunStatus, ShellStat, ToolCounts, Tokens } from '../types'
4import { NO_TOKENS, addTokens, isActive, isShown, limitLabel, notifiedStatus, runStatus, until } from './format'
5
6// Pure folds over the session's values: register.tsx reads the engine and writes the results.
7
8const KEEP = 40
9const WARN_AT = [95, 80]
10
11/** Where the session works: folder and git state, from `git status --porcelain=v2 --branch` and `rev-parse`. */
12export const placeOf = (cwd: string, gitStatus: string | null, gitDirs: string | null) => {
13 const lines = gitStatus?.split('\n') ?? []
14 const head = lines.find(l => l.startsWith('# branch.head '))?.slice(14)
15 const ab = lines.find(l => l.startsWith('# branch.ab '))?.match(/\+(\d+) -(\d+)/)
16 const [gitDir, commonDir] = gitDirs?.split('\n') ?? []
17 return {
18 dir: cwd.split('/').pop() || cwd,
19 branch: head && head !== '(detached)' ? head : null,
20 isWorktree: gitDir !== commonDir,
21 ahead: Number(ab?.[1] ?? 0),
22 behind: Number(ab?.[2] ?? 0),
23 changed: lines.filter(l => l && !l.startsWith('#')).length,
24 }
25}
26
27/**
28 * The meters, from a measurement (`session.measure`'s input or `$.session.usage()`): context fill
29 * against the auto-compact window when one smaller than the model's is known, as /context does.
30 */
31export const metersOf = (
32 measured: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>,
33 compactWindow: number | null,
34) => {
35 const { tokens, window: modelWindow } = measured.context
36 const contextWindow = compactWindow === null ? modelWindow : Math.min(modelWindow, compactWindow)
37 return {
38 contextPercent: tokens === undefined ? null : Math.round((tokens / contextWindow) * 100),
39 contextTokens: tokens ?? null,
40 contextWindow,
41 costUsd: measured.cost?.usd ?? null,
42 limits: measured.rateLimits.map(r => ({ kind: r.kind, percent: r.percentUsed, resetsAt: r.resetsAt ?? null })),
43 }
44}
45
46/** A field of an object in the endpoint's answer; undefined for anything else. */
47const field = (value: unknown, key: string): unknown => (typeof value === 'object' && value !== null ? Reflect.get(value, key) : undefined)
48
49const numberAt = (value: unknown, key: string) => {
50 const n = field(value, key)
51 return typeof n === 'number' && Number.isFinite(n) ? n : null
52}
53
54const textAt = (value: unknown, key: string) => {
55 const text = field(value, key)
56 return typeof text === 'string' ? text : null
57}
58
59/** A time the endpoint spells to the microsecond, as `Date` spells it; null when unreadable. */
60const isoOf = (text: string | null) => {
61 const at = text === null ? NaN : Date.parse(text)
62 return Number.isFinite(at) ? new Date(at).toISOString() : null
63}
64
65/** A percentage to one decimal, as the headers give it. */
66const tenths = (n: number) => Math.round(n * 10) / 10
67
68/** The windows the headers report too, each with the kind of the server's usage row that grades it. */
69const PLAN_WINDOWS = [
70 { kind: 'five_hour', row: 'session' },
71 { kind: 'seven_day', row: 'weekly_all' },
72]
73
74/**
75 * Usage credits from the answer's `extra_usage`, whose amounts are in the currency's minor units:
76 * null while they are off and nothing has been spent on them.
77 */
78const creditsOf = (extra: unknown): Credits | null => {
79 const used = numberAt(extra, 'used_credits') ?? 0
80 const isOn = field(extra, 'is_enabled') === true
81 if (!isOn && used <= 0) return null
82 const places = numberAt(extra, 'decimal_places')
83 const unit = 10 ** (places !== null && Number.isInteger(places) && places >= 0 && places <= 4 ? places : 2)
84 const limit = numberAt(extra, 'monthly_limit')
85 return { isOn, used: used / unit, limit: limit === null ? null : limit / unit, currency: (textAt(extra, 'currency') ?? 'USD').toUpperCase() }
86}
87
88/** The weekly limit's shares by product from the answer's `seven_day_breakdown`; null without rows. */
89const breakdownOf = (value: unknown): Breakdown | null => {
90 const rows = field(value, 'rows')
91 const shares = (Array.isArray(rows) ? rows : []).flatMap(r => {
92 const name = textAt(r, 'display_name') ?? textAt(r, 'key')
93 const percent = numberAt(r, 'percent')
94 return name === null || percent === null ? [] : [{ name, percent: tenths(percent) }]
95 })
96 return shares.length === 0 ? null : { asOf: isoOf(textAt(value, 'as_of')), rows: shares }
97}
98
99/**
100 * The account's usage in an answer of `/api/oauth/usage`, what claude.ai's usage page and `/usage`
101 * show: null for an answer that is not a JSON object. The 5-hour and weekly windows carry the grade
102 * of their usage row; the other rows (a model's own weekly limit) come as the server sends them,
103 * classified by kind, never by label. A window or a row the answer leaves out or nulls is left out.
104 */
105export const parsePlanUsage = (text: string): Omit<PlanUsage, 'at'> | null => {
106 let body: unknown
107 try {
108 body = JSON.parse(text)
109 } catch {
110 return null
111 }
112 if (typeof body !== 'object' || body === null || Array.isArray(body)) return null
113 const listed = field(body, 'limits')
114 const usageRows: unknown[] = Array.isArray(listed) ? listed : []
115 const shares = field(body, 'weekly_scoped_shares')
116 const ofWeekly = (index: number) =>
117 numberAt(Array.isArray(shares) ? shares.find(share => numberAt(share, 'limit_index') === index) : undefined, 'allowance_percent_of_weekly')
118 const limits = PLAN_WINDOWS.flatMap(({ kind, row }): Limit[] => {
119 const window = field(body, kind)
120 const percent = numberAt(window, 'utilization')
121 if (percent === null) return []
122 const graded = usageRows.find(r => textAt(r, 'kind') === row)
123 const severity = textAt(graded, 'severity')
124 const active = field(graded, 'is_active')
125 return [
126 {
127 kind,
128 percent: tenths(percent),
129 resetsAt: isoOf(textAt(window, 'resets_at')),
130 ...(severity === null ? {} : { severity }),
131 ...(typeof active === 'boolean' ? { isActive: active } : {}),
132 },
133 ]
134 })
135 const windowRows = new Set(PLAN_WINDOWS.map(w => w.row))
136 const rows = usageRows.flatMap((r, index): PlanRow[] => {
137 const kind = textAt(r, 'kind')
138 const percent = numberAt(r, 'percent')
139 if (kind === null || percent === null || windowRows.has(kind)) return []
140 const scope = field(r, 'scope')
141 return [
142 {
143 kind,
144 label: textAt(field(scope, 'model'), 'display_name') ?? textAt(field(scope, 'surface'), 'display_name') ?? kind,
145 percent: tenths(percent),
146 resetsAt: isoOf(textAt(r, 'resets_at')),
147 severity: textAt(r, 'severity') ?? 'normal',
148 isActive: field(r, 'is_active') === true,
149 ofWeekly: ofWeekly(index),
150 },
151 ]
152 })
153 return { limits, rows, credits: creditsOf(field(body, 'extra_usage')), breakdown: breakdownOf(field(body, 'seven_day_breakdown')) }
154}
155
156/** Two readings of one window end within this of each other; two windows of a kind end hours apart. */
157const SAME_WINDOW_MS = 60 * 60_000
158
159const endOf = (limit: Limit) => {
160 const end = limit.resetsAt === null ? NaN : Date.parse(limit.resetsAt)
161 return Number.isFinite(end) ? end : null
162}
163
164/** A limit's grade from the server, as fields to spread: none where it has not graded it. */
165const gradeOf = (limit: Limit) => ({
166 ...(limit.severity === undefined ? {} : { severity: limit.severity }),
167 ...(limit.isActive === undefined ? {} : { isActive: limit.isActive }),
168})
169
170/**
171 * One limit from two readings of it, `b` the usage endpoint's. Within a window usage only grows, so
172 * of two readings of the same window the higher is the truth, whichever source took it and when,
173 * graded as the endpoint graded that window; a window that has ended loses to one that has not, an
174 * earlier window to a later one, an unknown end to a known one.
175 */
176const pickLimit = (a: Limit, b: Limit, now: number): Limit => {
177 const [endA, endB] = [endOf(a), endOf(b)]
178 const [isOverA, isOverB] = [endA !== null && endA <= now, endB !== null && endB <= now]
179 if (isOverA !== isOverB) return isOverA ? b : a
180 if (endA === null && endB !== null) return b
181 if (endB === null && endA !== null) return a
182 if (endA !== null && endB !== null && Math.abs(endA - endB) >= SAME_WINDOW_MS) return endA > endB ? a : b
183 const higher = a.percent >= b.percent ? a : b
184 const graded = b.severity !== undefined || b.isActive !== undefined ? b : a
185 return { kind: higher.kind, percent: higher.percent, resetsAt: higher.resetsAt, ...gradeOf(graded) }
186}
187
188/**
189 * The plan limits from this session's last API response and the account's usage endpoint, one per
190 * window. Each end is set to the minute, so one window reads the same from either source (the
191 * headers spell it in whole seconds, the endpoint to the microsecond) and its toasts raise once.
192 */
193export const mergeLimits = (measured: Limit[], plan: Limit[], now: number): Limit[] =>
194 [...new Set([...measured, ...plan].map(l => l.kind))].flatMap(kind => {
195 const [a, b] = [measured.find(l => l.kind === kind), plan.find(l => l.kind === kind)]
196 const limit = a !== undefined && b !== undefined ? pickLimit(a, b, now) : (a ?? b)
197 if (limit === undefined) return []
198 const end = endOf(limit)
199 return [{ ...limit, resetsAt: end === null ? limit.resetsAt : new Date(Math.round(end / 60_000) * 60_000).toISOString() }]
200 })
201
202/** The rows of a `/context` breakdown that take room in the window: deferred tool schemas do not. */
203export const contextPartsOf = (breakdown: SessionContextBreakdown | undefined): ContextPart[] | null =>
204 breakdown === undefined
205 ? null
206 : breakdown.categories.flatMap(c =>
207 c.kind === 'deferred' || c.tokens <= 0 ? [] : [{ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }],
208 )
209
210/** The compaction window and threshold a `/context` breakdown reports, when auto-compaction is on. */
211export const compactionOf = (breakdown: SessionContextBreakdown | undefined) => ({
212 compactWindow: breakdown?.isAutoCompactEnabled ? breakdown.rawMaxTokens : null,
213 autoCompactAt: breakdown?.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null,
214})
215
216/** The plan-limit toasts not raised yet in their window: a key to remember and the text to show. */
217export const limitWarnings = (limits: Limit[], seen: string[], now: number) =>
218 limits.flatMap(limit => {
219 const threshold = WARN_AT.find(t => limit.percent >= t)
220 const key = `${limit.kind}@${threshold}@${limit.resetsAt}`
221 if (threshold === undefined || seen.includes(key)) return []
222 const resets = limit.resetsAt ? ` · resets in ${until(limit.resetsAt, now)}` : ''
223 return [{ key, text: `${limitLabel(limit.kind)} usage limit at ${limit.percent}%${resets}` }]
224 })
225
226/** The /config effort row's value, read until the first request reports the effort it used. */
227export const configuredEffort = (rows: ConfigRow[]) => {
228 const row = rows.find(r => /effort/i.test(r.key))
229 return typeof row?.value === 'string' ? row.value : null
230}
231
232const newAgent = (id: string, now: number): AgentStat => ({
233 id,
234 description: '',
235 type: 'agent',
236 model: null,
237 effort: null,
238 status: 'running',
239 startedAt: now,
240 endedAt: null,
241 tokens: NO_TOKENS,
242 tools: 0,
243})
244
245/** Applies `patch` to the agent `id`, adding it first when the band has not seen it yet. */
246export const withAgent = (list: AgentStat[], id: string, now: number, patch: (a: AgentStat) => AgentStat) => {
247 const found = list.find(a => a.id === id)
248 if (found) return list.map(a => (a.id === id ? patch(a) : a))
249 return [...list, patch(newAgent(id, now))].slice(-KEEP)
250}
251
252export const settle = <T extends { status: RunStatus; endedAt: number | null }>(run: T, status: RunStatus, now: number): T => ({
253 ...run,
254 status,
255 endedAt: isActive(status) ? null : (run.endedAt ?? now),
256})
257
258/** The engine's roster folded in: names, types and statuses; one it no longer lists has ended. */
259export const mergeRoster = (list: AgentStat[], roster: AgentInfo[], now: number) => {
260 let next = list
261 for (const info of roster) {
262 next = withAgent(next, info.id, now, a =>
263 settle({ ...a, description: info.description || a.description, type: info.type }, runStatus(info.status), now),
264 )
265 }
266 const listed = new Set(roster.map(r => r.id))
267 return next.map(a => (isActive(a.status) && !listed.has(a.id) ? settle(a, 'completed', now) : a))
268}
269
270/** A subagent's model request: the model and effort it runs on, and it runs again. */
271export const agentStep = (list: AgentStat[], agentId: string, model: string, level: string | null, now: number) =>
272 withAgent(list, agentId, now, a => ({ ...settle(a, isActive(a.status) ? a.status : 'running', now), model, effort: level ?? a.effort }))
273
274export const agentTokens = (list: AgentStat[], agentId: string, used: Tokens, now: number) =>
275 withAgent(list, agentId, now, a => ({ ...a, tokens: addTokens(a.tokens, used) }))
276
277export const agentTool = (list: AgentStat[], agentId: string, now: number) =>
278 withAgent(list, agentId, now, a => ({ ...a, tools: a.tools + 1 }))
279
280/** One more call of `tool` in the session that started at `since`. */
281export const countTool = (counts: ToolCounts | null, tool: string, since: number): ToolCounts => {
282 const list = counts?.since === since ? counts.counts : []
283 const found = list.find(c => c.tool === tool)
284 return {
285 since,
286 counts: found ? list.map(c => (c === found ? { ...c, count: c.count + 1 } : c)) : [...list, { tool, count: 1, errors: 0 }],
287 }
288}
289
290/** One more failed call of `tool`, counted when its result came back as an error. */
291export const countError = (counts: ToolCounts | null, tool: string, since: number): ToolCounts | null =>
292 counts?.since === since ? { since, counts: counts.counts.map(c => (c.tool === tool ? { ...c, errors: (c.errors ?? 0) + 1 } : c)) } : counts
293
294export const startLive = (list: LiveTool[], call: LiveTool) => [...list.filter(t => t.id !== call.id), call].slice(-20)
295
296export const addShell = (list: ShellStat[], shell: ShellStat) => [...list.filter(s => s.id !== shell.id), shell].slice(-KEEP)
297
298/** Ends the run `id` (a shell or an agent) with `status`. */
299export const endRun = <T extends { id: string; status: RunStatus; endedAt: number | null }>(list: T[], id: string, status: RunStatus, now: number) =>
300 list.map(r => (r.id === id ? settle(r, status, now) : r))
301
302/** The task ids and statuses a task notification's text carries. */
303export const parseNotification = (text: string) => {
304 const ids = [...text.matchAll(/<task-id>([^<]+)<\/task-id>/g)].map(m => m[1]!.trim())
305 const statuses = [...text.matchAll(/<status>([^<]+)<\/status>/g)].map(m => m[1]!.trim())
306 return ids.map((id, i) => ({ id, status: notifiedStatus(statuses[i] ?? 'completed') }))
307}
308
309/** Whether the band has something moving: a tool in flight, a run going, or one leaving after it ended. */
310export const isBusy = (live: LiveTool[], agents: AgentStat[], shells: ShellStat[], now: number) =>
311 live.length > 0 || agents.some(a => isShown(a, now)) || shells.some(s => isShown(s, now))
312hooks/forecast.ts 59 lines1import type { Limit } from '../types'
2
3// Where each plan limit will be at its reset, at the pace you are spending it now.
4
5/** How long each window lasts, so its start is its reset less this. */
6const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
7
8/** The recent pace needs this much history; with less, the window's average pace stands in. */
9const RECENT_MIN_MS = 10 * 60_000
10/** How far back the recent pace looks. */
11export const RECENT_MS = 60 * 60_000
12
13export type Sample = { at: number; percent: number }
14
15export type Forecast = {
16 kind: string
17 percent: number
18 resetAt: number
19 /** When the limit reaches 100% at this pace, if that comes before the reset; else null. */
20 outAt: number | null
21 /** Where the limit will stand at the reset at this pace. */
22 atReset: number
23 /** Which pace the forecast uses: the last hour's, or the window's average. */
24 pace: 'recent' | 'average'
25}
26
27/**
28 * The forecast for one limit: the last hour's pace when there is at least ten minutes of it
29 * within this window, else the average since the window began. Null for a window of unknown
30 * length or with no reset time.
31 */
32export const forecast = (limit: Limit, samples: Sample[], now: number): Forecast | null => {
33 const length = WINDOW_MS[limit.kind]
34 if (length === undefined || limit.resetsAt === null) return null
35 const resetAt = Date.parse(limit.resetsAt)
36 if (!Number.isFinite(resetAt) || resetAt <= now) return null
37 const start = resetAt - length
38 const recent = samples.filter(s => s.at >= Math.max(start, now - RECENT_MS))
39 const first = recent[0]
40 const hasRecent = first !== undefined && now - first.at >= RECENT_MIN_MS && limit.percent >= first.percent
41 const perMs = hasRecent ? (limit.percent - first.percent) / (now - first.at) : now > start ? limit.percent / (now - start) : 0
42 const outAt = perMs > 0 ? now + (100 - limit.percent) / perMs : null
43 return {
44 kind: limit.kind,
45 percent: limit.percent,
46 resetAt,
47 outAt: outAt !== null && outAt < resetAt && limit.percent < 100 ? outAt : limit.percent >= 100 ? now : null,
48 atReset: Math.min(999, Math.round(limit.percent + perMs * (resetAt - now))),
49 pace: hasRecent ? 'recent' : 'average',
50 }
51}
52
53/** A sample more for `kind`, keeping only what the recent pace can use. */
54export const addSample = (samples: Sample[], sample: Sample) => {
55 const kept = samples.filter(s => s.at >= sample.at - RECENT_MS)
56 const last = kept[kept.length - 1]
57 return last !== undefined && last.percent === sample.percent ? kept : [...kept, sample]
58}
59hooks/format.ts 149 lines1import type { ModelUsage } from 'claude-code'
2
3import type { RunStatus, Tokens } from '../types'
4
5export const NO_TOKENS: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
6
7export const toTokens = (u: ModelUsage): Tokens => ({
8 input: u.input_tokens,
9 output: u.output_tokens,
10 cacheRead: u.cache_read_input_tokens,
11 cacheWrite: u.cache_creation_input_tokens,
12})
13
14export const addTokens = (a: Tokens, b: Tokens): Tokens => ({
15 input: a.input + b.input,
16 output: a.output + b.output,
17 cacheRead: a.cacheRead + b.cacheRead,
18 cacheWrite: a.cacheWrite + b.cacheWrite,
19})
20
21/** Everything the requests sent and got back: uncached, cache read and written, output. */
22export const totalTokens = (t: Tokens) => t.input + t.cacheRead + t.cacheWrite + t.output
23
24// Share of the prompt the cache served: read over everything sent (uncached, written, read).
25export const hitRate = (t: Tokens) => {
26 const sent = t.input + t.cacheRead + t.cacheWrite
27 return sent === 0 ? null : Math.round((t.cacheRead / sent) * 100)
28}
29
30export const count = (n: number) =>
31 n >= 1_000_000_000
32 ? `${+(n / 1_000_000_000).toFixed(1)}B`
33 : n >= 1_000_000
34 ? `${+(n / 1_000_000).toFixed(1)}M`
35 : n >= 1000
36 ? `${+(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
37 : `${n}`
38
39/** A tool's short name: an MCP tool's own, without its server prefix (`mcp__srv__ctx_execute` → `ctx_execute`). */
40export const toolName = (tool: string) => (tool.startsWith('mcp__') ? (tool.split('__').pop() ?? tool) : tool)
41
42export const tone = (percent: number) => (percent >= 95 ? 'error' : percent >= 80 ? 'warning' : undefined)
43
44/** The server's grade of a usage row as a tone: `critical` red, `warning` amber, else none. */
45export const gradeTone = (severity: string | undefined) => (severity === 'critical' ? 'error' : severity === 'warning' ? 'warning' : undefined)
46
47/** A meter's tone: the graver of its own thresholds and the server's grade, which may know more. */
48export const meterTone = (percent: number, severity?: string) => {
49 const [own, server] = [tone(percent), gradeTone(severity)]
50 return own === 'error' || server === 'error' ? 'error' : own ?? server
51}
52
53const LABELS: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly' }
54const SHORT_LABELS: Record<string, string> = { five_hour: '5H', seven_day: '7D' }
55
56export const limitLabel = (kind: string) => LABELS[kind] ?? kind.replace(/_/g, ' ')
57export const limitShortLabel = (kind: string) => SHORT_LABELS[kind] ?? kind.replace(/_/g, ' ').toUpperCase()
58
59// `claude-opus-5-5[1m]` → `Opus 5.5 1M`; a display name such as `Opus 5.5` passes unchanged.
60export const prettyModel = (id: string) => {
61 const isLongContext = /\[1m\]$/i.test(id)
62 const bare = id.replace(/\[1m\]$/i, '').replace(/^claude-/, '').replace(/-\d{8}$/, '')
63 const [family = '', ...rest] = bare.split('-')
64 if (family === '') return id
65 const version = rest.filter(p => /^\d+$/.test(p)).join('.')
66 const name = family[0]!.toUpperCase() + family.slice(1)
67 return `${name}${version ? ` ${version}` : ''}${isLongContext ? ' 1M' : ''}`
68}
69
70export const until = (iso: string, now: number) => {
71 const minutes = Math.max(0, Math.round((Date.parse(iso) - now) / 60_000))
72 if (minutes < 60) return `${minutes}m`
73 if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
74 return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`
75}
76
77export const elapsed = (ms: number) => {
78 const s = Math.max(0, Math.round(ms / 1000))
79 if (s < 60) return `${s}s`
80 if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
81 return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
82}
83
84export const ago = (ms: number) => {
85 const minutes = Math.floor(ms / 60_000)
86 if (minutes < 1) return '<1m'
87 if (minutes < 60) return `${minutes}m`
88 if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
89 return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`
90}
91
92const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
93
94/** The glyph and colour of a run's status; a running one spins with the band's tick. */
95export const statusMark = (status: RunStatus, tick: number): { glyph: string; color?: string } => {
96 if (status === 'running') return { glyph: SPINNER[tick % SPINNER.length]!, color: 'claude' }
97 if (status === 'waiting') return { glyph: '◷', color: 'suggestion' }
98 if (status === 'completed') return { glyph: '✓', color: 'success' }
99 if (status === 'failed') return { glyph: '✗', color: 'error' }
100 return { glyph: '■', color: 'warning' }
101}
102
103export const isActive = (status: RunStatus) => status === 'running' || status === 'waiting'
104
105/** How long a run that has ended stays in the band, its ✓ or ✗ on show, before it leaves. */
106export const LINGER_MS = 3000
107
108/** Whether the band still shows a run: while it runs, and for LINGER_MS after it ends. */
109export const isShown = (run: { status: RunStatus; endedAt: number | null }, now: number) =>
110 isActive(run.status) || (run.endedAt !== null && now - run.endedAt < LINGER_MS)
111
112/** The engine's agent statuses folded into the band's five. */
113export const runStatus = (status: string): RunStatus => {
114 if (status === 'completed' || status === 'failed' || status === 'killed' || status === 'waiting') return status
115 if (status === 'idle') return 'waiting'
116 return 'running'
117}
118
119/** A task notification's status word (`completed`, `failed`, `killed`, `stopped`, ...). */
120export const notifiedStatus = (status: string): RunStatus =>
121 status === 'completed' ? 'completed' : status === 'killed' || status === 'stopped' ? 'killed' : status === 'running' ? 'running' : 'failed'
122
123/** The last segment of a plugin-scoped agent type: `pr-review:code-reviewer` → `code-reviewer`. */
124export const shortType = (type: string) => type.split(':').pop() || type
125
126/** One line of a shell command, its whitespace folded. */
127export const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
128
129/** Dollars as a glance reads them: `$9.23`, `$118`, `$4.4k`. */
130export const money = (usd: number) =>
131 usd >= 1000 ? `$${+(usd / 1000).toFixed(1)}k` : usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`
132
133/** An amount in its currency: dollars as `money` writes them, any other with its code (`12.50 EUR`). */
134export const cash = (amount: number, currency: string) => (currency === 'USD' ? money(amount) : `${amount.toFixed(2)} ${currency}`)
135
136const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
137
138/** The effort as pips out of five: `high` → `▰▰▰▱▱`; a numeric budget draws none. */
139export const effortPips = (level: string) => {
140 const i = LEVELS.indexOf(level)
141 return i < 0 ? '' : '▰'.repeat(i + 1) + '▱'.repeat(LEVELS.length - i - 1)
142}
143
144/** A change as an arrow and a signed percentage, or empty with nothing to compare against. */
145export const delta = (percent: number | null) =>
146 percent === null ? '' : percent > 0 ? `▲ +${percent}%` : percent < 0 ? `▼ ${percent}%` : '= 0%'
147
148export type Part ={ text: string; color?: string; emphasis?: 'warning' | 'strong' | 'plain' }
149hooks/report.ts 169 lines1import type { DayUsage, ModelDay } from '../types'
2
3// Usage history from `ccusage claude daily --json`: parsed defensively (it is another program's
4// output), then folded into today, this week and this month against the period before.
5
6const num = (o: object, key: string) => {
7 const v: unknown = Reflect.get(o, key)
8 return typeof v === 'number' && Number.isFinite(v) ? v : 0
9}
10
11const str = (o: object, key: string) => {
12 const v: unknown = Reflect.get(o, key)
13 return typeof v === 'string' ? v : null
14}
15
16const isObject = (v: unknown): v is object => typeof v === 'object' && v !== null
17
18const parseModels = (raw: unknown): ModelDay[] =>
19 Array.isArray(raw)
20 ? raw.filter(isObject).flatMap(m => {
21 const model = str(m, 'modelName')
22 if (model === null) return []
23 const tokens = num(m, 'inputTokens') + num(m, 'outputTokens') + num(m, 'cacheReadTokens') + num(m, 'cacheCreationTokens')
24 return [{ model, costUsd: num(m, 'cost'), tokens }]
25 })
26 : []
27
28/** The days of a ccusage daily report, or null when the text is not one. */
29export const parseDaily = (text: string): DayUsage[] | null => {
30 let data: unknown
31 try {
32 data = JSON.parse(text)
33 } catch {
34 return null
35 }
36 if (!isObject(data)) return null
37 const daily: unknown = Reflect.get(data, 'daily')
38 if (!Array.isArray(daily)) return null
39 return daily.filter(isObject).flatMap(d => {
40 const date = str(d, 'date') ?? str(d, 'period')
41 if (date === null || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return []
42 return [
43 {
44 date,
45 costUsd: num(d, 'totalCost'),
46 tokens: num(d, 'totalTokens'),
47 input: num(d, 'inputTokens'),
48 output: num(d, 'outputTokens'),
49 cacheRead: num(d, 'cacheReadTokens'),
50 cacheWrite: num(d, 'cacheCreationTokens'),
51 models: parseModels(Reflect.get(d, 'modelBreakdowns')),
52 },
53 ]
54 })
55}
56
57const pad = (n: number) => String(n).padStart(2, '0')
58
59/** The local calendar day of `ms`, as ccusage groups by default: `2026-10-08`. */
60export const localDate = (ms: number) => {
61 const d = new Date(ms)
62 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
63}
64
65/** `date` moved by `n` days, in calendar terms (noon keeps a DST shift off the date). */
66export const addDays = (date: string, n: number) => {
67 const [y = 0, m = 1, d = 1] = date.split('-').map(Number)
68 return localDate(new Date(y, m - 1, d + n, 12).getTime())
69}
70
71/** The Monday that starts `date`'s week. */
72export const weekStart = (date: string) => {
73 const [y = 0, m = 1, d = 1] = date.split('-').map(Number)
74 const weekday = (new Date(y, m - 1, d, 12).getDay() + 6) % 7
75 return addDays(date, -weekday)
76}
77
78/** The first day of `date`'s month, and of the month before. */
79export const monthStart = (date: string) => `${date.slice(0, 7)}-01`
80export const previousMonthStart = (date: string) => monthStart(addDays(monthStart(date), -1))
81
82/** Whole weeks the report shows, this one included. */
83export const REPORT_WEEKS = 6
84
85/** The ccusage `--since` that covers this month, the one before and the report's weeks: `20260831`. */
86export const historySince = (today: string) => {
87 const weeksBack = addDays(weekStart(today), -7 * (REPORT_WEEKS - 1))
88 const lastMonth = previousMonthStart(today)
89 return (weeksBack < lastMonth ? weeksBack : lastMonth).replace(/-/g, '')
90}
91
92export type Span = { costUsd: number; tokens: number; days: number }
93
94/** The days from `from` to `to`, both included. */
95export const span = (days: DayUsage[], from: string, to: string): Span => {
96 const inside = days.filter(d => d.date >= from && d.date <= to)
97 return {
98 costUsd: inside.reduce((s, d) => s + d.costUsd, 0),
99 tokens: inside.reduce((s, d) => s + d.tokens, 0),
100 days: inside.length,
101 }
102}
103
104/** Every model's share of the days from `from` to `to`, the costliest first. */
105export const modelShares = (days: DayUsage[], from: string, to: string) => {
106 const byModel = new Map<string, ModelDay>()
107 for (const day of days) {
108 if (day.date < from || day.date > to) continue
109 for (const m of day.models) {
110 const held = byModel.get(m.model)
111 byModel.set(m.model, held ? { model: m.model, costUsd: held.costUsd + m.costUsd, tokens: held.tokens + m.tokens } : m)
112 }
113 }
114 return [...byModel.values()].sort((a, b) => b.costUsd - a.costUsd)
115}
116
117export type Period = { name: string; now: Span; before: Span | null; from: string; to: string }
118
119/**
120 * Today against yesterday, this week against last week's same days, this month against last
121 * month's same days: the comparison is like for like however far into the period we are.
122 */
123/** The first day the history holds: Claude Code keeps transcripts for `cleanupPeriodDays` (30 by default). */
124export const firstDate = (days: DayUsage[]) => days.reduce<string | null>((first, d) => (first === null || d.date < first ? d.date : first), null)
125
126export const periods = (days: DayUsage[], today: string): Period[] => {
127 // A period that starts before the history does is not known, so it is not compared against.
128 const first = firstDate(days)
129 const known = (from: string, to: string) => (first === null || from < first ? null : span(days, from, to))
130 const week = weekStart(today)
131 const intoWeek = Math.round((Date.parse(today) - Date.parse(week)) / 86_400_000)
132 const month = monthStart(today)
133 const lastMonth = previousMonthStart(today)
134 const intoMonth = Number(today.slice(8, 10)) - 1
135 const lastMonthSame = addDays(lastMonth, intoMonth) < month ? addDays(lastMonth, intoMonth) : addDays(month, -1)
136 return [
137 { name: 'today', now: span(days, today, today), before: known(addDays(today, -1), addDays(today, -1)), from: today, to: today },
138 { name: 'week', now: span(days, week, today), before: known(addDays(week, -7), addDays(week, intoWeek - 7)), from: week, to: today },
139 { name: 'month', now: span(days, month, today), before: known(lastMonth, lastMonthSame), from: month, to: today },
140 ]
141}
142
143/** The cost of each of the last `n` days, oldest first, a missing day as zero. */
144export const dailyCosts = (days: DayUsage[], today: string, n: number) => {
145 const byDate = new Map(days.map(d => [d.date, d.costUsd]))
146 return Array.from({ length: n }, (_, i) => byDate.get(addDays(today, i - n + 1)) ?? 0)
147}
148
149/** The change from `before` to `now` as a whole percentage, or null when there is nothing to compare. */
150export const change = (now: number, before: number) => (before <= 0 ? null : Math.round(((now - before) / before) * 100))
151
152/** Whole weeks (Monday first) back from this one, newest first. */
153export const weekSpans = (days: DayUsage[], today: string, n: number) =>
154 Array.from({ length: n }, (_, i) => {
155 const from = addDays(weekStart(today), -7 * i)
156 const to = i === 0 ? today : addDays(from, 6)
157 return { from, to, ...span(days, from, to) }
158 })
159
160/** This month and the one before, newest first. */
161export const monthSpans = (days: DayUsage[], today: string) => {
162 const month = monthStart(today)
163 const lastMonth = previousMonthStart(today)
164 return [
165 { from: month, to: today, ...span(days, month, today) },
166 { from: lastMonth, to: addDays(month, -1), ...span(days, lastMonth, addDays(month, -1)) },
167 ]
168}
169hooks/report-view.tsx 232 lines1import type { HistoryProblem, PlanUsage, UsageHistory } from '../types'
2import { ago, cash, count, delta, limitLabel, meterTone, money, prettyModel, until } from './format'
3import { REPORT_WEEKS, addDays, change, firstDate, localDate, modelShares, monthSpans, periods, weekSpans } from './report'
4import { ACCENT, blocks, rule, table } from './ui'
5import type { Canvas, Cell, Column } from './ui'
6
7const FRAME_CELLS = 4
8const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
9const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
10
11const weekday = (date: string) => {
12 const [y = 0, m = 1, d = 1] = date.split('-').map(Number)
13 return WEEKDAYS[new Date(y, m - 1, d, 12).getDay()] ?? ''
14}
15
16/** A cost bar against the period's largest, as a cell of the BAR column. */
17const barCell = (value: number, top: number, cells: number): Cell => ({ text: blocks(top > 0 ? (value / top) * 100 : 0, cells).filled, color: ACCENT })
18
19const deltaCell = (now: number, before: number | undefined): Cell => {
20 const pct = before === undefined ? null : change(now, before)
21 return pct === null ? { text: '—', dim: true } : { text: delta(pct), color: pct > 0 ? 'warning' : 'success' }
22}
23
24const DAY_COLUMNS: Column[] = [
25 { title: 'DAY', width: 14 },
26 { title: 'COST', width: 7, align: 'right' },
27 { title: 'TOKENS', width: 6, align: 'right' },
28 { title: 'TOP MODEL', width: 12, priority: 1 },
29 { title: 'BAR', width: 10, grow: true },
30]
31
32const PERIOD_COLUMNS: Column[] = [
33 { title: 'PERIOD', width: 17 },
34 { title: 'COST', width: 7, align: 'right' },
35 { title: 'TOKENS', width: 6, align: 'right' },
36 { title: 'VS BEFORE', width: 9, align: 'right', priority: 1 },
37 { title: 'BAR', width: 10, grow: true },
38]
39
40const MODEL_COLUMNS: Column[] = [
41 { title: 'MODEL', width: 14 },
42 { title: 'COST', width: 7, align: 'right' },
43 { title: 'TOKENS', width: 6, align: 'right' },
44 { title: 'SHARE', width: 5, align: 'right' },
45 { title: 'BAR', width: 10, grow: true },
46]
47
48const PLAN_COLUMNS: Column[] = [
49 { title: 'LIMIT', width: 22 },
50 { title: 'USED', width: 6, align: 'right' },
51 { title: 'RESETS', width: 7, align: 'right' },
52 { title: 'NOTE', width: 22, priority: 1 },
53 { title: 'BAR', width: 10, grow: true },
54]
55
56const SHARE_COLUMNS: Column[] = [
57 { title: 'PRODUCT', width: 22 },
58 { title: 'SHARE', width: 6, align: 'right' },
59 { title: 'BAR', width: 10, grow: true },
60]
61
62/** A limit's bar against 100%, in the tone its meter in the band has. */
63const limitBar = (percent: number, severity: string | undefined, cells: number): Cell => ({
64 text: blocks(percent, cells).filled,
65 color: meterTone(percent, severity) ?? ACCENT,
66})
67
68/**
69 * The account's plan limits as its usage endpoint reports them, every session, machine and claude.ai
70 * chat counted: the 5-hour and weekly windows, each model's own weekly limit, usage credits, and the
71 * weekly limit's shares by product. `▸` marks the limit the server names as the one that counts now.
72 */
73const drawPlan = (canvas: Canvas, inner: number, now: number, plan: PlanUsage) => {
74 const { Box, Text } = canvas.els
75 const barCells = Math.max(6, inner - 22 - 6 - 7 - 22 - 3 * 4)
76 const resets = (iso: string | null): Cell => (iso === null ? { text: '—', dim: true } : { text: until(iso, now), dim: true })
77 const graded = (severity: string | undefined, isActive: boolean) =>
78 [isActive ? 'counts now' : '', severity !== undefined && severity !== 'normal' ? severity : ''].filter(t => t !== '').join(' · ')
79 const limitRows = plan.limits.map((l): Record<string, Cell> => ({
80 LIMIT: { text: `${l.isActive === true ? '▸ ' : ''}${limitLabel(l.kind)}${l.kind === 'seven_day' ? ' · all models' : ''}`, bold: true },
81 USED: { text: `${l.percent}%`, bold: true, color: meterTone(l.percent, l.severity) },
82 RESETS: resets(l.resetsAt),
83 NOTE: { text: graded(l.severity, l.isActive === true), color: meterTone(l.percent, l.severity) },
84 BAR: limitBar(l.percent, l.severity, barCells),
85 }))
86 const otherRows = plan.rows.map((r): Record<string, Cell> => ({
87 LIMIT: { text: `${r.isActive ? '▸ ' : ''}${r.kind.startsWith('weekly') ? 'Weekly · ' : ''}${r.label}`, bold: true },
88 USED: { text: `${r.percent}%`, bold: true, color: meterTone(r.percent, r.severity) },
89 RESETS: resets(r.resetsAt),
90 NOTE: { text: [r.ofWeekly === null ? '' : `up to ${r.ofWeekly}% of weekly`, graded(r.severity, r.isActive)].filter(t => t !== '').join(' · '), dim: true },
91 BAR: limitBar(r.percent, r.severity, barCells),
92 }))
93 const c = plan.credits
94 const creditRows: Record<string, Cell>[] =
95 c === null
96 ? []
97 : [
98 {
99 LIMIT: { text: 'Usage credits', bold: true },
100 USED: c.limit === null || c.limit <= 0 ? { text: '—', dim: true } : { text: `${Math.round((c.used / c.limit) * 100)}%`, bold: true },
101 RESETS: { text: 'monthly', dim: true },
102 NOTE: { text: `${cash(c.used, c.currency)}${c.limit === null ? ' · no cap' : ` of ${cash(c.limit, c.currency)}`}${c.isOn ? '' : ' · off'}`, dim: true },
103 BAR: c.limit === null || c.limit <= 0 ? { text: '' } : limitBar((c.used / c.limit) * 100, undefined, barCells),
104 },
105 ]
106 const b = plan.breakdown
107 const shareCells = Math.max(6, inner - 22 - 6 - 3 * 2)
108 const shareRows = (b?.rows ?? []).map((r): Record<string, Cell> => ({
109 PRODUCT: { text: r.name, bold: r.percent > 0, dim: r.percent === 0 },
110 SHARE: { text: `${r.percent}%`, dim: r.percent === 0 },
111 BAR: { text: blocks(r.percent, shareCells).filled, color: ACCENT },
112 }))
113 return (
114 <Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1}>
115 <Box flexDirection="row" justifyContent="space-between">
116 <Text bold color={ACCENT}>{'🎯 PLAN LIMITS · your account, every session and claude.ai'}</Text>
117 <Text dimColor>{`/api/oauth/usage · ${ago(now - plan.at)} ago`}</Text>
118 </Box>
119 {table(canvas.els, PLAN_COLUMNS, [...limitRows, ...otherRows, ...creditRows], inner)}
120 {b !== null && rule(canvas.els, '🧭 THIS WEEK BY PRODUCT', `share of the weekly limit${b.asOf === null ? '' : ` · as of ${ago(now - Date.parse(b.asOf))} ago`}`, inner)}
121 {b !== null && table(canvas.els, SHARE_COLUMNS, shareRows, inner)}
122 </Box>
123 )
124}
125
126/**
127 * The usage report: the account's plan limits when there is a reading of them, then this machine's
128 * Claude Code spend from ccusage: the last 14 days, the last 6 weeks, this month and the last, and
129 * the models.
130 */
131export const drawReport = (
132 canvas: Canvas,
133 room: number,
134 now: number,
135 history: UsageHistory | null,
136 problem: HistoryProblem | null,
137 isReading: boolean,
138 plan: PlanUsage | null = null,
139) => {
140 const { Box, Text } = canvas.els
141 const inner = room - FRAME_CELLS
142 const planBox = plan === null ? null : drawPlan(canvas, inner, now, plan)
143 if (history === null) {
144 return (
145 <Box flexDirection="column">
146 {planBox}
147 <Text dimColor>{problem === null ? 'Reading usage history…' : `No usage history: ${problem.reason}`}</Text>
148 </Box>
149 )
150 }
151 const today = localDate(now)
152 // Days before the history's first one have no data at all: not zero spent, nothing known.
153 const first = firstDate(history.days) ?? today
154 const noData: Cell = { text: 'no data', dim: true }
155 const barCells = Math.max(6, inner - 14 - 7 - 6 - 12 - 3 * 4)
156 const byDate = new Map(history.days.map(d => [d.date, d]))
157 const dates = Array.from({ length: 14 }, (_, i) => addDays(today, -i))
158 const dayTop = Math.max(0, ...dates.map(d => byDate.get(d)?.costUsd ?? 0))
159 const dayRows = dates.map((date): Record<string, Cell> => {
160 const day = byDate.get(date)
161 const top = day?.models.slice().sort((a, b) => b.costUsd - a.costUsd)[0]
162 if (date < first) return { DAY: { text: `${weekday(date)} ${date.slice(5)}`, dim: true }, COST: noData }
163 return {
164 DAY: { text: `${weekday(date)} ${date.slice(5)}${date === today ? ' ◀' : ''}`, bold: date === today },
165 COST: { text: money(day?.costUsd ?? 0), dim: day === undefined },
166 TOKENS: { text: count(day?.tokens ?? 0), dim: true },
167 'TOP MODEL': { text: top ? prettyModel(top.model) : '—', dim: true },
168 BAR: barCell(day?.costUsd ?? 0, dayTop, barCells),
169 }
170 })
171 // The week and the month still running are set against the same days of the one before.
172 const [, thisWeek, thisMonth] = periods(history.days, today)
173 const weeks = weekSpans(history.days, today, REPORT_WEEKS)
174 const weekTop = Math.max(0, ...weeks.map(w => w.costUsd))
175 const weekRows = weeks.map((w, i): Record<string, Cell> => {
176 const name = i === 0 ? 'this week' : `week of ${w.from.slice(5)}`
177 if (w.to < first) return { PERIOD: { text: name, dim: true }, COST: noData }
178 const before = weeks[i + 1]
179 const beforeCost = i === 0 ? thisWeek?.before?.costUsd : before !== undefined && before.from >= first ? before.costUsd : undefined
180 return {
181 PERIOD: { text: `${name}${w.from < first ? ` (from ${first.slice(5)})` : ''}`, bold: i === 0 },
182 COST: { text: money(w.costUsd), bold: i === 0 },
183 TOKENS: { text: count(w.tokens), dim: true },
184 'VS BEFORE': w.from < first ? { text: '—', dim: true } : deltaCell(w.costUsd, beforeCost),
185 BAR: barCell(w.costUsd, weekTop, barCells),
186 }
187 })
188 const months = monthSpans(history.days, today)
189 const monthTop = Math.max(0, ...months.map(m => m.costUsd))
190 const monthRows = months.map((m, i): Record<string, Cell> => ({
191 PERIOD: {
192 text: `${MONTHS[Number(m.from.slice(5, 7)) - 1] ?? m.from} ${m.from.slice(0, 4)}${i === 0 ? ' (so far)' : m.from < first ? ` (from ${first.slice(5)})` : ''}`,
193 bold: i === 0,
194 },
195 COST: { text: money(m.costUsd), bold: i === 0 },
196 TOKENS: { text: count(m.tokens), dim: true },
197 'VS BEFORE': i === 0 ? deltaCell(m.costUsd, thisMonth?.before?.costUsd) : { text: '—', dim: true },
198 BAR: barCell(m.costUsd, monthTop, barCells),
199 }))
200 const month = months[0]
201 const models = month === undefined ? [] : modelShares(history.days, month.from, month.to)
202 const monthCost = month?.costUsd ?? 0
203 const modelRows = models.map((m): Record<string, Cell> => ({
204 MODEL: { text: prettyModel(m.model), bold: true },
205 COST: { text: money(m.costUsd) },
206 TOKENS: { text: count(m.tokens), dim: true },
207 SHARE: { text: `${monthCost > 0 ? Math.round((m.costUsd / monthCost) * 100) : 0}%` },
208 BAR: barCell(m.costUsd, models[0]?.costUsd ?? 0, barCells),
209 }))
210 return (
211 <Box flexDirection="column">
212 {planBox}
213 <Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1}>
214 <Box flexDirection="row" justifyContent="space-between">
215 <Text bold color={ACCENT}>{'📊 USAGE REPORT · Claude Code on this machine'}</Text>
216 <Text dimColor>
217 {`ccusage · data from ${first.slice(5)} · ${isReading ? 'reading…' : `${ago(now - history.at)} ago`}${problem !== null && problem.at > history.at ? ' · ⚠ stale' : ''}`}
218 </Text>
219 </Box>
220 {rule(canvas.els, '📅 LAST 14 DAYS', '', inner)}
221 {table(canvas.els, DAY_COLUMNS, dayRows, inner)}
222 {rule(canvas.els, '🗓 WEEKS', 'Monday to Sunday · this week against the same days last week', inner)}
223 {table(canvas.els, PERIOD_COLUMNS, weekRows, inner)}
224 {rule(canvas.els, '🌙 MONTHS', 'this month against the same days last month', inner)}
225 {table(canvas.els, PERIOD_COLUMNS, monthRows, inner)}
226 {rule(canvas.els, '🧠 MODELS THIS MONTH', '', inner)}
227 {table(canvas.els, MODEL_COLUMNS, modelRows, inner)}
228 </Box>
229 </Box>
230 )
231}
232hooks/ui.tsx 230 lines1import type { Elements, RenderChildren, RenderSurface } from 'claude-code'
2
3import { meterTone } from './format'
4import type { Part } from './format'
5
6// The band is raised on the terminal and the desktop only; every surface's table has Box and Text.
7export type Els = Elements[RenderSurface]
8
9/** What draws and the elements it draws with: bars are text in the terminal, SVG on the desktop. */
10export type Canvas = { els: Els; surface: RenderSurface }
11
12export type Cell = { text: string; color?: string; dim?: boolean; bold?: boolean }
13
14/**
15 * A table column: `width` in cells (the least, for the one that `grow`s), `priority` the order
16 * columns give way in when the band is narrow (lowest first; absent never gives way).
17 */
18export type Column = { title: string; width: number; align?: 'left' | 'right'; grow?: boolean; priority?: number }
19
20export const ACCENT = 'claude'
21const BAR_FILL = 'suggestion'
22const SEPARATOR = ' │ '
23
24// Symbols the terminal draws two cells wide although they sit below the emoji planes.
25const WIDE = new Set([0x23f3, 0x231b, 0x26a1, 0x26fd, 0x2705, 0x274c, 0x2b50])
26
27/** Cells a string takes in the terminal: emoji count two, joiners and selectors none. */
28export const cellWidth = (text: string) => {
29 let width = 0
30 for (const ch of text) {
31 const cp = ch.codePointAt(0) ?? 0
32 if (cp === 0xfe0f || cp === 0x200d) continue
33 width += cp >= 0x1f000 || WIDE.has(cp) ? 2 : 1
34 }
35 return width
36}
37
38const tableWidth = (columns: Column[]) => columns.reduce((sum, c) => sum + c.width, 0) + SEPARATOR.length * (columns.length - 1)
39
40/** Drops the lowest-priority columns until the table, separators included, fits `room` cells. */
41export const fitColumns = (columns: Column[], room: number) => {
42 let kept = columns
43 while (tableWidth(kept) > room) {
44 const droppable = kept.filter(c => c.priority !== undefined)
45 if (droppable.length === 0) break
46 const lowest = droppable.reduce((a, b) => ((a.priority ?? 0) <= (b.priority ?? 0) ? a : b))
47 kept = kept.filter(c => c !== lowest)
48 }
49 // The growing column takes what the others leave.
50 const spare = Math.max(0, room - tableWidth(kept))
51 return kept.map(c => (c.grow ? { ...c, width: c.width + spare } : c))
52}
53
54const cellBox = (els: Els, column: Column, cell: Cell) => {
55 const { Box, Text } = els
56 return (
57 <Box width={column.width} flexShrink={0} justifyContent={column.align === 'right' ? 'flex-end' : 'flex-start'}>
58 <Text wrap="truncate-end" color={cell.color} dimColor={cell.dim} bold={cell.bold}>
59 {cell.text}
60 </Text>
61 </Box>
62 )
63}
64
65const tableRow = (els: Els, columns: Column[], cells: (c: Column) => Cell) => {
66 const { Box, Text } = els
67 return (
68 <Box flexDirection="row">
69 {columns.map((c, i) => (
70 <Box flexDirection="row">
71 {i > 0 && <Text dimColor>{SEPARATOR}</Text>}
72 {cellBox(els, c, cells(c))}
73 </Box>
74 ))}
75 </Box>
76 )
77}
78
79/**
80 * A ruled table: a bold header, a `─┼─` rule under it and one row per entry, every column cut to
81 * its width. Cells are keyed by column title, so a column dropped for room drops its cells too.
82 */
83export const table = (els: Els, columns: Column[], rows: Record<string, Cell>[], room: number) => {
84 const { Box, Text } = els
85 const kept = fitColumns(columns, room)
86 return (
87 <Box flexDirection="column">
88 {tableRow(els, kept, c => ({ text: c.title, bold: true, dim: true }))}
89 <Box flexDirection="row">
90 {kept.map((c, i) => (
91 <Box flexDirection="row">
92 {i > 0 && <Text dimColor>{'─┼─'}</Text>}
93 <Box width={c.width} flexShrink={0}>
94 <Text dimColor wrap="truncate-end">{'─'.repeat(c.width)}</Text>
95 </Box>
96 </Box>
97 ))}
98 </Box>
99 {rows.map(row => tableRow(els, kept, c => row[c.title] ?? { text: '' }))}
100 </Box>
101 )
102}
103
104/**
105 * A section's own box: a rounded border `room` cells wide, the title (accent) and a dim note on its
106 * first row, the body under them laid out in `room - 4` cells.
107 */
108export const card = (els: Els, title: string, note: string, room: number, body: RenderChildren, borderColor = 'subtle') => {
109 const { Box, Text } = els
110 return (
111 <Box flexDirection="column" width={room} flexShrink={0} borderStyle="round" borderColor={borderColor} paddingX={1}>
112 <Box flexDirection="row" justifyContent="space-between">
113 <Text bold color={ACCENT}>{title}</Text>
114 {note !== '' && <Text dimColor wrap="truncate-end">{note}</Text>}
115 </Box>
116 {body}
117 </Box>
118 )
119}
120
121/** Cells a card leaves its body: the border and one cell of padding on each side. */
122export const CARD_CELLS = 4
123/** Rows a card adds around its body: the border and the title row. */
124export const CARD_ROWS = 3
125
126/** A section rule across the frame: `── 🔥 TOKENS ─────────── note ──`. */
127export const rule = (els: Els, title: string, note: string, room: number) => {
128 const { Box, Text } = els
129 const tail = note === '' ? '──' : ` ${note} ──`
130 const fill = Math.max(2, room - cellWidth(`── ${title} `) - cellWidth(tail))
131 return (
132 <Box flexDirection="row">
133 <Text dimColor>{'── '}</Text>
134 <Text bold color={ACCENT}>{title}</Text>
135 <Text dimColor wrap="truncate-end">{` ${'─'.repeat(fill)}${tail}`}</Text>
136 </Box>
137 )
138}
139
140/** Parts joined by dim middots; a warning part in the warning colour, a strong one bold. */
141export const parts = (els: Els, list: Part[]) => {
142 const { Box, Text } = els
143 return (
144 <Box flexDirection="row" flexWrap="wrap">
145 {list.map((p, i) => (
146 <Box flexDirection="row">
147 {i > 0 && <Text dimColor>{' · '}</Text>}
148 <Text
149 color={p.emphasis === 'warning' ? 'warning' : p.color}
150 bold={p.emphasis === 'strong'}
151 dimColor={p.emphasis === undefined && p.color === undefined}
152 >
153 {p.text}
154 </Text>
155 </Box>
156 ))}
157 </Box>
158 )
159}
160
161// Drawn as an image on the desktop, so it cannot follow the theme: a translucent track reads on both.
162const svgBar = (percent: number, width: number, color: string | undefined) => {
163 const fill = color === 'error' ? '#e5484d' : color === 'warning' ? '#e0a030' : '#2f7de1'
164 const filled = Math.round((Math.min(percent, 100) / 100) * width)
165 return (
166 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="8" viewBox="0 0 ${width} 8">` +
167 `<rect width="${width}" height="8" rx="4" fill="#808080" fill-opacity="0.3"/>` +
168 `<rect width="${filled}" height="8" rx="4" fill="${fill}"/></svg>`
169 )
170}
171
172const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
173
174/** `cells` of bar at an eighth of a cell's resolution: whole blocks, one partial, the dim track. */
175export const blocks = (percent: number, cells: number) => {
176 const eighths = Math.round((Math.max(0, Math.min(percent, 100)) / 100) * cells * 8)
177 const whole = Math.floor(eighths / 8)
178 const partial = EIGHTHS[eighths % 8] ?? ''
179 return { filled: '█'.repeat(whole) + partial, track: '░'.repeat(Math.max(0, cells - whole - (partial ? 1 : 0))) }
180}
181
182/** A bar `cells` wide in the meter's tone: blocks in the terminal, an SVG bar on the desktop. */
183export const gauge = (canvas: Canvas, name: string, percent: number, cells: number, color = meterTone(percent)) => {
184 const { Box, Text } = canvas.els
185 if (canvas.surface === 'desktop' && 'Svg' in canvas.els) {
186 const { Svg } = canvas.els
187 const px = cells * 9
188 return <Svg source={svgBar(percent, px, color)} alt={`${name} ${percent}% used`} width={px} height={8} />
189 }
190 const bar = blocks(percent, cells)
191 return (
192 <Box flexDirection="row" flexShrink={0}>
193 {bar.filled !== '' && <Text color={color ?? BAR_FILL}>{bar.filled}</Text>}
194 {bar.track !== '' && <Text dimColor>{bar.track}</Text>}
195 </Box>
196 )
197}
198
199/** The cells a meter takes besides its bar: icon, label, percentage and detail. */
200export const meterChrome = (label: string, detail: string) => cellWidth(label) + 1 + 1 + 4 + (detail === '' ? 0 : 1 + cellWidth(detail))
201
202/**
203 * `⛽ CTX ███████▍░░░░ 29% 289k/1M`: a label, a bar, the percentage and a dim detail, amber from
204 * 80% and red from 95%, or as the server grades it (`severity`) when that is graver.
205 */
206export const meter = (canvas: Canvas, label: string, percent: number | null, detail: string, cells: number, severity?: string) => {
207 const { Box, Text } = canvas.els
208 const color = percent === null ? undefined : meterTone(percent, severity)
209 return (
210 <Box flexDirection="row" flexShrink={0}>
211 <Text bold>{`${label} `}</Text>
212 {percent === null ? <Text dimColor>{'─'.repeat(cells)}</Text> : gauge(canvas, label, percent, cells, color)}
213 <Box width={5} justifyContent="flex-end" flexShrink={0}>
214 <Text bold color={color} dimColor={percent === null}>
215 {percent === null ? '—' : `${percent}%`}
216 </Text>
217 </Box>
218 {detail !== '' && <Text dimColor>{` ${detail}`}</Text>}
219 </Box>
220 )
221}
222
223const SPARKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
224
225/** A sparkline of `values`, each against the largest; a zero is the lowest bar. */
226export const sparkline = (values: number[]) => {
227 const top = Math.max(0, ...values)
228 return values.map(v => (top === 0 ? SPARKS[0] : SPARKS[Math.min(7, Math.floor((v / top) * 7.999))])).join('')
229}
230hooks/sections.tsx 495 lines1import type { RenderChildren } from 'claude-code'
2
3import type { AgentStat, Compactions, ContextPart, HistoryProblem, LiveTool, PlanUsage, ShellStat, Snapshot, ToolCounts, Totals, TurnStat, UsageHistory } from '../types'
4import {
5 NO_TOKENS,
6 addTokens,
7 ago,
8 count,
9 delta,
10 elapsed,
11 hitRate,
12 isActive,
13 isShown,
14 money,
15 oneLine,
16 prettyModel,
17 shortType,
18 statusMark,
19 toolName,
20 totalTokens,
21} from './format'
22import type { Part } from './format'
23import type { Forecast } from './forecast'
24import { change, dailyCosts, localDate, modelShares, periods } from './report'
25import { ACCENT, CARD_CELLS, CARD_ROWS, blocks, card, cellWidth, parts, sparkline, table } from './ui'
26import type { Canvas, Cell, Column } from './ui'
27
28export type BandInput = {
29 canvas: Canvas
30 /** Cells the drawing is laid out in: the site's columns. */
31 room: number
32 /** Rows the drawing may take before it would scroll. */
33 rows: number
34 isWorking: boolean
35 now: number
36 snap: Snapshot
37 turn: TurnStat | null
38 totals: Totals | null
39 compactions: Compactions | null
40 effort: string | null
41 agents: AgentStat[]
42 shells: ShellStat[]
43 live: LiveTool[]
44 tools: ToolCounts | null
45 history: UsageHistory | null
46 historyProblem: HistoryProblem | null
47 /** The account's usage as its usage endpoint last reported it; null with no Claude login. */
48 plan: PlanUsage | null
49 forecasts: Forecast[]
50 cacheTtlMs: number
51}
52
53/**
54 * A section: its own box when the rows allow (`fullRows` tall, box included), else one line.
55 * `room` is the width the box takes.
56 */
57export type Section = { full: () => RenderChildren; fullRows: number; mini: () => RenderChildren }
58
59/** A table's header and the rule under it. */
60const TABLE_HEAD = 2
61
62export const EFFORT_COLORS: Record<string, string> = { max: 'error', xhigh: 'warning', high: ACCENT, medium: 'suggestion', low: 'success' }
63
64export const effortCell = (level: string | null): Cell =>
65 level === null ? { text: '—', dim: true } : { text: level, color: EFFORT_COLORS[level] }
66const hitCell = (hit: number | null): Cell =>
67 hit === null ? { text: '—', dim: true } : { text: `${hit}%`, color: hit < 50 ? 'warning' : hit >= 80 ? 'success' : undefined }
68
69/** The spinner frame: the band redraws once a second while something runs. */
70export const frame = (input: BandInput) => Math.floor(input.now / 1000)
71
72/** Active runs first, then the newest; ended ones stay, dimmed, until newer ones push them out. */
73const newestFirst = <T extends { status: AgentStat['status']; startedAt: number }>(runs: T[]) =>
74 [...runs].sort((a, b) => Number(isActive(b.status)) - Number(isActive(a.status)) || b.startedAt - a.startedAt)
75
76const runNote = (running: number, all: number, hidden: number) =>
77 `${running} running · ${all - running} done${hidden > 0 ? ` · +${hidden} in /vitals pane` : ''}`
78
79/** A one-line section: a bold label, then its parts. */
80export const lineOf = (input: BandInput, label: string, list: Part[]) => {
81 const { Box, Text } = input.canvas.els
82 return (
83 <Box flexDirection="row">
84 <Box width={11} flexShrink={0}>
85 <Text bold color={ACCENT}>{label}</Text>
86 </Box>
87 {parts(input.canvas.els, list)}
88 </Box>
89 )
90}
91
92const TOKEN_COLUMNS: Column[] = [
93 { title: ' ', width: 7 },
94 { title: 'IN', width: 6, align: 'right' },
95 { title: 'OUT', width: 6, align: 'right' },
96 { title: 'CACHE R', width: 7, align: 'right' },
97 { title: 'CACHE W', width: 7, align: 'right' },
98 { title: 'HIT', width: 4, align: 'right' },
99 { title: 'TOTAL', width: 6, align: 'right', priority: 1 },
100 { title: 'NOTE', width: 8, grow: true, priority: 0 },
101]
102
103type TokenCounts = Record<'input' | 'output' | 'cacheRead' | 'cacheWrite', number>
104
105const tokenRow = (name: string, t: TokenCounts, note: Cell) => ({
106 ' ': { text: name, bold: true },
107 IN: { text: count(t.input) },
108 OUT: { text: count(t.output) },
109 'CACHE R': { text: count(t.cacheRead) },
110 'CACHE W': { text: count(t.cacheWrite) },
111 HIT: hitCell(hitRate(t)),
112 TOTAL: { text: count(totalTokens(t)), bold: true },
113 NOTE: note,
114})
115
116const tokenParts = (name: string, t: TokenCounts): Part[] => {
117 const hit = hitRate(t)
118 return [
119 { text: name, emphasis: 'strong' },
120 { text: `in ${count(t.input)} out ${count(t.output)}` },
121 { text: `R ${count(t.cacheRead)} W ${count(t.cacheWrite)}` },
122 ...(hit === null ? [] : [{ text: `hit ${hit}%`, color: hit < 50 ? 'warning' : 'success' }]),
123 ]
124}
125
126export const tokensSection = (input: BandInput, room: number): Section | null => {
127 const { turn, totals, snap } = input
128 const hasTurn = turn !== null && turn.tokens !== null && turn.at >= snap.startedAt
129 const hasTotals = totals !== null && totals.since === snap.startedAt
130 if (!hasTurn && !hasTotals) return null
131 const idleMs = turn === null ? 0 : Math.max(0, input.now - turn.at)
132 const isCold = hasTurn && !input.isWorking && idleMs >= input.cacheTtlMs
133 const cache = !hasTurn ? '' : isCold ? '🥶 cache cold' : `🧊 cache warm${input.isWorking ? '' : ` · expires in ${ago(input.cacheTtlMs - idleMs)}`}`
134 const rows: Record<string, Cell>[] = []
135 if (hasTurn && turn.tokens !== null) {
136 const note = input.isWorking ? elapsed(turn.durationMs) : `${elapsed(turn.durationMs)} · idle ${ago(idleMs)}`
137 rows.push(tokenRow('turn', turn.tokens, { text: note, color: isCold ? 'warning' : undefined, dim: !isCold }))
138 }
139 if (hasTotals) rows.push(tokenRow('session', totals.tokens, { text: `${totals.turns} turn${totals.turns === 1 ? '' : 's'}`, dim: true }))
140 return {
141 fullRows: CARD_ROWS + TABLE_HEAD + rows.length,
142 full: () => card(input.canvas.els, '🔥 TOKENS', cache, room, table(input.canvas.els, TOKEN_COLUMNS, rows, room - CARD_CELLS)),
143 mini: () =>
144 lineOf(input, '🔥 TOKENS', [
145 ...(hasTurn && turn.tokens !== null ? tokenParts('turn', turn.tokens) : []),
146 ...(hasTotals ? tokenParts('session', totals.tokens) : []),
147 ...(cache === '' ? [] : [{ text: cache, color: isCold ? 'warning' : undefined }]),
148 ]),
149 }
150}
151
152const AGENT_COLUMNS: Column[] = [
153 { title: ' ', width: 1 },
154 { title: 'AGENT', width: 12, grow: true },
155 { title: 'TYPE', width: 13, priority: 1 },
156 { title: 'MODEL', width: 11 },
157 { title: 'EFFORT', width: 6, priority: 3 },
158 { title: 'TOKENS', width: 6, align: 'right' },
159 { title: 'HIT', width: 4, align: 'right', priority: 2 },
160 { title: 'TOOLS', width: 5, align: 'right', priority: 0 },
161 { title: 'TIME', width: 6, align: 'right' },
162]
163
164const mainRow = (input: BandInput): Record<string, Cell> => {
165 const agentTokens = input.agents.reduce((sum, a) => addTokens(sum, a.tokens), NO_TOKENS)
166 const all = input.totals?.since === input.snap.startedAt ? input.totals.tokens : NO_TOKENS
167 const own = {
168 input: all.input - agentTokens.input,
169 output: all.output - agentTokens.output,
170 cacheRead: all.cacheRead - agentTokens.cacheRead,
171 cacheWrite: all.cacheWrite - agentTokens.cacheWrite,
172 }
173 const toolsAll = input.tools?.since === input.snap.startedAt ? input.tools.counts.reduce((s, c) => s + c.count, 0) : 0
174 const toolsAgents = input.agents.reduce((s, a) => s + a.tools, 0)
175 return {
176 ' ': { text: input.isWorking ? statusMark('running', frame(input)).glyph : '◆', color: ACCENT },
177 AGENT: { text: 'main', bold: true },
178 TYPE: { text: 'session', dim: true },
179 MODEL: { text: prettyModel(input.snap.model) },
180 EFFORT: effortCell(input.effort),
181 TOKENS: { text: count(Math.max(0, totalTokens(own))) },
182 HIT: hitCell(hitRate(own)),
183 TOOLS: { text: `${Math.max(0, toolsAll - toolsAgents)}`, dim: true },
184 TIME: { text: ago(input.now - input.snap.startedAt), dim: true },
185 }
186}
187
188const agentRow = (input: BandInput, a: AgentStat): Record<string, Cell> => {
189 const mark = statusMark(a.status, frame(input))
190 return {
191 ' ': { text: mark.glyph, color: mark.color },
192 AGENT: { text: a.description || shortType(a.type), dim: !isActive(a.status) },
193 TYPE: { text: shortType(a.type), dim: true },
194 MODEL: a.model === null ? { text: '…', dim: true } : { text: prettyModel(a.model) },
195 EFFORT: effortCell(a.effort),
196 TOKENS: { text: count(totalTokens(a.tokens)) },
197 HIT: hitCell(hitRate(a.tokens)),
198 TOOLS: { text: `${a.tools}`, dim: true },
199 TIME: { text: elapsed((a.endedAt ?? input.now) - a.startedAt), dim: !isActive(a.status) },
200 }
201}
202
203/** The main loop and every subagent: what each runs on and what it spent. */
204/** Which runs a table shows: the band only those still running, the pane every one this session. */
205export type RunScope = 'running' | 'all'
206
207/** The note over a runs table: how many run, and where the ended ones are when the band hides them. */
208const scopeNote = (scope: RunScope, running: number, all: number, shown: number, none: string) => {
209 const ended = all - running
210 if (scope === 'all') return all === 0 ? none : runNote(running, all, all - shown)
211 if (running === 0) return ended > 0 ? `none running · ${ended} done in /vitals pane` : none
212 return `${running} running${ended > 0 ? ` · ${ended} done in /vitals pane` : ''}`
213}
214
215export const agentsSection = (input: BandInput, room: number, limit: number, scope: RunScope): Section => {
216 const sorted = newestFirst(input.agents)
217 const running = sorted.filter(a => isActive(a.status))
218 const pool = scope === 'all' ? sorted : sorted.filter(r => isShown(r, input.now))
219 const shown = pool.slice(0, Math.max(running.length, Math.min(pool.length, limit)))
220 const note = scopeNote(scope, running.length, sorted.length, shown.length, 'no subagents yet')
221 return {
222 fullRows: CARD_ROWS + TABLE_HEAD + 1 + shown.length,
223 full: () =>
224 card(
225 input.canvas.els,
226 '🤖 AGENTS',
227 note,
228 room,
229 table(input.canvas.els, AGENT_COLUMNS, [mainRow(input), ...shown.map(a => agentRow(input, a))], room - CARD_CELLS),
230 ),
231 mini: () =>
232 lineOf(input, '🤖 AGENTS', [
233 ...(pool.length === 0 ? [{ text: note }] : []),
234 ...pool.slice(0, 4).map(a => {
235 const mark = statusMark(a.status, frame(input))
236 return {
237 text: `${mark.glyph} ${shortType(a.type)} ${a.model === null ? '' : prettyModel(a.model)}${a.effort ? ` ${a.effort}` : ''} ${count(totalTokens(a.tokens))}`,
238 color: isActive(a.status) ? 'text' : undefined,
239 }
240 }),
241 ...(pool.length > 4 ? [{ text: `+${pool.length - 4}` }] : []),
242 ]),
243 }
244}
245
246const TOOL_COLUMNS: Column[] = [
247 { title: ' ', width: 1 },
248 { title: 'TOOL', width: 12, grow: true },
249 { title: 'CALLS', width: 5, align: 'right' },
250 { title: 'ERR', width: 3, align: 'right', priority: 1 },
251 { title: 'USE', width: 10, priority: 0 },
252 { title: 'NOW', width: 16, priority: 2 },
253]
254
255/** Every tool the session called: calls, failures, share of use, and what runs this second. */
256export const toolsSection = (input: BandInput, room: number, limit: number): Section | null => {
257 const counts = input.tools?.since === input.snap.startedAt ? input.tools.counts : []
258 if (counts.length === 0 && input.live.length === 0) return null
259 const agentName = (id: string | null) => {
260 const agent = id === null ? undefined : input.agents.find(a => a.id === id)
261 return agent ? ` ‹${shortType(agent.type)}›` : ''
262 }
263 const runningOf = (tool: string) => input.live.filter(t => t.tool === tool).sort((a, b) => a.startedAt - b.startedAt)
264 const sorted = [...counts].sort((a, b) => runningOf(b.tool).length - runningOf(a.tool).length || b.count - a.count)
265 const shown = sorted.slice(0, Math.max(1, limit))
266 const top = Math.max(1, ...counts.map(c => c.count))
267 const calls = counts.reduce((s, c) => s + c.count, 0)
268 const errors = counts.reduce((s, c) => s + (c.errors ?? 0), 0)
269 const rows = shown.map((c): Record<string, Cell> => {
270 const runs = runningOf(c.tool)
271 const oldest = runs[0]
272 return {
273 ' ': oldest ? { text: statusMark('running', frame(input)).glyph, color: ACCENT } : { text: '·', dim: true },
274 TOOL: { text: toolName(c.tool), bold: oldest !== undefined },
275 CALLS: { text: `${c.count}` },
276 ERR: (c.errors ?? 0) > 0 ? { text: `${c.errors}`, color: 'warning' } : { text: '0', dim: true },
277 USE: { text: blocks((c.count / top) * 100, 10).filled, color: oldest ? ACCENT : 'suggestion' },
278 NOW: oldest
279 ? { text: `${elapsed(input.now - oldest.startedAt)}${runs.length > 1 ? ` ×${runs.length}` : ''}${agentName(oldest.agentId)}`, color: ACCENT }
280 : { text: '' },
281 }
282 })
283 const note = `${calls} calls · ${errors} error${errors === 1 ? '' : 's'}${input.live.length > 0 ? ` · ${input.live.length} running` : ''}`
284 return {
285 fullRows: CARD_ROWS + TABLE_HEAD + rows.length,
286 full: () => card(input.canvas.els, '🔧 TOOLS', note, room, table(input.canvas.els, TOOL_COLUMNS, rows, room - CARD_CELLS)),
287 mini: () =>
288 lineOf(input, '🔧 TOOLS', [
289 ...input.live.map(t => ({ text: `${statusMark('running', frame(input)).glyph} ${toolName(t.tool)} ${elapsed(input.now - t.startedAt)}`, color: ACCENT })),
290 ...sorted.slice(0, 6).map(c => ({ text: `${toolName(c.tool)} ${c.count}` })),
291 ]),
292 }
293}
294
295const SHELL_COLUMNS: Column[] = [
296 { title: ' ', width: 1 },
297 { title: 'SHELL', width: 9, priority: 1 },
298 { title: 'COMMAND', width: 16, grow: true },
299 { title: 'BY', width: 12, priority: 0 },
300 { title: 'STATUS', width: 9, priority: 2 },
301 { title: 'TIME', width: 6, align: 'right' },
302]
303
304export const shellsSection = (input: BandInput, room: number, limit: number, scope: RunScope): Section | null => {
305 const sorted = newestFirst(input.shells)
306 const running = sorted.filter(s => isActive(s.status))
307 const pool = scope === 'all' ? sorted : sorted.filter(r => isShown(r, input.now))
308 if (pool.length === 0) return null
309 const by = (agentId: string | null) => {
310 const agent = agentId === null ? undefined : input.agents.find(a => a.id === agentId)
311 return agent ? shortType(agent.type) : 'main'
312 }
313 const shown = pool.slice(0, Math.max(running.length, Math.min(pool.length, limit)))
314 const cells = shown.map((s): Record<string, Cell> => {
315 const mark = statusMark(s.status, frame(input))
316 return {
317 ' ': { text: mark.glyph, color: mark.color },
318 SHELL: { text: s.id, dim: true },
319 COMMAND: { text: `$ ${oneLine(s.description ?? s.command)}`, dim: !isActive(s.status) },
320 BY: { text: by(s.agentId), dim: true },
321 STATUS: { text: s.status, color: mark.color },
322 TIME: { text: elapsed((s.endedAt ?? input.now) - s.startedAt), dim: !isActive(s.status) },
323 }
324 })
325 const note = scopeNote(scope, running.length, sorted.length, shown.length, 'none')
326 return {
327 fullRows: CARD_ROWS + TABLE_HEAD + shown.length,
328 full: () => card(input.canvas.els, '🐚 SHELLS', note, room, table(input.canvas.els, SHELL_COLUMNS, cells, room - CARD_CELLS)),
329 mini: () =>
330 lineOf(input, '🐚 SHELLS', [
331 ...pool.slice(0, 3).map(s => {
332 const mark = statusMark(s.status, frame(input))
333 return {
334 text: `${mark.glyph} ${oneLine(s.description ?? s.command)} ${s.status} ${elapsed((s.endedAt ?? input.now) - s.startedAt)}`,
335 color: isActive(s.status) ? ACCENT : undefined,
336 }
337 }),
338 ...(pool.length > 3 ? [{ text: `+${pool.length - 3}` }] : []),
339 ]),
340 }
341}
342
343const USAGE_COLUMNS: Column[] = [
344 { title: 'PERIOD', width: 6 },
345 { title: 'COST', width: 7, align: 'right' },
346 { title: 'TOKENS', width: 7, align: 'right' },
347 { title: 'VS BEFORE', width: 9, align: 'right', priority: 2 },
348 { title: 'TOP MODELS', width: 10, grow: true, priority: 1 },
349]
350
351const deltaCell = (now: number, before: number): Cell => {
352 const pct = change(now, before)
353 return pct === null ? { text: '—', dim: true } : { text: delta(pct), color: pct > 0 ? 'warning' : 'success' }
354}
355
356/** Today, this week and this month across every session, with a 14-day sparkline. */
357export const usageSection = (input: BandInput, room: number): Section | null => {
358 const { Box, Text } = input.canvas.els
359 const { history, historyProblem: problem } = input
360 if (history === null) {
361 if (problem === null) return null
362 const line = () => lineOf(input, '📊 USAGE', [{ text: `no history: ${problem.reason}` }])
363 return { fullRows: 1, full: line, mini: line }
364 }
365 const today = localDate(input.now)
366 const spans = periods(history.days, today)
367 const rows = spans.map((p): Record<string, Cell> => {
368 const top = modelShares(history.days, p.from, p.to).slice(0, 2)
369 const share = (usd: number) => (p.now.costUsd > 0 ? Math.round((usd / p.now.costUsd) * 100) : 0)
370 return {
371 PERIOD: { text: p.name, bold: true },
372 COST: { text: money(p.now.costUsd), bold: true },
373 TOKENS: { text: count(p.now.tokens) },
374 'VS BEFORE': p.before === null ? { text: '—', dim: true } : deltaCell(p.now.costUsd, p.before.costUsd),
375 'TOP MODELS': { text: top.map(m => `${prettyModel(m.model)} ${share(m.costUsd)}%`).join(' · '), dim: true },
376 }
377 })
378 const costs = dailyCosts(history.days, today, 14)
379 const stale = problem !== null && problem.at > history.at ? '⚠ stale · ' : ''
380 return {
381 fullRows: CARD_ROWS + TABLE_HEAD + rows.length + 1,
382 full: () =>
383 card(
384 input.canvas.els,
385 '📊 USAGE',
386 `${stale}ccusage · ${ago(input.now - history.at)} ago`,
387 room,
388 <Box flexDirection="column">
389 {table(input.canvas.els, USAGE_COLUMNS, rows, room - CARD_CELLS)}
390 <Box flexDirection="row">
391 <Text dimColor>{'14 days '}</Text>
392 <Text color={ACCENT}>{sparkline(costs)}</Text>
393 <Text dimColor>{` peak ${money(Math.max(...costs))} · /vitals report`}</Text>
394 </Box>
395 </Box>,
396 ),
397 mini: () =>
398 lineOf(input, '📊 USAGE', [
399 ...spans.map(p => ({ text: `${p.name} ${money(p.now.costUsd)}`, emphasis: 'strong' as const })),
400 { text: sparkline(costs), color: ACCENT },
401 ]),
402 }
403}
404
405const LEGEND_GAP = 3
406
407/** Splits `cells` among parts by their tokens, the rounding spread so the bar fills exactly. */
408const shares = (tokens: number[], cells: number) => {
409 const total = tokens.reduce((s, t) => s + t, 0)
410 if (total <= 0) return tokens.map(() => 0)
411 const exact = tokens.map(t => (t / total) * cells)
412 const whole = exact.map(Math.floor)
413 let left = cells - whole.reduce((s, w) => s + w, 0)
414 const byRemainder = exact.map((e, i) => ({ i, r: e - Math.floor(e) })).sort((a, b) => b.r - a.r)
415 for (const { i } of byRemainder) {
416 if (left <= 0) break
417 whole[i] = (whole[i] ?? 0) + 1
418 left -= 1
419 }
420 return whole
421}
422
423const partGlyph = (kind: ContextPart['kind']) => (kind === 'free' ? '░' : kind === 'buffer' ? '▒' : '█')
424
425/** What fills the context, as /context breaks it down: one bar in its colours and a legend. */
426export const contextSection = (input: BandInput, room: number): Section | null => {
427 const partsOf = input.snap.contextParts
428 if (partsOf === null || partsOf.length === 0) return null
429 const { Box, Text } = input.canvas.els
430 const inner = room - CARD_CELLS
431 const total = partsOf.reduce((s, p) => s + p.tokens, 0)
432 const cells = shares(partsOf.map(p => p.tokens), inner)
433 const items = partsOf.map(p => ({
434 part: p,
435 text: `${p.name} ${count(p.tokens)} ${total > 0 ? Math.round((p.tokens / total) * 100) : 0}%`,
436 }))
437 // The legend packed into rows by width, so its height is known before it is drawn.
438 const legend: (typeof items)[] = [[]]
439 let width = 0
440 for (const item of items) {
441 const w = 2 + cellWidth(item.text) + LEGEND_GAP
442 const row = legend[legend.length - 1]
443 if (row !== undefined && row.length > 0 && width + w > inner) {
444 legend.push([item])
445 width = w
446 } else {
447 row?.push(item)
448 width += w
449 }
450 }
451 const used = partsOf.filter(p => p.kind === 'used').reduce((s, p) => s + p.tokens, 0)
452 const swatch = (p: ContextPart) => (p.kind === 'free' ? { dim: true } : { color: p.color })
453 return {
454 fullRows: CARD_ROWS + 1 + legend.length,
455 full: () =>
456 card(
457 input.canvas.els,
458 '🧩 CONTEXT',
459 `${count(used)} used of ${count(total)} · estimate`,
460 room,
461 <Box flexDirection="column">
462 <Box flexDirection="row">
463 {partsOf.map((p, i) =>
464 (cells[i] ?? 0) > 0 ? (
465 <Text color={swatch(p).color} dimColor={swatch(p).dim}>
466 {partGlyph(p.kind).repeat(cells[i] ?? 0)}
467 </Text>
468 ) : null,
469 )}
470 </Box>
471 {legend.map(row => (
472 <Box flexDirection="row">
473 {row.map(({ part, text }) => (
474 <Box flexDirection="row" marginRight={LEGEND_GAP}>
475 <Text color={swatch(part).color} dimColor={swatch(part).dim}>{`${partGlyph(part.kind)} `}</Text>
476 <Text dimColor={part.kind !== 'used'}>{text}</Text>
477 </Box>
478 ))}
479 </Box>
480 ))}
481 </Box>,
482 ),
483 mini: () =>
484 lineOf(
485 input,
486 '🧩 CONTEXT',
487 [...partsOf]
488 .filter(p => p.kind === 'used')
489 .sort((a, b) => b.tokens - a.tokens)
490 .slice(0, 4)
491 .map(p => ({ text: `${p.name} ${count(p.tokens)}`, color: p.color })),
492 ),
493 }
494}
495types/index.d.ts 170 lines1/**
2 * A plan limit. `severity` and `isActive` are the server's, when its usage endpoint read the same
3 * window: its grade for the meter's colour (`normal`, `warning`, `critical`) and whether this is
4 * the limit a single-value indicator shows.
5 */
6export type Limit = { kind: string; percent: number; resetsAt: string | null; severity?: string; isActive?: boolean }
7
8export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
9
10export type Snapshot = {
11 at: number
12 startedAt: number
13 prompts: number
14 model: string
15 dir: string
16 branch: string | null
17 isWorktree: boolean
18 ahead: number
19 behind: number
20 changed: number
21 contextPercent: number | null
22 contextTokens: number | null
23 contextWindow: number
24 costUsd: number | null
25 limits: Limit[]
26 /** The token count auto-compaction runs at, or null when it is off or not read yet. */
27 autoCompactAt: number | null
28 /** What fills the context, as /context breaks it down (estimated); null until first read. */
29 contextParts: ContextPart[] | null
30}
31
32/** One row of the context's breakdown: its tokens and the theme colour /context draws it in. */
33export type ContextPart = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
34
35export type TurnStat = {
36 at: number
37 durationMs: number
38 model: string | null
39 tokens: Tokens | null
40}
41
42export type Totals = { since: number; turns: number; tokens: Tokens }
43
44export type Compactions = {
45 since: number
46 count: number
47 before: number | null
48 after: number | null
49 /** When the last one finished, and what started it (`auto`, `manual`, `plugin`). */
50 at: number | null
51 trigger: string | null
52}
53
54/** Where a subagent or a background shell stands, as the band draws it. */
55export type RunStatus = 'running' | 'waiting' | 'completed' | 'failed' | 'killed'
56
57export type AgentStat = {
58 id: string
59 description: string
60 type: string
61 model: string | null
62 effort: string | null
63 status: RunStatus
64 startedAt: number
65 endedAt: number | null
66 tokens: Tokens
67 tools: number
68}
69
70export type ShellStat = {
71 id: string
72 command: string
73 description: string | null
74 agentId: string | null
75 status: RunStatus
76 startedAt: number
77 endedAt: number | null
78}
79
80/** A tool call in flight: drawn live with its elapsed time. */
81export type LiveTool = { id: string; tool: string; agentId: string | null; startedAt: number }
82
83export type ToolCount = { tool: string; count: number; errors: number }
84
85export type ToolCounts = { since: number; counts: ToolCount[] }
86
87/** How much the band shows: the vitals box alone, plus context and agents, or everything. */
88export type View = 'low' | 'medium' | 'high'
89
90/** One model's share of a day, as ccusage prices it. */
91export type ModelDay = { model: string; costUsd: number; tokens: number }
92
93/** One local day of Claude Code usage across every session on this machine. */
94export type DayUsage = {
95 date: string
96 costUsd: number
97 tokens: number
98 input: number
99 output: number
100 cacheRead: number
101 cacheWrite: number
102 models: ModelDay[]
103}
104
105/** The daily history the usage section and the report pane read: when it was read and the days. */
106export type UsageHistory = { at: number; days: DayUsage[] }
107
108/** Why the history could not be read this time; the last good one stays drawn. */
109export type HistoryProblem = { at: number; reason: string }
110
111/**
112 * One of the server's usage rows beyond the 5-hour and weekly windows, as it sends them: a model's
113 * or a surface's own weekly limit (`weekly_scoped`), or a meter Vitals does not know yet.
114 */
115export type PlanRow = {
116 kind: string
117 /** The server's label for what the row is for (`Fable`), else its kind. */
118 label: string
119 percent: number
120 resetsAt: string | null
121 severity: string
122 isActive: boolean
123 /** The share of the weekly limit this row may use, when the server says (`50`). */
124 ofWeekly: number | null
125}
126
127/** Usage credits (extra usage), in the currency's major units: what they cover once a limit is hit. */
128export type Credits = {
129 /** Whether they cover sends now: off when turned off, or when the month's limit is spent. */
130 isOn: boolean
131 used: number
132 /** The month's limit; null for none. */
133 limit: number | null
134 currency: string
135}
136
137/** Who spent the weekly limit, by product (Claude Code, chats, Cowork, ...), as shares of it. */
138export type Breakdown = { asOf: string | null; rows: { name: string; percent: number }[] }
139
140/** The account's usage as its usage endpoint last reported it, and when. */
141export type PlanUsage = {
142 at: number
143 limits: Limit[]
144 rows: PlanRow[]
145 /** Null while usage credits are off. */
146 credits: Credits | null
147 breakdown: Breakdown | null
148}
149
150declare module 'claude-code' {
151 interface PluginState {
152 vitals: {
153 snap: Snapshot | null
154 warned: string[]
155 lastTurn: TurnStat | null
156 totals: Totals | null
157 compactions: Compactions | null
158 effort: string | null
159 view: View
160 agents: AgentStat[]
161 shells: ShellStat[]
162 live: LiveTool[]
163 tools: ToolCounts | null
164 history: UsageHistory | null
165 historyProblem: HistoryProblem | null
166 plan: PlanUsage | null
167 }
168 }
169}
170