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.
- package/README.md +144 -32
- package/dist/backend/client.d.ts.map +1 -1
- package/dist/backend/client.js +10 -0
- package/dist/backend/client.js.map +1 -1
- package/dist/backend/listener.d.ts.map +1 -1
- package/dist/backend/listener.js +6 -5
- package/dist/backend/listener.js.map +1 -1
- package/dist/backend/resolve.d.ts.map +1 -1
- package/dist/backend/resolve.js +35 -1
- package/dist/backend/resolve.js.map +1 -1
- package/dist/bin/hub.d.ts +3 -3
- package/dist/bin/hub.d.ts.map +1 -1
- package/dist/bin/hub.js +22 -12
- package/dist/bin/hub.js.map +1 -1
- package/dist/bin/quota.d.ts +14 -6
- package/dist/bin/quota.d.ts.map +1 -1
- package/dist/bin/quota.js +27 -19
- package/dist/bin/quota.js.map +1 -1
- package/dist/cli.d.ts +38 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +129 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/runtime-model.d.ts +10 -10
- package/dist/config/runtime-model.js +10 -10
- package/dist/handlers/account.d.ts +1 -1
- package/dist/handlers/account.js +1 -1
- package/dist/handlers/dispatch.d.ts.map +1 -1
- package/dist/handlers/dispatch.js +42 -36
- package/dist/handlers/dispatch.js.map +1 -1
- package/dist/handlers/io.d.ts.map +1 -1
- package/dist/handlers/io.js +19 -13
- package/dist/handlers/io.js.map +1 -1
- package/dist/handlers/session.d.ts +58 -4
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +317 -87
- package/dist/handlers/session.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -8
- package/dist/index.js.map +1 -1
- package/dist/interaction/adapter.d.ts +1 -0
- package/dist/interaction/adapter.d.ts.map +1 -1
- package/dist/interaction/adapter.js +5 -1
- package/dist/interaction/adapter.js.map +1 -1
- package/dist/quota/color.d.ts +4 -4
- package/dist/quota/color.d.ts.map +1 -1
- package/dist/quota/color.js +7 -5
- package/dist/quota/color.js.map +1 -1
- package/dist/quota/combined.d.ts +1 -1
- package/dist/quota/combined.js +4 -4
- package/dist/quota/combined.js.map +1 -1
- package/dist/quota/format.d.ts +1 -1
- package/dist/quota/opencode-go/format.d.ts.map +1 -1
- package/dist/quota/opencode-go/format.js +5 -1
- package/dist/quota/opencode-go/format.js.map +1 -1
- package/dist/quota/opencode-go/index.d.ts +1 -1
- package/dist/quota/opencode-go/index.js +1 -1
- package/dist/quota/opencode-go/types.d.ts +1 -1
- package/dist/quota/opencode-go/types.js +1 -1
- package/dist/quota/rounding.d.ts +8 -0
- package/dist/quota/rounding.d.ts.map +1 -0
- package/dist/quota/rounding.js +10 -0
- package/dist/quota/rounding.js.map +1 -0
- package/dist/remote/config.d.ts +1 -1
- package/dist/remote/config.d.ts.map +1 -1
- package/dist/remote/config.js +1 -1
- package/dist/remote/config.js.map +1 -1
- package/dist/remote/endpoint.d.ts +1 -1
- package/dist/remote/endpoint.d.ts.map +1 -1
- package/dist/remote/endpoint.js +6 -1
- package/dist/remote/endpoint.js.map +1 -1
- package/dist/remote/hub-server.d.ts +1 -1
- package/dist/remote/hub-server.d.ts.map +1 -1
- package/dist/remote/hub-server.js +13 -10
- package/dist/remote/hub-server.js.map +1 -1
- package/dist/remote/session-rename-endpoint.d.ts +20 -0
- package/dist/remote/session-rename-endpoint.d.ts.map +1 -0
- package/dist/remote/session-rename-endpoint.js +108 -0
- package/dist/remote/session-rename-endpoint.js.map +1 -0
- package/dist/repl/App.d.ts +108 -0
- package/dist/repl/App.d.ts.map +1 -0
- package/dist/repl/App.js +706 -0
- package/dist/repl/App.js.map +1 -0
- package/dist/repl/history.d.ts +25 -0
- package/dist/repl/history.d.ts.map +1 -0
- package/dist/repl/history.js +73 -0
- package/dist/repl/history.js.map +1 -0
- package/dist/repl/input-buffer.d.ts +77 -0
- package/dist/repl/input-buffer.d.ts.map +1 -0
- package/dist/repl/input-buffer.js +174 -0
- package/dist/repl/input-buffer.js.map +1 -0
- package/dist/repl/model.d.ts +275 -0
- package/dist/repl/model.d.ts.map +1 -0
- package/dist/repl/model.js +596 -0
- package/dist/repl/model.js.map +1 -0
- package/dist/repl/mouse.d.ts +49 -0
- package/dist/repl/mouse.d.ts.map +1 -0
- package/dist/repl/mouse.js +165 -0
- package/dist/repl/mouse.js.map +1 -0
- package/dist/repl/run.d.ts +12 -0
- package/dist/repl/run.d.ts.map +1 -0
- package/dist/repl/run.js +902 -0
- package/dist/repl/run.js.map +1 -0
- package/dist/server.d.ts +19 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +32 -4
- package/dist/server.js.map +1 -1
- package/dist/tasks-index.d.ts +7 -0
- package/dist/tasks-index.d.ts.map +1 -1
- package/dist/tasks-index.js +50 -9
- package/dist/tasks-index.js.map +1 -1
- package/dist/translators/tool-helpers.d.ts.map +1 -1
- package/dist/translators/tool-helpers.js +3 -0
- package/dist/translators/tool-helpers.js.map +1 -1
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/BACKLOG.md +40 -21
- package/docs/REMOTE-CLIENTS.md +37 -8
- package/docs/TROUBLESHOOTING.md +1 -1
- package/docs/adr/0007-unified-cli-entry-and-bin-pruning.md +40 -0
- package/package.json +9 -4
package/dist/repl/App.js
ADDED
|
@@ -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
|