catherd-cli 0.1.0 → 0.2.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/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/skills/catherd/SKILL.md +1 -1
- package/src/profile/agents.ts +12 -0
- package/src/profile/role-prompts.ts +1 -1
- package/src/routing/jev.ts +9 -5
- package/src/tui/commands.ts +5 -3
- package/src/tui/dashboard.tsx +245 -0
- package/src/tui/editor.tsx +20 -27
- package/src/tui/init.tsx +25 -38
- package/src/tui/matrix-model.ts +102 -34
- package/src/tui/matrix.tsx +288 -126
- package/src/tui/theme.ts +41 -4
- package/src/tui/ui.tsx +187 -24
- package/src/tui/watch.tsx +84 -106
package/src/tui/matrix.tsx
CHANGED
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core";
|
|
2
|
-
import { useKeyboard } from "@opentui/react";
|
|
2
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
3
4
|
import { useState } from "react";
|
|
4
5
|
import type { HarnessCost } from "../core/harness.ts";
|
|
5
6
|
import { entryFor, saveTreatLike } from "../routing/catalog.ts";
|
|
6
|
-
import {
|
|
7
|
+
import { candidates } from "../routing/select.ts";
|
|
8
|
+
import { type Backend, type Catalog, type Profile, type RungId, rungOf, splitRung } from "../types.ts";
|
|
7
9
|
import type { BackendStatus } from "./backends.ts";
|
|
8
10
|
import {
|
|
11
|
+
budgetDetailRows,
|
|
9
12
|
type BudgetField,
|
|
13
|
+
budgetSummary,
|
|
14
|
+
type DetailRow,
|
|
15
|
+
failoverDetailRows,
|
|
10
16
|
failoverOptions,
|
|
17
|
+
failoverSummary,
|
|
11
18
|
isTicked,
|
|
12
19
|
openKey,
|
|
13
20
|
readySet,
|
|
14
21
|
type Row,
|
|
15
22
|
rowId,
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
roleDetailRows,
|
|
24
|
+
sectionOf,
|
|
18
25
|
toggle,
|
|
26
|
+
topLevelRows,
|
|
27
|
+
type TopRow,
|
|
19
28
|
treatLikeOptions,
|
|
20
29
|
} from "./matrix-model.ts";
|
|
21
|
-
import {
|
|
30
|
+
import { dot, glyph, harnessLine, shortRung, tint, type Ui } from "./theme.ts";
|
|
31
|
+
import { DetailPane, ListLine } from "./ui.tsx";
|
|
22
32
|
|
|
23
33
|
export interface MatrixProps {
|
|
24
34
|
ui: Ui;
|
|
@@ -26,6 +36,7 @@ export interface MatrixProps {
|
|
|
26
36
|
catalog: Catalog;
|
|
27
37
|
backends: BackendStatus[];
|
|
28
38
|
costs?: HarnessCost[];
|
|
39
|
+
errors?: string[];
|
|
29
40
|
reloadCatalog: () => Catalog;
|
|
30
41
|
onChange: (p: Profile) => void;
|
|
31
42
|
onCatalog: (c: Catalog) => void;
|
|
@@ -33,11 +44,21 @@ export interface MatrixProps {
|
|
|
33
44
|
onQuit: () => void;
|
|
34
45
|
onKey?: (input: string) => void;
|
|
35
46
|
active?: boolean;
|
|
36
|
-
|
|
37
|
-
|
|
47
|
+
/** Controlled "?" help toggle, so the caller's `Frame` can render the hint bar it computes
|
|
48
|
+
* with `matrixHint` outside the panel border instead of Matrix drawing its own. */
|
|
49
|
+
onToggleHelp?: () => void;
|
|
38
50
|
}
|
|
39
51
|
|
|
40
|
-
|
|
52
|
+
/** The hint bar text for a Matrix at the given help state — exported so the caller's `Frame`
|
|
53
|
+
* can render it outside the panel border while Matrix owns none of the layout itself. */
|
|
54
|
+
export function matrixHint(ui: Ui, submitLabel: string, showHelp: boolean, extraHint = ""): string {
|
|
55
|
+
const dotSep = ` ${glyph("dot", ui.plain)} `;
|
|
56
|
+
return showHelp
|
|
57
|
+
? `/ filter${dotSep}${extraHint}`
|
|
58
|
+
: `${glyph("keys", ui.plain)} navigate${dotSep}${glyph("arrow", ui.plain)} open${dotSep}space ticks${dotSep}enter ${submitLabel}${dotSep}? more${dotSep}q quit`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type EffortRow = Extract<DetailRow, { kind: "effort" }>;
|
|
41
62
|
type Mode =
|
|
42
63
|
| "nav"
|
|
43
64
|
| "filter"
|
|
@@ -45,28 +66,48 @@ type Mode =
|
|
|
45
66
|
| { failover: RungId }
|
|
46
67
|
| { budgetField: BudgetField; buffer: string };
|
|
47
68
|
|
|
69
|
+
/** "sol", "luna", "opus 5.5" — the short display name the right pane uses for a model id
|
|
70
|
+
* (the full id still shows in the effort rows below it). */
|
|
71
|
+
export function shortModelName(id: string): string {
|
|
72
|
+
const base = id.replace(/^.*\//, "").replace(/^gpt-\d+-/, "");
|
|
73
|
+
return base.replace(/^claude-([a-z]+)-(\d+)-(\d+)$/, "$1 $2.$3");
|
|
74
|
+
}
|
|
75
|
+
|
|
48
76
|
export function Matrix(props: MatrixProps) {
|
|
49
|
-
const {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
costs = [],
|
|
55
|
-
active = true,
|
|
56
|
-
height = 14,
|
|
57
|
-
submitLabel = "save",
|
|
58
|
-
} = props;
|
|
59
|
-
const [cursor, setCursor] = useState(0);
|
|
77
|
+
const { ui, profile, catalog, backends, costs = [], errors = [], active = true } = props;
|
|
78
|
+
const { height: termHeight } = useTerminalDimensions();
|
|
79
|
+
const [leftCursor, setLeftCursor] = useState(0);
|
|
80
|
+
const [focus, setFocus] = useState<"left" | "right">("left");
|
|
81
|
+
const [rightCursor, setRightCursor] = useState(0);
|
|
60
82
|
const [open, setOpen] = useState<ReadonlySet<string>>(new Set());
|
|
61
83
|
const [filter, setFilter] = useState("");
|
|
62
84
|
const [mode, setMode] = useState<Mode>("nav");
|
|
63
85
|
const [note, setNote] = useState("");
|
|
86
|
+
// "?" only does anything when the caller controls help (renders it outside the panel via
|
|
87
|
+
// `matrixHint`) — Matrix itself draws no help text, so there is nothing to toggle otherwise.
|
|
88
|
+
const toggleHelp = () => props.onToggleHelp?.();
|
|
64
89
|
const ready = readySet(backends);
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
90
|
+
const dotSep = ` ${glyph("dot", ui.plain)} `;
|
|
91
|
+
|
|
92
|
+
const top = topLevelRows();
|
|
93
|
+
const selTop = top[Math.min(leftCursor, top.length - 1)] as TopRow;
|
|
94
|
+
const detail: DetailRow[] =
|
|
95
|
+
selTop.kind === "role"
|
|
96
|
+
? roleDetailRows(profile, catalog, selTop.role, open, filter)
|
|
97
|
+
: selTop.kind === "budget-summary"
|
|
98
|
+
? budgetDetailRows()
|
|
99
|
+
: selTop.kind === "failover-summary"
|
|
100
|
+
? failoverDetailRows(profile, catalog)
|
|
101
|
+
: [];
|
|
102
|
+
const rightAt = Math.min(rightCursor, Math.max(0, detail.length - 1));
|
|
103
|
+
const hereRight = detail[rightAt];
|
|
104
|
+
|
|
105
|
+
const moveLeft = (idx: number) => {
|
|
106
|
+
setLeftCursor(Math.max(0, Math.min(top.length - 1, idx)));
|
|
107
|
+
setFocus("left");
|
|
108
|
+
setRightCursor(0);
|
|
109
|
+
setNote("");
|
|
110
|
+
};
|
|
70
111
|
|
|
71
112
|
const apply = (row: Row, c: Catalog) => {
|
|
72
113
|
const t = toggle(profile, c, row, ready);
|
|
@@ -82,24 +123,29 @@ export function Matrix(props: MatrixProps) {
|
|
|
82
123
|
}
|
|
83
124
|
};
|
|
84
125
|
|
|
85
|
-
const collapse = (
|
|
86
|
-
const
|
|
87
|
-
const key =
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
126
|
+
const collapse = () => {
|
|
127
|
+
const row = hereRight;
|
|
128
|
+
const key = row && (row.kind === "model" || row.kind === "effort") ? openKey(row) : null;
|
|
129
|
+
if (key && row && (row.kind === "effort" || open.has(key))) {
|
|
130
|
+
const next = new Set(open);
|
|
131
|
+
next.delete(key);
|
|
132
|
+
setOpen(next);
|
|
133
|
+
if (row.kind === "effort" && selTop.kind === "role") {
|
|
134
|
+
const list = roleDetailRows(profile, catalog, selTop.role, next, filter);
|
|
135
|
+
setRightCursor(
|
|
136
|
+
Math.max(
|
|
137
|
+
0,
|
|
138
|
+
list.findIndex((r) => r.kind === "model" && openKey(r) === key),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
setFocus("left");
|
|
99
145
|
};
|
|
100
146
|
|
|
101
147
|
useKeyboard((key) => {
|
|
102
|
-
if (!active
|
|
148
|
+
if (!active) return;
|
|
103
149
|
if (typeof mode !== "string") {
|
|
104
150
|
if (key.name === "escape") {
|
|
105
151
|
setMode("nav");
|
|
@@ -135,28 +181,46 @@ export function Matrix(props: MatrixProps) {
|
|
|
135
181
|
const ch = key.name === "space" ? " " : key.sequence;
|
|
136
182
|
// Typed text can arrive as several keypress events inside one JS tick, before this
|
|
137
183
|
// component re-renders, so the update must build on the queued value, not the closure's.
|
|
138
|
-
if (ch && ch.length === 1)
|
|
139
|
-
setFilter((f) => f + ch);
|
|
140
|
-
setCursor(0);
|
|
141
|
-
}
|
|
184
|
+
if (ch && ch.length === 1) setFilter((f) => f + ch);
|
|
142
185
|
}
|
|
143
186
|
return;
|
|
144
187
|
}
|
|
145
|
-
if (key.name === "up")
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
else if (key.name === "
|
|
188
|
+
if (key.name === "up") {
|
|
189
|
+
if (focus === "left") moveLeft(leftCursor - 1);
|
|
190
|
+
else setRightCursor(Math.max(0, rightAt - 1));
|
|
191
|
+
} else if (key.name === "down") {
|
|
192
|
+
if (focus === "left") moveLeft(leftCursor + 1);
|
|
193
|
+
else setRightCursor(Math.min(Math.max(0, detail.length - 1), rightAt + 1));
|
|
194
|
+
} else if (key.name === "right") {
|
|
195
|
+
if (focus === "left") {
|
|
196
|
+
if (detail.length > 0) {
|
|
197
|
+
setFocus("right");
|
|
198
|
+
setRightCursor(0);
|
|
199
|
+
}
|
|
200
|
+
} else if (hereRight?.kind === "model") {
|
|
201
|
+
const k = openKey(hereRight);
|
|
202
|
+
if (k) setOpen(new Set(open).add(k));
|
|
203
|
+
}
|
|
204
|
+
} else if (key.name === "left") {
|
|
205
|
+
if (focus === "right") collapse();
|
|
206
|
+
} else if (key.name === "return") props.onSubmit();
|
|
152
207
|
else if (key.name === "space") {
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
else
|
|
208
|
+
if (focus === "left") {
|
|
209
|
+
if (selTop.kind === "budget-summary" || selTop.kind === "failover-summary") {
|
|
210
|
+
setFocus("right");
|
|
211
|
+
setRightCursor(0);
|
|
212
|
+
} else apply(selTop, catalog);
|
|
213
|
+
} else if (hereRight) {
|
|
214
|
+
if (hereRight.kind === "model" && !isTicked(profile, hereRight)) {
|
|
215
|
+
const k = openKey(hereRight);
|
|
216
|
+
if (k) setOpen(new Set(open).add(k));
|
|
217
|
+
} else if (hereRight.kind === "budget")
|
|
218
|
+
setMode({ budgetField: hereRight.field, buffer: String(profile.budget?.[hereRight.field] ?? "") });
|
|
219
|
+
else if (hereRight.kind === "failover") setMode({ failover: hereRight.rung });
|
|
220
|
+
else apply(hereRight, catalog);
|
|
221
|
+
}
|
|
159
222
|
} else if (key.sequence === "/") setMode("filter");
|
|
223
|
+
else if (key.sequence === "?") toggleHelp();
|
|
160
224
|
else if (key.sequence === "q") props.onQuit();
|
|
161
225
|
else if (key.sequence) props.onKey?.(key.sequence);
|
|
162
226
|
});
|
|
@@ -166,7 +230,7 @@ export function Matrix(props: MatrixProps) {
|
|
|
166
230
|
<box style={{ flexDirection: "column" }}>
|
|
167
231
|
<text wrapMode="none" truncate>{`${mode.budgetField}: ${mode.buffer}_`}</text>
|
|
168
232
|
<text attributes={TextAttributes.DIM}>
|
|
169
|
-
{`digits set the cap${
|
|
233
|
+
{`digits set the cap${dotSep}backspace edits${dotSep}enter saves${dotSep}esc cancels${dotSep}empty clears it`}
|
|
170
234
|
</text>
|
|
171
235
|
</box>
|
|
172
236
|
);
|
|
@@ -197,7 +261,7 @@ export function Matrix(props: MatrixProps) {
|
|
|
197
261
|
setMode("nav");
|
|
198
262
|
}}
|
|
199
263
|
/>
|
|
200
|
-
<text attributes={TextAttributes.DIM}>{`enter picks${
|
|
264
|
+
<text attributes={TextAttributes.DIM}>{`enter picks${dotSep}esc cancels`}</text>
|
|
201
265
|
</box>
|
|
202
266
|
);
|
|
203
267
|
}
|
|
@@ -227,7 +291,7 @@ export function Matrix(props: MatrixProps) {
|
|
|
227
291
|
apply(mode.row, c);
|
|
228
292
|
}}
|
|
229
293
|
/>
|
|
230
|
-
<text attributes={TextAttributes.DIM}>{`enter picks${
|
|
294
|
+
<text attributes={TextAttributes.DIM}>{`enter picks${dotSep}esc cancels`}</text>
|
|
231
295
|
</box>
|
|
232
296
|
);
|
|
233
297
|
}
|
|
@@ -235,116 +299,214 @@ export function Matrix(props: MatrixProps) {
|
|
|
235
299
|
const notReady = new Map<Backend, string>(
|
|
236
300
|
backends.filter((b) => !(b.installed && b.loggedIn)).map((b) => [b.backend, b.fix ?? ""]),
|
|
237
301
|
);
|
|
238
|
-
const box = (on: boolean) => glyph(on ? "on" : "off", ui.plain);
|
|
239
|
-
const fold = (k: string) =>
|
|
240
|
-
glyph(open.has(k) || (filter !== "" && k.startsWith("role:")) ? "open" : "shut", ui.plain);
|
|
241
302
|
|
|
242
|
-
|
|
303
|
+
// --- left pane -----------------------------------------------------------------------------
|
|
304
|
+
const leftText = (r: TopRow): { text: string; dim: boolean; tag: string | null } => {
|
|
243
305
|
switch (r.kind) {
|
|
244
306
|
case "role": {
|
|
245
307
|
const on = profile.roles[r.role].enabled;
|
|
246
|
-
const rungs = ticked(profile, r.role).map(shortRung).join(", ") || "nothing ticked";
|
|
247
308
|
return {
|
|
248
|
-
text: `${
|
|
309
|
+
text: `${dot(on ? "ready" : "missing", ui.plain)} ${r.role}`,
|
|
249
310
|
dim: !on,
|
|
250
|
-
tag: null,
|
|
311
|
+
tag: on ? null : "off",
|
|
251
312
|
};
|
|
252
313
|
}
|
|
314
|
+
case "objective":
|
|
315
|
+
return { text: "objective", dim: false, tag: profile.objective };
|
|
316
|
+
case "lock":
|
|
317
|
+
return { text: "heavy slots", dim: false, tag: String(profile.lock.heavy) };
|
|
318
|
+
case "harness":
|
|
319
|
+
return {
|
|
320
|
+
text: r.backend,
|
|
321
|
+
dim: notReady.has(r.backend),
|
|
322
|
+
tag: profile.harness[r.backend].isolated ? "isolated" : "native",
|
|
323
|
+
};
|
|
324
|
+
case "budget-summary":
|
|
325
|
+
return { text: "budget", dim: false, tag: budgetSummary(profile, ` ${glyph("dot", ui.plain)} `) };
|
|
326
|
+
case "failover-summary":
|
|
327
|
+
return { text: "failover", dim: false, tag: failoverSummary(profile) };
|
|
328
|
+
case "notify": {
|
|
329
|
+
const on = profile.notify.includes(r.moment);
|
|
330
|
+
return { text: `${dot(on ? "ready" : "missing", ui.plain)} ${r.moment}`, dim: !on, tag: null };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// Rows left for the panes once the cat, the border, the padding and the hint are drawn.
|
|
336
|
+
const rows = Math.max(12, termHeight || 24);
|
|
337
|
+
const LEFT_HEIGHT = Math.max(4, rows - 18);
|
|
338
|
+
const RIGHT_HEIGHT = Math.max(3, rows - 20);
|
|
339
|
+
const start = Math.max(0, Math.min(leftCursor - Math.floor(LEFT_HEIGHT / 2), top.length - LEFT_HEIGHT));
|
|
340
|
+
const leftWindow = top.slice(start, start + LEFT_HEIGHT);
|
|
341
|
+
let lastSection = "";
|
|
342
|
+
const leftLines = leftWindow.flatMap((r, i) => {
|
|
343
|
+
const idx = start + i;
|
|
344
|
+
const section = sectionOf(r);
|
|
345
|
+
const heading =
|
|
346
|
+
section !== lastSection ? (
|
|
347
|
+
<text key={`h${section}`} fg={tint("pink", ui.depth)} attributes={TextAttributes.BOLD}>
|
|
348
|
+
{section}
|
|
349
|
+
</text>
|
|
350
|
+
) : null;
|
|
351
|
+
lastSection = section;
|
|
352
|
+
const { text, dim, tag } = leftText(r);
|
|
353
|
+
return [
|
|
354
|
+
heading,
|
|
355
|
+
<ListLine
|
|
356
|
+
key={rowId(r)}
|
|
357
|
+
ui={ui}
|
|
358
|
+
selected={idx === leftCursor && focus === "left"}
|
|
359
|
+
dim={dim}
|
|
360
|
+
text={text}
|
|
361
|
+
tag={tag}
|
|
362
|
+
/>,
|
|
363
|
+
];
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// --- right pane ------------------------------------------------------------------------------
|
|
367
|
+
const dstart = Math.max(
|
|
368
|
+
0,
|
|
369
|
+
Math.min(rightAt - Math.floor(RIGHT_HEIGHT / 2), Math.max(0, detail.length - RIGHT_HEIGHT)),
|
|
370
|
+
);
|
|
371
|
+
const detailText = (r: DetailRow): { text: string; dim: boolean; tag: string | null } => {
|
|
372
|
+
switch (r.kind) {
|
|
253
373
|
case "backend": {
|
|
254
|
-
const fix = notReady.get(r.backend);
|
|
374
|
+
const fix = notReady.get(r.backend as Backend);
|
|
255
375
|
return {
|
|
256
|
-
text:
|
|
376
|
+
text: r.backend,
|
|
257
377
|
dim: fix !== undefined,
|
|
258
|
-
tag: fix === undefined ? null : `not ready${
|
|
378
|
+
tag: fix === undefined ? null : `not ready${dotSep}${fix}`,
|
|
259
379
|
};
|
|
260
380
|
}
|
|
261
|
-
case "harness": {
|
|
262
|
-
const iso = profile.harness[r.backend].isolated;
|
|
263
|
-
const cost = costs.find((h) => h.backend === r.backend);
|
|
264
|
-
const sw = `${iso ? "native" : "[native]"} ${glyph("swap", ui.plain)} ${iso ? "[isolated]" : "isolated"}`;
|
|
265
|
-
return { text: ` ${r.backend}: ${sw}`, dim: false, tag: cost ? costNote(cost) || null : null };
|
|
266
|
-
}
|
|
267
381
|
case "model": {
|
|
268
382
|
const efforts = profile.roles[r.role].models[r.model.id] ?? [];
|
|
383
|
+
const on = efforts.length > 0;
|
|
269
384
|
return {
|
|
270
|
-
text:
|
|
385
|
+
text: `${glyph(open.has(openKey(r) ?? "") ? "open" : "shut", ui.plain)} ${dot(on ? "ready" : "missing", ui.plain)} ${shortModelName(r.model.id)} ${efforts.join(" ")}`.trimEnd(),
|
|
271
386
|
dim: !ready.has(r.model.backend),
|
|
272
|
-
tag:
|
|
387
|
+
tag: r.model.id.includes("/") ? (r.model.id.split("/")[0] ?? r.model.id) : r.model.id,
|
|
273
388
|
};
|
|
274
389
|
}
|
|
275
390
|
case "effort": {
|
|
276
391
|
const rung = rungOf(r.model.id, r.effort);
|
|
277
392
|
const like = catalog.treatLike[rung];
|
|
278
393
|
return {
|
|
279
|
-
text: `
|
|
394
|
+
text: ` ${dot(isTicked(profile, r) ? "ready" : "missing", ui.plain)} ${r.effort}`,
|
|
280
395
|
dim: !ready.has(r.model.backend),
|
|
281
396
|
tag: like ? `like ${shortRung(like)}` : entryFor(catalog, rung) ? null : "unscored",
|
|
282
397
|
};
|
|
283
398
|
}
|
|
284
|
-
case "objective":
|
|
285
|
-
return {
|
|
286
|
-
text: `objective ${profile.objective} (${profile.objective === "cost" ? "cheapest rung first" : "fastest rung first"})`,
|
|
287
|
-
dim: false,
|
|
288
|
-
tag: null,
|
|
289
|
-
};
|
|
290
|
-
case "lock":
|
|
291
|
-
return { text: `heavy slots ${profile.lock.heavy} (for catherd lock)`, dim: false, tag: null };
|
|
292
399
|
case "budget": {
|
|
293
400
|
const v = profile.budget?.[r.field];
|
|
294
|
-
return {
|
|
295
|
-
text: `${`budget ${r.field}`.padEnd(16)}${v === undefined ? "no cap" : v}`,
|
|
296
|
-
dim: false,
|
|
297
|
-
tag: null,
|
|
298
|
-
};
|
|
401
|
+
return { text: r.field, dim: false, tag: v === undefined ? "no cap" : String(v) };
|
|
299
402
|
}
|
|
300
403
|
case "failover": {
|
|
301
404
|
const to = profile.failover?.[r.rung];
|
|
302
405
|
return {
|
|
303
|
-
text:
|
|
406
|
+
text: shortRung(r.rung),
|
|
304
407
|
dim: false,
|
|
305
|
-
tag:
|
|
408
|
+
tag: to ? `${glyph("arrow", ui.plain)} ${shortRung(to)}` : "no stand-in",
|
|
306
409
|
};
|
|
307
410
|
}
|
|
308
|
-
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const header = (): { title: string; subtitle: string } => {
|
|
415
|
+
switch (selTop.kind) {
|
|
416
|
+
case "role": {
|
|
417
|
+
const on = profile.roles[selTop.role].enabled;
|
|
418
|
+
return { title: selTop.role, subtitle: on ? "capable models and efforts" : "off" };
|
|
419
|
+
}
|
|
420
|
+
case "objective":
|
|
309
421
|
return {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
tag: null,
|
|
422
|
+
title: "objective",
|
|
423
|
+
subtitle: profile.objective === "cost" ? "cheapest rung first" : "fastest rung first",
|
|
313
424
|
};
|
|
425
|
+
case "lock":
|
|
426
|
+
return { title: "heavy slots", subtitle: "how many roles catherd's lock treats as heavy" };
|
|
427
|
+
case "harness":
|
|
428
|
+
return { title: selTop.backend, subtitle: "vendor harness: native config or an isolated sandbox" };
|
|
429
|
+
case "budget-summary":
|
|
430
|
+
return { title: "budget", subtitle: "minutes, tokens and dollars this profile allows a run" };
|
|
431
|
+
case "failover-summary":
|
|
432
|
+
return { title: "failover", subtitle: "a stand-in rung for a codex/opencode quota limit" };
|
|
433
|
+
case "notify":
|
|
434
|
+
return { title: "notify", subtitle: "when catherd pings you" };
|
|
314
435
|
}
|
|
315
436
|
};
|
|
316
437
|
|
|
317
|
-
const
|
|
438
|
+
const { title, subtitle } = header();
|
|
439
|
+
const detailBody: ReactNode[] = [];
|
|
440
|
+
if (selTop.kind === "harness") {
|
|
441
|
+
const cost = costs.find((h) => h.backend === selTop.backend);
|
|
442
|
+
detailBody.push(
|
|
443
|
+
<text key="h" wrapMode="word">
|
|
444
|
+
{harnessLine(selTop.backend, profile.harness[selTop.backend].isolated, cost, ui.plain)}
|
|
445
|
+
</text>,
|
|
446
|
+
);
|
|
447
|
+
} else if (selTop.kind === "notify") {
|
|
448
|
+
for (const m of ["milestone", "finish", "blocked"] as const) {
|
|
449
|
+
const on = profile.notify.includes(m);
|
|
450
|
+
detailBody.push(
|
|
451
|
+
<text
|
|
452
|
+
key={m}
|
|
453
|
+
wrapMode="none"
|
|
454
|
+
truncate
|
|
455
|
+
attributes={m === selTop.moment ? TextAttributes.BOLD : TextAttributes.NONE}
|
|
456
|
+
>
|
|
457
|
+
{`${dot(on ? "ready" : "missing", ui.plain)} ${m}`}
|
|
458
|
+
</text>,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
detail.slice(dstart, dstart + RIGHT_HEIGHT).forEach((r, i) => {
|
|
463
|
+
const idx = dstart + i;
|
|
464
|
+
const { text, dim, tag } = detailText(r);
|
|
465
|
+
detailBody.push(
|
|
466
|
+
<ListLine
|
|
467
|
+
key={rowId(r)}
|
|
468
|
+
ui={ui}
|
|
469
|
+
selected={focus === "right" && idx === rightAt}
|
|
470
|
+
dim={dim}
|
|
471
|
+
text={text}
|
|
472
|
+
tag={tag}
|
|
473
|
+
/>,
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
if (selTop.kind === "role") {
|
|
477
|
+
const ladder = candidates(profile, catalog, selTop.role).map(
|
|
478
|
+
(r) => `${shortModelName(splitRung(r).model)} ${splitRung(r).effort}`,
|
|
479
|
+
);
|
|
480
|
+
if (ladder.length > 0)
|
|
481
|
+
detailBody.push(
|
|
482
|
+
<text key="ladder" attributes={TextAttributes.DIM} wrapMode="word">
|
|
483
|
+
{`ladder ${dotSep}${ladder.join(` ${glyph("arrow", ui.plain)} `)}`}
|
|
484
|
+
</text>,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
318
489
|
return (
|
|
319
|
-
<box style={{ flexDirection: "
|
|
320
|
-
{
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
<span fg={pink}>{cur ? glyph("cursor", ui.plain) : " "}</span>
|
|
327
|
-
{` ${text}`}
|
|
328
|
-
{tag ? <span fg={pink}>{` ${tag}`}</span> : null}
|
|
490
|
+
<box style={{ flexDirection: "row" }}>
|
|
491
|
+
<box style={{ flexDirection: "column", width: 22 }}>{leftLines}</box>
|
|
492
|
+
<DetailPane ui={ui} title={title} subtitle={subtitle}>
|
|
493
|
+
{detailBody}
|
|
494
|
+
{note ? (
|
|
495
|
+
<text fg={tint("pink", ui.depth)} wrapMode="none" truncate>
|
|
496
|
+
{note}
|
|
329
497
|
</text>
|
|
330
|
-
)
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
{
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
</
|
|
342
|
-
{filter && mode === "nav" ? <text attributes={TextAttributes.DIM}>{`filter: ${filter}`}</text> : null}
|
|
343
|
-
{note ? (
|
|
344
|
-
<text fg={pink} wrapMode="none" truncate>
|
|
345
|
-
{note}
|
|
346
|
-
</text>
|
|
347
|
-
) : null}
|
|
498
|
+
) : null}
|
|
499
|
+
{mode === "filter" ? (
|
|
500
|
+
<text attributes={TextAttributes.DIM} wrapMode="none" truncate>{`filter: ${filter}_`}</text>
|
|
501
|
+
) : filter ? (
|
|
502
|
+
<text attributes={TextAttributes.DIM} wrapMode="none" truncate>{`filter: ${filter}`}</text>
|
|
503
|
+
) : null}
|
|
504
|
+
{errors.map((e) => (
|
|
505
|
+
<text key={e} fg={tint("pink", ui.depth)} wrapMode="none" truncate>
|
|
506
|
+
{e}
|
|
507
|
+
</text>
|
|
508
|
+
))}
|
|
509
|
+
</DetailPane>
|
|
348
510
|
</box>
|
|
349
511
|
);
|
|
350
512
|
}
|
package/src/tui/theme.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { WriteStream } from "node:tty";
|
|
|
2
2
|
import type { HarnessCost } from "../core/harness.ts";
|
|
3
3
|
|
|
4
4
|
export type Mood = "good" | "working" | "waiting" | "failed" | "landed";
|
|
5
|
-
export type Tone = "ginger" | "cream" | "charcoal" | "pink";
|
|
5
|
+
export type Tone = "ginger" | "cream" | "charcoal" | "pink" | "green" | "amber" | "red" | "surface" | "ink";
|
|
6
6
|
/** Node's getColorDepth: 1 none, 4 sixteen, 8 256, 24 true colour. */
|
|
7
7
|
export type Depth = 1 | 4 | 8 | 24;
|
|
8
8
|
|
|
@@ -19,8 +19,16 @@ const TONES: Record<Tone, string> = {
|
|
|
19
19
|
cream: "#F5E6C8",
|
|
20
20
|
charcoal: "#4A4340",
|
|
21
21
|
pink: "#FF6FA5",
|
|
22
|
+
green: "#8FBF6A",
|
|
23
|
+
amber: "#E0A63E",
|
|
24
|
+
red: "#D9636A",
|
|
25
|
+
surface: "#211C20",
|
|
26
|
+
ink: "#1A1417",
|
|
22
27
|
};
|
|
23
28
|
|
|
29
|
+
/** The one accent colour: headings and the selected-row bar. Everything else stays neutral or dim. */
|
|
30
|
+
export const ACCENT: Tone = "pink";
|
|
31
|
+
|
|
24
32
|
export const GRADIENT: string[] = [TONES.ginger, TONES.pink];
|
|
25
33
|
|
|
26
34
|
export function tint(tone: Tone, depth: Depth): string | undefined {
|
|
@@ -49,14 +57,15 @@ export function face(mood: Mood, plain: boolean): string {
|
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
const GLYPHS = {
|
|
52
|
-
on: ["
|
|
53
|
-
off: ["○
|
|
60
|
+
on: ["●", "*"],
|
|
61
|
+
off: ["○", "o"],
|
|
54
62
|
cat: ["🐈", "*"],
|
|
63
|
+
paw: ["🐾", ""],
|
|
55
64
|
dot: ["·", "-"],
|
|
56
65
|
more: ["…", "..."],
|
|
57
66
|
open: ["▾", "v"],
|
|
58
67
|
shut: ["▸", ">"],
|
|
59
|
-
cursor: ["
|
|
68
|
+
cursor: ["▶", ">"],
|
|
60
69
|
arrow: ["→", "->"],
|
|
61
70
|
keys: ["↑↓", "up/down"],
|
|
62
71
|
swap: ["⇄", "<->"],
|
|
@@ -69,6 +78,21 @@ export function glyph(g: Glyph, plain: boolean): string {
|
|
|
69
78
|
return GLYPHS[g][plain ? 1 : 0];
|
|
70
79
|
}
|
|
71
80
|
|
|
81
|
+
/** Ready/warn/missing status, painted with the same two shapes `on`/`off` toggles use — a
|
|
82
|
+
* warning is still a "filled" dot (amber instead of green), missing is the hollow one (red).
|
|
83
|
+
* ponytail: --plain collapses warn into the ready glyph (a star), the spec's ASCII table only
|
|
84
|
+
* names two dot characters, star and o; upgrade to a three-way ASCII split if that ever matters. */
|
|
85
|
+
export type DotState = "ready" | "warn" | "missing";
|
|
86
|
+
const DOT_TONE: Record<DotState, Tone> = { ready: "green", warn: "amber", missing: "red" };
|
|
87
|
+
|
|
88
|
+
export function dot(state: DotState, plain: boolean): string {
|
|
89
|
+
return glyph(state === "missing" ? "off" : "on", plain);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function dotTint(state: DotState, depth: Depth): string | undefined {
|
|
93
|
+
return tint(DOT_TONE[state], depth);
|
|
94
|
+
}
|
|
95
|
+
|
|
72
96
|
/** The conductor: ears, a face that follows the mood, a raised baton, paws. */
|
|
73
97
|
export function mascot(mood: Mood, plain: boolean): [string, string, string] {
|
|
74
98
|
return [" /\\_/\\ .", `(${face(mood, plain)})/`, ' (")(")'].map((l) => l.padEnd(9)) as [
|
|
@@ -114,6 +138,19 @@ export function costNote(h: HarnessCost): string {
|
|
|
114
138
|
return h.isolatedMedian !== null ? `isolated ${kTokens(h.isolatedMedian)} tokens/run` : "";
|
|
115
139
|
}
|
|
116
140
|
|
|
141
|
+
/** The profile editor's HARNESS detail: the native/isolated choice plus what it costs, falling
|
|
142
|
+
* back to the static hint (`HARNESS_HINT`) until a run has recorded a real cost. */
|
|
143
|
+
export function harnessLine(
|
|
144
|
+
backend: "codex" | "opencode",
|
|
145
|
+
isolated: boolean,
|
|
146
|
+
cost: HarnessCost | undefined,
|
|
147
|
+
plain: boolean,
|
|
148
|
+
): string {
|
|
149
|
+
const mode = isolated ? "isolated" : "native";
|
|
150
|
+
const note = (cost && costNote(cost)) || HARNESS_HINT[backend];
|
|
151
|
+
return `${mode} ${glyph("dot", plain)} ${note}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
117
154
|
export function clock(secs: number): string {
|
|
118
155
|
const s = Math.max(0, Math.floor(secs));
|
|
119
156
|
const h = Math.floor(s / 3600);
|