catherd-cli 0.1.0 → 0.2.1

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.
@@ -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 { type Backend, type Catalog, type Profile, type RungId, rungOf } from "../types.ts";
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
- rows as buildRows,
17
- ticked,
23
+ roleDetailRows,
24
+ sectionOf,
18
25
  toggle,
26
+ topLevelRows,
27
+ type TopRow,
19
28
  treatLikeOptions,
20
29
  } from "./matrix-model.ts";
21
- import { costNote, glyph, HARNESS_HINT, shortRung, tint, type Ui } from "./theme.ts";
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
- height?: number;
37
- submitLabel?: string;
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
- type EffortRow = Extract<Row, { kind: "effort" }>;
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
- ui,
51
- profile,
52
- catalog,
53
- backends,
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 list = buildRows(profile, catalog, open, filter);
66
- const at = Math.min(cursor, list.length - 1);
67
- const here = list[at];
68
- const pink = tint("pink", ui.depth);
69
- const dot = ` ${glyph("dot", ui.plain)} `;
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 = (row: Row) => {
86
- const own = openKey(row);
87
- const key =
88
- row.kind === "backend" || row.kind === "harness" || (row.kind === "model" && !open.has(own ?? ""))
89
- ? `role:${row.role}`
90
- : own;
91
- if (!key) return;
92
- const next = new Set(open);
93
- next.delete(key);
94
- setOpen(next);
95
- const idx = buildRows(profile, catalog, next, filter).findIndex(
96
- (r) => (r.kind === "role" || r.kind === "model") && openKey(r) === key,
97
- );
98
- setCursor(Math.max(0, idx));
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 || !here) return;
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") setCursor(Math.max(0, at - 1));
146
- else if (key.name === "down") setCursor(Math.min(list.length - 1, at + 1));
147
- else if (key.name === "right") {
148
- const k = here.kind === "role" || here.kind === "model" ? openKey(here) : null;
149
- if (k) setOpen(new Set(open).add(k));
150
- } else if (key.name === "left") collapse(here);
151
- else if (key.name === "return") props.onSubmit();
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 (here.kind === "model" && !isTicked(profile, here))
154
- setOpen(new Set(open).add(`model:${here.role}:${here.model.id}`));
155
- else if (here.kind === "budget")
156
- setMode({ budgetField: here.field, buffer: String(profile.budget?.[here.field] ?? "") });
157
- else if (here.kind === "failover") setMode({ failover: here.rung });
158
- else apply(here, catalog);
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${dot}backspace edits${dot}enter saves${dot}esc cancels${dot}empty clears it`}
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${dot}esc cancels`}</text>
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${dot}esc cancels`}</text>
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
- const describe = (r: Row): { text: string; dim: boolean; tag: string | null } => {
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: `${fold(`role:${r.role}`)} ${box(on)} ${r.role.padEnd(12)}${on ? rungs : "off"}`,
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: ` ${r.backend}`,
376
+ text: r.backend,
257
377
  dim: fix !== undefined,
258
- tag: fix === undefined ? null : `not ready${dot}${fix}`,
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: ` ${fold(`model:${r.role}:${r.model.id}`)} ${box(efforts.length > 0)} ${r.model.id} ${efforts.join(" ")}`,
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: null,
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: ` ${box(isTicked(profile, r))} ${r.effort}`,
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: `${`failover ${shortRung(r.rung)}`.padEnd(28)}${to ? `${glyph("arrow", ui.plain)} ${shortRung(to)}` : "no stand-in"}`,
406
+ text: shortRung(r.rung),
304
407
  dim: false,
305
- tag: null,
408
+ tag: to ? `${glyph("arrow", ui.plain)} ${shortRung(to)}` : "no stand-in",
306
409
  };
307
410
  }
308
- case "notify":
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
- text: `${box(profile.notify.includes(r.moment))} notify on ${r.moment}`,
311
- dim: false,
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 start = Math.max(0, Math.min(at - Math.floor(height / 2), list.length - height));
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: "column" }}>
320
- {list.slice(start, start + height).map((r, i) => {
321
- const cur = start + i === at;
322
- const { text, dim, tag } = describe(r);
323
- const attrs = (cur ? TextAttributes.BOLD : 0) | (dim && !cur ? TextAttributes.DIM : 0);
324
- return (
325
- <text key={rowId(r)} wrapMode="none" truncate attributes={attrs}>
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
- {here?.kind === "harness" ? (
333
- <text attributes={TextAttributes.DIM} wrapMode="none" truncate>
334
- {HARNESS_HINT[here.backend]}
335
- </text>
336
- ) : null}
337
- <text attributes={TextAttributes.DIM} wrapMode="none" truncate>
338
- {mode === "filter"
339
- ? `filter: ${filter}_${dot}enter keeps it${dot}esc clears it`
340
- : `${glyph("keys", ui.plain)} move${dot}${glyph("arrow", ui.plain)} open${dot}space ticks${dot}/ filter${dot}enter ${submitLabel}${dot}q quit`}
341
- </text>
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: ["🐾", "[x]"],
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);