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

개인 환경 백업 및 복구 자동화 스크립트~ :smile:
# 설정 및 프로그램 목록등 백업
bash ./backupmysetting.sh
# 전체 설치
bash ./installall.sh
# 필요한 환경/프로그램은 installxxx.sh 사용
#!/usr/bin/env bash shebang 을 추가하거나, 아래처럼 전역 sh 링크를 bash로 변경한다.sudo unlink /bin/sh
sudo ln -s /bin/bash /bin/shhooks/register.tsx 815 lines1import 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};
815hooks/format.ts 63 lines1import 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-/, "");
63hooks/pane.tsx 461 lines1import 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};
461hooks/rain.ts 152 lines1import { 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});
152hooks/status.ts 62 lines1import 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);
62hooks/theme.ts 96 lines1import 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;
96hooks/cron.ts 56 lines1// 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};
56types/index.d.ts 101 lines1export 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