zcode-acp-server 0.11.8 → 0.14.1

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