@hyperfrontend/questions 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs.js CHANGED
@@ -2,14 +2,289 @@
2
2
 
3
3
  const index_cjs_js = require('./_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/object/index.cjs.js');
4
4
  const node_readline = require('node:readline');
5
- const index_cjs_js$1 = require('./_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/promise/index.cjs.js');
6
- const index_cjs_js$2 = require('./_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/math/index.cjs.js');
5
+ const node_string_decoder = require('node:string_decoder');
6
+ const index_cjs_js$2 = require('./_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/promise/index.cjs.js');
7
+ const index_cjs_js$1 = require('./_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/math/index.cjs.js');
8
+ const index_cjs_js$3 = require('./_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/error/index.cjs.js');
9
+ const index_cjs_js$4 = require('./_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/number/index.cjs.js');
7
10
 
8
11
  /**
9
- * Terminal I/O utilities using Node.js readline.
12
+ * ANSI-aware text measurement helpers.
13
+ *
14
+ * Display width is measured in Unicode code points with ANSI escape
15
+ * sequences excluded. East-asian wide characters and grapheme clusters are
16
+ * counted as one column each; full terminal-accurate width is out of scope.
10
17
  *
11
18
  * @internal
12
19
  */
20
+ /** Escape character introducing ANSI control sequences. */
21
+ const Esc = '\x1B';
22
+ /**
23
+ * Matches the ANSI escape sequence starting at `index`. The character at
24
+ * `index` must be the escape character (`\x1B`); callers check this before
25
+ * calling. Recognizes CSI (`ESC [ ... final`) and SS3 (`ESC O x`) sequences;
26
+ * any other escape is reported as a lone one-character escape.
27
+ *
28
+ * @param text - Text containing the sequence
29
+ * @param index - Position of the escape character
30
+ * @returns Sequence length and whether it is complete
31
+ *
32
+ * @example Matching an arrow-key sequence
33
+ * ```typescript
34
+ * matchAnsiSequence('\x1B[A', 0)
35
+ * // => { length: 3, complete: true }
36
+ * ```
37
+ */
38
+ function matchAnsiSequence(text, index) {
39
+ if (index + 1 >= text.length) {
40
+ return index_cjs_js.freeze({ length: 1, complete: false });
41
+ }
42
+ const introducer = text.charAt(index + 1);
43
+ if (introducer === '[') {
44
+ return matchCsi(text, index);
45
+ }
46
+ if (introducer === 'O') {
47
+ if (index + 2 >= text.length) {
48
+ return index_cjs_js.freeze({ length: 2, complete: false });
49
+ }
50
+ return index_cjs_js.freeze({ length: 3, complete: true });
51
+ }
52
+ return index_cjs_js.freeze({ length: 1, complete: true });
53
+ }
54
+ /**
55
+ * Matches a CSI sequence (`ESC [` + parameter/intermediate bytes + final
56
+ * byte in `0x40`-`0x7E`). A byte outside the CSI ranges terminates the match
57
+ * without being consumed so malformed sequences cannot swallow input.
58
+ *
59
+ * @param text - Text containing the sequence
60
+ * @param index - Position of the escape character
61
+ * @returns Sequence length and whether it is complete
62
+ */
63
+ function matchCsi(text, index) {
64
+ let i = index + 2;
65
+ while (i < text.length) {
66
+ const code = text.charCodeAt(i);
67
+ if (code >= 0x40 && code <= 0x7e) {
68
+ return index_cjs_js.freeze({ length: i - index + 1, complete: true });
69
+ }
70
+ if (code < 0x20 || code > 0x3f) {
71
+ return index_cjs_js.freeze({ length: i - index, complete: true });
72
+ }
73
+ i++;
74
+ }
75
+ return index_cjs_js.freeze({ length: text.length - index, complete: false });
76
+ }
77
+ /**
78
+ * Removes all ANSI escape sequences from a string.
79
+ *
80
+ * @param text - Text possibly containing escape sequences
81
+ * @returns Text with every escape sequence removed
82
+ *
83
+ * @example Stripping color codes
84
+ * ```typescript
85
+ * stripAnsi('\x1B[36mhello\x1B[0m')
86
+ * // => 'hello'
87
+ * ```
88
+ */
89
+ function stripAnsi(text) {
90
+ let out = '';
91
+ let i = 0;
92
+ while (i < text.length) {
93
+ if (text.charAt(i) === Esc) {
94
+ i += matchAnsiSequence(text, i).length;
95
+ continue;
96
+ }
97
+ out += text.charAt(i);
98
+ i++;
99
+ }
100
+ return out;
101
+ }
102
+ /**
103
+ * Measures the display width of a string in terminal columns: Unicode code
104
+ * points with ANSI escape sequences excluded. Wide east-asian characters
105
+ * count as one column (documented limitation).
106
+ *
107
+ * @param text - Text to measure
108
+ * @returns Number of display columns
109
+ *
110
+ * @example Measuring styled text
111
+ * ```typescript
112
+ * displayWidth('\x1B[1mhi\x1B[0m')
113
+ * // => 2
114
+ * ```
115
+ */
116
+ function displayWidth(text) {
117
+ return [...stripAnsi(text)].length;
118
+ }
119
+
120
+ /**
121
+ * Incremental parser turning raw terminal input chunks into input tokens.
122
+ *
123
+ * Handles bracketed paste bodies (accumulated across chunks between
124
+ * `ESC[200~` and `ESC[201~`), escape-sequence keys, and printable runs.
125
+ * A multi-character printable run outside bracketed paste is treated as a
126
+ * paste from a terminal without bracketed-paste support, and the run's
127
+ * trailing line endings become Enter keys.
128
+ *
129
+ * @internal
130
+ */
131
+ /** Control character sent by Ctrl+C in raw mode. */
132
+ const CtrlC = '\x03';
133
+ /** Carriage return, the character every prompt reads as Enter. */
134
+ const Enter = '\r';
135
+ /** Line feed, which a pipe sends where a terminal sends a carriage return. */
136
+ const LineFeed = '\n';
137
+ /** Sequence a bracketed-paste-aware terminal sends before pasted content. */
138
+ const PasteStart = '\x1B[200~';
139
+ /** Sequence a bracketed-paste-aware terminal sends after pasted content. */
140
+ const PasteEnd = '\x1B[201~';
141
+ /**
142
+ * Kinds of tokens produced while reading terminal input.
143
+ */
144
+ const TokenType = index_cjs_js.freeze({
145
+ /** A single keypress (character or escape sequence) */
146
+ Key: 'key',
147
+ /** A block of pasted text */
148
+ Paste: 'paste',
149
+ /** The output terminal was resized */
150
+ Resize: 'resize',
151
+ });
152
+ /**
153
+ * Builds a key token.
154
+ *
155
+ * @param value - Key character or escape sequence
156
+ * @returns Frozen key token
157
+ */
158
+ function keyToken(value) {
159
+ return index_cjs_js.freeze({ type: TokenType.Key, value });
160
+ }
161
+ /**
162
+ * Builds a paste token.
163
+ *
164
+ * @param value - Raw pasted text
165
+ * @returns Frozen paste token
166
+ */
167
+ function pasteToken(value) {
168
+ return index_cjs_js.freeze({ type: TokenType.Paste, value });
169
+ }
170
+ /**
171
+ * Finds where a trailing partial occurrence of `marker` starts in `data`,
172
+ * scanning no earlier than `from`. Used to hold back a possible split
173
+ * bracketed-paste end marker until the next chunk arrives.
174
+ *
175
+ * @param data - Buffered input text
176
+ * @param from - First position that may be held back
177
+ * @param marker - Marker whose prefix may be split across chunks
178
+ * @returns Index where the partial marker starts, or `data.length` when the
179
+ * text does not end with a marker prefix
180
+ */
181
+ function partialMarkerStart(data, from, marker) {
182
+ const maxLength = index_cjs_js$1.min(marker.length - 1, data.length - from);
183
+ for (let k = maxLength; k >= 1; k--) {
184
+ if (data.endsWith(marker.slice(0, k))) {
185
+ return data.length - k;
186
+ }
187
+ }
188
+ return data.length;
189
+ }
190
+ /**
191
+ * Creates an incremental input tokenizer.
192
+ *
193
+ * Escape sequences split across chunks are buffered until complete; a lone
194
+ * trailing escape is likewise buffered (prompts do not act on a bare Escape
195
+ * key, so delaying it until the next chunk is unobservable). Ctrl+C outside
196
+ * a bracketed paste is always its own key token; inside a paste body it is
197
+ * plain data. Outside a bracketed paste, the trailing run of carriage
198
+ * returns and line feeds is split off a printable run and emitted as one
199
+ * Enter key per line ending (`\r\n` counts once), so a single chunk
200
+ * carrying typed text and Enter submits; line endings inside the run stay
201
+ * data.
202
+ *
203
+ * @returns Stateful token parser
204
+ *
205
+ * @example Parsing a paste split across two chunks
206
+ * ```typescript
207
+ * const parser = createTokenParser()
208
+ * parser.feed('\x1B[200~hello ')
209
+ * // => []
210
+ * parser.feed('world\x1B[201~')
211
+ * // => [{ type: 'paste', value: 'hello world' }]
212
+ * ```
213
+ */
214
+ function createTokenParser() {
215
+ let carry = '';
216
+ let pasteBuffer;
217
+ const feed = (chunk) => {
218
+ const data = carry + chunk;
219
+ carry = '';
220
+ const tokens = [];
221
+ let pos = 0;
222
+ while (pos < data.length) {
223
+ if (pasteBuffer !== undefined) {
224
+ const endIndex = data.indexOf(PasteEnd, pos);
225
+ if (endIndex >= 0) {
226
+ tokens.push(pasteToken(pasteBuffer + data.slice(pos, endIndex)));
227
+ pasteBuffer = undefined;
228
+ pos = endIndex + PasteEnd.length;
229
+ continue;
230
+ }
231
+ const holdFrom = partialMarkerStart(data, pos, PasteEnd);
232
+ pasteBuffer += data.slice(pos, holdFrom);
233
+ carry = data.slice(holdFrom);
234
+ break;
235
+ }
236
+ const char = data.charAt(pos);
237
+ if (char === Esc) {
238
+ const match = matchAnsiSequence(data, pos);
239
+ if (!match.complete) {
240
+ carry = data.slice(pos);
241
+ break;
242
+ }
243
+ const sequence = data.slice(pos, pos + match.length);
244
+ pos += match.length;
245
+ if (sequence === PasteStart) {
246
+ pasteBuffer = '';
247
+ continue;
248
+ }
249
+ // why: a stray end marker without a matching start carries no content
250
+ if (sequence === PasteEnd)
251
+ continue;
252
+ tokens.push(keyToken(sequence));
253
+ continue;
254
+ }
255
+ if (char === CtrlC) {
256
+ tokens.push(keyToken(char));
257
+ pos++;
258
+ continue;
259
+ }
260
+ let end = pos + 1;
261
+ while (end < data.length && data.charAt(end) !== Esc && data.charAt(end) !== CtrlC) {
262
+ end++;
263
+ }
264
+ const run = data.slice(pos, end);
265
+ pos = end;
266
+ let bodyEnd = run.length;
267
+ // why: a chunk that ends in newlines carries a submit, so the newline run is peeled off the body before the body is classified
268
+ while (bodyEnd > 0 && (run.charAt(bodyEnd - 1) === Enter || run.charAt(bodyEnd - 1) === LineFeed)) {
269
+ bodyEnd--;
270
+ }
271
+ const body = run.slice(0, bodyEnd);
272
+ if (body.length > 0) {
273
+ // why: raw mode delivers one keystroke per chunk, so a longer run means the terminal pasted without bracketed-paste support
274
+ tokens.push(body.length === 1 ? keyToken(body) : pasteToken(body));
275
+ }
276
+ for (let index = bodyEnd; index < run.length; index++) {
277
+ // why: CRLF is one Enter and a lone line feed is the Enter a pipe sends, matching how readline normalises line endings
278
+ if (run.charAt(index) === LineFeed && run.charAt(index - 1) === Enter)
279
+ continue;
280
+ tokens.push(keyToken(Enter));
281
+ }
282
+ }
283
+ return index_cjs_js.freeze(tokens);
284
+ };
285
+ return index_cjs_js.freeze({ feed });
286
+ }
287
+
13
288
  /**
14
289
  * Key codes for terminal navigation.
15
290
  */
@@ -55,6 +330,13 @@ const Ansi = index_cjs_js.freeze({
55
330
  * @returns ANSI escape sequence string
56
331
  */
57
332
  cursorLeft: (n) => `\x1B[${n}D`,
333
+ /**
334
+ * Generates ANSI escape code to move cursor right by specified columns.
335
+ *
336
+ * @param n - Number of columns to move right
337
+ * @returns ANSI escape sequence string
338
+ */
339
+ cursorRight: (n) => `\x1B[${n}C`,
58
340
  /** Escape code to hide cursor */
59
341
  HideCursor: '\x1B[?25l',
60
342
  /** Escape code to show cursor */
@@ -65,6 +347,10 @@ const Ansi = index_cjs_js.freeze({
65
347
  RestoreCursor: '\x1B8',
66
348
  /** Clear from cursor to end of screen */
67
349
  ClearToEnd: '\x1B[J',
350
+ /** Ask the terminal to wrap pasted text in ESC[200~ / ESC[201~ markers */
351
+ BracketedPasteOn: '\x1B[?2004h',
352
+ /** Stop wrapping pasted text in bracketed-paste markers */
353
+ BracketedPasteOff: '\x1B[?2004l',
68
354
  /** Escape code for bold text */
69
355
  Bold: '\x1B[1m',
70
356
  /** Escape code for dim text */
@@ -85,6 +371,14 @@ const Ansi = index_cjs_js.freeze({
85
371
  /**
86
372
  * Creates a terminal interface for interactive prompts.
87
373
  *
374
+ * The first `readToken`/`readKey` call opens a read session: the input is
375
+ * switched to raw mode for the whole session (restored on `close`),
376
+ * bracketed paste mode is enabled on TTY inputs, and resize events from the
377
+ * output surface as resize tokens. Input chunks are tokenized by a
378
+ * persistent listener so no chunk is lost between reads. When the input
379
+ * ends, the session cancels and delivers a Ctrl+C key so a waiting read
380
+ * resolves.
381
+ *
88
382
  * @param config - Terminal configuration options
89
383
  * @returns Terminal interface with read/write methods
90
384
  *
@@ -92,7 +386,7 @@ const Ansi = index_cjs_js.freeze({
92
386
  * ```typescript
93
387
  * const term = createTerminal()
94
388
  * term.write('Enter name: ')
95
- * const name = await term.readLine()
389
+ * const token = await term.readToken()
96
390
  * term.close()
97
391
  * ```
98
392
  */
@@ -100,7 +394,15 @@ function createTerminal(config = {}) {
100
394
  const input = config.input ?? process.stdin;
101
395
  const output = config.output ?? process.stdout;
102
396
  let cancelled = false;
397
+ let closed = false;
398
+ let sessionActive = false;
399
+ let savedRawMode = false;
103
400
  let rl;
401
+ let pendingWaiter;
402
+ const parser = createTokenParser();
403
+ // why: decoding through StringDecoder keeps multibyte characters intact when a large paste is split across stream chunks mid-code-point
404
+ const decoder = new node_string_decoder.StringDecoder('utf8');
405
+ const tokenQueue = [];
104
406
  const getReadline = () => {
105
407
  if (!rl) {
106
408
  rl = node_readline.createInterface({ input, output, terminal: true });
@@ -110,25 +412,84 @@ function createTerminal(config = {}) {
110
412
  const write = (text) => {
111
413
  output.write(text);
112
414
  };
113
- const readKey = () => index_cjs_js$1.createPromise((resolve) => {
114
- const wasRaw = input.isRaw;
415
+ const deliver = (tokens) => {
416
+ for (const token of tokens) {
417
+ if (token.type === TokenType.Key && token.value === Key.CtrlC) {
418
+ cancelled = true;
419
+ }
420
+ // why: a resize drag fires many events; consecutive notifications collapse into one so the prompt repaints once per drained batch
421
+ if (token.type === TokenType.Resize && tokenQueue[tokenQueue.length - 1]?.type === TokenType.Resize) {
422
+ continue;
423
+ }
424
+ tokenQueue.push(token);
425
+ }
426
+ if (pendingWaiter !== undefined) {
427
+ const token = tokenQueue.shift();
428
+ if (token !== undefined) {
429
+ const waiter = pendingWaiter;
430
+ pendingWaiter = undefined;
431
+ waiter(token);
432
+ }
433
+ }
434
+ };
435
+ const onData = (data) => {
436
+ const text = decoder.write(data);
437
+ if (text !== '') {
438
+ deliver(parser.feed(text));
439
+ }
440
+ };
441
+ const onResize = () => {
442
+ deliver(index_cjs_js.freeze([index_cjs_js.freeze({ type: TokenType.Resize })]));
443
+ };
444
+ const onEnd = () => {
445
+ // why: an ended input can never deliver another key, so a waiting prompt resolves through the cancellation branch instead of hanging
446
+ deliver(index_cjs_js.freeze([index_cjs_js.freeze({ type: TokenType.Key, value: Key.CtrlC })]));
447
+ };
448
+ const openSession = () => {
449
+ if (sessionActive)
450
+ return;
451
+ sessionActive = true;
452
+ savedRawMode = input.isRaw === true;
115
453
  if (input.setRawMode) {
116
454
  input.setRawMode(true);
455
+ write(Ansi.BracketedPasteOn);
117
456
  }
118
- const onData = (data) => {
119
- input.removeListener('data', onData);
120
- if (input.setRawMode) {
121
- input.setRawMode(wasRaw);
122
- }
123
- const key = data.toString();
124
- if (key === Key.CtrlC) {
125
- cancelled = true;
126
- }
127
- resolve(key);
128
- };
129
- input.once('data', onData);
130
- });
131
- const readLine = () => index_cjs_js$1.createPromise((resolve) => {
457
+ input.on('data', onData);
458
+ input.once('end', onEnd);
459
+ output.on('resize', onResize);
460
+ input.resume();
461
+ };
462
+ const closeSession = () => {
463
+ if (!sessionActive)
464
+ return;
465
+ sessionActive = false;
466
+ input.removeListener('data', onData);
467
+ input.removeListener('end', onEnd);
468
+ output.removeListener('resize', onResize);
469
+ if (input.setRawMode) {
470
+ write(Ansi.BracketedPasteOff);
471
+ input.setRawMode(savedRawMode);
472
+ }
473
+ input.pause();
474
+ };
475
+ const readToken = () => {
476
+ openSession();
477
+ const queued = tokenQueue.shift();
478
+ if (queued !== undefined) {
479
+ return index_cjs_js$2.promiseResolve(queued);
480
+ }
481
+ return index_cjs_js$2.createPromise((resolve) => {
482
+ pendingWaiter = resolve;
483
+ });
484
+ };
485
+ const readKey = async () => {
486
+ let token = await readToken();
487
+ while (token.type === TokenType.Resize) {
488
+ token = await readToken();
489
+ }
490
+ return token.value;
491
+ };
492
+ const readLine = () => index_cjs_js$2.createPromise((resolve) => {
132
493
  const readline = getReadline();
133
494
  readline.once('line', (line) => {
134
495
  resolve(line);
@@ -148,7 +509,15 @@ function createTerminal(config = {}) {
148
509
  }
149
510
  }
150
511
  };
512
+ const getSize = () => index_cjs_js.freeze({
513
+ columns: output.columns > 0 ? output.columns : 80,
514
+ rows: output.rows > 0 ? output.rows : 24,
515
+ });
151
516
  const close = () => {
517
+ if (closed)
518
+ return;
519
+ closed = true;
520
+ closeSession();
152
521
  if (rl) {
153
522
  rl.close();
154
523
  rl = undefined;
@@ -158,8 +527,10 @@ function createTerminal(config = {}) {
158
527
  return index_cjs_js.freeze({
159
528
  write,
160
529
  readKey,
530
+ readToken,
161
531
  readLine,
162
532
  clearLines,
533
+ getSize,
163
534
  close,
164
535
  isCancelled: () => cancelled,
165
536
  cancel: () => {
@@ -286,6 +657,139 @@ function renderCancelled() {
286
657
  return style.dim('(cancelled)');
287
658
  }
288
659
 
660
+ /**
661
+ * Hard-wraps one logical line into physical rows no wider than `width`
662
+ * display columns. ANSI escape sequences are zero-width and never split;
663
+ * surrogate pairs travel together.
664
+ *
665
+ * @param line - Logical line, possibly containing ANSI sequences
666
+ * @param width - Maximum display columns per row (at least 1)
667
+ * @returns Physical rows covering the line (always at least one row)
668
+ *
669
+ * @example Wrapping a long line
670
+ * ```typescript
671
+ * wrapLine('abcdefghij', 4)
672
+ * // => ['abcd', 'efgh', 'ij']
673
+ * ```
674
+ */
675
+ function wrapLine(line, width) {
676
+ const rows = [];
677
+ let current = '';
678
+ let col = 0;
679
+ let i = 0;
680
+ while (i < line.length) {
681
+ if (line.charAt(i) === Esc) {
682
+ const { length } = matchAnsiSequence(line, i);
683
+ current += line.slice(i, i + length);
684
+ i += length;
685
+ continue;
686
+ }
687
+ if (col === width) {
688
+ rows.push(current);
689
+ current = '';
690
+ col = 0;
691
+ }
692
+ const charCode = line.charCodeAt(i);
693
+ // why: keep surrogate pairs on the same row so code points never split
694
+ const charLength = charCode >= 0xd800 && charCode <= 0xdbff ? 2 : 1;
695
+ current += line.slice(i, i + charLength);
696
+ col++;
697
+ i += charLength;
698
+ }
699
+ rows.push(current);
700
+ return index_cjs_js.freeze(rows);
701
+ }
702
+ /**
703
+ * Creates a frame renderer bound to a terminal.
704
+ *
705
+ * @param terminal - Terminal used for size queries and output
706
+ * @returns Screen renderer
707
+ *
708
+ * @example Repainting a prompt line with a parked cursor
709
+ * ```typescript
710
+ * const screen = createScreen(terminal)
711
+ * screen.render({ lines: ['? Name: Jo'], cursor: { line: 0, col: 10 } })
712
+ * screen.render({ lines: ['? Name: Joe'], cursor: { line: 0, col: 11 } })
713
+ * ```
714
+ */
715
+ function createScreen(terminal) {
716
+ let lastRows = index_cjs_js.freeze([]);
717
+ let lastWidth = 0;
718
+ let lastCursorRow = 0;
719
+ let lastCursorCol = 0;
720
+ const reflowedCursorRow = (width) => {
721
+ let row = 0;
722
+ for (const paintedRow of lastRows.slice(0, lastCursorRow)) {
723
+ row += index_cjs_js$1.max(1, index_cjs_js$1.ceil(displayWidth(paintedRow) / width));
724
+ }
725
+ return row + index_cjs_js$1.floor(lastCursorCol / width);
726
+ };
727
+ const eraseLastFrame = (width, viewportRows) => {
728
+ if (lastRows.length === 0)
729
+ return;
730
+ // why: on a width change the terminal reflowed the old paint, so the cursor's row offset is recomputed against the new width
731
+ const cursorRow = width === lastWidth ? lastCursorRow : reflowedCursorRow(width);
732
+ // why: travel is capped at the viewport height so a reflow estimate can never climb past the frame into content above it
733
+ const travelRows = index_cjs_js$1.min(cursorRow, viewportRows - 1);
734
+ let travel = Ansi.CursorStart;
735
+ if (travelRows > 0)
736
+ travel += Ansi.cursorUp(travelRows);
737
+ terminal.write(travel + Ansi.ClearToEnd);
738
+ };
739
+ const resolveCursor = (frame, rowsPerLine, lastRowText, totalRows, width) => {
740
+ if (frame.cursor === undefined) {
741
+ return index_cjs_js.freeze({ row: totalRows - 1, col: displayWidth(lastRowText) });
742
+ }
743
+ let row = 0;
744
+ for (const count of rowsPerLine.slice(0, frame.cursor.line)) {
745
+ row += count;
746
+ }
747
+ let extraRows = index_cjs_js$1.floor(frame.cursor.col / width);
748
+ let col = frame.cursor.col - extraRows * width;
749
+ if (col === 0 && frame.cursor.col > 0) {
750
+ // why: a cursor at an exact wrap boundary parks at the end of the previous physical row rather than on an unpainted row
751
+ extraRows -= 1;
752
+ col = width;
753
+ }
754
+ return index_cjs_js.freeze({ row: row + extraRows, col });
755
+ };
756
+ const render = (frame) => {
757
+ const size = terminal.getSize();
758
+ const width = index_cjs_js$1.max(1, size.columns);
759
+ const viewportRows = index_cjs_js$1.max(1, size.rows);
760
+ eraseLastFrame(width, viewportRows);
761
+ const wrappedRows = [];
762
+ const rowsPerLine = [];
763
+ let lastRowText = '';
764
+ for (const line of frame.lines) {
765
+ const wrapped = wrapLine(line, width);
766
+ rowsPerLine.push(wrapped.length);
767
+ for (const row of wrapped) {
768
+ wrappedRows.push(row);
769
+ lastRowText = row;
770
+ }
771
+ }
772
+ // why: a frame taller than the viewport would scroll its own top off-screen and desync the erase math, so only the tail that fits is painted
773
+ const dropped = index_cjs_js$1.max(0, wrappedRows.length - viewportRows);
774
+ const physicalRows = dropped > 0 ? wrappedRows.slice(dropped) : wrappedRows;
775
+ terminal.write(physicalRows.join('\n'));
776
+ const rawCursor = resolveCursor(frame, rowsPerLine, lastRowText, wrappedRows.length, width);
777
+ const cursor = rawCursor.row >= dropped ? index_cjs_js.freeze({ row: rawCursor.row - dropped, col: rawCursor.col }) : index_cjs_js.freeze({ row: 0, col: 0 });
778
+ let travel = Ansi.CursorStart;
779
+ const rowsUp = physicalRows.length - 1 - cursor.row;
780
+ if (rowsUp > 0)
781
+ travel += Ansi.cursorUp(rowsUp);
782
+ if (cursor.col > 0)
783
+ travel += Ansi.cursorRight(cursor.col);
784
+ terminal.write(travel);
785
+ lastRows = index_cjs_js.freeze(physicalRows);
786
+ lastWidth = width;
787
+ lastCursorRow = cursor.row;
788
+ lastCursorCol = cursor.col;
789
+ };
790
+ return index_cjs_js.freeze({ render });
791
+ }
792
+
289
793
  /**
290
794
  * Core types for terminal prompts.
291
795
  *
@@ -317,11 +821,36 @@ function renderOptions(initial) {
317
821
  }
318
822
  return style.dim('(y/n)');
319
823
  }
824
+ /**
825
+ * Interprets a key or paste token as a yes/no answer. Keys accept `y`/`n`
826
+ * (any case); pastes accept a trimmed, lowercased `y`/`yes`/`n`/`no`.
827
+ *
828
+ * @internal
829
+ * @param token - Token to interpret
830
+ * @returns The boolean answer, or undefined when the token is not one
831
+ */
832
+ function parseAnswer(token) {
833
+ const normalized = token.value.trim().toLowerCase();
834
+ if (token.type === TokenType.Paste) {
835
+ if (normalized === 'y' || normalized === 'yes')
836
+ return true;
837
+ if (normalized === 'n' || normalized === 'no')
838
+ return false;
839
+ return undefined;
840
+ }
841
+ if (normalized === 'y')
842
+ return true;
843
+ if (normalized === 'n')
844
+ return false;
845
+ return undefined;
846
+ }
320
847
  /**
321
848
  * Prompts for yes/no confirmation.
322
849
  *
323
850
  * Pure functional prompt that asks a yes/no question and returns a boolean.
324
- * Supports default values and responds to y/Y/n/N keys.
851
+ * Supports default values and responds to y/Y/n/N keys. A pasted
852
+ * `y`/`yes`/`n`/`no` (trimmed, case-insensitive) is accepted; any other
853
+ * paste is ignored. The prompt repaints on terminal resize.
325
854
  *
326
855
  * @param config - Confirm prompt configuration
327
856
  * @returns Promise resolving to boolean value or cancellation
@@ -344,38 +873,143 @@ function renderOptions(initial) {
344
873
  */
345
874
  async function confirm(config) {
346
875
  const term = createTerminal({ input: config.input, output: config.output });
347
- const drawPrompt = () => {
348
- term.write(Ansi.CursorStart + Ansi.ClearLine);
349
- term.write(renderMessage(config.message) + renderOptions(config.initial) + ' ');
876
+ const screen = createScreen(term);
877
+ const finish = (value) => {
878
+ screen.render(index_cjs_js.freeze({ lines: index_cjs_js.freeze([renderMessage(config.message) + renderSubmitted(value ? 'Yes' : 'No')]) }));
879
+ term.write('\n');
880
+ return index_cjs_js.freeze({ result: PromptResult.Submitted, value });
350
881
  };
351
- const drawResult = (value) => {
352
- term.write(Ansi.CursorStart + Ansi.ClearLine);
353
- term.write(renderMessage(config.message) + renderSubmitted(value ? 'Yes' : 'No') + '\n');
882
+ const redraw = () => {
883
+ screen.render(index_cjs_js.freeze({ lines: index_cjs_js.freeze([renderMessage(config.message) + renderOptions(config.initial) + ' ']) }));
354
884
  };
355
- drawPrompt();
356
- while (true) {
357
- const key = await term.readKey();
358
- const lowerKey = key.toLowerCase();
359
- if (term.isCancelled()) {
360
- term.write(Ansi.CursorStart + Ansi.ClearLine);
361
- term.write(renderMessage(config.message) + renderCancelled() + '\n');
362
- term.close();
363
- return index_cjs_js.freeze({ result: PromptResult.Cancelled, value: undefined });
885
+ try {
886
+ redraw();
887
+ while (true) {
888
+ const token = await term.readToken();
889
+ if (term.isCancelled()) {
890
+ screen.render(index_cjs_js.freeze({ lines: index_cjs_js.freeze([renderMessage(config.message) + renderCancelled()]) }));
891
+ term.write('\n');
892
+ return index_cjs_js.freeze({ result: PromptResult.Cancelled, value: undefined });
893
+ }
894
+ if (token.type === TokenType.Resize) {
895
+ redraw();
896
+ continue;
897
+ }
898
+ const answer = parseAnswer(token);
899
+ if (answer !== undefined) {
900
+ return finish(answer);
901
+ }
902
+ if (token.value === Key.Enter && config.initial !== undefined) {
903
+ return finish(config.initial);
904
+ }
364
905
  }
365
- if (lowerKey === 'y') {
366
- drawResult(true);
367
- term.close();
368
- return index_cjs_js.freeze({ result: PromptResult.Submitted, value: true });
906
+ }
907
+ finally {
908
+ term.close();
909
+ }
910
+ }
911
+
912
+ /**
913
+ * Sanitizers for pasted text before it enters prompt state.
914
+ *
915
+ * @internal
916
+ */
917
+ /**
918
+ * Sanitizes pasted text for insertion into a single-line input: interior
919
+ * runs of `\r\n`, `\r`, or `\n` collapse into a single space, leading and
920
+ * trailing newline runs are dropped, and all other C0 control characters
921
+ * (plus DEL) are removed. Pasting therefore never submits or triggers
922
+ * prompt actions.
923
+ *
924
+ * @param raw - Raw pasted text
925
+ * @returns Sanitized single-line text (possibly empty)
926
+ *
927
+ * @example Collapsing a multi-line paste
928
+ * ```typescript
929
+ * sanitizePasteText('first\r\nsecond\n')
930
+ * // => 'first second'
931
+ * ```
932
+ */
933
+ function sanitizePasteText(raw) {
934
+ let out = '';
935
+ let pendingBreak = false;
936
+ for (const char of raw) {
937
+ if (char === '\r' || char === '\n') {
938
+ pendingBreak = true;
939
+ continue;
369
940
  }
370
- if (lowerKey === 'n') {
371
- drawResult(false);
372
- term.close();
373
- return index_cjs_js.freeze({ result: PromptResult.Submitted, value: false });
941
+ const code = char.charCodeAt(0);
942
+ if (code < 0x20 || code === 0x7f)
943
+ continue;
944
+ if (pendingBreak && out.length > 0) {
945
+ out += ' ';
374
946
  }
375
- if (key === Key.Enter && config.initial !== undefined) {
376
- drawResult(config.initial);
377
- term.close();
378
- return index_cjs_js.freeze({ result: PromptResult.Submitted, value: config.initial });
947
+ pendingBreak = false;
948
+ out += char;
949
+ }
950
+ return out;
951
+ }
952
+ /**
953
+ * Extracts the first line of a paste for search-query style inputs: content
954
+ * is cut at the first `\r` or `\n`, then remaining C0 control characters
955
+ * (plus DEL) are removed.
956
+ *
957
+ * @param raw - Raw pasted text
958
+ * @returns Printable characters of the first pasted line
959
+ *
960
+ * @example Keeping only the first pasted line
961
+ * ```typescript
962
+ * firstPasteLine('query\nsecond line')
963
+ * // => 'query'
964
+ * ```
965
+ */
966
+ function firstPasteLine(raw) {
967
+ let out = '';
968
+ for (const char of raw) {
969
+ if (char === '\r' || char === '\n')
970
+ break;
971
+ const code = char.charCodeAt(0);
972
+ if (code < 0x20 || code === 0x7f)
973
+ continue;
974
+ out += char;
975
+ }
976
+ return out;
977
+ }
978
+
979
+ /**
980
+ * Configuration guards shared by the choice-list prompts.
981
+ *
982
+ * @internal
983
+ */
984
+ /**
985
+ * Rejects a choice-list configuration no keypress could ever resolve: an
986
+ * empty choice list, or a starting index that names no choice. Without the
987
+ * check the prompt paints a frame and waits forever.
988
+ *
989
+ * @param promptName - Prompt name quoted in the error message
990
+ * @param choices - Choices the prompt was configured with
991
+ * @param initial - Indices the prompt is asked to start on
992
+ * @throws {Error} When the choice list is empty, or an initial index is not a whole number inside the list
993
+ *
994
+ * @example Rejecting an empty choice list
995
+ * ```typescript
996
+ * assertResolvableChoices('select', [], [])
997
+ * // => throws Error: select requires at least one choice
998
+ * ```
999
+ *
1000
+ * @example Rejecting an index past the end of the list
1001
+ * ```typescript
1002
+ * assertResolvableChoices('select', [{ label: 'Red', value: 'red' }], [7])
1003
+ * // => throws Error: select initial must be an index between 0 and 0, received 7
1004
+ * ```
1005
+ */
1006
+ function assertResolvableChoices(promptName, choices, initial) {
1007
+ if (choices.length === 0) {
1008
+ throw index_cjs_js$3.createError(`${promptName} requires at least one choice`);
1009
+ }
1010
+ for (const index of initial) {
1011
+ if (!index_cjs_js$4.isInteger(index) || index < 0 || index >= choices.length) {
1012
+ throw index_cjs_js$3.createError(`${promptName} initial must be an index between 0 and ${choices.length - 1}, received ${index}`);
379
1013
  }
380
1014
  }
381
1015
  }
@@ -484,72 +1118,75 @@ function renderChoice$1(choice, isSelected, isFocused) {
484
1118
  return `${pointer} ${checkbox} ${label}${hint}`;
485
1119
  }
486
1120
  /**
487
- * Renders the multiselect prompt to the terminal.
1121
+ * Builds the frame lines for the multiselect prompt.
488
1122
  *
489
1123
  * @internal
490
- * @param term - Terminal interface
491
1124
  * @param config - Prompt configuration
492
1125
  * @param state - Current prompt state
493
- * @param submitted - Whether the prompt has been submitted
494
- * @returns Number of lines rendered
1126
+ * @param maxVisible - Maximum number of visible choices
1127
+ * @returns Logical lines describing the prompt
495
1128
  */
496
- function render$2(term, config, state, submitted) {
497
- const maxVisible = config.maxVisible ?? 10;
1129
+ function buildLines$1(config, state, maxVisible) {
498
1130
  const { indices: visibleIndices, startIndex } = getVisibleChoices$1(state, maxVisible);
499
- let output = Ansi.CursorStart + Ansi.ClearLine + renderMessage(config.message);
500
- if (submitted) {
501
- const selectedLabels = state.selected.map((i) => state.choices[i]?.label ?? '').join(', ');
502
- output += renderSubmitted(selectedLabels || 'none');
503
- term.write(output);
504
- return 1;
505
- }
1131
+ const lines = [];
1132
+ let header = renderMessage(config.message);
506
1133
  if (config.searchable && state.searchQuery) {
507
- output += style.cyan(state.searchQuery) + style.dim(' (type to filter)');
1134
+ header += style.cyan(state.searchQuery) + style.dim(' (type to filter)');
508
1135
  }
509
1136
  else if (config.searchable) {
510
- output += style.dim('(type to filter, space to toggle, enter to submit)');
1137
+ header += style.dim('(type to filter, space to toggle, enter to submit)');
511
1138
  }
512
1139
  else {
513
- output += style.dim('(space to toggle, enter to submit)');
1140
+ header += style.dim('(space to toggle, enter to submit)');
514
1141
  }
515
- term.write(output + '\n');
516
- let lineCount = 1;
1142
+ lines.push(header);
517
1143
  const minMax = [];
518
1144
  if (config.min !== undefined)
519
1145
  minMax.push(`min: ${config.min}`);
520
1146
  if (config.max !== undefined)
521
1147
  minMax.push(`max: ${config.max}`);
522
1148
  const countHint = minMax.length > 0 ? ` (${minMax.join(', ')})` : '';
523
- term.write(Ansi.ClearLine + style.dim(` ${state.selected.length} selected${countHint}`) + '\n');
524
- lineCount++;
525
- const showScrollUp = startIndex > 0;
526
- const showScrollDown = startIndex + maxVisible < state.filteredIndices.length;
527
- if (showScrollUp) {
528
- term.write(Ansi.ClearLine + style.dim(` ${Symbol.Ellipsis} (${startIndex} more above)`) + '\n');
529
- lineCount++;
1149
+ lines.push(style.dim(` ${state.selected.length} selected${countHint}`));
1150
+ if (startIndex > 0) {
1151
+ lines.push(style.dim(` ${Symbol.Ellipsis} (${startIndex} more above)`));
530
1152
  }
531
1153
  visibleIndices.forEach((actualIndex, i) => {
532
1154
  const choice = state.choices[actualIndex];
533
- /* istanbul ignore if -- @preserve defensive: actualIndex always valid from filteredIndices */
1155
+ // why: actualIndex always comes from filteredIndices, so this guard is defensive and never taken.
534
1156
  if (!choice)
535
1157
  return;
536
- const viewIndex = startIndex + i;
537
- const isFocused = viewIndex === state.cursor;
1158
+ const isFocused = startIndex + i === state.cursor;
538
1159
  const isSelected = arrayIncludes(state.selected, actualIndex);
539
- const line = renderChoice$1(choice, isSelected, isFocused);
540
- term.write(Ansi.ClearLine + line + '\n');
541
- lineCount++;
1160
+ lines.push(renderChoice$1(choice, isSelected, isFocused));
542
1161
  });
543
- if (showScrollDown) {
1162
+ if (startIndex + maxVisible < state.filteredIndices.length) {
544
1163
  const remaining = state.filteredIndices.length - (startIndex + maxVisible);
545
- term.write(Ansi.ClearLine + style.dim(` ${Symbol.Ellipsis} (${remaining} more below)`) + '\n');
546
- lineCount++;
1164
+ lines.push(style.dim(` ${Symbol.Ellipsis} (${remaining} more below)`));
547
1165
  }
548
1166
  if (state.filteredIndices.length === 0 && config.searchable) {
549
- term.write(Ansi.ClearLine + style.dim(' No matches found') + '\n');
550
- lineCount++;
1167
+ lines.push(style.dim(' No matches found'));
551
1168
  }
552
- return lineCount;
1169
+ return index_cjs_js.freeze(lines);
1170
+ }
1171
+ /**
1172
+ * Appends text to the search query, re-filtering choices.
1173
+ *
1174
+ * @internal
1175
+ * @param query - Text to append (a typed character or pasted line)
1176
+ * @param state - Current prompt state
1177
+ * @returns Updated state with the new query applied
1178
+ */
1179
+ function appendSearch$1(query, state) {
1180
+ if (query === '')
1181
+ return state;
1182
+ const newQuery = state.searchQuery + query;
1183
+ return index_cjs_js.freeze({
1184
+ ...state,
1185
+ searchQuery: newQuery,
1186
+ filteredIndices: filterChoices$1(state.choices, newQuery),
1187
+ cursor: 0,
1188
+ scrollOffset: 0,
1189
+ });
553
1190
  }
554
1191
  /**
555
1192
  * Processes a keypress and returns updated state.
@@ -558,10 +1195,10 @@ function render$2(term, config, state, submitted) {
558
1195
  * @param key - The key that was pressed
559
1196
  * @param state - Current prompt state
560
1197
  * @param config - Prompt configuration
1198
+ * @param maxVisible - Maximum number of visible choices
561
1199
  * @returns Updated state after processing the key
562
1200
  */
563
- function processKey$2(key, state, config) {
564
- const maxVisible = config.maxVisible ?? 10;
1201
+ function processKey$2(key, state, config, maxVisible) {
565
1202
  const total = state.filteredIndices.length;
566
1203
  if (total === 0 && key !== Key.Backspace && key !== '\b')
567
1204
  return state;
@@ -596,7 +1233,7 @@ function processKey$2(key, state, config) {
596
1233
  if (actualIndex === undefined)
597
1234
  return state;
598
1235
  const choice = state.choices[actualIndex];
599
- /* istanbul ignore if -- @preserve defensive: actualIndex validated above */
1236
+ // why: actualIndex is validated above, so this guard is defensive and never taken.
600
1237
  if (!choice || choice.disabled)
601
1238
  return state;
602
1239
  const isSelected = arrayIncludes(state.selected, actualIndex);
@@ -628,15 +1265,7 @@ function processKey$2(key, state, config) {
628
1265
  return state;
629
1266
  }
630
1267
  if (key.length === 1 && key >= ' ' && key !== Key.Space) {
631
- const newQuery = state.searchQuery + key;
632
- const newFiltered = filterChoices$1(state.choices, newQuery);
633
- return index_cjs_js.freeze({
634
- ...state,
635
- searchQuery: newQuery,
636
- filteredIndices: newFiltered,
637
- cursor: 0,
638
- scrollOffset: 0,
639
- });
1268
+ return appendSearch$1(key, state);
640
1269
  }
641
1270
  }
642
1271
  return state;
@@ -658,14 +1287,30 @@ function validateSelection(state, config) {
658
1287
  }
659
1288
  return undefined;
660
1289
  }
1290
+ /**
1291
+ * Resolves the visible-window size, capped so the frame fits the terminal
1292
+ * height (header and scroll-indicator rows reserved).
1293
+ *
1294
+ * @internal
1295
+ * @param term - Terminal used for size queries
1296
+ * @param configured - Configured maximum visible choices
1297
+ * @returns Effective maximum visible choices (at least 1)
1298
+ */
1299
+ function effectiveMaxVisible$1(term, configured) {
1300
+ return index_cjs_js$1.max(1, index_cjs_js$1.min(configured ?? 10, term.getSize().rows - 4));
1301
+ }
661
1302
  /**
662
1303
  * Prompts for multiple selections from a list of choices.
663
1304
  *
664
1305
  * Pure functional prompt with arrow key navigation, space to toggle,
665
- * scrolling support, min/max constraints, and optional type-to-filter search.
1306
+ * scrolling support, min/max constraints, and optional type-to-filter
1307
+ * search. In searchable mode, pasted text appends its first line to the
1308
+ * filter query; pasting never toggles or submits. The prompt repaints on
1309
+ * terminal resize, preserving cursor, selection, and scroll state.
666
1310
  *
667
1311
  * @param config - Multiselect prompt configuration
668
1312
  * @returns Promise resolving to array of selected values or cancellation
1313
+ * @throws {Error} When `choices` is empty, or an `initial` entry is not a whole number inside the choice list
669
1314
  *
670
1315
  * @example Basic multiselect
671
1316
  * ```typescript
@@ -703,56 +1348,61 @@ function validateSelection(state, config) {
703
1348
  * ```
704
1349
  */
705
1350
  async function multiselect(config) {
1351
+ assertResolvableChoices('multiselect', config.choices, config.initial ?? []);
706
1352
  const term = createTerminal({ input: config.input, output: config.output });
1353
+ const screen = createScreen(term);
707
1354
  let state = createInitialState$2(config);
708
- let lineCount = 0;
709
1355
  let errorMessage;
710
1356
  term.write(Ansi.HideCursor);
711
- const redraw = (submitted = false) => {
712
- if (lineCount > 0) {
713
- term.write(Ansi.cursorUp(lineCount) + Ansi.CursorStart);
714
- }
715
- term.write(Ansi.ClearToEnd);
716
- lineCount = render$2(term, config, state, submitted);
717
- if (errorMessage && !submitted) {
718
- term.write(Ansi.ClearLine + style.yellow(` ${errorMessage}`) + '\n');
719
- lineCount++;
1357
+ const redraw = () => {
1358
+ const lines = [...buildLines$1(config, state, effectiveMaxVisible$1(term, config.maxVisible))];
1359
+ if (errorMessage) {
1360
+ lines.push(style.yellow(` ${errorMessage}`));
720
1361
  }
1362
+ screen.render(index_cjs_js.freeze({ lines: index_cjs_js.freeze(lines) }));
721
1363
  };
722
- redraw();
723
- while (true) {
724
- const key = await term.readKey();
725
- if (term.isCancelled()) {
726
- if (lineCount > 0) {
727
- term.write(Ansi.cursorUp(lineCount) + Ansi.CursorStart);
1364
+ try {
1365
+ redraw();
1366
+ while (true) {
1367
+ const token = await term.readToken();
1368
+ if (term.isCancelled()) {
1369
+ screen.render(index_cjs_js.freeze({ lines: index_cjs_js.freeze([renderMessage(config.message) + renderCancelled()]) }));
1370
+ term.write('\n' + Ansi.ShowCursor);
1371
+ return index_cjs_js.freeze({ result: PromptResult.Cancelled, value: undefined });
728
1372
  }
729
- term.write(Ansi.ClearToEnd);
730
- term.write(renderMessage(config.message) + renderCancelled() + '\n');
731
- term.write(Ansi.ShowCursor);
732
- term.close();
733
- return index_cjs_js.freeze({ result: PromptResult.Cancelled, value: undefined });
734
- }
735
- if (key === Key.Enter) {
736
- const validationError = validateSelection(state, config);
737
- if (validationError) {
738
- errorMessage = validationError;
1373
+ if (token.type === TokenType.Resize) {
739
1374
  redraw();
740
1375
  continue;
741
1376
  }
742
- const selectedValues = state.selected.map((i) => state.choices[i]?.value).filter((v) => v !== undefined);
743
- if (lineCount > 0) {
744
- term.write(Ansi.cursorUp(lineCount) + Ansi.CursorStart);
1377
+ if (token.type === TokenType.Paste) {
1378
+ if (config.searchable) {
1379
+ errorMessage = undefined;
1380
+ state = appendSearch$1(firstPasteLine(token.value), state);
1381
+ redraw();
1382
+ }
1383
+ continue;
1384
+ }
1385
+ if (token.value === Key.Enter) {
1386
+ const validationError = validateSelection(state, config);
1387
+ if (validationError) {
1388
+ errorMessage = validationError;
1389
+ redraw();
1390
+ continue;
1391
+ }
1392
+ const selectedValues = state.selected.map((i) => state.choices[i]?.value).filter((v) => v !== undefined);
1393
+ const selectedLabels = state.selected.map((i) => state.choices[i]?.label ?? '').join(', ');
1394
+ const submittedLine = renderMessage(config.message) + renderSubmitted(selectedLabels || 'none');
1395
+ screen.render(index_cjs_js.freeze({ lines: index_cjs_js.freeze([submittedLine]) }));
1396
+ term.write('\n' + Ansi.ShowCursor);
1397
+ return index_cjs_js.freeze({ result: PromptResult.Submitted, value: index_cjs_js.freeze(selectedValues) });
745
1398
  }
746
- term.write(Ansi.ClearToEnd);
747
- render$2(term, config, state, true);
748
- term.write('\n');
749
- term.write(Ansi.ShowCursor);
750
- term.close();
751
- return index_cjs_js.freeze({ result: PromptResult.Submitted, value: index_cjs_js.freeze(selectedValues) });
1399
+ errorMessage = undefined;
1400
+ state = processKey$2(token.value, state, config, effectiveMaxVisible$1(term, config.maxVisible));
1401
+ redraw();
752
1402
  }
753
- errorMessage = undefined;
754
- state = processKey$2(key, state, config);
755
- redraw();
1403
+ }
1404
+ finally {
1405
+ term.close();
756
1406
  }
757
1407
  }
758
1408
 
@@ -839,65 +1489,66 @@ function renderChoice(choice, isFocused) {
839
1489
  return `${pointer} ${label}${hint}`;
840
1490
  }
841
1491
  /**
842
- * Renders the select prompt to the terminal.
1492
+ * Builds the frame lines for the select prompt.
843
1493
  *
844
1494
  * @internal
845
- * @param term - Terminal interface
846
1495
  * @param config - Prompt configuration
847
1496
  * @param state - Current prompt state
848
- * @param submitted - Whether the prompt has been submitted
849
- * @returns Number of lines rendered
1497
+ * @param maxVisible - Maximum number of visible choices
1498
+ * @returns Logical lines describing the prompt
850
1499
  */
851
- function render$1(term, config, state, submitted) {
852
- const maxVisible = config.maxVisible ?? 10;
1500
+ function buildLines(config, state, maxVisible) {
853
1501
  const { indices: visibleIndices, startIndex } = getVisibleChoices(state, maxVisible);
854
- let output = Ansi.CursorStart + Ansi.ClearLine + renderMessage(config.message);
855
- if (submitted) {
856
- const actualIndex = state.filteredIndices[state.cursor];
857
- const selectedChoice = actualIndex !== undefined ? state.choices[actualIndex] : undefined;
858
- /* istanbul ignore next -- @preserve defensive: cursor always within bounds */
859
- output += renderSubmitted(selectedChoice?.label ?? '');
860
- term.write(output);
861
- return 1;
862
- }
1502
+ const lines = [];
1503
+ let header = renderMessage(config.message);
863
1504
  if (config.searchable && state.searchQuery) {
864
- output += style.cyan(state.searchQuery) + style.dim(' (type to filter)');
1505
+ header += style.cyan(state.searchQuery) + style.dim(' (type to filter)');
865
1506
  }
866
1507
  else if (config.searchable) {
867
- output += style.dim('(type to filter, enter to select)');
1508
+ header += style.dim('(type to filter, enter to select)');
868
1509
  }
869
1510
  else {
870
- output += style.dim('(use arrows, enter to select)');
1511
+ header += style.dim('(use arrows, enter to select)');
871
1512
  }
872
- term.write(output + '\n');
873
- let lineCount = 1;
874
- const showScrollUp = startIndex > 0;
875
- const showScrollDown = startIndex + maxVisible < state.filteredIndices.length;
876
- if (showScrollUp) {
877
- term.write(Ansi.ClearLine + style.dim(` ${Symbol.Ellipsis} (${startIndex} more above)`) + '\n');
878
- lineCount++;
1513
+ lines.push(header);
1514
+ if (startIndex > 0) {
1515
+ lines.push(style.dim(` ${Symbol.Ellipsis} (${startIndex} more above)`));
879
1516
  }
880
1517
  visibleIndices.forEach((actualIndex, i) => {
881
1518
  const choice = state.choices[actualIndex];
882
- /* istanbul ignore if -- @preserve defensive: actualIndex always valid from filteredIndices */
1519
+ // why: actualIndex always comes from filteredIndices, so this guard is defensive and never taken.
883
1520
  if (!choice)
884
1521
  return;
885
- const viewIndex = startIndex + i;
886
- const isFocused = viewIndex === state.cursor;
887
- const line = renderChoice(choice, isFocused);
888
- term.write(Ansi.ClearLine + line + '\n');
889
- lineCount++;
1522
+ lines.push(renderChoice(choice, startIndex + i === state.cursor));
890
1523
  });
891
- if (showScrollDown) {
1524
+ if (startIndex + maxVisible < state.filteredIndices.length) {
892
1525
  const remaining = state.filteredIndices.length - (startIndex + maxVisible);
893
- term.write(Ansi.ClearLine + style.dim(` ${Symbol.Ellipsis} (${remaining} more below)`) + '\n');
894
- lineCount++;
1526
+ lines.push(style.dim(` ${Symbol.Ellipsis} (${remaining} more below)`));
895
1527
  }
896
1528
  if (state.filteredIndices.length === 0 && config.searchable) {
897
- term.write(Ansi.ClearLine + style.dim(' No matches found') + '\n');
898
- lineCount++;
1529
+ lines.push(style.dim(' No matches found'));
899
1530
  }
900
- return lineCount;
1531
+ return index_cjs_js.freeze(lines);
1532
+ }
1533
+ /**
1534
+ * Appends text to the search query, re-filtering choices.
1535
+ *
1536
+ * @internal
1537
+ * @param query - Text to append (a typed character or pasted line)
1538
+ * @param state - Current prompt state
1539
+ * @returns Updated state with the new query applied
1540
+ */
1541
+ function appendSearch(query, state) {
1542
+ if (query === '')
1543
+ return state;
1544
+ const newQuery = state.searchQuery + query;
1545
+ return index_cjs_js.freeze({
1546
+ ...state,
1547
+ searchQuery: newQuery,
1548
+ filteredIndices: filterChoices(state.choices, newQuery),
1549
+ cursor: 0,
1550
+ scrollOffset: 0,
1551
+ });
901
1552
  }
902
1553
  /**
903
1554
  * Processes a keypress and returns updated state.
@@ -906,10 +1557,10 @@ function render$1(term, config, state, submitted) {
906
1557
  * @param key - The key that was pressed
907
1558
  * @param state - Current prompt state
908
1559
  * @param config - Prompt configuration
1560
+ * @param maxVisible - Maximum number of visible choices
909
1561
  * @returns Updated state after processing the key
910
1562
  */
911
- function processKey$1(key, state, config) {
912
- const maxVisible = config.maxVisible ?? 10;
1563
+ function processKey$1(key, state, config, maxVisible) {
913
1564
  const total = state.filteredIndices.length;
914
1565
  if (total === 0 && key !== Key.Backspace && key !== '\b')
915
1566
  return state;
@@ -955,15 +1606,7 @@ function processKey$1(key, state, config) {
955
1606
  return state;
956
1607
  }
957
1608
  if (key.length === 1 && key >= ' ') {
958
- const newQuery = state.searchQuery + key;
959
- const newFiltered = filterChoices(state.choices, newQuery);
960
- return index_cjs_js.freeze({
961
- ...state,
962
- searchQuery: newQuery,
963
- filteredIndices: newFiltered,
964
- cursor: 0,
965
- scrollOffset: 0,
966
- });
1609
+ return appendSearch(key, state);
967
1610
  }
968
1611
  }
969
1612
  return state;
@@ -972,10 +1615,14 @@ function processKey$1(key, state, config) {
972
1615
  * Prompts for single selection from a list of choices.
973
1616
  *
974
1617
  * Pure functional prompt with arrow key navigation, scrolling support,
975
- * optional disabled choices, and optional type-to-filter search.
1618
+ * optional disabled choices, and optional type-to-filter search. In
1619
+ * searchable mode, pasted text appends its first line to the filter query.
1620
+ * The prompt repaints on terminal resize, preserving cursor and scroll
1621
+ * state.
976
1622
  *
977
1623
  * @param config - Select prompt configuration
978
1624
  * @returns Promise resolving to selected value or cancellation
1625
+ * @throws {Error} When `choices` is empty, or `initial` is not a whole number inside the choice list
979
1626
  *
980
1627
  * @example Basic select
981
1628
  * ```typescript
@@ -1015,52 +1662,67 @@ function processKey$1(key, state, config) {
1015
1662
  * ```
1016
1663
  */
1017
1664
  async function select(config) {
1665
+ assertResolvableChoices('select', config.choices, config.initial === undefined ? [] : [config.initial]);
1018
1666
  const term = createTerminal({ input: config.input, output: config.output });
1667
+ const screen = createScreen(term);
1019
1668
  let state = createInitialState$1(config);
1020
- let lineCount = 0;
1021
1669
  term.write(Ansi.HideCursor);
1022
- const redraw = (submitted = false) => {
1023
- if (lineCount > 0) {
1024
- term.write(Ansi.cursorUp(lineCount) + Ansi.CursorStart);
1025
- }
1026
- term.write(Ansi.ClearToEnd);
1027
- lineCount = render$1(term, config, state, submitted);
1670
+ const redraw = () => {
1671
+ screen.render(index_cjs_js.freeze({ lines: buildLines(config, state, effectiveMaxVisible(term, config.maxVisible)) }));
1028
1672
  };
1029
- redraw();
1030
- while (true) {
1031
- const key = await term.readKey();
1032
- if (term.isCancelled()) {
1033
- if (lineCount > 0) {
1034
- term.write(Ansi.cursorUp(lineCount) + Ansi.CursorStart);
1673
+ try {
1674
+ redraw();
1675
+ while (true) {
1676
+ const token = await term.readToken();
1677
+ if (term.isCancelled()) {
1678
+ screen.render(index_cjs_js.freeze({ lines: index_cjs_js.freeze([renderMessage(config.message) + renderCancelled()]) }));
1679
+ term.write('\n' + Ansi.ShowCursor);
1680
+ return index_cjs_js.freeze({ result: PromptResult.Cancelled, value: undefined });
1035
1681
  }
1036
- term.write(Ansi.ClearToEnd);
1037
- term.write(renderMessage(config.message) + renderCancelled() + '\n');
1038
- term.write(Ansi.ShowCursor);
1039
- term.close();
1040
- return index_cjs_js.freeze({ result: PromptResult.Cancelled, value: undefined });
1041
- }
1042
- if (key === Key.Enter) {
1043
- const actualIndex = state.filteredIndices[state.cursor];
1044
- if (actualIndex === undefined) {
1682
+ if (token.type === TokenType.Resize) {
1683
+ redraw();
1045
1684
  continue;
1046
1685
  }
1047
- const selectedChoice = state.choices[actualIndex];
1048
- if (!selectedChoice || selectedChoice.disabled) {
1686
+ if (token.type === TokenType.Paste) {
1687
+ if (config.searchable) {
1688
+ state = appendSearch(firstPasteLine(token.value), state);
1689
+ redraw();
1690
+ }
1049
1691
  continue;
1050
1692
  }
1051
- if (lineCount > 0) {
1052
- term.write(Ansi.cursorUp(lineCount) + Ansi.CursorStart);
1693
+ if (token.value === Key.Enter) {
1694
+ const actualIndex = state.filteredIndices[state.cursor];
1695
+ if (actualIndex === undefined) {
1696
+ continue;
1697
+ }
1698
+ const selectedChoice = state.choices[actualIndex];
1699
+ if (!selectedChoice || selectedChoice.disabled) {
1700
+ continue;
1701
+ }
1702
+ const submittedLine = renderMessage(config.message) + renderSubmitted(selectedChoice.label);
1703
+ screen.render(index_cjs_js.freeze({ lines: index_cjs_js.freeze([submittedLine]) }));
1704
+ term.write('\n' + Ansi.ShowCursor);
1705
+ return index_cjs_js.freeze({ result: PromptResult.Submitted, value: selectedChoice.value });
1053
1706
  }
1054
- term.write(Ansi.ClearToEnd);
1055
- render$1(term, config, state, true);
1056
- term.write('\n');
1057
- term.write(Ansi.ShowCursor);
1058
- term.close();
1059
- return index_cjs_js.freeze({ result: PromptResult.Submitted, value: selectedChoice.value });
1707
+ state = processKey$1(token.value, state, config, effectiveMaxVisible(term, config.maxVisible));
1708
+ redraw();
1060
1709
  }
1061
- state = processKey$1(key, state, config);
1062
- redraw();
1063
1710
  }
1711
+ finally {
1712
+ term.close();
1713
+ }
1714
+ }
1715
+ /**
1716
+ * Resolves the visible-window size, capped so the frame fits the terminal
1717
+ * height (header and scroll-indicator rows reserved).
1718
+ *
1719
+ * @internal
1720
+ * @param term - Terminal used for size queries
1721
+ * @param configured - Configured maximum visible choices
1722
+ * @returns Effective maximum visible choices (at least 1)
1723
+ */
1724
+ function effectiveMaxVisible(term, configured) {
1725
+ return index_cjs_js$1.max(1, index_cjs_js$1.min(configured ?? 10, term.getSize().rows - 4));
1064
1726
  }
1065
1727
 
1066
1728
  /**
@@ -1078,40 +1740,63 @@ function createInitialState(config) {
1078
1740
  };
1079
1741
  }
1080
1742
  /**
1081
- * Renders the text prompt to the terminal.
1743
+ * Builds the current prompt message text.
1744
+ *
1745
+ * @internal
1746
+ * @param config - Prompt configuration
1747
+ * @param value - Current input value
1748
+ * @returns Message text for the current value
1749
+ */
1750
+ function messageText(config, value) {
1751
+ return config.renderMessage ? config.renderMessage(value) : config.message;
1752
+ }
1753
+ /**
1754
+ * Builds the frame for the text prompt.
1082
1755
  *
1083
1756
  * @internal
1084
- * @param term - Terminal interface for output
1085
1757
  * @param config - Prompt configuration
1086
1758
  * @param state - Current prompt state
1087
1759
  * @param submitted - Whether the prompt has been submitted
1088
- * @returns Number of lines rendered
1760
+ * @returns Frame describing lines and cursor position
1089
1761
  */
1090
- function render(term, config, state, submitted) {
1762
+ function buildFrame(config, state, submitted) {
1091
1763
  const displayValue = config.format ? config.format(state.value) : state.value;
1092
- const messageText = config.renderMessage ? config.renderMessage(state.value) : config.message;
1093
- let output = renderMessage(messageText);
1094
- let trailingChars = 0;
1764
+ let line = renderMessage(messageText(config, state.value));
1095
1765
  if (submitted) {
1096
- output += renderSubmitted(displayValue || config.initial || '');
1766
+ line += renderSubmitted(displayValue || config.initial || '');
1767
+ return index_cjs_js.freeze({ lines: index_cjs_js.freeze([line]) });
1097
1768
  }
1098
- else {
1099
- output += displayValue;
1100
- trailingChars = displayValue.length - state.cursorPos;
1101
- if (config.initial && !state.value) {
1102
- output += style.dim(config.initial);
1103
- trailingChars += config.initial.length;
1104
- }
1769
+ line += displayValue;
1770
+ let trailingWidth = displayWidth(state.value.slice(state.cursorPos));
1771
+ if (config.initial && !state.value) {
1772
+ line += style.dim(config.initial);
1773
+ trailingWidth += displayWidth(config.initial);
1105
1774
  }
1106
- term.write(Ansi.CursorStart + Ansi.ClearLine + output);
1775
+ const cursor = index_cjs_js.freeze({ line: 0, col: displayWidth(line) - trailingWidth });
1107
1776
  if (state.error) {
1108
- term.write('\n' + style.yellow(` ${state.error}`));
1109
- return 2;
1110
- }
1111
- if (!submitted && trailingChars > 0) {
1112
- term.write(Ansi.cursorLeft(trailingChars));
1777
+ return index_cjs_js.freeze({ lines: index_cjs_js.freeze([line, style.yellow(` ${state.error}`)]), cursor });
1113
1778
  }
1114
- return 1;
1779
+ return index_cjs_js.freeze({ lines: index_cjs_js.freeze([line]), cursor });
1780
+ }
1781
+ /**
1782
+ * Inserts text at the cursor position, moving the cursor past it.
1783
+ *
1784
+ * @internal
1785
+ * @param insert - Text to insert (already sanitized for pastes)
1786
+ * @param state - Current prompt state
1787
+ * @returns Updated state after insertion
1788
+ */
1789
+ function insertText(insert, state) {
1790
+ if (insert === '')
1791
+ return state;
1792
+ const before = state.value.slice(0, state.cursorPos);
1793
+ const after = state.value.slice(state.cursorPos);
1794
+ return {
1795
+ ...state,
1796
+ value: before + insert + after,
1797
+ cursorPos: state.cursorPos + insert.length,
1798
+ error: undefined,
1799
+ };
1115
1800
  }
1116
1801
  /**
1117
1802
  * Processes a keypress and returns updated state.
@@ -1123,14 +1808,7 @@ function render(term, config, state, submitted) {
1123
1808
  */
1124
1809
  function processKey(key, state) {
1125
1810
  if (key.length === 1 && key >= ' ' && key !== Key.Backspace) {
1126
- const before = state.value.slice(0, state.cursorPos);
1127
- const after = state.value.slice(state.cursorPos);
1128
- return {
1129
- ...state,
1130
- value: before + key + after,
1131
- cursorPos: state.cursorPos + 1,
1132
- error: undefined,
1133
- };
1811
+ return insertText(key, state);
1134
1812
  }
1135
1813
  if (key === Key.Backspace || key === '\b') {
1136
1814
  if (state.cursorPos > 0) {
@@ -1147,13 +1825,13 @@ function processKey(key, state) {
1147
1825
  if (key === Key.Left) {
1148
1826
  return {
1149
1827
  ...state,
1150
- cursorPos: index_cjs_js$2.max(0, state.cursorPos - 1),
1828
+ cursorPos: index_cjs_js$1.max(0, state.cursorPos - 1),
1151
1829
  };
1152
1830
  }
1153
1831
  if (key === Key.Right) {
1154
1832
  return {
1155
1833
  ...state,
1156
- cursorPos: index_cjs_js$2.min(state.value.length, state.cursorPos + 1),
1834
+ cursorPos: index_cjs_js$1.min(state.value.length, state.cursorPos + 1),
1157
1835
  };
1158
1836
  }
1159
1837
  return state;
@@ -1162,7 +1840,11 @@ function processKey(key, state) {
1162
1840
  * Prompts for text input with optional validation.
1163
1841
  *
1164
1842
  * Pure functional prompt that reads text from the user with support for
1165
- * default values, input validation, and display formatting.
1843
+ * default values, input validation, and display formatting. Pasted text is
1844
+ * sanitized (newlines collapse to spaces, control characters are removed)
1845
+ * and inserted at the cursor without ever auto-submitting. The prompt
1846
+ * repaints on terminal resize, preserving value, cursor, and any
1847
+ * validation error.
1166
1848
  *
1167
1849
  * @param config - Text prompt configuration
1168
1850
  * @returns Promise resolving to submitted value or cancellation
@@ -1196,40 +1878,49 @@ function processKey(key, state) {
1196
1878
  */
1197
1879
  async function text(config) {
1198
1880
  const term = createTerminal({ input: config.input, output: config.output });
1881
+ const screen = createScreen(term);
1199
1882
  let state = createInitialState(config);
1200
- let lineCount = 0;
1201
1883
  const redraw = (submitted = false) => {
1202
- if (lineCount > 0) {
1203
- term.clearLines(lineCount);
1204
- }
1205
- lineCount = render(term, config, state, submitted);
1884
+ screen.render(buildFrame(config, state, submitted));
1206
1885
  };
1207
- redraw();
1208
- while (true) {
1209
- const key = await term.readKey();
1210
- if (term.isCancelled()) {
1211
- redraw();
1212
- term.write(renderCancelled() + '\n');
1213
- term.close();
1214
- return index_cjs_js.freeze({ result: PromptResult.Cancelled, value: undefined });
1215
- }
1216
- if (key === Key.Enter) {
1217
- const value = state.value || config.initial || '';
1218
- if (config.validate) {
1219
- const errorMessage = config.validate(value);
1220
- if (errorMessage) {
1221
- state = { ...state, error: errorMessage };
1222
- redraw();
1223
- continue;
1886
+ try {
1887
+ redraw();
1888
+ while (true) {
1889
+ const token = await term.readToken();
1890
+ if (term.isCancelled()) {
1891
+ screen.render(index_cjs_js.freeze({ lines: index_cjs_js.freeze([renderMessage(messageText(config, state.value)) + renderCancelled()]) }));
1892
+ term.write('\n');
1893
+ return index_cjs_js.freeze({ result: PromptResult.Cancelled, value: undefined });
1894
+ }
1895
+ if (token.type === TokenType.Resize) {
1896
+ redraw();
1897
+ continue;
1898
+ }
1899
+ if (token.type === TokenType.Paste) {
1900
+ state = insertText(sanitizePasteText(token.value), state);
1901
+ redraw();
1902
+ continue;
1903
+ }
1904
+ if (token.value === Key.Enter) {
1905
+ const value = state.value || config.initial || '';
1906
+ if (config.validate) {
1907
+ const errorMessage = config.validate(value);
1908
+ if (errorMessage) {
1909
+ state = { ...state, error: errorMessage };
1910
+ redraw();
1911
+ continue;
1912
+ }
1224
1913
  }
1914
+ redraw(true);
1915
+ term.write('\n');
1916
+ return index_cjs_js.freeze({ result: PromptResult.Submitted, value });
1225
1917
  }
1226
- redraw(true);
1227
- term.write('\n');
1228
- term.close();
1229
- return index_cjs_js.freeze({ result: PromptResult.Submitted, value });
1918
+ state = processKey(token.value, state);
1919
+ redraw();
1230
1920
  }
1231
- state = processKey(key, state);
1232
- redraw();
1921
+ }
1922
+ finally {
1923
+ term.close();
1233
1924
  }
1234
1925
  }
1235
1926