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.
- 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/mcp/preflight.ts +16 -1
- package/src/profile/agents.ts +12 -0
- package/src/profile/role-prompts.ts +2 -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/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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 <
|
|
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,
|
|
9
|
-
import { CatSpinner,
|
|
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
|
|
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
|
-
>{
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
110
|
-
const pink = tint("pink", ui.depth);
|
|
109
|
+
const jev = deps.readJev(it.run.dir);
|
|
111
110
|
return (
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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(-
|
|
155
|
-
<text key={`j${i}`} wrapMode="none" truncate>
|
|
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
|
-
|
|
158
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
227
|
-
|
|
228
|
-
ui={ui}
|
|
229
|
-
|
|
230
|
-
|
|
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: "
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
</
|
|
234
|
+
</Frame>
|
|
257
235
|
);
|
|
258
236
|
}
|