SLOPSHOPPER

summary

Toggle a summary pane (ctrl+x s) of skill runs, subagents and teammates, edited files, failed tool calls and recent work

newpanespinnerguardcommandprompt
★ 9v0.1.0MITupdated 2026-10-10ysoftman/myenv/.claude/mods/summary
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · summary
│ ┃ summary ✕ › fix the failing auth test and add an audit log call │ ┃ ◇ summary │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ alerts ⏺ Update(src/auth.ts) │ ┃ │ ✗ 0s ago Bash src/auth.test.ts: ⎿ Added 2 lines, removed 1 line │ ┃ ╰─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ╭─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ recent │ ┃ │ ⠋ 0s ago 42s 99k summarizing… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ agents │ ┃ │ no agent running › /summary │ ┃ ╰─────────────────────────────────────────── ⎿ summary: summary pane opened (/summary to close) │ ┃ ╭─────────────────────────────────────────── │ ┃ │ background 0 running · 0 scheduled │ ┃ │ no background task or schedule │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ files 3 edited · 0 uncommitted │ ┃ │ · 1× src/cache.ts │ ┃ │ · 1× src/audit.ts │ ┃ │ · 1× src/auth.ts │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · summary
◇ summary ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ╭──────────────────────────────────────────────────────╮ │ alerts │ │ ✗ 0s ago Bash src/auth.test.ts: │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ recent │ │ ⠋ 0s ago 42s 99k summarizing… │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ agents │ │ no agent running │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ background 0 running · 0 scheduled │ │ no background task or schedule │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ files 3 edited · 0 uncommitted │ │ · 1× src/cache.ts │ │ · 1× src/audit.ts │ │ · 1× src/auth.ts │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ git ? (no upstream) · 0 uncommitted │ │ no commit yet │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ skills │ │ no skill yet │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ turns 1 · avg 42s · max 42s │ │ █ │ ╰──────────────────────────────────────────────────────╯ toggle ctrl+x tab focus 7ホ コラ ン セ ミ 9 ハ チ キソ セ ク コメ ロ ソタ ロヌ 9 ル カ 3 メ ネ 8ハ ハ ツ フ ルケ 2 メル ネ 8 ヘ ノ クヘケ ウモロタト オ ヘユ チ 6 ケア キ wake up, neo... the matrix resumes when claude works シ 2エ2カケ リメ3
README

myenv

개인 환경 백업 및 복구 자동화 스크립트~ :smile:

  • myenv 는 디폴트로 ~/workspace/myenv 에 위치(클론)해야 한다. 변경하려면 .zshrc/.bashrc > myenv_path 값을 수정해야 한다.
  • 설치 & 백업
# 설정 및 프로그램 목록등 백업
bash ./backupmysetting.sh

# 전체 설치
bash ./installall.sh

# 필요한 환경/프로그램은 installxxx.sh 사용
  • mac iterm2 사용시
  • iterm2 > general > selection > application in terminal may access clipboard 활성화(tmux 환경에서 클립보드를 사용하기 위해)
  • iterm2 > profiles > colors > color presets 선택
  • iterm2 > profiles > text > font : 18, font-hack-nerd-font(installbrew.sh 에서 설치함)
  • iterm2 에서 tmux 사용시 더블 클릭은 alt + double click(triple click) 로 사용해야 한다.
  • windows alacritty 사용시
  • 설정 파일은 다른 OS 도 같이 사용하니 윈도우 전용으로 수정하지 않는다. 대신 기본 powershell 에서 wsl 실행하면 된다.
  • windows terminal 사용시
  • 폰트 다운로드 받아 설치
  • <https://github.com/ryanoasis/nerd-fonts/releases/download/v3.5.1/Hack.zip>
  • <https://github.com/ryanoasis/nerd-fonts/releases/download/v3.5.1/FiraCode.zip>
  • terminal 에서 ctrl + , 로 설정 파일을 오픈 후 settings.json 내용 복사 & 붙여넣기로 설정 적용
  • 터미널앱 사용(창 선택)시 배경색이 바뀌는 경우, 윈도우 투명도(settings > transparency effects > off)를 비활성화 해야 한다.
  • Ubuntu, sh > dash 쉘로 링크되어 있어 bash 로 쉘스크립트를 실행하려면 상단에 #!/usr/bin/env bash shebang 을 추가하거나, 아래처럼 전역 sh 링크를 bash로 변경한다.
sudo unlink /bin/sh
sudo ln -s /bin/bash /bin/sh
Source 8 files
hooks/register.tsx 815 lines
1import type { ClassicHookInputs, EngineInterface, Register } from "claude-code";
2import { atom, read, update } from "claude-code";
3import type {
4	AgentRow,
5	Alert,
6	BackgroundTask,
7	Commit,
8	FileTouch,
9	RepoState,
10	SessionCron,
11	SkillRun,
12	TurnMark,
13	WorkItem,
14} from "../types";
15import { clip, cut, firstLine, pathOf, short, totalTokens } from "./format";
16import { paneView } from "./pane";
17import {
18	newRain,
19	RAIN,
20	RAIN_MS,
21	RAIN_ROWS,
22	rainCells,
23	rainMessage,
24	rainSpeed,
25} from "./rain";
26import {
27	end,
28	isBusy,
29	isLive,
30	isOpenRun,
31	RUN_END,
32	STATUS,
33	sync,
34	TURN_END,
35} from "./status";
36import { PANE, SHOWN, TOGGLE_ACTION } from "./theme";
37
38const agents = atom({ plugin: "summary", key: "agents" } as const, []);
39
40const now = atom({ plugin: "summary", key: "now" } as const, 0);
41
42const recent = atom({ plugin: "summary", key: "recent" } as const, []);
43
44const files = atom({ plugin: "summary", key: "files" } as const, []);
45
46const dirty = atom({ plugin: "summary", key: "dirty" } as const, 0);
47
48const alerts = atom({ plugin: "summary", key: "alerts" } as const, []);
49
50const skillRuns = atom({ plugin: "summary", key: "runs" } as const, []);
51
52const mainModel = atom({ plugin: "summary", key: "model" } as const, "");
53
54const paneShown = atom({ plugin: "summary", key: "shown" } as const, false);
55
56const chord = atom({ plugin: "summary", key: "chord" } as const, "");
57
58const seen = atom({ plugin: "summary", key: "seen" } as const, []);
59
60// when the main turn in progress began; 0 while Claude is idle
61const working = atom({ plugin: "summary", key: "working" } as const, 0);
62
63// background work and session crons as the last stop reported them
64const tasks = atom({ plugin: "summary", key: "tasks" } as const, []);
65
66const crons = atom({ plugin: "summary", key: "crons" } as const, []);
67
68const turns = atom({ plugin: "summary", key: "turns" } as const, []);
69
70const repo = atom({ plugin: "summary", key: "repo" } as const, {
71	ahead: 0,
72	behind: 0,
73	commits: [],
74});
75
76const FILES_MAX = 20;
77
78const TURNS_MAX = 200;
79
80const EDIT_TOOLS = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
81
82const SUMMARY =
83	"You write one-line entries for a log of finished work. Given what the user asked and the " +
84	"coding assistant's final reply, say what the assistant did in at most 40 characters, in the " +
85	"language of the reply, as a terse phrase. Describe the action taken, not the content it " +
86	"reported. No quotes, no markdown, no trailing period.";
87
88const patch = async (
89	$: EngineInterface,
90	id: string,
91	fn: (a: AgentRow) => AgentRow | undefined,
92) => {
93	if ((await read($, agents)).some((a) => a.id === id && fn(a) !== undefined)) {
94		await update($, agents, (list) =>
95			list.map((a) => (a.id === id ? (fn(a) ?? a) : a)),
96		);
97	}
98};
99
100const summarize = async ($: EngineInterface, ask: string, answer: string) => {
101	try {
102		const done = await $.model.complete({
103			model: "haiku",
104			system: SUMMARY,
105			prompt: `User asked:\n${ask}\n\nAssistant replied:\n${answer}`,
106			maxTokens: 60,
107			timeoutMs: 20_000,
108		});
109		const line = done.isAnswered ? done.text.split("\n")[0]?.trim() : undefined;
110		return line ? cut(line, 60) : firstLine(answer);
111	} catch {
112		return firstLine(answer);
113	}
114};
115
116// "## main...origin/main [ahead 1, behind 2]", the head line of `status -b`,
117// and the last commits: the first `ahead` of them are not pushed yet.
118const refreshRepo = async ($: EngineInterface, root: string, head: string) => {
119	const m =
120		/^## (?:No commits yet on )?(.+?)(?:\.\.\.(\S+))?(?: \[(.*)\])?$/.exec(
121			head,
122		);
123	const count = (word: string) =>
124		Number(new RegExp(`${word} (\\d+)`).exec(m?.[3] ?? "")?.[1] ?? 0);
125	const log = await $.process
126		.run(
127			[
128				"git",
129				"-C",
130				root,
131				"log",
132				"-n",
133				String(SHOWN),
134				"--format=%h%x09%ct%x09%s",
135			],
136			{
137				timeoutMs: 5_000,
138			},
139		)
140		.catch(() => undefined);
141	const commits: Commit[] =
142		log?.exitCode === 0
143			? log.stdout
144					.split("\n")
145					.map((l) => l.split("\t"))
146					.filter((f) => f.length >= 3)
147					.map(([hash = "", at = "0", ...subject]) => ({
148						hash,
149						at: Number(at) * 1000,
150						subject: subject.join("\t"),
151					}))
152			: [];
153	const next: RepoState = {
154		...(m?.[1] === undefined ? {} : { branch: m[1] }),
155		...(m?.[2] === undefined ? {} : { upstream: m[2] }),
156		ahead: count("ahead"),
157		behind: count("behind"),
158		commits,
159	};
160	if (JSON.stringify(next) !== JSON.stringify(await read($, repo))) {
161		await update($, repo, () => next);
162	}
163};
164
165const refreshGit = async (
166	$: EngineInterface,
167	root: string | undefined,
168	adopt: boolean,
169) => {
170	if (root === undefined) {
171		return;
172	}
173	const run = await $.process
174		.run(
175			[
176				"git",
177				"-C",
178				root,
179				"status",
180				"--porcelain=v1",
181				"--branch",
182				"--untracked-files=all",
183			],
184			{
185				timeoutMs: 5_000,
186			},
187		)
188		.catch(() => undefined);
189	if (run === undefined || run.exitCode !== 0) {
190		return;
191	}
192	const lines = run.stdout.split("\n");
193	await refreshRepo($, root, lines[0]?.startsWith("## ") ? lines[0] : "");
194	const status = new Map(
195		lines
196			.filter((l) => l.length > 3 && !l.startsWith("## "))
197			.map(
198				(l) =>
199					[
200						l.slice(3).split(" -> ").at(-1) ?? "",
201						l.slice(0, 2).trim(),
202					] as const,
203			),
204	);
205	if ((await read($, dirty)) !== status.size) {
206		await update($, dirty, () => status.size);
207	}
208	const before = await read($, seen);
209	const paths = [...status.keys()];
210	if (JSON.stringify(paths) !== JSON.stringify(before)) {
211		await update($, seen, () => paths);
212	}
213	const list = await read($, files);
214	const known = new Set([...before, ...list.map((f) => f.path)]);
215	const fresh = adopt
216		? paths.filter((p) => !known.has(p) && !known.has(`${root}/${p}`))
217		: [];
218	const added: FileTouch[] = fresh.map((p) => ({
219		path: `${root}/${p}`,
220		edits: 0,
221	}));
222	const next = [...added, ...list]
223		.slice(0, FILES_MAX)
224		.map(({ status: _, ...f }) => {
225			const s = f.path.startsWith(`${root}/`)
226				? status.get(f.path.slice(root.length + 1))
227				: undefined;
228			return s === undefined ? f : { ...f, status: s };
229		});
230	if (JSON.stringify(next) !== JSON.stringify(list)) {
231		await update($, files, () => next);
232	}
233};
234
235const record = async (
236	$: EngineInterface,
237	tool: string,
238	agentId: string | undefined,
239	path: string | undefined,
240	ran: { deny?: string; isError?: true; text?: string },
241) => {
242	const failed = ran.deny !== undefined || ran.isError === true;
243	if (!failed && EDIT_TOOLS.has(tool) && path !== undefined) {
244		const stat = await $.fs
245			.stat(path, { resolve: true })
246			.catch(() => undefined);
247		const real = stat?.realPath ?? path;
248		await update($, files, (list) => {
249			const hit = list.find((f) => f.path === real);
250			return [
251				{ ...hit, path: real, edits: (hit?.edits ?? 0) + 1 },
252				...list.filter((f) => f.path !== real),
253			].slice(0, FILES_MAX);
254		});
255	}
256	if (failed && tool !== "SubagentHandback") {
257		const agent =
258			agentId === undefined
259				? undefined
260				: (await read($, agents)).find((a) => a.id === agentId)?.label;
261		const alert: Alert = {
262			at: await $.clock.now(),
263			tool,
264			reason: cut(
265				firstLine((ran.deny ?? ran.text ?? "").trim() || "error"),
266				120,
267			),
268			...(agent === undefined ? {} : { agent }),
269		};
270		await update($, alerts, (list) => [alert, ...list].slice(0, SHOWN));
271	}
272};
273
274type StopInput = ClassicHookInputs["Stop"];
275
276const syncBackground = async (
277	$: EngineInterface,
278	list: StopInput["background_tasks"] = [],
279	scheduled: StopInput["session_crons"] = [],
280) => {
281	const line = (s: string) => cut(s.split("\n")[0] ?? "", 120);
282	// subagents already have their rows under agents
283	const nextTasks: BackgroundTask[] = list
284		.filter((b) => b.type !== "subagent" && b.agent_type === undefined)
285		.map((b) => ({
286			id: b.id,
287			type: b.type,
288			status: b.status,
289			label: line(
290				b.command ??
291					(b.server === undefined
292						? (b.name ?? b.description)
293						: `${b.server} ${b.tool ?? ""}`),
294			),
295		}));
296	const nextCrons: SessionCron[] = scheduled.map((c) => ({
297		id: c.id,
298		schedule: c.schedule,
299		recurring: c.recurring,
300		prompt: line(c.prompt),
301	}));
302	if (JSON.stringify(nextTasks) !== JSON.stringify(await read($, tasks))) {
303		await update($, tasks, () => nextTasks);
304	}
305	if (JSON.stringify(nextCrons) !== JSON.stringify(await read($, crons))) {
306		await update($, crons, () => nextCrons);
307	}
308};
309
310const discover = async ($: EngineInterface, id: string, model: string) => {
311	if ((await read($, agents)).some((a) => a.id === id)) {
312		return;
313	}
314	const info = (await $.agent.list()).find((a) => a.id === id);
315	if (info === undefined) {
316		return;
317	}
318	const row: AgentRow = {
319		id,
320		label: clip(info.name ?? info.type),
321		model: short(model),
322		description: info.description,
323		startedAt: await $.clock.now(),
324		status: "running",
325		listed: true,
326		...(info.name === undefined ? {} : { name: info.name }),
327	};
328	await update($, agents, (list) =>
329		list.some((a) => a.id === id) ? list : [...list, row],
330	);
331	await update($, now, () => row.startedAt);
332};
333
334const patchOpen = async ($: EngineInterface, fn: (r: SkillRun) => SkillRun) => {
335	const last = (await read($, skillRuns)).at(-1);
336	if (
337		last !== undefined &&
338		isOpenRun(last) &&
339		JSON.stringify(fn(last)) !== JSON.stringify(last)
340	) {
341		await update($, skillRuns, (l) => [...l.slice(0, -1), fn(last)]);
342	}
343};
344
345const openRun = async (
346	$: EngineInterface,
347	skills: string[],
348	model: string,
349	effort?: string,
350) => {
351	if (isOpenRun((await read($, skillRuns)).at(-1))) {
352		await patchOpen($, (r) => ({ ...r, skills }));
353		return;
354	}
355	const run: SkillRun = {
356		skills,
357		model,
358		...(effort === undefined ? {} : { effort }),
359		startedAt: await $.clock.now(),
360	};
361	await update($, skillRuns, (l) => [...l, run].slice(-SHOWN));
362};
363
364const isPaneShown = async ($: EngineInterface) =>
365	(await $.ui.panes()).some((p) => p.id === PANE && p.isShown);
366
367const setShown = async ($: EngineInterface, isShown: boolean) => {
368	if ((await read($, paneShown)) !== isShown) {
369		await update($, paneShown, () => isShown);
370	}
371};
372
373const syncChord = async ($: EngineInterface) => {
374	const key = (await boundKey($)) ?? "";
375	if ((await read($, chord)) !== key) {
376		await update($, chord, () => key);
377	}
378	return key;
379};
380
381// Shown: close. Open behind another pane's tab: the engine only retitles an
382// open id, while a newly opened pane becomes the shown tab, so reopen it.
383const toggle = async ($: EngineInterface, root: string | undefined) => {
384	const pane = (await $.ui.panes()).find((p) => p.id === PANE);
385	if (pane?.isShown) {
386		await $.ui.close({ id: PANE });
387		await setShown($, false);
388		return false;
389	}
390	if (pane !== undefined) {
391		await $.ui.close({ id: PANE });
392	}
393	await $.ui.open({ id: PANE, title: "summary", columns: 60 });
394	await setShown($, true);
395	await syncChord($);
396	await refreshGit($, root, false);
397	return true;
398};
399
400const hintOf = (key: string) => key || `/${PANE}`;
401
402const boundKey = async ($: EngineInterface) => {
403	try {
404		const { bindings = [] } = JSON.parse(
405			await $.fs.read(`${await $.env.get("HOME")}/.claude/keybindings.json`),
406		) as {
407			bindings?: { context?: string; bindings?: Record<string, unknown> }[];
408		};
409		return bindings
410			.filter((b) => b.context === "Global")
411			.flatMap((b) => Object.entries(b.bindings ?? {}))
412			.find(([, action]) => action === TOGGLE_ACTION)?.[0];
413	} catch {
414		return undefined;
415	}
416};
417
418export const register: Register = (on) => {
419	let loaded: string[] = [];
420	let worked = false;
421	let ask = "";
422	let draining = false;
423	let tick = 0;
424	let root: string | undefined;
425	const rain = newRain();
426	// the tokens the last main model call took
427	let stepTokens = 0;
428	// The hint line's isWorking, the engine's own word on a running turn;
429	// undefined until the line draws after a (re)load.
430	let hintWorking: boolean | undefined;
431
432	on("session.start", async ($, e, next) => {
433		const key = await syncChord($);
434		const top = await $.process
435			.run(["git", "rev-parse", "--show-toplevel"], { timeoutMs: 5_000 })
436			.catch(() => undefined);
437		root = top?.exitCode === 0 ? top.stdout.trim() : undefined;
438		await refreshGit($, root, false);
439		await setShown($, await isPaneShown($));
440		$.clock.every(100, async () => {
441			// a render hook only reads, so the hint line's word lands here
442			if (
443				hintWorking !== undefined &&
444				hintWorking !== (await read($, working)) > 0
445			) {
446				const t = await $.clock.now();
447				await update($, working, () => (hintWorking ? t : 0));
448			}
449			// a click on another pane's tab hides this one without an event
450			await setShown($, await isPaneShown($));
451			if (!(await read($, paneShown))) {
452				return;
453			}
454			tick++;
455			const list = await read($, agents);
456			const busy = isBusy(
457				list,
458				await read($, skillRuns),
459				await read($, recent),
460			);
461			const since = await read($, working);
462			const running = list.filter((a) => a.status === "running");
463			rain.pace = rainSpeed(
464				[
465					...(since > 0 ? [await read($, mainModel)] : []),
466					...running.map((a) => a.model),
467				],
468				running.length,
469				stepTokens,
470			);
471			// a turn alone redraws once a second for its elapsed time
472			if (busy || tick % (since > 0 ? 10 : 100) === 0) {
473				$.ui.invalidate("ui.render");
474			}
475		});
476		// The rain repaints its Raster in place: a redraw of the whole pane folds
477		// to one per 100 ms, a blit runs at the surface's frame rate.
478		$.clock.every(RAIN_MS, async () => {
479			if (rain.columns === 0 || !(await read($, paneShown))) {
480				return;
481			}
482			const now = await $.clock.now();
483			rain.t += Math.min(now - rain.at, 1000) * rain.pace;
484			rain.at = now;
485			// idle, the rain clock stands still and only the message moves
486			if (rain.pace === 0) {
487				if (!rain.wasIdle) {
488					rain.wasIdle = true;
489					rain.idleSince = now;
490				}
491				const message = rainMessage(rain.columns, rain.idleSince, now);
492				if (message === rain.lastMessage) {
493					return;
494				}
495				rain.lastMessage = message;
496				await $.ui
497					.blit({
498						requestId: PANE,
499						key: RAIN,
500						cells: rainCells(rain.t, rain.columns, RAIN_ROWS, message),
501					})
502					.catch(() => undefined);
503				return;
504			}
505			rain.wasIdle = false;
506			rain.lastMessage = "";
507			await $.ui
508				.blit({
509					requestId: PANE,
510					key: RAIN,
511					cells: rainCells(rain.t, rain.columns, RAIN_ROWS),
512				})
513				.catch(() => undefined);
514		});
515		$.clock.every(1500, async () => {
516			const item = (await read($, recent)).find((w) => w.text === undefined);
517			if (draining || item === undefined) {
518				return;
519			}
520			draining = true;
521			try {
522				const text = await summarize($, item.ask ?? "", item.answer ?? "");
523				await update($, recent, (list) =>
524					list.map(({ ask: _ask, answer: _answer, ...w }) =>
525						w.id === item.id ? { ...w, text } : w,
526					),
527				);
528			} finally {
529				draining = false;
530			}
531		});
532		// commits and pushes from another terminal show up too
533		$.clock.every(15_000, async () => {
534			if (await read($, paneShown)) {
535				await refreshGit($, root, false);
536			}
537		});
538		$.clock.every(1000, async () => {
539			const rows = await read($, agents);
540			if (!rows.some(isLive)) {
541				return;
542			}
543			const t = await $.clock.now();
544			const live = new Map(
545				(await $.agent.list()).map((a) => [a.id, STATUS[a.status]]),
546			);
547			if (rows.some((a) => sync(a, live.get(a.id), t) !== a)) {
548				await update($, agents, (list) =>
549					list.map((a) => sync(a, live.get(a.id), t)),
550				);
551			}
552			if ((await read($, agents)).some((a) => a.status === "running")) {
553				await update($, now, () => t);
554			}
555		});
556
557		await $.command.register({
558			name: PANE,
559			description: `Open or close the summary pane${key === "" ? "" : ` (${key})`}`,
560			immediate: true,
561		});
562
563		return next(e);
564	});
565
566	on("agent.spawn", async ($, e, next) => {
567		const ran = await next(e);
568		if (ran.agentId === undefined) {
569			return ran;
570		}
571		const row: AgentRow = {
572			id: ran.agentId,
573			label: clip(e.name ?? e.subagentType),
574			model: short(ran.model),
575			description: e.description,
576			startedAt: await $.clock.now(),
577			status: "running",
578			...(e.name === undefined ? {} : { name: e.name }),
579		};
580		await update($, agents, (list) => [
581			...list.filter((a) => a.id !== row.id),
582			row,
583		]);
584		await update($, now, () => row.startedAt);
585
586		return ran;
587	}).catch((_, e, next) => next(e));
588
589	on("turn.step", async function* ($, e, next) {
590		if (e.agentId !== undefined) {
591			await discover($, e.agentId, e.model);
592			return yield* next(e);
593		}
594		const effort = e.effort === undefined ? undefined : String(e.effort);
595		if (loaded.length > 0) {
596			await openRun($, [...loaded], short(e.model), effort);
597		}
598		const step = yield* next(e);
599		if (step.usage !== null) {
600			stepTokens = totalTokens(step.usage);
601		}
602		const answered = short(step.usage?.model ?? e.model);
603		if (loaded.length > 0) {
604			await patchOpen($, (r) => ({ ...r, model: answered }));
605		}
606		const tag = [answered, effort].filter((v) => v !== undefined).join(" · ");
607		if ((await read($, mainModel)) !== tag) {
608			await update($, mainModel, () => tag);
609		}
610
611		return step;
612	});
613
614	on("tool.call", { tool: "Skill" }, async (_, e, next) => {
615		const ran = await next(e);
616		if (
617			e.agentId === undefined &&
618			ran.deny === undefined &&
619			ran.isError !== true
620		) {
621			loaded = [...new Set([...loaded, e.skill])];
622		}
623
624		return ran;
625	}).catch((_, e, next) => next(e));
626
627	on("tool.call", async ($, e, next) => {
628		const tool = String(e.tool);
629		const id = e.agentId;
630		worked ||= id === undefined;
631		const [, ran] = await Promise.all([
632			id === undefined || tool === "SubagentHandback"
633				? undefined
634				: patch($, id, (a) =>
635						a.status === "running" && a.tool === tool
636							? undefined
637							: { ...a, status: "running", tool },
638					),
639			next(e),
640		]);
641		await record($, tool, id, pathOf(e), ran);
642
643		return ran;
644	}).catch((_, e, next) => next(e));
645
646	on("tool.call", { tool: "TaskStop" }, async ($, e, next) => {
647		const ran = await next(e);
648		const task = e.task_id ?? e.shell_id;
649		if (ran.deny !== undefined || ran.isError === true || task === undefined) {
650			return ran;
651		}
652		const t = await $.clock.now();
653		const hit = (a: AgentRow) =>
654			a.status !== "killed" && (a.id === task || a.name === task);
655		if ((await read($, agents)).some(hit)) {
656			await update($, agents, (list) =>
657				list.map((a) =>
658					hit(a) ? { ...a, status: "killed", endedAt: end(a, t) } : a,
659				),
660			);
661		}
662
663		return ran;
664	}).catch((_, e, next) => next(e));
665
666	on("turn.complete", async ($, e, next) => {
667		const id = e.agentId;
668		if (id !== undefined && !(await $.agent.list()).some((a) => a.id === id)) {
669			const endedAt = await $.clock.now();
670			await patch($, id, (a) =>
671				isLive(a) && !a.listed
672					? { ...a, status: TURN_END[e.reason], endedAt }
673					: undefined,
674			);
675		}
676		if (id !== undefined) {
677			await refreshGit($, root, true);
678		}
679		if (id === undefined) {
680			const mark: TurnMark = { ms: e.durationMs, ok: e.reason === "answer" };
681			await update($, turns, (list) => [...list, mark].slice(-TURNS_MAX));
682			if (loaded.length > 0) {
683				const endedAt = await $.clock.now();
684				await patchOpen($, (r) => ({
685					...r,
686					endedAt,
687					result: RUN_END[e.reason],
688				}));
689				loaded = [];
690			}
691			if (worked && e.reason === "answer" && e.answer.trim() !== "") {
692				const item: WorkItem = {
693					id: e.turnId,
694					at: await $.clock.now(),
695					durationMs: e.durationMs,
696					...(e.usage === undefined ? {} : { tokens: totalTokens(e.usage) }),
697					ask,
698					answer: e.answer.slice(0, 4000),
699				};
700				await update($, recent, (list) =>
701					[item, ...list.filter((w) => w.id !== item.id)].slice(0, SHOWN),
702				);
703			}
704			if (worked) {
705				await refreshGit($, root, true);
706			}
707			worked = false;
708		}
709
710		return next(e);
711	});
712
713	// A stop carries the session's background work and crons; a finished task
714	// or a fired cron wakes the session, so the next stop brings the change.
715	on("classic.Stop", async ($, e, next) => {
716		await syncBackground($, e.background_tasks, e.session_crons);
717		return next(e);
718	}).catch((_, e, next) => next(e));
719
720	on("classic.SubagentStop", async ($, e, next) => {
721		await syncBackground($, e.background_tasks, e.session_crons);
722		return next(e);
723	}).catch((_, e, next) => next(e));
724
725	on("prompt.submit", async ($, e, next) => {
726		if (e.turnId === undefined) {
727			ask = e.text.slice(0, 1000);
728			const name = /^\/([\w:.-]+)(?=\s|$)/.exec(e.text)?.[1];
729			const isCommand =
730				name !== undefined &&
731				(await $.command.list()).some((c) => c.name === name);
732			loaded = isCommand ? [name] : [];
733		}
734		if (e.origin.kind === "composer" || e.origin.kind === "bridge") {
735			await update($, agents, (list) => list.filter(isLive));
736			if ((await read($, alerts)).length > 0) {
737				await update($, alerts, () => []);
738			}
739		}
740
741		return next(e);
742	}).catch((_, e, next) => next(e));
743
744	on("command.run", { command: PANE }, async ($) => ({
745		text: (await toggle($, root))
746			? `summary pane opened (${hintOf(await read($, chord))} to close)`
747			: "summary pane closed",
748	})).catch((_, e, next) => next(e));
749
750	on("ui.close", { id: PANE }, async ($, e, next) => {
751		const closed = await next(e);
752		await setShown($, false);
753
754		return closed;
755	}).catch((_, e, next) => next(e));
756
757	// The hint under the prompt carries the toggle hint as dim tail text, so the
758	// band above the prompt stays free. A chord presses only a mounted Button, so
759	// one rides along hidden.
760	on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
761		hintWorking = e.props.isWorking;
762		if (await read($, paneShown)) {
763			return next(e);
764		}
765		const key = await read($, chord);
766		const tail = [e.props.tail, key === "" ? `/${PANE}` : `${key} ${PANE}`]
767			.filter(Boolean)
768			.join(" · ");
769		const below = await next({ ...e, props: { ...e.props, tail } });
770		if (key === "") {
771			return below;
772		}
773		const { Box, Button } = $.ui.resolve(e);
774
775		return (
776			<Box flexDirection="column">
777				{below}
778				<Box display="none">
779					<Button
780						plain
781						key="toggle"
782						label={PANE}
783						action={TOGGLE_ACTION}
784						onPress={() => toggle($, root)}
785					/>
786				</Box>
787			</Box>
788		);
789	});
790
791	on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) =>
792		paneView({
793			e,
794			els: $.ui.resolve(e),
795			t: await $.clock.now(),
796			root,
797			rain,
798			hint: hintOf(await read($, chord)),
799			onToggle: () => toggle($, root),
800			list: await read($, agents),
801			runs: await read($, skillRuns),
802			work: await read($, recent),
803			touched: await read($, files),
804			uncommitted: await read($, dirty),
805			git: await read($, repo),
806			warnings: await read($, alerts),
807			tag: await read($, mainModel),
808			since: await read($, working),
809			bg: await read($, tasks),
810			scheduled: await read($, crons),
811			marks: await read($, turns),
812		}),
813	);
814};
815
hooks/format.ts 63 lines
1import type { ModelUsage } from "claude-code";
2
3export const totalTokens = (u: ModelUsage) =>
4	u.input_tokens +
5	u.output_tokens +
6	u.cache_read_input_tokens +
7	u.cache_creation_input_tokens;
8
9export const LABEL_MAX = 20;
10
11export const elapsed = (ms: number) => {
12	const s = Math.max(0, Math.round(ms / 1000));
13	return s < 60
14		? `${s}s`
15		: `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
16};
17
18export const clip = (s: string) =>
19	s.length > LABEL_MAX ? `${s.slice(0, LABEL_MAX - 1)}…` : s;
20
21export const cut = (s: string, n: number) =>
22	s.length > n ? `${s.slice(0, n - 1)}…` : s;
23
24export const firstLine = (answer: string) =>
25	cut(
26		answer
27			.split("\n")
28			.map((l) =>
29				l
30					.replace(/^[#>*\-\s]+/, "")
31					.replace(/[*`]/g, "")
32					.trim(),
33			)
34			.find((l) => l !== "") ?? "done",
35		60,
36	);
37
38export const ago = (ms: number) => {
39	const s = Math.max(0, Math.round(ms / 1000));
40	return s < 60
41		? `${s}s`
42		: s < 3600
43			? `${Math.floor(s / 60)}m`
44			: `${Math.floor(s / 3600)}h`;
45};
46
47export const kilo = (n: number) =>
48	n < 1000 ? `${n}` : `${Math.round(n / 1000)}k`;
49
50export const tail = (s: string, n: number) =>
51	s.length > n ? `…${s.slice(s.length - n + 1)}` : s;
52
53export const pathOf = (e: object) => {
54	const { file_path, notebook_path } = e as {
55		file_path?: unknown;
56		notebook_path?: unknown;
57	};
58	const path = file_path ?? notebook_path;
59	return typeof path === "string" ? path : undefined;
60};
61
62export const short = (model: string) => model.replace(/^.*claude-/, "");
63
hooks/pane.tsx 461 lines
1import type { Elements, RenderInput } from "claude-code";
2import type {
3	AgentRow,
4	Alert,
5	BackgroundTask,
6	FileTouch,
7	RepoState,
8	SessionCron,
9	SkillRun,
10	TurnMark,
11	WorkItem,
12} from "../types";
13import { hhmm, nextFire } from "./cron";
14import { ago, elapsed, kilo, tail } from "./format";
15import type { Rain } from "./rain";
16import { RAIN, RAIN_ROWS, rainCells, rainMessage } from "./rain";
17import { end, isBusy, isLive } from "./status";
18import {
19	C,
20	COLOR,
21	GIT_COLOR,
22	GLOW,
23	ICON,
24	modelColor,
25	ORDER,
26	PULSE,
27	RUN,
28	SHOWN,
29	SPARK,
30	SPINNER,
31	TOGGLE_ACTION,
32} from "./theme";
33
34// Everything the pane draws, read by register (only it may touch $).
35export type PaneValues = {
36	e: RenderInput<"Pane">;
37	els: Elements[RenderInput<"Pane">["surface"]];
38	t: number;
39	root: string | undefined;
40	rain: Rain;
41	hint: string;
42	onToggle: () => unknown;
43	list: AgentRow[];
44	runs: SkillRun[];
45	work: WorkItem[];
46	touched: FileTouch[];
47	uncommitted: number;
48	git: RepoState;
49	warnings: Alert[];
50	tag: string;
51	since: number;
52	bg: BackgroundTask[];
53	scheduled: SessionCron[];
54	marks: TurnMark[];
55};
56
57export const paneView = ({
58	e,
59	els,
60	t,
61	root,
62	rain,
63	hint,
64	onToggle,
65	list,
66	runs,
67	work,
68	touched,
69	uncommitted,
70	git,
71	warnings,
72	tag,
73	since,
74	bg,
75	scheduled,
76	marks,
77}: PaneValues) => {
78	const { Box, Button, Text } = els;
79	const frame = Math.floor(t / 100);
80	const spin = SPINNER[frame % SPINNER.length];
81	const busy = isBusy(list, runs, work);
82	const glow = busy ? (GLOW[frame % GLOW.length] ?? C.mauve) : C.mauve;
83	const width = Math.max(30, e.props.bodyColumns);
84	const rule = width - 2;
85	const at = busy ? (frame % (rule + 6)) - 3 : -3;
86	const lit = [at, at + 3].map((n) => Math.min(rule, Math.max(0, n))) as [
87		number,
88		number,
89	];
90	const typeWidth = Math.max(0, ...bg.map((b) => b.type.length));
91	const spark = marks.slice(-(width - 4));
92	const longest = Math.max(1, ...spark.map((m) => m.ms));
93	// live agents first, then the latest finished
94	const keep = new Set(
95		[...list.filter(isLive), ...list.filter((a) => !isLive(a)).reverse()].slice(
96			0,
97			SHOWN,
98		),
99	);
100	const shown = list.filter((a) => keep.has(a));
101	const bgShown = bg.slice(0, SHOWN);
102	const cronsShown = scheduled.slice(0, SHOWN - bgShown.length);
103	const bgMore =
104		bg.length + scheduled.length - bgShown.length - cronsShown.length;
105	const labelWidth = Math.max(0, ...shown.map((a) => a.label.length));
106	const modelWidth = Math.max(0, ...shown.map((a) => a.model.length));
107	const pathWidth = width - 4 - 3 - 4;
108	const shortPath = (p: string) =>
109		root !== undefined && p.startsWith(`${root}/`)
110			? p.slice(root.length + 1)
111			: p;
112	const counts = ORDER.map(
113		(s) => [s, list.filter((a) => a.status === s).length] as const,
114	);
115	if ("Raster" in els) {
116		rain.columns = width;
117	}
118	// docked, the pane fills its height so the rain sits on the bottom edge;
119	// inline, the frame fits the tree and would grow to bodyRows instead
120	const floor =
121		e.props.placement === "dock" ? e.props.scroll.bodyRows : undefined;
122	const keycap = (k: string) => (
123		<Text backgroundColor={C.surface0} color={C.lavender}>{` ${k} `}</Text>
124	);
125
126	return (
127		<Box
128			flexDirection="column"
129			width={width}
130			{...(floor === undefined ? {} : { minHeight: floor })}
131		>
132			<Box paddingX={1} justifyContent="space-between">
133				<Box gap={2}>
134					<Text bold color={C.mauve}>
135						<Text color={glow}>{busy ? PULSE[frame % PULSE.length] : "◆"}</Text>{" "}
136						summary
137					</Text>
138					{since > 0 && (
139						<Text color={C.peach}>{`● working ${elapsed(t - since)}`}</Text>
140					)}
141				</Box>
142				<Text color={modelColor(tag)}>{tag}</Text>
143			</Box>
144			<Box paddingX={1}>
145				<Text>
146					<Text color={C.surface0}>{"━".repeat(lit[0])}</Text>
147					<Text color={C.lavender}>{"━".repeat(lit[1] - lit[0])}</Text>
148					<Text color={C.surface0}>{"━".repeat(rule - lit[1])}</Text>
149				</Text>
150			</Box>
151			{warnings.length > 0 && (
152				<Box
153					flexDirection="column"
154					borderStyle="round"
155					borderColor={C.red}
156					paddingX={1}
157				>
158					<Text bold color={C.red}>
159						alerts
160					</Text>
161					{warnings.map((a) => (
162						<Text wrap="truncate-end">
163							<Text color={C.red}>✗</Text>{" "}
164							<Text color={C.overlay}>{`${ago(t - a.at)} ago`.padEnd(7)}</Text>{" "}
165							<Text color={C.peach}>{a.tool}</Text>
166							{a.agent !== undefined && (
167								<Text color={C.overlay}>{` @${a.agent}`}</Text>
168							)}
169							<Text color={C.subtext}>{`  ${a.reason}`}</Text>
170						</Text>
171					))}
172				</Box>
173			)}
174			<Box
175				flexDirection="column"
176				borderStyle="round"
177				borderColor={C.surface1}
178				paddingX={1}
179			>
180				<Text bold color={C.green}>
181					recent
182				</Text>
183				{work.length === 0 && (
184					<Text color={C.overlay}>no finished work yet</Text>
185				)}
186				{work.map((w) => (
187					<Text wrap="truncate-end">
188						{w.text === undefined ? (
189							<Text color={C.peach}>{spin}</Text>
190						) : (
191							<Text color={C.green}>✓</Text>
192						)}{" "}
193						<Text color={C.overlay}>{`${ago(t - w.at)} ago`.padEnd(7)}</Text>{" "}
194						<Text color={C.overlay} dimColor>
195							{elapsed(w.durationMs).padStart(5)}
196						</Text>{" "}
197						<Text color={C.overlay} dimColor>
198							{(w.tokens === undefined ? "" : kilo(w.tokens)).padStart(4)}
199						</Text>{" "}
200						<Text color={w.text === undefined ? C.subtext : C.text}>
201							{w.text ?? "summarizing…"}
202						</Text>
203					</Text>
204				))}
205			</Box>
206			<Box
207				flexDirection="column"
208				borderStyle="round"
209				borderColor={C.surface1}
210				paddingX={1}
211			>
212				<Text>
213					<Text bold color={C.mauve}>
214						agents
215					</Text>
216					{counts
217						.filter(([, n]) => n > 0)
218						.map(([s, n]) => (
219							<Text color={COLOR[s]}>{`  ${ICON[s]} ${n} ${s}`}</Text>
220						))}
221				</Text>
222				{list.length === 0 && <Text color={C.overlay}>no agent running</Text>}
223				{shown.map((a) => (
224					<Text wrap="truncate-end">
225						<Text color={COLOR[a.status]}>
226							{a.status === "running" ? spin : ICON[a.status]}
227						</Text>{" "}
228						<Text color={C.text} dimColor={a.status !== "running"}>
229							{a.label.padEnd(labelWidth)}
230						</Text>{" "}
231						<Text color={modelColor(a.model)} dimColor={a.status !== "running"}>
232							{a.model.padEnd(modelWidth)}
233						</Text>{" "}
234						<Text color={C.overlay} dimColor={a.status !== "running"}>
235							{elapsed(end(a, t) - a.startedAt).padStart(6)}
236						</Text>{" "}
237						<Text color={C.subtext} dimColor>
238							{a.description}
239							{a.status === "running" && a.tool !== undefined
240								? ` · ${a.tool}`
241								: ""}
242						</Text>
243					</Text>
244				))}
245				{list.length > shown.length && (
246					<Text color={C.overlay}>{`+${list.length - shown.length} more`}</Text>
247				)}
248			</Box>
249			<Box
250				flexDirection="column"
251				borderStyle="round"
252				borderColor={C.surface1}
253				paddingX={1}
254			>
255				<Text>
256					<Text bold color={C.teal}>
257						background
258					</Text>
259					<Text color={C.overlay}>
260						{`  ${bg.length} running · ${scheduled.length} scheduled`}
261					</Text>
262				</Text>
263				{bg.length === 0 && scheduled.length === 0 && (
264					<Text color={C.overlay}>no background task or schedule</Text>
265				)}
266				{bgShown.map((b) => (
267					<Text wrap="truncate-end">
268						<Text color={b.status === "running" ? C.yellow : C.overlay}>
269							{b.status === "running" ? "●" : "○"}
270						</Text>{" "}
271						<Text color={C.sky}>{b.type.padEnd(typeWidth)}</Text>{" "}
272						<Text color={C.subtext}>{b.label}</Text>
273					</Text>
274				))}
275				{cronsShown.map((c) => {
276					const at = nextFire(c.schedule, t);
277					return (
278						<Text wrap="truncate-end">
279							<Text color={C.lavender}>{c.recurring ? "↻" : "◷"}</Text>{" "}
280							<Text color={C.text}>
281								{at === undefined
282									? c.schedule
283									: `${hhmm(at, t)} in ${ago(at - t)}`}
284							</Text>{" "}
285							<Text color={C.subtext}>{c.prompt}</Text>
286						</Text>
287					);
288				})}
289				{bgMore > 0 && <Text color={C.overlay}>{`+${bgMore} more`}</Text>}
290			</Box>
291			<Box
292				flexDirection="column"
293				borderStyle="round"
294				borderColor={C.surface1}
295				paddingX={1}
296			>
297				<Text>
298					<Text bold color={C.yellow}>
299						files
300					</Text>
301					<Text color={C.overlay}>
302						{`  ${touched.length} edited · ${uncommitted} uncommitted`}
303					</Text>
304				</Text>
305				{touched.length === 0 && (
306					<Text color={C.overlay}>no file edited yet</Text>
307				)}
308				{touched.slice(0, SHOWN).map((f) => (
309					<Text>
310						<Text
311							color={
312								f.status === undefined
313									? C.overlay
314									: (GIT_COLOR[f.status] ?? C.peach)
315							}
316						>
317							{(f.status ?? "·").padEnd(2)}
318						</Text>{" "}
319						<Text color={C.overlay}>
320							{(f.edits > 0 ? `${f.edits}×` : "·").padStart(3)}
321						</Text>{" "}
322						<Text color={C.text}>{tail(shortPath(f.path), pathWidth)}</Text>
323					</Text>
324				))}
325				{touched.length > SHOWN && (
326					<Text color={C.overlay}>{`+${touched.length - SHOWN} more`}</Text>
327				)}
328			</Box>
329			<Box
330				flexDirection="column"
331				borderStyle="round"
332				borderColor={C.surface1}
333				paddingX={1}
334			>
335				<Text>
336					<Text bold color={C.peach}>
337						git
338					</Text>
339					<Text color={C.overlay}>
340						{root === undefined
341							? "  not a repository"
342							: `  ${git.branch ?? "?"}${
343									git.upstream === undefined
344										? ` (no upstream) · ${uncommitted} uncommitted`
345										: ` → ${git.upstream} · ${uncommitted} uncommitted · ↑${git.ahead} ↓${git.behind}`
346								}`}
347					</Text>
348				</Text>
349				{git.commits.length === 0 && (
350					<Text color={C.overlay}>no commit yet</Text>
351				)}
352				{git.commits.map((c, i) => (
353					<Text wrap="truncate-end">
354						{git.upstream === undefined ? (
355							<Text color={C.overlay}>·</Text>
356						) : i < git.ahead ? (
357							<Text color={C.yellow}>↑</Text>
358						) : (
359							<Text color={C.green}>✓</Text>
360						)}{" "}
361						<Text color={C.overlay}>{c.hash}</Text>{" "}
362						<Text color={C.overlay} dimColor>
363							{ago(t - c.at).padStart(3)}
364						</Text>{" "}
365						<Text color={i < git.ahead ? C.text : C.subtext}>{c.subject}</Text>
366					</Text>
367				))}
368			</Box>
369			<Box
370				flexDirection="column"
371				borderStyle="round"
372				borderColor={C.surface1}
373				paddingX={1}
374			>
375				<Text bold color={C.blue}>
376					skills
377				</Text>
378				{runs.length === 0 ? (
379					<Text color={C.overlay}>no skill yet</Text>
380				) : (
381					[...runs].reverse().map((r) => (
382						<Text wrap="truncate-end">
383							{r.result === undefined ? (
384								<Text color={C.peach}>{spin}</Text>
385							) : (
386								<Text color={RUN[r.result].color}>{RUN[r.result].icon}</Text>
387							)}{" "}
388							<Text
389								bold={r.result === undefined}
390								color={r.result === undefined ? C.text : C.subtext}
391							>
392								{r.skills.join(", ")}
393							</Text>{" "}
394							<Text color={modelColor(r.model)}>
395								{[r.model, r.effort].filter((v) => v !== undefined).join(" · ")}
396							</Text>{" "}
397							<Text color={C.overlay}>
398								{elapsed((r.endedAt ?? t) - r.startedAt)}
399							</Text>
400						</Text>
401					))
402				)}
403			</Box>
404			<Box
405				flexDirection="column"
406				borderStyle="round"
407				borderColor={C.surface1}
408				paddingX={1}
409			>
410				<Text>
411					<Text bold color={C.sky}>
412						turns
413					</Text>
414					{marks.length > 0 && (
415						<Text color={C.overlay}>
416							{`  ${marks.length} · avg ${elapsed(
417								marks.reduce((n, m) => n + m.ms, 0) / marks.length,
418							)} · max ${elapsed(Math.max(...marks.map((m) => m.ms)))}`}
419						</Text>
420					)}
421				</Text>
422				{spark.length === 0 ? (
423					<Text color={C.overlay}>no turn yet</Text>
424				) : (
425					<Text>
426						{spark.map((m) => (
427							<Text color={m.ok ? C.green : C.red}>
428								{SPARK[Math.max(0, Math.ceil((m.ms / longest) * 8) - 1)]}
429							</Text>
430						))}
431					</Text>
432				)}
433			</Box>
434			<Box paddingX={1} gap={2}>
435				<Button plain key="toggle" action={TOGGLE_ACTION} onPress={onToggle}>
436					{keycap(hint)}
437					<Text color={C.subtext}> close</Text>
438				</Button>
439				<Text>
440					{keycap("ctrl+x tab")}
441					<Text color={C.subtext}> focus</Text>
442				</Text>
443			</Box>
444			<Box flexGrow={1} />
445			{"Raster" in els && (
446				<els.Raster
447					key={RAIN}
448					columns={width}
449					rows={RAIN_ROWS}
450					cells={rainCells(
451						rain.t,
452						width,
453						RAIN_ROWS,
454						rain.pace === 0 ? rainMessage(width, rain.idleSince, t) : undefined,
455					)}
456				/>
457			)}
458		</Box>
459	);
460};
461
hooks/rain.ts 152 lines
1import { C, rgb } from "./theme";
2
3export const RAIN = "rain";
4
5export const RAIN_ROWS = 6;
6
7export const RAIN_MS = 33; // ~30 fps
8
9export const RAIN_MAX_SPEED = 3;
10
11export const RAIN_GLYPHS = [
12	..."アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789",
13].map((g) => g.codePointAt(0) ?? 0x20);
14
15export const RAIN_FADE = [
16	C.text,
17	C.green,
18	C.green,
19	C.teal,
20	C.teal,
21	C.surface2,
22	C.surface1,
23].map(rgb);
24
25export const TERMINAL_DEFAULT = 0x01000000;
26
27// Idle, the rain stands still in faded colors and a message types itself out
28// on its middle row: the first that fits, ASCII (a Raster takes width-1 code
29// points only), then a blinking cursor.
30export const RAIN_IDLE = [
31	"wake up, neo... the matrix resumes when claude works",
32	"the matrix resumes when claude works",
33	"awaiting signal",
34];
35
36export const RAIN_TYPE_MS = 40;
37
38export const RAIN_BLINK_MS = 530;
39
40// the message as typed by now, padded to its full length so it stays put
41export const rainMessage = (columns: number, since: number, now: number) => {
42	const full = RAIN_IDLE.find((m) => m.length + 3 <= columns) ?? "";
43	const typed = Math.min(full.length, Math.floor((now - since) / RAIN_TYPE_MS));
44	const cursor =
45		typed < full.length || Math.floor(now / RAIN_BLINK_MS) % 2 === 0
46			? "█"
47			: " ";
48	return `${full.slice(0, typed)}${cursor}`.padEnd(full.length + 1);
49};
50
51export const hash = (n: number) => {
52	const h = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
53	const k = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
54	return (k ^ (k >>> 16)) >>> 0;
55};
56
57export const modelWeight = (model: string) =>
58	model.startsWith("opus") ? 1.5 : model.startsWith("haiku") ? 0.6 : 1;
59
60// How fast the rain falls, a multiple of its base speed: the biggest model at
61// work, +25% an agent running (up to +150%) and the last model call's tokens
62// (x1 at 10k, x1.5 at 100k, x2 from 1M), capped; 0 with no model at work.
63export const rainSpeed = (models: string[], agents: number, tokens: number) =>
64	models.length === 0
65		? 0
66		: Math.min(
67				RAIN_MAX_SPEED,
68				Math.max(...models.map(modelWeight)) *
69					(1 + Math.min(1.5, agents * 0.25)) *
70					(1 + Math.min(1, Math.log10(Math.max(1, tokens / 10_000)) / 2)),
71			);
72
73// Raster cells for the clock at t, faded and carrying the idle message when one
74// is given. Each column's drop gets its speed (rows a second), trail and gap
75// from the column index, so the clock alone draws it.
76export const rainCells = (
77	t: number,
78	columns: number,
79	rows: number,
80	message?: string,
81) => {
82	const words = new Uint32Array(columns * rows * 3).fill(TERMINAL_DEFAULT);
83	for (let c = 0; c < columns; c++) {
84		const trail = 4 + (hash(c * 3 + 1) % (RAIN_FADE.length - 3));
85		const period = rows + trail + (hash(c * 3 + 2) % 12);
86		const speed = 8 + (hash(c * 3) % 9);
87		const head = (Math.floor((t / 1000) * speed) + hash(c * 7 + 5)) % period;
88		for (let r = 0; r < rows; r++) {
89			const d = head - r;
90			const i = (r * columns + c) * 3;
91			if (d < 0 || d >= trail) {
92				words[i] = 0x20;
93				continue;
94			}
95			const flicker = Math.floor(t / (d === 0 ? 50 : 300));
96			words[i] =
97				RAIN_GLYPHS[hash(c * 131 + r * 17 + flicker) % RAIN_GLYPHS.length] ??
98				0x20;
99			words[i + 1] =
100				message === undefined
101					? (RAIN_FADE[d === 0 ? 0 : d + RAIN_FADE.length - trail] ??
102						TERMINAL_DEFAULT)
103					: rgb(d === 0 ? C.surface2 : C.surface1);
104		}
105	}
106	if (message !== undefined) {
107		const r = Math.floor(rows / 2);
108		const from = Math.max(0, Math.floor((columns - message.length) / 2));
109		// a blank cell either side keeps the rain off the words
110		for (
111			let c = Math.max(0, from - 1);
112			c <= Math.min(columns - 1, from + message.length);
113			c++
114		) {
115			const i = (r * columns + c) * 3;
116			const ch = message.codePointAt(c - from);
117			words[i] = c >= from && ch !== undefined ? ch : 0x20;
118			words[i + 1] = rgb(C.green);
119		}
120	}
121	// the engine's runtime has toBase64 (its Raster docs use it); TS lib lags
122	return (
123		new Uint8Array(words.buffer) as Uint8Array & { toBase64(): string }
124	).toBase64();
125};
126
127// What the rain keeps between frames, made per register so a reload or a
128// test starts it over: the pane reads it, the blit timer advances it.
129export type Rain = {
130	// the mounted Raster's width; a blit for another width is refused
131	columns: number;
132	// rainSpeed's latest, 0 while nothing works
133	pace: number;
134	// the rain's own clock, so a change of speed does not jump it
135	t: number;
136	at: number;
137	// when the rain last stopped; 0 has the message typed out in full
138	idleSince: number;
139	wasIdle: boolean;
140	lastMessage: string;
141};
142
143export const newRain = (): Rain => ({
144	columns: 0,
145	pace: 0,
146	t: 0,
147	at: 0,
148	idleSince: 0,
149	wasIdle: true,
150	lastMessage: "",
151});
152
hooks/status.ts 62 lines
1import type { AgentStatus, TurnCompleteReason } from "claude-code";
2import type { AgentRow, SkillRun, WorkItem } from "../types";
3
4export const STATUS: Record<AgentStatus, AgentRow["status"]> = {
5	pending: "running",
6	running: "running",
7	waiting: "running",
8	idle: "idle",
9	completed: "done",
10	failed: "failed",
11	killed: "killed",
12};
13
14export const TURN_END: Record<TurnCompleteReason, AgentRow["status"]> = {
15	answer: "done",
16	aborted: "killed",
17	refusal: "failed",
18	error: "failed",
19};
20
21export const RUN_END: Record<TurnCompleteReason, SkillRun["result"]> = {
22	answer: "done",
23	aborted: "aborted",
24	refusal: "failed",
25	error: "failed",
26};
27
28export const isLive = (a: AgentRow) =>
29	a.status === "running" || a.status === "idle";
30
31export const end = (a: AgentRow, t: number) =>
32	a.status === "running" ? t : (a.endedAt ?? t);
33
34export const sync = (
35	a: AgentRow,
36	live: AgentRow["status"] | undefined,
37	t: number,
38): AgentRow => {
39	if (
40		!isLive(a) ||
41		(live === undefined && !a.listed) ||
42		(live === a.status && a.listed)
43	) {
44		return a;
45	}
46	const status = live ?? "done";
47	return {
48		...a,
49		listed: true,
50		status,
51		...(status === "running" ? {} : { endedAt: end(a, t) }),
52	};
53};
54
55export const isOpenRun = (r: SkillRun | undefined) =>
56	r !== undefined && r.result === undefined;
57
58export const isBusy = (list: AgentRow[], runs: SkillRun[], work: WorkItem[]) =>
59	list.some((a) => a.status === "running") ||
60	(runs.length > 0 && runs.at(-1)?.result === undefined) ||
61	work.some((w) => w.text === undefined);
62
hooks/theme.ts 96 lines
1import type { AgentRow, SkillRun } from "../types";
2
3export const PANE = "summary";
4
5export const TOGGLE_ACTION = "app:toggleDiffPreSession";
6
7// rows each box lists at most, so the pane fits a terminal's height
8export const SHOWN = 3;
9
10export const SPARK = "▁▂▃▄▅▆▇█";
11
12export const C = {
13	text: "#cdd6f4",
14	subtext: "#a6adc8",
15	overlay: "#7f849c",
16	surface2: "#585b70",
17	surface1: "#45475a",
18	surface0: "#313244",
19	mauve: "#cba6f7",
20	pink: "#f5c2e7",
21	lavender: "#b4befe",
22	blue: "#89b4fa",
23	sky: "#89dceb",
24	teal: "#94e2d5",
25	green: "#a6e3a1",
26	yellow: "#f9e2af",
27	peach: "#fab387",
28	red: "#f38ba8",
29};
30
31export const SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
32
33export const PULSE = "◇◈◆◈";
34
35export const GLOW = [
36	C.mauve,
37	C.pink,
38	C.lavender,
39	C.blue,
40	C.sky,
41	C.blue,
42	C.lavender,
43	C.pink,
44];
45
46// head first; a drop's trail always ends on the last color
47export const rgb = (hex: string) => Number.parseInt(hex.slice(1), 16);
48
49export const ICON: Record<AgentRow["status"], string> = {
50	running: "●",
51	idle: "○",
52	done: "✓",
53	failed: "✗",
54	killed: "■",
55};
56
57export const COLOR: Record<AgentRow["status"], string> = {
58	running: C.yellow,
59	idle: C.overlay,
60	done: C.green,
61	failed: C.red,
62	killed: C.red,
63};
64
65export const GIT_COLOR: Record<string, string> = {
66	M: C.yellow,
67	A: C.green,
68	D: C.red,
69	R: C.blue,
70	"??": C.teal,
71};
72
73export const RUN: Record<
74	NonNullable<SkillRun["result"]>,
75	{ icon: string; color: string }
76> = {
77	done: { icon: "✓", color: C.green },
78	failed: { icon: "✗", color: C.red },
79	aborted: { icon: "■", color: C.overlay },
80};
81
82export const ORDER: AgentRow["status"][] = [
83	"running",
84	"idle",
85	"done",
86	"failed",
87	"killed",
88];
89
90export const modelColor = (model: string) =>
91	model.startsWith("opus")
92		? C.peach
93		: model.startsWith("haiku")
94			? C.teal
95			: C.blue;
96
hooks/cron.ts 56 lines
1// One cron field (`*`, `5`, `1-5`, `*/10`, `0,6`) against a value.
2export const cronMatches = (field: string, v: number, lo: number, hi: number) =>
3	field.split(",").some((part) => {
4		const [range = "", every = "1"] = part.split("/");
5		const [from = Number.NaN, to = from] =
6			range === "*"
7				? [lo, hi]
8				: range.includes("-")
9					? range.split("-").map(Number)
10					: [Number(range), part.includes("/") ? hi : Number(range)];
11		return v >= from && v <= to && (v - from) % Number(every) === 0;
12	});
13
14// The next minute after now a five-field cron fires, in local time; undefined
15// past a week and a day or for what does not parse.
16export const nextFire = (schedule: string, now: number) => {
17	const [minute, hour, dom, month, dow, ...rest] = schedule.trim().split(/\s+/);
18	if (
19		minute === undefined ||
20		hour === undefined ||
21		dom === undefined ||
22		month === undefined ||
23		dow === undefined ||
24		rest.length > 0
25	) {
26		return undefined;
27	}
28	const d = new Date(now);
29	d.setSeconds(0, 0);
30	for (let i = 0; i < 8 * 24 * 60; i++) {
31		d.setMinutes(d.getMinutes() + 1);
32		const day = d.getDay();
33		const byDom = cronMatches(dom, d.getDate(), 1, 31);
34		const byDow =
35			cronMatches(dow, day, 0, 7) || (day === 0 && cronMatches(dow, 7, 0, 7));
36		if (
37			cronMatches(minute, d.getMinutes(), 0, 59) &&
38			cronMatches(hour, d.getHours(), 0, 23) &&
39			cronMatches(month, d.getMonth() + 1, 1, 12) &&
40			// both day fields set: either may match, as cron has it
41			(dom !== "*" && dow !== "*" ? byDom || byDow : byDom && byDow)
42		) {
43			return d.getTime();
44		}
45	}
46	return undefined;
47};
48
49export const hhmm = (ms: number, now: number) => {
50	const d = new Date(ms);
51	const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
52	return ms - now < 86_400_000
53		? time
54		: `${d.getMonth() + 1}/${d.getDate()} ${time}`;
55};
56
types/index.d.ts 101 lines
1export type AgentRow = {
2	id: string;
3	label: string;
4	name?: string;
5	model: string;
6	description: string;
7	startedAt: number;
8	endedAt?: number;
9	status: "running" | "idle" | "done" | "failed" | "killed";
10	tool?: string;
11	listed?: boolean;
12};
13
14export type WorkItem = {
15	id: string;
16	at: number;
17	durationMs: number;
18	tokens?: number;
19	text?: string;
20	ask?: string;
21	answer?: string;
22};
23
24export type FileTouch = {
25	path: string;
26	edits: number;
27	status?: string;
28};
29
30export type Alert = {
31	at: number;
32	tool: string;
33	reason: string;
34	agent?: string;
35};
36
37export type SkillRun = {
38	skills: string[];
39	model: string;
40	effort?: string;
41	startedAt: number;
42	endedAt?: number;
43	result?: "done" | "failed" | "aborted";
44};
45
46export type BackgroundTask = {
47	id: string;
48	type: string;
49	status: string;
50	label: string;
51};
52
53export type SessionCron = {
54	id: string;
55	schedule: string;
56	recurring: boolean;
57	prompt: string;
58};
59
60export type Commit = {
61	hash: string;
62	at: number;
63	subject: string;
64};
65
66export type RepoState = {
67	branch?: string;
68	upstream?: string;
69	ahead: number;
70	behind: number;
71	commits: Commit[];
72};
73
74export type TurnMark = {
75	ms: number;
76	ok: boolean;
77};
78
79declare module "claude-code" {
80	interface PluginState {
81		summary: {
82			agents: AgentRow[];
83			now: number;
84			recent: WorkItem[];
85			files: FileTouch[];
86			dirty: number;
87			alerts: Alert[];
88			runs: SkillRun[];
89			model: string;
90			shown: boolean;
91			chord: string;
92			seen: string[];
93			working: number;
94			tasks: BackgroundTask[];
95			crons: SessionCron[];
96			turns: TurnMark[];
97			repo: RepoState;
98		};
99	}
100}
101