wtagent 0.1.0 → 0.2.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 +50 -36
- package/package.json +2 -2
- package/src/browser/base-web-adapter.js +1178 -0
- package/src/browser/cdp-browser.js +62 -0
- package/src/browser/cdp-state.js +52 -4
- package/src/browser/chatgpt-web-adapter.js +301 -992
- package/src/browser/claude-web-adapter.js +205 -0
- package/src/browser/deepseek-web-adapter.js +337 -0
- package/src/browser/gemini-web-adapter.js +465 -0
- package/src/browser/glm-web-adapter.js +246 -0
- package/src/browser/kimi-web-adapter.js +285 -0
- package/src/browser/provider-registry.js +199 -0
- package/src/cli/main.js +276 -59
- package/src/cli/notice-store.js +125 -0
- package/src/cli/prompt-input.js +504 -170
- package/src/cli/render-events.js +14 -8
- package/src/cli/self-update.js +145 -0
- package/src/cli/startup-notices.js +161 -0
- package/src/platform/paths.js +7 -0
- package/src/protocol/markers.js +3 -2
- package/src/protocol/prompt-builder.js +9 -5
- package/src/protocol/xml-protocol.js +193 -11
- package/src/runtime/agent-runtime.js +131 -18
- package/src/session/agent-session.js +4 -1
- package/src/session/canonical-transcript.js +2 -2
- package/src/session/session-export.js +2 -2
- package/src/shared/fetch-json.js +24 -0
- package/src/shared/limits.js +11 -1
- package/src/shared/package-info.js +23 -0
- package/src/shared/usage-limit.js +2 -2
package/src/cli/prompt-input.js
CHANGED
|
@@ -1,24 +1,61 @@
|
|
|
1
|
-
import { input, select } from "@inquirer/prompts";
|
|
2
|
-
import { createInterface } from "node:readline/promises";
|
|
3
|
-
import { Transform } from "node:stream";
|
|
1
|
+
import { confirm, input, select } from "@inquirer/prompts";
|
|
4
2
|
import { StringDecoder } from "node:string_decoder";
|
|
5
3
|
|
|
6
4
|
const BRACKETED_PASTE_START = "\u001b[200~";
|
|
7
5
|
const BRACKETED_PASTE_END = "\u001b[201~";
|
|
8
6
|
const ENABLE_BRACKETED_PASTE = "\u001b[?2004h";
|
|
9
7
|
const DISABLE_BRACKETED_PASTE = "\u001b[?2004l";
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
8
|
+
// Shift+Enter encodings we can actually distinguish from a plain Enter:
|
|
9
|
+
// CSI-u (kitty / iTerm CSI-u) ESC [ 13 ; 2 u
|
|
10
|
+
// xterm modifyOtherKeys ESC [ 27 ; 2 ; 13 ~
|
|
11
|
+
// ESC + CR some iTerm / VS Code / tmux setups
|
|
12
|
+
// Terminals that still send a bare CR for Shift+Enter cannot be disambiguated;
|
|
13
|
+
// Ctrl+J (bare LF in raw mode) is the reliable fallback and is also mapped.
|
|
14
|
+
const SHIFT_ENTER_KITTY = "\u001b[13;2u";
|
|
15
|
+
const SHIFT_ENTER_MODIFY_OTHER = "\u001b[27;2;13~";
|
|
16
|
+
const SHIFT_ENTER_ESC_CR = "\u001b\r";
|
|
17
|
+
const SHIFT_ENTER_SEQUENCES = Object.freeze([
|
|
18
|
+
SHIFT_ENTER_KITTY,
|
|
19
|
+
SHIFT_ENTER_MODIFY_OTHER,
|
|
20
|
+
SHIFT_ENTER_ESC_CR,
|
|
21
|
+
]);
|
|
20
22
|
const ENABLE_KITTY_KEYBOARD = "\u001b[>1u";
|
|
21
23
|
const DISABLE_KITTY_KEYBOARD = "\u001b[<1u";
|
|
24
|
+
const ENABLE_MODIFY_OTHER_KEYS = "\u001b[>4;2m";
|
|
25
|
+
const DISABLE_MODIFY_OTHER_KEYS = "\u001b[>4;0m";
|
|
26
|
+
|
|
27
|
+
const KEY_UP = "\u001b[A";
|
|
28
|
+
const KEY_DOWN = "\u001b[B";
|
|
29
|
+
const KEY_RIGHT = "\u001b[C";
|
|
30
|
+
const KEY_LEFT = "\u001b[D";
|
|
31
|
+
const KEY_HOME = "\u001b[H";
|
|
32
|
+
const KEY_END = "\u001b[F";
|
|
33
|
+
const KEY_HOME_TILDE = "\u001b[1~";
|
|
34
|
+
const KEY_END_TILDE = "\u001b[4~";
|
|
35
|
+
const KEY_HOME_APP = "\u001bOH";
|
|
36
|
+
const KEY_END_APP = "\u001bOF";
|
|
37
|
+
const KEY_DELETE = "\u001b[3~";
|
|
38
|
+
|
|
39
|
+
// Every escape sequence the editor understands, used both for dispatch and to
|
|
40
|
+
// hold a split-across-chunks prefix back instead of inserting it as text.
|
|
41
|
+
const KNOWN_ESCAPE_SEQUENCES = Object.freeze([
|
|
42
|
+
BRACKETED_PASTE_START,
|
|
43
|
+
BRACKETED_PASTE_END,
|
|
44
|
+
KEY_UP,
|
|
45
|
+
KEY_DOWN,
|
|
46
|
+
KEY_RIGHT,
|
|
47
|
+
KEY_LEFT,
|
|
48
|
+
KEY_HOME,
|
|
49
|
+
KEY_END,
|
|
50
|
+
KEY_HOME_TILDE,
|
|
51
|
+
KEY_END_TILDE,
|
|
52
|
+
KEY_HOME_APP,
|
|
53
|
+
KEY_END_APP,
|
|
54
|
+
KEY_DELETE,
|
|
55
|
+
SHIFT_ENTER_KITTY,
|
|
56
|
+
SHIFT_ENTER_MODIFY_OTHER,
|
|
57
|
+
SHIFT_ENTER_ESC_CR,
|
|
58
|
+
]);
|
|
22
59
|
|
|
23
60
|
const CLEAN_EXIT_ERRORS = new Set([
|
|
24
61
|
"AbortPromptError",
|
|
@@ -32,121 +69,58 @@ const EXIT_COMMANDS = new Set([
|
|
|
32
69
|
"/quit",
|
|
33
70
|
]);
|
|
34
71
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
72
|
+
// Rough terminal cell width for one code point. Good enough for wrap and
|
|
73
|
+
// cursor math on typical chat input: CJK/fullwidth/emoji count 2, combining
|
|
74
|
+
// marks count 0, everything else 1.
|
|
75
|
+
function charWidth(codePoint) {
|
|
76
|
+
if (
|
|
77
|
+
codePoint >= 0x1100 && (
|
|
78
|
+
codePoint <= 0x115F
|
|
79
|
+
|| (codePoint >= 0x2E80 && codePoint <= 0xA4CF)
|
|
80
|
+
|| (codePoint >= 0xAC00 && codePoint <= 0xD7A3)
|
|
81
|
+
|| (codePoint >= 0xF900 && codePoint <= 0xFAFF)
|
|
82
|
+
|| (codePoint >= 0xFE10 && codePoint <= 0xFE19)
|
|
83
|
+
|| (codePoint >= 0xFE30 && codePoint <= 0xFE6F)
|
|
84
|
+
|| (codePoint >= 0xFF00 && codePoint <= 0xFF60)
|
|
85
|
+
|| (codePoint >= 0xFFE0 && codePoint <= 0xFFE6)
|
|
86
|
+
|| (codePoint >= 0x1F300 && codePoint <= 0x1F64F)
|
|
87
|
+
|| (codePoint >= 0x1F900 && codePoint <= 0x1F9FF)
|
|
88
|
+
|| (codePoint >= 0x20000 && codePoint <= 0x3FFFD)
|
|
89
|
+
)
|
|
90
|
+
) {
|
|
91
|
+
return 2;
|
|
41
92
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function looksLikeUnbracketedMultilinePaste(text) {
|
|
54
|
-
const normalized = text.replace(/\r\n|\r/g, "\n");
|
|
55
|
-
const firstNewline = normalized.indexOf("\n");
|
|
56
|
-
if (firstNewline < 0) {
|
|
57
|
-
return false;
|
|
93
|
+
if (
|
|
94
|
+
(codePoint >= 0x0300 && codePoint <= 0x036F)
|
|
95
|
+
|| (codePoint >= 0x1AB0 && codePoint <= 0x1AFF)
|
|
96
|
+
|| (codePoint >= 0x1DC0 && codePoint <= 0x1DFF)
|
|
97
|
+
|| (codePoint >= 0x20D0 && codePoint <= 0x20FF)
|
|
98
|
+
|| (codePoint >= 0xFE00 && codePoint <= 0xFE0F)
|
|
99
|
+
|| codePoint === 0x200D
|
|
100
|
+
) {
|
|
101
|
+
return 0;
|
|
58
102
|
}
|
|
59
|
-
|
|
60
|
-
// event, cannot be a single Enter key. Treat it as an unmarked paste. A
|
|
61
|
-
// normal test/pipe write such as "message\n" remains a submit.
|
|
62
|
-
return firstNewline < normalized.length - 1
|
|
63
|
-
|| normalized.indexOf("\n", firstNewline + 1) >= 0;
|
|
103
|
+
return 1;
|
|
64
104
|
}
|
|
65
105
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
this.pending = "";
|
|
71
|
-
this.inBracketedPaste = false;
|
|
72
|
-
this.isTTY = source.isTTY;
|
|
73
|
-
this.columns = source.columns;
|
|
74
|
-
this.setRawMode = typeof source.setRawMode === "function"
|
|
75
|
-
? (enabled) => source.setRawMode(enabled)
|
|
76
|
-
: undefined;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
_transform(chunk, _encoding, callback) {
|
|
80
|
-
try {
|
|
81
|
-
this.#consume(this.decoder.write(chunk), false);
|
|
82
|
-
callback();
|
|
83
|
-
} catch (error) {
|
|
84
|
-
callback(error);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
_flush(callback) {
|
|
89
|
-
try {
|
|
90
|
-
this.#consume(this.decoder.end(), true);
|
|
91
|
-
callback();
|
|
92
|
-
} catch (error) {
|
|
93
|
-
callback(error);
|
|
94
|
-
}
|
|
106
|
+
function displayWidth(text) {
|
|
107
|
+
let width = 0;
|
|
108
|
+
for (const ch of String(text ?? "")) {
|
|
109
|
+
width += charWidth(ch.codePointAt(0));
|
|
95
110
|
}
|
|
111
|
+
return width;
|
|
112
|
+
}
|
|
96
113
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
: BRACKETED_PASTE_START;
|
|
105
|
-
const markerIndex = this.pending.indexOf(marker);
|
|
106
|
-
if (markerIndex >= 0) {
|
|
107
|
-
const before = this.pending.slice(0, markerIndex);
|
|
108
|
-
output += this.inBracketedPaste
|
|
109
|
-
? encodePastedLines(before)
|
|
110
|
-
: this.#outsidePaste(before);
|
|
111
|
-
this.pending = this.pending.slice(markerIndex + marker.length);
|
|
112
|
-
this.inBracketedPaste = !this.inBracketedPaste;
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// A Shift+Enter sequence can also arrive split across chunks; keep any
|
|
117
|
-
// partial prefix pending so it is not pushed through as literal text.
|
|
118
|
-
const markerRetained = flush
|
|
119
|
-
? 0
|
|
120
|
-
: longestMarkerPrefixAtEnd(this.pending, marker);
|
|
121
|
-
const shiftEnterRetained = flush
|
|
122
|
-
? 0
|
|
123
|
-
: longestMarkerPrefixAtEnd(this.pending, SHIFT_ENTER_SEQUENCE);
|
|
124
|
-
const retained = Math.max(markerRetained, shiftEnterRetained);
|
|
125
|
-
const ready = this.pending.slice(0, this.pending.length - retained);
|
|
126
|
-
output += this.inBracketedPaste
|
|
127
|
-
? encodePastedLines(ready)
|
|
128
|
-
: this.#outsidePaste(ready);
|
|
129
|
-
this.pending = this.pending.slice(this.pending.length - retained);
|
|
130
|
-
break;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (output) {
|
|
134
|
-
this.push(output);
|
|
114
|
+
// Longest tail of `text` that is a strict prefix of `sequence` — the part that
|
|
115
|
+
// must be held back because the sequence may continue in the next chunk.
|
|
116
|
+
function longestPrefixAtEnd(text, sequence) {
|
|
117
|
+
const limit = Math.min(text.length, sequence.length - 1);
|
|
118
|
+
for (let length = limit; length > 0; length -= 1) {
|
|
119
|
+
if (sequence.startsWith(text.slice(-length))) {
|
|
120
|
+
return length;
|
|
135
121
|
}
|
|
136
122
|
}
|
|
137
|
-
|
|
138
|
-
#outsidePaste(text) {
|
|
139
|
-
// Shift+Enter (CSI-u "Enter with shift") is a newline, not a submit. Map
|
|
140
|
-
// it to the same non-submitting line separator readline keeps in its
|
|
141
|
-
// editable buffer, then decode it back to LF with the rest on output.
|
|
142
|
-
const withNewlines = text.replaceAll(
|
|
143
|
-
SHIFT_ENTER_SEQUENCE,
|
|
144
|
-
PASTED_LINE_SEPARATOR,
|
|
145
|
-
);
|
|
146
|
-
return looksLikeUnbracketedMultilinePaste(withNewlines)
|
|
147
|
-
? encodePastedLines(withNewlines)
|
|
148
|
-
: withNewlines;
|
|
149
|
-
}
|
|
123
|
+
return 0;
|
|
150
124
|
}
|
|
151
125
|
|
|
152
126
|
export function classifyChatInput(value) {
|
|
@@ -177,6 +151,11 @@ export async function readChatMessage(read) {
|
|
|
177
151
|
}
|
|
178
152
|
}
|
|
179
153
|
|
|
154
|
+
// Interactive chat input: a small raw-mode multi-line editor. Newlines are
|
|
155
|
+
// REAL newlines — pasted line breaks and Shift+Enter/Ctrl+J are visible while
|
|
156
|
+
// editing (readline's single-line buffer forced an invisible U+2028 encoding
|
|
157
|
+
// instead). Enter submits, ↑/↓ walk history, ←/→/Home/End move the cursor,
|
|
158
|
+
// Ctrl+C / Ctrl+D cancel.
|
|
180
159
|
export class ShellChatInput {
|
|
181
160
|
constructor({
|
|
182
161
|
inputStream = process.stdin,
|
|
@@ -204,62 +183,410 @@ export class ShellChatInput {
|
|
|
204
183
|
if (this.inputStream.readableEnded) {
|
|
205
184
|
return null;
|
|
206
185
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
this.inputStream.pipe(pasteInput);
|
|
210
|
-
this.outputStream.write(ENABLE_BRACKETED_PASTE);
|
|
211
|
-
// Ask the terminal to disambiguate modified Enter via kitty keyboard
|
|
212
|
-
// protocol (level 1). Terminals that ignore it keep sending plain CR for
|
|
213
|
-
// Shift+Enter, which is submitted like Enter.
|
|
214
|
-
this.outputStream.write(ENABLE_KITTY_KEYBOARD);
|
|
215
|
-
|
|
216
|
-
const readline = createInterface({
|
|
217
|
-
input: pasteInput,
|
|
218
|
-
output: this.outputStream,
|
|
219
|
-
terminal: true,
|
|
220
|
-
historySize: this.historySize,
|
|
221
|
-
removeHistoryDuplicates: true,
|
|
186
|
+
return await new Promise((resolve) => {
|
|
187
|
+
this.#edit(prompt, resolve);
|
|
222
188
|
});
|
|
223
|
-
readline.history = this.history.map(encodePastedLines);
|
|
224
|
-
|
|
225
|
-
const controller = new AbortController();
|
|
226
|
-
let closed = false;
|
|
227
|
-
const onClose = () => {
|
|
228
|
-
closed = true;
|
|
229
|
-
controller.abort();
|
|
230
|
-
};
|
|
231
|
-
const onInterrupt = () => readline.close();
|
|
232
|
-
readline.once("close", onClose);
|
|
233
|
-
readline.on("SIGINT", onInterrupt);
|
|
234
|
-
|
|
235
|
-
try {
|
|
236
|
-
const answer = await readline.question(prompt, {
|
|
237
|
-
signal: controller.signal,
|
|
238
|
-
});
|
|
239
|
-
this.history = readline.history
|
|
240
|
-
.slice(0, this.historySize)
|
|
241
|
-
.map(decodePastedLines);
|
|
242
|
-
return decodePastedLines(answer);
|
|
243
|
-
} catch (error) {
|
|
244
|
-
if (closed || error?.name === "AbortError") {
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
throw error;
|
|
248
|
-
} finally {
|
|
249
|
-
readline.removeListener("close", onClose);
|
|
250
|
-
readline.removeListener("SIGINT", onInterrupt);
|
|
251
|
-
readline.close();
|
|
252
|
-
this.outputStream.write(DISABLE_BRACKETED_PASTE);
|
|
253
|
-
this.outputStream.write(DISABLE_KITTY_KEYBOARD);
|
|
254
|
-
this.inputStream.unpipe(pasteInput);
|
|
255
|
-
pasteInput.destroy();
|
|
256
|
-
}
|
|
257
189
|
}
|
|
258
190
|
|
|
259
191
|
close() {
|
|
260
|
-
// Each
|
|
192
|
+
// Each read() owns its raw-mode session and cleans it up on completion.
|
|
261
193
|
// This method keeps ownership explicit for callers and future extensions.
|
|
262
194
|
}
|
|
195
|
+
|
|
196
|
+
#edit(prompt, resolve) {
|
|
197
|
+
const inputStream = this.inputStream;
|
|
198
|
+
const outputStream = this.outputStream;
|
|
199
|
+
const previousRaw = inputStream.isRaw;
|
|
200
|
+
const decoder = new StringDecoder("utf8");
|
|
201
|
+
const columns = () => Math.max(10, Number(inputStream.columns) || 80);
|
|
202
|
+
|
|
203
|
+
let buffer = []; // flat code-point buffer; "\n" chars are real line breaks
|
|
204
|
+
let cursor = 0;
|
|
205
|
+
let historyIndex = this.history.length;
|
|
206
|
+
let draft = "";
|
|
207
|
+
let pending = ""; // undecoded partial escape sequence / paste terminator
|
|
208
|
+
let inPaste = false;
|
|
209
|
+
let settled = false;
|
|
210
|
+
let linesDrawn = 1;
|
|
211
|
+
let cursorRow = 0;
|
|
212
|
+
|
|
213
|
+
const cleanup = () => {
|
|
214
|
+
inputStream.removeListener("data", onData);
|
|
215
|
+
inputStream.removeListener("end", onEnd);
|
|
216
|
+
inputStream.removeListener("close", onEnd);
|
|
217
|
+
inputStream.setRawMode?.(previousRaw);
|
|
218
|
+
outputStream.write(DISABLE_MODIFY_OTHER_KEYS);
|
|
219
|
+
outputStream.write(DISABLE_BRACKETED_PASTE);
|
|
220
|
+
outputStream.write(DISABLE_KITTY_KEYBOARD);
|
|
221
|
+
// A lingering 'data' listener (e.g. readline's keypress machinery left by
|
|
222
|
+
// the adapter's ESC-cancel attach) would keep the TTY read active and
|
|
223
|
+
// pin the event loop open; pausing lets the process exit between turns.
|
|
224
|
+
inputStream.pause();
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const done = (value) => {
|
|
228
|
+
if (settled) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
settled = true;
|
|
232
|
+
cleanup();
|
|
233
|
+
resolve(value);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Moves to the end of the drawn input and starts a fresh line.
|
|
237
|
+
const finishLine = () => {
|
|
238
|
+
if (cursorRow < linesDrawn - 1) {
|
|
239
|
+
outputStream.write(`\x1b[${linesDrawn - 1 - cursorRow}B`);
|
|
240
|
+
}
|
|
241
|
+
outputStream.write("\r\n");
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const submit = () => {
|
|
245
|
+
const text = buffer.join("");
|
|
246
|
+
if (text.trim()) {
|
|
247
|
+
this.remember(text);
|
|
248
|
+
}
|
|
249
|
+
finishLine();
|
|
250
|
+
done(text);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const cancel = () => {
|
|
254
|
+
finishLine();
|
|
255
|
+
done(null);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const setBufferText = (text) => {
|
|
259
|
+
buffer = [...String(text)];
|
|
260
|
+
cursor = buffer.length;
|
|
261
|
+
render();
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const insertText = (text) => {
|
|
265
|
+
const chars = [...String(text)];
|
|
266
|
+
buffer.splice(cursor, 0, ...chars);
|
|
267
|
+
cursor += chars.length;
|
|
268
|
+
historyIndex = this.history.length;
|
|
269
|
+
render();
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// historyIndex is the DISPLAYED entry's index, or history.length while the
|
|
273
|
+
// user edits the fresh draft. ↑ walks into the list newest-first; ↓ walks
|
|
274
|
+
// back out and restores the draft.
|
|
275
|
+
const historyPrev = () => {
|
|
276
|
+
if (this.history.length === 0) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (historyIndex === this.history.length) {
|
|
280
|
+
draft = buffer.join("");
|
|
281
|
+
historyIndex = 0;
|
|
282
|
+
} else if (historyIndex < this.history.length - 1) {
|
|
283
|
+
historyIndex += 1;
|
|
284
|
+
}
|
|
285
|
+
setBufferText(this.history[historyIndex]);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const historyNext = () => {
|
|
289
|
+
if (historyIndex === this.history.length) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (historyIndex === 0) {
|
|
293
|
+
historyIndex = this.history.length;
|
|
294
|
+
setBufferText(draft);
|
|
295
|
+
} else {
|
|
296
|
+
historyIndex -= 1;
|
|
297
|
+
setBufferText(this.history[historyIndex]);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const handlePlainText = (text) => {
|
|
302
|
+
for (const ch of String(text)) {
|
|
303
|
+
if (ch === "\r") {
|
|
304
|
+
submit();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (ch === "\n") {
|
|
308
|
+
insertText("\n");
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (ch === "") {
|
|
312
|
+
cancel();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (ch === "") {
|
|
316
|
+
if (buffer.length === 0) {
|
|
317
|
+
done(null);
|
|
318
|
+
} else if (cursor < buffer.length) {
|
|
319
|
+
buffer.splice(cursor, 1);
|
|
320
|
+
historyIndex = this.history.length;
|
|
321
|
+
render();
|
|
322
|
+
}
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (ch === "" || ch === "") {
|
|
326
|
+
if (cursor > 0) {
|
|
327
|
+
buffer.splice(cursor - 1, 1);
|
|
328
|
+
cursor -= 1;
|
|
329
|
+
historyIndex = this.history.length;
|
|
330
|
+
render();
|
|
331
|
+
}
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (ch === "") {
|
|
335
|
+
cursor = 0;
|
|
336
|
+
render();
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (ch === "") {
|
|
340
|
+
cursor = buffer.length;
|
|
341
|
+
render();
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (ch === "") {
|
|
345
|
+
buffer = buffer.slice(0, cursor);
|
|
346
|
+
historyIndex = this.history.length;
|
|
347
|
+
render();
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (ch === "") {
|
|
351
|
+
buffer = [];
|
|
352
|
+
cursor = 0;
|
|
353
|
+
historyIndex = this.history.length;
|
|
354
|
+
render();
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
insertText(ch);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const handleSequence = (sequence) => {
|
|
362
|
+
switch (sequence) {
|
|
363
|
+
case BRACKETED_PASTE_START:
|
|
364
|
+
inPaste = true;
|
|
365
|
+
return;
|
|
366
|
+
case BRACKETED_PASTE_END:
|
|
367
|
+
inPaste = false;
|
|
368
|
+
return;
|
|
369
|
+
case KEY_UP:
|
|
370
|
+
historyPrev();
|
|
371
|
+
return;
|
|
372
|
+
case KEY_DOWN:
|
|
373
|
+
historyNext();
|
|
374
|
+
return;
|
|
375
|
+
case KEY_RIGHT:
|
|
376
|
+
cursor = Math.min(cursor + 1, buffer.length);
|
|
377
|
+
render();
|
|
378
|
+
return;
|
|
379
|
+
case KEY_LEFT:
|
|
380
|
+
cursor = Math.max(cursor - 1, 0);
|
|
381
|
+
render();
|
|
382
|
+
return;
|
|
383
|
+
case KEY_HOME:
|
|
384
|
+
case KEY_HOME_TILDE:
|
|
385
|
+
case KEY_HOME_APP:
|
|
386
|
+
cursor = 0;
|
|
387
|
+
render();
|
|
388
|
+
return;
|
|
389
|
+
case KEY_END:
|
|
390
|
+
case KEY_END_TILDE:
|
|
391
|
+
case KEY_END_APP:
|
|
392
|
+
cursor = buffer.length;
|
|
393
|
+
render();
|
|
394
|
+
return;
|
|
395
|
+
case KEY_DELETE:
|
|
396
|
+
if (cursor < buffer.length) {
|
|
397
|
+
buffer.splice(cursor, 1);
|
|
398
|
+
historyIndex = this.history.length;
|
|
399
|
+
render();
|
|
400
|
+
}
|
|
401
|
+
return;
|
|
402
|
+
default:
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
if (SHIFT_ENTER_SEQUENCES.includes(sequence)) {
|
|
406
|
+
insertText("\n");
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const consume = (text) => {
|
|
411
|
+
pending += text;
|
|
412
|
+
for (;;) {
|
|
413
|
+
if (inPaste) {
|
|
414
|
+
// Inside a bracketed paste everything is data; only the end marker
|
|
415
|
+
// is special (and it can arrive split across chunks).
|
|
416
|
+
const endIndex = pending.indexOf(BRACKETED_PASTE_END);
|
|
417
|
+
if (endIndex >= 0) {
|
|
418
|
+
insertText(
|
|
419
|
+
pending.slice(0, endIndex).replace(/\r\n|\r/g, "\n"),
|
|
420
|
+
);
|
|
421
|
+
pending = pending.slice(endIndex + BRACKETED_PASTE_END.length);
|
|
422
|
+
inPaste = false;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const retained = longestPrefixAtEnd(pending, BRACKETED_PASTE_END);
|
|
426
|
+
const ready = pending.slice(0, pending.length - retained);
|
|
427
|
+
if (ready) {
|
|
428
|
+
insertText(ready.replace(/\r\n|\r/g, "\n"));
|
|
429
|
+
}
|
|
430
|
+
pending = pending.slice(pending.length - retained);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const escapeIndex = pending.indexOf("\u001b");
|
|
435
|
+
if (escapeIndex < 0) {
|
|
436
|
+
handlePlainText(pending);
|
|
437
|
+
pending = "";
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (escapeIndex > 0) {
|
|
441
|
+
handlePlainText(pending.slice(0, escapeIndex));
|
|
442
|
+
pending = pending.slice(escapeIndex);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// pending starts with ESC.
|
|
447
|
+
const sequence = KNOWN_ESCAPE_SEQUENCES.find((candidate) => (
|
|
448
|
+
pending.startsWith(candidate)
|
|
449
|
+
));
|
|
450
|
+
if (sequence) {
|
|
451
|
+
handleSequence(sequence);
|
|
452
|
+
pending = pending.slice(sequence.length);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (
|
|
456
|
+
pending.length === 1
|
|
457
|
+
|| KNOWN_ESCAPE_SEQUENCES.some((candidate) => (
|
|
458
|
+
candidate.startsWith(pending)
|
|
459
|
+
))
|
|
460
|
+
) {
|
|
461
|
+
// The rest of the sequence may arrive in the next chunk.
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
// Unknown escape: drop the ESC byte and re-scan what follows.
|
|
465
|
+
pending = pending.slice(1);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// Draws the prompt + buffer with REAL line breaks, tracks wrapping, and
|
|
470
|
+
// places the cursor at the flat cursor index.
|
|
471
|
+
const render = () => {
|
|
472
|
+
const width = columns();
|
|
473
|
+
const promptWidth = displayWidth(prompt);
|
|
474
|
+
const text = buffer.join("");
|
|
475
|
+
|
|
476
|
+
const rendered = [];
|
|
477
|
+
const ranges = []; // { start, end } flat char indices per rendered line
|
|
478
|
+
let current = "";
|
|
479
|
+
let currentWidth = 0;
|
|
480
|
+
let rangeStart = 0;
|
|
481
|
+
let charIndex = 0;
|
|
482
|
+
const flush = () => {
|
|
483
|
+
rendered.push(current);
|
|
484
|
+
ranges.push({ start: rangeStart, end: charIndex });
|
|
485
|
+
current = "";
|
|
486
|
+
currentWidth = 0;
|
|
487
|
+
rangeStart = charIndex;
|
|
488
|
+
};
|
|
489
|
+
for (const ch of text) {
|
|
490
|
+
if (ch === "\n") {
|
|
491
|
+
flush();
|
|
492
|
+
charIndex += 1;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
const w = charWidth(ch.codePointAt(0));
|
|
496
|
+
const base = rendered.length === 0 ? promptWidth : 0;
|
|
497
|
+
if (base + currentWidth + w > width && currentWidth > 0) {
|
|
498
|
+
flush();
|
|
499
|
+
}
|
|
500
|
+
current += ch;
|
|
501
|
+
currentWidth += w;
|
|
502
|
+
charIndex += 1;
|
|
503
|
+
}
|
|
504
|
+
flush();
|
|
505
|
+
if (rendered.length === 0) {
|
|
506
|
+
rendered.push("");
|
|
507
|
+
ranges.push({ start: 0, end: 0 });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (linesDrawn > 1) {
|
|
511
|
+
outputStream.write(`\x1b[${linesDrawn - 1}A`);
|
|
512
|
+
}
|
|
513
|
+
outputStream.write("\r");
|
|
514
|
+
for (let index = 0; index < rendered.length; index += 1) {
|
|
515
|
+
outputStream.write("\x1b[2K");
|
|
516
|
+
if (index === 0) {
|
|
517
|
+
outputStream.write(prompt);
|
|
518
|
+
}
|
|
519
|
+
outputStream.write(rendered[index]);
|
|
520
|
+
if (index < rendered.length - 1) {
|
|
521
|
+
outputStream.write("\r\n");
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (rendered.length < linesDrawn) {
|
|
525
|
+
for (let index = rendered.length; index < linesDrawn; index += 1) {
|
|
526
|
+
outputStream.write("\x1b[2K");
|
|
527
|
+
if (index < linesDrawn - 1) {
|
|
528
|
+
outputStream.write("\r\n");
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
linesDrawn = rendered.length;
|
|
533
|
+
|
|
534
|
+
// Position the cursor at the flat cursor index.
|
|
535
|
+
let row = rendered.length - 1;
|
|
536
|
+
let col = 0;
|
|
537
|
+
for (let r = 0; r < rendered.length; r += 1) {
|
|
538
|
+
const range = ranges[r];
|
|
539
|
+
if (cursor < range.end || r === rendered.length - 1) {
|
|
540
|
+
row = r;
|
|
541
|
+
const base = r === 0 ? promptWidth : 0;
|
|
542
|
+
const prefixLength = Math.max(0, cursor - range.start);
|
|
543
|
+
col = base + displayWidth(
|
|
544
|
+
[...rendered[r]].slice(0, prefixLength).join(""),
|
|
545
|
+
);
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
cursorRow = row;
|
|
550
|
+
// Drawing ends at the last rendered line; move UP to the cursor's row.
|
|
551
|
+
const moveUp = rendered.length - 1 - row;
|
|
552
|
+
if (moveUp > 0) {
|
|
553
|
+
outputStream.write(`\x1b[${moveUp}A`);
|
|
554
|
+
}
|
|
555
|
+
outputStream.write("\r");
|
|
556
|
+
if (col > 0) {
|
|
557
|
+
outputStream.write(`\x1b[${col}C`);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const onEnd = () => done(null);
|
|
562
|
+
const onData = (chunk) => {
|
|
563
|
+
try {
|
|
564
|
+
consume(decoder.write(chunk));
|
|
565
|
+
} catch (error) {
|
|
566
|
+
finishLine();
|
|
567
|
+
done(null);
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
inputStream.setRawMode?.(true);
|
|
573
|
+
outputStream.write(ENABLE_BRACKETED_PASTE);
|
|
574
|
+
// Ask the terminal to disambiguate modified Enter. Kitty CSI-u covers
|
|
575
|
+
// kitty/iTerm; xterm modifyOtherKeys covers many others, including some
|
|
576
|
+
// tmux + iTerm combinations. Terminals that ignore both still send a bare
|
|
577
|
+
// CR for Shift+Enter, which cannot be distinguished from Enter — Ctrl+J
|
|
578
|
+
// always works as the newline fallback.
|
|
579
|
+
outputStream.write(ENABLE_KITTY_KEYBOARD);
|
|
580
|
+
outputStream.write(ENABLE_MODIFY_OTHER_KEYS);
|
|
581
|
+
render();
|
|
582
|
+
inputStream.on("data", onData);
|
|
583
|
+
inputStream.on("end", onEnd);
|
|
584
|
+
inputStream.on("close", onEnd);
|
|
585
|
+
// A previous cleanup paused the stream (so a lingering 'data' listener
|
|
586
|
+
// cannot pin the event loop); after an explicit pause() attaching a
|
|
587
|
+
// listener alone does not resume it, so resume explicitly.
|
|
588
|
+
inputStream.resume?.();
|
|
589
|
+
}
|
|
263
590
|
}
|
|
264
591
|
|
|
265
592
|
async function promptWithCleanExit(config, {
|
|
@@ -314,3 +641,10 @@ export async function promptForSelect(config, options = {}) {
|
|
|
314
641
|
prompt: options.prompt ?? select,
|
|
315
642
|
});
|
|
316
643
|
}
|
|
644
|
+
|
|
645
|
+
export async function promptForConfirm(config, options = {}) {
|
|
646
|
+
return await promptWithCleanExit(config, {
|
|
647
|
+
...options,
|
|
648
|
+
prompt: options.prompt ?? confirm,
|
|
649
|
+
});
|
|
650
|
+
}
|