SLOPSHOPPER

status-panel

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…

newpanebandguardtoastmodel
v1.3.0MITupdated 2026-10-16tichara1/ai.claude-status-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-panel
│ ┃ session ✕ › fix the failing auth test and add an audit log call │ ┃ 🔥 0 tokens · ≈0.00$ by price list · │ ┃ engine 0.42$ ⏺ Read(src/auth.ts) │ ┃ ────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ──────────── ⏺ Update(src/auth.ts) │ ┃ Subagents ⎿ Added 2 lines, removed 1 line │ ┃ No subagents in this session yet. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ k-folder feat/auth-refresh – · – │ │ │ │ context ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 49% 97k [200k] k-5h ▐▛█▜▌ │ │ k-keep– Σ –% – k-claude k-mcp ▝▜█▛▘ │ │ k-session │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ k-folder feat/auth-refresh – · – │ │ │ │ context ▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ 49% 97k [200k] k-5h ▐▛█▜▌ │ │ k-keep– Σ –% – k-claude k-mcp ▝▜█▛▘ │ │ k-session │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · session
🔥 0 tokens · ≈0.00$ by price list · engine 0.42$ ────────────────────────────────────────────────────── Subagents No subagents in this session yet.
Pane · memory
No CLAUDE․md or AGENTS․md in context.
Pane · tools
⚠ Any switch changes the tools and skills listed to the model: the next request writes the prompt cache again. MCP servers No MCP server. ────────────────────────────────────────────────────── Skills No skill.
README

status-panel

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.

Preview

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.

What it shows

  • Header: the folder (click to open it), the git branch with ahead/behind against upstream (↑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.
  • Click ↓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.
  • Context: tokens in use against the window (333k [1M]), with a bar and percentage coloured by fill (green → yellow → orange → red).
  • Prompt cache: hit rate of the last request and of the session, and a live countdown until the cache expires. A cache miss line explains the most likely cause and disappears once the cache is warm again.
  • Keep-alive: click the word cache to switch it on; the dot next to it turns green. A minute before the TTL runs out it sends one tiny request over the session's own transcript ($.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.
  • Rate limits: 5-hour and weekly windows with the time left until reset; click one to show the exact reset times. Hidden on Enterprise.
  • Money, under the limits, with the same colour scale (/login to another account is picked up within 30 s):
  • personal plan: extra usage beyond the subscription against its monthly limit, from your account; hidden while extra usage is off or has no limit,
  • Enterprise: 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.
  • CLAUDE.md and MCP on the cache row, under the token count, each open a side pane.
  • Bottom row: the session cost (always), prepaid credits when the account has any, and what keep-alive has sent and cost.
  • Clawd: a mini Claude Code robot, grey at rest, in colour and animated while a turn is running.
  • Alerts: the frame turns orange when the cache expired with the context above 35 %, and red above 65 %.
  • Currency: amounts carry the sign after the number (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.

Side panes

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
  • Each subagent with its type, model, effort, tokens, status (✻ running 1m20s or ✅ 8s) and estimated cost, in aligned columns.
  • The coloured dot is the color from the agent's definition (~/.claude/agents, the project's .claude/agents; symbolic links are followed). Built-in types have none.
  • Click a subagent to expand its request and response; clear drops the finished ones (running ones stay); the summary counts every subagent spawned this session.

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
  • A switch writes to the project's .claude/settings.local.json (personal, not committed), the same file /skills writes:
  • MCP server: deniedMcpServers: [{ "serverName": … }]; it takes effect when the server reconnects (/mcp or a new session),
  • skill: skillOverrides: { "<name>": "off" }.
  • Plugin skills have no switch: 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.
  • A settings file that is not valid JSON is never overwritten; the toast says so.

Install

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.

Configure

/plugin configure status-panel
OptionDefaultMeaning
ttlMinutes60Prompt-cache TTL your requests use (5 or 60). Drives the countdown, keep-alive and the cache-write price.
budgetUsd250Monthly Enterprise budget in USD that the month bar is measured against when the account reports no limit of its own.

Update and uninstall

claude plugin update status-panel@status-panel
claude plugin uninstall status-panel@status-panel

Run /reload-plugins in sessions that are already open.

Good to know

  • Costs are estimates. The session cost comes from Claude Code itself; per-kind, per-subagent and keep-alive costs are computed from public API prices. On a subscription plan they show the value of what you used, not money you are billed.
  • The monthly total is local on Enterprise without account figures: it sums the sessions this mod has seen on this machine.
  • The plan label is read from oauthAccount.organizationType and organizationRateLimitTier in ~/.claude.json; nothing else from that file is used.
  • Opening files and folders uses start on Windows, open on macOS and xdg-open on Linux.
  • Tested with Claude Code 2.1.301 in the terminal.

Development

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.

License

MIT

Source 8 files
hooks/register.tsx 922 lines
1import { 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};
922
hooks/band.tsx 355 lines
1import 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}
355
hooks/format.ts 721 lines
1import 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" : "";
721
hooks/lists.tsx 228 lines
1import 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}
228
hooks/memory.tsx 105 lines
1import 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}
105
hooks/tools.tsx 163 lines
1import 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}
163
hooks/robot.tsx 48 lines
1import 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}
48
types/index.d.ts 169 lines
1export 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