catherd-cli 0.2.0 → 1.0.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/README.md +114 -30
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +227 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +316 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +58 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -25
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +33 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +133 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +196 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -69
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { useApp, useBack, useNow } from "../providers/app.tsx";
|
|
2
|
+
import { usePoll, useData } from "../providers/data.tsx";
|
|
3
|
+
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
4
|
+
import { errorToast } from "../providers/toast.tsx";
|
|
5
|
+
import { useUi } from "../providers/theme.tsx";
|
|
6
|
+
import { isArmed } from "../state.ts";
|
|
7
|
+
import { ago, clock, shortRung } from "../text.ts";
|
|
8
|
+
import { glyph, mascot, type Token } from "../theme.ts";
|
|
9
|
+
import { Line, type Part } from "../widgets/line.tsx";
|
|
10
|
+
import { List, type ListItem, useSelected } from "../widgets/list.tsx";
|
|
11
|
+
import type { RunDetail } from "../effects.ts";
|
|
12
|
+
import { runParts } from "./status.tsx";
|
|
13
|
+
|
|
14
|
+
/** How often an open run is read again, unless paused. */
|
|
15
|
+
export const RUN_EVERY_MS = 1_000;
|
|
16
|
+
|
|
17
|
+
/** `[██████░░░░] 52% · 31/60 min`, green below 80 %, amber below 100 %, red at 100 % (spec §4.6). */
|
|
18
|
+
function budgetParts(d: RunDetail, width: number, plain: boolean): Part[] {
|
|
19
|
+
const b = d.summary.budget;
|
|
20
|
+
if (!b) return [{ text: "no budget cap", tone: "muted" }];
|
|
21
|
+
const cells = Math.max(10, Math.min(30, width - 40));
|
|
22
|
+
const full = Math.min(cells, Math.round(b.fraction * cells));
|
|
23
|
+
const tone: Token = b.fraction >= 1 ? "error" : b.fraction >= 0.8 ? "warning" : "success";
|
|
24
|
+
const caps = [
|
|
25
|
+
b.minutes ? `${Math.round(b.minutes.spent)}/${b.minutes.cap} min` : "",
|
|
26
|
+
b.tokens ? `${b.tokens.spent}/${b.tokens.cap} tokens` : "",
|
|
27
|
+
b.usd ? `$${b.usd.spent.toFixed(2)}/$${b.usd.cap.toFixed(2)}` : "",
|
|
28
|
+
].filter(Boolean);
|
|
29
|
+
return [
|
|
30
|
+
{ text: "[" },
|
|
31
|
+
{ text: glyph("full", plain).repeat(full), tone },
|
|
32
|
+
{ text: glyph("empty", plain).repeat(cells - full), tone: "muted" },
|
|
33
|
+
{ text: `] ${Math.round(b.fraction * 100)}%`, tone },
|
|
34
|
+
{ text: ` · ${caps.join(" · ")}`, tone: "muted" },
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The selected row, where moving the cursor takes back a first ctrl+d (Ruling 3: moving or esc disarms),
|
|
40
|
+
* as the dialog list does.
|
|
41
|
+
*/
|
|
42
|
+
function useSelection(): [string | null, (key: string) => void, () => string | null] {
|
|
43
|
+
const app = useApp();
|
|
44
|
+
const sel = useSelected();
|
|
45
|
+
const select = (key: string) => {
|
|
46
|
+
if (key !== sel.current() && app.getState().armed) app.dispatch({ type: "disarm" });
|
|
47
|
+
sel.select(key);
|
|
48
|
+
};
|
|
49
|
+
return [sel.selected, select, sel.current];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function RunList(props: { width: number; height: number }) {
|
|
53
|
+
const app = useApp();
|
|
54
|
+
const data = useData();
|
|
55
|
+
const ui = useUi();
|
|
56
|
+
const now = useNow(1_000);
|
|
57
|
+
const [selected, setSelected, selectedNow] = useSelection();
|
|
58
|
+
const rows = data.runs.value?.rows ?? [];
|
|
59
|
+
useCommandLayer("row.runs", {
|
|
60
|
+
"runs.open": () => {
|
|
61
|
+
const id = selectedNow();
|
|
62
|
+
if (id) app.dispatch({ type: "run", id });
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const updated = app.state.paused
|
|
66
|
+
? "paused"
|
|
67
|
+
: data.runs.at !== null
|
|
68
|
+
? `updated ${ago(now - data.runs.at)}`
|
|
69
|
+
: "";
|
|
70
|
+
const items: ListItem[] = rows.map((r) => ({
|
|
71
|
+
key: r.id,
|
|
72
|
+
selectable: true,
|
|
73
|
+
render: (sel, w) => (
|
|
74
|
+
<Line width={w} selected={sel} parts={[{ text: " " }, ...runParts(r, now, ui.plain)]} />
|
|
75
|
+
),
|
|
76
|
+
}));
|
|
77
|
+
const warnings = data.runs.value?.warnings ?? [];
|
|
78
|
+
// a failed read says so, whether rows from an earlier good read are shown, none were, or none exist
|
|
79
|
+
const failed = data.runs.error;
|
|
80
|
+
const failure = failed ? (
|
|
81
|
+
<Line
|
|
82
|
+
width={props.width}
|
|
83
|
+
parts={[{ text: ` ${glyph("fail", ui.plain)} could not read the runs: ${failed}`, tone: "error" }]}
|
|
84
|
+
/>
|
|
85
|
+
) : null;
|
|
86
|
+
if (rows.length === 0 && data.runs.value) {
|
|
87
|
+
const art = mascot("waiting");
|
|
88
|
+
return (
|
|
89
|
+
<box flexDirection="column" width={props.width} height={props.height} paddingTop={failed ? 1 : 2}>
|
|
90
|
+
{failure}
|
|
91
|
+
{art.map((l) => (
|
|
92
|
+
<Line key={l} width={props.width} parts={[{ text: ` ${l}`, tone: "muted" }]} />
|
|
93
|
+
))}
|
|
94
|
+
<Line
|
|
95
|
+
width={props.width}
|
|
96
|
+
parts={[
|
|
97
|
+
{ text: " No runs yet. Start one in Claude Code: /catherd <what to build>", tone: "muted" },
|
|
98
|
+
]}
|
|
99
|
+
/>
|
|
100
|
+
</box>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return (
|
|
104
|
+
<box flexDirection="column" width={props.width} height={props.height}>
|
|
105
|
+
<Line
|
|
106
|
+
width={props.width}
|
|
107
|
+
parts={[
|
|
108
|
+
{ text: " RUNS", bold: true },
|
|
109
|
+
{ text: ` ${updated}`, tone: app.state.paused ? "warning" : "muted" },
|
|
110
|
+
]}
|
|
111
|
+
/>
|
|
112
|
+
{failure}
|
|
113
|
+
<List
|
|
114
|
+
items={items}
|
|
115
|
+
selected={selected}
|
|
116
|
+
onSelect={setSelected}
|
|
117
|
+
width={props.width}
|
|
118
|
+
height={props.height - 1 - (failed ? 1 : 0) - Math.min(2, warnings.length)}
|
|
119
|
+
filter={null}
|
|
120
|
+
empty={failed ? "no runs read yet" : "reading runs…"}
|
|
121
|
+
/>
|
|
122
|
+
{warnings.slice(0, 2).map((w) => (
|
|
123
|
+
<Line
|
|
124
|
+
key={w}
|
|
125
|
+
width={props.width}
|
|
126
|
+
parts={[{ text: ` ${glyph("warn", ui.plain)} ${w}`, tone: "warning" }]}
|
|
127
|
+
/>
|
|
128
|
+
))}
|
|
129
|
+
</box>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function RunView(props: { id: string; width: number; height: number }) {
|
|
134
|
+
const app = useApp();
|
|
135
|
+
const ui = useUi();
|
|
136
|
+
const now = useNow(1_000);
|
|
137
|
+
const [selected, setSelected, selectedNow] = useSelection();
|
|
138
|
+
const polled = usePoll(() => app.effects.run(props.id), RUN_EVERY_MS, {
|
|
139
|
+
paused: app.state.paused,
|
|
140
|
+
key: props.id,
|
|
141
|
+
});
|
|
142
|
+
useBack(true, "view", () => app.dispatch({ type: "run", id: null }));
|
|
143
|
+
const d = polled.value;
|
|
144
|
+
const laneOf = (key: string | null) => (key?.startsWith("lane:") ? key.slice("lane:".length) : null);
|
|
145
|
+
useCommandLayer("row.runs", {
|
|
146
|
+
"runs.cancel": () => {
|
|
147
|
+
const lane = laneOf(selectedNow());
|
|
148
|
+
if (!lane) return;
|
|
149
|
+
const t = app.clock.now();
|
|
150
|
+
if (!isArmed(app.getState(), "cancel", lane, t))
|
|
151
|
+
return app.dispatch({ type: "arm", what: "cancel", target: lane, at: t });
|
|
152
|
+
app.dispatch({ type: "disarm" });
|
|
153
|
+
void app.effects.cancel(props.id, lane).then(
|
|
154
|
+
(msg) => {
|
|
155
|
+
app.toast({ variant: "success", message: msg });
|
|
156
|
+
polled.refresh();
|
|
157
|
+
},
|
|
158
|
+
(e: unknown) => app.toast(errorToast(e)),
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
if (!d)
|
|
163
|
+
return (
|
|
164
|
+
<Line
|
|
165
|
+
width={props.width}
|
|
166
|
+
parts={[
|
|
167
|
+
{
|
|
168
|
+
text: polled.error ? ` ${polled.error}` : " reading the run…",
|
|
169
|
+
tone: polled.error ? "error" : "muted",
|
|
170
|
+
},
|
|
171
|
+
]}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
const s = d.summary;
|
|
175
|
+
const items: ListItem[] = [];
|
|
176
|
+
const text = (key: string, parts: Part[]) =>
|
|
177
|
+
items.push({ key, selectable: false, render: (_sel, w) => <Line width={w} parts={parts} /> });
|
|
178
|
+
const heading = (key: string, label: string) => text(key, [{ text: ` ${label}`, bold: true }]);
|
|
179
|
+
heading("h:live", "LIVE");
|
|
180
|
+
if (s.live.length === 0) text("live:none", [{ text: " no role is running", tone: "muted" }]);
|
|
181
|
+
for (const l of s.live) {
|
|
182
|
+
const armed = isArmed(app.state, "cancel", l.name, app.clock.now());
|
|
183
|
+
items.push({
|
|
184
|
+
key: `lane:${l.name}`,
|
|
185
|
+
selectable: true,
|
|
186
|
+
render: (sel, w) => (
|
|
187
|
+
<Line
|
|
188
|
+
width={w}
|
|
189
|
+
selected={sel}
|
|
190
|
+
parts={[
|
|
191
|
+
{ text: ` ${glyph(l.state === "running" ? "live" : "waiting", ui.plain)} `, tone: "info" },
|
|
192
|
+
{ text: l.name.padEnd(16), bold: true },
|
|
193
|
+
{ text: shortRung(l.rung).padEnd(20) },
|
|
194
|
+
{ text: clock(l.secs).padEnd(8) },
|
|
195
|
+
armed
|
|
196
|
+
? { text: "press ctrl+d again to cancel", tone: "warning" }
|
|
197
|
+
: { text: l.state, tone: "muted" },
|
|
198
|
+
]}
|
|
199
|
+
/>
|
|
200
|
+
),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (d.climbs.length) heading("h:climbs", "CLIMBS");
|
|
204
|
+
d.climbs.forEach((c, i) =>
|
|
205
|
+
text(`climb:${i}`, [
|
|
206
|
+
{ text: ` ${c.lane.padEnd(8)} ` },
|
|
207
|
+
{ text: `${shortRung(c.from)} ${glyph("arrow", ui.plain)} ${shortRung(c.to)}` },
|
|
208
|
+
{ text: ` ${c.reason}${c.env ? " (environment)" : ""}`, tone: "muted" },
|
|
209
|
+
]),
|
|
210
|
+
);
|
|
211
|
+
if (d.decisions.length) heading("h:routes", "ROUTES");
|
|
212
|
+
d.decisions.forEach((r, i) =>
|
|
213
|
+
text(`route:${i}`, [
|
|
214
|
+
{ text: ` ${r.lane.padEnd(8)} ${r.role.padEnd(10)}` },
|
|
215
|
+
{ text: `${r.source.padEnd(8)}`, tone: r.source === "jev" ? "info" : "muted" },
|
|
216
|
+
{ text: `${r.kind ?? "-"}/${r.difficulty ?? "-"} `, tone: "muted" },
|
|
217
|
+
{ text: shortRung(r.rung) },
|
|
218
|
+
]),
|
|
219
|
+
);
|
|
220
|
+
if (s.milestones.length) heading("h:landed", "LANDED");
|
|
221
|
+
s.milestones.forEach((m, i) =>
|
|
222
|
+
text(`landed:${i}`, [{ text: ` ${glyph("ok", ui.plain)} `, tone: "success" }, { text: m }]),
|
|
223
|
+
);
|
|
224
|
+
if (s.stateTail.length) heading("h:next", "STATE");
|
|
225
|
+
s.stateTail.forEach((l, i) => text(`state:${i}`, [{ text: ` ${l}`, tone: "muted" }]));
|
|
226
|
+
const updated = app.state.paused ? "paused" : polled.at !== null ? `updated ${ago(now - polled.at)}` : "";
|
|
227
|
+
const t = s.totals;
|
|
228
|
+
return (
|
|
229
|
+
<box flexDirection="column" width={props.width} height={props.height}>
|
|
230
|
+
<Line
|
|
231
|
+
width={props.width}
|
|
232
|
+
parts={[
|
|
233
|
+
{ text: ` ${s.title}`, bold: true },
|
|
234
|
+
{ text: ` ${s.repo} · started ${ago(now - Date.parse(s.createdAt))} · `, tone: "muted" },
|
|
235
|
+
{ text: updated, tone: app.state.paused ? "warning" : "muted" },
|
|
236
|
+
{
|
|
237
|
+
text: polled.error ? ` · ${glyph("fail", ui.plain)} could not read the run: ${polled.error}` : "",
|
|
238
|
+
tone: "error",
|
|
239
|
+
},
|
|
240
|
+
]}
|
|
241
|
+
/>
|
|
242
|
+
<Line width={props.width} parts={[{ text: " budget " }, ...budgetParts(d, props.width, ui.plain)]} />
|
|
243
|
+
<Line
|
|
244
|
+
width={props.width}
|
|
245
|
+
parts={[
|
|
246
|
+
{
|
|
247
|
+
text: ` ${t.runs} role runs, ${t.ok} ok · ${(t.tokens.input / 1000).toFixed(0)}k in · ${(t.tokens.output / 1000).toFixed(0)}k out · $${t.costUsd.toFixed(2)}`,
|
|
248
|
+
tone: "muted",
|
|
249
|
+
},
|
|
250
|
+
]}
|
|
251
|
+
/>
|
|
252
|
+
<List
|
|
253
|
+
items={items}
|
|
254
|
+
selected={selected}
|
|
255
|
+
onSelect={setSelected}
|
|
256
|
+
width={props.width}
|
|
257
|
+
height={props.height - 3}
|
|
258
|
+
filter={null}
|
|
259
|
+
empty=""
|
|
260
|
+
/>
|
|
261
|
+
</box>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Spec §9.1 tab 3: the run list; enter opens a run (live lanes, climbs, routes, budget, landed); p pauses. */
|
|
266
|
+
export function RunsView(props: { width: number; height: number }) {
|
|
267
|
+
const app = useApp();
|
|
268
|
+
const data = useData();
|
|
269
|
+
useCommandLayer("tab.runs", {
|
|
270
|
+
"runs.refresh": () => data.runs.refresh(),
|
|
271
|
+
"runs.pause": () => {
|
|
272
|
+
app.dispatch({ type: "pause" });
|
|
273
|
+
app.toast({ variant: "info", message: app.getState().paused ? "Updates paused" : "Updates resumed" });
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
return app.state.run ? (
|
|
277
|
+
<RunView id={app.state.run} width={props.width} height={props.height} />
|
|
278
|
+
) : (
|
|
279
|
+
<RunList width={props.width} height={props.height} />
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
2
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { errorMessage } from "../../../domain/errors.ts";
|
|
4
|
+
import {
|
|
5
|
+
applyPatch,
|
|
6
|
+
type Change,
|
|
7
|
+
diffProfiles,
|
|
8
|
+
type ProfileDoc,
|
|
9
|
+
patchBetween,
|
|
10
|
+
resolveProfile,
|
|
11
|
+
} from "../../../domain/profile.ts";
|
|
12
|
+
import type { Validation } from "../../../domain/profile-rules.ts";
|
|
13
|
+
import type { Effects } from "../effects.ts";
|
|
14
|
+
import { useApp } from "../providers/app.tsx";
|
|
15
|
+
import { useData, useLoad } from "../providers/data.tsx";
|
|
16
|
+
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
17
|
+
import { useUi } from "../providers/theme.tsx";
|
|
18
|
+
import { withStaged } from "../profile-tree.ts";
|
|
19
|
+
import { type Dialog as DialogState, type Draft, isSaving } from "../state.ts";
|
|
20
|
+
import { wrap } from "../text.ts";
|
|
21
|
+
import { glyph } from "../theme.ts";
|
|
22
|
+
import { Buttons } from "../widgets/dialog-confirm.tsx";
|
|
23
|
+
import { Dialog, dialogRows } from "../widgets/dialog.tsx";
|
|
24
|
+
import { Line, type Part } from "../widgets/line.tsx";
|
|
25
|
+
|
|
26
|
+
type Save = Extract<DialogState, { kind: "save" }>;
|
|
27
|
+
|
|
28
|
+
/** Said when a Save found the file changed since the dialog showed what saving would do. */
|
|
29
|
+
const CHANGED_ON_DISK = "the profile changed on disk — check the changes and choose again";
|
|
30
|
+
|
|
31
|
+
const show = (v: unknown): string =>
|
|
32
|
+
v === null || v === undefined ? "none" : Array.isArray(v) ? v.join(", ") : String(v);
|
|
33
|
+
|
|
34
|
+
/** What saving a draft would do: the changed fields, staged treat-likes, issues, and agent changes. */
|
|
35
|
+
export interface SavePreview {
|
|
36
|
+
changes: Change[];
|
|
37
|
+
treatLikes: [string, string][];
|
|
38
|
+
validation: Validation;
|
|
39
|
+
agentsAdded: string[];
|
|
40
|
+
agentsRemoved: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The save is a patch over the file as it is when it is written (Ruling 7), so the preview applies the
|
|
45
|
+
* draft's patch to the file as it is now, not to the copy the draft began from.
|
|
46
|
+
*/
|
|
47
|
+
function previewSave(
|
|
48
|
+
d: Draft,
|
|
49
|
+
fx: Pick<Effects, "catalog" | "validate" | "agents" | "readProfile">,
|
|
50
|
+
now: ProfileDoc = fx.readProfile(d.name),
|
|
51
|
+
): SavePreview {
|
|
52
|
+
const before = resolveProfile(now, d.name);
|
|
53
|
+
const after = resolveProfile(applyPatch(now, patchBetween(d.base, d.doc)), d.name);
|
|
54
|
+
const { catalog } = fx.catalog(after.billing);
|
|
55
|
+
const a = new Set(fx.agents(before));
|
|
56
|
+
const b = new Set(fx.agents(after));
|
|
57
|
+
return {
|
|
58
|
+
changes: diffProfiles(before, after),
|
|
59
|
+
treatLikes: Object.entries(d.treatLikes),
|
|
60
|
+
validation: fx.validate(after, withStaged(catalog, d.treatLikes)),
|
|
61
|
+
agentsAdded: [...b].filter((x) => !a.has(x)),
|
|
62
|
+
agentsRemoved: [...a].filter((x) => !b.has(x)),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A block of the save dialog: heading lines, then items of one or more lines each. */
|
|
67
|
+
interface Section {
|
|
68
|
+
head: Part[][];
|
|
69
|
+
items: Part[][][];
|
|
70
|
+
/**
|
|
71
|
+
* How long the section holds on when rows run short (spec §9.2, Ruling 8):
|
|
72
|
+
* "filler" (changes, warnings, agent files) loses items first, then goes whole;
|
|
73
|
+
* "note" (what applies when) goes whole, never in part;
|
|
74
|
+
* "error" (validation errors) loses items only after both, and keeps its first;
|
|
75
|
+
* "failure" (why the last save failed) is never cut.
|
|
76
|
+
*/
|
|
77
|
+
hold: "filler" | "note" | "error" | "failure";
|
|
78
|
+
/** the line that ends a cut section; "… and N more" when unset */
|
|
79
|
+
more?: (n: number) => string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const HOLD = { filler: 0, note: 1, error: 2, failure: 3 } as const;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The sections' lines in at most `rows` lines. Empty sections are left out. Over budget, in turn:
|
|
86
|
+
* the longest filler section loses its last item (a cut section ends with one muted "… and N more"
|
|
87
|
+
* line); filler and note sections go whole, the least-held first; error sections lose items down
|
|
88
|
+
* to their first; last, lines come off the end of every section but the failure, then the failure.
|
|
89
|
+
*/
|
|
90
|
+
export function fitSections(sections: Section[], rows: number): Part[][] {
|
|
91
|
+
const shown = sections.filter((s) => s.items.length > 0);
|
|
92
|
+
const keep = shown.map((s) => s.items.length);
|
|
93
|
+
const gone = shown.map(() => false);
|
|
94
|
+
const size = (i: number) => {
|
|
95
|
+
if (gone[i]) return 0;
|
|
96
|
+
const s = shown[i]!;
|
|
97
|
+
const body = s.items.slice(0, keep[i]).reduce((n, it) => n + it.length, 0);
|
|
98
|
+
return s.head.length + body + (keep[i]! < s.items.length ? 1 : 0);
|
|
99
|
+
};
|
|
100
|
+
const total = () => shown.reduce((n, _, i) => n + size(i), 0);
|
|
101
|
+
/** the largest live section `ok` accepts, or -1 */
|
|
102
|
+
const largest = (ok: (s: Section, i: number) => boolean) => {
|
|
103
|
+
let at = -1;
|
|
104
|
+
for (const [i, s] of shown.entries()) if (!gone[i] && ok(s, i) && (at < 0 || size(i) > size(at))) at = i;
|
|
105
|
+
return at;
|
|
106
|
+
};
|
|
107
|
+
for (let at; total() > rows && (at = largest((s, i) => s.hold === "filler" && keep[i]! > 0)) >= 0;)
|
|
108
|
+
keep[at]! -= 1;
|
|
109
|
+
for (const hold of ["filler", "note"] as const)
|
|
110
|
+
for (let at; total() > rows && (at = largest((s) => s.hold === hold)) >= 0;) gone[at] = true;
|
|
111
|
+
for (let at; total() > rows && (at = largest((s, i) => s.hold === "error" && keep[i]! > 1)) >= 0;)
|
|
112
|
+
keep[at]! -= 1;
|
|
113
|
+
const lines = shown.map((s, i) => {
|
|
114
|
+
if (gone[i]) return [];
|
|
115
|
+
const more = s.items.length - keep[i]!;
|
|
116
|
+
const tail = s.more?.(more) ?? `… and ${more} more`;
|
|
117
|
+
return [
|
|
118
|
+
...s.head,
|
|
119
|
+
...s.items.slice(0, keep[i]).flat(),
|
|
120
|
+
...(more > 0 ? [[{ text: tail, tone: "muted" as const }]] : []),
|
|
121
|
+
];
|
|
122
|
+
});
|
|
123
|
+
// still over: the blank lines between sections go, then lines come off the end of the least-held
|
|
124
|
+
// sections, the failure last
|
|
125
|
+
let over = lines.reduce((n, l) => n + l.length, 0) - Math.max(0, rows);
|
|
126
|
+
const order = shown.map((_, i) => i).sort((x, y) => HOLD[shown[x]!.hold] - HOLD[shown[y]!.hold]);
|
|
127
|
+
for (const i of order)
|
|
128
|
+
while (over > 0 && lines[i]![0]?.length === 0 && shown[i]!.head.length > 0 && !gone[i]) {
|
|
129
|
+
lines[i]!.shift();
|
|
130
|
+
over -= 1;
|
|
131
|
+
}
|
|
132
|
+
for (const i of order) {
|
|
133
|
+
const off = Math.min(over, lines[i]!.length);
|
|
134
|
+
lines[i]!.splice(lines[i]!.length - off, off);
|
|
135
|
+
over -= off;
|
|
136
|
+
}
|
|
137
|
+
return lines.flat();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Spec §9.2: ctrl+s opens this, never enter. It shows the diff, the agent-file changes and what applies
|
|
142
|
+
* when, with Save, Save & make active and Cancel; errors leave only Cancel.
|
|
143
|
+
*/
|
|
144
|
+
export function SaveDialog(props: { dialog: Save }) {
|
|
145
|
+
const app = useApp();
|
|
146
|
+
const data = useData();
|
|
147
|
+
const ui = useUi();
|
|
148
|
+
const dims = useTerminalDimensions();
|
|
149
|
+
const renderer = useRenderer();
|
|
150
|
+
const name = props.dialog.purpose.name;
|
|
151
|
+
const draft = app.state.drafts[name];
|
|
152
|
+
// the preview reads the catalog and agent files: off the render path, so a read that throws is shown
|
|
153
|
+
// here as the reason nothing can be saved, not as a broken screen
|
|
154
|
+
const key = draft ? JSON.stringify([draft.doc, draft.treatLikes]) : "";
|
|
155
|
+
const loaded = useLoad(() => (draft ? previewSave(draft, app.effects) : null), key);
|
|
156
|
+
// a Save re-reads the file: when that changed what saving would do, the dialog shows the new preview
|
|
157
|
+
// in place of the loaded one and asks again (another process may have written the profile meanwhile)
|
|
158
|
+
const [fresh, setFresh] = useState<{ key: string; value: SavePreview | null; error: string | null } | null>(
|
|
159
|
+
null,
|
|
160
|
+
);
|
|
161
|
+
const shown = fresh?.key === key ? fresh : loaded;
|
|
162
|
+
const preview = shown.value;
|
|
163
|
+
const error = shown.error;
|
|
164
|
+
// what the dialog shows now, for keys in one burst
|
|
165
|
+
const shownRef = useRef<SavePreview | null>(preview);
|
|
166
|
+
shownRef.current = preview;
|
|
167
|
+
// set when a Save replaces the preview, cleared by the first frame drawn to the terminal after the
|
|
168
|
+
// commit that holds the new one: a Save between the two (a later key in the same burst) would answer a
|
|
169
|
+
// preview nobody has seen. React may commit between two keys of one burst, so a commit is not enough.
|
|
170
|
+
const unseenRef = useRef(false);
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (!unseenRef.current) return;
|
|
173
|
+
const seen = () => {
|
|
174
|
+
unseenRef.current = false;
|
|
175
|
+
};
|
|
176
|
+
renderer.once("frame", seen);
|
|
177
|
+
renderer.requestRender();
|
|
178
|
+
return () => {
|
|
179
|
+
renderer.off("frame", seen);
|
|
180
|
+
};
|
|
181
|
+
}, [fresh, renderer]);
|
|
182
|
+
const [changed, setChanged] = useState(false);
|
|
183
|
+
/** reads the file again; a preview other than the shown one replaces it and asks again (false) */
|
|
184
|
+
const recheck = (): { same: boolean; now: ProfileDoc | null } => {
|
|
185
|
+
const d = app.getState().drafts[name];
|
|
186
|
+
let doc: ProfileDoc | null = null;
|
|
187
|
+
let now: SavePreview | null = null;
|
|
188
|
+
let failed: string | null = null;
|
|
189
|
+
try {
|
|
190
|
+
doc = d ? app.effects.readProfile(name) : null;
|
|
191
|
+
now = d && doc ? previewSave(d, app.effects, doc) : null;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
failed = errorMessage(e);
|
|
194
|
+
}
|
|
195
|
+
if (failed === null && JSON.stringify(now) === JSON.stringify(shownRef.current))
|
|
196
|
+
return { same: true, now: doc };
|
|
197
|
+
shownRef.current = now;
|
|
198
|
+
unseenRef.current = true;
|
|
199
|
+
setFresh({ key, value: now, error: failed });
|
|
200
|
+
setChanged(true);
|
|
201
|
+
return { same: false, now: doc };
|
|
202
|
+
};
|
|
203
|
+
const blocked = error !== null || (preview?.validation.errors.length ?? 0) > 0;
|
|
204
|
+
// spec §9.2's order: Save / Save & make active / Cancel; errors leave only Cancel
|
|
205
|
+
const labels = blocked ? ["Cancel"] : ["Save", "Save & make active", "Cancel"];
|
|
206
|
+
const [focused, setFocusedState] = useState(0);
|
|
207
|
+
// keys in one burst read the focus as it is now, not as it was drawn
|
|
208
|
+
const focusedRef = useRef(0);
|
|
209
|
+
const setFocused = (f: number) => {
|
|
210
|
+
focusedRef.current = f;
|
|
211
|
+
setFocusedState(f);
|
|
212
|
+
};
|
|
213
|
+
useCommandLayer("dialog", {
|
|
214
|
+
"dialog.left": () => setFocused(Math.max(0, focusedRef.current - 1)),
|
|
215
|
+
"dialog.right": () => setFocused(Math.min(labels.length - 1, focusedRef.current + 1)),
|
|
216
|
+
"dialog.submit": () => {
|
|
217
|
+
// a save that is writing takes no answer
|
|
218
|
+
if (isSaving(app.getState())) return;
|
|
219
|
+
const label = labels[Math.min(focusedRef.current, labels.length - 1)];
|
|
220
|
+
// nothing is saved before its diff has been shown
|
|
221
|
+
if (label !== "Cancel" && (!shownRef.current || unseenRef.current)) return;
|
|
222
|
+
if (label === "Cancel") return app.dispatch({ type: "close" });
|
|
223
|
+
// only the save the dialog shows is made: the file is read again now, and a different preview
|
|
224
|
+
// replaces the shown one and asks again instead of saving
|
|
225
|
+
const { same, now } = recheck();
|
|
226
|
+
if (!same || !now) return;
|
|
227
|
+
// the save writes only over the file this preview read: one changed while it writes (another
|
|
228
|
+
// process, while a treat-like waits for the catalog lock) is refused, and the dialog asks again
|
|
229
|
+
const extra = {
|
|
230
|
+
shown: now,
|
|
231
|
+
// the notice shows even when the change is one this preview does not show (another field)
|
|
232
|
+
changed: () => {
|
|
233
|
+
if (recheck().same) setChanged(true);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
app.answer(label === "Save" ? "save" : "activate", extra);
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
const rows = (inner: number): ReactNode[] => {
|
|
240
|
+
const out: ReactNode[] = [];
|
|
241
|
+
const line = (key: string, parts: Part[]) => out.push(<Line key={key} width={inner} parts={parts} />);
|
|
242
|
+
if (error !== null) {
|
|
243
|
+
wrap(`Cannot show what saving would do: ${error}`, inner).forEach((t, i) =>
|
|
244
|
+
line(`fail${i}`, [{ text: t, tone: "error" }]),
|
|
245
|
+
);
|
|
246
|
+
line("gap", []);
|
|
247
|
+
out.push(<Buttons key="buttons" labels={labels} focused={0} width={inner} />);
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
if (!preview) {
|
|
251
|
+
line("gone", [{ text: draft ? "reading…" : "Nothing to save.", tone: "muted" }]);
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
const repo = data.profiles.value?.repo;
|
|
255
|
+
const text = (t: string, tone: Part["tone"]): Part[][] => wrap(t, inner).map((l) => [{ text: l, tone }]);
|
|
256
|
+
const sections: Section[] = [
|
|
257
|
+
{
|
|
258
|
+
head: [],
|
|
259
|
+
hold: "filler",
|
|
260
|
+
items: [
|
|
261
|
+
...preview.changes.map((c): Part[][] => [
|
|
262
|
+
[
|
|
263
|
+
{ text: `${c.path} ` },
|
|
264
|
+
{ text: show(c.before), tone: "diffDel" },
|
|
265
|
+
{ text: ` ${glyph("arrow", ui.plain)} ` },
|
|
266
|
+
{ text: show(c.after), tone: "diffAdd" },
|
|
267
|
+
],
|
|
268
|
+
]),
|
|
269
|
+
...preview.treatLikes.map(([rung, like]): Part[][] => [
|
|
270
|
+
[{ text: `treat ${rung} like ` }, { text: like, tone: "diffAdd" }],
|
|
271
|
+
]),
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
head: [[]],
|
|
276
|
+
hold: "error",
|
|
277
|
+
more: (n) => `… and ${n} more ${n === 1 ? "error" : "errors"}`,
|
|
278
|
+
items: preview.validation.errors.map((e) =>
|
|
279
|
+
text(`${glyph("fail", ui.plain)} ${e.path}: ${e.message}`, "error"),
|
|
280
|
+
),
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
// warnings follow the errors in one block
|
|
284
|
+
head: preview.validation.errors.length ? [] : [[]],
|
|
285
|
+
hold: "filler",
|
|
286
|
+
more: (n) => `… and ${n} more ${n === 1 ? "warning" : "warnings"}`,
|
|
287
|
+
items: preview.validation.warnings.map((w) =>
|
|
288
|
+
text(`${glyph("warn", ui.plain)} ${w.path}: ${w.message}`, "warning"),
|
|
289
|
+
),
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
head: [[], [{ text: "Agent files", bold: true }]],
|
|
293
|
+
hold: "filler",
|
|
294
|
+
items: [
|
|
295
|
+
...preview.agentsAdded.map((a): Part[][] => [[{ text: `+ ${a}`, tone: "diffAdd" }]]),
|
|
296
|
+
...preview.agentsRemoved.map((a): Part[][] => [[{ text: `- ${a}`, tone: "diffDel" }]]),
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
head: [[]],
|
|
301
|
+
hold: "note",
|
|
302
|
+
items: [
|
|
303
|
+
text(
|
|
304
|
+
"Applies to: native Claude agents in new Claude Code sessions; codex, claude-code and opencode from the next dispatch.",
|
|
305
|
+
"muted",
|
|
306
|
+
),
|
|
307
|
+
// inside a bound repo, making a profile active binds the repo to it (the button keeps §9.2's label)
|
|
308
|
+
...(repo ? [text(`Save & make active binds ${repo} to ${name}.`, "muted")] : []),
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
{ head: [[]], hold: "failure", items: props.dialog.error ? [text(props.dialog.error, "error")] : [] },
|
|
312
|
+
];
|
|
313
|
+
// the gap and the buttons always fit: every section shares what is left of the panel
|
|
314
|
+
const notice =
|
|
315
|
+
changed && !props.dialog.saving ? wrap(`${glyph("warn", ui.plain)} ${CHANGED_ON_DISK}`, inner) : [];
|
|
316
|
+
fitSections(sections, dialogRows(dims.height) - 2 - notice.length).forEach((p, i) => line(`r${i}`, p));
|
|
317
|
+
line("gap5", []);
|
|
318
|
+
notice.forEach((t, i) => line(`changed${i}`, [{ text: t, tone: "warning" }]));
|
|
319
|
+
if (props.dialog.saving) line("saving", [{ text: "saving…", tone: "muted" }]);
|
|
320
|
+
else
|
|
321
|
+
out.push(
|
|
322
|
+
<Buttons
|
|
323
|
+
key="buttons"
|
|
324
|
+
labels={labels}
|
|
325
|
+
focused={Math.min(focused, labels.length - 1)}
|
|
326
|
+
width={inner}
|
|
327
|
+
/>,
|
|
328
|
+
);
|
|
329
|
+
return out;
|
|
330
|
+
};
|
|
331
|
+
return <Dialog title={`Save profile ${name}`} size="large" rows={rows} />;
|
|
332
|
+
}
|