Keeps the conversation in the transcript and moves tool activity to a docked Activity pane.

jadee's NixOS and nix-darwin flake. One flake-parts entry point builds four machines with shared home-manager modules on Linux and macOS.
Full reference: AGENTS.md (workflows, profiles, desktop stack, gotchas). This file is the short tour.
| Host | System | Hardware | Notes |
|---|---|---|---|
desktop | x86_64-linux | NVIDIA GPU | DP-2 @ 2560×1440 / 170 Hz |
framework | x86_64-linux | AMD, Framework 13 7040 | eDP-2 @ 2880×1920 / 120 Hz, 2.0× scale |
caya | aarch64-darwin | Apple Silicon | user caya-jonas; Homebrew casks via nix-homebrew |
mini | x86_64-linux | Minisforum MS-01 | headless server; hostClass = "server"; static LAN 192.168.178.100; tailnet-only firewall |
Per-host metadata lives in hosts/<name>/host.nix. The registry is hosts/hosts.nix (hostnames, users, home directories, main monitor, build cores, DMS/niri config filenames, host class). Linux hosts share sharedNixOSUser = jadee and an extra angelie account (hosts/lib.nix); mini opts out (extraUsers = [] — no guest accounts on a headless server).
The active host is read from .flake-host (not committed). Set it with just init (prompts) or just _init <host> (no prompt).
mini is the headless server (Minisforum MS-01): local LLM chat + embeddings (llama.cpp via just mini llm), Beszel monitoring, Atuin shell-history sync, the Nixflix media stack (Jellyfin, VPN-confined downloaders, Unraid payloads — see docs/adr/), and an Immich photo library with off-host backups. Ops notes live in docs/hosts/mini.md; deploy with just mini deploy (definitions in just/mini.just).
Use just recipes for builds and switches (not bare nixos-rebuild / home-manager switch / nh without the Justfile). Flakes only see git-tracked files: run git add before nix build, nix eval, or anything that must see new files.
For new nixpkgs names, run nix search nixpkgs <name> first; check Hydra if you care about binary cache hits (AGENTS.md has a one-liner).
# build / switch
just switch # flake check → build → commit on success → activate; errors → clipboard
just switch-fast # nh switch only
just switch-check # nix flake check only
just build-dry # dry run (nh darwin build --dry / nh os test)
just build-dev # switch with --show-trace
just rollback # previous generation
# format / lint
just fmt # treefmt + deadnix + ruff + ty + biome
just lint # deadnix + statix
# maintenance
just update # update-packages, flake update, fmt; Framework: fwupdmgr when on framework
just gc-days # nh clean, keep store paths newer than N days
just health # git status, disk, nh os info
just alone runs tv just-recipes (cable TOML in modules/profiles/essentials/utils/television/cable/; Home Manager links it to ~/.config/television/cable/ like navi cheats). F5 runs the highlighted recipe (just in execute mode). Install the television package so tv is on PATH, or run just --choose yourself. just list lists every recipe; just info shows nix flake metadata.
| Group | Recipes |
|---|---|
build | build, build-boot, build-dev, build-dry |
switch | switch, switch-fast, switch-check |
generations | generation-list, generation-switch, generation-delete, generation-bootloader |
gc | gc (= gc-keep), gc-days, gc-all |
format | fmt, fmt-notree, lint |
backups | backups, backups-clean, backups-clean-dry |
check | update-packages, nix-update-pkg, symlink-check, symlink-check-dms |
config | init, _init, read-defaults, secret-set, setup-age-editor, setup-age-darwin, sops host-key lifecycle (bootstrap-sops-host-key, bootstrap-sops-host-key-from-editor, rotate-sops-host-key, verify-sops-host-key), sync-1password, sync-media-secrets-to-pass |
system | health, rollback, update, flatten-dir, flatten-dir-dry |
repo | git (quiet fmt + status/log + commit + push) |
zen | zen-sync, zen-check |
llm | unsloth, unsloth-stop, unsloth-reset, unsloth-logs, unsloth-status (mini: just mini llm) |
mini | just mini <cmd>: pull, deploy, deploy-dry, deploy-boot, ssh, reboot, dns-sync, hermes/media/immich/immich-backup status + restart, llm sub-module (models, chat, perf, embedding, bench, …) |
unraid | just unraid <cmd>: ssh, fancurve-deploy, fancurve-status |
meta | list, info |
NH_FLAKE is set to this repo. Shell helpers live in scripts/shell/common.sh (get_host, is_darwin, notify, …).
flake/
├── flake.nix # inputs, per-system packages, formatter = nixfmt-tree
├── Justfile # root recipes + module imports
├── just/ # Justfile modules: mini.just, mini-llm.just, unraid.just
├── .flake-host # active host (local only)
├── lib/ # getPkgs / getPkgsStable / getPkgsSmall / getPkgsWithConfig
├── parts/
│ ├── hosts.nix # nixosConfigurations + darwinConfigurations
│ ├── shells.nix # devShell
│ ├── packages.nix # exposes packages/<name> as flake `packages`
│ ├── checks.nix # eval-level flake checks + formatter
│ └── overlays/ # local packages, skhd pin, CachyOS kernel, …
├── .agents/skills/ # project-only agent skills (repo discovery)
├── data/ # e.g. users/users.nix, static files
├── hosts/ # hosts.nix + per-host NixOS/darwin config
├── modules/ # profiles (features, system + HM halves) / platform base
├── packages/ # custom packages; auto-registered as pkgs.<name> (see overlays)
├── data/agents/ # global AGENTS.md + local skills + Claude Code mods
├── docs/ # ADRs (docs/adr), host runbooks (docs/hosts), bench, research
├── scripts/ # Justfile helpers + uv Python package (flake-scripts)
└── secrets/secrets.yaml # sops + age
packages/* with a default.nix is picked up by parts/overlays/local-packages.nix.
Bash under scripts/shell/ backs the Justfile — including host-ops helpers (mini-*.bash, dns-sync.bash, flatten-dir.bash, sync-1password.bash, …). Python is a uv project in scripts/ (console entry points in pyproject.toml): symlink-check, update-packages, read-defaults, zen-sync, etc. See scripts/README.md for details.
| Input | Role |
|---|---|
nixpkgs | nixos-unstable |
nixpkgs-small | nixos-unstable-small (exposed via lib/pkgs.nix → getPkgsSmall) |
nixpkgs-stable | nixos-26.05 → pkgs-stable in modules |
nixpkgs-skhd | pinned nixpkgs for skhd (macOS Accessibility grant; standing pin, not a workaround — parts/overlays/skhd-pinned-darwin.nix) |
nixpkgs-zed | declared but currently unused (Zed Darwin pin removed 2026-08-17; zed builds from nixpkgs) |
systems | this repo's lib/systems.nix (drops x86_64-darwin from the systems list) |
home-manager, nix-darwin | user / macOS system config |
flake-parts | module structure |
determinate | Determinate Nix |
sops-nix | secrets |
stylix | theming |
lanzaboote | secure boot (Linux) |
disko | disk partitioning (mini: hosts/mini/disko.nix) |
niri | niri compositor (Wayland) |
dms | DankMaterialShell (bar / desktop shell) |
dank-greeter, dankcalendar | DMS greeter / calendar widget |
noctalia | Noctalia v5 shell (niri alternative; flake TOML via HM settings) |
quickshell | shell/bar framework |
zen-browser | Zen browser |
vicinae, vicinae-extensions | Vicinae browser integration (Zen native-messaging host) |
nix-flatpak | declarative Flatpak remotes/packages (modules/profiles/integrations.nix) |
nixflix | Jellyfin/Plex wrapper for mini's media stack |
nix-homebrew, homebrew-* | Homebrew pins (non-flake fetches) |
nixos-hardware | hardware modules |
nix-cachyos-kernel | CachyOS kernel (x86_64-linux) |
google-workspace-cli | gws in per-system packages |
llm-agents | agent CLI overlay (pkgs.llm-agents.*: codex, claude-code, apm, hermes, handy, …) |
hermes-agent | Hermes agent NixOS module + service on mini (services.hermes-agent) |
corecycler | kept while disabled (upstream bug); not wired into any host |
packages.<system> exposes every packages/<name> automatically (parts/packages.nix + packages/names.nix).
Available in system/home modules (see AGENTS.md for the full list): inputs, hostData, hostKey, host, user, isDarwin, system, pkgs-stable, …
secrets/secrets.yaml is encrypted with sops and age. See docs/secrets/sops-age-keys.md. Edit with sops secrets/secrets.yaml. Editor key: just setup-age-editor → ~/.config/sops/age/keys.txt.
Home-manager exports select secrets to the user session env via modules/profiles/minimal/shells/sops-session-env.nix (env.d + systemctl --user set-environment on Linux; LaunchAgent + launchctl setenv on Darwin), so every new shell — and GUI apps launched outside a terminal — inherit them.
| Goal | Where |
|---|---|
| Profile toggles | dotfiles.profiles in modules/profiles/; enable per host in hosts/<name>/ |
| Profiles overview | docs/profiles.md (structure + conventions; the tree is its own index) |
| Architecture decisions | docs/adr/ (media stack, Immich, network stack, …) |
| Host ops / troubleshooting | docs/hosts/ (e.g. docs/hosts/mini.md) |
| System packages (profile-gated) | modules/profiles/<profile>.nix (platform extras inline) |
| System packages (unconditional base) | modules/shared/, modules/nixos/, modules/darwin/ |
| One host only | hosts/<name>/ |
| User (HM) config | the owning profile's feature folder in modules/profiles/ (pushed via home-manager.sharedModules) |
| NixOS services | modules/nixos/ |
| Desktop / Wayland | modules/profiles/desktop/ (system + DMS or Noctalia/niri HM + data) |
| Custom package | packages/<name>/ + update.json when you want just update-packages to handle bumps |
| New secret | sops file + declarations (see modules/shared/security/ / encryption) |
New .nix files must be listed in the parent directory’s default.nix imports.
just fmtjust switch or just switch-fastjust symlink-check (and reload compositor / session tools as needed).Theme, Stylix, niri, DMS, and recovery topics are documented in AGENTS.md.
hooks/register.tsx 1559 lines1import { atom, read, update } from "claude-code";
2import type {
3 EngineInterface,
4 Register,
5 RenderViewport,
6 SessionUsage,
7} from "claude-code";
8
9import type {
10 ActivityCall,
11 ActivityEntry,
12 AgentLabel,
13 CallStatus,
14 ContextInfo,
15 ContextRow,
16 FileChange,
17 FileDiff,
18 McpServerUse,
19 SkillUse,
20 TaskItem,
21 TaskStatus,
22} from "../types";
23import {
24 barCells,
25 clockTime,
26 commandOf,
27 cut,
28 cutStart,
29 duration,
30 firstLine,
31 hardWrap,
32 limitLabel,
33 mcpParts,
34 pathsOf,
35 resetLabel,
36 skillParts,
37 summarize,
38 tokenCount,
39 toolLabel,
40 turnsLeft,
41} from "./format";
42import { diffColor, diffCounts, gitLabel, parseStatus } from "./git";
43import {
44 FILE_TOOLS,
45 TASK_TOOLS,
46 fileRowsOf,
47 fit,
48 fitFiles,
49 matchSkill,
50 mcpRowsOf,
51 nextFiles,
52 nextMcp,
53 nextSkills,
54 nextTasks,
55 shareRows,
56} from "./work";
57import type { FileRow, ListedSkill, McpRow } from "./work";
58
59const PANE = "activity";
60const TITLE = "Activity";
61const STORE_SPLIT = "isSplit";
62const MAX_ENTRIES = 400;
63const MAX_RESULT = 4000;
64// Cells before a call's glyph: its duration, right-aligned, and a space.
65const LEAD = 7;
66// The minimum number of body rows for the split. A smaller body scrolls.
67const MIN_BODY_ROWS = 12;
68// The parts under the context: tasks, skills, MCP servers, and files. The
69// context leaves at least their headings.
70const WORK_PARTS = 4;
71const RULE = "─".repeat(400);
72// From these fills of the window, in percent, the bar takes the warning
73// color and the Compact button shows, then the bar takes the error color.
74const WARN_PERCENT = 70;
75const ALERT_PERCENT = 90;
76// The number of recent turns that the estimate of turns until compaction uses.
77const TURN_SAMPLES = 6;
78
79// The person answers these tools, so their rows stay in the conversation.
80const CONVERSATION_TOOLS = new Set(["AskUserQuestion", "ExitPlanMode"]);
81
82const USAGE = "Usage: /stereo-view [on|off|results|clear]";
83
84const entries = atom({ plugin: "stereo-view", key: "entries" } as const, []);
85const isSplit = atom({ plugin: "stereo-view", key: "isSplit" } as const, true);
86const expanded = atom(
87 { plugin: "stereo-view", key: "expanded" } as const,
88 null,
89);
90const showResults = atom(
91 { plugin: "stereo-view", key: "showResults" } as const,
92 false,
93);
94const contextInfo = atom(
95 { plugin: "stereo-view", key: "context" } as const,
96 null,
97);
98const anchor = atom({ plugin: "stereo-view", key: "anchor" } as const, null);
99const tasks = atom({ plugin: "stereo-view", key: "tasks" } as const, []);
100const agents = atom({ plugin: "stereo-view", key: "agents" } as const, []);
101const files = atom({ plugin: "stereo-view", key: "files" } as const, []);
102const showCategories = atom(
103 { plugin: "stereo-view", key: "showCategories" } as const,
104 false,
105);
106const turnTokens = atom(
107 { plugin: "stereo-view", key: "turnTokens" } as const,
108 [],
109);
110const gitState = atom({ plugin: "stereo-view", key: "git" } as const, null);
111const fileDiff = atom(
112 { plugin: "stereo-view", key: "fileDiff" } as const,
113 null,
114);
115const diffScroll = atom(
116 { plugin: "stereo-view", key: "diffScroll" } as const,
117 0,
118);
119const onlyFailed = atom(
120 { plugin: "stereo-view", key: "onlyFailed" } as const,
121 false,
122);
123const skills = atom({ plugin: "stereo-view", key: "skills" } as const, []);
124const mcp = atom({ plugin: "stereo-view", key: "mcp" } as const, []);
125const closedServers = atom(
126 { plugin: "stereo-view", key: "closedServers" } as const,
127 [],
128);
129
130// One row of the Tasks list: a task, or the label of the subagent whose
131// tasks follow it, indented.
132type TaskRow =
133 | { kind: "task"; task: TaskItem; isNested: boolean }
134 | { kind: "agent"; agent: AgentLabel };
135
136// The main tasks first, then a label for each subagent that runs or that
137// wrote tasks, with its tasks under it.
138function taskRowsOf(
139 list: readonly TaskItem[],
140 labels: readonly AgentLabel[],
141): TaskRow[] {
142 const rows: TaskRow[] = [];
143 for (const task of list)
144 if (task.agentId === undefined)
145 rows.push({ kind: "task", task, isNested: false });
146 const ids = new Set([
147 ...labels.flatMap((one) => (one.isDone ? [] : [one.id])),
148 ...list.flatMap((task) => task.agentId ?? []),
149 ]);
150 for (const id of ids) {
151 const agent = labels.find((one) => one.id === id) ?? {
152 id,
153 label: "subagent",
154 type: "agent",
155 isDone: false,
156 };
157 rows.push({ kind: "agent", agent });
158 for (const task of list)
159 if (task.agentId === id)
160 rows.push({ kind: "task", task, isNested: true });
161 }
162
163 return rows;
164}
165
166const TASK_GLYPH: Record<TaskStatus, string> = {
167 pending: "○",
168 in_progress: "◐",
169 completed: "✓",
170};
171
172const TASK_COLOR: Record<TaskStatus, string | undefined> = {
173 pending: undefined,
174 in_progress: "warning",
175 completed: "success",
176};
177
178// The lower half as the pane last drew it: the ids of the list's entries,
179// oldest first, the first one the window may end on, so that the window
180// stays full, and the rows that an open diff shows.
181let layout: { ids: string[]; top: number; diffRows: number } = {
182 ids: [],
183 top: 0,
184 diffRows: 0,
185};
186
187const AREA: Record<ContextRow["kind"], string> = {
188 used: "█",
189 free: "░",
190 buffer: "▒",
191};
192
193const KIND_ORDER: Record<ContextRow["kind"], number> = {
194 used: 0,
195 free: 1,
196 buffer: 2,
197};
198
199const GLYPH: Record<CallStatus, string> = {
200 running: "○",
201 done: "✓",
202 error: "✗",
203 denied: "⊘",
204};
205
206const COLOR: Record<CallStatus, string> = {
207 running: "warning",
208 done: "success",
209 error: "error",
210 denied: "error",
211};
212
213function isCall(entry: ActivityEntry): entry is ActivityCall {
214 return entry.kind === "call";
215}
216
217function append(list: ActivityEntry[], entry: ActivityEntry): ActivityEntry[] {
218 return [...list, entry].slice(-MAX_ENTRIES);
219}
220
221function finish(
222 list: ActivityEntry[],
223 id: string,
224 change: Partial<ActivityCall>,
225): ActivityEntry[] {
226 return list.map((entry) =>
227 entry.kind === "call" && entry.id === id ? { ...entry, ...change } : entry,
228 );
229}
230
231// Tool rows hide only while the Activity pane is on screen beside a
232// fullscreen transcript. A change of width redraws every row, so a pane
233// that becomes placed on a wider terminal takes the rows with it.
234async function isActivityShown(
235 $: EngineInterface,
236 viewport: RenderViewport | undefined,
237): Promise<boolean> {
238 if (viewport?.isFullscreen !== true) return false;
239 if (!(await read($, isSplit))) return false;
240 const panes = await $.ui.panes();
241
242 return panes.some((pane) => pane.id === PANE && pane.isPlaced);
243}
244
245async function setSplit($: EngineInterface, isOn: boolean): Promise<void> {
246 await $.store.set(STORE_SPLIT, isOn);
247 if (!isOn) {
248 await update($, isSplit, () => false);
249 await $.ui.close({ id: PANE });
250
251 return;
252 }
253 const opened = await $.ui.open({ id: PANE, title: TITLE });
254 await update($, isSplit, () => true);
255 if (!opened.isPlaced) $.ui.toast(`Activity waits: ${opened.reason}`);
256}
257
258// Each folder's repository root, or null outside any. A folder that holds
259// `.git` (a folder, or a file in a worktree) is a root.
260const rootOf = new Map<string, string | null>();
261
262async function repoRoot(
263 $: EngineInterface,
264 path: string,
265): Promise<string | null> {
266 const seen: string[] = [];
267 let dir = path.slice(0, path.lastIndexOf("/"));
268 let root: string | null = null;
269 while (dir !== "") {
270 const known = rootOf.get(dir);
271 if (known !== undefined) {
272 root = known;
273 break;
274 }
275 seen.push(dir);
276 if (await $.fs.exists(`${dir}/.git`).catch(() => false)) {
277 root = dir;
278 break;
279 }
280 dir = dir.slice(0, dir.lastIndexOf("/"));
281 }
282 for (const one of seen) rootOf.set(one, root);
283
284 return root;
285}
286
287async function rootsFor(
288 $: EngineInterface,
289 input: Record<string, unknown>,
290): Promise<string[]> {
291 const roots = await Promise.all(
292 pathsOf(input).map((path) => repoRoot($, path)),
293 );
294
295 return roots.filter((root): root is string => root !== null);
296}
297
298type ReadBack = {
299 list: ActivityEntry[];
300 taskList: TaskItem[];
301 fileList: FileChange[];
302 skillList: SkillUse[];
303 mcpList: McpServerUse[];
304};
305
306// Rebuilds the list, the tasks, the changed files, the skills and the MCP
307// servers from the stored conversation, for a resumed session. Subagent
308// calls are not stored there. Of the skills, only the Skill tool's calls are
309// stored: a skill that the person typed as `/name` is not read back.
310async function readBack($: EngineInterface): Promise<ReadBack> {
311 const messages = await $.session.messages();
312 const list: ActivityEntry[] = [];
313 let taskList: TaskItem[] = [];
314 let fileList: FileChange[] = [];
315 let skillList: SkillUse[] = [];
316 let mcpList: McpServerUse[] = [];
317 if (!Array.isArray(messages))
318 return { list, taskList, fileList, skillList, mcpList };
319 const listed = await skillListing($).catch(() => []);
320 for (const [index, message] of messages.entries()) {
321 if (
322 message.role === "user" &&
323 message.text.trim() !== "" &&
324 message.toolResults === undefined
325 ) {
326 list.push({
327 kind: "turn",
328 id: `turn:${index}`,
329 text: firstLine(message.text),
330 });
331 }
332 for (const use of message.toolUses) {
333 const summary = summarize(use.input, await rootsFor($, use.input));
334 list.push({
335 kind: "call",
336 id: use.tool_use_id,
337 tool: use.tool,
338 summary,
339 command: commandOf(use.input)?.slice(0, MAX_RESULT),
340 status: use.isError === true ? "error" : "done",
341 result: use.text?.slice(0, MAX_RESULT),
342 });
343 // A call in flight has no text yet.
344 if (use.text !== undefined)
345 mcpList = nextMcp(mcpList, use.tool, use.isError === true);
346 if (use.isError === true || use.text === undefined) continue;
347 const skill = use.input.skill;
348 if (use.tool === "Skill" && typeof skill === "string") {
349 const found = matchSkill(listed, skill);
350 skillList = nextSkills(skillList, found ?? { name: skill, source: "" });
351 }
352 if (TASK_TOOLS.has(use.tool))
353 taskList = nextTasks(taskList, use.tool, use.input, use.result);
354 const path = pathsOf(use.input)[0];
355 if (FILE_TOOLS.has(use.tool) && path !== undefined)
356 fileList = nextFiles(fileList, path, summary);
357 }
358 }
359
360 return {
361 list: list.slice(-MAX_ENTRIES),
362 taskList,
363 fileList,
364 skillList,
365 mcpList,
366 };
367}
368
369// Keeps the label of a subagent and marks it as running. The engine drops
370// a finished agent from its own list after a while. A spawn gives the label,
371// else the engine's list does.
372async function noteAgent(
373 $: EngineInterface,
374 agentId: string,
375 spawned?: { label: string; type: string },
376): Promise<void> {
377 if ((await read($, agents)).some((one) => one.id === agentId)) {
378 await update($, agents, (list) =>
379 list.map((one) => (one.id === agentId ? { ...one, isDone: false } : one)),
380 );
381
382 return;
383 }
384 const info =
385 spawned === undefined
386 ? (await $.agent.list()).find((one) => one.id === agentId)
387 : undefined;
388 const agent: AgentLabel = {
389 id: agentId,
390 label: spawned?.label || info?.description || info?.name || "subagent",
391 type: spawned?.type ?? info?.type ?? "agent",
392 isDone: false,
393 };
394 await update($, agents, (list) =>
395 list.some((one) => one.id === agentId) ? list : [...list, agent],
396 );
397}
398
399function infoOf(usage: SessionUsage): ContextInfo {
400 const { context } = usage;
401 const breakdown = context.breakdown;
402 const rows: ContextRow[] = [];
403 for (const one of breakdown?.categories ?? []) {
404 // Deferred tool schemas sit outside the window.
405 if (one.kind === "deferred") continue;
406 rows.push({
407 name: one.name,
408 tokens: one.tokens,
409 color: one.color,
410 kind: one.kind,
411 });
412 }
413 // The used rows first, then the free space, then the compaction
414 // buffer, so the bar ends where auto-compaction starts.
415 rows.sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind]);
416
417 // The input of the last response, and the part of it that came from the
418 // prompt cache.
419 const api = breakdown?.apiUsage;
420 const input =
421 api === undefined || api === null
422 ? 0
423 : api.input_tokens +
424 api.cache_creation_input_tokens +
425 api.cache_read_input_tokens;
426
427 return {
428 model: breakdown?.model,
429 tokens: context.tokens,
430 window: context.window,
431 percent: context.percent,
432 rowsWindow: breakdown?.rawMaxTokens,
433 rows,
434 compactAt: breakdown?.autoCompactThreshold,
435 usd: usage.cost?.usd,
436 cacheRate:
437 api === undefined || api === null || input === 0
438 ? undefined
439 : Math.round((api.cache_read_input_tokens / input) * 100),
440 limits: usage.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({
441 kind,
442 percentUsed,
443 resetsAt,
444 })),
445 };
446}
447
448// Each reading runs only once at a time. A call during a reading starts
449// one more reading after it. The engine follows `$` only into functions
450// declared here, so each reading keeps its own loop.
451const contextReading = { isReading: false, isStale: false };
452const gitReading = { isReading: false, isStale: false };
453
454// Reads the context window and its breakdown. The `summary` breakdown
455// estimates locally and sends no request.
456async function readContext($: EngineInterface): Promise<void> {
457 if (contextReading.isReading) {
458 contextReading.isStale = true;
459
460 return;
461 }
462 contextReading.isReading = true;
463 try {
464 do {
465 contextReading.isStale = false;
466 const usage = await $.session.usage({ breakdown: "summary" });
467 await update($, contextInfo, () => infoOf(usage));
468 } while (contextReading.isStale);
469 } finally {
470 contextReading.isReading = false;
471 }
472}
473
474// Reads the git state of the folder that the session runs in. Outside a
475// repository, the state is null.
476async function readGit($: EngineInterface): Promise<void> {
477 if (gitReading.isReading) {
478 gitReading.isStale = true;
479
480 return;
481 }
482 gitReading.isReading = true;
483 try {
484 do {
485 gitReading.isStale = false;
486 const argv = ["git", "status", "--porcelain=v2", "--branch"];
487 const ran = await $.process.run(argv);
488 const state = ran.exitCode === 0 ? parseStatus(ran.stdout) : null;
489 await update($, gitState, () => state);
490 } while (gitReading.isStale);
491 } finally {
492 gitReading.isReading = false;
493 }
494}
495
496const MAX_DIFF_LINES = 2000;
497
498// The diff of one file against HEAD, one line each. A file that git does
499// not track shows as new.
500async function diffOf($: EngineInterface, path: string): Promise<string[]> {
501 const root = await repoRoot($, path);
502 if (root === null) return ["The file is not in a git repository."];
503 const git = (argv: string[]) =>
504 $.process.run(["git", ...argv], { cwd: root });
505 const isTracked =
506 (await git(["ls-files", "--error-unmatch", "--", path])).exitCode === 0;
507 const ran = isTracked
508 ? await git(["diff", "--no-color", "HEAD", "--", path])
509 : await git(["diff", "--no-color", "--no-index", "--", "/dev/null", path]);
510 // `--no-index` ends with 1 when the files differ.
511 if (ran.exitCode > 1) return [firstLine(ran.stderr) || "git diff failed."];
512 const text = ran.stdout.replaceAll("\t", " ").trimEnd();
513 if (text === "") return ["No changes against HEAD."];
514
515 return text.split("\n").slice(0, MAX_DIFF_LINES);
516}
517
518// The session's skill listing, from the local context estimate. Empty when
519// the estimate has none.
520async function skillListing($: EngineInterface): Promise<ListedSkill[]> {
521 const usage = await $.session.usage({ breakdown: "summary" });
522
523 return [...(usage.context.breakdown?.skills?.skillFrontmatter ?? [])];
524}
525
526// Records one use of a skill under its listed name and source. The guard
527// that the account seats first skips `skill.prompt` hooks, so the mod sees
528// a skill only as a Skill tool call or as a typed `/name`. A name that the
529// listing does not hold counts only when `isSkill` is true: a typed `/name`
530// can also be a built-in command.
531async function noteSkill(
532 $: EngineInterface,
533 name: string,
534 isSkill: boolean,
535): Promise<void> {
536 const listed = await skillListing($).catch(() => []);
537 const found = matchSkill(listed, name);
538 if (found === undefined && !isSkill) return;
539 await update($, skills, (list) =>
540 nextSkills(list, found ?? { name, source: "" }),
541 );
542}
543
544async function openDiff($: EngineInterface, file: FileChange): Promise<void> {
545 const lines = await diffOf($, file.path);
546 const diff: FileDiff = { path: file.path, label: file.label, lines };
547 await update($, fileDiff, () => diff);
548 await update($, diffScroll, () => 0);
549}
550
551export const register: Register = (on) => {
552 on("session.start", async ($, e, next) => {
553 await $.command.register({
554 name: "stereo-view",
555 description: "Split tool activity from the conversation into a side pane",
556 argumentHint: "[on|off|results|clear]",
557 });
558
559 const stored = await $.store.get(STORE_SPLIT);
560 const isOn = stored !== false;
561 await update($, isSplit, () => isOn);
562
563 // Each value that is still empty comes from the stored conversation.
564 const isEmpty = {
565 list: (await read($, entries)).length === 0,
566 taskList: (await read($, tasks)).length === 0,
567 fileList: (await read($, files)).length === 0,
568 skillList: (await read($, skills)).length === 0,
569 mcpList: (await read($, mcp)).length === 0,
570 };
571 if (Object.values(isEmpty).some((value) => value)) {
572 const back = await readBack($).catch(() => null);
573 if (back !== null && isEmpty.list)
574 await update($, entries, () => back.list);
575 if (back !== null && isEmpty.taskList)
576 await update($, tasks, () => back.taskList);
577 if (back !== null && isEmpty.fileList)
578 await update($, files, () => back.fileList);
579 if (back !== null && isEmpty.skillList)
580 await update($, skills, () => back.skillList);
581 if (back !== null && isEmpty.mcpList)
582 await update($, mcp, () => back.mcpList);
583 }
584
585 // Opened unasked only where the pane is a sidebar: in fullscreen mode.
586 const settings = await $.settings.read();
587 if (isOn && e.isInteractive && settings.tui === "fullscreen") {
588 void $.ui.open({ id: PANE, title: TITLE });
589 }
590
591 const started = await next(e);
592 void readContext($).catch(() => undefined);
593 void readGit($).catch(() => undefined);
594
595 return started;
596 });
597
598 on("session.end", async ($, e, next) => {
599 if (e.reason === "clear") {
600 await update($, entries, () => []);
601 await update($, expanded, () => null);
602 await update($, anchor, () => null);
603 await update($, contextInfo, () => null);
604 await update($, tasks, () => []);
605 await update($, agents, () => []);
606 await update($, files, () => []);
607 await update($, turnTokens, () => []);
608 await update($, fileDiff, () => null);
609 await update($, skills, () => []);
610 await update($, mcp, () => []);
611 await update($, closedServers, () => []);
612 }
613
614 return next(e);
615 });
616
617 // A subagent runs from its spawn. Its own description labels it.
618 on("agent.spawn", async ($, e, next) => {
619 const spawned = await next(e);
620 if (spawned.agentId !== undefined) {
621 await noteAgent($, spawned.agentId, {
622 label: e.description,
623 type: e.subagentType,
624 });
625 }
626
627 return spawned;
628 }).catch((_$, e, next) => next(e));
629
630 // A subagent is done when its turn ends.
631 on("turn.complete", async ($, e, next) => {
632 const id = e.agentId;
633 if (
634 id !== undefined &&
635 (await read($, agents)).some((one) => one.id === id)
636 )
637 await update($, agents, (list) =>
638 list.map((one) => (one.id === id ? { ...one, isDone: true } : one)),
639 );
640
641 return next(e);
642 }).catch((_$, e, next) => next(e));
643
644 // The engine measures after each turn of the main conversation. The
645 // input tokens of the recent turns give the turns until compaction. A
646 // drop, as after a compaction, starts the samples again.
647 on("session.measure", async ($, e, next) => {
648 const tokens = e.context.tokens;
649 if (e.changed.includes("context") && tokens !== undefined) {
650 await update($, turnTokens, (list) => {
651 const last = list.at(-1);
652 if (last !== undefined && tokens < last) return [tokens];
653
654 return [...list, tokens].slice(-TURN_SAMPLES);
655 });
656 }
657 void readContext($).catch(() => undefined);
658 void readGit($).catch(() => undefined);
659
660 return next(e);
661 }).catch((_$, e, next) => next(e));
662
663 on("command.run", { command: "stereo-view" }, async ($, e) => {
664 const arg = e.args.trim().toLowerCase();
665
666 if (arg === "clear") {
667 await update($, entries, () => []);
668 await update($, anchor, () => null);
669 $.ui.toast("Activity cleared");
670
671 return {};
672 }
673 if (arg === "results") {
674 const isShown = await update($, showResults, (value) => !value);
675 $.ui.toast(isShown ? "Activity shows results" : "Activity hides results");
676
677 return {};
678 }
679 if (arg !== "" && arg !== "on" && arg !== "off") return { text: USAGE };
680
681 const isOn = arg === "" ? !(await read($, isSplit)) : arg === "on";
682 await setSplit($, isOn);
683
684 return {};
685 });
686
687 // The person closed the pane with its mark: show tool rows again.
688 on("ui.close", { id: PANE }, async ($, e, next) => {
689 if (e.origin.kind === "person") {
690 await update($, isSplit, () => false);
691 await $.store.set(STORE_SPLIT, false);
692 }
693
694 return next(e);
695 }).catch((_$, e, next) => next(e));
696
697 on("prompt.submit", async ($, e, next) => {
698 if (e.command === undefined && e.isMeta !== true && e.text.trim() !== "") {
699 const startedAt = await $.clock.now();
700 const id = `turn:${startedAt}`;
701 await update($, entries, (list) =>
702 append(list, { kind: "turn", id, text: firstLine(e.text), startedAt }),
703 );
704 }
705
706 const sent = await next(e);
707 // A typed `/name` that the skill listing holds is a use of that skill.
708 if (e.command !== undefined && sent.drop === undefined)
709 await noteSkill($, e.command.name, false);
710
711 return sent;
712 }).catch((_$, e, next) => next(e));
713
714 // These hooks only record. If one fails, the call goes on without the mod.
715 on("tool.call", async ($, e, next) => {
716 const startedAt = await $.clock.now();
717 const id = e.tool_use_id;
718 const input = e as unknown as Record<string, unknown>;
719 const call: ActivityCall = {
720 kind: "call",
721 id,
722 tool: String(e.tool),
723 summary: summarize(input, await rootsFor($, input)),
724 command: commandOf(input)?.slice(0, MAX_RESULT),
725 agentId: e.agentId,
726 startedAt,
727 status: "running",
728 };
729 await update($, entries, (list) => append(list, call));
730
731 const ran = await next(e);
732 const endedAt = await $.clock.now();
733 if (ran.deny !== undefined) {
734 await update($, entries, (list) =>
735 finish(list, id, { status: "denied", endedAt, result: ran.deny }),
736 );
737 } else {
738 const status: CallStatus = ran.isError === true ? "error" : "done";
739 const result = ran.text?.slice(0, MAX_RESULT);
740 await update($, entries, (list) =>
741 finish(list, id, { status, endedAt, result }),
742 );
743 if (status === "done") {
744 if (TASK_TOOLS.has(call.tool)) {
745 await update($, tasks, (list) =>
746 nextTasks(list, call.tool, input, ran.result, e.agentId),
747 );
748 if (e.agentId !== undefined) await noteAgent($, e.agentId);
749 }
750 const path = pathsOf(input)[0];
751 if (FILE_TOOLS.has(call.tool) && path !== undefined) {
752 await update($, files, (list) => nextFiles(list, path, call.summary));
753 // An open diff of this file shows the new change.
754 const open = await read($, fileDiff);
755 if (open?.path === path) {
756 const lines = await diffOf($, path);
757 await update($, fileDiff, () => ({ ...open, lines }));
758 }
759 }
760 // A file tool or a shell command can change the working tree.
761 if (FILE_TOOLS.has(call.tool) || call.tool === "Bash")
762 void readGit($).catch(() => undefined);
763 const skill = input.skill;
764 if (call.tool === "Skill" && typeof skill === "string")
765 await noteSkill($, skill, true);
766 }
767 }
768 // Each MCP call counts, also a failed or denied one.
769 if (mcpParts(call.tool) !== undefined) {
770 const isFailed = ran.deny !== undefined || ran.isError === true;
771 await update($, mcp, (list) => nextMcp(list, call.tool, isFailed));
772 }
773 // The result is now part of the main conversation's context.
774 if (e.agentId === undefined) void readContext($).catch(() => undefined);
775
776 return ran;
777 }).catch((_$, e, next) => next(e));
778
779 // The tree fits the body, so the engine's window has nothing to move.
780 // The lower half moves instead: an open diff by rows, else the list by
781 // one entry for each row of the move.
782 on("ui.scroll", { requestId: PANE }, async ($, e) => {
783 const { ids, top, diffRows } = layout;
784 if (e.by === 0) return {};
785 const diff = await read($, fileDiff);
786 if (diff !== null) {
787 const most = Math.max(0, diff.lines.length - diffRows);
788 await update($, diffScroll, (at) =>
789 Math.min(most, Math.max(0, at + e.by)),
790 );
791
792 return {};
793 }
794 if (ids.length === 0) return {};
795 const last = ids.length - 1;
796 await update($, anchor, (current) => {
797 const at = current === null ? -1 : ids.indexOf(current);
798 const to = Math.min(last, Math.max(top, (at < 0 ? last : at) + e.by));
799
800 return to >= last ? null : (ids[to] ?? null);
801 });
802
803 return {};
804 }).catch((_$, e, next) => next(e));
805
806 on("ui.render", { component: "ToolUse" }, async ($, e, next) => {
807 if (CONVERSATION_TOOLS.has(e.props.tool)) return next(e);
808 if (!(await isActivityShown($, e.viewport))) return next(e);
809 const { Box } = $.ui.resolve(e);
810
811 return <Box />;
812 });
813
814 on("ui.render", { component: "ToolResult" }, async ($, e, next) => {
815 if (CONVERSATION_TOOLS.has(e.props.tool)) return next(e);
816 if (!(await isActivityShown($, e.viewport))) return next(e);
817 const { Box } = $.ui.resolve(e);
818
819 return <Box />;
820 });
821
822 on("ui.render", { component: "ToolGroup" }, async ($, e, next) => {
823 if (e.props.calls.some((call) => CONVERSATION_TOOLS.has(call.tool)))
824 return next(e);
825 if (!(await isActivityShown($, e.viewport))) return next(e);
826 const { Box } = $.ui.resolve(e);
827
828 return <Box />;
829 });
830
831 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
832 const { Box, Button, Text } = $.ui.resolve(e);
833 const width = Math.max(20, e.props.bodyColumns);
834 const all = await read($, entries);
835 const open = await read($, expanded);
836 const withResults = await read($, showResults);
837 const info = await read($, contextInfo);
838 const pinned = await read($, anchor);
839 const taskList = await read($, tasks);
840 const agentList = await read($, agents);
841 const fileList = await read($, files);
842 const withCategories = await read($, showCategories);
843 const samples = await read($, turnTokens);
844 const git = await read($, gitState);
845 const diff = await read($, fileDiff);
846 const diffAt = await read($, diffScroll);
847 const failedOnly = await read($, onlyFailed);
848 const skillList = await read($, skills);
849 const mcpList = await read($, mcp);
850 const closed = await read($, closedServers);
851 const now = await $.clock.now();
852
853 // The body splits in half. The top half shows the context window,
854 // then the tasks, the skills, the MCP servers and the changed files.
855 // The lower half shows the calls. Each part gives its first row to
856 // its heading.
857 const bodyRows = Math.max(MIN_BODY_ROWS, e.props.scroll.bodyRows);
858 const topRows = Math.floor(bodyRows / 2);
859 const lowRows = bodyRows - topRows;
860 const listRows = lowRows - 1;
861
862 // A heading row: a title, dim details, and a rule to the right edge.
863 const heading = (title: string, detail: string) => (
864 <Box
865 flexDirection="row"
866 flexGrow={1}
867 height={1}
868 overflow="hidden"
869 gap={1}
870 >
871 <Text wrap="truncate-end">
872 <Text dimColor>{"── "}</Text>
873 <Text weight="bold">{title}</Text>
874 <Text dimColor>{detail === "" ? "" : ` ${detail}`}</Text>
875 </Text>
876 <Box flexGrow={1} width={0} minWidth={1} height={1} overflow="hidden">
877 <Text dimColor>{RULE}</Text>
878 </Box>
879 </Box>
880 );
881
882 // From WARN_PERCENT, the used part of the bar takes the warning color
883 // and the heading shows a Compact button.
884 const fillPercent = info?.percent ?? 0;
885 const isWarm = fillPercent >= WARN_PERCENT;
886 const usedColor =
887 fillPercent >= ALERT_PERCENT ? "error" : isWarm ? "warning" : undefined;
888
889 // The bar and the categories measure against the compaction window,
890 // which can be smaller than the model's window.
891 const categories = info?.rows ?? [];
892 const total =
893 info === null
894 ? 0
895 : categories.length > 0
896 ? (info.rowsWindow ??
897 categories.reduce((sum, row) => sum + row.tokens, 0))
898 : info.window;
899 const parts =
900 categories.length > 0
901 ? categories.map((row) => ({
902 glyph: AREA[row.kind],
903 color:
904 row.kind === "used" && usedColor !== undefined
905 ? usedColor
906 : row.color,
907 tokens: row.tokens,
908 }))
909 : info === null
910 ? []
911 : [
912 {
913 glyph: AREA.used,
914 color: usedColor ?? "claude",
915 tokens: info.tokens ?? 0,
916 },
917 {
918 glyph: AREA.free,
919 color: "inactive",
920 tokens: Math.max(0, info.window - (info.tokens ?? 0)),
921 },
922 ];
923 const cells = barCells(
924 parts.map((part) => part.tokens),
925 total,
926 width,
927 );
928 const share = (tokens: number) =>
929 total > 0 ? `${((tokens / total) * 100).toFixed(1)}%` : "";
930 // The categories are indented by two cells under their toggle.
931 const nameRoom = Math.max(1, width - 18);
932
933 let fill = "";
934 const spend: string[] = [];
935 // Each limit is one item of its row. The row spreads them out: two go
936 // to the left and right edges, three also to the middle.
937 const limits: string[] = [];
938 if (info !== null) {
939 fill =
940 info.tokens === undefined
941 ? `${tokenCount(info.window)} window, no reply yet`
942 : `${tokenCount(info.tokens)} / ${tokenCount(info.window)} tokens (${info.percent ?? 0}%)`;
943 if (info.compactAt !== undefined) {
944 fill += ` · compacts at ${tokenCount(info.compactAt)}`;
945 const left = turnsLeft(samples, info.compactAt);
946 if (left !== undefined) fill += `, ~${left} turns`;
947 }
948 if (info.usd !== undefined) spend.push(`$${info.usd.toFixed(2)}`);
949 if (info.cacheRate !== undefined) spend.push(`cache ${info.cacheRate}%`);
950 for (const limit of info.limits) {
951 const reset =
952 limit.resetsAt === undefined ? "" : resetLabel(limit.resetsAt, now);
953 limits.push(
954 `${limitLabel(limit.kind)} ${limit.percentUsed}%${reset === "" ? "" : ` ↻ ${reset}`}`,
955 );
956 }
957 }
958
959 // The context takes the rows it needs, at least its heading and its
960 // bar. The parts under it keep at least the rows of their headings.
961 const contextNeed =
962 info === null
963 ? 2
964 : 3 +
965 (spend.length > 0 ? 1 : 0) +
966 (limits.length > 0 ? 1 : 0) +
967 (categories.length === 0
968 ? 0
969 : 1 + (withCategories ? categories.length : 0));
970 const contextRows = Math.min(
971 contextNeed,
972 Math.max(2, topRows - WORK_PARTS),
973 );
974 const workRows = topRows - contextRows;
975
976 const contextPart = (
977 <Box
978 flexDirection="column"
979 height={contextRows}
980 flexShrink={0}
981 overflow="hidden"
982 >
983 <Box flexDirection="row" height={1} gap={1}>
984 {heading("Context", info?.model ?? "")}
985 {isWarm && (
986 <Box flexDirection="row" flexShrink={0}>
987 <Button
988 key="compact"
989 label="Compact"
990 onPress={async () => {
991 $.ui.toast("Compacting the conversation");
992 await $.session.compact();
993 }}
994 />
995 </Box>
996 )}
997 </Box>
998 {info === null && <Text dimColor>No reading yet.</Text>}
999 {info !== null && (
1000 <Text wrap="truncate-end">
1001 {parts.map((part, index) => (
1002 <Text color={part.color}>
1003 {part.glyph.repeat(cells[index] ?? 0)}
1004 </Text>
1005 ))}
1006 </Text>
1007 )}
1008 {info !== null && <Text wrap="truncate-end">{fill}</Text>}
1009 {spend.length > 0 && (
1010 <Text dimColor wrap="truncate-end">
1011 {spend.join(" · ")}
1012 </Text>
1013 )}
1014 {limits.length > 0 && (
1015 <Box
1016 flexDirection="row"
1017 height={1}
1018 overflow="hidden"
1019 justifyContent="space-between"
1020 >
1021 {limits.map((limit) => (
1022 <Text dimColor wrap="truncate-end">
1023 {limit}
1024 </Text>
1025 ))}
1026 </Box>
1027 )}
1028 {categories.length > 0 && (
1029 <Button
1030 key="categories"
1031 plain
1032 onPress={() => update($, showCategories, (value) => !value)}
1033 >
1034 <Text dimColor>{withCategories ? "▾ " : "▸ "}</Text>
1035 {"Categories"}
1036 <Text dimColor>{` ${categories.length}`}</Text>
1037 </Button>
1038 )}
1039 {withCategories &&
1040 categories.map((row) => (
1041 <Text wrap="truncate-end">
1042 <Text color={row.color}>{` ${AREA[row.kind]} `}</Text>
1043 {cut(row.name, nameRoom).padEnd(nameRoom)}
1044 <Text dimColor>
1045 {`${tokenCount(row.tokens).padStart(7)}${share(row.tokens).padStart(7)}`}
1046 </Text>
1047 </Text>
1048 ))}
1049 </Box>
1050 );
1051
1052 // Each of the four parts gets its heading row, then an equal share of
1053 // the rows that are left, up to the rows that it needs. The files
1054 // show grouped by folder, and the git state takes one row under
1055 // their heading. An MCP server shows its tools unless the person
1056 // folded it.
1057 const gitRows = git === null ? 0 : 1;
1058 const allFileRows = fileRowsOf(fileList);
1059 const allMcpRows = mcpRowsOf(mcpList, closed);
1060 const otherNeeds = [
1061 1 + skillList.length,
1062 1 + allMcpRows.length,
1063 1 + gitRows + allFileRows.length,
1064 ];
1065 // When the whole task list does not fit, the completed tasks hide
1066 // first. The rows that they free go to the other parts.
1067 const allTaskRows = taskRowsOf(taskList, agentList);
1068 let openTaskRows = allTaskRows;
1069 let shares = shareRows([1 + allTaskRows.length, ...otherNeeds], workRows);
1070 if ((shares[0] ?? 0) - 1 < allTaskRows.length) {
1071 openTaskRows = taskRowsOf(
1072 taskList.filter((task) => task.status !== "completed"),
1073 agentList,
1074 );
1075 shares = shareRows([1 + openTaskRows.length, ...otherNeeds], workRows);
1076 }
1077 const [taskRows = 0, skillRows = 0, mcpRows = 0, fileRows = 0] = shares;
1078 const done = taskList.filter((task) => task.status === "completed").length;
1079 const runningAgents = agentList.filter((one) => !one.isDone).length;
1080 const taskDetail: string[] = [];
1081 if (taskList.length > 0) taskDetail.push(`${done}/${taskList.length} done`);
1082 if (runningAgents > 0)
1083 taskDetail.push(
1084 `${runningAgents} ${runningAgents === 1 ? "agent" : "agents"} running`,
1085 );
1086 const shownTasks = fit(openTaskRows, taskRows - 1);
1087
1088 // A count of uses or calls, right-aligned in the last six cells.
1089 const countCell = (n: number) => `×${n}`.padStart(6);
1090 const failedCell = (n: number) => (n > 0 ? ` ✗${n}` : "");
1091 const plural = (n: number, word: string) =>
1092 `${n} ${n === 1 ? word : `${word}s`}`;
1093
1094 // Each skill reads its name, where it comes from, and its uses.
1095 const shownSkills = fit(skillList, skillRows - 1);
1096 const skillNames = shownSkills.kept.map(skillParts);
1097 const originRoom = Math.min(
1098 16,
1099 Math.max(0, ...skillNames.map((one) => one.origin.length)),
1100 );
1101 const skillRoom = Math.max(
1102 1,
1103 width - 6 - (originRoom > 0 ? originRoom + 1 : 0),
1104 );
1105 const skillLine = (skill: SkillUse, index: number) => {
1106 const { name, origin } = skillNames[index] ?? skillParts(skill);
1107 const originText =
1108 originRoom > 0 ? ` ${cut(origin, originRoom).padEnd(originRoom)}` : "";
1109
1110 return (
1111 <Text wrap="truncate-end">
1112 {cut(name, skillRoom).padEnd(skillRoom)}
1113 <Text dimColor>{originText + countCell(skill.uses)}</Text>
1114 </Text>
1115 );
1116 };
1117
1118 // A press on a server folds its tools away, or shows them again.
1119 const mcpCalls = mcpList.reduce(
1120 (sum, one) => sum + one.tools.reduce((all, t) => all + t.calls, 0),
1121 0,
1122 );
1123 const shownMcp = fit(allMcpRows, mcpRows - 1);
1124 const mcpLine = (row: McpRow) => {
1125 const failed = row.kind === "server" ? row.failed : row.tool.failed;
1126 const calls = row.kind === "server" ? row.calls : row.tool.calls;
1127 const tail = failedCell(failed);
1128 if (row.kind === "tool") {
1129 const room = Math.max(1, width - 4 - 6 - tail.length);
1130
1131 return (
1132 <Text wrap="truncate-end">
1133 {` ${cut(row.tool.name, room).padEnd(room)}`}
1134 {tail !== "" && <Text color="error">{tail}</Text>}
1135 <Text dimColor>{countCell(calls)}</Text>
1136 </Text>
1137 );
1138 }
1139 const room = Math.max(1, width - 2 - 6 - tail.length);
1140
1141 return (
1142 <Button
1143 key={`mcp:${row.server}`}
1144 plain
1145 onPress={() =>
1146 update($, closedServers, (list) =>
1147 list.includes(row.server)
1148 ? list.filter((one) => one !== row.server)
1149 : [...list, row.server],
1150 )
1151 }
1152 >
1153 <Text dimColor>{row.isOpen ? "▾ " : "▸ "}</Text>
1154 <Text weight="bold">{cut(row.server, room).padEnd(room)}</Text>
1155 {tail !== "" && <Text color="error">{tail}</Text>}
1156 <Text dimColor>{countCell(calls)}</Text>
1157 </Button>
1158 );
1159 };
1160
1161 // A press on a file opens its diff in the lower half. A press on the
1162 // open file closes the diff.
1163 const gitShown = git !== null && fileRows > 1;
1164 const fileListRows = fileRows - 1 - (gitShown ? 1 : 0);
1165 const shownFiles = fitFiles(allFileRows, fileListRows);
1166 const fileRoom = Math.max(1, width - 2 - 6);
1167 const fileLine = (row: FileRow) => {
1168 if (row.kind === "dir") {
1169 return (
1170 <Text dimColor wrap="truncate-end">
1171 {cutStart(row.label, width)}
1172 </Text>
1173 );
1174 }
1175 const { file } = row;
1176
1177 return (
1178 <Button
1179 key={`file:${file.path}`}
1180 plain
1181 onPress={() =>
1182 diff?.path === file.path
1183 ? update($, fileDiff, () => null)
1184 : openDiff($, file)
1185 }
1186 >
1187 <Text weight={diff?.path === file.path ? "bold" : undefined}>
1188 {` ${cut(row.name, fileRoom).padEnd(fileRoom)}`}
1189 </Text>
1190 <Text dimColor>{countCell(file.edits)}</Text>
1191 </Button>
1192 );
1193 };
1194 const gitLine = git !== null && (
1195 <Text dimColor wrap="truncate-end">{`⎇ ${gitLabel(git)}`}</Text>
1196 );
1197
1198 // The tasks, the skills and the MCP servers start at the top. The
1199 // files end at the bottom, above the calls, and the free rows stay
1200 // between them.hooks/format.ts 303 lines1// Keys that the engine adds beside a tool's own arguments.
2const RESERVED = new Set([
3 "tool",
4 "tool_use_id",
5 "agentId",
6 "requestMeta",
7 "consent",
8]);
9
10// The argument that best names what a call works on, in order of preference.
11const SUMMARY_KEYS = [
12 "command",
13 "file_path",
14 "notebook_path",
15 "path",
16 "pattern",
17 "url",
18 "query",
19 "description",
20 "skill",
21 "prompt",
22];
23
24export function firstLine(text: string): string {
25 const line = text.split("\n").find((one) => one.trim() !== "") ?? "";
26
27 return line.trim();
28}
29
30export function cut(text: string, width: number): string {
31 if (width <= 0) return "";
32 if (text.length <= width) return text;
33
34 return `${text.slice(0, Math.max(0, width - 1))}…`;
35}
36
37/** Cuts text from the start, so the end of a path stays: `…/hooks/a.ts`. */
38export function cutStart(text: string, width: number): string {
39 if (width <= 0) return "";
40 if (text.length <= width) return text;
41
42 return `…${text.slice(text.length - Math.max(0, width - 1))}`;
43}
44
45// The arguments that hold a path, which a row names from its repository root.
46const PATH_KEYS = ["file_path", "notebook_path", "path"];
47
48function relative(path: string, roots: readonly string[]): string {
49 // The deepest root wins, so a nested repository names its own files.
50 const root = roots
51 .filter((one) => path.startsWith(`${one}/`))
52 .reduce((best, one) => (one.length > best.length ? one : best), "");
53
54 return root === "" ? path : path.slice(root.length + 1);
55}
56
57function toolArgs(input: Record<string, unknown>): Record<string, unknown> {
58 return Object.fromEntries(
59 Object.entries(input).filter(([key]) => !RESERVED.has(key)),
60 );
61}
62
63/** The absolute paths that a call's input names. */
64export function pathsOf(input: Record<string, unknown>): string[] {
65 const args = toolArgs(input);
66
67 return PATH_KEYS.map((key) => args[key]).filter(
68 (value): value is string =>
69 typeof value === "string" && value.startsWith("/"),
70 );
71}
72
73/**
74 * One line that names what a call works on, from its input. A path shows
75 * from the repository root that holds it, one of `roots`.
76 */
77export function summarize(
78 input: Record<string, unknown>,
79 roots: readonly string[],
80): string {
81 const args = toolArgs(input);
82
83 if (Array.isArray(args.todos)) return `${args.todos.length} todos`;
84
85 // A command's own description says what it is for, in fewer words.
86 if (
87 typeof args.command === "string" &&
88 typeof args.description === "string"
89 ) {
90 const line = firstLine(args.description);
91 if (line !== "") return line;
92 }
93
94 for (const key of SUMMARY_KEYS) {
95 const value = args[key];
96 if (typeof value !== "string" || value === "") continue;
97 const line = firstLine(value);
98 const named = key.endsWith("path") ? relative(line, roots) : line;
99 if (key === "pattern" && typeof args.path === "string") {
100 return `${named} in ${relative(args.path, roots)}`;
101 }
102
103 return named;
104 }
105
106 const json = JSON.stringify(args);
107
108 return json === "{}" ? "" : json;
109}
110
111/**
112 * The server and the tool of an MCP tool's name: `mcp__linear__save_issue`
113 * gives `linear` and `save_issue`, and a claude.ai connector's
114 * `mcp__claude_ai_Notion__notion-search` gives `Notion` and `search`.
115 * Undefined for a tool that is not an MCP tool.
116 */
117export function mcpParts(
118 tool: string,
119): { server: string; name: string } | undefined {
120 if (!tool.startsWith("mcp__")) return undefined;
121 const rest = tool.slice("mcp__".length);
122 const split = rest.indexOf("__");
123 if (split < 0) return undefined;
124 const server = rest.slice(0, split).replace(/^claude_ai_/, "");
125 let name = rest.slice(split + 2);
126 // A tool that repeats its server's name drops it.
127 const own = server.toLowerCase();
128 const prefix = name.slice(0, own.length + 1).toLowerCase();
129 if (prefix === `${own}-` || prefix === `${own}_`)
130 name = name.slice(own.length + 1);
131
132 return { server, name };
133}
134
135/**
136 * A short name for a tool: an MCP tool reads `server:tool`, as `mcpParts`
137 * splits it. Other tools keep their name.
138 */
139export function toolLabel(tool: string): string {
140 const parts = mcpParts(tool);
141
142 return parts === undefined ? tool : `${parts.server}:${parts.name}`;
143}
144
145// Short words for the places that a skill comes from.
146const SKILL_ORIGINS: Record<string, string> = {
147 userSettings: "user",
148 projectSettings: "project",
149 policySettings: "policy",
150 syncedSkills: "synced",
151};
152
153/**
154 * How the Skills list names a skill, and where it comes from: its plugin,
155 * else a short word for its source. A plugin skill's name drops the plugin
156 * prefix, so `simple-english:simple-english` reads `simple-english`.
157 */
158export function skillParts(skill: {
159 name: string;
160 source: string;
161 pluginName?: string;
162}): { name: string; origin: string } {
163 const plugin = skill.pluginName;
164 if (plugin !== undefined) {
165 const prefix = `${plugin}:`;
166 const name = skill.name.startsWith(prefix)
167 ? skill.name.slice(prefix.length)
168 : skill.name;
169
170 return { name, origin: plugin };
171 }
172
173 return {
174 name: skill.name,
175 origin: SKILL_ORIGINS[skill.source] ?? skill.source,
176 };
177}
178
179/** The full command of a call, for its expanded row. */
180export function commandOf(input: Record<string, unknown>): string | undefined {
181 const { command } = toolArgs(input);
182
183 return typeof command === "string" && command.trim() !== ""
184 ? command
185 : undefined;
186}
187
188function pad(n: number): string {
189 return String(n).padStart(2, "0");
190}
191
192/** `HH:mm:ss` in local time, or `--:--:--` when the time is unknown. */
193export function clockTime(at: number | undefined): string {
194 if (at === undefined) return "--:--:--";
195 const date = new Date(at);
196
197 return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
198}
199
200/** A short duration such as `850ms`, `4.2s` or `3m10s`. */
201export function duration(ms: number): string {
202 if (ms < 1000) return `${Math.round(ms)}ms`;
203 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
204 const minutes = Math.floor(ms / 60_000);
205
206 return `${minutes}m${pad(Math.round((ms % 60_000) / 1000))}s`;
207}
208
209/** A short token count such as `850`, `3.1k`, `128k` or `1M`. */
210export function tokenCount(n: number): string {
211 if (n < 1000) return String(Math.round(n));
212 if (n < 100_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
213 if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
214
215 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
216}
217
218/** The short name of a rate-limit window: `five_hour` reads `5h`. */
219export function limitLabel(kind: string): string {
220 if (kind === "five_hour") return "5h";
221 if (kind === "seven_day") return "7d";
222 if (kind === "spend_limit") return "spend";
223
224 return kind;
225}
226
227const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
228
229/**
230 * When a rate-limit window resets, in local time: `18:00` within a day,
231 * else `Fri 09:00`. Empty when the timestamp does not parse.
232 */
233export function resetLabel(iso: string, now: number): string {
234 const at = Date.parse(iso);
235 if (Number.isNaN(at)) return "";
236 const date = new Date(at);
237 const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
238
239 return at - now < 86_400_000 ? time : `${DAYS[date.getDay()]} ${time}`;
240}
241
242/**
243 * How many more turns fit before auto-compaction at `compactAt`, from the
244 * mean growth over the recent turns. Undefined when the context did not
245 * grow or when there are fewer than two turns.
246 */
247export function turnsLeft(
248 samples: readonly number[],
249 compactAt: number,
250): number | undefined {
251 const first = samples[0];
252 const last = samples.at(-1);
253 if (samples.length < 2 || first === undefined || last === undefined)
254 return undefined;
255 const growth = (last - first) / (samples.length - 1);
256 if (growth <= 0) return undefined;
257
258 return Math.max(0, Math.floor((compactAt - last) / growth));
259}
260
261/**
262 * The cells of a bar `width` wide that each part takes of `total`. Edges
263 * round once, so the cells add up to the bar's filled width.
264 */
265export function barCells(
266 parts: readonly number[],
267 total: number,
268 width: number,
269): number[] {
270 let sum = 0;
271 let drawn = 0;
272
273 return parts.map((part) => {
274 if (total <= 0) return 0;
275 sum += part;
276 const edge = Math.min(width, Math.round((sum / total) * width));
277 const cells = Math.max(0, edge - drawn);
278 drawn += cells;
279
280 return cells;
281 });
282}
283
284/**
285 * Splits text into lines of at most `width` characters, so each line takes
286 * one row of the terminal. A tab counts as two spaces.
287 */
288export function hardWrap(text: string, width: number): string[] {
289 const size = Math.max(1, width);
290
291 return text
292 .replaceAll("\t", " ")
293 .split("\n")
294 .flatMap((line) => {
295 if (line.length <= size) return [line];
296 const parts: string[] = [];
297 for (let at = 0; at < line.length; at += size)
298 parts.push(line.slice(at, at + size));
299
300 return parts;
301 });
302}
303hooks/git.ts 68 lines1import type { GitState } from "../types";
2
3/** Reads the output of `git status --porcelain=v2 --branch`. */
4export function parseStatus(stdout: string): GitState {
5 const state: GitState = {
6 branch: "",
7 ahead: 0,
8 behind: 0,
9 changed: 0,
10 staged: 0,
11 };
12 for (const line of stdout.split("\n")) {
13 if (line.startsWith("# branch.head ")) {
14 state.branch = line.slice("# branch.head ".length);
15 } else if (line.startsWith("# branch.ab ")) {
16 const [ahead, behind] = line.slice("# branch.ab ".length).split(" ");
17 state.ahead = Math.abs(Number(ahead) || 0);
18 state.behind = Math.abs(Number(behind) || 0);
19 } else if (line.startsWith("? ")) {
20 state.changed += 1;
21 } else if (/^[12u] /.test(line)) {
22 // The second field holds the staged and the unstaged state.
23 state.changed += 1;
24 if (line[2] !== ".") state.staged += 1;
25 }
26 }
27
28 return state;
29}
30
31/** One line for the git state, such as `main ↑1 · 4 uncommitted, 1 staged`. */
32export function gitLabel(state: GitState): string {
33 let text = state.branch === "(detached)" ? "detached HEAD" : state.branch;
34 if (state.ahead > 0) text += ` ↑${state.ahead}`;
35 if (state.behind > 0) text += ` ↓${state.behind}`;
36 if (state.changed === 0) return `${text} · clean`;
37 text += ` · ${state.changed} uncommitted`;
38
39 return state.staged > 0 ? `${text}, ${state.staged} staged` : text;
40}
41
42/** How many lines a diff adds and removes, without its file headers. */
43export function diffCounts(lines: readonly string[]): {
44 added: number;
45 removed: number;
46} {
47 let added = 0;
48 let removed = 0;
49 for (const line of lines) {
50 if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
51 if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
52 }
53
54 return { added, removed };
55}
56
57/** The theme color of one diff line; undefined keeps the text color. */
58export function diffColor(line: string): string | undefined {
59 if (line.startsWith("+++") || line.startsWith("---")) return "inactive";
60 if (line.startsWith("+")) return "success";
61 if (line.startsWith("-")) return "error";
62 if (line.startsWith("@@")) return "suggestion";
63 if (/^(diff|index|new file|deleted file|similarity|rename) /.test(line))
64 return "inactive";
65
66 return undefined;
67}
68hooks/work.ts 344 lines1import type {
2 FileChange,
3 McpServerUse,
4 McpToolUse,
5 SkillUse,
6 TaskItem,
7 TaskStatus,
8} from "../types";
9import { mcpParts } from "./format";
10
11/** The tools that change the task list. */
12export const TASK_TOOLS = new Set([
13 "TodoWrite",
14 "TaskCreate",
15 "TaskUpdate",
16 "TaskList",
17]);
18
19/** The tools that change a file. */
20export const FILE_TOOLS = new Set([
21 "Edit",
22 "MultiEdit",
23 "Write",
24 "NotebookEdit",
25]);
26
27const MAX_FILES = 200;
28
29function isRecord(value: unknown): value is Record<string, unknown> {
30 return typeof value === "object" && value !== null;
31}
32
33function statusOf(value: unknown): TaskStatus | undefined {
34 return value === "pending" || value === "in_progress" || value === "completed"
35 ? value
36 : undefined;
37}
38
39/**
40 * The task list after a successful call of `tool` by `agentId` (absent for
41 * the main conversation). TodoWrite gives the whole list of that agent.
42 * The Task tools share one list: a new task goes to the agent that made it,
43 * and an update finds the task by its id in any agent's group.
44 */
45export function nextTasks(
46 list: readonly TaskItem[],
47 tool: string,
48 input: Record<string, unknown>,
49 result: unknown,
50 agentId?: string,
51): TaskItem[] {
52 const isOwn = (task: TaskItem) => task.agentId === agentId;
53 const owner = agentId === undefined ? {} : { agentId };
54
55 if (tool === "TodoWrite" && Array.isArray(input.todos)) {
56 const todos = input.todos.flatMap((todo: unknown, index) => {
57 if (!isRecord(todo) || typeof todo.content !== "string") return [];
58 const status = statusOf(todo.status) ?? "pending";
59 const id = `todo:${agentId ?? "main"}:${index}`;
60
61 return [{ id, subject: todo.content, status, ...owner }];
62 });
63
64 return [...list.filter((task) => !isOwn(task)), ...todos];
65 }
66 if (tool === "TaskList" && isRecord(result) && Array.isArray(result.tasks)) {
67 const listed = new Map<string, TaskItem>();
68 for (const task of result.tasks as unknown[]) {
69 if (!isRecord(task)) continue;
70 const { id, subject } = task;
71 if (typeof id !== "string" || typeof subject !== "string") continue;
72 const status = statusOf(task.status) ?? "pending";
73 listed.set(id, { id, subject, status });
74 }
75 // A task of the caller that the list leaves out is gone.
76 const kept = list.flatMap((task) => {
77 const now = listed.get(task.id);
78 if (now !== undefined) return [{ ...task, ...now }];
79 const isGone = isOwn(task) && !task.id.startsWith("todo:");
80
81 return isGone ? [] : [task];
82 });
83 const known = new Set(list.map((task) => task.id));
84 const added = [...listed.values()]
85 .filter((task) => !known.has(task.id))
86 .map((task) => ({ ...task, ...owner }));
87
88 return [...kept, ...added];
89 }
90 if (tool === "TaskCreate" && isRecord(result) && isRecord(result.task)) {
91 const { id, subject } = result.task;
92 if (typeof id !== "string" || typeof subject !== "string") return [...list];
93 const task: TaskItem = { id, subject, status: "pending", ...owner };
94
95 return [...list.filter((one) => one.id !== id), task];
96 }
97 const id = input.taskId;
98 if (tool === "TaskUpdate" && typeof id === "string") {
99 if (isRecord(result) && result.success === false) return [...list];
100 if (input.status === "deleted")
101 return list.filter((task) => task.id !== id);
102 const subject = input.subject;
103 const status = statusOf(input.status);
104
105 return list.map((task) =>
106 task.id !== id
107 ? task
108 : {
109 ...task,
110 subject: typeof subject === "string" ? subject : task.subject,
111 status: status ?? task.status,
112 },
113 );
114 }
115
116 return [...list];
117}
118
119/** The changed files after one more change of `path`, the most recent first. */
120export function nextFiles(
121 list: readonly FileChange[],
122 path: string,
123 label: string,
124): FileChange[] {
125 const edits = (list.find((file) => file.path === path)?.edits ?? 0) + 1;
126
127 return [
128 { path, label, edits },
129 ...list.filter((file) => file.path !== path),
130 ].slice(0, MAX_FILES);
131}
132
133/** One row of the Files list: a folder, or a file under it. */
134export type FileRow =
135 | { kind: "dir"; dir: string; label: string }
136 | { kind: "file"; file: FileChange; name: string };
137
138/**
139 * The changed files grouped by folder. A folder row names the folder from
140 * its repository root, `./` for the root itself. The folder with the newest
141 * change comes first, and in a folder the newest change comes first.
142 */
143export function fileRowsOf(list: readonly FileChange[]): FileRow[] {
144 const groups = new Map<string, { label: string; files: FileChange[] }>();
145 for (const file of list) {
146 const dir = file.path.slice(0, file.path.lastIndexOf("/") + 1);
147 const split = file.label.lastIndexOf("/");
148 const label = split < 0 ? "./" : file.label.slice(0, split + 1);
149 const group = groups.get(dir) ?? { label, files: [] };
150 group.files.push(file);
151 groups.set(dir, group);
152 }
153
154 return [...groups.entries()].flatMap(([dir, group]): FileRow[] => [
155 { kind: "dir", dir, label: group.label },
156 ...group.files.map(
157 (file): FileRow => ({
158 kind: "file",
159 file,
160 name: file.label.slice(file.label.lastIndexOf("/") + 1),
161 }),
162 ),
163 ]);
164}
165
166/**
167 * The file rows that fit in `room` rows, as `fit` keeps them, but a folder
168 * row never ends the kept rows. `hidden` counts the files that do not fit.
169 */
170export function fitFiles(
171 rows: readonly FileRow[],
172 room: number,
173): { kept: readonly FileRow[]; hidden: number } {
174 const { kept } = fit(rows, room);
175 let end = kept.length;
176 while (end > 0 && kept[end - 1]?.kind === "dir") end -= 1;
177 const shown = kept.slice(0, end);
178 const isFile = (row: FileRow) => row.kind === "file";
179
180 return {
181 kept: shown,
182 hidden: rows.filter(isFile).length - shown.filter(isFile).length,
183 };
184}
185
186/** One skill of the session's skill listing. */
187export type ListedSkill = { name: string; source: string; pluginName?: string };
188
189/**
190 * The listed skill that `name` names, with or without its plugin prefix,
191 * or undefined when the listing does not hold it.
192 */
193export function matchSkill(
194 listed: readonly ListedSkill[],
195 name: string,
196): ListedSkill | undefined {
197 return (
198 listed.find((one) => one.name === name) ??
199 listed.find(
200 (one) =>
201 one.pluginName !== undefined &&
202 one.name === `${one.pluginName}:${name}`,
203 )
204 );
205}
206
207const MAX_SKILLS = 200;
208
209/**
210 * The skills after one more use of `used`, the most recent first. A use
211 * that knows its source fills in the source of a skill read back on resume.
212 */
213export function nextSkills(
214 list: readonly SkillUse[],
215 used: { name: string; source: string; pluginName?: string },
216): SkillUse[] {
217 const old = list.find((one) => one.name === used.name);
218 const pluginName = used.pluginName ?? old?.pluginName;
219 const skill: SkillUse = {
220 name: used.name,
221 source: used.source === "" ? (old?.source ?? "") : used.source,
222 uses: (old?.uses ?? 0) + 1,
223 ...(pluginName === undefined ? {} : { pluginName }),
224 };
225
226 return [skill, ...list.filter((one) => one.name !== used.name)].slice(
227 0,
228 MAX_SKILLS,
229 );
230}
231
232const MAX_SERVERS = 50;
233const MAX_SERVER_TOOLS = 100;
234
235/**
236 * The MCP servers after one more call of `tool`, the most recently called
237 * server and tool first. A tool that is not an MCP tool changes nothing.
238 */
239export function nextMcp(
240 list: readonly McpServerUse[],
241 tool: string,
242 isFailed: boolean,
243): McpServerUse[] {
244 const parts = mcpParts(tool);
245 if (parts === undefined) return [...list];
246 const server = list.find((one) => one.server === parts.server);
247 const old = server?.tools.find((one) => one.name === parts.name);
248 const called: McpToolUse = {
249 name: parts.name,
250 calls: (old?.calls ?? 0) + 1,
251 failed: (old?.failed ?? 0) + (isFailed ? 1 : 0),
252 };
253 const others = (server?.tools ?? []).filter((one) => one.name !== parts.name);
254
255 return [
256 {
257 server: parts.server,
258 tools: [called, ...others].slice(0, MAX_SERVER_TOOLS),
259 },
260 ...list.filter((one) => one.server !== parts.server),
261 ].slice(0, MAX_SERVERS);
262}
263
264/** One row of the MCP list: a server with its totals, or one of its tools. */
265export type McpRow =
266 | {
267 kind: "server";
268 server: string;
269 calls: number;
270 failed: number;
271 isOpen: boolean;
272 }
273 | { kind: "tool"; server: string; tool: McpToolUse };
274
275/** Each server's row, with its tools under it unless it is in `closed`. */
276export function mcpRowsOf(
277 list: readonly McpServerUse[],
278 closed: readonly string[],
279): McpRow[] {
280 return list.flatMap((one): McpRow[] => {
281 const isOpen = !closed.includes(one.server);
282 const sum = (pick: (tool: McpToolUse) => number) =>
283 one.tools.reduce((total, tool) => total + pick(tool), 0);
284 const head: McpRow = {
285 kind: "server",
286 server: one.server,
287 calls: sum((tool) => tool.calls),
288 failed: sum((tool) => tool.failed),
289 isOpen,
290 };
291 if (!isOpen) return [head];
292
293 return [
294 head,
295 ...one.tools.map(
296 (tool): McpRow => ({ kind: "tool", server: one.server, tool }),
297 ),
298 ];
299 });
300}
301
302/**
303 * Shares `room` rows among parts that need `needs` rows each. Each part
304 * first gets one row for its heading, in order, while rows are left. Then
305 * the parts that need more get equal shares of the rest, until no part
306 * needs more or no row is left.
307 */
308export function shareRows(needs: readonly number[], room: number): number[] {
309 let left = Math.max(0, room);
310 const rows = needs.map((need): number => {
311 const heading = need > 0 && left > 0 ? 1 : 0;
312 left -= heading;
313
314 return heading;
315 });
316 const wantOf = (index: number) => (needs[index] ?? 0) - (rows[index] ?? 0);
317 for (;;) {
318 const open = rows.flatMap((_, index) => (wantOf(index) > 0 ? [index] : []));
319 if (open.length === 0 || left === 0) break;
320 const share = Math.max(1, Math.floor(left / open.length));
321 for (const index of open) {
322 const give = Math.min(share, wantOf(index), left);
323 rows[index] = (rows[index] ?? 0) + give;
324 left -= give;
325 }
326 }
327
328 return rows;
329}
330
331/**
332 * The items that fit in `room` rows. When some do not fit, the last row
333 * goes to a line that counts the others, so `kept` is one shorter.
334 */
335export function fit<T>(
336 items: readonly T[],
337 room: number,
338): { kept: readonly T[]; hidden: number } {
339 if (items.length <= room) return { kept: items, hidden: 0 };
340 const kept = items.slice(0, Math.max(0, room - 1));
341
342 return { kept, hidden: items.length - kept.length };
343}
344types/index.d.ts 194 lines1export type CallStatus = "running" | "done" | "error" | "denied";
2
3/** One tool call in the Activity pane. */
4export type ActivityCall = {
5 kind: "call";
6 id: string;
7 tool: string;
8 /** One line that names what the call does or works on (a description, a path). */
9 summary: string;
10 /** The full command, for calls that run one; the expanded row shows it. */
11 command?: string;
12 /** The loop the call ran in; absent on the main conversation. */
13 agentId?: string;
14 /** Milliseconds since the epoch; absent for calls read back on resume. */
15 startedAt?: number;
16 endedAt?: number;
17 status: CallStatus;
18 /** The result as the model read it, cut to a fixed length. */
19 result?: string;
20};
21
22/** A divider that marks where the person sent a prompt. */
23export type ActivityTurn = {
24 kind: "turn";
25 id: string;
26 text: string;
27 /** Milliseconds since the epoch; absent for prompts read back on resume. */
28 startedAt?: number;
29};
30
31export type ActivityEntry = ActivityCall | ActivityTurn;
32
33/** One category of the context window, as /context lists it. */
34export type ContextRow = {
35 name: string;
36 tokens: number;
37 /** The theme color of the category in /context. */
38 color: string;
39 kind: "used" | "free" | "buffer";
40};
41
42/** The context window as the pane last read it. */
43export type ContextInfo = {
44 /** The model that the breakdown is for. Absent without a breakdown. */
45 model?: string;
46 /** Input tokens of the last response. Absent before the first response. */
47 tokens?: number;
48 /** The model's context window, in tokens. */
49 window: number;
50 /** `tokens` over `window`, 0 to 100. */
51 percent?: number;
52 /** The window that the rows measure against: the compaction window. */
53 rowsWindow?: number;
54 /** The window by category, estimated. Empty when there is no breakdown. */
55 rows: ContextRow[];
56 /**
57 * The token count at which auto-compaction starts. Absent when
58 * auto-compaction is off.
59 */
60 compactAt?: number;
61 /** What the session has cost so far, in US dollars. */
62 usd?: number;
63 /**
64 * The share of the last input that came from the prompt cache, 0 to 100.
65 * Absent before the first response.
66 */
67 cacheRate?: number;
68 /**
69 * Each rate-limit window, such as `five_hour`: how much of it is used,
70 * and when it resets, as an ISO 8601 timestamp.
71 */
72 limits: { kind: string; percentUsed: number; resetsAt?: string }[];
73};
74
75export type TaskStatus = "pending" | "in_progress" | "completed";
76
77/** One item of a task list. */
78export type TaskItem = {
79 /** The task id, or `todo:<agent>:<index>` for an item of TodoWrite. */
80 id: string;
81 subject: string;
82 status: TaskStatus;
83 /** The subagent that wrote the task; absent for the main conversation. */
84 agentId?: string;
85};
86
87/** A subagent that wrote tasks, as the Tasks list labels it. */
88export type AgentLabel = {
89 id: string;
90 /** The description that the Agent call gave. */
91 label: string;
92 /** The agent type, such as `Explore`. */
93 type: string;
94 isDone: boolean;
95};
96
97/** One file that a tool changed in this session. */
98export type FileChange = {
99 /** The absolute path. */
100 path: string;
101 /** The path from its repository root, as the Activity rows show it. */
102 label: string;
103 /** How many successful calls changed the file. */
104 edits: number;
105};
106
107/** One skill that the session used. */
108export type SkillUse = {
109 /** The skill's name, as `/skills` lists it. */
110 name: string;
111 /**
112 * Where the skill comes from, in the engine's words, such as `plugin`.
113 * Empty when the session's skill listing does not hold the skill.
114 */
115 source: string;
116 /** The plugin that provides the skill, when one does. */
117 pluginName?: string;
118 /** How many times the session used the skill: Skill tool calls and typed `/name`s. */
119 uses: number;
120};
121
122/** One tool of an MCP server that the session called. */
123export type McpToolUse = {
124 /** The tool's name without its server, as the Activity rows show it. */
125 name: string;
126 calls: number;
127 /** Of those, the calls that failed or that were denied. */
128 failed: number;
129};
130
131/** One MCP server that the session called. */
132export type McpServerUse = {
133 /** The server's short name, as the Activity rows show it. */
134 server: string;
135 /** Its tools, the most recently called first. */
136 tools: McpToolUse[];
137};
138
139/** The git state of the folder that the session runs in. */
140export type GitState = {
141 /** The branch, or `(detached)`. */
142 branch: string;
143 /** Commits that the upstream does not have. */
144 ahead: number;
145 /** Commits of the upstream that the branch does not have. */
146 behind: number;
147 /** Files with changes that are not committed, untracked files included. */
148 changed: number;
149 /** Of those, the files with staged changes. */
150 staged: number;
151};
152
153/** The diff of one changed file, as the lower half shows it. */
154export type FileDiff = {
155 path: string;
156 label: string;
157 lines: string[];
158};
159
160declare module "claude-code" {
161 interface PluginState {
162 "stereo-view": {
163 entries: ActivityEntry[];
164 isSplit: boolean;
165 expanded: string | null;
166 showResults: boolean;
167 context: ContextInfo | null;
168 /** The newest entry that the list shows. When null, the list follows the newest call. */
169 anchor: string | null;
170 tasks: TaskItem[];
171 agents: AgentLabel[];
172 /** The changed files, the most recent first. */
173 files: FileChange[];
174 /** When true, the context shows one row for each category. */
175 showCategories: boolean;
176 /** Input tokens after each recent turn, for the turns until compaction. */
177 turnTokens: number[];
178 git: GitState | null;
179 /** The diff that the lower half shows in place of the calls. */
180 fileDiff: FileDiff | null;
181 /** The first diff line that the lower half shows. */
182 diffScroll: number;
183 /** When true, the call list shows only failed and denied calls. */
184 onlyFailed: boolean;
185 /** The skills that the session used, the most recent first. */
186 skills: SkillUse[];
187 /** The MCP servers that the session called, the most recent first. */
188 mcp: McpServerUse[];
189 /** The MCP servers whose tools the person folded away. */
190 closedServers: string[];
191 };
192 }
193}
194