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/src/tui/ui.tsx CHANGED
@@ -1,11 +1,171 @@
1
+ import type { BorderCharacters } from "@opentui/core";
1
2
  import { TextAttributes } from "@opentui/core";
2
3
  import { useKeyboard, useTerminalDimensions } from "@opentui/react";
3
4
  import { type ReactNode, useEffect, useState } from "react";
4
- import { GRADIENT, glyph, herdLine, INTRO, type Mood, mascot, SPINNER, tint, type Ui } from "./theme.ts";
5
+ import { VERSION } from "../version.ts";
6
+ import {
7
+ ACCENT,
8
+ GRADIENT,
9
+ glyph,
10
+ herdLine,
11
+ INTRO,
12
+ type Mood,
13
+ mascot,
14
+ SPINNER,
15
+ tint,
16
+ type Ui,
17
+ } from "./theme.ts";
5
18
 
6
- export function Screen({ children }: { children: ReactNode }) {
19
+ /** --plain's border: no box-drawing characters, just `+ - |` (spec: "ASCII borders"). */
20
+ const ASCII_BORDER: BorderCharacters = {
21
+ topLeft: "+",
22
+ topRight: "+",
23
+ bottomLeft: "+",
24
+ bottomRight: "+",
25
+ horizontal: "-",
26
+ vertical: "|",
27
+ topT: "+",
28
+ bottomT: "+",
29
+ leftT: "+",
30
+ rightT: "+",
31
+ cross: "+",
32
+ };
33
+
34
+ /** Every screen's chrome: a bordered panel with its title set into the top border, and a short
35
+ * key-hint line outside and below it (so the hint never competes with the border for space). */
36
+ /** The widest the layout grows; wider terminals centre it with margins on both sides. */
37
+ const MAX_WIDTH = 110;
38
+
39
+ export function Frame({
40
+ ui,
41
+ title,
42
+ hint,
43
+ mood = "good",
44
+ above,
45
+ children,
46
+ }: {
47
+ ui: Ui;
48
+ title: string;
49
+ hint: string;
50
+ mood?: Mood;
51
+ above?: ReactNode;
52
+ children: ReactNode;
53
+ }) {
7
54
  const { width } = useTerminalDimensions();
8
- return <box style={{ flexDirection: "column", width: Math.min(width || 80, 80) }}>{children}</box>;
55
+ const term = Math.max(width || 80, 80);
56
+ const w = Math.min(term - 4, MAX_WIDTH);
57
+ const accent = tint(ACCENT, ui.depth);
58
+ return (
59
+ <box style={{ flexDirection: "column", width: term, alignItems: "center", paddingTop: 1 }}>
60
+ <box style={{ flexDirection: "column", width: w }}>
61
+ {above ?? <Wordmark ui={ui} mood={mood} suffix={VERSION} />}
62
+ <text> </text>
63
+ <box
64
+ style={{
65
+ flexDirection: "column",
66
+ width: w,
67
+ paddingLeft: 2,
68
+ paddingRight: 2,
69
+ paddingTop: 1,
70
+ paddingBottom: 1,
71
+ }}
72
+ border
73
+ borderStyle={ui.plain ? "single" : "rounded"}
74
+ customBorderChars={ui.plain ? ASCII_BORDER : undefined}
75
+ borderColor={accent}
76
+ title={` ${title} `}
77
+ titleColor={accent}
78
+ titleAlignment="left"
79
+ >
80
+ {children}
81
+ </box>
82
+ <box style={{ paddingLeft: 2, paddingTop: 1 }}>
83
+ <text attributes={TextAttributes.DIM} wrapMode="none" truncate>
84
+ {hint}
85
+ </text>
86
+ </box>
87
+ </box>
88
+ </box>
89
+ );
90
+ }
91
+
92
+ /** The right pane: a slightly lighter surface than the panel, with an uppercase accent title,
93
+ * a dim one-line subtitle, and whatever detail rows the caller has for the selected item. */
94
+ export function DetailPane({
95
+ ui,
96
+ heading,
97
+ title,
98
+ subtitle,
99
+ children,
100
+ }: {
101
+ ui: Ui;
102
+ heading?: string;
103
+ title: string;
104
+ subtitle: string;
105
+ children: ReactNode;
106
+ }) {
107
+ return (
108
+ <box
109
+ style={{
110
+ flexDirection: "column",
111
+ flexGrow: 1,
112
+ marginLeft: 2,
113
+ paddingLeft: 2,
114
+ paddingRight: 2,
115
+ paddingTop: 1,
116
+ paddingBottom: 1,
117
+ }}
118
+ backgroundColor={ui.plain ? undefined : tint("surface", ui.depth)}
119
+ >
120
+ {heading && (
121
+ <text attributes={TextAttributes.DIM} wrapMode="none" truncate>
122
+ {heading}
123
+ </text>
124
+ )}
125
+ <text fg={tint(ACCENT, ui.depth)} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
126
+ {title}
127
+ </text>
128
+ <text attributes={TextAttributes.DIM} wrapMode="none" truncate>
129
+ {subtitle}
130
+ </text>
131
+ <text> </text>
132
+ {children}
133
+ </box>
134
+ );
135
+ }
136
+
137
+ /** One row of a left-pane list: a filled accent bar with a ▶ marker when selected, plain
138
+ * otherwise; `tag` is a dim trailing value (e.g. a role's "off", a rung's rung id). */
139
+ export function ListLine({
140
+ ui,
141
+ selected,
142
+ dim,
143
+ text,
144
+ tag,
145
+ }: {
146
+ ui: Ui;
147
+ selected: boolean;
148
+ dim?: boolean;
149
+ text: string;
150
+ tag?: string | null;
151
+ }) {
152
+ const accent = tint(ACCENT, ui.depth);
153
+ const filled = selected && !ui.plain;
154
+ return (
155
+ <box style={{ flexDirection: "row", width: "100%" }} backgroundColor={filled ? accent : undefined}>
156
+ <text
157
+ wrapMode="none"
158
+ truncate
159
+ fg={filled ? tint("ink", ui.depth) : undefined}
160
+ attributes={(selected ? TextAttributes.BOLD : 0) | (dim && !selected ? TextAttributes.DIM : 0)}
161
+ >
162
+ {selected ? glyph("cursor", ui.plain) : " "} {text}
163
+ {tag ? (
164
+ <span attributes={filled ? TextAttributes.BOLD : TextAttributes.DIM}>{` ${tag}`}</span>
165
+ ) : null}
166
+ </text>
167
+ </box>
168
+ );
9
169
  }
10
170
 
11
171
  const hex = (h: string): [number, number, number] => [
@@ -24,16 +184,21 @@ export function gradientLetters(word: string): { ch: string; fg: string }[] {
24
184
  });
25
185
  }
26
186
 
27
- export function Header({
187
+ /** The cat, and the wordmark it carries: `word` gradient-lettered where colour is available,
188
+ * an optional `suffix` on the same line (the dashboard's version number), and an optional
189
+ * second `subtitle` line (the intro's herding copy). Only place the 🐾 glyph still appears. */
190
+ export function Wordmark({
28
191
  ui,
29
192
  mood,
30
- title,
31
193
  word = "catherd",
194
+ suffix,
195
+ subtitle,
32
196
  }: {
33
197
  ui: Ui;
34
198
  mood: Mood;
35
- title: string;
36
199
  word?: string;
200
+ suffix?: string;
201
+ subtitle?: string;
37
202
  }) {
38
203
  const [ears, head, paws] = mascot(mood, ui.plain);
39
204
  const ginger = tint("ginger", ui.depth);
@@ -45,24 +210,22 @@ export function Header({
45
210
  <text fg={ginger}>{paws}</text>
46
211
  </box>
47
212
  <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}
213
+ <text wrapMode="none" truncate attributes={TextAttributes.BOLD}>
214
+ {ui.depth >= 8 && word
215
+ ? gradientLetters(word).map((s, i) => (
216
+ // biome-ignore lint: index key is stable, the wordmark never reorders
217
+ <span key={i} fg={s.fg}>
218
+ {s.ch}
219
+ </span>
220
+ ))
221
+ : (word ?? " ")}
222
+ {suffix ? <span attributes={TextAttributes.DIM}>{` ${suffix}`}</span> : null}
65
223
  </text>
224
+ {subtitle ? (
225
+ <text fg={tint("pink", ui.depth)} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
226
+ {subtitle}
227
+ </text>
228
+ ) : null}
66
229
  </box>
67
230
  </box>
68
231
  );
@@ -100,5 +263,5 @@ export function Intro({ ui, onDone }: { ui: Ui; onDone: () => void }) {
100
263
  }, [step]);
101
264
  const mood: Mood = step < 2 ? "waiting" : step < INTRO.steps ? "working" : "good";
102
265
  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} />;
266
+ return <Wordmark ui={ui} mood={mood} word={word} subtitle={herdLine(step, ui.plain)} />;
104
267
  }
package/src/tui/watch.tsx CHANGED
@@ -5,19 +5,16 @@ import { useEffect, useRef, useState } from "react";
5
5
  import { type BudgetStatus, formatBudget, type RunSummary, summarizeRun } from "../core/status.ts";
6
6
  import { listRuns, readJsonl, readRunRecords, type Run } from "../core/runstore.ts";
7
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";
8
+ import { face, glyph, tint, type Ui } from "./theme.ts";
9
+ import { CatSpinner, DetailPane, Frame, gradientLetters, ListLine } from "./ui.tsx";
10
10
  import {
11
11
  budgetTone,
12
12
  climbs,
13
13
  jevLine,
14
14
  type JevLine,
15
15
  liveLine,
16
- milestoneLine,
17
16
  plainBudgetBar,
18
- recordLine,
19
17
  runLine,
20
- runMood,
21
18
  } from "./watch-model.ts";
22
19
 
23
20
  export interface WatchDeps {
@@ -36,7 +33,7 @@ const watchDeps: WatchDeps = {
36
33
 
37
34
  // ponytail: only the 20 newest runs are summarized each poll; add paging when someone keeps more than that live
38
35
  const MAX_RUNS = 20;
39
- const SHOWN = 6;
36
+ const LEFT_HEIGHT = 12;
40
37
 
41
38
  interface Item {
42
39
  run: Run;
@@ -71,7 +68,7 @@ function BudgetBar({ ui, budget }: { ui: Ui; budget: BudgetStatus }) {
71
68
  <text
72
69
  wrapMode="none"
73
70
  truncate
74
- >{` ${plainBudgetBar(budget.fraction, width)} ${formatBudget(budget)}`}</text>
71
+ >{`${plainBudgetBar(budget.fraction, width)} ${formatBudget(budget)}`}</text>
75
72
  );
76
73
  }
77
74
  const filled = Math.round(Math.max(0, Math.min(1, budget.fraction)) * width);
@@ -83,7 +80,6 @@ function BudgetBar({ ui, budget }: { ui: Ui; budget: BudgetStatus }) {
83
80
  : gradientLetters("█".repeat(filled));
84
81
  return (
85
82
  <text wrapMode="none" truncate>
86
- {" "}
87
83
  {cells.map((cell, i) => (
88
84
  // biome-ignore lint: index key is stable, the bar never reorders
89
85
  <span key={i} fg={cell.fg}>
@@ -96,66 +92,58 @@ function BudgetBar({ ui, budget }: { ui: Ui; budget: BudgetStatus }) {
96
92
  );
97
93
  }
98
94
 
99
- function RunBlock({ ui, it, here }: { ui: Ui; it: Item; here: boolean }) {
100
- const cur = here ? glyph("cursor", ui.plain) : " ";
95
+ /** The right pane for the highlighted run: its live lanes, recent climbs, Jev decisions and the
96
+ * budget bar — the run's records and raw state text stay out (they were noise, spec §item 4). */
97
+ function RunDetail({ ui, it, climbed, deps }: { ui: Ui; it: Item; climbed: string[]; deps: WatchDeps }) {
98
+ const pink = tint("pink", ui.depth);
101
99
  if (!it.summary) {
102
100
  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>
101
+ <DetailPane ui={ui} title={it.run.id} subtitle="unreadable">
102
+ <text fg={pink} wrapMode="none" truncate>
103
+ {`${face("failed", ui.plain)} ${it.error}`}
104
+ </text>
105
+ </DetailPane>
106
106
  );
107
107
  }
108
108
  const s = it.summary;
109
- const ginger = tint("ginger", ui.depth);
110
- const pink = tint("pink", ui.depth);
109
+ const jev = deps.readJev(it.run.dir);
111
110
  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`}
111
+ <DetailPane ui={ui} title={s.title} subtitle={it.run.meta.repo}>
112
+ {s.live.length > 0 ? (
113
+ s.live.map((l) => (
114
+ <text key={l.name} wrapMode="none" truncate>
115
+ {liveLine(l, ui.plain)}
116
+ </text>
117
+ ))
118
+ ) : (
119
+ <text attributes={TextAttributes.DIM}>no lane is running right now</text>
120
+ )}
121
+ {climbed.length > 0 ? (
122
+ <>
123
+ <text> </text>
124
+ {climbed.map((c, i) => (
125
+ <text key={`c${i}`} fg={pink} wrapMode="none" truncate>
126
+ {c}
127
+ </text>
128
+ ))}
129
+ </>
130
+ ) : null}
131
+ <text> </text>
132
+ <text attributes={TextAttributes.DIM}>
133
+ {`jev ${glyph("dot", ui.plain)} ${s.jev.decisions} decisions ${glyph("dot", ui.plain)} ${s.jev.fallbacks} fell back`}
153
134
  </text>
154
- {jev.slice(-6).map((e, i) => (
155
- <text key={`j${i}`} wrapMode="none" truncate>{` ${jevLine(e, ui.plain)}`}</text>
135
+ {jev.slice(-4).map((e, i) => (
136
+ <text key={`j${i}`} wrapMode="none" truncate>
137
+ {jevLine(e, ui.plain)}
138
+ </text>
156
139
  ))}
157
- <text attributes={TextAttributes.DIM}>{`esc back${dot}q quit`}</text>
158
- </box>
140
+ {s.budget ? (
141
+ <>
142
+ <text> </text>
143
+ <BudgetBar ui={ui} budget={s.budget} />
144
+ </>
145
+ ) : null}
146
+ </DetailPane>
159
147
  );
160
148
  }
161
149
 
@@ -174,7 +162,6 @@ export function Watch({
174
162
  const [state, setState] = useState<{ items: Item[]; problem: string | null } | null>(null);
175
163
  const [climbed, setClimbed] = useState<string[]>([]);
176
164
  const [cursor, setCursor] = useState(0);
177
- const [open, setOpen] = useState<string | null>(null);
178
165
 
179
166
  useEffect(() => {
180
167
  const refresh = () => {
@@ -192,67 +179,58 @@ export function Watch({
192
179
 
193
180
  const items = state?.items ?? [];
194
181
  const at = Math.min(cursor, Math.max(0, items.length - 1));
195
- const detail = items.find((it) => it.run.dir === open);
182
+ const leftStart = Math.max(0, Math.min(at - Math.floor(LEFT_HEIGHT / 2), items.length - LEFT_HEIGHT));
196
183
 
197
184
  useKeyboard((key) => {
198
185
  if (key.sequence === "q") {
199
186
  renderer.destroy();
200
187
  return;
201
188
  }
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
189
  if (key.name === "up") setCursor(Math.max(0, at - 1));
208
190
  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
191
  });
211
192
 
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
193
  const pink = tint("pink", ui.depth);
223
- const start = Math.max(0, Math.min(at - Math.floor(SHOWN / 2), items.length - SHOWN));
194
+ const title = `Watch ${glyph("dot", ui.plain)} ${items.length} runs`;
195
+ const hint = `${glyph("keys", ui.plain)} navigate q quit refreshes every ${intervalMs / 1000} s`;
196
+
197
+ if (state?.problem) {
198
+ return (
199
+ <Frame ui={ui} title={title} hint="q quit">
200
+ <text fg={pink} wrapMode="none" truncate>{`${face("failed", ui.plain)} ${state.problem}`}</text>
201
+ </Frame>
202
+ );
203
+ }
224
204
 
225
205
  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} />
206
+ <Frame ui={ui} title={title} hint={hint}>
207
+ {state === null ? (
208
+ <CatSpinner ui={ui} label="reading the runs" />
209
+ ) : items.length === 0 ? (
210
+ <text>No runs yet. Start one with /catherd in Claude Code.</text>
234
211
  ) : (
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}
212
+ <box style={{ flexDirection: "row" }}>
213
+ <box style={{ flexDirection: "column", width: 24 }}>
214
+ <text fg={pink} attributes={TextAttributes.BOLD}>
215
+ RUNS
249
216
  </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>
217
+ {items.slice(leftStart, leftStart + LEFT_HEIGHT).map((it, i) => (
218
+ <ListLine
219
+ key={it.run.dir}
220
+ ui={ui}
221
+ selected={leftStart + i === at}
222
+ dim={!it.summary}
223
+ text={
224
+ it.summary
225
+ ? runLine(it.summary, ui.plain)
226
+ : `${face("failed", ui.plain)} ${it.run.id} ${glyph("dot", ui.plain)} unreadable: ${it.error}`
227
+ }
228
+ />
229
+ ))}
230
+ </box>
231
+ {items[at] ? <RunDetail ui={ui} it={items[at]} climbed={climbed} deps={d} /> : null}
254
232
  </box>
255
233
  )}
256
- </Screen>
234
+ </Frame>
257
235
  );
258
236
  }