laohuang 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -16
- package/dist/agent.d.ts +166 -0
- package/dist/agent.js +858 -0
- package/dist/agent.js.map +1 -0
- package/dist/bash-runner.d.ts +79 -0
- package/dist/bash-runner.js +464 -0
- package/dist/bash-runner.js.map +1 -0
- package/dist/cancellation.d.ts +36 -0
- package/dist/cancellation.js +123 -0
- package/dist/cancellation.js.map +1 -0
- package/dist/cli.d.ts +117 -0
- package/dist/cli.js +1307 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.js +17 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +123 -0
- package/dist/commands.js +660 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +51 -0
- package/dist/config.js +183 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +12 -0
- package/dist/credentials.js +102 -0
- package/dist/credentials.js.map +1 -0
- package/dist/events.d.ts +288 -0
- package/dist/events.js +838 -0
- package/dist/events.js.map +1 -0
- package/dist/model-adapter.d.ts +138 -0
- package/dist/model-adapter.js +244 -0
- package/dist/model-adapter.js.map +1 -0
- package/dist/model-selection.d.ts +67 -0
- package/dist/model-selection.js +148 -0
- package/dist/model-selection.js.map +1 -0
- package/dist/model-stream.d.ts +128 -0
- package/dist/model-stream.js +582 -0
- package/dist/model-stream.js.map +1 -0
- package/dist/project-instructions.d.ts +99 -0
- package/dist/project-instructions.js +348 -0
- package/dist/project-instructions.js.map +1 -0
- package/dist/providers.d.ts +9 -0
- package/dist/providers.js +26 -0
- package/dist/providers.js.map +1 -0
- package/dist/routing.d.ts +170 -0
- package/dist/routing.js +669 -0
- package/dist/routing.js.map +1 -0
- package/dist/semantic-classifier.d.ts +66 -0
- package/dist/semantic-classifier.js +86 -0
- package/dist/semantic-classifier.js.map +1 -0
- package/dist/session.d.ts +162 -0
- package/dist/session.js +871 -0
- package/dist/session.js.map +1 -0
- package/dist/system-prompt.d.ts +16 -0
- package/dist/system-prompt.js +42 -0
- package/dist/system-prompt.js.map +1 -0
- package/dist/terminal/editor.d.ts +161 -0
- package/dist/terminal/editor.js +1060 -0
- package/dist/terminal/editor.js.map +1 -0
- package/dist/terminal/input.d.ts +61 -0
- package/dist/terminal/input.js +276 -0
- package/dist/terminal/input.js.map +1 -0
- package/dist/terminal/markdown.d.ts +20 -0
- package/dist/terminal/markdown.js +621 -0
- package/dist/terminal/markdown.js.map +1 -0
- package/dist/terminal/screen.d.ts +66 -0
- package/dist/terminal/screen.js +624 -0
- package/dist/terminal/screen.js.map +1 -0
- package/dist/terminal/theme.d.ts +23 -0
- package/dist/terminal/theme.js +101 -0
- package/dist/terminal/theme.js.map +1 -0
- package/dist/terminal/ui.d.ts +285 -0
- package/dist/terminal/ui.js +1815 -0
- package/dist/terminal/ui.js.map +1 -0
- package/dist/tools.d.ts +95 -0
- package/dist/tools.js +444 -0
- package/dist/tools.js.map +1 -0
- package/dist/ui-state.d.ts +51 -0
- package/dist/ui-state.js +194 -0
- package/dist/ui-state.js.map +1 -0
- package/dist/web.d.ts +56 -0
- package/dist/web.js +247 -0
- package/dist/web.js.map +1 -0
- package/package.json +24 -21
- package/bin/laohuang.js +0 -10
- package/lib/launcher.js +0 -133
- package/vendor/laohuangcode-0.3.2-py3-none-any.whl +0 -0
|
@@ -0,0 +1,1815 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polished interactive terminal UI for laoHuangCode.
|
|
3
|
+
*
|
|
4
|
+
* Port of terminal_ui.py: the sole terminal writer. Everything visible is an
|
|
5
|
+
* append-only transcript of blocks; mutable blocks stream inline and are
|
|
6
|
+
* frozen (never rewritten) once their turn completes. Cancelled/truncated
|
|
7
|
+
* partial replies stay on screen and are marked.
|
|
8
|
+
*
|
|
9
|
+
* Scope notes for the TypeScript rewrite:
|
|
10
|
+
* - The legacy prompt_toolkit/rich console path has no TS equivalent; the
|
|
11
|
+
* interactive UI is always the single-renderer raw loop, and non-TTY
|
|
12
|
+
* sessions use PlainEventSink.
|
|
13
|
+
* - The editor and raw-input decoder behind the loop live behind the
|
|
14
|
+
* EditorLike/InputDecoderLike interfaces so terminal/input.ts (another
|
|
15
|
+
* workstream) can be injected once landed. BasicEditorState and
|
|
16
|
+
* BasicInputDecoder are the built-in defaults implementing that contract.
|
|
17
|
+
*/
|
|
18
|
+
var _a, _b;
|
|
19
|
+
import { appendFileSync } from "node:fs";
|
|
20
|
+
import { createUIState, UIEventReducer, } from "../ui-state.js";
|
|
21
|
+
import { renderMarkdownLines } from "./markdown.js";
|
|
22
|
+
import { resolveTerminalTheme } from "./theme.js";
|
|
23
|
+
import { PiMainScreenRenderer, charCellWidth, truncateToWidth, visibleWidth, wrapTextToWidth, } from "./screen.js";
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// BasicEditorState — default line editor behind the raw loop
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
function cpSlice(text, start, end) {
|
|
28
|
+
return [...text].slice(start, end).join("");
|
|
29
|
+
}
|
|
30
|
+
function cpLength(text) {
|
|
31
|
+
return [...text].length;
|
|
32
|
+
}
|
|
33
|
+
function cellWidth(char) {
|
|
34
|
+
return Math.max(1, charCellWidth(char));
|
|
35
|
+
}
|
|
36
|
+
function textDisplayWidth(text) {
|
|
37
|
+
let width = 0;
|
|
38
|
+
for (const char of text) {
|
|
39
|
+
width += cellWidth(char);
|
|
40
|
+
}
|
|
41
|
+
return width;
|
|
42
|
+
}
|
|
43
|
+
function wrapEditorLine(text, width) {
|
|
44
|
+
const rows = [];
|
|
45
|
+
let row = "";
|
|
46
|
+
let rowWidth = 0;
|
|
47
|
+
for (const char of text) {
|
|
48
|
+
const charWidth = cellWidth(char);
|
|
49
|
+
if (row && rowWidth + charWidth > width) {
|
|
50
|
+
rows.push(row);
|
|
51
|
+
row = "";
|
|
52
|
+
rowWidth = 0;
|
|
53
|
+
}
|
|
54
|
+
row += char;
|
|
55
|
+
rowWidth += charWidth;
|
|
56
|
+
}
|
|
57
|
+
if (row || rows.length === 0) {
|
|
58
|
+
rows.push(row);
|
|
59
|
+
}
|
|
60
|
+
return rows;
|
|
61
|
+
}
|
|
62
|
+
function editorCursorPosition(text, width) {
|
|
63
|
+
let row = 0;
|
|
64
|
+
let column = 0;
|
|
65
|
+
for (const char of text) {
|
|
66
|
+
const charWidth = cellWidth(char);
|
|
67
|
+
if (column > 0 && column + charWidth > width) {
|
|
68
|
+
row += 1;
|
|
69
|
+
column = 0;
|
|
70
|
+
}
|
|
71
|
+
column += charWidth;
|
|
72
|
+
if (column === width) {
|
|
73
|
+
row += 1;
|
|
74
|
+
column = 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [row, column];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Text, history, and command-completion state for the active editor.
|
|
81
|
+
*
|
|
82
|
+
* Default implementation of EditorLike; the richer editor from
|
|
83
|
+
* terminal/input.ts can replace it through the same interface.
|
|
84
|
+
*/
|
|
85
|
+
export class BasicEditorState {
|
|
86
|
+
text = "";
|
|
87
|
+
/** Cursor position in code points. */
|
|
88
|
+
cursor = 0;
|
|
89
|
+
history = [];
|
|
90
|
+
historyIndex = null;
|
|
91
|
+
#historyDraft = "";
|
|
92
|
+
completions = [];
|
|
93
|
+
selectedCompletion = null;
|
|
94
|
+
get completionVisible() {
|
|
95
|
+
return this.completions.length > 0;
|
|
96
|
+
}
|
|
97
|
+
setCompletions(values) {
|
|
98
|
+
this.completions = this.text.startsWith("/") ? [...values] : [];
|
|
99
|
+
this.selectedCompletion = this.completions.length > 0 ? 0 : null;
|
|
100
|
+
}
|
|
101
|
+
apply(action, options) {
|
|
102
|
+
if (action.kind === "submit") {
|
|
103
|
+
if (this.completionVisible) {
|
|
104
|
+
this.#acceptCompletion();
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
return this.#submit();
|
|
108
|
+
}
|
|
109
|
+
if (action.kind === "newline") {
|
|
110
|
+
this.#insert("\n");
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
if (action.kind === "cancel") {
|
|
114
|
+
return this.#cancelOrClear(options.runtimeActive);
|
|
115
|
+
}
|
|
116
|
+
if (action.kind === "eof") {
|
|
117
|
+
return this.#exitOrNotice(options.runtimeActive);
|
|
118
|
+
}
|
|
119
|
+
if (action.kind === "dismiss") {
|
|
120
|
+
this.#clearCompletions();
|
|
121
|
+
return {};
|
|
122
|
+
}
|
|
123
|
+
return this.#applyEditAction(action);
|
|
124
|
+
}
|
|
125
|
+
renderLines(width, options = {}) {
|
|
126
|
+
const prompt = options.prompt ?? "❯ ";
|
|
127
|
+
const mask = options.mask ?? false;
|
|
128
|
+
const targetWidth = Math.max(3, width);
|
|
129
|
+
const promptWidth = Math.max(1, textDisplayWidth(prompt));
|
|
130
|
+
const contentWidth = Math.max(1, targetWidth - promptWidth);
|
|
131
|
+
const displayText = mask ? "*".repeat(cpLength(this.text)) : this.text;
|
|
132
|
+
const sourceLines = displayText.split("\n");
|
|
133
|
+
const rows = [];
|
|
134
|
+
for (const source of sourceLines) {
|
|
135
|
+
rows.push(...wrapEditorLine(source, contentWidth));
|
|
136
|
+
}
|
|
137
|
+
const lastSource = sourceLines[sourceLines.length - 1];
|
|
138
|
+
if (displayText &&
|
|
139
|
+
!displayText.endsWith("\n") &&
|
|
140
|
+
textDisplayWidth(lastSource) % contentWidth === 0) {
|
|
141
|
+
rows.push("");
|
|
142
|
+
}
|
|
143
|
+
const rendered = rows.map((row, index) => (index === 0 ? prompt : " ".repeat(promptWidth)) + row);
|
|
144
|
+
const before = cpSlice(displayText, 0, this.cursor);
|
|
145
|
+
const beforeLines = before.split("\n");
|
|
146
|
+
let priorRows = 0;
|
|
147
|
+
for (const line of beforeLines.slice(0, -1)) {
|
|
148
|
+
priorRows += wrapEditorLine(line, contentWidth).length;
|
|
149
|
+
}
|
|
150
|
+
const current = beforeLines[beforeLines.length - 1];
|
|
151
|
+
const [currentRow, currentColumn] = editorCursorPosition(current, contentWidth);
|
|
152
|
+
const cursorRow = priorRows + currentRow;
|
|
153
|
+
const cursorColumn = promptWidth + currentColumn;
|
|
154
|
+
return {
|
|
155
|
+
lines: rendered,
|
|
156
|
+
cursorRow: Math.min(cursorRow, rendered.length - 1),
|
|
157
|
+
cursorCol: Math.min(cursorColumn, targetWidth - 1),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
#submit() {
|
|
161
|
+
const submitted = this.text;
|
|
162
|
+
this.#clearCompletions();
|
|
163
|
+
this.text = "";
|
|
164
|
+
this.cursor = 0;
|
|
165
|
+
this.historyIndex = null;
|
|
166
|
+
this.#historyDraft = "";
|
|
167
|
+
if (!submitted) {
|
|
168
|
+
return {};
|
|
169
|
+
}
|
|
170
|
+
this.history = [...this.history, submitted];
|
|
171
|
+
return { submit: submitted };
|
|
172
|
+
}
|
|
173
|
+
#cancelOrClear(runtimeActive) {
|
|
174
|
+
if (runtimeActive) {
|
|
175
|
+
return { cancelRequested: true };
|
|
176
|
+
}
|
|
177
|
+
this.text = "";
|
|
178
|
+
this.cursor = 0;
|
|
179
|
+
this.historyIndex = null;
|
|
180
|
+
this.#clearCompletions();
|
|
181
|
+
return {};
|
|
182
|
+
}
|
|
183
|
+
#exitOrNotice(runtimeActive) {
|
|
184
|
+
if (runtimeActive) {
|
|
185
|
+
return { notice: "A task is still running. Press Ctrl+C to cancel it." };
|
|
186
|
+
}
|
|
187
|
+
if (this.text) {
|
|
188
|
+
return { notice: "Clear the editor before exiting." };
|
|
189
|
+
}
|
|
190
|
+
return { exitRequested: true };
|
|
191
|
+
}
|
|
192
|
+
#applyEditAction(action) {
|
|
193
|
+
if (action.kind === "history_up" && this.completionVisible) {
|
|
194
|
+
this.#moveCompletion(-1);
|
|
195
|
+
}
|
|
196
|
+
else if (action.kind === "history_down" && this.completionVisible) {
|
|
197
|
+
this.#moveCompletion(1);
|
|
198
|
+
}
|
|
199
|
+
else if (action.kind === "insert") {
|
|
200
|
+
this.#insert(action.text ?? "");
|
|
201
|
+
}
|
|
202
|
+
else if (action.kind === "backspace" && this.cursor > 0) {
|
|
203
|
+
this.#clearCompletions();
|
|
204
|
+
this.text = cpSlice(this.text, 0, this.cursor - 1) + cpSlice(this.text, this.cursor);
|
|
205
|
+
this.cursor -= 1;
|
|
206
|
+
}
|
|
207
|
+
else if (action.kind === "cursor_left") {
|
|
208
|
+
this.#clearCompletions();
|
|
209
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
210
|
+
}
|
|
211
|
+
else if (action.kind === "cursor_right") {
|
|
212
|
+
this.#clearCompletions();
|
|
213
|
+
this.cursor = Math.min(cpLength(this.text), this.cursor + 1);
|
|
214
|
+
}
|
|
215
|
+
else if (action.kind === "history_up") {
|
|
216
|
+
this.#historyUp();
|
|
217
|
+
}
|
|
218
|
+
else if (action.kind === "history_down") {
|
|
219
|
+
this.#historyDown();
|
|
220
|
+
}
|
|
221
|
+
else if (action.kind === "complete") {
|
|
222
|
+
this.#acceptCompletion();
|
|
223
|
+
}
|
|
224
|
+
this.#closeCompletionIfContextLost();
|
|
225
|
+
return {};
|
|
226
|
+
}
|
|
227
|
+
#insert(text) {
|
|
228
|
+
this.#clearCompletions();
|
|
229
|
+
this.text = cpSlice(this.text, 0, this.cursor) + text + cpSlice(this.text, this.cursor);
|
|
230
|
+
this.cursor += cpLength(text);
|
|
231
|
+
this.historyIndex = null;
|
|
232
|
+
}
|
|
233
|
+
#acceptCompletion() {
|
|
234
|
+
if (this.selectedCompletion === null) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const item = this.completions[this.selectedCompletion];
|
|
238
|
+
const start = Math.max(0, this.cursor + item.start);
|
|
239
|
+
this.text = cpSlice(this.text, 0, start) + item.value + cpSlice(this.text, this.cursor);
|
|
240
|
+
this.cursor = start + cpLength(item.value);
|
|
241
|
+
this.#clearCompletions();
|
|
242
|
+
}
|
|
243
|
+
#moveCompletion(offset) {
|
|
244
|
+
if (this.selectedCompletion === null) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const count = this.completions.length;
|
|
248
|
+
this.selectedCompletion = (((this.selectedCompletion + offset) % count) + count) % count;
|
|
249
|
+
}
|
|
250
|
+
#historyUp() {
|
|
251
|
+
if (this.history.length === 0) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (this.historyIndex === null) {
|
|
255
|
+
this.#historyDraft = this.text;
|
|
256
|
+
this.historyIndex = this.history.length - 1;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
this.historyIndex = Math.max(0, this.historyIndex - 1);
|
|
260
|
+
}
|
|
261
|
+
this.text = this.history[this.historyIndex];
|
|
262
|
+
this.cursor = cpLength(this.text);
|
|
263
|
+
}
|
|
264
|
+
#historyDown() {
|
|
265
|
+
if (this.historyIndex === null) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (this.historyIndex === this.history.length - 1) {
|
|
269
|
+
this.text = this.#historyDraft;
|
|
270
|
+
this.historyIndex = null;
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
this.historyIndex += 1;
|
|
274
|
+
this.text = this.history[this.historyIndex];
|
|
275
|
+
}
|
|
276
|
+
this.cursor = cpLength(this.text);
|
|
277
|
+
}
|
|
278
|
+
#closeCompletionIfContextLost() {
|
|
279
|
+
if (!this.text.startsWith("/")) {
|
|
280
|
+
this.#clearCompletions();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
#clearCompletions() {
|
|
284
|
+
this.completions = [];
|
|
285
|
+
this.selectedCompletion = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// BasicInputDecoder — default bytes-to-actions pipeline
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
const ESC = 0x1b;
|
|
292
|
+
const PASTE_START = [0x1b, 0x5b, 0x32, 0x30, 0x30, 0x7e]; // \x1b[200~
|
|
293
|
+
const PASTE_END = [0x1b, 0x5b, 0x32, 0x30, 0x31, 0x7e]; // \x1b[201~
|
|
294
|
+
const CSI_ARROW_ACTIONS = {
|
|
295
|
+
"\x1b[A": "history_up",
|
|
296
|
+
"\x1b[B": "history_down",
|
|
297
|
+
"\x1b[C": "cursor_right",
|
|
298
|
+
"\x1b[D": "cursor_left",
|
|
299
|
+
"\x1bOA": "history_up",
|
|
300
|
+
"\x1bOB": "history_down",
|
|
301
|
+
"\x1bOC": "cursor_right",
|
|
302
|
+
"\x1bOD": "cursor_left",
|
|
303
|
+
};
|
|
304
|
+
const CONTROL_ACTIONS = {
|
|
305
|
+
3: "cancel",
|
|
306
|
+
4: "eof",
|
|
307
|
+
9: "complete",
|
|
308
|
+
8: "backspace",
|
|
309
|
+
127: "backspace",
|
|
310
|
+
};
|
|
311
|
+
const SPECIAL_ESCAPE_ACTIONS = {
|
|
312
|
+
"\x1b[13;2u": "newline",
|
|
313
|
+
"\x1b[57414;2u": "newline",
|
|
314
|
+
"\x1b[13u": "submit",
|
|
315
|
+
"\x1b[13;1u": "submit",
|
|
316
|
+
"\x1b[57414u": "submit",
|
|
317
|
+
"\x1b[57414;1u": "submit",
|
|
318
|
+
"\x1b[9u": "complete",
|
|
319
|
+
"\x1b[9;1u": "complete",
|
|
320
|
+
"\x1b[127u": "backspace",
|
|
321
|
+
"\x1b[127;1u": "backspace",
|
|
322
|
+
"\x1b[27u": "dismiss",
|
|
323
|
+
"\x1b[27;1u": "dismiss",
|
|
324
|
+
};
|
|
325
|
+
const utf8Decoder = new TextDecoder("utf-8", { fatal: false });
|
|
326
|
+
function findSubsequence(haystack, needle) {
|
|
327
|
+
outer: for (let index = 0; index + needle.length <= haystack.length; index += 1) {
|
|
328
|
+
for (let offset = 0; offset < needle.length; offset += 1) {
|
|
329
|
+
if (haystack[index + offset] !== needle[offset]) {
|
|
330
|
+
continue outer;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return index;
|
|
334
|
+
}
|
|
335
|
+
return -1;
|
|
336
|
+
}
|
|
337
|
+
/** Split a byte run into complete UTF-8 content and an incomplete tail. */
|
|
338
|
+
function splitIncompleteUtf8(bytes) {
|
|
339
|
+
let index = bytes.length - 1;
|
|
340
|
+
let continuation = 0;
|
|
341
|
+
while (index >= 0 && continuation < 4 && (bytes[index] & 0xc0) === 0x80) {
|
|
342
|
+
index -= 1;
|
|
343
|
+
continuation += 1;
|
|
344
|
+
}
|
|
345
|
+
if (index < 0) {
|
|
346
|
+
return [bytes, []];
|
|
347
|
+
}
|
|
348
|
+
const lead = bytes[index];
|
|
349
|
+
const needed = lead < 0x80 ? 1 : lead >= 0xc0 && lead <= 0xdf ? 2 : lead >= 0xe0 && lead <= 0xef ? 3 : lead >= 0xf0 && lead <= 0xf7 ? 4 : 1;
|
|
350
|
+
if (bytes.length - index < needed) {
|
|
351
|
+
return [bytes.slice(0, index), bytes.slice(index)];
|
|
352
|
+
}
|
|
353
|
+
return [bytes, []];
|
|
354
|
+
}
|
|
355
|
+
function decodeUtf8(bytes) {
|
|
356
|
+
return utf8Decoder.decode(new Uint8Array(bytes));
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Incremental raw-input decoder with bracketed-paste and keyboard-mode
|
|
360
|
+
* negotiation handling. Default InputDecoderLike; the input workstream's
|
|
361
|
+
* pipeline can replace it through the same interface.
|
|
362
|
+
*/
|
|
363
|
+
export class BasicInputDecoder {
|
|
364
|
+
#hooks;
|
|
365
|
+
#isAppleTerminal;
|
|
366
|
+
#shiftPressed;
|
|
367
|
+
#buffer = [];
|
|
368
|
+
#pasteMode = false;
|
|
369
|
+
#pasteBuffer = [];
|
|
370
|
+
kittyProtocolActive = false;
|
|
371
|
+
constructor(hooks, options = {}) {
|
|
372
|
+
this.#hooks = hooks;
|
|
373
|
+
this.#isAppleTerminal = options.isAppleTerminal ?? (() => false);
|
|
374
|
+
this.#shiftPressed = options.shiftPressed ?? (() => false);
|
|
375
|
+
}
|
|
376
|
+
feed(data) {
|
|
377
|
+
if (data.length === 0) {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
let bytes = [...data];
|
|
381
|
+
if (bytes.length === 1 && bytes[0] > 127) {
|
|
382
|
+
bytes = [ESC, bytes[0] - 128];
|
|
383
|
+
}
|
|
384
|
+
this.#buffer.push(...bytes);
|
|
385
|
+
return this.#process();
|
|
386
|
+
}
|
|
387
|
+
flush() {
|
|
388
|
+
if (this.#pasteMode || this.#buffer.length === 0) {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
const buffered = this.#buffer;
|
|
392
|
+
this.#buffer = [];
|
|
393
|
+
if (buffered.length === 1 && buffered[0] === ESC) {
|
|
394
|
+
return [{ kind: "dismiss" }];
|
|
395
|
+
}
|
|
396
|
+
if (buffered[0] === ESC && buffered[1] === 0x5b) {
|
|
397
|
+
// A split CSI sequence stays buffered; a genuine one is dropped.
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
const [actions] = this.#decodeSequences(buffered);
|
|
401
|
+
return actions;
|
|
402
|
+
}
|
|
403
|
+
clear() {
|
|
404
|
+
this.#buffer = [];
|
|
405
|
+
this.#pasteBuffer = [];
|
|
406
|
+
this.#pasteMode = false;
|
|
407
|
+
}
|
|
408
|
+
#process() {
|
|
409
|
+
const actions = [];
|
|
410
|
+
for (;;) {
|
|
411
|
+
if (this.#pasteMode) {
|
|
412
|
+
this.#pasteBuffer.push(...this.#buffer);
|
|
413
|
+
this.#buffer = [];
|
|
414
|
+
const end = findSubsequence(this.#pasteBuffer, PASTE_END);
|
|
415
|
+
if (end === -1) {
|
|
416
|
+
return actions;
|
|
417
|
+
}
|
|
418
|
+
const pasted = this.#pasteBuffer.slice(0, end);
|
|
419
|
+
const remaining = this.#pasteBuffer.slice(end + PASTE_END.length);
|
|
420
|
+
this.#pasteBuffer = [];
|
|
421
|
+
this.#pasteMode = false;
|
|
422
|
+
actions.push({ kind: "insert", text: decodeUtf8(pasted) });
|
|
423
|
+
this.#buffer = [...remaining, ...this.#buffer];
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const start = findSubsequence(this.#buffer, PASTE_START);
|
|
427
|
+
if (start !== -1) {
|
|
428
|
+
const before = this.#buffer.slice(0, start);
|
|
429
|
+
const after = this.#buffer.slice(start + PASTE_START.length);
|
|
430
|
+
const [sequenceActions] = this.#decodeSequences(before);
|
|
431
|
+
actions.push(...sequenceActions);
|
|
432
|
+
this.#buffer = [];
|
|
433
|
+
this.#pasteMode = true;
|
|
434
|
+
this.#pasteBuffer = after;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const [sequenceActions, remainder] = this.#decodeSequences(this.#buffer);
|
|
438
|
+
actions.push(...sequenceActions);
|
|
439
|
+
this.#buffer = remainder;
|
|
440
|
+
return actions;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
#decodeSequences(bytes) {
|
|
444
|
+
const actions = [];
|
|
445
|
+
let index = 0;
|
|
446
|
+
while (index < bytes.length) {
|
|
447
|
+
const byte = bytes[index];
|
|
448
|
+
if (byte === ESC) {
|
|
449
|
+
const parsed = this.#parseEscape(bytes, index);
|
|
450
|
+
if (parsed === null) {
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
index = parsed.next;
|
|
454
|
+
if (parsed.action !== undefined) {
|
|
455
|
+
actions.push(parsed.action);
|
|
456
|
+
}
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (byte === 10 || byte === 13) {
|
|
460
|
+
index += 1;
|
|
461
|
+
if (byte === 13 && this.#isAppleTerminal() && this.#shiftPressed()) {
|
|
462
|
+
actions.push({ kind: "newline" });
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
actions.push({ kind: "submit" });
|
|
466
|
+
}
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const control = CONTROL_ACTIONS[byte];
|
|
470
|
+
if (control !== undefined) {
|
|
471
|
+
index += 1;
|
|
472
|
+
actions.push({ kind: control });
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (byte < 32) {
|
|
476
|
+
index += 1;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
let end = index;
|
|
480
|
+
while (end < bytes.length && bytes[end] >= 32 && bytes[end] !== 127) {
|
|
481
|
+
end += 1;
|
|
482
|
+
}
|
|
483
|
+
const [complete, tail] = splitIncompleteUtf8(bytes.slice(index, end));
|
|
484
|
+
if (complete.length > 0) {
|
|
485
|
+
actions.push({ kind: "insert", text: decodeUtf8(complete) });
|
|
486
|
+
}
|
|
487
|
+
index = end - tail.length;
|
|
488
|
+
if (tail.length > 0) {
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return [actions, bytes.slice(index)];
|
|
493
|
+
}
|
|
494
|
+
#parseEscape(bytes, start) {
|
|
495
|
+
if (start + 1 >= bytes.length) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
const second = bytes[start + 1];
|
|
499
|
+
if (second === 10 || second === 13) {
|
|
500
|
+
return { next: start + 2, action: { kind: "newline" } };
|
|
501
|
+
}
|
|
502
|
+
if (second === 0x5b) {
|
|
503
|
+
let final = -1;
|
|
504
|
+
for (let index = start + 2; index < bytes.length; index += 1) {
|
|
505
|
+
const code = bytes[index];
|
|
506
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
507
|
+
final = index;
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (final === -1) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
const sequence = String.fromCharCode(...bytes.slice(start, final + 1));
|
|
515
|
+
const action = this.#classifyCsi(sequence);
|
|
516
|
+
return action === undefined
|
|
517
|
+
? { next: final + 1 }
|
|
518
|
+
: { next: final + 1, action };
|
|
519
|
+
}
|
|
520
|
+
if (second === 0x4f) {
|
|
521
|
+
if (start + 2 >= bytes.length) {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
const sequence = String.fromCharCode(...bytes.slice(start, start + 3));
|
|
525
|
+
const kind = CSI_ARROW_ACTIONS[sequence];
|
|
526
|
+
return kind === undefined
|
|
527
|
+
? { next: start + 3 }
|
|
528
|
+
: { next: start + 3, action: { kind } };
|
|
529
|
+
}
|
|
530
|
+
// An unsupported Alt sequence has no editor meaning; retain its character.
|
|
531
|
+
return { next: start + 1 };
|
|
532
|
+
}
|
|
533
|
+
#classifyCsi(sequence) {
|
|
534
|
+
const kittyFlags = /^\x1b\[\?(\d+)u$/.exec(sequence);
|
|
535
|
+
if (kittyFlags !== null) {
|
|
536
|
+
if (Number.parseInt(kittyFlags[1], 10)) {
|
|
537
|
+
this.#hooks.disableModifyOtherKeys();
|
|
538
|
+
this.kittyProtocolActive = true;
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
this.#hooks.enableModifyOtherKeys();
|
|
542
|
+
}
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
if (/^\x1b\[\?[\d;]*c$/.test(sequence)) {
|
|
546
|
+
if (!this.kittyProtocolActive) {
|
|
547
|
+
this.#hooks.enableModifyOtherKeys();
|
|
548
|
+
}
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
if (sequence.startsWith("\x1b[?")) {
|
|
552
|
+
// Other negotiation responses (or their abandoned tails) are swallowed.
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
const special = SPECIAL_ESCAPE_ACTIONS[sequence];
|
|
556
|
+
if (special !== undefined) {
|
|
557
|
+
return { kind: special };
|
|
558
|
+
}
|
|
559
|
+
const arrow = CSI_ARROW_ACTIONS[sequence];
|
|
560
|
+
if (arrow !== undefined) {
|
|
561
|
+
return { kind: arrow };
|
|
562
|
+
}
|
|
563
|
+
if (this.#isKittyRelease(sequence)) {
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
const kittyPrintable = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/.exec(sequence);
|
|
567
|
+
if (kittyPrintable !== null) {
|
|
568
|
+
const codepoint = Number.parseInt(kittyPrintable[1], 10);
|
|
569
|
+
const shifted = kittyPrintable[2]
|
|
570
|
+
? Number.parseInt(kittyPrintable[2], 10)
|
|
571
|
+
: null;
|
|
572
|
+
const modifier = Number.parseInt(kittyPrintable[4] ?? "1", 10) - 1;
|
|
573
|
+
const lockMask = 64 + 128;
|
|
574
|
+
if (modifier & ~(1 | lockMask)) {
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
if (modifier & (2 | 4)) {
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
const effective = modifier & 1 && shifted !== null ? shifted : codepoint;
|
|
581
|
+
if (effective < 32) {
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
return { kind: "insert", text: String.fromCodePoint(effective) };
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const modifyOtherKeys = /^\x1b\[27;(\d+);(\d+)~$/.exec(sequence);
|
|
592
|
+
if (modifyOtherKeys !== null) {
|
|
593
|
+
const modifier = Number.parseInt(modifyOtherKeys[1], 10) - 1;
|
|
594
|
+
if (modifier & ~1) {
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
const codepoint = Number.parseInt(modifyOtherKeys[2], 10);
|
|
598
|
+
if (codepoint < 32) {
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
return { kind: "insert", text: String.fromCodePoint(codepoint) };
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
#isKittyRelease(sequence) {
|
|
611
|
+
if (sequence.includes("\x1b[200~")) {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
return [":3u", ":3~", ":3A", ":3B", ":3C", ":3D", ":3H", ":3F"].some((marker) => sequence.includes(marker));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
export function createTranscriptBlock(kind, key, fields = {}) {
|
|
618
|
+
return {
|
|
619
|
+
kind,
|
|
620
|
+
key,
|
|
621
|
+
text: fields.text ?? "",
|
|
622
|
+
mutable: fields.mutable ?? false,
|
|
623
|
+
name: fields.name ?? "",
|
|
624
|
+
subject: fields.subject ?? "",
|
|
625
|
+
status: fields.status ?? "",
|
|
626
|
+
exitCode: fields.exitCode ?? null,
|
|
627
|
+
durationMs: fields.durationMs ?? null,
|
|
628
|
+
streamError: fields.streamError ?? "",
|
|
629
|
+
style: fields.style ?? "",
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
/** Local command feedback sharing the loop's event queue. */
|
|
633
|
+
class LocalMessage {
|
|
634
|
+
text;
|
|
635
|
+
style;
|
|
636
|
+
constructor(text, style = "") {
|
|
637
|
+
this.text = text;
|
|
638
|
+
this.style = style;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
function isRecord(value) {
|
|
642
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
643
|
+
}
|
|
644
|
+
/** Keep captured tool stdout out of user-facing terminal render queues. */
|
|
645
|
+
function isHiddenToolStdout(event) {
|
|
646
|
+
if (!isRecord(event)) {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
if (String(event.kind ?? "") !== "tool.output_delta") {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
const payload = event.payload;
|
|
653
|
+
return isRecord(payload) && String(payload.stream ?? "stdout") === "stdout";
|
|
654
|
+
}
|
|
655
|
+
function eventKind(event) {
|
|
656
|
+
return isRecord(event) ? String(event.kind ?? "") : "";
|
|
657
|
+
}
|
|
658
|
+
function eventPayload(event) {
|
|
659
|
+
return isRecord(event) && isRecord(event.payload) ? event.payload : {};
|
|
660
|
+
}
|
|
661
|
+
/** Mirror Python's `int(...)`: truncate numbers, parse numeric strings. */
|
|
662
|
+
function toInt(value) {
|
|
663
|
+
const parsed = typeof value === "string" ? Number.parseInt(value, 10) : Number(value);
|
|
664
|
+
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
|
|
665
|
+
}
|
|
666
|
+
function projectionDropped(event) {
|
|
667
|
+
const payload = eventPayload(event);
|
|
668
|
+
return toInt(payload._projection_dropped ?? 0);
|
|
669
|
+
}
|
|
670
|
+
function clip(value, limit) {
|
|
671
|
+
return value.length <= limit ? value : `${value.slice(0, limit)}…`;
|
|
672
|
+
}
|
|
673
|
+
const HIGH_FREQUENCY_KINDS = new Set([
|
|
674
|
+
"model.reasoning_delta",
|
|
675
|
+
"model.tool_call_delta",
|
|
676
|
+
"tool.output_delta",
|
|
677
|
+
]);
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
// ANSI style helpers (rich-style "italic #rrggbb" / "bg:#rrggbb" strings)
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
const NAMED_COLOR_CODES = {
|
|
682
|
+
black: "30",
|
|
683
|
+
red: "31",
|
|
684
|
+
green: "32",
|
|
685
|
+
yellow: "33",
|
|
686
|
+
blue: "34",
|
|
687
|
+
magenta: "35",
|
|
688
|
+
cyan: "36",
|
|
689
|
+
white: "37",
|
|
690
|
+
};
|
|
691
|
+
function hexToRgb(value) {
|
|
692
|
+
if (!/^#[0-9a-fA-F]{6}$/.test(value)) {
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
return [
|
|
696
|
+
Number.parseInt(value.slice(1, 3), 16),
|
|
697
|
+
Number.parseInt(value.slice(3, 5), 16),
|
|
698
|
+
Number.parseInt(value.slice(5, 7), 16),
|
|
699
|
+
];
|
|
700
|
+
}
|
|
701
|
+
function ansiCodes(style) {
|
|
702
|
+
const codes = [];
|
|
703
|
+
let nextColorIsBackground = false;
|
|
704
|
+
for (const token of style.replace(/bg:/g, " bg:").split(/\s+/).filter(Boolean)) {
|
|
705
|
+
if (token === "bold") {
|
|
706
|
+
codes.push("1");
|
|
707
|
+
}
|
|
708
|
+
else if (token === "dim") {
|
|
709
|
+
codes.push("2");
|
|
710
|
+
}
|
|
711
|
+
else if (token === "italic") {
|
|
712
|
+
codes.push("3");
|
|
713
|
+
}
|
|
714
|
+
else if (token === "underline") {
|
|
715
|
+
codes.push("4");
|
|
716
|
+
}
|
|
717
|
+
else if (token === "on") {
|
|
718
|
+
nextColorIsBackground = true;
|
|
719
|
+
}
|
|
720
|
+
else if (token.startsWith("bg:#") && token.length === 10) {
|
|
721
|
+
const rgb = hexToRgb(token.slice(3));
|
|
722
|
+
if (rgb !== null) {
|
|
723
|
+
codes.push(`48;2;${rgb[0]};${rgb[1]};${rgb[2]}`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
else if (token.startsWith("#") && token.length === 7) {
|
|
727
|
+
const rgb = hexToRgb(token);
|
|
728
|
+
if (rgb !== null) {
|
|
729
|
+
const prefix = nextColorIsBackground ? "48" : "38";
|
|
730
|
+
codes.push(`${prefix};2;${rgb[0]};${rgb[1]};${rgb[2]}`);
|
|
731
|
+
}
|
|
732
|
+
nextColorIsBackground = false;
|
|
733
|
+
}
|
|
734
|
+
else if (token in NAMED_COLOR_CODES) {
|
|
735
|
+
let code = NAMED_COLOR_CODES[token];
|
|
736
|
+
if (nextColorIsBackground) {
|
|
737
|
+
code = String(Number.parseInt(code, 10) + 10);
|
|
738
|
+
}
|
|
739
|
+
codes.push(code);
|
|
740
|
+
nextColorIsBackground = false;
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
nextColorIsBackground = false;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return codes;
|
|
747
|
+
}
|
|
748
|
+
function ansiStyledText(style, text) {
|
|
749
|
+
if (!style || !text) {
|
|
750
|
+
return text;
|
|
751
|
+
}
|
|
752
|
+
const codes = ansiCodes(style);
|
|
753
|
+
if (codes.length === 0) {
|
|
754
|
+
return text;
|
|
755
|
+
}
|
|
756
|
+
return `\x1b[${codes.join(";")}m${text}\x1b[0m`;
|
|
757
|
+
}
|
|
758
|
+
/** Serialize stdin, UI events, and all terminal writes in one loop. */
|
|
759
|
+
export class InteractiveTerminalLoop {
|
|
760
|
+
static ESCAPE_TIMEOUT_MS = 50;
|
|
761
|
+
static WORK_QUEUE_LIMIT = 4_096;
|
|
762
|
+
#ui;
|
|
763
|
+
#driver;
|
|
764
|
+
#work = [];
|
|
765
|
+
#editor;
|
|
766
|
+
#decoder;
|
|
767
|
+
#renderer;
|
|
768
|
+
#onSubmit = () => { };
|
|
769
|
+
#exitRequested = false;
|
|
770
|
+
#closed = false;
|
|
771
|
+
#running = false;
|
|
772
|
+
#question = null;
|
|
773
|
+
#needsRender = true;
|
|
774
|
+
#terminalModesStarted = false;
|
|
775
|
+
#keyboardProtocolPushed = false;
|
|
776
|
+
#modifyOtherKeysActive = false;
|
|
777
|
+
#exitResolve = null;
|
|
778
|
+
#escapeTimer = null;
|
|
779
|
+
#wakeupEnabled = false;
|
|
780
|
+
#wakeupScheduled = false;
|
|
781
|
+
writeError = null;
|
|
782
|
+
constructor(ui, driver, editor, decoderFactory) {
|
|
783
|
+
this.#ui = ui;
|
|
784
|
+
this.#driver = driver;
|
|
785
|
+
this.#editor = editor;
|
|
786
|
+
this.#decoder = decoderFactory({
|
|
787
|
+
enableModifyOtherKeys: () => {
|
|
788
|
+
this.#enableModifyOtherKeys();
|
|
789
|
+
},
|
|
790
|
+
disableModifyOtherKeys: () => {
|
|
791
|
+
this.#disableModifyOtherKeys();
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
this.#renderer = new PiMainScreenRenderer(driver);
|
|
795
|
+
}
|
|
796
|
+
get editor() {
|
|
797
|
+
return this.#editor;
|
|
798
|
+
}
|
|
799
|
+
get closed() {
|
|
800
|
+
return this.#closed;
|
|
801
|
+
}
|
|
802
|
+
get running() {
|
|
803
|
+
return this.#running;
|
|
804
|
+
}
|
|
805
|
+
start(onSubmit) {
|
|
806
|
+
this.#onSubmit = onSubmit;
|
|
807
|
+
this.#exitRequested = false;
|
|
808
|
+
this.#running = true;
|
|
809
|
+
}
|
|
810
|
+
publishEvent(event) {
|
|
811
|
+
if (this.#closed || isHiddenToolStdout(event)) {
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (this.#work.length >= _a.WORK_QUEUE_LIMIT - 128 &&
|
|
815
|
+
HIGH_FREQUENCY_KINDS.has(eventKind(event))) {
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
this.#work.push({ type: "event", event });
|
|
819
|
+
this.#scheduleWakeup();
|
|
820
|
+
}
|
|
821
|
+
feedInputBytes(data) {
|
|
822
|
+
if (this.#closed) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
this.#work.push({ type: "input", data });
|
|
826
|
+
this.#scheduleWakeup();
|
|
827
|
+
}
|
|
828
|
+
/** Synchronously consume queued work; this is also the test hook. */
|
|
829
|
+
drain() {
|
|
830
|
+
const changed = this.#drainWork();
|
|
831
|
+
if (changed || this.#needsRender) {
|
|
832
|
+
this.#render();
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
ask(message, options = {}) {
|
|
836
|
+
if (this.#closed || !this.#running) {
|
|
837
|
+
return Promise.resolve("");
|
|
838
|
+
}
|
|
839
|
+
const secret = options.secret ?? false;
|
|
840
|
+
return new Promise((resolve) => {
|
|
841
|
+
this.#question = { message, secret, resolve };
|
|
842
|
+
this.#resetEditor();
|
|
843
|
+
this.#needsRender = true;
|
|
844
|
+
this.#scheduleWakeup();
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
requestExit() {
|
|
848
|
+
this.#exitRequested = true;
|
|
849
|
+
this.#exitResolve?.();
|
|
850
|
+
}
|
|
851
|
+
close() {
|
|
852
|
+
if (this.#closed) {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
this.#closed = true;
|
|
856
|
+
this.#running = false;
|
|
857
|
+
const question = this.#question;
|
|
858
|
+
this.#question = null;
|
|
859
|
+
question?.resolve("");
|
|
860
|
+
if (this.#escapeTimer !== null) {
|
|
861
|
+
clearTimeout(this.#escapeTimer);
|
|
862
|
+
this.#escapeTimer = null;
|
|
863
|
+
}
|
|
864
|
+
this.#stopTerminalModes();
|
|
865
|
+
try {
|
|
866
|
+
this.#renderer.close();
|
|
867
|
+
}
|
|
868
|
+
catch (error) {
|
|
869
|
+
if (this.writeError === null) {
|
|
870
|
+
this.writeError = error;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
/** Production entry: raw mode, terminal modes, stdin-driven draining. */
|
|
875
|
+
async run(input) {
|
|
876
|
+
try {
|
|
877
|
+
this.#driver.enterRawMode?.();
|
|
878
|
+
this.startTerminalModes();
|
|
879
|
+
const onData = (data) => {
|
|
880
|
+
this.feedInputBytes(data);
|
|
881
|
+
this.drain();
|
|
882
|
+
this.#armEscapeTimer();
|
|
883
|
+
};
|
|
884
|
+
const onEnd = () => {
|
|
885
|
+
this.#applyActions(this.#decoder.flush());
|
|
886
|
+
// Port of terminal_ui.py: EOF applies the editor's exit effect —
|
|
887
|
+
// exit only when the editor is idle and empty; otherwise the editor
|
|
888
|
+
// notice ("Clear the editor before exiting.") is shown and the loop
|
|
889
|
+
// keeps running.
|
|
890
|
+
this.#applyEof();
|
|
891
|
+
this.drain();
|
|
892
|
+
};
|
|
893
|
+
input?.on("data", onData);
|
|
894
|
+
input?.on("end", onEnd);
|
|
895
|
+
this.#wakeupEnabled = true;
|
|
896
|
+
try {
|
|
897
|
+
if (!this.#exitRequested) {
|
|
898
|
+
await new Promise((resolve) => {
|
|
899
|
+
this.#exitResolve = resolve;
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
finally {
|
|
904
|
+
this.#wakeupEnabled = false;
|
|
905
|
+
input?.off?.("data", onData);
|
|
906
|
+
input?.off?.("end", onEnd);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
this.writeError = error;
|
|
911
|
+
}
|
|
912
|
+
finally {
|
|
913
|
+
if (this.#escapeTimer !== null) {
|
|
914
|
+
clearTimeout(this.#escapeTimer);
|
|
915
|
+
this.#escapeTimer = null;
|
|
916
|
+
}
|
|
917
|
+
this.close();
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
startTerminalModes() {
|
|
921
|
+
if (this.#terminalModesStarted) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
this.#terminalModesStarted = true;
|
|
925
|
+
this.#driver.write("\x1b[?2004h");
|
|
926
|
+
this.#keyboardProtocolPushed = true;
|
|
927
|
+
this.#driver.write("\x1b[>7u\x1b[?u\x1b[c");
|
|
928
|
+
this.#driver.flush();
|
|
929
|
+
}
|
|
930
|
+
#stopTerminalModes() {
|
|
931
|
+
if (!this.#terminalModesStarted) {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
this.#terminalModesStarted = false;
|
|
935
|
+
try {
|
|
936
|
+
this.#driver.write("\x1b[?2004l");
|
|
937
|
+
if (this.#keyboardProtocolPushed || this.#decoder.kittyProtocolActive) {
|
|
938
|
+
this.#driver.write("\x1b[<u");
|
|
939
|
+
this.#keyboardProtocolPushed = false;
|
|
940
|
+
this.#decoder.kittyProtocolActive = false;
|
|
941
|
+
}
|
|
942
|
+
this.#disableModifyOtherKeys();
|
|
943
|
+
this.#driver.flush();
|
|
944
|
+
}
|
|
945
|
+
finally {
|
|
946
|
+
this.#decoder.clear();
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
#enableModifyOtherKeys() {
|
|
950
|
+
if (this.#decoder.kittyProtocolActive || this.#modifyOtherKeysActive) {
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
this.#driver.write("\x1b[>4;2m");
|
|
954
|
+
this.#modifyOtherKeysActive = true;
|
|
955
|
+
}
|
|
956
|
+
#disableModifyOtherKeys() {
|
|
957
|
+
if (!this.#modifyOtherKeysActive) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
this.#driver.write("\x1b[>4;0m");
|
|
961
|
+
this.#modifyOtherKeysActive = false;
|
|
962
|
+
}
|
|
963
|
+
#armEscapeTimer() {
|
|
964
|
+
if (this.#escapeTimer !== null) {
|
|
965
|
+
clearTimeout(this.#escapeTimer);
|
|
966
|
+
}
|
|
967
|
+
this.#escapeTimer = setTimeout(() => {
|
|
968
|
+
this.#escapeTimer = null;
|
|
969
|
+
this.#applyActions(this.#decoder.flush());
|
|
970
|
+
this.drain();
|
|
971
|
+
}, _a.ESCAPE_TIMEOUT_MS);
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Production wakeup: while run() is active, queued work is rendered on the
|
|
975
|
+
* next event-loop turn — the role of the Python selector loop's wakeup
|
|
976
|
+
* pipe. Outside run(), drain() stays the synchronous test hook and nothing
|
|
977
|
+
* is scheduled.
|
|
978
|
+
*/
|
|
979
|
+
#scheduleWakeup() {
|
|
980
|
+
if (!this.#wakeupEnabled || this.#wakeupScheduled || this.#closed) {
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
this.#wakeupScheduled = true;
|
|
984
|
+
setImmediate(() => {
|
|
985
|
+
this.#wakeupScheduled = false;
|
|
986
|
+
this.drain();
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
#drainWork() {
|
|
990
|
+
let changed = false;
|
|
991
|
+
let item = this.#work.shift();
|
|
992
|
+
while (item !== undefined) {
|
|
993
|
+
if (item.type === "input") {
|
|
994
|
+
this.#applyActions(this.#decoder.feed(item.data));
|
|
995
|
+
changed = true;
|
|
996
|
+
}
|
|
997
|
+
else if (item.event instanceof LocalMessage) {
|
|
998
|
+
const message = item.event;
|
|
999
|
+
this.#ui.appendTranscript(createTranscriptBlock("notice", this.#ui.newBlockId(), {
|
|
1000
|
+
text: message.text,
|
|
1001
|
+
style: message.style,
|
|
1002
|
+
}));
|
|
1003
|
+
}
|
|
1004
|
+
else {
|
|
1005
|
+
this.#ui.applyProjectedEvent(item.event);
|
|
1006
|
+
changed = true;
|
|
1007
|
+
}
|
|
1008
|
+
item = this.#work.shift();
|
|
1009
|
+
}
|
|
1010
|
+
return changed;
|
|
1011
|
+
}
|
|
1012
|
+
#render() {
|
|
1013
|
+
this.#needsRender = false;
|
|
1014
|
+
try {
|
|
1015
|
+
const question = this.#question;
|
|
1016
|
+
this.#renderer.render(this.#ui.buildFrame({
|
|
1017
|
+
width: Math.max(1, this.#driver.getSize().columns),
|
|
1018
|
+
editor: this.#editor,
|
|
1019
|
+
prompt: question !== null ? `${question.message} ` : "❯ ",
|
|
1020
|
+
secret: question !== null && question.secret,
|
|
1021
|
+
}));
|
|
1022
|
+
}
|
|
1023
|
+
catch (error) {
|
|
1024
|
+
this.writeError = error;
|
|
1025
|
+
this.requestExit();
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
#applyActions(actions) {
|
|
1029
|
+
for (const action of actions) {
|
|
1030
|
+
if (this.#applyQuestionAction(action)) {
|
|
1031
|
+
this.#needsRender = true;
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
const effect = this.#editor.apply(action, {
|
|
1035
|
+
runtimeActive: this.#ui.isRunning(),
|
|
1036
|
+
});
|
|
1037
|
+
this.#applyEffect(effect);
|
|
1038
|
+
this.#refreshCompletions();
|
|
1039
|
+
this.#needsRender = true;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
#applyEof() {
|
|
1043
|
+
const effect = this.#editor.apply({ kind: "eof" }, { runtimeActive: this.#ui.isRunning() });
|
|
1044
|
+
this.#applyEffect(effect);
|
|
1045
|
+
this.#needsRender = true;
|
|
1046
|
+
}
|
|
1047
|
+
#applyEffect(effect) {
|
|
1048
|
+
if (effect.submit !== null && effect.submit !== undefined) {
|
|
1049
|
+
this.#ui.acceptUserInput(effect.submit);
|
|
1050
|
+
this.#onSubmit(effect.submit);
|
|
1051
|
+
}
|
|
1052
|
+
if (effect.cancelRequested) {
|
|
1053
|
+
this.#ui.cancelFromKeybinding();
|
|
1054
|
+
}
|
|
1055
|
+
if (effect.notice) {
|
|
1056
|
+
this.#ui.appendTranscript(createTranscriptBlock("notice", this.#ui.newBlockId(), {
|
|
1057
|
+
text: effect.notice,
|
|
1058
|
+
style: "yellow",
|
|
1059
|
+
}));
|
|
1060
|
+
}
|
|
1061
|
+
if (effect.exitRequested) {
|
|
1062
|
+
this.requestExit();
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
#applyQuestionAction(action) {
|
|
1066
|
+
const question = this.#question;
|
|
1067
|
+
if (question === null) {
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
if (action.kind === "submit") {
|
|
1071
|
+
const answer = this.#editor.text;
|
|
1072
|
+
if (this.#question === question) {
|
|
1073
|
+
this.#question = null;
|
|
1074
|
+
}
|
|
1075
|
+
this.#resetEditor();
|
|
1076
|
+
question.resolve(answer);
|
|
1077
|
+
return true;
|
|
1078
|
+
}
|
|
1079
|
+
if (action.kind === "cancel" || action.kind === "eof") {
|
|
1080
|
+
if (this.#question === question) {
|
|
1081
|
+
this.#question = null;
|
|
1082
|
+
}
|
|
1083
|
+
this.#resetEditor();
|
|
1084
|
+
question.resolve("");
|
|
1085
|
+
return true;
|
|
1086
|
+
}
|
|
1087
|
+
const effect = this.#editor.apply(action, { runtimeActive: false });
|
|
1088
|
+
if (effect.notice) {
|
|
1089
|
+
this.#ui.appendTranscript(createTranscriptBlock("notice", this.#ui.newBlockId(), {
|
|
1090
|
+
text: effect.notice,
|
|
1091
|
+
style: "yellow",
|
|
1092
|
+
}));
|
|
1093
|
+
}
|
|
1094
|
+
return true;
|
|
1095
|
+
}
|
|
1096
|
+
#resetEditor() {
|
|
1097
|
+
this.#editor.text = "";
|
|
1098
|
+
this.#editor.cursor = 0;
|
|
1099
|
+
this.#editor.historyIndex = null;
|
|
1100
|
+
this.#editor.setCompletions([]);
|
|
1101
|
+
}
|
|
1102
|
+
#refreshCompletions() {
|
|
1103
|
+
if (this.#question !== null) {
|
|
1104
|
+
this.#editor.setCompletions([]);
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
const registry = this.#ui.commandRegistry;
|
|
1108
|
+
if (registry !== null) {
|
|
1109
|
+
this.#editor.setCompletions(registry.complete(this.#editor.text, {
|
|
1110
|
+
state: this.#ui.isRunning() ? "RUNNING_MODEL" : this.#ui.state.sessionState,
|
|
1111
|
+
}));
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
_a = InteractiveTerminalLoop;
|
|
1116
|
+
/**
|
|
1117
|
+
* Width used when the real terminal width cannot be determined — the same
|
|
1118
|
+
* default Rich's Console falls back to on an unknown-size terminal.
|
|
1119
|
+
*/
|
|
1120
|
+
const FALLBACK_MARKDOWN_WIDTH = 80;
|
|
1121
|
+
/**
|
|
1122
|
+
* Best-effort terminal width for the plain (loop-less) fallback writer, so
|
|
1123
|
+
* markdown output wraps at the real terminal width instead of being
|
|
1124
|
+
* hard-wrapped mid-word by the terminal itself.
|
|
1125
|
+
*/
|
|
1126
|
+
function resolveFallbackMarkdownWidth() {
|
|
1127
|
+
const columns = process.stdout?.columns;
|
|
1128
|
+
return typeof columns === "number" && columns > 0 ? columns : FALLBACK_MARKDOWN_WIDTH;
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Render interactive agent sessions with the append-only transcript model.
|
|
1132
|
+
*
|
|
1133
|
+
* With a terminal driver, all painting goes through the raw interactive
|
|
1134
|
+
* loop; without one the UI is a headless transcript plus a plain fallback
|
|
1135
|
+
* writer (non-interactive sessions should prefer PlainEventSink).
|
|
1136
|
+
*/
|
|
1137
|
+
export class TerminalUI {
|
|
1138
|
+
theme;
|
|
1139
|
+
state;
|
|
1140
|
+
reducer;
|
|
1141
|
+
commandRegistry;
|
|
1142
|
+
cancelCallback;
|
|
1143
|
+
runtimeRunningCallback = null;
|
|
1144
|
+
projectRoot;
|
|
1145
|
+
provider;
|
|
1146
|
+
model;
|
|
1147
|
+
dashboardUrl;
|
|
1148
|
+
#output;
|
|
1149
|
+
#askFallback;
|
|
1150
|
+
#loop = null;
|
|
1151
|
+
#transcript = [];
|
|
1152
|
+
#byCorrelation = new Map();
|
|
1153
|
+
#nextBlockId = 0;
|
|
1154
|
+
constructor(options = {}) {
|
|
1155
|
+
this.theme = resolveTerminalTheme(options.theme);
|
|
1156
|
+
this.projectRoot = options.projectRoot ?? null;
|
|
1157
|
+
this.provider = options.provider ?? null;
|
|
1158
|
+
this.model = options.model ?? null;
|
|
1159
|
+
this.dashboardUrl = options.dashboardUrl ?? null;
|
|
1160
|
+
this.commandRegistry = options.commandRegistry ?? null;
|
|
1161
|
+
this.cancelCallback = options.cancelCallback ?? null;
|
|
1162
|
+
this.#output = options.output ?? ((text) => console.log(text));
|
|
1163
|
+
this.#askFallback = options.askFallback ?? null;
|
|
1164
|
+
this.state = createUIState();
|
|
1165
|
+
this.state.provider = options.provider ?? "";
|
|
1166
|
+
this.state.model = options.model ?? "";
|
|
1167
|
+
this.reducer = new UIEventReducer(this.state);
|
|
1168
|
+
if (options.driver) {
|
|
1169
|
+
const editorFactory = options.editorFactory ?? (() => new BasicEditorState());
|
|
1170
|
+
const decoderFactory = options.decoderFactory ?? ((hooks) => new BasicInputDecoder(hooks));
|
|
1171
|
+
this.#loop = new InteractiveTerminalLoop(this, options.driver, editorFactory(), decoderFactory);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
get interactiveLoop() {
|
|
1175
|
+
return this.#loop;
|
|
1176
|
+
}
|
|
1177
|
+
get renderError() {
|
|
1178
|
+
return this.#loop?.writeError ?? null;
|
|
1179
|
+
}
|
|
1180
|
+
// -- prompts ------------------------------------------------------------
|
|
1181
|
+
prompt(message) {
|
|
1182
|
+
if (this.#loop !== null && this.#loop.running) {
|
|
1183
|
+
return this.#loop.ask(message ?? "Input:");
|
|
1184
|
+
}
|
|
1185
|
+
if (this.#askFallback !== null) {
|
|
1186
|
+
return this.#askFallback(message ?? "", false);
|
|
1187
|
+
}
|
|
1188
|
+
return Promise.reject(new Error("interactive prompt requires a running terminal loop"));
|
|
1189
|
+
}
|
|
1190
|
+
promptSecret(message) {
|
|
1191
|
+
if (this.#loop !== null && this.#loop.running) {
|
|
1192
|
+
return this.#loop.ask(message, { secret: true });
|
|
1193
|
+
}
|
|
1194
|
+
if (this.#askFallback !== null) {
|
|
1195
|
+
return this.#askFallback(message, true);
|
|
1196
|
+
}
|
|
1197
|
+
return Promise.reject(new Error("interactive prompt requires a running terminal loop"));
|
|
1198
|
+
}
|
|
1199
|
+
// -- loop lifecycle -----------------------------------------------------
|
|
1200
|
+
/** Run the single-owner interactive terminal for the whole session. */
|
|
1201
|
+
async run(onSubmit) {
|
|
1202
|
+
if (this.#loop === null) {
|
|
1203
|
+
throw new Error("single-renderer mode is unavailable for this UI");
|
|
1204
|
+
}
|
|
1205
|
+
this.#loop.start(onSubmit);
|
|
1206
|
+
await this.#loop.run();
|
|
1207
|
+
}
|
|
1208
|
+
requestExit() {
|
|
1209
|
+
this.#loop?.requestExit();
|
|
1210
|
+
}
|
|
1211
|
+
close() {
|
|
1212
|
+
this.#loop?.close();
|
|
1213
|
+
}
|
|
1214
|
+
startLoop(onSubmit) {
|
|
1215
|
+
if (this.#loop === null) {
|
|
1216
|
+
throw new Error("a terminal driver is required");
|
|
1217
|
+
}
|
|
1218
|
+
this.#loop.start(onSubmit);
|
|
1219
|
+
this.#loop.startTerminalModes();
|
|
1220
|
+
}
|
|
1221
|
+
feedInputBytes(data) {
|
|
1222
|
+
if (this.#loop === null) {
|
|
1223
|
+
throw new Error("a terminal driver is required");
|
|
1224
|
+
}
|
|
1225
|
+
this.#loop.feedInputBytes(data);
|
|
1226
|
+
}
|
|
1227
|
+
drainLoop() {
|
|
1228
|
+
this.#loop?.drain();
|
|
1229
|
+
}
|
|
1230
|
+
flushEventRenderer() {
|
|
1231
|
+
this.#loop?.drain();
|
|
1232
|
+
}
|
|
1233
|
+
// -- callbacks ----------------------------------------------------------
|
|
1234
|
+
setCommandRegistry(registry) {
|
|
1235
|
+
this.commandRegistry = registry;
|
|
1236
|
+
}
|
|
1237
|
+
setCancelCallback(callback) {
|
|
1238
|
+
this.cancelCallback = callback;
|
|
1239
|
+
}
|
|
1240
|
+
setRuntimeRunningCallback(callback) {
|
|
1241
|
+
this.runtimeRunningCallback = callback;
|
|
1242
|
+
}
|
|
1243
|
+
isRunning() {
|
|
1244
|
+
if (this.runtimeRunningCallback !== null) {
|
|
1245
|
+
return Boolean(this.runtimeRunningCallback());
|
|
1246
|
+
}
|
|
1247
|
+
return ["RUNNING_MODEL", "RUNNING_TOOL", "RUNNING_TOOLS", "CANCELLING"].includes(this.state.sessionState);
|
|
1248
|
+
}
|
|
1249
|
+
cancelFromKeybinding() {
|
|
1250
|
+
if (this.state.sessionState === "CANCELLING") {
|
|
1251
|
+
this.write("Cancelling…");
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
this.cancelCallback?.();
|
|
1255
|
+
}
|
|
1256
|
+
// -- transcript ---------------------------------------------------------
|
|
1257
|
+
newBlockId() {
|
|
1258
|
+
this.#nextBlockId += 1;
|
|
1259
|
+
return `local-${this.#nextBlockId}`;
|
|
1260
|
+
}
|
|
1261
|
+
appendTranscript(block) {
|
|
1262
|
+
this.#transcript.push(block);
|
|
1263
|
+
if (block.key) {
|
|
1264
|
+
this.#byCorrelation.set(`${block.kind}:${block.key}`, block);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
acceptUserInput(text) {
|
|
1268
|
+
this.appendTranscript(createTranscriptBlock("user", this.newBlockId(), { text }));
|
|
1269
|
+
}
|
|
1270
|
+
blockFor(kind, key) {
|
|
1271
|
+
const block = this.#byCorrelation.get(`${kind}:${key}`);
|
|
1272
|
+
if (block === undefined) {
|
|
1273
|
+
throw new Error(`no transcript block for ${kind}:${key}`);
|
|
1274
|
+
}
|
|
1275
|
+
return block;
|
|
1276
|
+
}
|
|
1277
|
+
/** Build every persisted transcript line; never crop history here. */
|
|
1278
|
+
buildHistoryLines(width) {
|
|
1279
|
+
return this.#buildHistoryFrameParts(width).lines;
|
|
1280
|
+
}
|
|
1281
|
+
#buildHistoryFrameParts(width) {
|
|
1282
|
+
const usableWidth = Math.max(12, width);
|
|
1283
|
+
const blocks = [...this.#transcript];
|
|
1284
|
+
const lines = [];
|
|
1285
|
+
let activeStart = null;
|
|
1286
|
+
for (const block of blocks) {
|
|
1287
|
+
if (block.mutable && activeStart === null) {
|
|
1288
|
+
activeStart = lines.length;
|
|
1289
|
+
}
|
|
1290
|
+
if (block.kind === "assistant") {
|
|
1291
|
+
lines.push(...renderMarkdownLines(block.text, usableWidth, this.theme));
|
|
1292
|
+
}
|
|
1293
|
+
else {
|
|
1294
|
+
for (const [style, text] of this.#renderTranscriptItem(block, usableWidth)) {
|
|
1295
|
+
lines.push(ansiStyledText(style, text.replace(/\n+$/u, "")));
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
return { lines, activeStart };
|
|
1300
|
+
}
|
|
1301
|
+
buildFrame(options) {
|
|
1302
|
+
const { width, editor } = options;
|
|
1303
|
+
const prompt = options.prompt ?? "❯ ";
|
|
1304
|
+
const secret = options.secret ?? false;
|
|
1305
|
+
const { lines: history, activeStart } = this.#buildHistoryFrameParts(width);
|
|
1306
|
+
const editorResult = editor.renderLines(width, { prompt, mask: secret });
|
|
1307
|
+
const completion = this.#completionLines(width, editor);
|
|
1308
|
+
const footer = this.#footerLine(width);
|
|
1309
|
+
const rows = [
|
|
1310
|
+
...history,
|
|
1311
|
+
"─".repeat(width),
|
|
1312
|
+
...editorResult.lines,
|
|
1313
|
+
"─".repeat(width),
|
|
1314
|
+
...completion,
|
|
1315
|
+
...(footer !== null ? [footer] : []),
|
|
1316
|
+
];
|
|
1317
|
+
const editorStart = history.length + 1;
|
|
1318
|
+
return {
|
|
1319
|
+
lines: rows,
|
|
1320
|
+
activeStart: activeStart ?? editorStart,
|
|
1321
|
+
cursorRow: editorStart + editorResult.cursorRow,
|
|
1322
|
+
cursorCol: editorResult.cursorCol,
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
#completionLines(width, editor) {
|
|
1326
|
+
const rows = [];
|
|
1327
|
+
editor.completions.slice(0, 6).forEach((item, index) => {
|
|
1328
|
+
const marker = index === editor.selectedCompletion ? "›" : " ";
|
|
1329
|
+
const text = `${marker} ${item.value} ${item.description}`.replace(/\s+$/u, "");
|
|
1330
|
+
rows.push(truncateToWidth(text, width));
|
|
1331
|
+
});
|
|
1332
|
+
return rows;
|
|
1333
|
+
}
|
|
1334
|
+
#footerLine(width) {
|
|
1335
|
+
const text = this.#footerText();
|
|
1336
|
+
return text ? truncateToWidth(text, width) : null;
|
|
1337
|
+
}
|
|
1338
|
+
#footerText() {
|
|
1339
|
+
const details = [];
|
|
1340
|
+
if (this.projectRoot !== null) {
|
|
1341
|
+
details.push(this.projectRoot);
|
|
1342
|
+
}
|
|
1343
|
+
if (this.state.pendingCount || this.state.heldCount) {
|
|
1344
|
+
details.push(`queue ${this.state.pendingCount} pending / ${this.state.heldCount} held`);
|
|
1345
|
+
}
|
|
1346
|
+
if (this.state.totalTokens) {
|
|
1347
|
+
details.push(`↑${this.state.inputTokens} ↓${this.state.outputTokens}`);
|
|
1348
|
+
}
|
|
1349
|
+
const model = this.state.model || (this.model ?? "");
|
|
1350
|
+
if (model) {
|
|
1351
|
+
const provider = this.state.provider || (this.provider ?? "");
|
|
1352
|
+
details.push(provider ? `${provider}/${model}` : model);
|
|
1353
|
+
}
|
|
1354
|
+
return details.join(" · ");
|
|
1355
|
+
}
|
|
1356
|
+
#renderTranscriptItem(item, width) {
|
|
1357
|
+
if (item.kind === "user") {
|
|
1358
|
+
return this.#backgroundLines(item.text, width, "user_bg", "text");
|
|
1359
|
+
}
|
|
1360
|
+
if (item.kind === "thinking") {
|
|
1361
|
+
return this.#plainLines(`thinking ${item.text}`, width, `italic ${this.theme.color("thinking")}`);
|
|
1362
|
+
}
|
|
1363
|
+
if (item.kind === "tool") {
|
|
1364
|
+
const status = item.status || "running";
|
|
1365
|
+
const isRunning = status === "running";
|
|
1366
|
+
const background = isRunning
|
|
1367
|
+
? "tool_pending_bg"
|
|
1368
|
+
: status === "completed"
|
|
1369
|
+
? "tool_success_bg"
|
|
1370
|
+
: "tool_error_bg";
|
|
1371
|
+
const accent = isRunning
|
|
1372
|
+
? "accent"
|
|
1373
|
+
: status === "completed"
|
|
1374
|
+
? "success"
|
|
1375
|
+
: "warning";
|
|
1376
|
+
let title = `● ${item.name || "tool"}`;
|
|
1377
|
+
if (item.subject) {
|
|
1378
|
+
title += ` ${clip(item.subject, 180)}`;
|
|
1379
|
+
}
|
|
1380
|
+
if (item.key) {
|
|
1381
|
+
title += ` [${item.key.slice(-8)}]`;
|
|
1382
|
+
}
|
|
1383
|
+
let detail = isRunning ? "Running…" : status;
|
|
1384
|
+
if (item.exitCode !== null) {
|
|
1385
|
+
detail += ` · exit ${item.exitCode}`;
|
|
1386
|
+
}
|
|
1387
|
+
if (item.durationMs !== null) {
|
|
1388
|
+
detail += ` · ${item.durationMs}ms`;
|
|
1389
|
+
}
|
|
1390
|
+
if (item.streamError) {
|
|
1391
|
+
detail += `\n${clip(item.streamError, 1_200)}`;
|
|
1392
|
+
}
|
|
1393
|
+
const rendered = this.#backgroundLines(`${title}\n${detail}`, width, background, "text");
|
|
1394
|
+
const first = rendered[0];
|
|
1395
|
+
if (first !== undefined) {
|
|
1396
|
+
rendered[0] = [
|
|
1397
|
+
first[0].replace(this.theme.color("text"), this.theme.color(accent)),
|
|
1398
|
+
first[1],
|
|
1399
|
+
];
|
|
1400
|
+
}
|
|
1401
|
+
return rendered;
|
|
1402
|
+
}
|
|
1403
|
+
return this.#plainLines(item.text, width, item.style || this.theme.color("text"));
|
|
1404
|
+
}
|
|
1405
|
+
#backgroundLines(text, width, background, foreground) {
|
|
1406
|
+
const style = `bg:${this.theme.color(background)} ${this.theme.color(foreground)}`;
|
|
1407
|
+
return wrapTextToWidth(text, width).map((line) => [
|
|
1408
|
+
style,
|
|
1409
|
+
_b.#padLine(line, width) + "\n",
|
|
1410
|
+
]);
|
|
1411
|
+
}
|
|
1412
|
+
#plainLines(text, width, style) {
|
|
1413
|
+
return wrapTextToWidth(text, width).map((line) => [style, `${line}\n`]);
|
|
1414
|
+
}
|
|
1415
|
+
static #padLine(line, width) {
|
|
1416
|
+
return line + " ".repeat(Math.max(0, width - visibleWidth(line)));
|
|
1417
|
+
}
|
|
1418
|
+
// -- events ---------------------------------------------------------------
|
|
1419
|
+
/** Queue a projected event; the loop is the sole event writer. */
|
|
1420
|
+
publishEvent(event) {
|
|
1421
|
+
if (this.#loop !== null) {
|
|
1422
|
+
this.#loop.publishEvent(event);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (event instanceof LocalMessage) {
|
|
1426
|
+
this.appendTranscript(createTranscriptBlock("notice", this.newBlockId(), {
|
|
1427
|
+
text: event.text,
|
|
1428
|
+
style: event.style,
|
|
1429
|
+
}));
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
if (isHiddenToolStdout(event)) {
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const dropped = projectionDropped(event);
|
|
1436
|
+
if (dropped) {
|
|
1437
|
+
this.appendTranscript(createTranscriptBlock("notice", this.newBlockId(), {
|
|
1438
|
+
text: `… 省略了 ${dropped} 个流式展示事件;Agent 仍继续运行。`,
|
|
1439
|
+
style: "yellow",
|
|
1440
|
+
}));
|
|
1441
|
+
}
|
|
1442
|
+
this.applyProjectedEvent(event);
|
|
1443
|
+
}
|
|
1444
|
+
/** Reduce a projected event and apply its append-only block change. */
|
|
1445
|
+
applyProjectedEvent(event) {
|
|
1446
|
+
if (isHiddenToolStdout(event)) {
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
const update = this.reducer.apply(event);
|
|
1450
|
+
if (update !== null) {
|
|
1451
|
+
this.applyTranscriptUpdate(update);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
/** Mutate transcript state; the raw loop paints it later. */
|
|
1455
|
+
applyTranscriptUpdate(update) {
|
|
1456
|
+
const kind = update.kind;
|
|
1457
|
+
const correlationId = update.correlationId;
|
|
1458
|
+
if (kind === "ui.message") {
|
|
1459
|
+
this.appendTranscript(createTranscriptBlock("notice", this.newBlockId(), {
|
|
1460
|
+
text: update.text,
|
|
1461
|
+
style: String(update.payload.style ?? ""),
|
|
1462
|
+
}));
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
if (kind === "model.text_delta") {
|
|
1466
|
+
this.#freezeThinking();
|
|
1467
|
+
const key = `assistant:${correlationId}`;
|
|
1468
|
+
let item = this.#byCorrelation.get(key);
|
|
1469
|
+
if (item === undefined) {
|
|
1470
|
+
item = createTranscriptBlock("assistant", correlationId, { mutable: true });
|
|
1471
|
+
this.#transcript.push(item);
|
|
1472
|
+
this.#byCorrelation.set(key, item);
|
|
1473
|
+
}
|
|
1474
|
+
if (item.mutable) {
|
|
1475
|
+
item.text += update.text;
|
|
1476
|
+
}
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
if (kind === "model.reasoning_delta") {
|
|
1480
|
+
const key = `thinking:${correlationId}`;
|
|
1481
|
+
let item = this.#byCorrelation.get(key);
|
|
1482
|
+
if (item === undefined) {
|
|
1483
|
+
item = createTranscriptBlock("thinking", correlationId, { mutable: true });
|
|
1484
|
+
this.#transcript.push(item);
|
|
1485
|
+
this.#byCorrelation.set(key, item);
|
|
1486
|
+
}
|
|
1487
|
+
if (item.mutable) {
|
|
1488
|
+
item.text += update.text;
|
|
1489
|
+
}
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
if (kind === "model.response_committed" ||
|
|
1493
|
+
kind === "model.response_aborted" ||
|
|
1494
|
+
kind === "model.request_failed") {
|
|
1495
|
+
for (const blockKind of ["assistant", "thinking"]) {
|
|
1496
|
+
const block = this.#byCorrelation.get(`${blockKind}:${correlationId}`);
|
|
1497
|
+
if (block !== undefined) {
|
|
1498
|
+
block.mutable = false;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
if (kind === "tool.started") {
|
|
1504
|
+
this.#freezeThinking();
|
|
1505
|
+
const args = update.payload.arguments;
|
|
1506
|
+
let subject = "";
|
|
1507
|
+
if (isRecord(args)) {
|
|
1508
|
+
subject = String(args.command || args.path || "");
|
|
1509
|
+
}
|
|
1510
|
+
this.appendTranscript(createTranscriptBlock("tool", correlationId, {
|
|
1511
|
+
mutable: true,
|
|
1512
|
+
name: String(update.payload.name ?? "tool"),
|
|
1513
|
+
subject,
|
|
1514
|
+
status: "running",
|
|
1515
|
+
}));
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
if (kind === "tool.output_delta" && update.stream === "stderr") {
|
|
1519
|
+
const item = this.#byCorrelation.get(`tool:${correlationId}`);
|
|
1520
|
+
if (item !== undefined) {
|
|
1521
|
+
item.streamError = (item.streamError + update.text).slice(-20_000);
|
|
1522
|
+
}
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
if (kind === "tool.finished") {
|
|
1526
|
+
const key = `tool:${correlationId}`;
|
|
1527
|
+
let item = this.#byCorrelation.get(key);
|
|
1528
|
+
if (item === undefined) {
|
|
1529
|
+
item = createTranscriptBlock("tool", correlationId);
|
|
1530
|
+
this.#transcript.push(item);
|
|
1531
|
+
this.#byCorrelation.set(key, item);
|
|
1532
|
+
}
|
|
1533
|
+
item.status = String(update.payload.status ?? "completed");
|
|
1534
|
+
const rawExitCode = update.payload.exit_code;
|
|
1535
|
+
item.exitCode = typeof rawExitCode === "number" ? Math.trunc(rawExitCode) : null;
|
|
1536
|
+
const rawDuration = update.payload.duration_ms;
|
|
1537
|
+
item.durationMs = typeof rawDuration === "number" ? Math.trunc(rawDuration) : null;
|
|
1538
|
+
item.mutable = false;
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
if (kind === "task.cancelled") {
|
|
1542
|
+
this.appendTranscript(createTranscriptBlock("notice", this.newBlockId(), {
|
|
1543
|
+
text: "任务已取消;已经完成的文件修改不会自动撤销。",
|
|
1544
|
+
style: "yellow",
|
|
1545
|
+
}));
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (kind === "task.failed") {
|
|
1549
|
+
this.appendTranscript(createTranscriptBlock("notice", this.newBlockId(), {
|
|
1550
|
+
text: `Error: ${String(update.payload.error ?? "Task failed")}`,
|
|
1551
|
+
style: `bold ${this.theme.color("error")}`,
|
|
1552
|
+
}));
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
#freezeThinking() {
|
|
1556
|
+
for (const block of this.#transcript) {
|
|
1557
|
+
if (block.kind === "thinking") {
|
|
1558
|
+
block.mutable = false;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
// -- direct user-facing writes -------------------------------------------
|
|
1563
|
+
write(message) {
|
|
1564
|
+
this.#writeLocal(message);
|
|
1565
|
+
}
|
|
1566
|
+
showError(message) {
|
|
1567
|
+
this.#writeLocal(`Error: ${message}`, `bold ${this.theme.color("error")}`);
|
|
1568
|
+
}
|
|
1569
|
+
showInterrupted(options = {}) {
|
|
1570
|
+
const message = options.operation ? "Operation interrupted." : "Interrupted.";
|
|
1571
|
+
this.#writeLocal(message, this.theme.color("warning"));
|
|
1572
|
+
}
|
|
1573
|
+
showGoodbye() {
|
|
1574
|
+
if (this.#loop !== null) {
|
|
1575
|
+
if (!this.#loop.closed) {
|
|
1576
|
+
this.#writeLocal("Goodbye.", this.theme.color("dim"));
|
|
1577
|
+
this.flushEventRenderer();
|
|
1578
|
+
}
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
this.#output(ansiStyledText(this.theme.color("dim"), "Goodbye."));
|
|
1582
|
+
}
|
|
1583
|
+
showAssistant(response) {
|
|
1584
|
+
if (this.#loop !== null) {
|
|
1585
|
+
this.appendTranscript(createTranscriptBlock("assistant", this.newBlockId(), { text: response }));
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
for (const line of renderMarkdownLines(response, resolveFallbackMarkdownWidth(), this.theme)) {
|
|
1589
|
+
this.#output(line);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
showWelcome() {
|
|
1593
|
+
const details = [];
|
|
1594
|
+
if (this.projectRoot !== null) {
|
|
1595
|
+
details.push(this.projectRoot);
|
|
1596
|
+
}
|
|
1597
|
+
if (this.provider && this.model) {
|
|
1598
|
+
details.push(`${this.provider}/${this.model}`);
|
|
1599
|
+
}
|
|
1600
|
+
if (this.dashboardUrl) {
|
|
1601
|
+
details.push(`trace: ${this.dashboardUrl}`);
|
|
1602
|
+
}
|
|
1603
|
+
if (this.#loop !== null) {
|
|
1604
|
+
this.appendTranscript(createTranscriptBlock("notice", this.newBlockId(), {
|
|
1605
|
+
text: "laoHuangCode /help for commands",
|
|
1606
|
+
style: `bold ${this.theme.color("accent")}`,
|
|
1607
|
+
}));
|
|
1608
|
+
if (details.length > 0) {
|
|
1609
|
+
this.appendTranscript(createTranscriptBlock("notice", this.newBlockId(), {
|
|
1610
|
+
text: details.join(" · "),
|
|
1611
|
+
style: this.theme.color("dim"),
|
|
1612
|
+
}));
|
|
1613
|
+
}
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
this.#output(ansiStyledText(`bold ${this.theme.color("accent")}`, "laoHuangCode") +
|
|
1617
|
+
" " +
|
|
1618
|
+
ansiStyledText(this.theme.color("dim"), "/help for commands"));
|
|
1619
|
+
if (details.length > 0) {
|
|
1620
|
+
this.#output(ansiStyledText(this.theme.color("dim"), details.join(" · ")));
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
#writeLocal(message, style = "") {
|
|
1624
|
+
if (this.#loop !== null) {
|
|
1625
|
+
if (!this.#loop.closed) {
|
|
1626
|
+
this.#loop.publishEvent(new LocalMessage(message, style));
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
this.#output(ansiStyledText(style, message));
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
this.#output(ansiStyledText(style, message));
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
_b = TerminalUI;
|
|
1636
|
+
// ---------------------------------------------------------------------------
|
|
1637
|
+
// PlainEventSink — append-only renderer for non-interactive stdio
|
|
1638
|
+
// ---------------------------------------------------------------------------
|
|
1639
|
+
/**
|
|
1640
|
+
* Append-only renderer used when stdin/stdout are not interactive TTYs.
|
|
1641
|
+
*
|
|
1642
|
+
* The Python original renders from a background thread fed by a bounded
|
|
1643
|
+
* queue; the TS event loop is single-threaded, so events render
|
|
1644
|
+
* synchronously in publication order and flush()/stop() are lifecycle
|
|
1645
|
+
* no-ops kept for API parity. Render errors are captured, never thrown
|
|
1646
|
+
* back into the event bus.
|
|
1647
|
+
*/
|
|
1648
|
+
export class PlainEventSink {
|
|
1649
|
+
outputFn;
|
|
1650
|
+
#stopped = false;
|
|
1651
|
+
#modelBuffers = new Map();
|
|
1652
|
+
renderError = null;
|
|
1653
|
+
constructor(outputFn = (text) => console.log(text)) {
|
|
1654
|
+
this.outputFn = outputFn;
|
|
1655
|
+
}
|
|
1656
|
+
publishEvent(event) {
|
|
1657
|
+
if (this.#stopped || isHiddenToolStdout(event)) {
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
try {
|
|
1661
|
+
this.#renderEvent(event);
|
|
1662
|
+
}
|
|
1663
|
+
catch (error) {
|
|
1664
|
+
this.renderError = error;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
write(message) {
|
|
1668
|
+
if (!this.#stopped) {
|
|
1669
|
+
this.outputFn(message);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
flush() {
|
|
1673
|
+
// Synchronous rendering drains on every publish; nothing to wait for.
|
|
1674
|
+
}
|
|
1675
|
+
stop(_options = {}) {
|
|
1676
|
+
this.#stopped = true;
|
|
1677
|
+
}
|
|
1678
|
+
#renderEvent(event) {
|
|
1679
|
+
if (!isRecord(event)) {
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
const kind = String(event.kind ?? "");
|
|
1683
|
+
const payload = eventPayload(event);
|
|
1684
|
+
if (kind === "tool.output_delta" && String(payload.stream ?? "stdout") === "stdout") {
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
const dropped = toInt(payload._projection_dropped ?? 0);
|
|
1688
|
+
if (dropped) {
|
|
1689
|
+
this.outputFn(`[stream output omitted: ${dropped} event(s); runtime continued]`);
|
|
1690
|
+
}
|
|
1691
|
+
const correlationId = String(event.correlation_id ?? "").slice(-8);
|
|
1692
|
+
if (kind === "model.text_delta") {
|
|
1693
|
+
const text = String(payload.text ?? payload.chunk ?? "");
|
|
1694
|
+
if (text) {
|
|
1695
|
+
const buffer = this.#modelBuffers.get(correlationId) ?? [];
|
|
1696
|
+
buffer.push(text);
|
|
1697
|
+
this.#modelBuffers.set(correlationId, buffer);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
else if (kind === "model.response_committed") {
|
|
1701
|
+
const text = (this.#modelBuffers.get(correlationId) ?? []).join("");
|
|
1702
|
+
this.#modelBuffers.delete(correlationId);
|
|
1703
|
+
if (text) {
|
|
1704
|
+
this.outputFn(text);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
else if (kind === "model.response_aborted" || kind === "model.request_failed") {
|
|
1708
|
+
const text = (this.#modelBuffers.get(correlationId) ?? []).join("");
|
|
1709
|
+
this.#modelBuffers.delete(correlationId);
|
|
1710
|
+
if (text) {
|
|
1711
|
+
this.outputFn(text);
|
|
1712
|
+
this.outputFn("[response interrupted; not added to context]");
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
else if (kind === "ui.message") {
|
|
1716
|
+
this.outputFn(String(payload.text ?? ""));
|
|
1717
|
+
}
|
|
1718
|
+
else if (kind === "tool.started") {
|
|
1719
|
+
const name = String(payload.name ?? "tool");
|
|
1720
|
+
this.outputFn(`[tool:${correlationId || "unknown"}] ${name} started`);
|
|
1721
|
+
}
|
|
1722
|
+
else if (kind === "tool.output_delta") {
|
|
1723
|
+
const stream = String(payload.stream ?? "stdout");
|
|
1724
|
+
const text = String(payload.text ?? payload.chunk ?? "");
|
|
1725
|
+
if (text) {
|
|
1726
|
+
this.outputFn(`[${correlationId || "unknown"}:${stream}] ${text}`);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
else if (kind === "tool.finished") {
|
|
1730
|
+
const status = String(payload.status ?? "completed");
|
|
1731
|
+
this.outputFn(`[tool:${correlationId || "unknown"}] ${status}`);
|
|
1732
|
+
}
|
|
1733
|
+
else if (kind === "task.failed") {
|
|
1734
|
+
this.outputFn(`Error: ${String(payload.error ?? "Task failed")}`);
|
|
1735
|
+
}
|
|
1736
|
+
else if (kind === "task.cancelled") {
|
|
1737
|
+
this.outputFn("Task cancelled; completed file changes were not reverted.");
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
// ---------------------------------------------------------------------------
|
|
1742
|
+
// StdTerminalDriver — the small POSIX adapter used by the regular-screen loop
|
|
1743
|
+
// ---------------------------------------------------------------------------
|
|
1744
|
+
/**
|
|
1745
|
+
* Make terminal control bytes visible for the debug capture log: ESC becomes
|
|
1746
|
+
* the literal text `\x1b`, CR becomes `\r`, other C0/DEL bytes become `\xHH`.
|
|
1747
|
+
* Newlines are kept so the log stays line-oriented.
|
|
1748
|
+
*/
|
|
1749
|
+
function escapeDebugCapture(data) {
|
|
1750
|
+
let escaped = "";
|
|
1751
|
+
for (const char of data) {
|
|
1752
|
+
const code = char.codePointAt(0) ?? 0;
|
|
1753
|
+
if (char === "\r") {
|
|
1754
|
+
escaped += "\\r";
|
|
1755
|
+
}
|
|
1756
|
+
else if ((code < 0x20 && char !== "\n") || code === 0x7f) {
|
|
1757
|
+
escaped += `\\x${code.toString(16).padStart(2, "0")}`;
|
|
1758
|
+
}
|
|
1759
|
+
else {
|
|
1760
|
+
escaped += char;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
return escaped;
|
|
1764
|
+
}
|
|
1765
|
+
/** Production terminal driver over process stdin/stdout. */
|
|
1766
|
+
export class StdTerminalDriver {
|
|
1767
|
+
#rawModeActive = false;
|
|
1768
|
+
/**
|
|
1769
|
+
* Debug capture target from LAOHUANG_DEBUG_LOG: when set, every write is
|
|
1770
|
+
* teed to this file with escape sequences made visible (for diagnosing
|
|
1771
|
+
* stray-character bugs in the field). Null keeps write() zero-cost.
|
|
1772
|
+
*/
|
|
1773
|
+
#debugLogPath;
|
|
1774
|
+
constructor() {
|
|
1775
|
+
const path = process.env.LAOHUANG_DEBUG_LOG?.trim();
|
|
1776
|
+
this.#debugLogPath = path ? path : null;
|
|
1777
|
+
}
|
|
1778
|
+
get inputStream() {
|
|
1779
|
+
return process.stdin;
|
|
1780
|
+
}
|
|
1781
|
+
enterRawMode() {
|
|
1782
|
+
if (!process.stdin.isTTY) {
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
process.stdin.setRawMode(true);
|
|
1786
|
+
this.#rawModeActive = true;
|
|
1787
|
+
}
|
|
1788
|
+
write(data) {
|
|
1789
|
+
if (this.#debugLogPath !== null) {
|
|
1790
|
+
try {
|
|
1791
|
+
appendFileSync(this.#debugLogPath, escapeDebugCapture(data));
|
|
1792
|
+
}
|
|
1793
|
+
catch {
|
|
1794
|
+
// Debug capture is best-effort and must never break the UI.
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
process.stdout.write(data);
|
|
1798
|
+
}
|
|
1799
|
+
flush() {
|
|
1800
|
+
// process.stdout.write is already unbuffered from our perspective.
|
|
1801
|
+
}
|
|
1802
|
+
getSize() {
|
|
1803
|
+
return {
|
|
1804
|
+
columns: process.stdout.columns || 80,
|
|
1805
|
+
rows: process.stdout.rows || 24,
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
restore() {
|
|
1809
|
+
if (this.#rawModeActive) {
|
|
1810
|
+
process.stdin.setRawMode(false);
|
|
1811
|
+
this.#rawModeActive = false;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
//# sourceMappingURL=ui.js.map
|