zcode-acp-server 0.21.3 → 0.22.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.
Files changed (58) hide show
  1. package/README.md +6 -7
  2. package/dist/backend/sandbox.d.ts.map +1 -1
  3. package/dist/backend/sandbox.js +12 -0
  4. package/dist/backend/sandbox.js.map +1 -1
  5. package/dist/cli.d.ts +8 -5
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +22 -14
  8. package/dist/cli.js.map +1 -1
  9. package/dist/handlers/dispatch.d.ts +2 -0
  10. package/dist/handlers/dispatch.d.ts.map +1 -1
  11. package/dist/handlers/dispatch.js +28 -0
  12. package/dist/handlers/dispatch.js.map +1 -1
  13. package/dist/handlers/session.d.ts +17 -1
  14. package/dist/handlers/session.d.ts.map +1 -1
  15. package/dist/handlers/session.js +92 -4
  16. package/dist/handlers/session.js.map +1 -1
  17. package/dist/remote/hub-server.d.ts +7 -7
  18. package/dist/remote/hub-server.d.ts.map +1 -1
  19. package/dist/remote/hub-server.js +33 -33
  20. package/dist/remote/hub-server.js.map +1 -1
  21. package/dist/server.d.ts +5 -0
  22. package/dist/server.d.ts.map +1 -1
  23. package/dist/server.js +6 -0
  24. package/dist/server.js.map +1 -1
  25. package/dist/tui.d.ts +38 -0
  26. package/dist/tui.d.ts.map +1 -0
  27. package/dist/tui.js +96 -0
  28. package/dist/tui.js.map +1 -0
  29. package/docs/CLI.md +33 -76
  30. package/docs/REMOTE-CLIENTS.md +13 -9
  31. package/docs/REMOTE.md +11 -13
  32. package/docs/TROUBLESHOOTING.md +27 -1
  33. package/docs/adr/0007-unified-cli-entry-and-bin-pruning.md +2 -0
  34. package/package.json +3 -7
  35. package/dist/repl/App.d.ts +0 -108
  36. package/dist/repl/App.d.ts.map +0 -1
  37. package/dist/repl/App.js +0 -732
  38. package/dist/repl/App.js.map +0 -1
  39. package/dist/repl/history.d.ts +0 -25
  40. package/dist/repl/history.d.ts.map +0 -1
  41. package/dist/repl/history.js +0 -73
  42. package/dist/repl/history.js.map +0 -1
  43. package/dist/repl/hub.d.ts +0 -87
  44. package/dist/repl/hub.d.ts.map +0 -1
  45. package/dist/repl/hub.js +0 -188
  46. package/dist/repl/hub.js.map +0 -1
  47. package/dist/repl/input-buffer.d.ts +0 -77
  48. package/dist/repl/input-buffer.d.ts.map +0 -1
  49. package/dist/repl/input-buffer.js +0 -174
  50. package/dist/repl/input-buffer.js.map +0 -1
  51. package/dist/repl/model.d.ts +0 -312
  52. package/dist/repl/model.d.ts.map +0 -1
  53. package/dist/repl/model.js +0 -674
  54. package/dist/repl/model.js.map +0 -1
  55. package/dist/repl/run.d.ts +0 -12
  56. package/dist/repl/run.d.ts.map +0 -1
  57. package/dist/repl/run.js +0 -1245
  58. package/dist/repl/run.js.map +0 -1
package/dist/repl/App.js DELETED
@@ -1,732 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /**
3
- * Ink UI for the interactive REPL (bare `zcode-acp`).
4
- *
5
- * Rendering model: the whole app draws in a fixed-height frame managed by
6
- * run.ts (full-screen mode) — the transcript is an in-app viewport, and the
7
- * live turn (streaming text, thinking buffer, tool rows), prompt line, and
8
- * pickers render inside it. A pending permission request takes over the
9
- * input area with an arrow-key picker.
10
- */
11
- import { Box, Static, Text, useInput, usePaste } from "ink";
12
- import Spinner from "ink-spinner";
13
- import { Chalk } from "chalk";
14
- import { Fragment, useCallback, useEffect, useRef, useState } from "react";
15
- import stringWidth from "string-width";
16
- import { applyCompletion, COMPLETION_LIMIT, completionCandidates, estimateLines, isConfigArgumentMenu, isConfigCommand, isOneShotCommandValue, lastToolTail, locateCaret, pickerWindow, relativeTime, selectLabel, wrapEditorLine, } from "./model.js";
17
- import { backspaceAtCaret, caretLeft, caretRight, caretToEnd, caretToStart, createLineEditor, ctrlChord, deleteAtCaret, foldPasteChunk, insertAtCaret, planChunkOps, replaceText, sanitizeInputChunk, } from "./input-buffer.js";
18
- // Fixed-level chalk so colorization is deterministic regardless of TTY
19
- // detection (tests run with level 0; the REPL terminal always takes color).
20
- const color = new Chalk({ level: 2 });
21
- /**
22
- * Colorize markdown code fences: lines between ``` fences render cyan. Line
23
- * level state machine — exported for unit tests.
24
- */
25
- export function colorizeCodeFences(text) {
26
- let inFence = false;
27
- return text
28
- .split("\n")
29
- .map((line) => {
30
- if (line.trimStart().startsWith("```")) {
31
- inFence = !inFence;
32
- return color.dim(line);
33
- }
34
- return inFence ? color.cyan(line) : line;
35
- })
36
- .join("\n");
37
- }
38
- /**
39
- * Esc / Ctrl-C means "dismiss" for every picker (question form, /sessions,
40
- * permission). Both ctrl-c delivery shapes count: a lone \x03 arrives as
41
- * ("c", ctrl) but rapid presses coalesce into one raw chunk with ctrl=false.
42
- */
43
- function isCancelKey(inputChar, key) {
44
- return key.escape || (key.ctrl && inputChar === "c") || (inputChar ?? "").includes("\x03");
45
- }
46
- const TOOL_STATUS_ICON = {
47
- pending: "○",
48
- in_progress: "●",
49
- completed: "✔",
50
- failed: "✘",
51
- };
52
- /**
53
- * Startup welcome panel — the agent-CLI-style hero shown until the first
54
- * prompt pushes it into scrollback: branding, session info, the seeded
55
- * config, and the key hints a first-time user needs.
56
- */
57
- function WelcomeView({ info }) {
58
- const config = [info.model, info.mode, info.thought].filter(Boolean).join(" · ");
59
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "zcode-acp" }), _jsxs(Text, { dimColor: true, children: [" v", info.version] })] }), _jsxs(Text, { dimColor: true, children: ["session \u00B7 ", info.cwd] }), info.sandbox ? _jsx(Text, { color: "green", children: info.sandbox }) : null, config ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "\u25CF " }), _jsx(Text, { bold: true, children: config })] })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: " / command menu — ↑/↓ move · enter picks · esc closes" }), _jsx(Text, { dimColor: true, children: " /model switch model — /mode and /thought likewise" }), _jsx(Text, { dimColor: true, children: " /sessions list and resume past conversations of this project" }), _jsx(Text, { dimColor: true, children: " esc interrupt the running turn" }), _jsx(Text, { dimColor: true, children: " ctrl-c quit — press twice when idle" })] })] }));
60
- }
61
- function EntryView({ entry }) {
62
- switch (entry.kind) {
63
- case "user":
64
- return (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "cyan", bold: true, children: [">", " "] }), _jsx(Text, { children: entry.text })] }));
65
- case "thinking": {
66
- const oneLine = entry.text.replace(/\s+/g, " ").slice(0, 120);
67
- return _jsxs(Text, { dimColor: true, children: ["\u23BF thinking \u00B7 ", oneLine] });
68
- }
69
- case "assistant":
70
- return _jsx(Text, { children: colorizeCodeFences(entry.text) });
71
- case "tool":
72
- return (_jsxs(Text, { children: [_jsxs(Text, { color: entry.status === "failed" ? "red" : entry.status === "completed" ? "green" : "yellow", children: [TOOL_STATUS_ICON[entry.status] ?? "•", " "] }), _jsx(Text, { children: entry.title }), _jsxs(Text, { dimColor: true, children: [" (", entry.status, ")"] })] }));
73
- case "note":
74
- return _jsxs(Text, { dimColor: true, children: ["-- ", entry.text] });
75
- case "plan":
76
- // Plan approvals print the full plan into the scrollback (the footer
77
- // popup cannot scroll a long document); it stays as the record of what
78
- // was approved.
79
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "\u2500\u2500 plan \u2500\u2500" }), _jsx(Text, { children: colorizeCodeFences(entry.text) })] }));
80
- case "welcome":
81
- return _jsx(WelcomeView, { info: entry.info });
82
- }
83
- }
84
- /**
85
- * Derive ink-style key flags for a single char of a COALESCED multi-key chunk
86
- * (fast double-ctrl-c, "\t\t", …). ink delivers such chunks as ONE event with
87
- * the raw string and mostly-false flags, so we decompose per char to keep each
88
- * key's semantics. Chunks containing ESC (arrow-key sequences) are never
89
- * decomposed — the caller leaves those to ink's parser.
90
- */
91
- function derivedKey(ch) {
92
- return {
93
- return: ch === "\r" || ch === "\n",
94
- tab: ch === "\t",
95
- escape: false,
96
- backspace: ch === "\x7f" || ch === "\b",
97
- // Arrow / Delete keys always arrive as escape sequences and are handled
98
- // by ink's parser, never inside a decomposed printable chunk.
99
- forwardDelete: false,
100
- left: false,
101
- right: false,
102
- ctrl: ch >= "\x01" && ch <= "\x1a" && ch !== "\r" && ch !== "\n" && ch !== "\t",
103
- meta: false,
104
- };
105
- }
106
- /**
107
- * Self-managed input line. ink-text-input's submit/clear timing proved
108
- * unreliable under full external rerenders, so the line owns its value and
109
- * handles plain typing, caret-aware editing (←/→ or Ctrl-B/F to move,
110
- * Backspace/Delete at the caret, Ctrl-A/E line jumps, Ctrl-U clear), and
111
- * submit directly.
112
- *
113
- * While the line is a slash command (or a config command's argument), an
114
- * interactive completion menu sits above the box: ↑/↓ move, tab/→ complete
115
- * the highlighted candidate, enter picks it when the line is a partial match
116
- * (exact input sends instead), esc dismisses. ←/→ only move the caret when
117
- * they are not acting as menu/completion keys.
118
- */
119
- function InputLine({ busy, status, editor, applyEdit, onMenuOpenChange, quotaLine, onSubmitText, }) {
120
- // `ed` mirrors props.editor for the render below; edits go through
121
- // applyEdit so run.ts's store stays the single source of truth.
122
- const ed = editor;
123
- const [selIdx, setSelIdx] = useState(0);
124
- const [dismissed, setDismissed] = useState(false);
125
- // Mirror of `ed` for event handlers: a decomposed chunk fires several
126
- // state updates in one tick, so closures over `ed` would see the stale
127
- // pre-chunk line (and submit the wrong text on an embedded \r).
128
- const edRef = useRef(editor);
129
- edRef.current = editor;
130
- // Completion paths replace the whole line — adopt with caret at the end.
131
- const applyValue = (fn) => {
132
- applyEdit((e) => replaceText(fn(e.text)));
133
- };
134
- const submitWith = (text) => {
135
- applyEdit(() => createLineEditor());
136
- setDismissed(false);
137
- if (text.trim())
138
- onSubmitText(text.trim());
139
- };
140
- const candidates = completionCandidates(ed.text, status);
141
- const menu = !dismissed && candidates !== null && candidates.length > 0 ? candidates : null;
142
- // Mirror visibility out of React on every render: the app-level key handler
143
- // may run for the same keystroke and must see the PRE-keystroke value
144
- // (stale-by-one-keystroke is exactly right — it decides esc precedence
145
- // before this component's own listener dismisses anything). Plain store
146
- // assignment, no React state, so this can't loop.
147
- onMenuOpenChange(menu !== null);
148
- // New keystroke → new filter: restart the selection and re-open a
149
- // previously dismissed menu.
150
- useEffect(() => {
151
- setSelIdx(0);
152
- setDismissed(false);
153
- }, [ed.text]);
154
- const handleChar = (ch, k, nav) => {
155
- if (nav || k.tab) {
156
- if (nav?.up) {
157
- setSelIdx((i) => Math.max(0, i - 1));
158
- return;
159
- }
160
- if (nav?.down) {
161
- // Without an open menu there is nothing to move through — swallow
162
- // the press. `menu!` here used to throw inside a setState UPDATER,
163
- // which fires during the next render: React unmounted the whole
164
- // tree and ink restored the terminal, leaving a silent zombie.
165
- if (!menu)
166
- return;
167
- setSelIdx((i) => Math.min(menu.length - 1, i + 1));
168
- return;
169
- }
170
- // tab (or →) completes the highlighted candidate when a menu is open.
171
- // Without one, → falls through to caret movement below so it stays a
172
- // plain editing key; bare tabs are still dropped.
173
- if ((k.tab || nav?.right) && menu) {
174
- const item = menu[selIdx] ?? menu[0];
175
- if (item)
176
- applyValue((v) => applyCompletion(v, item));
177
- return;
178
- }
179
- }
180
- if (k.escape) {
181
- setDismissed(true);
182
- return;
183
- }
184
- if (k.return) {
185
- // With the menu open, Enter PICKS the highlighted candidate — never
186
- // sends the raw partial. It only sends when the line already is the
187
- // exact runnable form: a full NON-config command ("/help", "/exit") or
188
- // a config command with its exact option ("/mode plan"). Config
189
- // commands without an argument open their option menu instead of the
190
- // static listing, so a switch never needs hand-typed input.
191
- if (menu) {
192
- const item = menu[selIdx] ?? menu[0];
193
- const line = edRef.current.text;
194
- const spaceIdx = line.indexOf(" ");
195
- // One-shot commands ("/exit", "/help", "/compact", ...) run ON PICK:
196
- // the highlighted row already names the whole action. Other
197
- // command-name menus — skills, plugins, unknown advertised commands —
198
- // keep fill semantics: their bare form usually expects an argument,
199
- // and sending must stay the user's explicit act.
200
- if (spaceIdx < 0 && isOneShotCommandValue(item.value)) {
201
- submitWith(item.value);
202
- return;
203
- }
204
- // Config argument menus execute ON PICK too ("/model x" switches
205
- // right away): the highlighted row already states the full decision,
206
- // so fill-then-confirm would only add a redundant enter.
207
- if (spaceIdx >= 0 && isConfigArgumentMenu(line)) {
208
- submitWith(`${line.slice(0, spaceIdx + 1)}${item.value}`);
209
- return;
210
- }
211
- if (spaceIdx < 0) {
212
- if (line === item.value && !isConfigCommand(item.value.replace(/^\//, ""))) {
213
- submitWith(line);
214
- }
215
- else {
216
- applyValue(() => applyCompletion(line, item));
217
- }
218
- return;
219
- }
220
- if (line === `${line.slice(0, spaceIdx + 1)}${item.value}`) {
221
- submitWith(line);
222
- }
223
- else {
224
- applyValue(() => applyCompletion(line, item));
225
- }
226
- return;
227
- }
228
- submitWith(edRef.current.text);
229
- return;
230
- }
231
- // Caret-aware edits. ←/→ arrive as escape-sequence chunks (never inside
232
- // coalesced printable chunks), so they ride the k flags below.
233
- if (k.backspace) {
234
- applyEdit(backspaceAtCaret);
235
- return;
236
- }
237
- if (k.forwardDelete) {
238
- applyEdit(deleteAtCaret);
239
- return;
240
- }
241
- if (k.left) {
242
- applyEdit(caretLeft);
243
- return;
244
- }
245
- if (k.right && !menu) {
246
- applyEdit(caretRight);
247
- return;
248
- }
249
- // Readline-standard Ctrl chords: B/F move by char, A/E jump to the
250
- // line's ends, U kills the whole line. Accept both delivery shapes —
251
- // the letter (single keypress) and the raw control byte (coalesced
252
- // chunks) — see ctrlChord().
253
- if (k.ctrl) {
254
- const chord = ctrlChord(ch);
255
- if (chord === "b")
256
- return void applyEdit(caretLeft);
257
- if (chord === "f")
258
- return void applyEdit(caretRight);
259
- if (chord === "a")
260
- return void applyEdit(caretToStart);
261
- if (chord === "e")
262
- return void applyEdit(caretToEnd);
263
- if (chord === "u")
264
- return void applyEdit(() => createLineEditor());
265
- }
266
- if (ch && !k.ctrl && !k.meta) {
267
- // Paste/drop chunks can carry escape bytes (bracketed-paste wrappers,
268
- // image-drop binary) — sanitize before they reach editor state, or
269
- // every later render re-wraps corrupted text.
270
- const clean = sanitizeInputChunk(ch);
271
- if (clean)
272
- applyEdit((cur) => insertAtCaret(cur, clean));
273
- }
274
- };
275
- // Dedicated paste channel (ADR-0009): ink tokenizes ?2004 bracketed pastes
276
- // and delivers them here — markers stripped, newlines intact — and NEVER
277
- // forwards them to useInput while this hook is mounted. ink also arms and
278
- // disarms bracketed-paste mode with this hook's lifecycle.
279
- usePaste((text) => {
280
- applyEdit((cur) => insertAtCaret(cur, foldPasteChunk(text)));
281
- });
282
- useInput((inputChar, key) => {
283
- const applyOps = (chunk) => {
284
- for (const op of planChunkOps(chunk)) {
285
- if (op.kind === "insert") {
286
- applyEdit((cur) => insertAtCaret(cur, op.text));
287
- }
288
- else {
289
- handleChar(op.ch, derivedKey(op.ch), null);
290
- }
291
- }
292
- };
293
- // Legacy-terminal fallback (no ?2004): unwrapped pastes arrive as raw
294
- // coalesced chunks. Require a real newline to fold — lone \r bytes stay
295
- // semantic, so coalesced keystroke bursts that happen to carry an Enter
296
- // ("x\r" from fast typing, a held-down return) keep their submit
297
- // semantics exactly as they had before ADR-0009.
298
- if (inputChar && inputChar.length > 1 && inputChar.includes("\n") && !key.ctrl) {
299
- applyEdit((cur) => insertAtCaret(cur, foldPasteChunk(inputChar)));
300
- return;
301
- }
302
- // Coalesced printable chunk (paste, rapid keys) — batched via
303
- // planChunkOps: printable runs apply as ONE editor op, semantic bytes
304
- // stay per-character. Escape-sequence chunks (arrow keys flushed
305
- // together) keep ink's parsed flags instead.
306
- if (inputChar && inputChar.length > 1 && !inputChar.includes("\x1b") && !key.ctrl) {
307
- applyOps(inputChar);
308
- return;
309
- }
310
- handleChar(inputChar, {
311
- return: key.return,
312
- tab: key.tab,
313
- escape: key.escape,
314
- backspace: key.backspace,
315
- forwardDelete: key.delete,
316
- left: key.leftArrow,
317
- right: key.rightArrow,
318
- ctrl: key.ctrl,
319
- meta: key.meta,
320
- }, key.upArrow || key.downArrow || key.rightArrow
321
- ? { up: key.upArrow, down: key.downArrow, right: key.rightArrow }
322
- : null);
323
- });
324
- // e.g. "GLM-5.3 · build · max" — lives INSIDE the input box (bottom row),
325
- // mirroring the editor dropdown state. The plan-quota suffix rides after it
326
- // ("5h 34% · wk 8%"), clamped so one wrap never changes the box height —
327
- // the transcript viewport slices heights from CHROME_ROWS.
328
- const cols = process.stdout.columns || 100;
329
- const statusLine = [
330
- selectLabel(status.model),
331
- selectLabel(status.mode),
332
- selectLabel(status.thought),
333
- ]
334
- .filter(Boolean)
335
- .join(" · ");
336
- const LEFT_BUDGET = Math.max(12, cols - 40); // right hint ≈ 34 cols + margin
337
- const leftLine = [statusLine, quotaLine].filter(Boolean).join(" · ").slice(0, LEFT_BUDGET);
338
- return (
339
- // width="100%": the chrome parent is a row Box, so without an explicit
340
- // width the whole subtree shrink-wraps to its longest line and the
341
- // bordered input box stops spanning the terminal.
342
- _jsxs(Box, { flexDirection: "column", width: "100%", children: [menu ? (
343
- // Rows are pre-colored as ONE plain string (chalk) instead of a
344
- // <Text color> wrapper — whole-string rows always diff as clean
345
- // full-line replacements. The block ALWAYS paints COMPLETION_LIMIT
346
- // slots (blanks beyond the candidate count), so filtering changes
347
- // only line CONTENT, never height: ink's frame update erases the
348
- // previous frame's line count upward and rewrites, and a height
349
- // change misplaces that sequence — the top candidate row used to
350
- // land off-position and go invisible until the selection moved.
351
- _jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [Array.from({ length: COMPLETION_LIMIT }, (_, i) => {
352
- const m = menu[i];
353
- if (!m) {
354
- return _jsx(Text, { children: " " }, `slot:${i}`);
355
- }
356
- const desc = m.description ? ` — ${m.description}` : "";
357
- const body = `${m.current ? "● " : " "}${m.label}${desc}`;
358
- return (_jsx(Text, { children: i === selIdx ? color.cyan(`❯ ${body}`) : ` ${body}` }, `${i}:${m.label}:${m.value}`));
359
- }), _jsx(Text, { dimColor: true, children: (() => {
360
- // Hint follows the HIGHLIGHTED row: one-shot commands announce
361
- // that enter runs them, config menus that it switches, and
362
- // everything else keeps pick-to-fill.
363
- if (isConfigArgumentMenu(ed.text)) {
364
- return " ↑/↓ select · enter switches now · tab fills · esc dismiss";
365
- }
366
- const picked = menu[selIdx] ?? menu[0];
367
- if (picked && isOneShotCommandValue(picked.value)) {
368
- return " ↑/↓ select · enter runs it now · tab fills · esc dismiss";
369
- }
370
- return " ↑/↓ select · tab or enter picks · esc dismiss";
371
- })() })] })) : null, _jsxs(Box, { borderStyle: "round", borderColor: busy ? "gray" : "cyan", paddingX: 1, width: "100%", flexDirection: "column", children: [(() => {
372
- // Multi-row prompt: text hard-wraps at the inner width and the
373
- // block cursor sits on its OWN visual row — soft-wrap via implicit
374
- // ink layout used to strand the caret visually on the first row.
375
- const prefix = busy ? "starting… " : "❯ ";
376
- const prefixCols = stringWidth(prefix);
377
- const inner = Math.max(4, cols - 2 - prefixCols);
378
- const rows = wrapEditorLine(ed.text, inner);
379
- const pos = locateCaret(ed.text, ed.caret, inner);
380
- return (_jsx(Box, { flexDirection: "column", children: rows.map((r, i) => {
381
- const indent = i === 0 ? prefix : " ".repeat(prefixCols);
382
- if (i !== pos.row) {
383
- return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: indent }), r.text ? _jsx(Text, { children: r.text }) : _jsx(Text, { children: " " })] }, r.start));
384
- }
385
- // Block cursor on the character at the caret (a space past
386
- // the end) — makes ←/→ editing visible.
387
- const parts = Array.from(r.text);
388
- const split = Math.max(0, Math.min(parts.length, pos.rowOffset));
389
- const before = parts.slice(0, split).join("");
390
- const atCaret = parts.slice(split, split + 1).join("");
391
- const after = parts.slice(split + 1).join("");
392
- return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: indent }), _jsx(Text, { children: before }), _jsx(Text, { backgroundColor: "white", color: "black", children: atCaret || " " }), after ? _jsx(Text, { children: after }) : null] }, r.start));
393
- }) }));
394
- })(), _jsxs(Box, { justifyContent: "space-between", width: "100%", children: [_jsx(Text, { dimColor: true, children: leftLine || "type / for commands · tab completes" }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: busy ? "" : "enter send · esc interrupt · ctrl-c quit" })] })] })] }));
395
- }
396
- /**
397
- * Rendered height of one live-turn entry (local twin of the old viewport
398
- * entryHeight): feeds turnHeight's cap math only.
399
- */
400
- function entryHeight(entry, width) {
401
- switch (entry.kind) {
402
- case "user":
403
- return 1 + estimateLines(`> ${entry.text}`, width);
404
- case "welcome":
405
- // Branding + session + sandbox row + config + 5 hint lines + margins.
406
- return 1 + 13;
407
- case "tool":
408
- return estimateLines(`• ${entry.title} (${entry.status})`, width);
409
- default:
410
- return estimateLines(entry.text, width);
411
- }
412
- }
413
- /**
414
- * Height of the live-turn block (entries + streaming buffers + spinner).
415
- * Deliberately biased HIGH: an underestimate makes the whole layout exceed
416
- * the terminal and ink clips from the bottom — the input box disappears. A
417
- * few blank rows at the bottom of the viewport are harmless by contrast.
418
- */
419
- function turnHeight(turn, width) {
420
- if (!turn)
421
- return 0;
422
- let h = turn.entries.reduce((n, e) => n + entryHeight(e, width), 0);
423
- const tail = lastToolTail(turn);
424
- if (tail)
425
- h += estimateLines(tail, width);
426
- if (turn.thinkBuf.trim()) {
427
- // The render collapses whitespace and keeps only the tail, but even that
428
- // can wrap (CJK especially) — measure it instead of assuming one line.
429
- h += estimateLines(`⎿ thinking · ${turn.thinkBuf.replace(/\s+/g, " ").slice(-120)}`, width);
430
- }
431
- if (turn.textBuf)
432
- h += estimateLines(turn.textBuf, width);
433
- return h + 1 + 2; // spinner line + safety margin
434
- }
435
- export function App(props) {
436
- const { entries, turn, permission, question, sessionPick, queued, status, busy } = props;
437
- // Second consecutive idle Ctrl-C exits; a turn-running Ctrl-C only cancels.
438
- const idleIntCount = useRef(0);
439
- // --- live-turn liveness ticker ---
440
- // A turn with no streamed output (model thinking, silent tool calls) would
441
- // otherwise leave the footer frozen — indistinguishable from a stall. The
442
- // status row below re-renders once a second so time visibly progresses.
443
- const [, tick] = useState(0);
444
- const turnStartRef = useRef(null);
445
- if (turn && turnStartRef.current === null)
446
- turnStartRef.current = Date.now();
447
- if (!turn)
448
- turnStartRef.current = null;
449
- const turnActive = turn !== null;
450
- useEffect(() => {
451
- if (!turnActive)
452
- return;
453
- const timer = setInterval(() => tick((n) => n + 1), 1000);
454
- return () => clearInterval(timer);
455
- }, [turnActive]);
456
- // Native-scrollback layout: full terminal width — no centered reading
457
- // column. `rows` still matters for capping the live-turn tail so the input
458
- // box can't be pushed off-screen mid-stream; there is no viewport math.
459
- const cols = process.stdout.columns || 100;
460
- const rows = process.stdout.rows || 24;
461
- const submit = useCallback((text) => props.onSubmit(text), [props]);
462
- // --- picker keyboard state ---
463
- // Pickers are PURE VIEWS; all their keys are handled HERE in the app-level
464
- // useInput. ink's per-component useInput proved unreliable for components
465
- // mounted late via external rerenders (ink.rerender from the run loop):
466
- // the freshly-mounted hook sometimes never receives events. The root-level
467
- // hook always does.
468
- const EMPTY_Q = { index: 0, custom: null, toggled: [] };
469
- const [q, setQ] = useState(EMPTY_Q);
470
- const qRef = useRef(EMPTY_Q);
471
- const applyQ = (fn) => {
472
- qRef.current = fn(qRef.current);
473
- setQ(qRef.current);
474
- };
475
- useEffect(() => {
476
- // New question (or cleared) → fresh selection state.
477
- applyQ(() => EMPTY_Q);
478
- // eslint-disable-next-line react-hooks/exhaustive-deps
479
- }, [question]);
480
- const [permIndex, setPermIndex] = useState(0);
481
- const permIndexRef = useRef(0);
482
- useEffect(() => {
483
- permIndexRef.current = 0;
484
- setPermIndex(0);
485
- }, [permission]);
486
- // /sessions picker selection (mirrored in a ref for the key handler).
487
- const [sessIndex, setSessIndex] = useState(0);
488
- const sessIndexRef = useRef(0);
489
- useEffect(() => {
490
- sessIndexRef.current = 0;
491
- setSessIndex(0);
492
- }, [sessionPick]);
493
- useInput((inputChar, key) => {
494
- if (sessionPick && sessionPick.items.length > 0) {
495
- handleSessionKey(inputChar, key);
496
- return;
497
- }
498
- if (question) {
499
- handleQuestionKey(inputChar, key);
500
- return;
501
- }
502
- if (permission) {
503
- handlePermissionKey(inputChar, key);
504
- return;
505
- }
506
- // Prompt-history recall: the completion menu keeps its claim on ↑/↓ while
507
- // open (its own handler consumes them); with it closed — and no picker
508
- // mounted — the arrows walk submitted prompts (ADR-0008). InputLine's
509
- // handler also sees these keys but only nudges its (reset) selection.
510
- if ((key.upArrow || key.downArrow) &&
511
- !props.isMenuOpen() &&
512
- !sessionPick &&
513
- !question &&
514
- !permission) {
515
- if (key.upArrow)
516
- props.onHistoryUp();
517
- else
518
- props.onHistoryDown();
519
- return;
520
- }
521
- // Esc interrupts the running turn — with or without queued follow-ups;
522
- // the stop handler drains the queue, so a next queued message starts at
523
- // once. With the completion menu open, the menu takes this esc instead
524
- // (dismiss); a second esc reaches here and interrupts.
525
- if (key.escape && turn && !props.isMenuOpen()) {
526
- props.onCancelTurn();
527
- return;
528
- }
529
- // Count ctrl-c presses across both delivery shapes: a lone \x03 arrives
530
- // as ("c", ctrl) but rapid presses coalesce into one raw "\x03\x03"
531
- // chunk with ctrl=false (ink does not split multi-key chunks).
532
- const presses = (key.ctrl && inputChar === "c" ? 1 : 0) + ((inputChar ?? "").match(/\x03/g)?.length ?? 0);
533
- if (presses > 0) {
534
- if (turn) {
535
- props.onCancelTurn();
536
- idleIntCount.current = 0;
537
- }
538
- else {
539
- idleIntCount.current += presses;
540
- if (idleIntCount.current >= 2)
541
- props.onExit();
542
- }
543
- return;
544
- }
545
- idleIntCount.current = 0;
546
- });
547
- /** Keys for the active AskUserQuestion picker (state mirrors in qRef). */
548
- function handleQuestionKey(inputChar, key) {
549
- if (!question)
550
- return;
551
- const isSkip = isCancelKey(inputChar, key);
552
- if (qRef.current.custom !== null) {
553
- if (key.return) {
554
- props.onAnswerQuestion({ picked: [...qRef.current.toggled], custom: qRef.current.custom });
555
- return;
556
- }
557
- if (isSkip) {
558
- applyQ((s) => ({ ...s, custom: null }));
559
- return;
560
- }
561
- if (key.backspace || key.delete) {
562
- applyQ((s) => ({ ...s, custom: (s.custom ?? "").slice(0, -1) }));
563
- return;
564
- }
565
- if (inputChar &&
566
- !key.ctrl &&
567
- !key.meta &&
568
- inputChar.length === 1 &&
569
- !/[\r\n]/.test(inputChar)) {
570
- applyQ((s) => ({ ...s, custom: (s.custom ?? "") + inputChar }));
571
- }
572
- return;
573
- }
574
- if (key.upArrow) {
575
- applyQ((s) => ({ ...s, index: Math.max(0, s.index - 1) }));
576
- return;
577
- }
578
- if (key.downArrow) {
579
- applyQ((s) => ({ ...s, index: Math.min(question.options.length, s.index + 1) }));
580
- return;
581
- }
582
- if (isSkip) {
583
- props.onAnswerQuestion(null); // skip this question
584
- return;
585
- }
586
- const onCustomRow = qRef.current.index === question.options.length;
587
- if (key.return || key.tab) {
588
- if (onCustomRow) {
589
- applyQ((s) => ({ ...s, custom: "" }));
590
- return;
591
- }
592
- if (question.multiSelect) {
593
- props.onAnswerQuestion({ picked: [...qRef.current.toggled], custom: "" });
594
- return;
595
- }
596
- props.onAnswerQuestion({
597
- picked: [question.options[qRef.current.index].value],
598
- custom: "",
599
- });
600
- return;
601
- }
602
- if (inputChar === " " && question.multiSelect && !onCustomRow) {
603
- const value = question.options[qRef.current.index].value;
604
- applyQ((s) => ({
605
- ...s,
606
- toggled: s.toggled.includes(value)
607
- ? s.toggled.filter((v) => v !== value)
608
- : [...s.toggled, value],
609
- }));
610
- }
611
- }
612
- /** Keys for the /sessions picker (selection mirrored in sessIndexRef). */
613
- function handleSessionKey(inputChar, key) {
614
- if (!sessionPick)
615
- return;
616
- const last = sessionPick.items.length - 1;
617
- if (key.upArrow) {
618
- sessIndexRef.current = Math.max(0, sessIndexRef.current - 1);
619
- setSessIndex(sessIndexRef.current);
620
- return;
621
- }
622
- if (key.downArrow) {
623
- sessIndexRef.current = Math.min(last, sessIndexRef.current + 1);
624
- setSessIndex(sessIndexRef.current);
625
- return;
626
- }
627
- if (key.return) {
628
- props.onPickSession(sessionPick.items[sessIndexRef.current].sessionId);
629
- return;
630
- }
631
- if (isCancelKey(inputChar, key)) {
632
- props.onPickSession(null);
633
- }
634
- }
635
- /** Keys for the active permission picker (selection mirrored in permIndexRef). */
636
- function handlePermissionKey(inputChar, key) {
637
- if (!permission)
638
- return;
639
- if (key.upArrow) {
640
- permIndexRef.current = Math.max(0, permIndexRef.current - 1);
641
- setPermIndex(permIndexRef.current);
642
- return;
643
- }
644
- if (key.downArrow) {
645
- permIndexRef.current = Math.min(permission.options.length - 1, permIndexRef.current + 1);
646
- setPermIndex(permIndexRef.current);
647
- return;
648
- }
649
- if (key.return) {
650
- props.onAnswerPermission(permission.options[permIndexRef.current].id);
651
- return;
652
- }
653
- if (isCancelKey(inputChar, key)) {
654
- props.onAnswerPermission(null);
655
- }
656
- }
657
- // The queued-prompts panel sits between the turn block and the input box.
658
- const queuedShown = Math.min(queued.length, 3);
659
- const queueRows = queued.length > 0 ? queuedShown + (queued.length > 3 ? 1 : 0) + 1 : 0;
660
- // The live-turn block is capped at half the screen: the input box must
661
- // stay visible while a long reply streams, and non-alt-screen ink can't
662
- // pin anything — a taller dynamic footer would push it below the fold.
663
- // Older turn entries crop from the top (bottom-anchored + overflow:hidden);
664
- // the full transcript lands in <Static> on stop.
665
- const MAX_TURN_ROWS = Math.max(6, Math.floor(rows / 2));
666
- // Bottom-anchored overflow:hidden also absorbs ink word-wrap vs hard-cut
667
- // estimate drift INSIDE this box: an undercounted row crops invisibly off
668
- // the top instead of stretching the footer past the fold.
669
- const turnBudget = Math.min(turnHeight(turn, cols), MAX_TURN_ROWS);
670
- // Status row for the live-turn block: phase label + elapsed seconds. Bright
671
- // (not dim) and self-updating — this line is the "still alive" signal.
672
- const turnElapsed = turnStartRef.current
673
- ? Math.max(0, Math.floor((Date.now() - turnStartRef.current) / 1000))
674
- : 0;
675
- const turnLabel = turn
676
- ? turn.textBuf
677
- ? "writing"
678
- : turn.thinkBuf.trim()
679
- ? "thinking"
680
- : "working"
681
- : "";
682
- return (_jsxs(Box, { flexDirection: "column", width: cols, children: [_jsx(Static, { items: entries, children: (entry, index) => (_jsx(Box, { flexDirection: "column", width: cols, children: _jsx(EntryView, { entry: entry }) }, index)) }), props.replaying ? (_jsxs(Text, { dimColor: true, children: [_jsx(Spinner, { type: "dots" }), " restoring history\u2026"] })) : null, turn ? (_jsxs(Box, { flexDirection: "column", width: cols, height: turnBudget, overflow: "hidden", justifyContent: "flex-end", children: [(() => {
683
- // Attach the live output tail to the LAST tool row: a long silent
684
- // tool (build, sleep, sub-agent work) otherwise shows nothing but
685
- // its title and the spinner seconds for minutes on end.
686
- let tailIdx = -1;
687
- for (let i = turn.entries.length - 1; i >= 0; i--) {
688
- if (turn.entries[i].kind === "tool") {
689
- tailIdx = i;
690
- break;
691
- }
692
- }
693
- const tail = lastToolTail(turn);
694
- return turn.entries.map((e, i) => (_jsxs(Fragment, { children: [_jsx(EntryView, { entry: e }), i === tailIdx && tail ? _jsx(Text, { dimColor: true, children: tail }) : null] }, i)));
695
- })(), turn.thinkBuf.trim() ? (_jsxs(Text, { dimColor: true, children: ["\u23BF thinking \u00B7 ", turn.thinkBuf.replace(/\s+/g, " ").slice(-120)] })) : null, turn.textBuf ? _jsx(Text, { children: colorizeCodeFences(turn.textBuf) }) : null, _jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " ", turnLabel, "\u2026 (", turnElapsed, "s \u00B7 esc to interrupt)"] })] })) : null, queued.length > 0 ? (_jsxs(Box, { flexDirection: "column", width: cols, height: queueRows, overflow: "hidden", justifyContent: "flex-end", children: [queued.slice(0, 3).map((text, i) => (_jsxs(Text, { dimColor: true, children: ["\u23F8 queued \u00B7 ", text.length > cols - 16 ? `${text.slice(0, cols - 17)}…` : text] }, `${i}:${text}`))), queued.length > 3 ? _jsxs(Text, { dimColor: true, children: ["\u23F8 \u2026 +", queued.length - 3, " more"] }) : null, turn ? (_jsx(Text, { dimColor: true, children: " esc stops the current turn and sends the next queued message" })) : null] })) : null, _jsx(Box, { width: cols, children: sessionPick ? (_jsx(SessionPicker, { pick: sessionPick, index: sessIndex, cwd: process.cwd() })) : permission ? (_jsx(PermissionPicker, { prompt: permission, index: permIndex })) : question ? (_jsx(QuestionPicker, { prompt: question, state: q })) : (_jsx(InputLine, { busy: busy, status: status, editor: props.editor, applyEdit: props.applyEdit, quotaLine: props.quotaLine, onMenuOpenChange: props.onMenuOpenChange, onSubmitText: submit })) })] }));
696
- }
697
- /**
698
- * `/sessions` picker: resumable sessions of the current project, newest last
699
- * (the backend orders by activity). Enter resumes; esc dismisses.
700
- */
701
- function SessionPicker({ pick, index, cwd, }) {
702
- const win = pickerWindow(pick.items, index);
703
- const above = win.start;
704
- const below = pick.items.length - (win.start + win.slice.length);
705
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["sessions \u00B7 ", cwd, " \u00B7 ", Math.min(index + 1, pick.items.length), "/", pick.items.length] }), above > 0 ? _jsxs(Text, { dimColor: true, children: [" \u2026 ", above, " newer above"] }) : null, win.slice.map((s, i) => {
706
- const abs = win.start + i;
707
- const title = s.title?.trim() || "untitled";
708
- const age = relativeTime(s.updatedAt);
709
- const elsewhere = s.cwd && s.cwd !== cwd ? ` · ${s.cwd}` : "";
710
- // Live sessions are owned by another bridge instance right now —
711
- // picking one attaches through the hub and streams updates as they
712
- // happen (ADR-0018) instead of replaying a frozen local snapshot.
713
- const live = s.live ? (s.running ? " · live ●" : " · live") : "";
714
- return (_jsxs(Box, { paddingLeft: 1, children: [_jsxs(Text, { color: abs === index ? "cyan" : undefined, children: [abs === index ? "❯ " : " ", title] }), _jsxs(Text, { dimColor: true, children: [" ", age ? `· ${age}` : "", "\u00B7 ", s.sessionId.slice(0, 8), live, elsewhere] })] }, s.sessionId));
715
- }), below > 0 ? _jsxs(Text, { dimColor: true, children: [" \u2026 ", below, " older below"] }) : null, _jsx(Text, { dimColor: true, children: "\u2191/\u2193 select \u00B7 enter resume \u00B7 esc cancel" })] }));
716
- }
717
- function PermissionPicker({ prompt, index, }) {
718
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { bold: true, children: prompt.heading }), _jsx(Text, { children: prompt.title }), prompt.detail ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: colorizeCodeFences(prompt.detail.trimEnd()) }) })) : null, prompt.options.map((opt, i) => (_jsx(Box, { paddingLeft: 1, marginTop: i === 0 && prompt.detail ? 1 : 0, children: _jsxs(Text, { color: i === index ? "cyan" : undefined, children: [i === index ? "❯ " : " ", opt.name] }) }, opt.id))), _jsx(Text, { dimColor: true, children: "\u2191/\u2193 select \u00B7 enter confirm \u00B7 esc cancel" })] }));
719
- }
720
- /**
721
- * AskUserQuestion picker: the question text as the heading (NOT a permission
722
- * shell), options below, and a built-in custom-answer row. Single-select:
723
- * enter picks. Multi-select: space toggles, enter confirms. esc/ctrl-c skips
724
- * the question (its key is omitted from the form response).
725
- */
726
- function QuestionPicker({ prompt, state, }) {
727
- const customRow = prompt.options.length; // index of the custom row
728
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["\u2753 ", prompt.title] }), prompt.options.map((opt, i) => (_jsx(Box, { paddingLeft: 1, children: _jsxs(Text, { color: i === state.index ? "cyan" : undefined, children: [i === state.index ? "❯ " : " ", prompt.multiSelect ? (state.toggled.includes(opt.value) ? "[●] " : "[ ] ") : "", opt.label] }) }, opt.value))), state.custom !== null ? (_jsxs(Box, { paddingLeft: 1, children: [_jsx(Text, { color: "cyan", children: "\u276F \u270E " }), _jsx(Text, { children: state.custom }), _jsx(Text, { dimColor: true, children: "\u258F" })] })) : (_jsx(Box, { paddingLeft: 1, children: _jsxs(Text, { color: state.index === customRow ? "cyan" : undefined, dimColor: state.index !== customRow, children: [state.index === customRow ? "❯ " : " ", "\u270E type a custom answer\u2026"] }) })), _jsx(Text, { dimColor: true, children: prompt.multiSelect
729
- ? "↑/↓ move · space toggle · enter confirm · esc skip"
730
- : "↑/↓ move · enter pick · esc skip" })] }));
731
- }
732
- //# sourceMappingURL=App.js.map