@taicho-ai/cli 0.1.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.
@@ -0,0 +1,451 @@
1
+ /** Plan 21 — the artifact browser. Two surfaces, one component:
2
+ * - the SHELF, docked over the (still visible) chat scrollback: artifact list + live preview,
3
+ * scope control, filter chips, `/` search, mode line. Runs END here (App auto-docks on completed
4
+ * artifact-producing turns).
5
+ * - the READER, full-screen (captain-decided): markdown body + the VERBS — `a` annotate, `y`
6
+ * approve, `v` versions, `o` $EDITOR. The shelf's `g` (all-runs scope) previews GC with a dry
7
+ * run before archiving.
8
+ *
9
+ * ALL UI state lives in App's `browserState` (passed as `st` + `onChange`) so a pending approval
10
+ * card can UNMOUNT the browser outright and remount it losslessly — spec §1's suspension rule.
11
+ * Keys arrive via `keyRef` (a browser-OWNED ref, never cardKeyRef): App's useInput dispatches
12
+ * pending card → operation view → browser → chat, so ownership is fixed order, not last-writer-wins. */
13
+ import { useEffect, useMemo } from "react";
14
+ import { Box, Text, useApp } from "ink";
15
+ import { editFileInTerminal } from "./editor-handoff";
16
+ import { type Artifact, artifactHandle } from "@taicho-ai/contracts/artifact";
17
+ import { readArtifactBody, artifactBodyPath, artifactVersions, readArtifact, type GcReport } from "@taicho-ai/framework/store/artifacts";
18
+ import { listAnnotations, annotateArtifact } from "@taicho-ai/framework/store/annotations";
19
+ import { renderMarkdown } from "./markdown";
20
+ import type { CardKeyHandler } from "./ProposalCard";
21
+ import { MIN_PANE_COLS, MIN_PANE_ROWS } from "./SquadPanes";
22
+ import {
23
+ type BrowserScope, type BrowserSort, type BrowserFilters, type ShelfRow,
24
+ resolveScope, applyFilters, shelfRows, artifactRows, countLine, ageLabel,
25
+ } from "./browser-model";
26
+
27
+ export interface BrowserUiState {
28
+ scope: BrowserScope;
29
+ sort: BrowserSort;
30
+ filters: BrowserFilters;
31
+ sel: number; // indexes the ARTIFACT rows (headers are never selectable)
32
+ reading: boolean;
33
+ /** The reader is pinned to a HANDLE, never a row index: the shelf recomputes from disk every
34
+ * render, and a background save can reorder rows mid-read — an index would silently swap the
35
+ * artifact under the reader (and under an in-flight `a`/`y`, mis-targeting an append-only ledger). */
36
+ readingHandle: string | null;
37
+ scroll: number;
38
+ search: string | null; // "/" live query; null = closed
39
+ filterOpen: boolean; // the `f` chip row
40
+ filterField: number; // which chip ←/→ is on
41
+ feedback: string | null; // the `a` inline input; null = closed
42
+ versionsOpen: boolean; // the `v` jump list
43
+ versionSel: number;
44
+ versionOverride: string | null; // reader shows this exact handle instead of the row's latest
45
+ gcPreview: string[] | null; // the `g` dry-run would-archive list awaiting confirm
46
+ note?: string; // transient verb result line (approved / opened / gc report)
47
+ hint?: string; // background-settle scope hint (Phase 2)
48
+ }
49
+
50
+ export function initialBrowserUi(): BrowserUiState {
51
+ return {
52
+ scope: "run", sort: "run", filters: {}, sel: 0, reading: false, readingHandle: null, scroll: 0,
53
+ search: null, filterOpen: false, filterField: 0,
54
+ feedback: null, versionsOpen: false, versionSel: 0, versionOverride: null, gcPreview: null,
55
+ };
56
+ }
57
+
58
+ const SCOPES: { key: BrowserScope; label: string }[] = [
59
+ { key: "run", label: "1 this run" },
60
+ { key: "conversation", label: "2 conversation" },
61
+ { key: "all", label: "3 all runs" },
62
+ ];
63
+
64
+ const READER_LINES = 40;
65
+ const SHELF_LIST_LINES = 10;
66
+
67
+ /** The reader's markdown body as display lines. Renders the WHOLE document once — markdown is a
68
+ * multi-line grammar, so tables (header + `---|---` + rows), fenced code, lists and blockquotes only
69
+ * survive when marked sees them intact; rendering line-by-line handed it isolated fragments and broke
70
+ * every one of them. Split the ANSI-rendered result into lines for the scroll window. Both the scroll
71
+ * clamp (key handler) and the render read from HERE, so their line counts can't drift. `renderMarkdown`
72
+ * is memoised, so the repeated call per keypress is a cache hit. */
73
+ export function readerBodyLines(ws: string, handle: string, width: number): string[] {
74
+ const body = readArtifactBody(ws, handle)?.toString("utf8") ?? "";
75
+ return renderMarkdown(body, width).split("\n");
76
+ }
77
+
78
+ /** The `f` chip row: each field cycles a small closed value set with ↑↓. producer/type values are
79
+ * derived from the artifacts IN SCOPE (plus "any"), so the chips never offer a dead value. */
80
+ const FILTER_FIELDS = ["producer", "type", "feedback", "verdict", "since"] as const;
81
+ type FilterField = (typeof FILTER_FIELDS)[number];
82
+ function fieldValues(field: FilterField, inScope: Artifact[]): (string | undefined)[] {
83
+ if (field === "producer") return [undefined, ...new Set(inScope.map((a) => a.producer))];
84
+ if (field === "type") return [undefined, ...new Set(inScope.map((a) => a.type))];
85
+ if (field === "feedback") return [undefined, "open"];
86
+ if (field === "verdict") return [undefined, "pass", "fail"];
87
+ return [undefined, "24h", "7d", "30d"]; // since ("all" is the undefined chip)
88
+ }
89
+ function cycleFilter(f: BrowserFilters, field: FilterField, dir: 1 | -1, inScope: Artifact[]): BrowserFilters {
90
+ const values = fieldValues(field, inScope);
91
+ const cur = values.indexOf(f[field] as string | undefined);
92
+ const next = values[(cur + dir + values.length) % values.length];
93
+ return { ...f, [field]: next };
94
+ }
95
+
96
+ function badgeText(r: Extract<ShelfRow, { kind: "artifact" }>): { text: string; color?: string } | null {
97
+ if (r.badges.openFeedback > 0) return { text: `⚑ ${r.badges.openFeedback} open`, color: "yellow" };
98
+ if (r.badges.verdict === "fail") return { text: "✗ fail", color: "red" };
99
+ if (r.badges.approved || r.badges.verdict === "pass") return { text: "✓", color: "green" };
100
+ return null;
101
+ }
102
+
103
+ /** Open feedback the reader shows: actionable annotations only — approvals are state, not feedback. */
104
+ function openFeedback(ws: string, handle: string) {
105
+ return listAnnotations(ws, handle, { status: "open" }).filter((an) => an.kind !== "approval");
106
+ }
107
+
108
+ export function ArtifactBrowser(props: {
109
+ ws: string;
110
+ width: number;
111
+ rows: number;
112
+ rootRunId?: string;
113
+ st: BrowserUiState;
114
+ /** Data-change counter from App: a background settle that produced artifacts increments it, which
115
+ * is what invalidates the memoized disk pipeline below — onStep-driven re-renders reuse it. */
116
+ bump?: number;
117
+ /** Accepts a functional update so ACCUMULATING inputs (typed feedback/search, scroll) survive two
118
+ * keys arriving in one stdin chunk — ink dispatches both against the same render's closure. */
119
+ onChange: (next: BrowserUiState | ((prev: BrowserUiState) => BrowserUiState)) => void;
120
+ keyRef: React.MutableRefObject<CardKeyHandler | null>;
121
+ onClose: () => void;
122
+ /** App-owned GC (it computes the protected refs from traces + task refs); dryRun previews. */
123
+ gcRun?: (dryRun: boolean) => GcReport;
124
+ onSubmitChat?: (text: string) => void; // Phase 5: the reader's `r` verb
125
+ }) {
126
+ const { ws, st, onChange } = props;
127
+ const { suspendTerminal } = useApp(); // Ink 7.1.0: hand the tty to the captain's editor, then repaint
128
+
129
+ // Data pipeline — pure functions over the stores, MEMOIZED: App re-renders on every live
130
+ // background onStep event (statuses) and on the block ticker, and this pipeline is synchronous
131
+ // filesystem I/O (ledgers, traces, annotations per row). Disk changes the shelf must see arrive
132
+ // via `bump` (settle-with-artifacts) or a browser state change (scope/filters/verbs re-key it).
133
+ const filters: BrowserFilters = st.search != null ? { ...st.filters, q: st.search } : st.filters;
134
+ const filterKey = JSON.stringify(filters);
135
+ const { inScope, matched, rows } = useMemo(() => {
136
+ const inScope = resolveScope(ws, st.scope, { rootRunId: props.rootRunId });
137
+ const matched = applyFilters(ws, inScope, JSON.parse(filterKey) as BrowserFilters);
138
+ return { inScope, matched, rows: shelfRows(ws, matched, st.scope, st.sort) };
139
+ // eslint-disable-next-line react-hooks/exhaustive-deps
140
+ }, [ws, st.scope, st.sort, filterKey, props.rootRunId, props.bump, st.note, st.gcPreview]);
141
+ const arts = artifactRows(rows);
142
+ const sel = Math.min(st.sel, Math.max(0, arts.length - 1));
143
+ const rowArtifact: Artifact | undefined = arts[sel]?.artifact;
144
+ // The reader resolves its PINNED handle (an exact version via `v` wins); the shelf's row highlight
145
+ // may drift with a reorder — cosmetic — but the read/annotate/approve target never does.
146
+ const pinned = st.reading ? (st.versionOverride ?? st.readingHandle) : null;
147
+ const current: Artifact | undefined = pinned ? readArtifact(ws, pinned) ?? rowArtifact : rowArtifact;
148
+ // The pinned artifact's position in the CURRENT list (for ←/→ + the "n/m" label), found by id.
149
+ const pinnedIdx = current ? arts.findIndex((r) => r.artifact.id === current.id) : -1;
150
+ const readerIdx = pinnedIdx >= 0 ? pinnedIdx : sel;
151
+
152
+ const handler: CardKeyHandler = (input, key) => {
153
+ if (st.reading) {
154
+ // `a` inline feedback input owns printable keys while open.
155
+ if (st.feedback != null) {
156
+ if (key.escape) { onChange({ ...st, feedback: null }); return; }
157
+ if (key.return) {
158
+ const body = st.feedback.trim();
159
+ if (body && current) {
160
+ const an = annotateArtifact(ws, { target: artifactHandle(current), author: "human", body, kind: "feedback" });
161
+ onChange({ ...st, feedback: null, note: `✎ feedback on ${an.target}` });
162
+ } else onChange({ ...st, feedback: null });
163
+ return;
164
+ }
165
+ if (key.backspace || key.delete) { onChange((prev) => ({ ...prev, feedback: (prev.feedback ?? "").slice(0, -1) })); return; }
166
+ if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
167
+ onChange((prev) => ({ ...prev, feedback: (prev.feedback ?? "") + input }));
168
+ return;
169
+ }
170
+ return;
171
+ }
172
+ // `v` versions jump list.
173
+ if (st.versionsOpen && current) {
174
+ const versions = artifactVersions(ws, current.id);
175
+ if (key.escape) { onChange({ ...st, versionsOpen: false }); return; }
176
+ if (key.upArrow) { onChange({ ...st, versionSel: Math.max(0, st.versionSel - 1) }); return; }
177
+ if (key.downArrow) { onChange({ ...st, versionSel: Math.min(versions.length - 1, st.versionSel + 1) }); return; }
178
+ if (key.return) {
179
+ const v = versions[st.versionSel];
180
+ onChange({ ...st, versionsOpen: false, versionOverride: v ? `${current.id}@v${v}` : null, scroll: 0 });
181
+ return;
182
+ }
183
+ return;
184
+ }
185
+ if (key.escape) { onChange({ ...st, reading: false, readingHandle: null, scroll: 0, versionOverride: null, note: undefined }); return; }
186
+ if (key.upArrow) { onChange((prev) => ({ ...prev, scroll: Math.max(0, prev.scroll - 1) })); return; }
187
+ if (key.downArrow) {
188
+ // clamp HERE, not just at render — a render-only clamp accumulates invisible scroll debt
189
+ // that ↑ must repay one press at a time (the old viewer clamped in the handler too).
190
+ const max = current ? Math.max(0, readerBodyLines(ws, artifactHandle(current), props.width - 4).length - READER_LINES) : 0;
191
+ onChange((prev) => ({ ...prev, scroll: Math.min(max, prev.scroll + 1) }));
192
+ return;
193
+ }
194
+ if (key.leftArrow) {
195
+ const prevArt = arts[Math.max(0, readerIdx - 1)]?.artifact;
196
+ if (prevArt) onChange({ ...st, sel: Math.max(0, readerIdx - 1), readingHandle: artifactHandle(prevArt), scroll: 0, versionOverride: null, note: undefined });
197
+ return;
198
+ }
199
+ if (key.rightArrow) {
200
+ const nextArt = arts[Math.min(arts.length - 1, readerIdx + 1)]?.artifact;
201
+ if (nextArt) onChange({ ...st, sel: Math.min(arts.length - 1, readerIdx + 1), readingHandle: artifactHandle(nextArt), scroll: 0, versionOverride: null, note: undefined });
202
+ return;
203
+ }
204
+ if (input === "a") { onChange({ ...st, feedback: "" }); return; }
205
+ if (input === "y" && current) {
206
+ const an = annotateArtifact(ws, { target: artifactHandle(current), author: "human", body: "approved by captain", kind: "approval" });
207
+ onChange({ ...st, note: `✓ approved ${an.target}` });
208
+ return;
209
+ }
210
+ if (input === "v" && current) { onChange({ ...st, versionsOpen: true, versionSel: Math.max(0, artifactVersions(ws, current.id).length - 1) }); return; }
211
+ // `r` request revision (Phase 5): composes and submits a NORMAL chat turn — root plans/delegates
212
+ // it, approvals apply, and the revision run's completion re-docks the browser with the new
213
+ // version on top. One key, not a new run type; the money it spends goes through the same gates
214
+ // a typed request would.
215
+ if (input === "r" && current && props.onSubmitChat) {
216
+ const fb = openFeedback(ws, artifactHandle(current)).map((an) => an.body).join("; ");
217
+ props.onSubmitChat(`revise ${artifactHandle(current)}: ${fb || "the captain requests a revision"}`);
218
+ return;
219
+ }
220
+ if (input === "o" && current) {
221
+ const path = artifactBodyPath(ws, artifactHandle(current));
222
+ if (!path) {
223
+ const uri = current.location.kind === "external" ? current.location.uri : "(no local file)";
224
+ onChange({ ...st, note: `external — no local file: ${uri}` });
225
+ return;
226
+ }
227
+ // Plan 23: hand the terminal to the captain's editor ($EDITOR/nano) via Ink's suspendTerminal,
228
+ // wait for it to quit, then repaint — so a TERMINAL editor (nano/vim) actually gets the tty.
229
+ void editFileInTerminal(suspendTerminal, path).then((r) => onChange((prev) => ({ ...prev, note: r.note })));
230
+ return;
231
+ }
232
+ return; // reader consumes everything else
233
+ }
234
+ // ── shelf ──
235
+ // `g` dry-run preview awaiting confirm.
236
+ if (st.gcPreview) {
237
+ if (key.return && props.gcRun) {
238
+ // TOCTOU guard: the store may have changed while the confirm line sat waiting (a background
239
+ // save mints versions). Re-dry and compare — if the would-archive set moved, REFRESH the
240
+ // preview instead of archiving things the captain never saw; a second ⏎ confirms the new set.
241
+ const recheck = props.gcRun(true);
242
+ const same = recheck.archived.length === st.gcPreview.length && recheck.archived.every((h, i) => st.gcPreview![i] === h);
243
+ if (!same) {
244
+ onChange({ ...st, gcPreview: recheck.archived, note: "store changed while confirming — review the new set" });
245
+ return;
246
+ }
247
+ const r = props.gcRun(false);
248
+ onChange({ ...st, gcPreview: null, note: `gc: archived ${r.archived.length} version(s), kept ${r.kept}` });
249
+ return;
250
+ }
251
+ if (key.escape) { onChange({ ...st, gcPreview: null }); return; }
252
+ return;
253
+ }
254
+ // "/" search owns printable keys while open: type to narrow, ⏎ keeps the query, esc clears it.
255
+ if (st.search != null) {
256
+ if (key.escape) { onChange({ ...st, search: null }); return; } // esc DISCARDS the query
257
+ // ⏎ KEEPS the query: it moves into filters.q (the honesty line stays narrowed) and the keys
258
+ // return to the shelf so the found row can actually be navigated and opened. `/` reopens with
259
+ // the kept query preloaded for editing.
260
+ if (key.return) { onChange({ ...st, search: null, filters: { ...st.filters, q: st.search.trim() || undefined } }); return; }
261
+ if (key.backspace || key.delete) { onChange((prev) => ({ ...prev, search: (prev.search ?? "").slice(0, -1) })); return; }
262
+ if (input && !key.ctrl && !key.meta && !key.upArrow && !key.downArrow && !key.tab) {
263
+ onChange((prev) => ({ ...prev, search: (prev.search ?? "") + input, sel: 0 }));
264
+ return;
265
+ }
266
+ return;
267
+ }
268
+ // the `f` chip row: ←/→ field, ↑↓ value (applies LIVE), x clear all, ⏎/esc close.
269
+ if (st.filterOpen) {
270
+ const field = FILTER_FIELDS[st.filterField]!;
271
+ if (key.escape || key.return) { onChange({ ...st, filterOpen: false }); return; }
272
+ if (key.leftArrow) { onChange({ ...st, filterField: (st.filterField + FILTER_FIELDS.length - 1) % FILTER_FIELDS.length }); return; }
273
+ if (key.rightArrow) { onChange({ ...st, filterField: (st.filterField + 1) % FILTER_FIELDS.length }); return; }
274
+ if (key.upArrow) { onChange({ ...st, filters: cycleFilter(st.filters, field, -1, inScope), sel: 0 }); return; }
275
+ if (key.downArrow) { onChange({ ...st, filters: cycleFilter(st.filters, field, 1, inScope), sel: 0 }); return; }
276
+ if (input === "x") { onChange({ ...st, filters: {}, sel: 0 }); return; }
277
+ return;
278
+ }
279
+ if (key.escape) { props.onClose(); return; }
280
+ if (input === "/") { onChange({ ...st, search: st.filters.q ?? "", filters: { ...st.filters, q: undefined } }); return; }
281
+ if (input === "f") { onChange({ ...st, filterOpen: true, filterField: 0 }); return; }
282
+ if (input === "g" && st.scope === "all" && props.gcRun) {
283
+ const dry = props.gcRun(true);
284
+ onChange({ ...st, gcPreview: dry.archived, note: undefined });
285
+ return;
286
+ }
287
+ if (key.upArrow) { onChange({ ...st, sel: Math.max(0, sel - 1) }); return; }
288
+ if (key.downArrow) { onChange({ ...st, sel: Math.min(Math.max(0, arts.length - 1), sel + 1) }); return; }
289
+ if (key.return) { if (rowArtifact) onChange({ ...st, reading: true, readingHandle: artifactHandle(rowArtifact), scroll: 0, note: undefined }); return; }
290
+ if (key.tab) {
291
+ const i = SCOPES.findIndex((s) => s.key === st.scope);
292
+ onChange({ ...st, scope: SCOPES[(i + 1) % SCOPES.length]!.key, sel: 0, hint: undefined });
293
+ return;
294
+ }
295
+ if (input === "1" || input === "2" || input === "3") {
296
+ onChange({ ...st, scope: SCOPES[Number(input) - 1]!.key, sel: 0, hint: undefined });
297
+ return;
298
+ }
299
+ if (input === "s" && st.scope === "all") {
300
+ const order: BrowserSort[] = ["run", "time", "producer"];
301
+ onChange({ ...st, sort: order[(order.indexOf(st.sort) + 1) % order.length]! });
302
+ return;
303
+ }
304
+ // consume everything else — the browser owns the keyboard while docked
305
+ };
306
+
307
+ // Publish the key handler DURING RENDER, not in an effect — the boot-registered useInput in App
308
+ // forwards the very next keystroke here, and an effect lands a beat after the frame that shows the
309
+ // dock, dropping that first key (the exact ink registration race ProposalCard solved; a ⏎ typed
310
+ // right after "ARTIFACTS" appears must already find the handler). Cleanup runs on unmount only.
311
+ props.keyRef.current = handler;
312
+ useEffect(() => () => { props.keyRef.current = null; }, []);
313
+
314
+ // ── READER (full-screen) ──────────────────────────────────────────────────────────────────────
315
+ if (st.reading && current) {
316
+ const handle = artifactHandle(current);
317
+ const bodyLines = readerBodyLines(ws, handle, props.width - 4);
318
+ const visible = Math.min(READER_LINES, bodyLines.length);
319
+ const maxScroll = Math.max(0, bodyLines.length - visible);
320
+ const scroll = Math.min(st.scroll, maxScroll);
321
+ const open = openFeedback(ws, handle);
322
+ const versions = artifactVersions(ws, current.id);
323
+
324
+ if (st.versionsOpen) {
325
+ return (
326
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width}>
327
+ <Text color="cyan" bold>Versions of {current.id} (↑↓ move · ⏎ open · esc back)</Text>
328
+ <Text> </Text>
329
+ {versions.map((v, i) => {
330
+ const on = i === st.versionSel;
331
+ return <Text key={v} color={on ? "cyan" : undefined} bold={on}>{on ? "▸ " : " "}{current.id}@v{v}</Text>;
332
+ })}
333
+ </Box>
334
+ );
335
+ }
336
+
337
+ return (
338
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width}>
339
+ <Text dimColor>← esc / {current.title}</Text>
340
+ <Text color="cyan" bold>{handle} · {current.producer} · {ageLabel(current.created)} ago · {readerIdx + 1}/{arts.length} · {current.runId}</Text>
341
+ <Text> </Text>
342
+ {bodyLines.slice(scroll, scroll + visible).map((line, i) => (
343
+ <Text key={i}>{line || " "}</Text>
344
+ ))}
345
+ {maxScroll > 0 && <Text dimColor> ↑↓ scroll · {bodyLines.length - visible - scroll} more lines</Text>}
346
+ {open.length > 0 && <Text> </Text>}
347
+ {open.length > 0 && <Text color="yellow">⚑ open feedback ({open.length})</Text>}
348
+ {open.slice(0, 3).map((an) => (
349
+ <Text key={an.id} color="yellow"> {an.author} — {an.body.slice(0, props.width - 12)}</Text>
350
+ ))}
351
+ {st.note && <Text color="green">{st.note}</Text>}
352
+ <Text> </Text>
353
+ {st.feedback != null ? (
354
+ <Text><Text dimColor>feedback ▸ </Text>{st.feedback}<Text color="cyan">▏</Text><Text dimColor> (⏎ save · esc cancel)</Text></Text>
355
+ ) : (
356
+ <Text dimColor>a annotate · y approve · r revise · v versions · o $EDITOR · ←/→ prev/next · ↑/↓ scroll · esc shelf</Text>
357
+ )}
358
+ </Box>
359
+ );
360
+ }
361
+
362
+ // ── SHELF (docked) ────────────────────────────────────────────────────────────────────────────
363
+ const twoPane = props.width >= MIN_PANE_COLS * 2 && props.rows >= MIN_PANE_ROWS;
364
+ const scopeTabs = SCOPES.map((s) => {
365
+ const on = s.key === st.scope;
366
+ return (
367
+ <Text key={s.key} color={on ? "cyan" : "gray"} bold={on} inverse={on}>{` ${s.label} `}</Text>
368
+ );
369
+ });
370
+
371
+ // Visible list window around the selection.
372
+ let firstArtifactRowIndex = 0;
373
+ const selRowIndex = rows.findIndex((r) => r.kind === "artifact" && r.artifact === rowArtifact);
374
+ if (selRowIndex > SHELF_LIST_LINES - 2) firstArtifactRowIndex = selRowIndex - (SHELF_LIST_LINES - 2);
375
+ const visibleRows = rows.slice(firstArtifactRowIndex, firstArtifactRowIndex + SHELF_LIST_LINES);
376
+
377
+ const previewLines: string[] = [];
378
+ if (rowArtifact) {
379
+ const body = readArtifactBody(ws, artifactHandle(rowArtifact))?.toString("utf8") ?? "";
380
+ previewLines.push(...body.split("\n").slice(0, SHELF_LIST_LINES - 2));
381
+ }
382
+
383
+ const list = (
384
+ <Box flexDirection="column" width={twoPane ? Math.floor(props.width * 0.47) : undefined}>
385
+ {visibleRows.map((r, i) => {
386
+ if (r.kind === "header") return <Text key={`h${i}`} dimColor>{r.label}</Text>;
387
+ const selected = r.artifact === rowArtifact;
388
+ const badge = badgeText(r);
389
+ return (
390
+ <Text key={artifactHandle(r.artifact)} color={selected ? "cyan" : undefined} bold={selected}>
391
+ {selected ? "▸ " : " "}{artifactHandle(r.artifact)} {r.artifact.producer} {ageLabel(r.artifact.created)}
392
+ {badge ? " " : ""}{badge && <Text color={badge.color}>{badge.text}</Text>}
393
+ </Text>
394
+ );
395
+ })}
396
+ {rows.length === 0 && <Text dimColor> (no artifacts in this scope)</Text>}
397
+ </Box>
398
+ );
399
+
400
+ return (
401
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={props.width} marginTop={1}>
402
+ <Box>
403
+ <Text color="cyan" bold>ARTIFACTS </Text>
404
+ {scopeTabs}
405
+ <Text dimColor> </Text>
406
+ <Text color={matched.length === inScope.length ? undefined : "yellow"} dimColor={matched.length === inScope.length}>
407
+ {countLine(matched.length, inScope.length)}
408
+ </Text>
409
+ {st.filters.q && st.search == null && <Text color="yellow"> · /{st.filters.q}</Text>}
410
+ {st.hint && <Text color="yellow"> · {st.hint}</Text>}
411
+ </Box>
412
+ {st.filterOpen && (
413
+ <Box>
414
+ <Text color="cyan" bold>FILTER </Text>
415
+ {FILTER_FIELDS.map((field, i) => {
416
+ const on = i === st.filterField;
417
+ const value = (st.filters[field] as string | undefined) ?? "any";
418
+ return (
419
+ <Text key={field} color={on ? "cyan" : "gray"} bold={on}>
420
+ {field} <Text color={value === "any" ? "gray" : "yellow"}>{value}</Text>{i < FILTER_FIELDS.length - 1 ? " · " : ""}
421
+ </Text>
422
+ );
423
+ })}
424
+ </Box>
425
+ )}
426
+ {st.filterOpen && <Text dimColor> ←/→ field · ↑↓ value (live) · x clear · ⏎/esc close</Text>}
427
+ {st.search != null && (
428
+ <Text><Text dimColor>/</Text>{st.search}<Text color="cyan">▏</Text><Text dimColor> searching title + summary… (⏎ keep · esc clear)</Text></Text>
429
+ )}
430
+ {st.gcPreview && (
431
+ <Box flexDirection="column">
432
+ <Text color="yellow">gc would archive {st.gcPreview.length} version(s){st.gcPreview.length ? `: ${st.gcPreview.slice(0, 6).join(", ")}${st.gcPreview.length > 6 ? "…" : ""}` : " (nothing to collect)"}</Text>
433
+ <Text dimColor> ⏎ archive them · esc cancel</Text>
434
+ </Box>
435
+ )}
436
+ {st.note && <Text color="green">{st.note}</Text>}
437
+ <Text> </Text>
438
+ {twoPane ? (
439
+ <Box>
440
+ {list}
441
+ <Box flexDirection="column" marginLeft={2} flexGrow={1}>
442
+ {rowArtifact && <Text color="cyan">{artifactHandle(rowArtifact)} · {rowArtifact.producer} · {ageLabel(rowArtifact.created)} · {sel + 1}/{arts.length}</Text>}
443
+ {previewLines.map((l, i) => <Text key={i} wrap="truncate">{l}</Text>)}
444
+ </Box>
445
+ </Box>
446
+ ) : list}
447
+ <Text> </Text>
448
+ <Text dimColor>↑↓ move · ⏎ read · tab/1·2·3 scope · f filter · / search{st.scope === "all" ? " · s sort · g gc" : ""} · esc chat</Text>
449
+ </Box>
450
+ );
451
+ }
@@ -0,0 +1,146 @@
1
+ /** Plan 24: the REPL's message editor. Controlled (parent owns `value`), so the browser dock can unmount
2
+ * and remount it without losing the draft. The cursor is local STATE (not a ref) — a cursor-only move
3
+ * (←/→/word/home/end) changes no text, so `onChange` gets the same value and the parent bails out; only
4
+ * a `setCursor` re-render keeps the visible block cursor in sync with the logical position. Maps each key
5
+ * to an action via classifyKey, applies it to the text buffer / history, renders a bordered box that
6
+ * wraps as one paragraph. Enter submits; Shift+Enter / Ctrl+J insert a newline. ↑/↓ move by line inside a
7
+ * multi-line message, and only at the top/bottom edge do they drive the suggester menu or browse history. */
8
+ import { useRef, useReducer, type ReactNode } from "react";
9
+ import { Box, Text, useInput } from "ink";
10
+ import * as tb from "./text-buffer";
11
+ import { classifyKey } from "./input-keys";
12
+ import { histStart, histPrev, histNext, type HistNav } from "./input-history";
13
+
14
+ export interface ChatInputProps {
15
+ value: string;
16
+ onChange: (v: string) => void;
17
+ onSubmit: (v: string) => void;
18
+ history: string[];
19
+ isActive: boolean;
20
+ suggestOpen: boolean;
21
+ onSuggestNav?: (dir: -1 | 1) => void;
22
+ onSuggestAccept?: () => void;
23
+ placeholder?: string;
24
+ width: number;
25
+ dimmed?: boolean;
26
+ busy?: boolean; // a run is live → the prompt becomes ❯ (you can still type to steer)
27
+ }
28
+
29
+ export function ChatInput(props: ChatInputProps) {
30
+ // The cursor is a REF, not state: two keys can arrive before React re-renders, and each must read the
31
+ // LATEST cursor (state would be stale on the second, so "←←" would only move once). A `bump()` forces
32
+ // the re-render that state would have given us — needed because a cursor-only move changes no text, so
33
+ // onChange hands the parent the same value and it bails out (no repaint).
34
+ const cursor = useRef(props.value.length);
35
+ const lastValue = useRef(props.value);
36
+ const nav = useRef<HistNav>(histStart());
37
+ const [, bump] = useReducer((n: number) => n + 1, 0);
38
+
39
+ // If the PARENT changed value out from under us (submit clears, accept-suggestion fills, a set), snap the
40
+ // cursor to the end and reset history browsing.
41
+ if (props.value !== lastValue.current) {
42
+ lastValue.current = props.value;
43
+ cursor.current = props.value.length;
44
+ nav.current = histStart();
45
+ }
46
+ if (cursor.current > props.value.length) cursor.current = props.value.length; // defensive clamp
47
+ const cur = cursor.current;
48
+
49
+ // The ONE path that changes value/cursor. `bump()` re-renders even for a cursor-ONLY move (where the
50
+ // parent bails on the same value) — that is what keeps the visible block cursor in sync.
51
+ const commit = (value: string, nextCursor: number) => {
52
+ cursor.current = nextCursor;
53
+ lastValue.current = value;
54
+ props.onChange(value);
55
+ bump();
56
+ };
57
+ const apply = (next: tb.Buf) => commit(next.value, next.cursor);
58
+ const buf = (): tb.Buf => ({ value: props.value, cursor: cursor.current });
59
+
60
+ useInput(
61
+ (input, key) => {
62
+ // Tab accepts the highlighted command.
63
+ if (props.suggestOpen && key.tab) { props.onSuggestAccept?.(); return; }
64
+
65
+ // ↑/↓: move by LINE inside a multi-line message first; only at the top/bottom edge do they drive the
66
+ // command menu (while typing a new command) or browse history. History is a STICKY mode — once you've
67
+ // stepped into it (idx !== -1), ↑/↓ keep browsing even if a recalled "/command" re-opens the suggester.
68
+ if (key.upArrow || key.downArrow) {
69
+ const up = key.upArrow;
70
+ const atEdge = up ? tb.isOnFirstLine(props.value, cursor.current) : tb.isOnLastLine(props.value, cursor.current);
71
+ if (!atEdge) return apply(up ? tb.lineUp(buf()) : tb.lineDown(buf()));
72
+ const browsingHistory = nav.current.idx !== -1;
73
+ if (props.suggestOpen && !browsingHistory) { props.onSuggestNav?.(up ? -1 : 1); return; }
74
+ const r = up ? histPrev(nav.current, props.history, props.value) : histNext(nav.current, props.history);
75
+ if (r) { nav.current = r.nav; commit(r.value, r.value.length); }
76
+ return;
77
+ }
78
+
79
+ const a = classifyKey(input, key);
80
+ switch (a.kind) {
81
+ case "newline": { nav.current = histStart(); return apply(tb.insert(buf(), "\n")); } // Shift+Enter / Ctrl+J
82
+ case "insert": { nav.current = histStart(); return apply(tb.insert(buf(), a.text)); }
83
+ case "backspace": return apply(tb.backspace(buf()));
84
+ case "del": return apply(tb.del(buf()));
85
+ case "left": return apply(tb.left(buf()));
86
+ case "right": return apply(tb.right(buf()));
87
+ case "home": return apply(tb.home(buf()));
88
+ case "end": return apply(tb.end(buf()));
89
+ case "wordLeft": return apply(tb.wordLeft(buf()));
90
+ case "wordRight": return apply(tb.wordRight(buf()));
91
+ case "deleteWordBack": { nav.current = histStart(); return apply(tb.deleteWordBack(buf())); }
92
+ case "deleteWordForward": { nav.current = histStart(); return apply(tb.deleteWordForward(buf())); }
93
+ case "submit": { nav.current = histStart(); props.onSubmit(props.value); return; }
94
+ case "historyPrev": case "historyNext": return; // ↑/↓ handled above
95
+ case "noop": return;
96
+ }
97
+ },
98
+ { isActive: props.isActive },
99
+ );
100
+
101
+ // ONE Text node (prompt + content) so a long message WRAPS as a single clean paragraph inside the box.
102
+ const accent = props.dimmed ? "gray" : "cyan";
103
+ return (
104
+ <Box borderStyle="round" borderColor={accent} paddingX={1} width={props.width}>
105
+ <Text>
106
+ <Text color={accent}>{props.busy ? "❯ " : "> "}</Text>
107
+ {renderInner(props.value, cur, props.placeholder)}
108
+ </Text>
109
+ </Box>
110
+ );
111
+ }
112
+
113
+ // Ink drops color/inverse styling when stdout is not a TTY (i.e. under the test harness), which makes an
114
+ // inverse block cursor invisible to tests. So off-TTY we render a VISIBLE caret (▏) at the cursor position
115
+ // — the block-cursor position becomes assertable, and this exact desync bug can never regress silently.
116
+ const CARET_VISIBLE = !process.stdout.isTTY;
117
+
118
+ /** The value with the cursor. Real terminal: an inverse block on the char under the cursor. Off-TTY
119
+ * (tests): a visible caret `▏` immediately BEFORE that char, so its position shows in a stripped frame. */
120
+ function renderInner(value: string, cursor: number, placeholder?: string): ReactNode {
121
+ const before = value.slice(0, cursor);
122
+ const at = value.slice(cursor, cursor + 1);
123
+ const after = value.slice(cursor + 1);
124
+ if (value.length === 0) {
125
+ return (
126
+ <>
127
+ {CARET_VISIBLE ? <Text>▏</Text> : <Text inverse> </Text>}
128
+ {placeholder ? <Text dimColor>{placeholder}</Text> : null}
129
+ </>
130
+ );
131
+ }
132
+ // Off-TTY: a bar caret `▏` immediately before the char under the cursor (position is assertable).
133
+ if (CARET_VISIBLE) return (<>{before}<Text>▏</Text>{at}{after}</>);
134
+ // On a real terminal: an inverse BLOCK on the char under the cursor. When that char is a newline (or the
135
+ // cursor sits at end-of-text) highlight a space instead — an inverse `\n` renders oddly — then still emit
136
+ // the line break so the following text stays on its own row.
137
+ const onBreak = at === "" || at === "\n";
138
+ return (
139
+ <>
140
+ {before}
141
+ <Text inverse>{onBreak ? " " : at}</Text>
142
+ {at === "\n" ? "\n" : null}
143
+ {after}
144
+ </>
145
+ );
146
+ }