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