catherd-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/catalog/catalog.json +112 -0
- package/catalog/models-dev.json +56453 -0
- package/package.json +52 -0
- package/plugin/.claude-plugin/plugin.json +13 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/commands/catherd-setup.md +8 -0
- package/plugin/commands/catherd.md +10 -0
- package/plugin/skills/catherd/SKILL.md +301 -0
- package/plugin/skills/catherd-setup/SKILL.md +65 -0
- package/src/cli.ts +28 -0
- package/src/core/codex-events.ts +49 -0
- package/src/core/codex.ts +166 -0
- package/src/core/harness.ts +89 -0
- package/src/core/lanes.ts +67 -0
- package/src/core/lock.ts +111 -0
- package/src/core/opencode.ts +207 -0
- package/src/core/reconcile.ts +26 -0
- package/src/core/reply.ts +54 -0
- package/src/core/runstore.ts +163 -0
- package/src/core/spawn.ts +89 -0
- package/src/core/status.ts +193 -0
- package/src/files.ts +19 -0
- package/src/mcp/commands.ts +7 -0
- package/src/mcp/dispatch.ts +154 -0
- package/src/mcp/lane-tools.ts +200 -0
- package/src/mcp/out.ts +6 -0
- package/src/mcp/preflight.ts +69 -0
- package/src/mcp/run-tools.ts +121 -0
- package/src/mcp/runs.ts +118 -0
- package/src/mcp/server.ts +26 -0
- package/src/mcp/setup-tools.ts +175 -0
- package/src/paths.ts +20 -0
- package/src/profile/agents.ts +118 -0
- package/src/profile/profile.ts +228 -0
- package/src/profile/role-prompts.ts +129 -0
- package/src/routing/catalog.ts +227 -0
- package/src/routing/commands.ts +22 -0
- package/src/routing/jev.ts +181 -0
- package/src/routing/route.ts +155 -0
- package/src/routing/select.ts +90 -0
- package/src/tui/backends.ts +57 -0
- package/src/tui/commands.ts +51 -0
- package/src/tui/editor.tsx +221 -0
- package/src/tui/init.tsx +221 -0
- package/src/tui/matrix-model.ts +211 -0
- package/src/tui/matrix.tsx +350 -0
- package/src/tui/profiles.ts +38 -0
- package/src/tui/theme.ts +140 -0
- package/src/tui/ui.tsx +104 -0
- package/src/tui/watch-model.ts +83 -0
- package/src/tui/watch.tsx +258 -0
- package/src/types.ts +184 -0
- package/src/version.ts +7 -0
package/src/tui/theme.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { WriteStream } from "node:tty";
|
|
2
|
+
import type { HarnessCost } from "../core/harness.ts";
|
|
3
|
+
|
|
4
|
+
export type Mood = "good" | "working" | "waiting" | "failed" | "landed";
|
|
5
|
+
export type Tone = "ginger" | "cream" | "charcoal" | "pink";
|
|
6
|
+
/** Node's getColorDepth: 1 none, 4 sixteen, 8 256, 24 true colour. */
|
|
7
|
+
export type Depth = 1 | 4 | 8 | 24;
|
|
8
|
+
|
|
9
|
+
export interface Ui {
|
|
10
|
+
plain: boolean;
|
|
11
|
+
reducedMotion: boolean;
|
|
12
|
+
depth: Depth;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// OpenTUI renders true-colour RGB only and downsamples itself for the terminal it finds, so
|
|
16
|
+
// every depth above 1 gets the same hex; depth 1 (NO_COLOR) gets no colour at all.
|
|
17
|
+
const TONES: Record<Tone, string> = {
|
|
18
|
+
ginger: "#E8833A",
|
|
19
|
+
cream: "#F5E6C8",
|
|
20
|
+
charcoal: "#4A4340",
|
|
21
|
+
pink: "#FF6FA5",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const GRADIENT: string[] = [TONES.ginger, TONES.pink];
|
|
25
|
+
|
|
26
|
+
export function tint(tone: Tone, depth: Depth): string | undefined {
|
|
27
|
+
return depth === 1 ? undefined : TONES[tone];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function detectUi(rawArgs: string[], env: NodeJS.ProcessEnv = process.env): Ui {
|
|
31
|
+
return {
|
|
32
|
+
// is-unicode-supported answers exactly this on macOS and Linux.
|
|
33
|
+
plain: rawArgs.includes("--plain") || env.TERM === "linux",
|
|
34
|
+
reducedMotion: rawArgs.includes("--reduced-motion"),
|
|
35
|
+
depth: WriteStream.prototype.getColorDepth.call(process.stdout as WriteStream, env) as Depth,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const FACES: Record<Mood, string> = {
|
|
40
|
+
good: "=^.^=",
|
|
41
|
+
working: "=o.o=",
|
|
42
|
+
waiting: "=-.-=",
|
|
43
|
+
failed: "=x.x=",
|
|
44
|
+
landed: "=^ฯ^=",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function face(mood: Mood, plain: boolean): string {
|
|
48
|
+
return plain ? FACES[mood].replace("ฯ", "w") : FACES[mood];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const GLYPHS = {
|
|
52
|
+
on: ["๐พ", "[x]"],
|
|
53
|
+
off: ["โ ", "[ ]"],
|
|
54
|
+
cat: ["๐", "*"],
|
|
55
|
+
dot: ["ยท", "-"],
|
|
56
|
+
more: ["โฆ", "..."],
|
|
57
|
+
open: ["โพ", "v"],
|
|
58
|
+
shut: ["โธ", ">"],
|
|
59
|
+
cursor: ["โฏ", ">"],
|
|
60
|
+
arrow: ["โ", "->"],
|
|
61
|
+
keys: ["โโ", "up/down"],
|
|
62
|
+
swap: ["โ", "<->"],
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
export type Glyph = keyof typeof GLYPHS;
|
|
66
|
+
export const GLYPH_NAMES = Object.keys(GLYPHS) as Glyph[];
|
|
67
|
+
|
|
68
|
+
export function glyph(g: Glyph, plain: boolean): string {
|
|
69
|
+
return GLYPHS[g][plain ? 1 : 0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The conductor: ears, a face that follows the mood, a raised baton, paws. */
|
|
73
|
+
export function mascot(mood: Mood, plain: boolean): [string, string, string] {
|
|
74
|
+
return [" /\\_/\\ .", `(${face(mood, plain)})/`, ' (")(")'].map((l) => l.padEnd(9)) as [
|
|
75
|
+
string,
|
|
76
|
+
string,
|
|
77
|
+
string,
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const HERDING = [
|
|
82
|
+
"herding",
|
|
83
|
+
"counting whiskers",
|
|
84
|
+
"negotiating with Luna",
|
|
85
|
+
"coaxing Sol off the keyboard",
|
|
86
|
+
"lining up the kittens",
|
|
87
|
+
"untangling the yarn",
|
|
88
|
+
] as const;
|
|
89
|
+
|
|
90
|
+
export function herdLine(i: number, plain: boolean): string {
|
|
91
|
+
return `${HERDING[i % HERDING.length]}${glyph("more", plain)}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Kneading paws (m/n) and a flicking tail. ASCII already, so --plain needs no second set. */
|
|
95
|
+
export const SPINNER = {
|
|
96
|
+
frames: ["m=o.o=~", "n=o.o=-", "m=o.o=_", "n=o.o=-"],
|
|
97
|
+
ms: 160,
|
|
98
|
+
copyMs: 2400,
|
|
99
|
+
} as const;
|
|
100
|
+
|
|
101
|
+
/** The init intro: 6 frames of 200 ms, 1.2 s in all, inside the spec's 1.5 s. */
|
|
102
|
+
export const INTRO = { steps: 6, ms: 200 } as const;
|
|
103
|
+
|
|
104
|
+
export const HARNESS_HINT: Record<"codex" | "opencode", string> = {
|
|
105
|
+
codex: "native keeps your Codex config, hooks, skills, AGENTS.md; isolated saves tokens",
|
|
106
|
+
opencode: "native keeps your opencode config, agents and plugins; isolated saves tokens",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const kTokens = (n: number) => `~${Math.round(n / 1000)}k`;
|
|
110
|
+
|
|
111
|
+
export function costNote(h: HarnessCost): string {
|
|
112
|
+
if (h.extraPerRun !== null) return `customizations ${kTokens(h.extraPerRun)} tokens/run`;
|
|
113
|
+
if (h.nativeMedian !== null) return `native ${kTokens(h.nativeMedian)} tokens/run`;
|
|
114
|
+
return h.isolatedMedian !== null ? `isolated ${kTokens(h.isolatedMedian)} tokens/run` : "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function clock(secs: number): string {
|
|
118
|
+
const s = Math.max(0, Math.floor(secs));
|
|
119
|
+
const h = Math.floor(s / 3600);
|
|
120
|
+
const mm = String(Math.floor(s / 60) % 60).padStart(2, "0");
|
|
121
|
+
const ss = String(s % 60).padStart(2, "0");
|
|
122
|
+
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** "gpt-6-sol#medium" -> "sol#medium"; provider paths drop to the last segment. */
|
|
126
|
+
export function shortRung(rung: string): string {
|
|
127
|
+
return rung.replace(/^.*\//, "").replace(/^gpt-\d+-/, "");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function nick(rung: string): string {
|
|
131
|
+
const model = shortRung(rung).split("#")[0] ?? "";
|
|
132
|
+
return model.charAt(0).toUpperCase() + model.slice(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function climbLine(name: string, from: string, to: string, plain: boolean): string {
|
|
136
|
+
const a = nick(from);
|
|
137
|
+
const b = nick(to);
|
|
138
|
+
const story = a === b ? `${a} digs deeper` : `${a} gave up, ${b} takes over`;
|
|
139
|
+
return `${story} ${glyph("dot", plain)} ${name} ${shortRung(from)} ${glyph("arrow", plain)} ${shortRung(to)}`;
|
|
140
|
+
}
|
package/src/tui/ui.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
3
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
4
|
+
import { GRADIENT, glyph, herdLine, INTRO, type Mood, mascot, SPINNER, tint, type Ui } from "./theme.ts";
|
|
5
|
+
|
|
6
|
+
export function Screen({ children }: { children: ReactNode }) {
|
|
7
|
+
const { width } = useTerminalDimensions();
|
|
8
|
+
return <box style={{ flexDirection: "column", width: Math.min(width || 80, 80) }}>{children}</box>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const hex = (h: string): [number, number, number] => [
|
|
12
|
+
Number.parseInt(h.slice(1, 3), 16),
|
|
13
|
+
Number.parseInt(h.slice(3, 5), 16),
|
|
14
|
+
Number.parseInt(h.slice(5, 7), 16),
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** One colour per letter, interpolated between GRADIENT's two hex stops. */
|
|
18
|
+
export function gradientLetters(word: string): { ch: string; fg: string }[] {
|
|
19
|
+
const [a, b] = [hex(GRADIENT[0] as string), hex(GRADIENT[1] as string)];
|
|
20
|
+
return [...word].map((ch, i) => {
|
|
21
|
+
const t = word.length > 1 ? i / (word.length - 1) : 0;
|
|
22
|
+
const rgb = a.map((c, k) => Math.round(c + ((b[k] as number) - c) * t));
|
|
23
|
+
return { ch, fg: `#${rgb.map((c) => c.toString(16).padStart(2, "0")).join("")}` };
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Header({
|
|
28
|
+
ui,
|
|
29
|
+
mood,
|
|
30
|
+
title,
|
|
31
|
+
word = "catherd",
|
|
32
|
+
}: {
|
|
33
|
+
ui: Ui;
|
|
34
|
+
mood: Mood;
|
|
35
|
+
title: string;
|
|
36
|
+
word?: string;
|
|
37
|
+
}) {
|
|
38
|
+
const [ears, head, paws] = mascot(mood, ui.plain);
|
|
39
|
+
const ginger = tint("ginger", ui.depth);
|
|
40
|
+
return (
|
|
41
|
+
<box style={{ flexDirection: "row", marginBottom: 1 }}>
|
|
42
|
+
<box style={{ flexDirection: "column", marginRight: 2 }}>
|
|
43
|
+
<text fg={ginger}>{ears}</text>
|
|
44
|
+
<text fg={ginger}>{head}</text>
|
|
45
|
+
<text fg={ginger}>{paws}</text>
|
|
46
|
+
</box>
|
|
47
|
+
<box style={{ flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
|
|
48
|
+
{ui.depth >= 8 && word ? (
|
|
49
|
+
<text attributes={TextAttributes.BOLD}>
|
|
50
|
+
{gradientLetters(word).map((s, i) => (
|
|
51
|
+
// biome-ignore lint: index key is stable, the wordmark never reorders
|
|
52
|
+
<span key={i} fg={s.fg}>
|
|
53
|
+
{s.ch}
|
|
54
|
+
</span>
|
|
55
|
+
))}
|
|
56
|
+
</text>
|
|
57
|
+
) : (
|
|
58
|
+
<text fg={ginger} attributes={TextAttributes.BOLD}>
|
|
59
|
+
{word || " "}
|
|
60
|
+
</text>
|
|
61
|
+
)}
|
|
62
|
+
<text fg={tint("cream", ui.depth)}>herds coding agents</text>
|
|
63
|
+
<text fg={tint("pink", ui.depth)} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
|
|
64
|
+
{title}
|
|
65
|
+
</text>
|
|
66
|
+
</box>
|
|
67
|
+
</box>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function CatSpinner({ ui, label }: { ui: Ui; label: string }) {
|
|
72
|
+
const [n, setN] = useState(0);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (ui.reducedMotion) return;
|
|
75
|
+
const t = setInterval(() => setN((x) => x + 1), SPINNER.ms);
|
|
76
|
+
return () => clearInterval(t);
|
|
77
|
+
}, [ui.reducedMotion]);
|
|
78
|
+
const frame = SPINNER.frames[n % SPINNER.frames.length];
|
|
79
|
+
const copy = herdLine(Math.floor((n * SPINNER.ms) / SPINNER.copyMs), ui.plain);
|
|
80
|
+
return (
|
|
81
|
+
<text wrapMode="none" truncate>
|
|
82
|
+
<span fg={tint("ginger", ui.depth)}>{frame}</span> {label}{" "}
|
|
83
|
+
<span attributes={TextAttributes.DIM}>
|
|
84
|
+
{glyph("dot", ui.plain)} {copy}
|
|
85
|
+
</span>
|
|
86
|
+
</text>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function Intro({ ui, onDone }: { ui: Ui; onDone: () => void }) {
|
|
91
|
+
const [step, setStep] = useState(ui.reducedMotion ? INTRO.steps : 0);
|
|
92
|
+
useKeyboard(() => onDone());
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (step >= INTRO.steps) {
|
|
95
|
+
onDone();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const t = setTimeout(() => setStep(step + 1), INTRO.ms);
|
|
99
|
+
return () => clearTimeout(t);
|
|
100
|
+
}, [step]);
|
|
101
|
+
const mood: Mood = step < 2 ? "waiting" : step < INTRO.steps ? "working" : "good";
|
|
102
|
+
const word = "catherd".slice(0, Math.round((7 * step) / INTRO.steps));
|
|
103
|
+
return <Header ui={ui} mood={mood} title={herdLine(step, ui.plain)} word={word} />;
|
|
104
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { RunSummary } from "../core/status.ts";
|
|
2
|
+
import type { RunRecord, RungId } from "../types.ts";
|
|
3
|
+
import { climbLine, clock, face, glyph, type Mood, shortRung } from "./theme.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The fields `watch` shows from one `jev.jsonl` row (`JevLogRow` in `src/routing/jev.ts`):
|
|
7
|
+
* which questions were asked, the answer actually used, and whether Jev answered or the
|
|
8
|
+
* profile default did. `ts`, `answers` and `why` are in the file but not shown here.
|
|
9
|
+
*/
|
|
10
|
+
export interface JevLine {
|
|
11
|
+
questions: string[];
|
|
12
|
+
used: string;
|
|
13
|
+
source: "jev" | "default";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function runMood(s: RunSummary): Mood {
|
|
17
|
+
if (s.live.length > 0) return "working";
|
|
18
|
+
if (s.totals.notOk.length > 0) return "failed";
|
|
19
|
+
return s.totals.runs > 0 ? "good" : "waiting";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function runLine(s: RunSummary, plain: boolean): string {
|
|
23
|
+
return [
|
|
24
|
+
`${face(runMood(s), plain)} ${s.title}`,
|
|
25
|
+
`${s.live.length} live`,
|
|
26
|
+
`${s.totals.runs} runs`,
|
|
27
|
+
`${s.milestones.length} landed`,
|
|
28
|
+
].join(` ${glyph("dot", plain)} `);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function liveLine(l: RunSummary["live"][number], plain: boolean): string {
|
|
32
|
+
return `${glyph("cat", plain)} ${l.name} ${shortRung(l.rung)} ${clock(l.secs)} ${face("working", plain)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function milestoneLine(m: string, plain: boolean): string {
|
|
36
|
+
const id = /\bM\d+\b/.exec(m)?.[0];
|
|
37
|
+
if (!id) return `${face("landed", plain)} ${m}`;
|
|
38
|
+
const mins = /\b(\d+)m\b/.exec(m)?.[1];
|
|
39
|
+
const sha = /\b[0-9a-f]{7,40}\b/.exec(m)?.[0]?.slice(0, 7);
|
|
40
|
+
return [`${face("landed", plain)} ${id} landed`, mins ? `${mins}m` : null, sha ?? null]
|
|
41
|
+
.filter((x): x is string => x !== null)
|
|
42
|
+
.join(` ${glyph("dot", plain)} `);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Remembers each role's last rung across polls; a role that comes back on another rung has climbed. */
|
|
46
|
+
export function climbs(seen: Map<string, RungId>, s: RunSummary, plain: boolean): string[] {
|
|
47
|
+
const out: string[] = [];
|
|
48
|
+
for (const l of s.live) {
|
|
49
|
+
const key = `${s.id}/${l.name}`;
|
|
50
|
+
const from = seen.get(key);
|
|
51
|
+
if (from !== undefined && from !== l.rung) out.push(climbLine(l.name, from, l.rung, plain));
|
|
52
|
+
seen.set(key, l.rung);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function recordLine(r: RunRecord, plain: boolean): string {
|
|
58
|
+
const tail = r.replyStatus ? ` ${glyph("dot", plain)} ${r.replyStatus}` : "";
|
|
59
|
+
return `${r.name} ${shortRung(r.rung)} ${r.status} ${clock(r.secs)}${tail}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function jevLine(e: JevLine, plain: boolean): string {
|
|
63
|
+
const tail = e.source === "default" ? ` ${glyph("dot", plain)} fell back to the profile default` : "";
|
|
64
|
+
return `${e.questions.join(", ")} ${glyph("arrow", plain)} ${e.used}${tail}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** spec ยง11b: --plain's stand-in for the coloured budget bar, e.g. "[#####-----] 52%". */
|
|
68
|
+
export function plainBudgetBar(fraction: number, width = 10): string {
|
|
69
|
+
const f = Math.max(0, Math.min(1, fraction));
|
|
70
|
+
const filled = Math.round(f * width);
|
|
71
|
+
return `[${"#".repeat(filled)}${"-".repeat(width - filled)}] ${Math.round(f * 100)}%`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** spec ยง11b: the bar is the ginger-to-pink gradient below 80%, a solid warning tint from 80%,
|
|
75
|
+
* and a solid error tint once the budget is spent. Only ginger/pink exist in the warm palette
|
|
76
|
+
* (theme.ts), so "warning" and "error" reuse them rather than adding new tones. */
|
|
77
|
+
export type BudgetTone = "gradient" | "warning" | "error";
|
|
78
|
+
|
|
79
|
+
export function budgetTone(fraction: number): BudgetTone {
|
|
80
|
+
if (fraction >= 1) return "error";
|
|
81
|
+
if (fraction >= 0.8) return "warning";
|
|
82
|
+
return "gradient";
|
|
83
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { TextAttributes } from "@opentui/core";
|
|
3
|
+
import { useKeyboard, useRenderer } from "@opentui/react";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { type BudgetStatus, formatBudget, type RunSummary, summarizeRun } from "../core/status.ts";
|
|
6
|
+
import { listRuns, readJsonl, readRunRecords, type Run } from "../core/runstore.ts";
|
|
7
|
+
import type { RunRecord, RungId } from "../types.ts";
|
|
8
|
+
import { face, glyph, type Mood, tint, type Ui } from "./theme.ts";
|
|
9
|
+
import { CatSpinner, Header, Screen, gradientLetters } from "./ui.tsx";
|
|
10
|
+
import {
|
|
11
|
+
budgetTone,
|
|
12
|
+
climbs,
|
|
13
|
+
jevLine,
|
|
14
|
+
type JevLine,
|
|
15
|
+
liveLine,
|
|
16
|
+
milestoneLine,
|
|
17
|
+
plainBudgetBar,
|
|
18
|
+
recordLine,
|
|
19
|
+
runLine,
|
|
20
|
+
runMood,
|
|
21
|
+
} from "./watch-model.ts";
|
|
22
|
+
|
|
23
|
+
export interface WatchDeps {
|
|
24
|
+
listRuns: () => Run[];
|
|
25
|
+
summarizeRun: (run: Run) => RunSummary;
|
|
26
|
+
readRunRecords: (dir: string) => RunRecord[];
|
|
27
|
+
readJev: (dir: string) => JevLine[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const watchDeps: WatchDeps = {
|
|
31
|
+
listRuns,
|
|
32
|
+
summarizeRun,
|
|
33
|
+
readRunRecords,
|
|
34
|
+
readJev: (dir) => readJsonl<JevLine>(join(dir, "jev.jsonl")),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ponytail: only the 20 newest runs are summarized each poll; add paging when someone keeps more than that live
|
|
38
|
+
const MAX_RUNS = 20;
|
|
39
|
+
const SHOWN = 6;
|
|
40
|
+
|
|
41
|
+
interface Item {
|
|
42
|
+
run: Run;
|
|
43
|
+
summary: RunSummary | null;
|
|
44
|
+
error: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function poll(d: WatchDeps): { items: Item[]; problem: string | null } {
|
|
48
|
+
try {
|
|
49
|
+
const items = d
|
|
50
|
+
.listRuns()
|
|
51
|
+
.slice(0, MAX_RUNS)
|
|
52
|
+
.map((run): Item => {
|
|
53
|
+
try {
|
|
54
|
+
return { run, summary: d.summarizeRun(run), error: null };
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return { run, summary: null, error: (e as Error).message };
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return { items, problem: null };
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return { items: [], problem: (e as Error).message };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** spec ยง11b: a bar in the ginger-to-pink gradient, solid ginger from 80% spent and solid pink once
|
|
66
|
+
* the budget is exhausted (the palette has no dedicated warning/error tones, see watch-model.ts). */
|
|
67
|
+
function BudgetBar({ ui, budget }: { ui: Ui; budget: BudgetStatus }) {
|
|
68
|
+
const width = 10;
|
|
69
|
+
if (ui.plain) {
|
|
70
|
+
return (
|
|
71
|
+
<text
|
|
72
|
+
wrapMode="none"
|
|
73
|
+
truncate
|
|
74
|
+
>{` ${plainBudgetBar(budget.fraction, width)} ${formatBudget(budget)}`}</text>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const filled = Math.round(Math.max(0, Math.min(1, budget.fraction)) * width);
|
|
78
|
+
const tone = budgetTone(budget.fraction);
|
|
79
|
+
const solid =
|
|
80
|
+
tone === "error" ? tint("pink", ui.depth) : tone === "warning" ? tint("ginger", ui.depth) : null;
|
|
81
|
+
const cells = solid
|
|
82
|
+
? Array.from({ length: filled }, () => ({ ch: "โ", fg: solid }))
|
|
83
|
+
: gradientLetters("โ".repeat(filled));
|
|
84
|
+
return (
|
|
85
|
+
<text wrapMode="none" truncate>
|
|
86
|
+
{" "}
|
|
87
|
+
{cells.map((cell, i) => (
|
|
88
|
+
// biome-ignore lint: index key is stable, the bar never reorders
|
|
89
|
+
<span key={i} fg={cell.fg}>
|
|
90
|
+
{cell.ch}
|
|
91
|
+
</span>
|
|
92
|
+
))}
|
|
93
|
+
<span attributes={TextAttributes.DIM}>{"โ".repeat(width - filled)}</span>
|
|
94
|
+
{` ${formatBudget(budget)}`}
|
|
95
|
+
</text>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function RunBlock({ ui, it, here }: { ui: Ui; it: Item; here: boolean }) {
|
|
100
|
+
const cur = here ? glyph("cursor", ui.plain) : " ";
|
|
101
|
+
if (!it.summary) {
|
|
102
|
+
return (
|
|
103
|
+
<text wrapMode="none" truncate attributes={TextAttributes.DIM}>
|
|
104
|
+
{`${cur} ${face("failed", ui.plain)} ${it.run.id} ${glyph("dot", ui.plain)} unreadable: ${it.error}`}
|
|
105
|
+
</text>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const s = it.summary;
|
|
109
|
+
const ginger = tint("ginger", ui.depth);
|
|
110
|
+
const pink = tint("pink", ui.depth);
|
|
111
|
+
return (
|
|
112
|
+
<box style={{ flexDirection: "column" }}>
|
|
113
|
+
<text
|
|
114
|
+
wrapMode="none"
|
|
115
|
+
truncate
|
|
116
|
+
attributes={here ? TextAttributes.BOLD : TextAttributes.NONE}
|
|
117
|
+
>{`${cur} ${runLine(s, ui.plain)}`}</text>
|
|
118
|
+
{s.live.map((l) => (
|
|
119
|
+
<text key={l.name} wrapMode="none" truncate fg={ginger}>{` ${liveLine(l, ui.plain)}`}</text>
|
|
120
|
+
))}
|
|
121
|
+
{s.milestones.map((m) => (
|
|
122
|
+
<text key={m} wrapMode="none" truncate fg={pink}>{` ${milestoneLine(m, ui.plain)}`}</text>
|
|
123
|
+
))}
|
|
124
|
+
{s.budget ? <BudgetBar ui={ui} budget={s.budget} /> : null}
|
|
125
|
+
</box>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function Detail({ ui, run, summary, deps }: { ui: Ui; run: Run; summary: RunSummary; deps: WatchDeps }) {
|
|
130
|
+
const records = deps.readRunRecords(run.dir);
|
|
131
|
+
const jev = deps.readJev(run.dir);
|
|
132
|
+
const dot = ` ${glyph("dot", ui.plain)} `;
|
|
133
|
+
const ginger = tint("ginger", ui.depth);
|
|
134
|
+
return (
|
|
135
|
+
<box style={{ flexDirection: "column" }}>
|
|
136
|
+
<text
|
|
137
|
+
wrapMode="none"
|
|
138
|
+
truncate
|
|
139
|
+
attributes={TextAttributes.DIM}
|
|
140
|
+
>{`${run.meta.repo}${dot}${run.id}`}</text>
|
|
141
|
+
<text fg={ginger} attributes={TextAttributes.BOLD}>
|
|
142
|
+
state
|
|
143
|
+
</text>
|
|
144
|
+
{summary.stateTail.map((l, i) => (
|
|
145
|
+
<text key={`s${i}`} wrapMode="none" truncate>{` ${l}`}</text>
|
|
146
|
+
))}
|
|
147
|
+
<text fg={ginger} attributes={TextAttributes.BOLD}>{`records${dot}${records.length}`}</text>
|
|
148
|
+
{records.slice(-8).map((r, i) => (
|
|
149
|
+
<text key={`r${i}`} wrapMode="none" truncate>{` ${recordLine(r, ui.plain)}`}</text>
|
|
150
|
+
))}
|
|
151
|
+
<text fg={ginger} attributes={TextAttributes.BOLD}>
|
|
152
|
+
{`jev${dot}${summary.jev.decisions} decisions${dot}${summary.jev.fallbacks} fell back`}
|
|
153
|
+
</text>
|
|
154
|
+
{jev.slice(-6).map((e, i) => (
|
|
155
|
+
<text key={`j${i}`} wrapMode="none" truncate>{` ${jevLine(e, ui.plain)}`}</text>
|
|
156
|
+
))}
|
|
157
|
+
<text attributes={TextAttributes.DIM}>{`esc back${dot}q quit`}</text>
|
|
158
|
+
</box>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function Watch({
|
|
163
|
+
ui,
|
|
164
|
+
deps = {},
|
|
165
|
+
intervalMs = 2000,
|
|
166
|
+
}: {
|
|
167
|
+
ui: Ui;
|
|
168
|
+
deps?: Partial<WatchDeps>;
|
|
169
|
+
intervalMs?: number;
|
|
170
|
+
}) {
|
|
171
|
+
const d = { ...watchDeps, ...deps };
|
|
172
|
+
const renderer = useRenderer();
|
|
173
|
+
const seen = useRef(new Map<string, RungId>());
|
|
174
|
+
const [state, setState] = useState<{ items: Item[]; problem: string | null } | null>(null);
|
|
175
|
+
const [climbed, setClimbed] = useState<string[]>([]);
|
|
176
|
+
const [cursor, setCursor] = useState(0);
|
|
177
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
const refresh = () => {
|
|
181
|
+
const next = poll(d);
|
|
182
|
+
const fresh = next.items.flatMap((it) =>
|
|
183
|
+
it.summary ? climbs(seen.current, it.summary, ui.plain) : [],
|
|
184
|
+
);
|
|
185
|
+
if (fresh.length > 0) setClimbed((c) => [...fresh, ...c].slice(0, 3));
|
|
186
|
+
setState(next);
|
|
187
|
+
};
|
|
188
|
+
refresh();
|
|
189
|
+
const t = setInterval(refresh, intervalMs);
|
|
190
|
+
return () => clearInterval(t);
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
const items = state?.items ?? [];
|
|
194
|
+
const at = Math.min(cursor, Math.max(0, items.length - 1));
|
|
195
|
+
const detail = items.find((it) => it.run.dir === open);
|
|
196
|
+
|
|
197
|
+
useKeyboard((key) => {
|
|
198
|
+
if (key.sequence === "q") {
|
|
199
|
+
renderer.destroy();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (detail) {
|
|
203
|
+
if (key.name === "escape" || key.name === "left" || key.name === "return") setOpen(null);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const it = items[at];
|
|
207
|
+
if (key.name === "up") setCursor(Math.max(0, at - 1));
|
|
208
|
+
else if (key.name === "down") setCursor(Math.min(items.length - 1, at + 1));
|
|
209
|
+
else if (key.name === "return" && it?.summary) setOpen(it.run.dir);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const summaries = items.flatMap((it) => (it.summary ? [it.summary] : []));
|
|
213
|
+
const mood: Mood =
|
|
214
|
+
state === null || summaries.some((s) => s.live.length > 0)
|
|
215
|
+
? "working"
|
|
216
|
+
: state.problem || items.some((it) => it.error) || summaries.some((s) => runMood(s) === "failed")
|
|
217
|
+
? "failed"
|
|
218
|
+
: items.length > 0
|
|
219
|
+
? "good"
|
|
220
|
+
: "waiting";
|
|
221
|
+
const dot = ` ${glyph("dot", ui.plain)} `;
|
|
222
|
+
const pink = tint("pink", ui.depth);
|
|
223
|
+
const start = Math.max(0, Math.min(at - Math.floor(SHOWN / 2), items.length - SHOWN));
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<Screen>
|
|
227
|
+
<Header
|
|
228
|
+
ui={ui}
|
|
229
|
+
mood={mood}
|
|
230
|
+
title={detail ? `watch${dot}${detail.run.meta.title}` : `watch${dot}${items.length} runs`}
|
|
231
|
+
/>
|
|
232
|
+
{detail?.summary ? (
|
|
233
|
+
<Detail ui={ui} run={detail.run} summary={detail.summary} deps={d} />
|
|
234
|
+
) : (
|
|
235
|
+
<box style={{ flexDirection: "column" }}>
|
|
236
|
+
{state === null ? <CatSpinner ui={ui} label="reading the runs" /> : null}
|
|
237
|
+
{state?.problem ? (
|
|
238
|
+
<text fg={pink} wrapMode="none" truncate>{`${face("failed", ui.plain)} ${state.problem}`}</text>
|
|
239
|
+
) : null}
|
|
240
|
+
{state !== null && !state.problem && items.length === 0 ? (
|
|
241
|
+
<text>No runs yet. Start one with /catherd in Claude Code.</text>
|
|
242
|
+
) : null}
|
|
243
|
+
{items.slice(start, start + SHOWN).map((it, i) => (
|
|
244
|
+
<RunBlock key={it.run.dir} ui={ui} it={it} here={start + i === at} />
|
|
245
|
+
))}
|
|
246
|
+
{climbed.map((c, i) => (
|
|
247
|
+
<text key={`c${i}`} fg={pink} wrapMode="none" truncate>
|
|
248
|
+
{c}
|
|
249
|
+
</text>
|
|
250
|
+
))}
|
|
251
|
+
<text attributes={TextAttributes.DIM} wrapMode="none" truncate>
|
|
252
|
+
{`${glyph("keys", ui.plain)} move${dot}enter details${dot}q quit${dot}refreshes every ${intervalMs / 1000} s`}
|
|
253
|
+
</text>
|
|
254
|
+
</box>
|
|
255
|
+
)}
|
|
256
|
+
</Screen>
|
|
257
|
+
);
|
|
258
|
+
}
|