@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.
- package/README.md +4 -0
- package/package.json +46 -0
- package/src/index.tsx +334 -0
- package/src/ui/AgentBlock.tsx +183 -0
- package/src/ui/App.tsx +1404 -0
- package/src/ui/ArtifactBrowser.tsx +451 -0
- package/src/ui/ChatInput.tsx +146 -0
- package/src/ui/OperationView.tsx +185 -0
- package/src/ui/OrgBrowser.tsx +489 -0
- package/src/ui/PlanPanel.tsx +83 -0
- package/src/ui/ProposalCard.tsx +96 -0
- package/src/ui/QuestionCard.tsx +61 -0
- package/src/ui/SquadPanes.tsx +155 -0
- package/src/ui/StatusBar.tsx +72 -0
- package/src/ui/WorkflowBrowser.tsx +141 -0
- package/src/ui/banner.ts +10 -0
- package/src/ui/browser-model.ts +170 -0
- package/src/ui/editor-handoff.ts +51 -0
- package/src/ui/input-history.ts +55 -0
- package/src/ui/input-keys.ts +51 -0
- package/src/ui/input.ts +18 -0
- package/src/ui/markdown-stream.ts +31 -0
- package/src/ui/markdown.ts +45 -0
- package/src/ui/org-browser-model.ts +43 -0
- package/src/ui/slash.ts +270 -0
- package/src/ui/text-buffer.ts +92 -0
- package/src/ui/transcript-hydrate.ts +51 -0
- package/src/ui/transcript-style.ts +124 -0
- package/src/ui/workflow-browser-model.ts +65 -0
|
@@ -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
|
+
}
|