A framed panel above the prompt showing context, rate limits, session cost against a monthly budget, prompt-cache state with a live TTL countdown, git branch…

A Claude Code mod that draws a quiet, framed panel above the prompt with everything you want to keep an eye on during a session. Labels are dim; colour appears only where it tells you something.
Personal plan (Max 20x) with extra usage switched on:
╭──────────────────────────────────────────────────────────────────────────────────────────╮
│ www-trainers main ↓5 PR#1067 opus-5-5 · high · Max 20x │
│ │
│ context ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 33% 638k [1M] 5h 13% 38m │
│ cache ● 100% Σ 98% 56:32 CLAUDE.md 3 MCP 10 7d 16% 5d4h │
│ extra ━━━━━━━── 84% 42.00$ / 50.00$ │
│ session 11.02$ │
╰──────────────────────────────────────────────────────────────────────────────────────────╯
Enterprise with a 250$ monthly budget: the 5-hour and weekly limits are hidden (Enterprise pays per token); in their place the month's spend: the bar with its percentage, and the amount against the budget on the row below.
╭──────────────────────────────────────────────────────────────────────────────────────────╮
│ repo-x feature/abc ✓ PR#5541 opus-5-5 · high · Enterprise │
│ │
│ context ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 18% 185k [1M] month ━━━━━━── 76% │
│ cache ● 97% Σ 96% 42:10 CLAUDE.md 2 MCP 4 190.40$ / 250.00$ │
│ session 12.40$ │
╰──────────────────────────────────────────────────────────────────────────────────────────╯
On a personal plan with extra usage switched off (or without a limit) the money row is hidden; the session cost always stays.
↑2 ↓1, ✓ in sync), a clickable PR#n link when the branch has an open pull request (GitHub via gh, Azure DevOps via az with the azure-devops extension), model, effort and plan.↓n to fast-forward with git pull --ff-only: it never merges, and a diverged branch or conflicting local changes are left untouched; a toast says what happened.333k [1M]), with a bar and percentage coloured by fill (green → yellow → orange → red).cache miss line explains the most likely cause and disappears once the cache is warm again.$.model.fork), which reads the prefix from the cache and starts the TTL again. It skips a running turn and an expired cache, switches itself off when a request fails, and shows how many requests it sent and what they cost./login to another account is picked up within 30 s):extra usage beyond the subscription against its monthly limit, from your account; hidden while extra usage is off or has no limit,month, all spend this calendar month against the account's limit or budgetUsd, from your account once it reports it, otherwise estimated from the price list.credits when the account has any, and what keep-alive has sent and cost.3.71$, 3.71€). Account amounts use the currency the account reports; the session cost and price-list estimates are in US dollars, as Claude Code computes them.session, CLAUDE.md and MCP each open a pane docked to the right of the transcript (fullscreen from 110 columns, otherwise above the prompt). Only one is open at a time; click the same button again to close it.
Session pane:
🔥 47.2M tokens · ≈14.96$ by price list · engine 16.15$
────────────────────────────────────────────────────────────────
Subagents clear
▸ ● Color test: csharp-reviewer (csharp-reviewer) haiku-5-5 (XH) 🔥 96k ✅ 13s ≈0.01$
▾ Feedback on error message (Explore) haiku-5-5 (XH) 🔥 25k ✅ 12s ≈0.00$
╭────────────────────────────────────────────────────────────╮
│ ▌ request │
│ A client of a personal trainer tries to cancel … │
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
│ ▌ response │
│ - Tone: "Error" and "Operation not permitted" … │
╰────────────────────────────────────────────────────────────╯
════════════════════════════════════════════════════════════════
Σ subagents 12 spawned · 🔥 609k of 47.2M tokens · ≈0.28$ of 16.15$ this session
✻ running 1m20s or ✅ 8s) and estimated cost, in aligned columns.color from the agent's definition (~/.claude/agents, the project's .claude/agents; symbolic links are followed). Built-in types have none.CLAUDE.md pane: the loaded CLAUDE.md, CLAUDE.local.md and AGENTS.md files with where they come from and their tokens. Click a file to read it in an indented frame right in the pane (the first 60 lines); ↗ open opens it in your editor.
▾ CLAUDE.md Project · 5k tok ↗ open
╭──────────────────────────────────────────────────────────╮
│ # www-trainers │
│ │
│ ## Než začneš │
│ - Pracuješ na oblasti NNN: přečti spec.md a design.md. │
│ … 112 more lines · ↗ open shows the whole file │
╰──────────────────────────────────────────────────────────╯
▸ ~/.claude/CLAUDE.md User · 3k tok ↗ open
MCP & skills pane: MCP servers, then skills, each list with the active ones first and the most called first. Every row has an ON/OFF switch at the right edge.
⚠ Any switch changes the tools and skills listed to the model: the next request
writes the prompt cache again.
MCP servers
claude_ai_Claude_Docs [claude.ai] 12 calls · 3/3 tools · 4k tok ON
restapi 0 calls · 0/2 tools · 1k tok ON
────────────────────────────────────────────────────────────────────────────
Skills
plugin-authoring [system] 3 calls · built-in · 2k tok ON
superpowers:brainstorming [plugin] 0 calls · plugin · 1k tok ON
spec-write 0 calls · projectSettings · 1k tok OFF
.claude/settings.local.json (personal, not committed), the same file /skills writes:deniedMcpServers: [{ "serverName": … }]; it takes effect when the server reconnects (/mcp or a new session),skillOverrides: { "<name>": "off" }.skillOverrides does not apply to them.[system] marks Claude Code's own skills, [plugin] and [claude.ai] where a server or skill comes from. Any switch changes what the model is told it has, so the next request writes the prompt cache again.In a Claude Code terminal session:
/plugin install status-panel --marketplace tichara1/ai.claude-status-panel
Answer y to add the marketplace and pick the user scope so the panel shows in every project.
/plugin configure status-panel
| Option | Default | Meaning |
|---|---|---|
ttlMinutes | 60 | Prompt-cache TTL your requests use (5 or 60). Drives the countdown, keep-alive and the cache-write price. |
budgetUsd | 250 | Monthly Enterprise budget in USD that the month bar is measured against when the account reports no limit of its own. |
claude plugin update status-panel@status-panel
claude plugin uninstall status-panel@status-panel
Run /reload-plugins in sessions that are already open.
oauthAccount.organizationType and organizationRateLimitTier in ~/.claude.json; nothing else from that file is used.start on Windows, open on macOS and xdg-open on Linux.claude plugin validate .
claude plugin test .
The band is in hooks/band.tsx, the session pane in hooks/lists.tsx, the CLAUDE.md pane in hooks/memory.tsx, the MCP & skills pane in hooks/tools.tsx, state and hooks in hooks/register.tsx, pure helpers (with tests) in hooks/format.ts.
MIT
hooks/register.tsx 922 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type {
5 AccountSpend,
6 Billing,
7 KeepAlive,
8 Kind,
9 McpServer,
10 MemFile,
11 Miss,
12 PullRequest,
13 Repo,
14 SkillInfo,
15 Snap,
16 SubagentInfo,
17 SubagentUsage,
18 Tally,
19} from "../types";
20import { renderBand } from "./band";
21import type { Disabled } from "./format";
22import { renderList } from "./lists";
23import { renderMemory } from "./memory";
24import { renderTools } from "./tools";
25import {
26 addAgentStep,
27 baseName,
28 effortShort,
29 emptyAgent,
30 groupMcp,
31 disabledFromSettings,
32 toggleInSettings,
33 parseAgentDefinition,
34 isKeepAliveDue,
35 missReason,
36 moneyLine,
37 monthKey,
38 isAzureRemote,
39 parseAheadBehind,
40 parseAzurePullRequest,
41 parseAccountSpend,
42 parsePullRequest,
43 percentOf,
44 accountFromClaudeJson,
45 addCostParts,
46 stepCost,
47 stepCostParts,
48} from "./format";
49
50const snapAtom = atom(
51 { plugin: "status-panel", key: "snap" } as const,
52 null as Snap | null,
53);
54const tallyAtom = atom(
55 { plugin: "status-panel", key: "tally" } as const,
56 {
57 input: 0,
58 output: 0,
59 read: 0,
60 write: 0,
61 lastHit: null,
62 turns: 0,
63 } as Tally,
64);
65const cacheAtAtom = atom(
66 { plugin: "status-panel", key: "cacheAt" } as const,
67 0,
68);
69const nowAtom = atom({ plugin: "status-panel", key: "now" } as const, 0);
70const missAtom = atom(
71 { plugin: "status-panel", key: "miss" } as const,
72 null as Miss,
73);
74const repoAtom = atom(
75 { plugin: "status-panel", key: "repo" } as const,
76 { cwd: "", branch: "", sync: null, pr: null } as Repo,
77);
78const memAtom = atom(
79 { plugin: "status-panel", key: "mem" } as const,
80 [] as MemFile[],
81);
82const mcpAtom = atom(
83 { plugin: "status-panel", key: "mcp" } as const,
84 [] as McpServer[],
85);
86const callsAtom = atom(
87 { plugin: "status-panel", key: "calls" } as const,
88 {} as Record<string, number>,
89);
90const skillsAtom = atom(
91 { plugin: "status-panel", key: "skills" } as const,
92 [] as SkillInfo[],
93);
94const skillCallsAtom = atom(
95 { plugin: "status-panel", key: "skillCalls" } as const,
96 {} as Record<string, number>,
97);
98/** MCP servers and skills switched off in the project's .claude/settings.local.json. */
99const disabledAtom = atom(
100 { plugin: "status-panel", key: "disabled" } as const,
101 { mcp: [], skills: [] } as Disabled,
102);
103/** Where the switches are written: personal, not committed, as `/skills` writes it. */
104const LOCAL_SETTINGS = ".claude/settings.local.json";
105
106/** The project's local settings text; `null` when there is no such file yet. */
107async function readLocalSettings($: EngineInterface) {
108 const cwd = await $.session.cwd();
109 const text = await $.fs.read(`${cwd}/${LOCAL_SETTINGS}`).catch(() => null);
110
111 return typeof text === "string" ? text : null;
112}
113
114/**
115 * Switches an MCP server (`deniedMcpServers`) or a skill (`skillOverrides`) on or off in the
116 * project's local settings. A file that is no JSON is left as it is and the toast says so.
117 */
118async function switchInSettings(
119 $: EngineInterface,
120 kind: "mcp" | "skill",
121 name: string,
122) {
123 const text = await readLocalSettings($);
124 const isOff =
125 disabledFromSettings(text)[kind === "mcp" ? "mcp" : "skills"].includes(
126 name,
127 );
128 const next = toggleInSettings(text, kind, name, isOff);
129 if (next === null) {
130 $.ui.toast(`${LOCAL_SETTINGS} is not valid JSON: nothing written`);
131
132 return;
133 }
134 const cwd = await $.session.cwd();
135 await $.fs.write(`${cwd}/${LOCAL_SETTINGS}`, next);
136 await update($, disabledAtom, () => disabledFromSettings(next));
137 $.ui.toast(
138 kind === "mcp"
139 ? `MCP ${name} ${isOff ? "on" : "off"}: applies when it reconnects (/mcp or a new session)`
140 : `Skill ${name} ${isOff ? "on" : "off"}`,
141 );
142}
143const modelAtom = atom({ plugin: "status-panel", key: "model" } as const, "");
144const effortAtom = atom(
145 { plugin: "status-panel", key: "effort" } as const,
146 "–",
147);
148const planAtom = atom({ plugin: "status-panel", key: "plan" } as const, "");
149/** Local estimate of this organization's spend for the current month across sessions (USD). */
150const spentAtom = atom({ plugin: "status-panel", key: "spent" } as const, 0);
151/** Usage beyond the plan as the account reports it; `null` until read. */
152const accountAtom = atom(
153 { plugin: "status-panel", key: "account" } as const,
154 null as AccountSpend | null,
155);
156const billingAtom = atom(
157 { plugin: "status-panel", key: "billing" } as const,
158 { orgId: "", isUsageBased: false } as Billing,
159);
160const agentsAtom = atom(
161 { plugin: "status-panel", key: "agents" } as const,
162 [] as SubagentInfo[],
163);
164const agentUsageAtom = atom(
165 { plugin: "status-panel", key: "agentUsage" } as const,
166 {} as Record<string, SubagentUsage>,
167);
168/** Robot animation frame counter; grows only during a turn. */
169const frameAtom = atom({ plugin: "status-panel", key: "frame" } as const, 0);
170const showResetsAtom = atom(
171 { plugin: "status-panel", key: "showResets" } as const,
172 false,
173);
174const keepAliveAtom = atom(
175 { plugin: "status-panel", key: "keepAlive" } as const,
176 { isOn: false, pings: 0, usd: 0 } as KeepAlive,
177);
178/** How long before the cache expires the keep-alive request goes out. */
179const KEEP_ALIVE_LEAD_MS = 60_000;
180/** The keep-alive request: the shortest question with the shortest answer. */
181const KEEP_ALIVE_PROMPT = "Reply with the single word: ok";
182/** Estimated session cost by token kind (main thread and subagents). */
183const costPartsAtom = atom(
184 { plugin: "status-panel", key: "costParts" } as const,
185 {
186 input: 0,
187 output: 0,
188 read: 0,
189 write: 0,
190 },
191);
192
193/**
194 * With keep-alive on, sends one tiny request over the session's own transcript shortly before
195 * the cache expires, so the API reads the prefix from the cache and its TTL starts again.
196 * Not once the cache expired: the request would write the whole context again.
197 * A failed request switches keep-alive off.
198 */
199async function keepCacheAlive(
200 $: EngineInterface,
201 now: number,
202 ttlMs: number,
203 ttlMinutes: number,
204) {
205 const keep = await read($, keepAliveAtom);
206 const cacheAt = await read($, cacheAtAtom);
207 if (!isKeepAliveDue(keep.isOn, cacheAt, ttlMs, KEEP_ALIVE_LEAD_MS, now)) {
208 return;
209 }
210 const r = await $.model.fork({ prompt: KEEP_ALIVE_PROMPT });
211 const usage = "usage" in r ? r.usage : null;
212 const cost =
213 usage === null
214 ? 0
215 : (stepCost(await read($, modelAtom), usage, ttlMinutes) ?? 0);
216 await update($, keepAliveAtom, (k) => ({
217 ...k,
218 isOn: r.isAnswered && k.isOn,
219 pings: k.pings + 1,
220 usd: k.usd + cost,
221 }));
222 if (!r.isAnswered) {
223 $.ui.toast(`keep-alive off: ${r.reason}`);
224 } else if (
225 r.usage.cache_read_input_tokens + r.usage.cache_creation_input_tokens >
226 0
227 ) {
228 const doneAt = await $.clock.now();
229 await update($, cacheAtAtom, () => doneAt);
230 }
231}
232
233/** Opens a path with the OS opener: `start` on Windows, `open` on macOS, `xdg-open` elsewhere. */
234async function openWithOs($: EngineInterface, path: string) {
235 if ((await $.env.get("OS")) === "Windows_NT") {
236 await $.process.run(["cmd", "/c", "start", "", path]);
237
238 return;
239 }
240 const isMac =
241 (await $.env.get("OSTYPE"))?.startsWith("darwin") ||
242 (await $.process.run(["uname"]).catch(() => null))?.stdout.startsWith(
243 "Darwin",
244 );
245 await $.process.run([isMac ? "open" : "xdg-open", path]);
246}
247
248/** How long a looked-up pull request is trusted before `gh` or `az` is asked again. */
249const PR_TTL_MS = 2 * 60_000;
250/** Last `gh` / `az` answer: the network call runs on a branch change or after PR_TTL_MS. */
251let prCache: { key: string; at: number; pr: PullRequest | null } | null = null;
252
253async function lookupPr($: EngineInterface, cwd: string, branch: string) {
254 const key = `${cwd}|${branch}`;
255 const now = await $.clock.now();
256 if (prCache?.key === key && now - prCache.at < PR_TTL_MS) return prCache.pr;
257 // Without the CLI, without a login or without a PR it fails: no link is shown.
258 const run = (argv: string[]) =>
259 $.process.run(argv, { cwd }).catch(() => null);
260 const remote = await run(["git", "remote", "get-url", "origin"]);
261 let pr: PullRequest | null;
262 if (remote?.exitCode === 0 && isAzureRemote(remote.stdout)) {
263 // `az` is a .cmd script on Windows, which only a shell starts.
264 const az =
265 (await $.env.get("OS")) === "Windows_NT" ? ["cmd", "/c", "az"] : ["az"];
266 const out = await run([
267 ...az,
268 "repos",
269 "pr",
270 "list",
271 "--detect",
272 "true",
273 "--source-branch",
274 branch,
275 "--status",
276 "active",
277 "--top",
278 "1",
279 "-o",
280 "json",
281 ]);
282 pr =
283 out?.exitCode === 0
284 ? parseAzurePullRequest(out.stdout, remote.stdout)
285 : null;
286 } else {
287 const out = await run(["gh", "pr", "view", "--json", "number,url,state"]);
288 pr = out?.exitCode === 0 ? parsePullRequest(out.stdout) : null;
289 }
290 prCache = { key, at: now, pr };
291
292 return pr;
293}
294
295async function refreshRepo($: EngineInterface) {
296 const cwd = await $.session.cwd();
297 // Outside a git repository this is expected; the branch simply stays empty.
298 const run = (argv: string[]) =>
299 $.process.run(argv, { cwd }).catch(() => null);
300 const named = await run(["git", "branch", "--show-current"]);
301 let branch = named?.exitCode === 0 ? named.stdout.trim() : "";
302 if (named?.exitCode === 0 && branch === "") {
303 const sha = await run(["git", "rev-parse", "--short", "HEAD"]);
304 branch = sha?.exitCode === 0 ? `(${sha.stdout.trim()})` : "";
305 }
306 // Without an upstream branch it exits non-zero: the arrows are then not shown.
307 const counts = await run([
308 "git",
309 "rev-list",
310 "--left-right",
311 "--count",
312 "@{upstream}...HEAD",
313 ]);
314 const sync = counts?.exitCode === 0 ? parseAheadBehind(counts.stdout) : null;
315 // A detached HEAD has no pull request.
316 const pr =
317 named?.exitCode === 0 && named.stdout.trim() !== ""
318 ? await lookupPr($, cwd, branch)
319 : null;
320 await update($, repoAtom, () => ({ cwd, branch, sync, pr }));
321}
322
323/** Subagents spawned this session, the cleared ones included. */
324const spawnedAgentsAtom = atom(
325 { plugin: "status-panel", key: "spawnedAgents" } as const,
326 0,
327);
328/** Subagents whose request and response are expanded in the session pane. */
329const expandedAgentsAtom = atom(
330 { plugin: "status-panel", key: "expandedAgents" } as const,
331 [] as string[],
332);
333/** Longest response kept per subagent; the pane is for a look, the transcript has the rest. */
334const MAX_AGENT_TEXT = 20_000;
335
336/** Agent type → the `color` of its definition (user's, then the project's, which wins). */
337const agentColorsAtom = atom(
338 { plugin: "status-panel", key: "agentColors" } as const,
339 {} as Record<string, string>,
340);
341
342/** Reads the colors of the agent definitions in ~/.claude/agents and the project's .claude/agents. */
343async function refreshAgentColors($: EngineInterface) {
344 const configDir = await $.env.get("CLAUDE_CONFIG_DIR");
345 const home = (await $.env.get("USERPROFILE")) ?? (await $.env.get("HOME"));
346 const userDir =
347 configDir ?? (home === undefined ? undefined : `${home}/.claude`);
348 const cwd = await $.session.cwd();
349 const dirs = [
350 ...(userDir === undefined ? [] : [`${userDir}/agents`]),
351 `${cwd}/.claude/agents`,
352 ];
353 const colors: Record<string, string> = {};
354 for (const dir of dirs) {
355 // A missing folder just has no agents.
356 const entries = await $.fs.list(dir).catch(() => []);
357 for (const f of entries) {
358 // A symbolic link is listed as `other`: read it too, the read follows it.
359 if (f.kind === "dir" || !f.name.endsWith(".md")) continue;
360 const text = await $.fs.read(`${dir}/${f.name}`).catch(() => null);
361 const def = typeof text === "string" ? parseAgentDefinition(text) : null;
362 if (def !== null) colors[def.name] = def.color;
363 }
364 }
365 await update($, agentColorsAtom, () => colors);
366}
367
368/** The pane with the session's tokens and subagents, docked beside the transcript in fullscreen. */
369const SESSION_PANE = "session";
370/** Wide enough for a subagent row: description, model, tokens, status and cost. */
371const SESSION_PANE_COLUMNS = 96;
372
373/** The pane with the CLAUDE.md and AGENTS.md files in context. */
374const MEMORY_PANE = "memory";
375/** Files whose preview is open in the CLAUDE.md pane. */
376const expandedFilesAtom = atom(
377 { plugin: "status-panel", key: "expandedFiles" } as const,
378 [] as string[],
379);
380/** File text by path, read when its preview opens. */
381const previewsAtom = atom(
382 { plugin: "status-panel", key: "previews" } as const,
383 {} as Record<string, string>,
384);
385
386/** The memory files the panel lists: CLAUDE.md, CLAUDE.local.md and AGENTS.md. */
387const memoryFiles = (mem: readonly MemFile[]) =>
388 mem.filter((f) => /^(claude(\.local)?|agents)\.md$/i.test(baseName(f.path)));
389
390/** Opens or closes a file's preview; opening reads the file again, so the preview is current. */
391async function togglePreview($: EngineInterface, path: string) {
392 const isOpen = (await read($, expandedFilesAtom)).includes(path);
393 await update($, expandedFilesAtom, (paths) =>
394 isOpen ? paths.filter((x) => x !== path) : [...paths, path],
395 );
396 if (isOpen) return;
397 const text = await $.fs.read(path).catch(() => null);
398 await update($, previewsAtom, (all) => ({
399 ...all,
400 [path]: typeof text === "string" ? text : "– the file could not be read",
401 }));
402}
403
404/** The pane with MCP servers and skills, next to the session pane. */
405const TOOLS_PANE = "tools";
406
407/**
408 * Opens a pane by id, or closes it when it is already up. Only one of this mod's panes is up
409 * at a time: opening one closes the other, so closing it never leaves the other behind.
410 */
411async function togglePane($: EngineInterface, id: string, title: string) {
412 const up = (await $.ui.panes()).map((pane) => pane.id);
413 if (up.includes(id)) {
414 await $.ui.close({ id });
415
416 return;
417 }
418 for (const other of up) await $.ui.close({ id: other });
419 const opened = await $.ui.open({ id, title, columns: SESSION_PANE_COLUMNS });
420 if (!opened.isPlaced) $.ui.toast(`${title} pane: ${opened.reason}`);
421}
422
423/** A pull is on its way; another click waits for it rather than starting a second one. */
424let isPulling = false;
425
426/**
427 * Brings the branch level with its upstream: `git pull --ff-only` moves it forward and never
428 * merges, so a diverged branch or local changes in the way leave everything as it was and the
429 * toast says why.
430 */
431async function pullUpstream($: EngineInterface) {
432 if (isPulling) return;
433 isPulling = true;
434 try {
435 const cwd = await $.session.cwd();
436 $.ui.toast("git pull --ff-only…");
437 const r = await $.process
438 .run(["git", "pull", "--ff-only"], { cwd })
439 .catch(() => null);
440 const firstLine = (text: string) => text.trim().split("\n")[0] ?? "";
441 $.ui.toast(
442 r === null
443 ? "git pull: git did not run"
444 : r.exitCode === 0
445 ? "Pulled: up to date with upstream"
446 : `git pull failed: ${firstLine(r.stderr) || firstLine(r.stdout)}`,
447 );
448 await refreshRepo($);
449 } finally {
450 isPulling = false;
451 }
452}
453
454async function refreshContext($: EngineInterface) {
455 const b = (await $.session.usage({ breakdown: "summary" })).context.breakdown;
456 const model = b?.model ?? "";
457 await update($, modelAtom, () => model);
458 const files = (b?.memoryFiles ?? []).map((f) => ({
459 path: f.path,
460 type: f.type,
461 tokens: f.tokens,
462 }));
463 await update($, memAtom, () => files);
464 const servers = groupMcp(b?.mcpTools ?? []);
465 await update($, mcpAtom, () => servers);
466 const skills = (b?.skills?.skillFrontmatter ?? []).map((s) => ({
467 name: s.name,
468 source: String(s.source),
469 tokens: s.tokens,
470 }));
471 await update($, skillsAtom, () => skills);
472 const local = await readLocalSettings($);
473 await update($, disabledAtom, () => disabledFromSettings(local));
474}
475
476async function refreshPlan($: EngineInterface) {
477 const dir =
478 (await $.env.get("CLAUDE_CONFIG_DIR")) ??
479 (await $.env.get("USERPROFILE")) ??
480 (await $.env.get("HOME"));
481 if (dir === undefined) return;
482 // A missing file just means we do not show the subscription.
483 const text = await $.fs.read(`${dir}/.claude.json`).catch(() => null);
484 const account = typeof text === "string" ? accountFromClaudeJson(text) : null;
485 if (account === null) return;
486 await update($, planAtom, () => account.plan);
487 const prev = await read($, billingAtom);
488 await update($, billingAtom, () => ({
489 orgId: account.orgId,
490 isUsageBased: account.isUsageBased,
491 }));
492 // After /login into another account the old figures do not apply.
493 if (prev.orgId !== "" && prev.orgId !== account.orgId) {
494 await update($, accountAtom, () => null);
495 await refreshAccount($);
496 }
497}
498
499const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
500
501/** Reads usage beyond the plan and prepaid credits of the signed-in account. */
502async function refreshAccount($: EngineInterface) {
503 // Null with an API key, a 3P provider or no login: there is no account to ask.
504 const auth = await $.session.authorize();
505 if (auth === null) return;
506 // Offline or rate limited: the line keeps the last figures.
507 const res = await $.http
508 .fetch(USAGE_URL, {
509 auth: auth.handle,
510 headers: {
511 Accept: "application/json",
512 "anthropic-beta": "oauth-2025-04-20",
513 },
514 })
515 .catch(() => null);
516 if (res?.ok !== true) return;
517 const spend = parseAccountSpend(res.text);
518 await update($, accountAtom, () => spend);
519}
520
521/**
522 * Writes this session's cost into the organization's monthly total in `$.store` and reflects
523 * the total in state. Only an Enterprise seat pays for every request, so only it is counted.
524 */
525async function refreshSpend(
526 $: EngineInterface,
527 startedAt: number,
528 usd: number,
529) {
530 const billing = await read($, billingAtom);
531 if (!billing.isUsageBased) return;
532 const key = monthKey(await $.clock.now(), billing.orgId);
533 const stored = (await $.store.get(key)) as Record<string, number> | undefined;
534 const sessions = { ...stored, [String(startedAt)]: usd };
535 await $.store.set(key, sessions);
536 const spent = Object.values(sessions).reduce((sum, v) => sum + v, 0);
537 await update($, spentAtom, () => spent);
538}
539
540async function refresh($: EngineInterface) {
541 const u = await $.session.usage();
542 const snap: Snap = {
543 ctxTokens: u.context.tokens ?? 0,
544 ctxWindow: u.context.window,
545 ctxPercent: u.context.percent ?? 0,
546 usd: u.cost?.usd ?? 0,
547 limits: u.rateLimits.map((l) => ({
548 kind: l.kind,
549 percentUsed: l.percentUsed,
550 resetsAt: l.resetsAt,
551 })),
552 };
553 await update($, snapAtom, () => snap);
554 await refreshSpend($, u.startedAt, snap.usd);
555}
556
557export const register: Register = (on, options) => {
558 const ttlMinutes = Number(options.ttlMinutes ?? "60");
559 const ttlMs = ttlMinutes * 60_000;
560 const budget = Number(options.budgetUsd ?? "250");
561 let prevTotal = 0;
562 let prevModel = "";
563 let prevEffort: unknown;
564 let isCompacted = false;
565 let ticks = 0;
566 let isWorking = false;
567 /** A keep-alive request is on its way; the next tick does not send another. */
568 let isPinging = false;
569 /** Subagents that sent a request and whose turn has not ended yet. */
570 const runningAgents = new Set<string>();
571
572 on("turn.start", ($, e, next) => {
573 isWorking = true;
574
575 return next(e);
576 });
577
578 on("session.start", async ($, e, next) => {
579 const startedAt = await $.clock.now();
580 await update($, nowAtom, () => startedAt);
581 // The plan decides whether this session's cost counts toward the month.
582 await refreshPlan($);
583 await refresh($);
584 await refreshRepo($);
585 await refreshContext($);
586 await refreshAccount($);
587 await refreshAgentColors($);
588 $.clock.every(250, async () => {
589 // The animation (Clawd, asterisks of running subagents) runs while anyone is working.
590 if (isWorking || runningAgents.size > 0) {
591 await update($, frameAtom, (f) => f + 1);
592 }
593 });
594 $.clock.every(1000, async () => {
595 const tickAt = await $.clock.now();
596 await update($, nowAtom, () => tickAt);
597 ticks += 1;
598 if (ticks % 15 === 0) await refresh($);
599 if (ticks % 30 === 0) {
600 await refreshRepo($);
601 await refreshContext($);
602 await refreshPlan($);
603 }
604 if (ticks % 120 === 0) await refreshAccount($);
605 // Not while a turn runs (the turn refreshes the cache itself) or a request is on its way.
606 if (!isWorking && !isPinging) {
607 isPinging = true;
608 try {
609 await keepCacheAlive($, tickAt, ttlMs, ttlMinutes);
610 } finally {
611 isPinging = false;
612 }
613 }
614 });
615
616 return next(e);
617 });
618
619 on("turn.complete", async ($, e, next) => {
620 const agentId = e.agentId;
621 if (agentId === undefined) {
622 isWorking = false;
623 } else {
624 runningAgents.delete(agentId);
625 // Subagent run time = sum of its completed turns.
626 await update($, agentUsageAtom, (all) => {
627 const cur = all[agentId] ?? emptyAgent();
628
629 return {
630 ...all,
631 [agentId]: {
632 ...cur,
633 // A record from an older version of the mod has no durationMs.
634 durationMs:
635 (Number.isFinite(cur.durationMs) ? cur.durationMs : 0) +
636 e.durationMs,
637 isDone: true,
638 },
639 };
640 });
641 }
642 await refreshRepo($);
643 await refreshContext($);
644 const r = await next(e);
645 // A subagent's last answer is its response; the session pane shows it under its request.
646 if (agentId !== undefined && r.text !== "") {
647 const result = r.text.slice(0, MAX_AGENT_TEXT);
648 await update($, agentsAtom, (list) =>
649 list.map((a) => (a.id === agentId ? { ...a, result } : a)),
650 );
651 }
652
653 return r;
654 });
655
656 on("tool.call", async ($, e, next) => {
657 await update($, callsAtom, (c) => ({
658 ...c,
659 [e.tool]: (c[e.tool] ?? 0) + 1,
660 }));
661 if (e.tool === "Skill") {
662 const name = e.skill;
663 await update($, skillCallsAtom, (c) => ({
664 ...c,
665 [name]: (c[name] ?? 0) + 1,
666 }));
667 }
668
669 return next(e);
670 });
671
672 on("session.compact", ($, e, next) => {
673 isCompacted = true;
674
675 return next(e);
676 });
677
678 on("agent.spawn", async ($, e, next) => {
679 const startedAt = await $.clock.now();
680 // An agent defined or recoloured since the session started gets its colour too.
681 await refreshAgentColors($);
682 const r = await next(e);
683 const id = r.agentId;
684 if (id !== undefined) {
685 const info = {
686 id,
687 description: e.description,
688 type: e.subagentType,
689 startedAt,
690 prompt: e.prompt,
691 };
692 await update($, agentsAtom, (list) => [
693 ...list.filter((a) => a.id !== id),
694 info,
695 ]);
696 await update($, spawnedAgentsAtom, (n) => n + 1);
697 }
698
699 return r;
700 });
701
702 on("turn.step", async function* ($, e, next) {
703 const sentAt = await $.clock.now();
704 const r = yield* next(e);
705 const u = r.usage;
706
707 if (u !== null) {
708 const total =
709 u.input_tokens +
710 u.cache_read_input_tokens +
711 u.cache_creation_input_tokens;
712 const hit = percentOf(
713 u.cache_read_input_tokens,
714 u.cache_creation_input_tokens,
715 u.input_tokens,
716 );
717 await update($, tallyAtom, (t) => ({
718 input: t.input + u.input_tokens,
719 output: t.output + u.output_tokens,
720 read: t.read + u.cache_read_input_tokens,
721 write: t.write + u.cache_creation_input_tokens,
722 lastHit: e.agentId === undefined ? hit : t.lastHit,
723 turns: t.turns + 1,
724 }));
725
726 const parts = stepCostParts(e.model, u, ttlMinutes);
727 await update($, costPartsAtom, (sum) => addCostParts(sum, parts));
728
729 const agentId = e.agentId;
730 if (agentId !== undefined) {
731 runningAgents.add(agentId);
732 const cost = stepCost(e.model, u, ttlMinutes);
733 await update($, agentUsageAtom, (all) => ({
734 ...all,
735 [agentId]: addAgentStep(
736 all[agentId],
737 e.model,
738 u,
739 cost,
740 effortShort(e.effort),
741 ),
742 }));
743 } else {
744 const lastAt = await read($, cacheAtAtom);
745 if (prevTotal > 0 && u.cache_read_input_tokens < prevTotal * 0.5) {
746 const reason = missReason({
747 isCompacted,
748 total,
749 prevTotal,
750 model: e.model,
751 prevModel,
752 isEffortChanged: e.effort !== prevEffort,
753 gapMs: lastAt > 0 ? sentAt - lastAt : 0,
754 ttlMs,
755 });
756 await update($, missAtom, () => ({ at: sentAt, reason }));
757 } else if (u.cache_read_input_tokens > 0) {
758 // The request reads from the cache again: the cache is refreshed, the message no longer applies.
759 await update($, missAtom, () => null);
760 }
761 isCompacted = false;
762 prevTotal = total;
763 prevModel = e.model;
764 prevEffort = e.effort;
765 await update($, effortAtom, () => effortShort(e.effort));
766 if (u.cache_read_input_tokens + u.cache_creation_input_tokens > 0) {
767 const doneAt = await $.clock.now();
768 await update($, cacheAtAtom, () => doneAt);
769 }
770 }
771 }
772 await refresh($);
773
774 return r;
775 });
776
777 on(
778 "ui.render",
779 { component: "Pane", requestId: SESSION_PANE },
780 async ($, e) => {
781 const snap = await read($, snapAtom);
782 const el = $.ui.resolve(e);
783 const { Text } = el;
784 const list = renderList(el, {
785 open: "session",
786 agents: await read($, agentsAtom),
787 agentUsage: await read($, agentUsageAtom),
788 sessionUsd: snap?.usd ?? 0,
789 now: await read($, nowAtom),
790 frame: await read($, frameAtom),
791 tally: await read($, tallyAtom),
792 costParts: await read($, costPartsAtom),
793 columns: e.props.bodyColumns,
794 expanded: await read($, expandedAgentsAtom),
795 agentColors: await read($, agentColorsAtom),
796 spawnedAgents: await read($, spawnedAgentsAtom),
797 onToggleAgent: async (id: string) => {
798 await update($, expandedAgentsAtom, (ids) =>
799 ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id],
800 );
801 },
802 onClearAgents: async () => {
803 // Running subagents stay, so their live status is not lost.
804 const usage = await read($, agentUsageAtom);
805 const isRunning = (id: string) => usage[id]?.isDone !== true;
806 await update($, agentsAtom, (list) =>
807 list.filter((a) => isRunning(a.id)),
808 );
809 await update($, agentUsageAtom, (all) =>
810 Object.fromEntries(
811 Object.entries(all).filter(([id]) => isRunning(id)),
812 ),
813 );
814 await update($, expandedAgentsAtom, (ids) => ids.filter(isRunning));
815 },
816 });
817
818 // The session kind always draws a list; the fallback only satisfies the type.
819 return list ?? <Text dimColor>No session data yet.</Text>;
820 },
821 );
822
823 on("ui.render", { component: "Pane", requestId: MEMORY_PANE }, async ($, e) =>
824 renderMemory($.ui.resolve(e), {
825 columns: e.props.bodyColumns,
826 cwd: (await read($, repoAtom)).cwd,
827 files: memoryFiles(await read($, memAtom)),
828 expanded: await read($, expandedFilesAtom),
829 previews: await read($, previewsAtom),
830 onToggle: async (path: string) => {
831 await togglePreview($, path);
832 },
833 onOpen: async (path: string) => {
834 await openWithOs($, path);
835 $.ui.toast(`Opening ${baseName(path)}`);
836 },
837 }),
838 );
839
840 on("ui.render", { component: "Pane", requestId: TOOLS_PANE }, async ($, e) =>
841 renderTools($.ui.resolve(e), {
842 columns: e.props.bodyColumns,
843 mcp: await read($, mcpAtom),
844 calls: await read($, callsAtom),
845 skills: await read($, skillsAtom),
846 skillCalls: await read($, skillCallsAtom),
847 disabledMcp: (await read($, disabledAtom)).mcp,
848 disabledSkills: (await read($, disabledAtom)).skills,
849 onToggleMcp: async (server: string) => {
850 await switchInSettings($, "mcp", server);
851 },
852 onToggleSkill: async (skill: string) => {
853 await switchInSettings($, "skill", skill);
854 },
855 }),
856 );
857
858 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
859 const snap = await read($, snapAtom);
860 if (e.props.hasSurvey || snap === null) return next(e);
861
862 const mem = await read($, memAtom);
863 const toggle = async (kind: Kind) => {
864 // The session breakdown is long: it opens in its own pane, not in the band.
865 if (kind === "session") {
866 await togglePane($, SESSION_PANE, "Session");
867
868 return;
869 }
870 // MCP servers and skills open in a pane of their own as well.
871 if (kind === "mcp") {
872 await togglePane($, TOOLS_PANE, "MCP & skills");
873
874 return;
875 }
876 // CLAUDE.md and AGENTS.md files open in a pane too.
877 await togglePane($, MEMORY_PANE, "CLAUDE.md");
878 };
879 // A file in the default app, a folder in the file manager.
880 const openPath = async (path: string) => {
881 await openWithOs($, path);
882 $.ui.toast(`Opening ${baseName(path)}`);
883 };
884
885 return renderBand($.ui.resolve(e), {
886 now: await read($, nowAtom),
887 ttlMs,
888 snap,
889 money: moneyLine(
890 await read($, billingAtom),
891 await read($, accountAtom),
892 await read($, spentAtom),
893 budget,
894 ),
895 tally: await read($, tallyAtom),
896 cacheAt: await read($, cacheAtAtom),
897 miss: await read($, missAtom),
898 repo: await read($, repoAtom),
899 model: await read($, modelAtom),
900 effort: await read($, effortAtom),
901 plan: await read($, planAtom),
902 claudeFiles: memoryFiles(mem),
903 mcp: await read($, mcpAtom),
904 onToggle: toggle,
905 onOpen: openPath,
906 isWorking: e.props.isWorking,
907 frame: await read($, frameAtom),
908 showResets: await read($, showResetsAtom),
909 keepAlive: await read($, keepAliveAtom),
910 onPull: async () => {
911 await pullUpstream($);
912 },
913 onToggleKeepAlive: async () => {
914 await update($, keepAliveAtom, (k) => ({ ...k, isOn: !k.isOn }));
915 },
916 onToggleResets: async () => {
917 await update($, showResetsAtom, (shown) => !shown);
918 },
919 });
920 });
921};
922hooks/band.tsx 355 lines1import type { ElementTable } from "claude-code";
2
3import type {
4 CostParts,
5 SubagentInfo,
6 SubagentUsage,
7 KeepAlive,
8 Kind,
9 McpServer,
10 MemFile,
11 Miss,
12 Money,
13 Repo,
14 Snap,
15 Tally,
16} from "../types";
17import {
18 baseName,
19 clock,
20 countdown,
21 green,
22 noLink,
23 orange,
24 red,
25 percentCell,
26 relIn,
27 resetAt,
28 scale,
29 shortModel,
30 thinBar,
31 tokens,
32 ttlScale,
33 usd,
34 money as formatMoney,
35 yellow,
36} from "./format";
37import { renderRobot } from "./robot";
38
39export type BandProps = {
40 now: number;
41 ttlMs: number;
42 snap: Snap;
43 /** Extra usage beyond a personal plan, or all spend this month on Enterprise. */
44 money: Money;
45 tally: Tally;
46 cacheAt: number;
47 miss: Miss;
48 repo: Repo;
49 model: string;
50 effort: string;
51 /** Subscription, e.g. `Max 5x`; empty when unknown. */
52 plan: string;
53 /** CLAUDE.md, CLAUDE.local.md and AGENTS.md files in context. */
54 claudeFiles: readonly MemFile[];
55 mcp: readonly McpServer[];
56 onToggle: (kind: Kind) => void;
57 /** Opens a file or folder in the system. */
58 onOpen: (path: string) => void;
59 /** A turn is running (`e.props.isWorking`): the robot is working. */
60 isWorking: boolean;
61 /** Robot animation frame counter. */
62 frame: number;
63 /** Show the absolute reset time next to limits. */
64 showResets: boolean;
65 onToggleResets: () => void;
66 keepAlive: KeepAlive;
67 onToggleKeepAlive: () => void;
68 /** Fast-forwards the branch to its upstream (`git pull --ff-only`). */
69 onPull: () => void;
70};
71
72/** Width of the context and money bars in cells. */
73const BAR_CELLS = 15;
74/** The money bar sits beside the short time limits: a shorter bar keeps that column narrow. */
75const MONEY_BAR_CELLS = 8;
76/** Width of a row label (`context`, `cache`) including the gap after it. */
77const LABEL_CELLS = 9;
78/**
79 * Column where CLAUDE.md (cache row) and keep-alive's spend (bottom row) start: under the token count
80 * on the context row (label, bar, a space, the four-cell percentage and two spaces).
81 */
82const KEEP_ALIVE_COLUMN = LABEL_CELLS + BAR_CELLS + 1 + 4 + 2;
83/** Width of the relative time (`1.5h`, `45m`) including a space so absolute times line up. */
84const REL_CELLS = 6;
85/** Above this context fill a cold cache is expensive: the frame turns orange. */
86const ALERT_CTX_PERCENT = 35;
87/** Above this context fill the frame always turns red, even with an active cache. */
88const FULL_CTX_PERCENT = 65;
89/** Calm: no background, border in the theme's muted color. */
90const CALM = { background: undefined, border: "subtle", style: "round" };
91const ALERT = { background: "#4a0a0a", border: red, style: "bold" };
92const WARN = { background: "#3d2305", border: orange, style: "round" };
93
94const hitColor = (hit: number) =>
95 hit >= 80 ? green : hit >= 50 ? yellow : red;
96
97const resetsAtMs = (resetsAt?: string) =>
98 resetsAt ? Date.parse(resetsAt) : null;
99
100/** The money row shows only with a known limit and extra usage not switched off. */
101export const hasMoneyRow = (money: Money) =>
102 money.limit !== null && !money.isOff;
103
104/** The whole band above the prompt in a quiet frame: header, meters with limits, links and the expanded list. */
105export function renderBand(el: ElementTable, p: BandProps) {
106 const { Box, Button, Link, Text } = el;
107
108 const five = p.snap.limits.find((l) => l.kind === "five_hour");
109 const week = p.snap.limits.find((l) => l.kind === "seven_day");
110 const fiveAt = resetsAtMs(five?.resetsAt);
111 const weekAt = resetsAtMs(week?.resetsAt);
112
113 const left = p.cacheAt > 0 ? p.cacheAt + p.ttlMs - p.now : 0;
114 const isExpired = p.cacheAt > 0 && left <= 0;
115 // Above 65 % always red; 35–65 % with a cold cache orange.
116 const theme =
117 p.snap.ctxPercent > FULL_CTX_PERCENT
118 ? ALERT
119 : isExpired && p.snap.ctxPercent > ALERT_CTX_PERCENT
120 ? WARN
121 : CALM;
122 const sum = p.tally.read + p.tally.write + p.tally.input;
123 const sessionHit = sum === 0 ? null : Math.round((p.tally.read / sum) * 100);
124 const { money } = p;
125 // Usage-based billing (Enterprise) pays per token: the 5h and 7d windows say nothing there.
126 const showsLimits = money.label !== "month";
127 const moneyPercent =
128 money.spent !== null && money.limit !== null && money.limit > 0
129 ? (money.spent / money.limit) * 100
130 : 0;
131
132 /** A row label, dim and padded so the bars start in one column. */
133 const label = (text: string) => (
134 <Text dimColor>{text.padEnd(LABEL_CELLS)}</Text>
135 );
136 /** A thin bar and its percentage, both in the fill's color. */
137 const meter = (percent: number, cells = BAR_CELLS, isTall = false) => {
138 const thin = thinBar(percent, cells);
139 // The tall bar draws the same cells as ▬, a block centred in the row (the eighth blocks
140 // ▁–▇ grow from the bottom and sit low); the empty part keeps the height, only dim.
141 const filled = isTall ? thin.filled.replaceAll("━", "▬") : thin.filled;
142 const empty = isTall ? thin.empty.replaceAll("─", "▬") : thin.empty;
143
144 return (
145 <Text>
146 <Text color={scale(percent)}>{filled}</Text>
147 <Text dimColor>{empty}</Text>
148 <Text color={scale(percent)}> {percentCell(percent)}</Text>
149 </Text>
150 );
151 };
152 const header = [
153 p.model === "" ? "–" : shortModel(p.model),
154 p.effort,
155 p.plan,
156 ].filter((part) => part !== "");
157
158 return (
159 <Box
160 flexDirection="column"
161 borderStyle={theme.style}
162 borderColor={theme.border}
163 backgroundColor={theme.background}
164 paddingX={1}
165 >
166 <Box justifyContent="space-between" columnGap={4}>
167 <Box columnGap={2}>
168 <Button key="k-folder" plain onPress={() => p.onOpen(p.repo.cwd)}>
169 <Text bold underline>
170 {baseName(p.repo.cwd)}
171 </Text>
172 </Button>
173 {p.repo.branch ? (
174 <Box>
175 <Text>
176 <Text color={green}>{p.repo.branch}</Text>
177 {p.repo.sync === null ? null : p.repo.sync.ahead === 0 &&
178 p.repo.sync.behind === 0 ? (
179 <Text dimColor> ✓</Text>
180 ) : p.repo.sync.ahead > 0 ? (
181 <Text color={green}> ↑{p.repo.sync.ahead}</Text>
182 ) : null}{" "}
183 </Text>
184 {/* Behind upstream: a click fast-forwards the branch. */}
185 {p.repo.sync !== null && p.repo.sync.behind > 0 ? (
186 <Button key="k-pull" plain onPress={p.onPull}>
187 <Text color={orange} underline>
188 ↓{p.repo.sync.behind}
189 </Text>
190 </Button>
191 ) : null}
192 {p.repo.pr ? (
193 <Text>
194 {" "}
195 <Link href={p.repo.pr.url} label={`PR#${p.repo.pr.number}`} />
196 </Text>
197 ) : null}
198 </Box>
199 ) : null}
200 </Box>
201 <Text dimColor>{header.join(" · ")}</Text>
202 </Box>
203 <Text> </Text>
204
205 <Box justifyContent="space-between" flexWrap="wrap" columnGap={3}>
206 <Box flexDirection="column">
207 <Text>
208 {label("context")}
209 {meter(p.snap.ctxPercent, BAR_CELLS, true)}
210 <Text dimColor>
211 {" "}
212 {tokens(p.snap.ctxTokens)} [{tokens(p.snap.ctxWindow)}]
213 </Text>
214 </Text>
215 <Box>
216 <Box width={KEEP_ALIVE_COLUMN}>
217 {/* The word cache switches keep-alive: a tiny request shortly before the TTL
218 runs out keeps the cache warm. The dot is green while it is on. */}
219 <Button key="k-keep" plain onPress={p.onToggleKeepAlive}>
220 <Text dimColor underline>
221 cache
222 </Text>
223 {p.keepAlive.isOn ? (
224 <Text color={green}> ●</Text>
225 ) : (
226 <Text dimColor> ●</Text>
227 )}
228 <Text>{" ".repeat(LABEL_CELLS - "cache ●".length)}</Text>
229 </Button>
230 <Text>
231 {p.tally.lastHit === null ? (
232 <Text dimColor>–</Text>
233 ) : (
234 <Text color={hitColor(p.tally.lastHit)}>
235 {p.tally.lastHit}%
236 </Text>
237 )}
238 <Text dimColor>
239 {" "}Σ {sessionHit ?? "–"}%{" "}
240 </Text>
241 {p.cacheAt === 0 ? (
242 <Text dimColor>–</Text>
243 ) : left > 0 ? (
244 <Text color={ttlScale(left, p.ttlMs)}>{countdown(left)}</Text>
245 ) : (
246 <Text bold color={red}>
247 expired
248 </Text>
249 )}
250 </Text>
251 </Box>
252 {/* The side panes start under the token count of the context row. */}
253 <Box columnGap={3}>
254 <Button key="k-claude" plain onPress={() => p.onToggle("claude")}>
255 {/* noLink: the terminal would turn a file name into a link that opens the file. */}
256 <Text underline>{noLink("CLAUDE.md")}</Text>
257 <Text dimColor> {p.claudeFiles.length}</Text>
258 </Button>
259 <Button key="k-mcp" plain onPress={() => p.onToggle("mcp")}>
260 <Text underline>MCP</Text>
261 <Text dimColor> {p.mcp.length}</Text>
262 </Button>
263 </Box>
264 </Box>
265 {p.miss ? (
266 <Text color={orange}>
267 {" ".repeat(LABEL_CELLS)}cache miss {clock(p.miss.at)} ·{" "}
268 {p.miss.reason}
269 </Text>
270 ) : null}
271 </Box>
272 <Box columnGap={3}>
273 {/* Clicking a limit shows or hides when the windows reset. */}
274 <Box flexDirection="column">
275 {showsLimits && five ? (
276 <Button key="k-5h" plain onPress={p.onToggleResets}>
277 <Text dimColor>5h </Text>
278 <Text color={scale(five.percentUsed)}>
279 {percentCell(five.percentUsed)}
280 </Text>
281 <Text dimColor>
282 {" "}
283 {(fiveAt ? relIn(fiveAt, p.now) : "").padEnd(REL_CELLS)}
284 {p.showResets && fiveAt ? `[${clock(fiveAt)}]` : ""}
285 </Text>
286 </Button>
287 ) : null}
288 {showsLimits && week ? (
289 <Button key="k-7d" plain onPress={p.onToggleResets}>
290 <Text dimColor>7d </Text>
291 <Text color={scale(week.percentUsed)}>
292 {percentCell(week.percentUsed)}
293 </Text>
294 <Text dimColor>
295 {" "}
296 {(weekAt ? relIn(weekAt, p.now) : "").padEnd(REL_CELLS)}
297 {p.showResets && weekAt ? resetAt(weekAt) : ""}
298 </Text>
299 </Button>
300 ) : null}
301 {/* Money under the time limits: both say how much of an allowance is used. */}
302 {hasMoneyRow(money) ? (
303 // With no limits above it (Enterprise) the amounts go on a row of their own
304 // under the bar, so the block stays narrow; otherwise all on one row.
305 <Box flexDirection={showsLimits ? "row" : "column"}>
306 <Text>
307 <Text dimColor>{money.label} </Text>
308 {meter(moneyPercent, MONEY_BAR_CELLS)}
309 {showsLimits ? <Text>{" "}</Text> : null}
310 </Text>
311 <Text dimColor>
312 {money.spent === null
313 ? "–"
314 : formatMoney(money.spent, money.currency)}{" "}
315 / {formatMoney(money.limit ?? 0, money.currency)}
316 </Text>
317 </Box>
318 ) : null}
319 </Box>
320 {renderRobot(el, {
321 isWorking: p.isWorking,
322 frame: p.frame,
323 now: p.now,
324 })}
325 </Box>
326 </Box>
327
328 <Box>
329 {/* Session cost and credits on the left, keep-alive's spend from the token column on. */}
330 <Box width={KEEP_ALIVE_COLUMN} columnGap={3}>
331 <Button key="k-session" plain onPress={() => p.onToggle("session")}>
332 {/* The same label width as context and cache: the amount sits under their values. */}
333 {label("session")}
334 <Text bold underline>
335 {usd(p.snap.usd)}
336 </Text>
337 </Button>
338 {money.balance === null ? null : (
339 <Text>
340 <Text dimColor>credits </Text>
341 {formatMoney(money.balance, money.currency)}
342 </Text>
343 )}
344 </Box>
345 {/* What keep-alive has sent and cost; nothing until its first request. */}
346 {p.keepAlive.pings === 0 ? null : (
347 <Text dimColor>
348 keep-alive {p.keepAlive.pings}× {usd(p.keepAlive.usd)}
349 </Text>
350 )}
351 </Box>
352 </Box>
353 );
354}
355hooks/format.ts 721 lines1import type {
2 AccountSpend,
3 Billing,
4 CostParts,
5 Money,
6 PullRequest,
7} from "../types";
8
9const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
10
11const pad = (n: number) => String(n).padStart(2, "0");
12
13export const green = "#22c55e";
14export const yellow = "#eab308";
15export const orange = "#f97316";
16export const red = "#ef4444";
17
18/** Usage color scale: green < 50, yellow < 75, orange < 90, red. */
19export const scale = (percent: number) =>
20 percent < 50 ? green : percent < 75 ? yellow : percent < 90 ? orange : red;
21
22/** Remaining cache time scale: the less is left, the redder. */
23export const ttlScale = (remainingMs: number, ttlMs: number) => {
24 const left = remainingMs / ttlMs;
25
26 return left > 0.5 ? green : left > 0.2 ? yellow : left > 0.05 ? orange : red;
27};
28
29export const tokens = (n: number) =>
30 n >= 1_000_000
31 ? `${Number((n / 1_000_000).toFixed(1))}M`
32 : n >= 1000
33 ? `${Math.round(n / 1000)}k`
34 : String(n);
35
36/** Amounts in US dollars (the engine's session cost, price-list estimates): `3.71$`. */
37export const usd = (n: number) => `${n.toFixed(2)}$`;
38
39const CURRENCY_SIGNS: Readonly<Record<string, string>> = {
40 USD: "$",
41 EUR: "€",
42 GBP: "£",
43 CZK: " Kč",
44};
45
46/** An amount in the account's currency, the sign after the number: `3.71€`; `null` is dollars. */
47export const money = (n: number, currency: string | null) =>
48 `${n.toFixed(2)}${currency === null ? "$" : (CURRENCY_SIGNS[currency] ?? ` ${currency}`)}`;
49
50export const clock = (ms: number) => {
51 const d = new Date(ms);
52
53 return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
54};
55
56/** Time left until reset: from a day `5d5h`, from an hour `1.5h`, otherwise `45m`. */
57export const relIn = (resetsAtMs: number, nowMs: number) => {
58 const minutes = Math.max(0, (resetsAtMs - nowMs) / 60000);
59 if (minutes >= 24 * 60) {
60 const hours = Math.floor(minutes / 60);
61
62 return `${Math.floor(hours / 24)}d${hours % 24}h`;
63 }
64
65 return minutes >= 60
66 ? `${(minutes / 60).toFixed(1)}h`
67 : `${Math.round(minutes)}m`;
68};
69
70/** Percentage right-aligned to 4 cells (` 7%`, ` 31%`, `100%`) so the time after it lines up. */
71export const percentCell = (percent: number) =>
72 `${Math.round(percent)}%`.padStart(4);
73
74/** Absolute time only: `[Mon 12.10. 08:00]`. */
75export const resetAt = (resetsAtMs: number) => {
76 const d = new Date(resetsAtMs);
77
78 return `[${DAYS[d.getDay()]} ${d.getDate()}.${d.getMonth() + 1}. ${clock(resetsAtMs)}]`;
79};
80
81/** `mm:ss` until cache invalidation, `h:mm:ss` above an hour. */
82export const countdown = (ms: number) => {
83 const s = Math.max(0, Math.floor(ms / 1000));
84 const h = Math.floor(s / 3600);
85 const m = Math.floor((s % 3600) / 60);
86
87 return h > 0 ? `${h}:${pad(m)}:${pad(s % 60)}` : `${pad(m)}:${pad(s % 60)}`;
88};
89
90export const percentOf = (read: number, write: number, input: number) => {
91 const total = read + write + input;
92
93 return total === 0 ? null : Math.round((read / total) * 100);
94};
95
96const slashes = (p: string) => p.replace(/\\/g, "/");
97
98export const baseName = (path: string) =>
99 slashes(path).split("/").filter(Boolean).pop() ?? path;
100
101/** Path relative to the session folder, otherwise the last three segments. */
102export const shortPath = (path: string, cwd: string) => {
103 const p = slashes(path);
104 const root = slashes(cwd).replace(/\/$/, "");
105
106 return root !== "" && p.toLowerCase().startsWith(`${root.toLowerCase()}/`)
107 ? p.slice(root.length + 1)
108 : `…/${p.split("/").slice(-3).join("/")}`;
109};
110
111const EFFORT: Record<string, string> = {
112 low: "L",
113 medium: "M",
114 high: "H",
115 xhigh: "XH",
116 max: "MAX",
117};
118
119/** `low`→L, `medium`→M, `high`→H, `xhigh`→XH, `max`→MAX; a numeric budget as `8k`. */
120export const effortShort = (effort: string | number | undefined) =>
121 effort === undefined
122 ? "–"
123 : typeof effort === "number"
124 ? tokens(effort)
125 : (EFFORT[effort] ?? effort);
126
127/** `git rev-list --left-right --count @{u}...HEAD` → `{ behind, ahead }`. */
128export const parseAheadBehind = (out: string) => {
129 const [behind, ahead] = out.trim().split(/\s+/).map(Number);
130
131 return behind !== undefined &&
132 ahead !== undefined &&
133 Number.isFinite(behind) &&
134 Number.isFinite(ahead)
135 ? { behind, ahead }
136 : null;
137};
138
139const wire = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_");
140
141/** Sum of tool calls of one MCP server (server name as /mcp writes it). */
142export const serverCalls = (
143 calls: Readonly<Record<string, number>>,
144 server: string,
145) =>
146 Object.entries(calls)
147 .filter(([name]) => name.startsWith(`mcp__${wire(server)}__`))
148 .reduce((sum, [, n]) => sum + n, 0);
149
150/** An `origin` URL of Azure DevOps (`dev.azure.com`, `*.visualstudio.com`, also over SSH). */
151export const isAzureRemote = (url: string) =>
152 /(^|[@./])(dev\.azure\.com|visualstudio\.com)[:/]/i.test(url.trim());
153
154/**
155 * `az repos pr list --status active -o json` → the first pull request with its web page;
156 * an empty list or broken JSON → `null`.
157 */
158/**
159 * Web page of an Azure DevOps repository from its `origin` URL:
160 * `https://org@dev.azure.com/o/p/_git/r` or `git@ssh.dev.azure.com:v3/o/p/r` → `https://dev.azure.com/o/p/_git/r`.
161 */
162export const azureWebUrl = (remote: string) => {
163 const url = remote.trim().replace(/\.git$/, "");
164 const ssh = /[:/]v3\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(url);
165 if (ssh) return `https://dev.azure.com/${ssh[1]}/${ssh[2]}/_git/${ssh[3]}`;
166
167 return url.replace(/^https:\/\/[^@/]+@/, "https://");
168};
169
170/**
171 * `az repos pr list --status active -o json` → the first pull request with its web page
172 * (the repository's `webUrl`, else derived from `origin`); an empty list or broken JSON → `null`.
173 */
174export const parseAzurePullRequest = (
175 out: string,
176 remote: string,
177): PullRequest | null => {
178 let list: unknown;
179 try {
180 list = JSON.parse(out);
181 } catch (error) {
182 if (!(error instanceof SyntaxError)) throw error;
183
184 return null;
185 }
186 const pr = (Array.isArray(list) ? list[0] : undefined) as
187 | { pullRequestId?: unknown; repository?: { webUrl?: unknown } }
188 | undefined;
189 const id = pr?.pullRequestId;
190 const webUrl = pr?.repository?.webUrl;
191 const web = typeof webUrl === "string" ? webUrl : azureWebUrl(remote);
192
193 return typeof id === "number"
194 ? { number: id, url: `${web}/pullrequest/${id}` }
195 : null;
196};
197
198/** `gh pr view --json number,url,state` → the open pull request; closed, merged or broken JSON → `null`. */
199export const parsePullRequest = (out: string): PullRequest | null => {
200 let pr: { number?: unknown; url?: unknown; state?: unknown };
201 try {
202 pr = JSON.parse(out) as typeof pr;
203 } catch (error) {
204 if (!(error instanceof SyntaxError)) throw error;
205
206 return null;
207 }
208
209 return pr.state === "OPEN" &&
210 typeof pr.number === "number" &&
211 typeof pr.url === "string"
212 ? { number: pr.number, url: pr.url }
213 : null;
214};
215
216/** `claude_max` + `default_claude_max_5x` → `Max 5x`; without an OAuth account `API`. */
217export const planLabel = (
218 organizationType?: string,
219 rateLimitTier?: string,
220) => {
221 if (!organizationType) return "API";
222 const base = organizationType.replace(/^claude_/, "");
223 const name = base.charAt(0).toUpperCase() + base.slice(1);
224 const multiplier = /(\d+x)$/.exec(rateLimitTier ?? "")?.[1];
225
226 return multiplier ? `${name} ${multiplier}` : name;
227};
228
229/**
230 * Replaces dots with `․` (U+2024), which looks the same but the terminal does
231 * not recognize the text as a file path, so clicking it opens nothing.
232 */
233export const noLink = (text: string) => text.replaceAll(".", "․");
234
235type Price = { input: number; output: number; read: number };
236
237/** USD per MTok (price list 2026-10); a longer prefix must come before a shorter one. */
238const PRICES: readonly (readonly [string, Price])[] = [
239 ["claude-fable-5-1", { input: 10, output: 50, read: 0.25 }],
240 ["claude-mythos-5-1", { input: 10, output: 50, read: 0.25 }],
241 ["claude-fable-5", { input: 10, output: 50, read: 1 }],
242 ["claude-opus-5-5", { input: 4, output: 20, read: 0.2 }],
243 ["claude-opus-5", { input: 5, output: 25, read: 0.5 }],
244 ["claude-opus-4", { input: 5, output: 25, read: 0.5 }],
245 ["claude-sonnet-5-5", { input: 2, output: 10, read: 0.2 }],
246 ["claude-sonnet-5", { input: 2, output: 10, read: 0.2 }],
247 ["claude-sonnet-4", { input: 3, output: 15, read: 0.3 }],
248 ["claude-haiku-5-5", { input: 0.1, output: 0.5, read: 0.01 }],
249 ["claude-haiku-4", { input: 1, output: 5, read: 0.1 }],
250];
251
252export type StepUsage = {
253 input_tokens: number;
254 output_tokens: number;
255 cache_read_input_tokens: number;
256 cache_creation_input_tokens: number;
257};
258
259/**
260 * Estimated cost of one request by token kind; `null` for a model not in the price list.
261 * A cache write costs 1.25× input at a 5 min TTL and 2× at a 1 h TTL.
262 */
263export const stepCostParts = (
264 model: string,
265 u: StepUsage,
266 ttlMinutes: number,
267): CostParts | null => {
268 const id = model.replace(/\[.*\]$/, "");
269 const price = PRICES.find(([prefix]) => id.startsWith(prefix))?.[1];
270 if (price === undefined) return null;
271 const write = price.input * (ttlMinutes >= 60 ? 2 : 1.25);
272
273 return {
274 input: (u.input_tokens * price.input) / 1_000_000,
275 output: (u.output_tokens * price.output) / 1_000_000,
276 read: (u.cache_read_input_tokens * price.read) / 1_000_000,
277 write: (u.cache_creation_input_tokens * write) / 1_000_000,
278 };
279};
280
281/** Estimated cost of one request in USD; `null` for a model not in the price list. */
282export const stepCost = (model: string, u: StepUsage, ttlMinutes: number) => {
283 const parts = stepCostParts(model, u, ttlMinutes);
284
285 return parts === null
286 ? null
287 : parts.input + parts.output + parts.read + parts.write;
288};
289
290/** Adds one request's breakdown to the sum; an unpriced request leaves the sum unchanged. */
291export const addCostParts = (
292 sum: CostParts,
293 parts: CostParts | null,
294): CostParts =>
295 parts === null
296 ? sum
297 : {
298 input: sum.input + parts.input,
299 output: sum.output + parts.output,
300 read: sum.read + parts.read,
301 write: sum.write + parts.write,
302 };
303
304/** Month key in `$.store` for one organization's spend total: `spend:2026-10:<org>`. */
305export const monthKey = (ms: number, orgId: string) => {
306 const d = new Date(ms);
307
308 return `spend:${d.getFullYear()}-${pad(d.getMonth() + 1)}:${orgId}`;
309};
310
311export type MissInput = {
312 isCompacted: boolean;
313 total: number;
314 prevTotal: number;
315 model: string;
316 prevModel: string;
317 isEffortChanged: boolean;
318 gapMs: number;
319 ttlMs: number;
320};
321
322/** `45 min`, from an hour `11.7 h`. */
323export const duration = (ms: number) => {
324 const minutes = ms / 60_000;
325
326 return minutes < 60
327 ? `${Math.round(minutes)} min`
328 : `${(minutes / 60).toFixed(1)} h`;
329};
330
331/** Most likely cause of a cache miss; it is a guess based on the order of signals. */
332export const missReason = (m: MissInput) =>
333 m.isCompacted
334 ? "compact"
335 : m.total < m.prevTotal * 0.5
336 ? "context shrank (/clear)"
337 : m.model !== m.prevModel
338 ? `model changed (${m.model})`
339 : m.isEffortChanged
340 ? "effort changed"
341 : m.gapMs > m.ttlMs
342 ? `idle ${duration(m.gapMs)} (TTL ${duration(m.ttlMs)}), cache expired`
343 : "unknown cause (system prompt / tools / MCP?)";
344
345export type McpTool = { serverName: string; isLoaded: boolean; tokens: number };
346
347/** Tools from the breakdown grouped by server; an active server has a tool in context. */
348export const groupMcp = (tools: readonly McpTool[]) => {
349 const servers = new Map<
350 string,
351 { server: string; tools: number; loaded: number; tokens: number }
352 >();
353 for (const t of tools) {
354 const cur = servers.get(t.serverName) ?? {
355 server: t.serverName,
356 tools: 0,
357 loaded: 0,
358 tokens: 0,
359 };
360 servers.set(t.serverName, {
361 ...cur,
362 tools: cur.tools + 1,
363 loaded: cur.loaded + (t.isLoaded ? 1 : 0),
364 tokens: cur.tokens + t.tokens,
365 });
366 }
367
368 return [...servers.values()];
369};
370
371export type AgentTotals = {
372 model: string;
373 /** All tokens burned: input, cache read, cache write and output. */
374 tokens: number;
375 usd: number | null;
376 /** Sum of the durations of the subagent's completed turns. */
377 durationMs: number;
378 /** The subagent finished: its turn ended and no further request has arrived yet. */
379 isDone: boolean;
380 /** Effort abbreviation of the last request; `–` for a model without effort. */
381 effort: string;
382};
383
384/** Empty subagent total that requests and turns are added to. */
385export const emptyAgent = (model = ""): AgentTotals => ({
386 model,
387 tokens: 0,
388 usd: 0,
389 durationMs: 0,
390 isDone: false,
391 effort: "–",
392});
393
394/**
395 * Subagent status for the list: icon `✅` and run time once finished, an asterisk and
396 * `running 1m20s` while running. The icon is always two cells (`✅` is wide, the
397 * asterisk gets a space) so the status text lines up. A missing or invalid
398 * value (record from an older version of the mod) gives an empty time, never `NaN`.
399 */
400export const agentStatus = (
401 startedAt: number | undefined,
402 usage: Partial<Pick<AgentTotals, "isDone" | "durationMs">> | undefined,
403 now: number,
404 frame: number,
405) => {
406 const isValid = (ms: number | undefined): ms is number =>
407 ms !== undefined && Number.isFinite(ms);
408 if (usage?.isDone === true) {
409 const text =
410 isValid(usage.durationMs) && usage.durationMs > 0
411 ? elapsed(usage.durationMs)
412 : "";
413
414 return { icon: "✅", text };
415 }
416 const text = isValid(startedAt)
417 ? `running ${elapsed(now - startedAt)}`
418 : "running";
419
420 return { icon: `${spinner(frame)} `, text };
421};
422
423/** Spinner asterisks as in Claude Code. */
424const SPIN = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
425
426/** Animated asterisk frame for the given frame counter. */
427export const spinner = (frame: number) => SPIN[frame % SPIN.length] ?? "·";
428
429/** Adds one subagent request; the cost stays `null` once a single request could not be priced. */
430export const addAgentStep = (
431 cur: AgentTotals | undefined,
432 model: string,
433 u: StepUsage,
434 cost: number | null,
435 effort = "–",
436): AgentTotals => {
437 const base = cur ?? emptyAgent(model);
438
439 return {
440 model,
441 tokens:
442 base.tokens +
443 u.input_tokens +
444 u.output_tokens +
445 u.cache_read_input_tokens +
446 u.cache_creation_input_tokens,
447 usd: base.usd === null || cost === null ? null : base.usd + cost,
448 durationMs: base.durationMs,
449 // A new request means the subagent (even a resumed one) is running again.
450 isDone: false,
451 effort,
452 };
453};
454
455/** `5s`, `3m12s`, od hodiny `1h05m`. */
456export const elapsed = (ms: number) => {
457 const s = Math.max(0, Math.round(ms / 1000));
458 if (s < 60) return `${s}s`;
459 const m = Math.floor(s / 60);
460 if (m < 60) return `${m}m${pad(s % 60)}s`;
461
462 return `${Math.floor(m / 60)}h${pad(m % 60)}m`;
463};
464
465/**
466 * Takes the plan label and billing from the contents of `~/.claude.json`; broken JSON → `null`.
467 * An Enterprise seat pays for every request, a personal subscription only for extra usage.
468 */
469export const accountFromClaudeJson = (
470 text: string,
471): ({ plan: string } & Billing) | null => {
472 type Account = {
473 organizationUuid?: string;
474 organizationType?: string;
475 organizationRateLimitTier?: string;
476 };
477 let account: Account | undefined;
478 try {
479 account = (JSON.parse(text) as { oauthAccount?: Account }).oauthAccount;
480 } catch (error) {
481 if (!(error instanceof SyntaxError)) throw error;
482
483 return null;
484 }
485
486 return {
487 plan: planLabel(
488 account?.organizationType,
489 account?.organizationRateLimitTier,
490 ),
491 orgId: account?.organizationUuid ?? "",
492 isUsageBased: account?.organizationType === "claude_enterprise",
493 };
494};
495
496/** `{ amount_minor: 1234, exponent: 2 }` or a plain number → USD; anything else → `null`. */
497const dollars = (value: unknown) => {
498 if (typeof value === "number") return value;
499 const m = value as { amount_minor?: unknown; exponent?: unknown } | null;
500 if (typeof m?.amount_minor !== "number") return null;
501
502 return (
503 m.amount_minor / 10 ** (typeof m.exponent === "number" ? m.exponent : 2)
504 );
505};
506
507/** The ISO currency code of a `{ amount_minor, currency }` amount; `null` without one. */
508const currencyOf = (value: unknown) => {
509 const c = (value as { currency?: unknown } | null)?.currency;
510
511 return typeof c === "string" && c !== "" ? c.toUpperCase() : null;
512};
513
514/** The `spend` block of `api/oauth/usage` (usage beyond the plan); missing or broken JSON → `null`. */
515export const parseAccountSpend = (text: string): AccountSpend | null => {
516 let spend: Record<string, unknown> | null | undefined;
517 try {
518 spend = (JSON.parse(text) as { spend?: Record<string, unknown> | null })
519 .spend;
520 } catch (error) {
521 if (!(error instanceof SyntaxError)) throw error;
522
523 return null;
524 }
525 if (spend === null || spend === undefined) return null;
526
527 return {
528 used: dollars(spend.used) ?? 0,
529 limit: dollars(spend.limit) ?? dollars(spend.cap),
530 balance: dollars(spend.balance),
531 isEnabled: spend.enabled === true,
532 currency:
533 currencyOf(spend.used) ??
534 currencyOf(spend.limit) ??
535 currencyOf(spend.balance),
536 };
537};
538
539/**
540 * What the money line shows. A personal subscription: only extra usage beyond the plan
541 * from the account (`null` until it is read). Enterprise: all spend this month against the
542 * budget, from the account once it reports it, otherwise the local estimate.
543 */
544export const moneyLine = (
545 billing: Billing,
546 account: AccountSpend | null,
547 localSpent: number,
548 budget: number,
549): Money =>
550 billing.isUsageBased
551 ? {
552 label: "month",
553 spent: account?.isEnabled === true ? account.used : localSpent,
554 limit: account?.limit ?? budget,
555 balance: account?.balance ?? null,
556 isOff: false,
557 // Without the account's own figures the amounts are the local estimate in dollars.
558 currency: account?.isEnabled === true ? account.currency : null,
559 }
560 : {
561 label: "extra",
562 spent: account?.used ?? null,
563 limit: account?.limit ?? null,
564 balance: account?.balance ?? null,
565 isOff: account !== null && !account.isEnabled,
566 currency: account?.currency ?? null,
567 };
568
569/** Thin bar `━━━───` split into a filled and an empty part so they can be colored separately. */
570export const thinBar = (percent: number, cells: number) => {
571 const filled = Math.max(
572 0,
573 Math.min(cells, Math.round((percent / 100) * cells)),
574 );
575
576 return { filled: "━".repeat(filled), empty: "─".repeat(cells - filled) };
577};
578
579/** `claude-opus-5-5` → `opus-5-5`. */
580export const shortModel = (model: string) => model.replace(/^claude-/, "");
581
582/**
583 * Whether keep-alive should send its request now: switched on, a cache to keep, not expired
584 * yet (a request would write the whole context again) and at most `leadMs` left of its TTL.
585 */
586export const isKeepAliveDue = (
587 isOn: boolean,
588 cacheAt: number,
589 ttlMs: number,
590 leadMs: number,
591 now: number,
592) => {
593 const left = cacheAt + ttlMs - now;
594
595 return isOn && cacheAt > 0 && left > 0 && left <= leadMs;
596};
597
598/** `name` and `color` from an agent definition's frontmatter; `null` without both. */
599export const parseAgentDefinition = (text: string) => {
600 const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1];
601 if (front === undefined) return null;
602 const field = (key: string) =>
603 new RegExp(`^${key}:\\s*["']?([^"'\\r\\n]+?)["']?\\s*$`, "m")
604 .exec(front)?.[1]
605 ?.trim();
606 const name = field("name");
607 const color = field("color");
608
609 return name && color ? { name, color } : null;
610};
611
612/** Agent colors are names Claude Code knows; the terminal knows purple as magenta. */
613const AGENT_COLORS: Readonly<Record<string, string>> = {
614 purple: "magenta",
615 pink: "magentaBright",
616 orange: "#ff8c00",
617};
618
619/** The terminal color an agent definition's `color` is drawn in. */
620export const agentTerminalColor = (color: string) =>
621 AGENT_COLORS[color.toLowerCase()] ?? color.toLowerCase();
622
623/** Active first, then the most called, then by name: the order of the MCP & skills pane. */
624export const sortByUse = <
625 T extends { name: string; calls: number; isOn: boolean },
626>(
627 items: readonly T[],
628): T[] =>
629 [...items].sort(
630 (a, b) =>
631 Number(b.isOn) - Number(a.isOn) ||
632 b.calls - a.calls ||
633 a.name.localeCompare(b.name),
634 );
635
636/** Switched-off MCP servers and skills, as a settings file holds them. */
637export type Disabled = { mcp: string[]; skills: string[] };
638
639/** Parses a settings file as an object; `null` when it is no JSON object. */
640const settingsObject = (text: string): Record<string, unknown> | null => {
641 try {
642 const value: unknown = JSON.parse(text);
643
644 return typeof value === "object" && value !== null && !Array.isArray(value)
645 ? (value as Record<string, unknown>)
646 : null;
647 } catch (error) {
648 if (!(error instanceof SyntaxError)) throw error;
649
650 return null;
651 }
652};
653
654/** MCP servers named in `deniedMcpServers` and skills set to `"off"` in `skillOverrides`. */
655export const disabledFromSettings = (text: string | null): Disabled => {
656 const s = text === null ? null : settingsObject(text);
657 const denied = Array.isArray(s?.deniedMcpServers) ? s.deniedMcpServers : [];
658 const overrides =
659 typeof s?.skillOverrides === "object" && s.skillOverrides !== null
660 ? (s.skillOverrides as Record<string, unknown>)
661 : {};
662
663 return {
664 mcp: denied
665 .map((d) => (d as { serverName?: unknown } | null)?.serverName)
666 .filter((n): n is string => typeof n === "string"),
667 skills: Object.entries(overrides)
668 .filter(([, v]) => v === "off")
669 .map(([k]) => k),
670 };
671};
672
673/**
674 * The settings text with one MCP server or skill switched on or off; every other key kept.
675 * `null` when the existing text is no JSON object: nothing is written over it then.
676 */
677export const toggleInSettings = (
678 text: string | null,
679 kind: "mcp" | "skill",
680 name: string,
681 isOn: boolean,
682): string | null => {
683 const s = text === null || text.trim() === "" ? {} : settingsObject(text);
684 if (s === null) return null;
685 const next: Record<string, unknown> = { ...s };
686 if (kind === "mcp") {
687 const denied = (
688 Array.isArray(s.deniedMcpServers) ? s.deniedMcpServers : []
689 ).filter(
690 (d) => (d as { serverName?: unknown } | null)?.serverName !== name,
691 );
692 const list = isOn ? denied : [...denied, { serverName: name }];
693 if (list.length === 0) delete next.deniedMcpServers;
694 else next.deniedMcpServers = list;
695 } else {
696 const overrides = {
697 ...(typeof s.skillOverrides === "object" && s.skillOverrides !== null
698 ? (s.skillOverrides as Record<string, unknown>)
699 : {}),
700 };
701 if (isOn) delete overrides[name];
702 else overrides[name] = "off";
703 if (Object.keys(overrides).length === 0) delete next.skillOverrides;
704 else next.skillOverrides = overrides;
705 }
706
707 return `${JSON.stringify(next, null, 2)}\n`;
708};
709
710/** Where an MCP server comes from, as far as its name tells: `plugin`, `claude.ai`, or nothing. */
711export const mcpTag = (server: string) =>
712 /^plugin[_:]/i.test(server)
713 ? "plugin"
714 : /^claude[_.]ai[_ ]/i.test(server)
715 ? "claude.ai"
716 : "";
717
718/** A skill's origin worth a tag: Claude Code's own (`system`) or a plugin's. */
719export const skillTag = (source: string) =>
720 source === "built-in" ? "system" : source === "plugin" ? "plugin" : "";
721hooks/lists.tsx 228 lines1import type { ElementTable } from "claude-code";
2
3import type {
4 CostParts,
5 SubagentInfo,
6 SubagentUsage,
7 Kind,
8 Tally,
9} from "../types";
10import {
11 agentStatus,
12 agentTerminalColor,
13 shortModel,
14 tokens,
15 usd,
16} from "./format";
17
18/** Longest subagent description including its type; longer ones are cut to "…". */
19const MAX_LABEL = 40;
20
21export type ListProps = {
22 open: Kind | null;
23 agents: readonly SubagentInfo[];
24 agentUsage: Readonly<Record<string, SubagentUsage>>;
25 sessionUsd: number;
26 /** Current time in milliseconds, for the run time of a subagent that has not finished yet. */
27 now: number;
28 /** Animation frame counter for the asterisk of a running subagent. */
29 frame: number;
30 /** Session tokens by kind (main thread and subagents). */
31 tally: Tally;
32 costParts: CostParts;
33 /** Width of the area the list is drawn in, for the separator lines. */
34 columns?: number;
35 /** Subagents whose request and response are shown. */
36 expanded?: readonly string[];
37 /** Expands or collapses one subagent. */
38 onToggleAgent?: (id: string) => void;
39 /** Agent type → the `color` of its definition. */
40 agentColors?: Readonly<Record<string, string>>;
41 /** Removes the finished subagents from the list. */
42 onClearAgents?: () => void;
43 /** Subagents spawned this session, the cleared ones included. */
44 spawnedAgents?: number;
45};
46
47/** Expanded list below the button row; `null` when nothing is open. */
48export function renderList(el: ElementTable, p: ListProps) {
49 const { Box, Button, Text } = el;
50
51 if (p.open === "session") {
52 // A subagent without a spawn record (e.g. started before the mod loaded) has only an id.
53 const ids = [
54 ...new Set([...p.agents.map((a) => a.id), ...Object.keys(p.agentUsage)]),
55 ];
56 const info = new Map(p.agents.map((a) => [a.id, a]));
57 const agentsUsd = ids.reduce(
58 (sum, id) => sum + (p.agentUsage[id]?.usd ?? 0),
59 0,
60 );
61 const agentsTokens = ids.reduce(
62 (sum, id) => sum + (p.agentUsage[id]?.tokens ?? 0),
63 0,
64 );
65
66 const totalTokens =
67 p.tally.input + p.tally.read + p.tally.write + p.tally.output;
68 const totalUsd =
69 p.costParts.input +
70 p.costParts.output +
71 p.costParts.read +
72 p.costParts.write;
73
74 // Subagent rows in columns: description, model, tokens, status and cost aligned.
75 const rows = ids.map((id) => {
76 const a = info.get(id);
77 const u = p.agentUsage[id];
78 const type = ` (${a?.type ?? "?"})`;
79 const rawDesc = a?.description ?? id;
80 const room = Math.max(1, MAX_LABEL - type.length);
81 const desc =
82 rawDesc.length > room ? `${rawDesc.slice(0, room - 1)}…` : rawDesc;
83
84 return {
85 id,
86 desc,
87 type,
88 model: u !== undefined && u.model !== "" ? shortModel(u.model) : "–",
89 effort:
90 u?.effort !== undefined && u.effort !== "–" ? ` (${u.effort})` : "",
91 tokens: tokens(u?.tokens ?? 0),
92 status: agentStatus(a?.startedAt, u, p.now, p.frame),
93 cost: u?.usd === null || u === undefined ? "?" : `≈${usd(u.usd)}`,
94 };
95 });
96 const widest = (values: readonly string[]) =>
97 Math.max(0, ...values.map((v) => v.length));
98 const labelWidth = widest(rows.map((r) => r.desc + r.type));
99 const modelWidth = widest(rows.map((r) => r.model + r.effort));
100 const tokensWidth = widest(rows.map((r) => r.tokens));
101 const statusWidth = widest(rows.map((r) => r.status.text));
102 const costWidth = widest(rows.map((r) => r.cost));
103 // The list sits 2 cells in from the left edge.
104 const rule = (glyph: string) =>
105 glyph.repeat(Math.max(1, (p.columns ?? 80) - 2));
106 const expanded = p.expanded ?? [];
107 // Inside the detail frame: the list's 2, the frame's indent of 4, its border 2 and padding 2.
108 const detailWidth = Math.max(1, (p.columns ?? 80) - 10);
109
110 return (
111 <Box flexDirection="column" paddingLeft={2}>
112 <Text>
113 🔥 {tokens(totalTokens)}
114 <Text dimColor>
115 {" "}
116 tokens · ≈{usd(totalUsd)} by price list · engine {usd(p.sessionUsd)}
117 </Text>
118 </Text>
119 <Text dimColor>{rule("─")}</Text>
120 <Box columnGap={2}>
121 <Text dimColor>Subagents</Text>
122 {/* Drops the finished subagents from the list; running ones stay. */}
123 {ids.length > 0 && p.onClearAgents !== undefined ? (
124 <Button key="k-clear-agents" plain onPress={p.onClearAgents}>
125 <Text dimColor underline>
126 clear
127 </Text>
128 </Button>
129 ) : null}
130 </Box>
131 {ids.length === 0 ? (
132 <Text dimColor>No subagents in this session yet.</Text>
133 ) : (
134 rows.map((r) => {
135 const isOpen = expanded.includes(r.id);
136 const a = info.get(r.id);
137 const agentColor =
138 a === undefined ? undefined : p.agentColors?.[a.type];
139
140 return (
141 <Box key={r.id} flexDirection="column">
142 <Button
143 key={`a:${r.id}`}
144 plain
145 onPress={() => p.onToggleAgent?.(r.id)}
146 >
147 <Text>
148 {isOpen ? "▾" : "▸"}{" "}
149 {/* The agent's own color from its definition; a blank keeps the columns. */}
150 {agentColor === undefined ? (
151 " "
152 ) : (
153 <Text color={agentTerminalColor(agentColor)}>● </Text>
154 )}
155 <Text bold color="suggestion">
156 {r.desc}
157 </Text>
158 <Text dimColor>
159 {r.type.padEnd(labelWidth - r.desc.length)}
160 {" "}
161 </Text>
162 {r.model}
163 <Text dimColor>
164 {r.effort.padEnd(modelWidth - r.model.length)}
165 {" "}🔥{" "}
166 </Text>
167 {r.tokens.padStart(tokensWidth)}
168 {" "}
169 {r.status.icon} {r.status.text.padEnd(statusWidth)}
170 {" "}
171 {r.cost.padStart(costWidth)}
172 </Text>
173 </Button>
174 {isOpen ? (
175 // Its own dim frame, indented under the row: request and response stand apart
176 // from the list of subagents, with an empty row before the next one.
177 <Box flexDirection="column" paddingLeft={4} paddingBottom={1}>
178 <Box
179 flexDirection="column"
180 borderStyle="round"
181 borderColor="subtle"
182 paddingX={1}
183 >
184 <Text bold color="suggestion">
185 ▌ request
186 </Text>
187 <Box paddingLeft={2}>
188 <Text>
189 {a?.prompt ??
190 "– (started before the mod recorded requests)"}
191 </Text>
192 </Box>
193 {/* A dotted line between the two halves, as wide as the frame's inside. */}
194 <Text dimColor>{"┄".repeat(detailWidth)}</Text>
195 <Text bold color="success">
196 ▌ response
197 </Text>
198 <Box paddingLeft={2}>
199 <Text>
200 {a?.result ??
201 (p.agentUsage[r.id]?.isDone
202 ? "–"
203 : "still running…")}
204 </Text>
205 </Box>
206 </Box>
207 </Box>
208 ) : null}
209 </Box>
210 );
211 })
212 )}
213 {ids.length > 0 ? <Text dimColor>{rule("═")}</Text> : null}
214 {ids.length > 0 ? (
215 <Text dimColor>
216 Σ subagents {p.spawnedAgents ?? ids.length} spawned · 🔥{" "}
217 {tokens(agentsTokens)} of {tokens(totalTokens)} tokens · ≈
218 {usd(agentsUsd)} of {usd(p.sessionUsd)} this session (estimate from
219 model prices)
220 </Text>
221 ) : null}
222 </Box>
223 );
224 }
225
226 return null;
227}
228hooks/memory.tsx 105 lines1import type { ElementTable } from "claude-code";
2
3import type { MemFile } from "../types";
4import { noLink, shortPath, tokens } from "./format";
5
6/** Lines of a file shown in its preview; the rest is counted, `↗ open` shows it whole. */
7export const PREVIEW_LINES = 60;
8
9export type MemoryProps = {
10 /** Width of the pane body, for the preview frame. */
11 columns: number;
12 cwd: string;
13 /** CLAUDE.md, CLAUDE.local.md and AGENTS.md files in context. */
14 files: readonly MemFile[];
15 /** Files whose preview is open. */
16 expanded: readonly string[];
17 /** File text by path, read when its preview opened; absent while it loads or failed. */
18 previews: Readonly<Record<string, string>>;
19 onToggle: (path: string) => void;
20 /** Opens a file in the system. */
21 onOpen: (path: string) => void;
22};
23
24/** The CLAUDE.md pane: one row per memory file; a click shows its text in an indented frame. */
25export function renderMemory(el: ElementTable, p: MemoryProps) {
26 const { Box, Button, Text } = el;
27
28 if (p.files.length === 0) {
29 return (
30 <Box paddingX={1}>
31 <Text dimColor>
32 No {noLink("CLAUDE.md")} or {noLink("AGENTS.md")} in context.
33 </Text>
34 </Box>
35 );
36 }
37
38 return (
39 <Box flexDirection="column" paddingX={1}>
40 {p.files.map((f) => {
41 const isOpen = p.expanded.includes(f.path);
42 const text = p.previews[f.path];
43 const lines = text === undefined ? [] : text.split(/\r?\n/);
44 const rest = lines.length - PREVIEW_LINES;
45
46 return (
47 <Box key={f.path} flexDirection="column">
48 <Box columnGap={2}>
49 {/* noLink: the terminal would turn a file name into a link that opens the file. */}
50 <Button
51 key={`m:${f.path}`}
52 plain
53 onPress={() => p.onToggle(f.path)}
54 >
55 <Text>
56 {isOpen ? "▾" : "▸"}{" "}
57 <Text bold color="suggestion">
58 {noLink(shortPath(f.path, p.cwd))}
59 </Text>
60 <Text dimColor>
61 {" "}
62 {f.type} · {tokens(f.tokens)} tok
63 </Text>
64 </Text>
65 </Button>
66 <Button
67 key={`o:${f.path}`}
68 plain
69 onPress={() => p.onOpen(f.path)}
70 >
71 <Text dimColor underline>
72 ↗ open
73 </Text>
74 </Button>
75 </Box>
76 {isOpen ? (
77 // The text in its own dim frame, indented under the row, with an empty row after.
78 <Box flexDirection="column" paddingLeft={4} paddingBottom={1}>
79 <Box
80 flexDirection="column"
81 borderStyle="round"
82 borderColor="subtle"
83 paddingX={1}
84 width={Math.max(20, p.columns - 6)}
85 >
86 {text === undefined ? (
87 <Text dimColor>reading…</Text>
88 ) : (
89 <Text>{lines.slice(0, PREVIEW_LINES).join("\n")}</Text>
90 )}
91 {rest > 0 ? (
92 <Text dimColor>
93 … {rest} more lines · ↗ open shows the whole file
94 </Text>
95 ) : null}
96 </Box>
97 </Box>
98 ) : null}
99 </Box>
100 );
101 })}
102 </Box>
103 );
104}
105hooks/tools.tsx 163 lines1import type { ElementTable } from "claude-code";
2
3import type { McpServer, SkillInfo } from "../types";
4import {
5 green,
6 mcpTag,
7 serverCalls,
8 skillTag,
9 sortByUse,
10 tokens,
11 yellow,
12} from "./format";
13
14export type ToolsProps = {
15 /** Width of the pane body, for the right-aligned switches and the separator. */
16 columns: number;
17 mcp: readonly McpServer[];
18 /** Calls per tool name (`mcp__server__tool`). */
19 calls: Readonly<Record<string, number>>;
20 skills: readonly SkillInfo[];
21 /** Calls per skill name. */
22 skillCalls: Readonly<Record<string, number>>;
23 /** MCP servers switched off: listed after the active ones, drawn OFF. */
24 disabledMcp: readonly string[];
25 /** Skills switched off: listed after the active ones, drawn OFF. */
26 disabledSkills: readonly string[];
27 onToggleMcp?: (server: string) => void;
28 onToggleSkill?: (skill: string) => void;
29};
30
31/** One row: name and details on the left, the ON/OFF switch at the right edge. */
32type Row = {
33 key: string;
34 name: string;
35 detail: string;
36 isOn: boolean;
37 /** `system`, `plugin`, `claude.ai`; empty for the person's own. */
38 tag: string;
39 onToggle?: () => void;
40};
41
42/** The MCP & skills pane: MCP servers, a separator, then skills; each list active first, most called first. */
43export function renderTools(el: ElementTable, p: ToolsProps) {
44 const { Box, Button, Text } = el;
45 // The rows sit 1 cell in from each side of the pane body.
46 const width = Math.max(20, p.columns - 2);
47
48 const mcpRows: Row[] = sortByUse([
49 ...p.mcp.map((s) => ({
50 name: s.server,
51 calls: serverCalls(p.calls, s.server),
52 isOn: !p.disabledMcp.includes(s.server),
53 detail: `${s.loaded}/${s.tools} tools · ${tokens(s.tokens)} tok`,
54 tag: mcpTag(s.server),
55 })),
56 // A server switched off is no longer in the context: it is only named.
57 ...p.disabledMcp
58 .filter((name) => !p.mcp.some((s) => s.server === name))
59 .map((name) => ({
60 name,
61 calls: 0,
62 isOn: false,
63 detail: "",
64 tag: mcpTag(name),
65 })),
66 ]).map((r) => ({
67 key: `mcp:${r.name}`,
68 name: r.name,
69 detail: `${r.calls} calls${r.detail === "" ? "" : ` · ${r.detail}`}`,
70 isOn: r.isOn,
71 tag: r.tag,
72 onToggle:
73 p.onToggleMcp === undefined ? undefined : () => p.onToggleMcp?.(r.name),
74 }));
75
76 const skillRows: Row[] = sortByUse([
77 ...p.skills.map((s) => ({
78 name: s.name,
79 calls: p.skillCalls[s.name] ?? 0,
80 isOn: !p.disabledSkills.includes(s.name),
81 detail: `${s.source} · ${tokens(s.tokens)} tok`,
82 tag: skillTag(s.source),
83 // skillOverrides does not apply to a plugin's skills: no switch for them.
84 isSwitchable: s.source !== "plugin",
85 })),
86 ...p.disabledSkills
87 .filter((name) => !p.skills.some((s) => s.name === name))
88 .map((name) => ({
89 name,
90 calls: 0,
91 isOn: false,
92 detail: "",
93 tag: "",
94 isSwitchable: true,
95 })),
96 ]).map((r) => ({
97 key: `skill:${r.name}`,
98 name: r.name,
99 detail: `${r.calls} calls${r.detail === "" ? "" : ` · ${r.detail}`}`,
100 isOn: r.isOn,
101 tag: r.tag,
102 onToggle:
103 p.onToggleSkill === undefined || !r.isSwitchable
104 ? undefined
105 : () => p.onToggleSkill?.(r.name),
106 }));
107
108 const row = (r: Row) => (
109 <Box key={r.key} justifyContent="space-between" width={width}>
110 <Text>
111 <Text bold={r.isOn} dimColor={!r.isOn}>
112 {r.name}
113 </Text>
114 {r.tag === "" ? null : (
115 <Text
116 color={r.tag === "system" ? yellow : undefined}
117 dimColor={r.tag !== "system"}
118 >
119 {" "}
120 [{r.tag}]
121 </Text>
122 )}
123 <Text dimColor> {r.detail}</Text>
124 </Text>
125 {r.onToggle === undefined ? (
126 <Text dimColor={!r.isOn} color={r.isOn ? green : undefined}>
127 {r.isOn ? " ON" : "OFF"}
128 </Text>
129 ) : (
130 <Button key={`t:${r.key}`} plain onPress={r.onToggle}>
131 <Text dimColor={!r.isOn} color={r.isOn ? green : undefined}>
132 {r.isOn ? " ON" : "OFF"}
133 </Text>
134 </Button>
135 )}
136 </Box>
137 );
138
139 return (
140 <Box flexDirection="column" paddingX={1}>
141 {/* Every switch changes what the model is told it has, so the prompt prefix changes. */}
142 <Text color={yellow}>
143 ⚠ Any switch changes the tools and skills listed to the model: the next
144 request writes the prompt cache again.
145 </Text>
146 <Text> </Text>
147 <Text dimColor>MCP servers</Text>
148 {mcpRows.length === 0 ? (
149 <Text dimColor>No MCP server.</Text>
150 ) : (
151 mcpRows.map(row)
152 )}
153 <Text dimColor>{"─".repeat(width)}</Text>
154 <Text dimColor>Skills</Text>
155 {skillRows.length === 0 ? (
156 <Text dimColor>No skill.</Text>
157 ) : (
158 skillRows.map(row)
159 )}
160 </Box>
161 );
162}
163hooks/robot.tsx 48 lines1import type { ElementTable } from "claude-code";
2
3import { spinner } from "./format";
4
5/** Mini Clawd from the Claude Code welcome screen: head and body, two rows of 5 cells. */
6const IDLE = ["▐▛█▜▌", "▝▜█▛▘"];
7const BLINK = ["▐███▌", "▝▜█▛▘"];
8/** While working it waves its arms and darts its eyes; the fourth frame blinks. */
9const WORKING = [
10 ["▐▛█▜▌", "▝▜█▛▘"],
11 ["▐▛█▜▌", "▗▟█▙▖"],
12 ["▐▜█▛▌", "▝▜█▛▘"],
13 ["▐███▌", "▗▟█▙▖"],
14];
15
16export type RobotProps = {
17 isWorking: boolean;
18 /** Frame counter; while working it grows four times per second. */
19 frame: number;
20 /** Current time in milliseconds; when idle the robot blinks once every few seconds. */
21 now: number;
22};
23
24/** Animated mini robot next to the limits: works while a turn is running, otherwise stands still and blinks now and then. */
25export function renderRobot(el: ElementTable, p: RobotProps) {
26 const { Box, Text } = el;
27 const isBlink = Math.floor(p.now / 1000) % 6 === 0;
28 const rows = p.isWorking
29 ? (WORKING[p.frame % WORKING.length] ?? IDLE)
30 : isBlink
31 ? BLINK
32 : IDLE;
33 const spin = p.isWorking ? spinner(p.frame) : " ";
34 // In color only while working; at rest it stays in the background.
35 const color = p.isWorking ? "claude" : undefined;
36
37 return (
38 <Box flexDirection="column">
39 <Text color={color} dimColor={!p.isWorking}>
40 {rows[0]}
41 </Text>
42 <Text color={color} dimColor={!p.isWorking}>
43 {rows[1]} {spin}
44 </Text>
45 </Box>
46 );
47}
48types/index.d.ts 169 lines1export type Snap = {
2 ctxTokens: number;
3 ctxWindow: number;
4 ctxPercent: number;
5 usd: number;
6 limits: { kind: string; percentUsed: number; resetsAt?: string }[];
7};
8
9export type Tally = {
10 input: number;
11 output: number;
12 read: number;
13 write: number;
14 lastHit: number | null;
15 turns: number;
16};
17
18export type Miss = { at: number; reason: string } | null;
19
20/** An open pull request of the current branch as `gh pr view` reports it. */
21export type PullRequest = { number: number; url: string };
22
23/**
24 * `sync` is the state against the upstream branch as of the last fetch; `null` without an upstream.
25 * `pr` is the branch's open pull request; `null` without one, outside GitHub or without `gh`.
26 */
27export type Repo = {
28 cwd: string;
29 branch: string;
30 sync: { ahead: number; behind: number } | null;
31 pr?: PullRequest | null;
32};
33
34export type MemFile = { path: string; type: string; tokens: number };
35
36export type Kind = "claude" | "mcp" | "session";
37
38/** A skill the session lists, from the context breakdown. */
39export type SkillInfo = {
40 name: string;
41 /** Where it comes from (`userSettings`, `plugin`, `built-in`, …). */
42 source: string;
43 tokens: number;
44};
45
46export type McpServer = {
47 server: string;
48 tools: number;
49 loaded: number;
50 tokens: number;
51};
52
53/** Subagent as reported by `agent.spawn`; `startedAt` is the start time (ms). */
54export type SubagentInfo = {
55 id: string;
56 description: string;
57 type: string;
58 startedAt: number;
59 /** The task it was given; absent in a record from an older version of the mod. */
60 prompt?: string;
61 /** Its final answer, once it finished. */
62 result?: string;
63};
64
65/** Usage of one subagent: tokens and a price estimate from the model price list. */
66export type SubagentUsage = {
67 model: string;
68 /** All tokens burned. */
69 tokens: number;
70 /** `null` when the model is not in the price list. */
71 usd: number | null;
72 /** Sum of the durations of completed turns. */
73 durationMs: number;
74 /** The subagent finished. */
75 isDone: boolean;
76 /** Effort abbreviation of the last request (`L`, `M`, `H`…); `–` for a model without effort. */
77 effort: string;
78};
79
80/** Cost in USD split by token kind. */
81export type CostParts = {
82 input: number;
83 output: number;
84 read: number;
85 write: number;
86};
87
88/** Usage beyond the plan as `api/oauth/usage` reports it under `spend`, in USD. */
89export type AccountSpend = {
90 used: number;
91 /** Monthly cap; `null` without one. */
92 limit: number | null;
93 /** Prepaid credits left; `null` when the account reports none. */
94 balance: number | null;
95 /** Extra usage is switched on. */
96 isEnabled: boolean;
97 /** ISO code the account bills in (`USD`, `EUR`); `null` when it says none. */
98 currency: string | null;
99};
100
101/** The signed-in organization; an Enterprise seat pays for every request. */
102export type Billing = { orgId: string; isUsageBased: boolean };
103
104/** The money line: `extra` beyond a personal plan, or all of `month` on Enterprise. */
105/** Keeps the prompt cache warm with a tiny request shortly before the TTL runs out. */
106export type KeepAlive = {
107 isOn: boolean;
108 /** Requests sent this session. */
109 pings: number;
110 /** Their estimated cost in USD. */
111 usd: number;
112};
113
114export type Money = {
115 label: "extra" | "month";
116 /** `null` while unknown. */
117 spent: number | null;
118 limit: number | null;
119 balance: number | null;
120 /** Extra usage is switched off. */
121 isOff: boolean;
122 /** ISO code of the amounts; `null` for US dollars from the price list or the budget. */
123 currency: string | null;
124};
125
126declare module "claude-code" {
127 interface PluginState {
128 "status-panel": {
129 snap: Snap | null;
130 tally: Tally;
131 cacheAt: number;
132 now: number;
133 miss: Miss;
134 repo: Repo;
135 mem: MemFile[];
136 model: string;
137 effort: string;
138 plan: string;
139 mcp: McpServer[];
140 calls: Record<string, number>;
141 spent: number;
142 account: AccountSpend | null;
143 billing: Billing;
144 agents: SubagentInfo[];
145 agentUsage: Record<string, SubagentUsage>;
146 showResets: boolean;
147 keepAlive: KeepAlive;
148 /** Subagents whose request and response are expanded in the session pane. */
149 expandedAgents: string[];
150 /** Agent type → the `color` of its definition, for the dot in the session pane. */
151 agentColors: Record<string, string>;
152 /** Subagents spawned this session, the cleared ones included. */
153 spawnedAgents: number;
154 /** Files whose preview is open in the CLAUDE.md pane. */
155 expandedFiles: string[];
156 /** File text by path, read when its preview opens. */
157 previews: Record<string, string>;
158 /** Skills the session lists. */
159 skills: SkillInfo[];
160 /** Calls per skill name. */
161 skillCalls: Record<string, number>;
162 /** MCP servers and skills switched off in the project's local settings. */
163 disabled: { mcp: string[]; skills: string[] };
164 costParts: CostParts;
165 frame: number;
166 };
167 }
168}
169