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.
@@ -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
- // readline treats CR/LF as submit. U+2028 is a non-submitting, semantic line
11
- // separator that can live in readline's editable buffer and history; decode it
12
- // back to LF before the value leaves this module.
13
- const PASTED_LINE_SEPARATOR = "\u2028";
14
- // Shift+Enter from terminals with kitty keyboard protocol support arrives as
15
- // the CSI-u sequence for "Enter with shift". Map it to the same non-submitting
16
- // line separator used for pasted newlines so it inserts a newline instead of
17
- // submitting. Terminals without the protocol keep sending plain CR for
18
- // Shift+Enter, which readline treats as submit — an unavoidable terminal limit.
19
- const SHIFT_ENTER_SEQUENCE = "\u001b[13;2u";
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
- function longestMarkerPrefixAtEnd(text, marker) {
36
- const limit = Math.min(text.length, marker.length - 1);
37
- for (let length = limit; length > 0; length -= 1) {
38
- if (text.endsWith(marker.slice(0, length))) {
39
- return length;
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
- return 0;
43
- }
44
-
45
- function encodePastedLines(text) {
46
- return text.replace(/\r\n|\r|\n/g, PASTED_LINE_SEPARATOR);
47
- }
48
-
49
- function decodePastedLines(text) {
50
- return String(text ?? "").replaceAll(PASTED_LINE_SEPARATOR, "\n");
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
- // A newline followed by more text, or two or more newlines in one raw data
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
- class PasteAwareInput extends Transform {
67
- constructor(source) {
68
- super();
69
- this.decoder = new StringDecoder("utf8");
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
- #consume(text, flush) {
98
- this.pending += text;
99
- let output = "";
100
-
101
- for (;;) {
102
- const marker = this.inBracketedPaste
103
- ? BRACKETED_PASTE_END
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
- const pasteInput = new PasteAwareInput(this.inputStream);
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 readline instance is scoped to one question and is closed in read().
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
+ }