SLOPSHOPPER

stereo-view

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

newpanerowsguardcommandtoast
v0.1.0no licenseupdated 2026-10-26jadeezomg/flake/data/agents/mods/stereo-view
A shopper browsing a rack in a slop shop
README

dotfiles

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.

Hosts

HostSystemHardwareNotes
desktopx86_64-linuxNVIDIA GPUDP-2 @ 2560×1440 / 170 Hz
frameworkx86_64-linuxAMD, Framework 13 7040eDP-2 @ 2880×1920 / 120 Hz, 2.0× scale
cayaaarch64-darwinApple Siliconuser caya-jonas; Homebrew casks via nix-homebrew
minix86_64-linuxMinisforum MS-01headless 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).

Conventions

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).

Quick start

# 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.

Justfile groups

GroupRecipes
buildbuild, build-boot, build-dev, build-dry
switchswitch, switch-fast, switch-check
generationsgeneration-list, generation-switch, generation-delete, generation-bootloader
gcgc (= gc-keep), gc-days, gc-all
formatfmt, fmt-notree, lint
backupsbackups, backups-clean, backups-clean-dry
checkupdate-packages, nix-update-pkg, symlink-check, symlink-check-dms
configinit, _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
systemhealth, rollback, update, flatten-dir, flatten-dir-dry
repogit (quiet fmt + status/log + commit + push)
zenzen-sync, zen-check
llmunsloth, unsloth-stop, unsloth-reset, unsloth-logs, unsloth-status (mini: just mini llm)
minijust 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, …)
unraidjust unraid <cmd>: ssh, fancurve-deploy, fancurve-status
metalist, info

NH_FLAKE is set to this repo. Shell helpers live in scripts/shell/common.sh (get_host, is_darwin, notify, …).

Layout

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.

Scripts

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.

Flake inputs (high level)

InputRole
nixpkgsnixos-unstable
nixpkgs-smallnixos-unstable-small (exposed via lib/pkgs.nix → getPkgsSmall)
nixpkgs-stablenixos-26.05 → pkgs-stable in modules
nixpkgs-skhdpinned nixpkgs for skhd (macOS Accessibility grant; standing pin, not a workaround — parts/overlays/skhd-pinned-darwin.nix)
nixpkgs-zeddeclared but currently unused (Zed Darwin pin removed 2026-08-17; zed builds from nixpkgs)
systemsthis repo's lib/systems.nix (drops x86_64-darwin from the systems list)
home-manager, nix-darwinuser / macOS system config
flake-partsmodule structure
determinateDeterminate Nix
sops-nixsecrets
stylixtheming
lanzabootesecure boot (Linux)
diskodisk partitioning (mini: hosts/mini/disko.nix)
niriniri compositor (Wayland)
dmsDankMaterialShell (bar / desktop shell)
dank-greeter, dankcalendarDMS greeter / calendar widget
noctaliaNoctalia v5 shell (niri alternative; flake TOML via HM settings)
quickshellshell/bar framework
zen-browserZen browser
vicinae, vicinae-extensionsVicinae browser integration (Zen native-messaging host)
nix-flatpakdeclarative Flatpak remotes/packages (modules/profiles/integrations.nix)
nixflixJellyfin/Plex wrapper for mini's media stack
nix-homebrew, homebrew-*Homebrew pins (non-flake fetches)
nixos-hardwarehardware modules
nix-cachyos-kernelCachyOS kernel (x86_64-linux)
google-workspace-cligws in per-system packages
llm-agentsagent CLI overlay (pkgs.llm-agents.*: codex, claude-code, apm, hermes, handy, …)
hermes-agentHermes agent NixOS module + service on mini (services.hermes-agent)
corecyclerkept while disabled (upstream bug); not wired into any host

packages.<system> exposes every packages/<name> automatically (parts/packages.nix + packages/names.nix).

specialArgs

Available in system/home modules (see AGENTS.md for the full list): inputs, hostData, hostKey, host, user, isDarwin, system, pkgs-stable, …

Secrets

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.

Where to add things

GoalWhere
Profile togglesdotfiles.profiles in modules/profiles/; enable per host in hosts/<name>/
Profiles overviewdocs/profiles.md (structure + conventions; the tree is its own index)
Architecture decisionsdocs/adr/ (media stack, Immich, network stack, …)
Host ops / troubleshootingdocs/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 onlyhosts/<name>/
User (HM) configthe owning profile's feature folder in modules/profiles/ (pushed via home-manager.sharedModules)
NixOS servicesmodules/nixos/
Desktop / Waylandmodules/profiles/desktop/ (system + DMS or Noctalia/niri HM + data)
Custom packagepackages/<name>/ + update.json when you want just update-packages to handle bumps
New secretsops file + declarations (see modules/shared/security/ / encryption)

New .nix files must be listed in the parent directory’s default.nix imports.

Workflow

  1. Edit the right file (table above; details in AGENTS.md).
  2. just fmt
  3. just switch or just switch-fast
  4. After desktop config edits, just symlink-check (and reload compositor / session tools as needed).

Theme, Stylix, niri, DMS, and recovery topics are documented in AGENTS.md.

Source 5 files
hooks/register.tsx 1559 lines
1import { 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 lines
1// 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}
303
hooks/git.ts 68 lines
1import 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}
68
hooks/work.ts 344 lines
1import 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}
344
types/index.d.ts 194 lines
1export 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