@hyperfrontend/questions 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.esm.js CHANGED
@@ -1,13 +1,263 @@
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';
5
6
 
6
7
  /**
7
- * Terminal I/O utilities using Node.js readline.
8
+ * ANSI-aware text measurement helpers.
9
+ *
10
+ * Display width is measured in Unicode code points with ANSI escape
11
+ * sequences excluded. East-asian wide characters and grapheme clusters are
12
+ * counted as one column each; full terminal-accurate width is out of scope.
13
+ *
14
+ * @internal
15
+ */
16
+ /** Escape character introducing ANSI control sequences. */
17
+ const Esc = '\x1B';
18
+ /**
19
+ * Matches the ANSI escape sequence starting at `index`. The character at
20
+ * `index` must be the escape character (`\x1B`); callers check this before
21
+ * calling. Recognizes CSI (`ESC [ ... final`) and SS3 (`ESC O x`) sequences;
22
+ * any other escape is reported as a lone one-character escape.
23
+ *
24
+ * @param text - Text containing the sequence
25
+ * @param index - Position of the escape character
26
+ * @returns Sequence length and whether it is complete
27
+ *
28
+ * @example Matching an arrow-key sequence
29
+ * ```typescript
30
+ * matchAnsiSequence('\x1B[A', 0)
31
+ * // => { length: 3, complete: true }
32
+ * ```
33
+ */
34
+ function matchAnsiSequence(text, index) {
35
+ if (index + 1 >= text.length) {
36
+ return freeze({ length: 1, complete: false });
37
+ }
38
+ const introducer = text.charAt(index + 1);
39
+ if (introducer === '[') {
40
+ return matchCsi(text, index);
41
+ }
42
+ if (introducer === 'O') {
43
+ if (index + 2 >= text.length) {
44
+ return freeze({ length: 2, complete: false });
45
+ }
46
+ return freeze({ length: 3, complete: true });
47
+ }
48
+ return freeze({ length: 1, complete: true });
49
+ }
50
+ /**
51
+ * Matches a CSI sequence (`ESC [` + parameter/intermediate bytes + final
52
+ * byte in `0x40`-`0x7E`). A byte outside the CSI ranges terminates the match
53
+ * without being consumed so malformed sequences cannot swallow input.
54
+ *
55
+ * @param text - Text containing the sequence
56
+ * @param index - Position of the escape character
57
+ * @returns Sequence length and whether it is complete
58
+ */
59
+ function matchCsi(text, index) {
60
+ let i = index + 2;
61
+ while (i < text.length) {
62
+ const code = text.charCodeAt(i);
63
+ if (code >= 0x40 && code <= 0x7e) {
64
+ return freeze({ length: i - index + 1, complete: true });
65
+ }
66
+ if (code < 0x20 || code > 0x3f) {
67
+ return freeze({ length: i - index, complete: true });
68
+ }
69
+ i++;
70
+ }
71
+ return freeze({ length: text.length - index, complete: false });
72
+ }
73
+ /**
74
+ * Removes all ANSI escape sequences from a string.
75
+ *
76
+ * @param text - Text possibly containing escape sequences
77
+ * @returns Text with every escape sequence removed
78
+ *
79
+ * @example Stripping color codes
80
+ * ```typescript
81
+ * stripAnsi('\x1B[36mhello\x1B[0m')
82
+ * // => 'hello'
83
+ * ```
84
+ */
85
+ function stripAnsi(text) {
86
+ let out = '';
87
+ let i = 0;
88
+ while (i < text.length) {
89
+ if (text.charAt(i) === Esc) {
90
+ i += matchAnsiSequence(text, i).length;
91
+ continue;
92
+ }
93
+ out += text.charAt(i);
94
+ i++;
95
+ }
96
+ return out;
97
+ }
98
+ /**
99
+ * Measures the display width of a string in terminal columns: Unicode code
100
+ * points with ANSI escape sequences excluded. Wide east-asian characters
101
+ * count as one column (documented limitation).
102
+ *
103
+ * @param text - Text to measure
104
+ * @returns Number of display columns
105
+ *
106
+ * @example Measuring styled text
107
+ * ```typescript
108
+ * displayWidth('\x1B[1mhi\x1B[0m')
109
+ * // => 2
110
+ * ```
111
+ */
112
+ function displayWidth(text) {
113
+ return [...stripAnsi(text)].length;
114
+ }
115
+
116
+ /**
117
+ * Incremental parser turning raw terminal input chunks into input tokens.
118
+ *
119
+ * Handles bracketed paste bodies (accumulated across chunks between
120
+ * `ESC[200~` and `ESC[201~`), escape-sequence keys, and printable runs.
121
+ * A multi-character printable run outside bracketed paste is treated as a
122
+ * paste from a terminal without bracketed-paste support.
8
123
  *
9
124
  * @internal
10
125
  */
126
+ /** Control character sent by Ctrl+C in raw mode. */
127
+ const CtrlC = '\x03';
128
+ /** Sequence a bracketed-paste-aware terminal sends before pasted content. */
129
+ const PasteStart = '\x1B[200~';
130
+ /** Sequence a bracketed-paste-aware terminal sends after pasted content. */
131
+ const PasteEnd = '\x1B[201~';
132
+ /**
133
+ * Kinds of tokens produced while reading terminal input.
134
+ */
135
+ const TokenType = freeze({
136
+ /** A single keypress (character or escape sequence) */
137
+ Key: 'key',
138
+ /** A block of pasted text */
139
+ Paste: 'paste',
140
+ /** The output terminal was resized */
141
+ Resize: 'resize',
142
+ });
143
+ /**
144
+ * Builds a key token.
145
+ *
146
+ * @param value - Key character or escape sequence
147
+ * @returns Frozen key token
148
+ */
149
+ function keyToken(value) {
150
+ return freeze({ type: TokenType.Key, value });
151
+ }
152
+ /**
153
+ * Builds a paste token.
154
+ *
155
+ * @param value - Raw pasted text
156
+ * @returns Frozen paste token
157
+ */
158
+ function pasteToken(value) {
159
+ return freeze({ type: TokenType.Paste, value });
160
+ }
161
+ /**
162
+ * Finds where a trailing partial occurrence of `marker` starts in `data`,
163
+ * scanning no earlier than `from`. Used to hold back a possible split
164
+ * bracketed-paste end marker until the next chunk arrives.
165
+ *
166
+ * @param data - Buffered input text
167
+ * @param from - First position that may be held back
168
+ * @param marker - Marker whose prefix may be split across chunks
169
+ * @returns Index where the partial marker starts, or `data.length` when the
170
+ * text does not end with a marker prefix
171
+ */
172
+ function partialMarkerStart(data, from, marker) {
173
+ const maxLength = min(marker.length - 1, data.length - from);
174
+ for (let k = maxLength; k >= 1; k--) {
175
+ if (data.endsWith(marker.slice(0, k))) {
176
+ return data.length - k;
177
+ }
178
+ }
179
+ return data.length;
180
+ }
181
+ /**
182
+ * Creates an incremental input tokenizer.
183
+ *
184
+ * Escape sequences split across chunks are buffered until complete; a lone
185
+ * trailing escape is likewise buffered (prompts do not act on a bare Escape
186
+ * key, so delaying it until the next chunk is unobservable). Ctrl+C outside
187
+ * a bracketed paste is always its own key token; inside a paste body it is
188
+ * plain data.
189
+ *
190
+ * @returns Stateful token parser
191
+ *
192
+ * @example Parsing a paste split across two chunks
193
+ * ```typescript
194
+ * const parser = createTokenParser()
195
+ * parser.feed('\x1B[200~hello ')
196
+ * // => []
197
+ * parser.feed('world\x1B[201~')
198
+ * // => [{ type: 'paste', value: 'hello world' }]
199
+ * ```
200
+ */
201
+ function createTokenParser() {
202
+ let carry = '';
203
+ let pasteBuffer;
204
+ const feed = (chunk) => {
205
+ const data = carry + chunk;
206
+ carry = '';
207
+ const tokens = [];
208
+ let pos = 0;
209
+ while (pos < data.length) {
210
+ if (pasteBuffer !== undefined) {
211
+ const endIndex = data.indexOf(PasteEnd, pos);
212
+ if (endIndex >= 0) {
213
+ tokens.push(pasteToken(pasteBuffer + data.slice(pos, endIndex)));
214
+ pasteBuffer = undefined;
215
+ pos = endIndex + PasteEnd.length;
216
+ continue;
217
+ }
218
+ const holdFrom = partialMarkerStart(data, pos, PasteEnd);
219
+ pasteBuffer += data.slice(pos, holdFrom);
220
+ carry = data.slice(holdFrom);
221
+ break;
222
+ }
223
+ const char = data.charAt(pos);
224
+ if (char === Esc) {
225
+ const match = matchAnsiSequence(data, pos);
226
+ if (!match.complete) {
227
+ carry = data.slice(pos);
228
+ break;
229
+ }
230
+ const sequence = data.slice(pos, pos + match.length);
231
+ pos += match.length;
232
+ if (sequence === PasteStart) {
233
+ pasteBuffer = '';
234
+ continue;
235
+ }
236
+ // why: a stray end marker without a matching start carries no content
237
+ if (sequence === PasteEnd)
238
+ continue;
239
+ tokens.push(keyToken(sequence));
240
+ continue;
241
+ }
242
+ if (char === CtrlC) {
243
+ tokens.push(keyToken(char));
244
+ pos++;
245
+ continue;
246
+ }
247
+ let end = pos + 1;
248
+ while (end < data.length && data.charAt(end) !== Esc && data.charAt(end) !== CtrlC) {
249
+ end++;
250
+ }
251
+ const run = data.slice(pos, end);
252
+ // why: raw mode delivers one keystroke per chunk, so a longer run means the terminal pasted without bracketed-paste support
253
+ tokens.push(run.length === 1 ? keyToken(run) : pasteToken(run));
254
+ pos = end;
255
+ }
256
+ return freeze(tokens);
257
+ };
258
+ return freeze({ feed });
259
+ }
260
+
11
261
  /**
12
262
  * Key codes for terminal navigation.
13
263
  */
@@ -53,6 +303,13 @@ const Ansi = freeze({
53
303
  * @returns ANSI escape sequence string
54
304
  */
55
305
  cursorLeft: (n) => `\x1B[${n}D`,
306
+ /**
307
+ * Generates ANSI escape code to move cursor right by specified columns.
308
+ *
309
+ * @param n - Number of columns to move right
310
+ * @returns ANSI escape sequence string
311
+ */
312
+ cursorRight: (n) => `\x1B[${n}C`,
56
313
  /** Escape code to hide cursor */
57
314
  HideCursor: '\x1B[?25l',
58
315
  /** Escape code to show cursor */
@@ -63,6 +320,10 @@ const Ansi = freeze({
63
320
  RestoreCursor: '\x1B8',
64
321
  /** Clear from cursor to end of screen */
65
322
  ClearToEnd: '\x1B[J',
323
+ /** Ask the terminal to wrap pasted text in ESC[200~ / ESC[201~ markers */
324
+ BracketedPasteOn: '\x1B[?2004h',
325
+ /** Stop wrapping pasted text in bracketed-paste markers */
326
+ BracketedPasteOff: '\x1B[?2004l',
66
327
  /** Escape code for bold text */
67
328
  Bold: '\x1B[1m',
68
329
  /** Escape code for dim text */
@@ -83,6 +344,12 @@ const Ansi = freeze({
83
344
  /**
84
345
  * Creates a terminal interface for interactive prompts.
85
346
  *
347
+ * The first `readToken`/`readKey` call opens a read session: the input is
348
+ * switched to raw mode for the whole session (restored on `close`),
349
+ * bracketed paste mode is enabled on TTY inputs, and resize events from the
350
+ * output surface as resize tokens. Input chunks are tokenized by a
351
+ * persistent listener so no chunk is lost between reads.
352
+ *
86
353
  * @param config - Terminal configuration options
87
354
  * @returns Terminal interface with read/write methods
88
355
  *
@@ -90,7 +357,7 @@ const Ansi = freeze({
90
357
  * ```typescript
91
358
  * const term = createTerminal()
92
359
  * term.write('Enter name: ')
93
- * const name = await term.readLine()
360
+ * const token = await term.readToken()
94
361
  * term.close()
95
362
  * ```
96
363
  */
@@ -98,7 +365,15 @@ function createTerminal(config = {}) {
98
365
  const input = config.input ?? process.stdin;
99
366
  const output = config.output ?? process.stdout;
100
367
  let cancelled = false;
368
+ let closed = false;
369
+ let sessionActive = false;
370
+ let savedRawMode = false;
101
371
  let rl;
372
+ let pendingWaiter;
373
+ const parser = createTokenParser();
374
+ // why: decoding through StringDecoder keeps multibyte characters intact when a large paste is split across stream chunks mid-code-point
375
+ const decoder = new StringDecoder('utf8');
376
+ const tokenQueue = [];
102
377
  const getReadline = () => {
103
378
  if (!rl) {
104
379
  rl = createInterface({ input, output, terminal: true });
@@ -108,24 +383,77 @@ function createTerminal(config = {}) {
108
383
  const write = (text) => {
109
384
  output.write(text);
110
385
  };
111
- const readKey = () => createPromise((resolve) => {
112
- const wasRaw = input.isRaw;
386
+ const deliver = (tokens) => {
387
+ for (const token of tokens) {
388
+ if (token.type === TokenType.Key && token.value === Key.CtrlC) {
389
+ cancelled = true;
390
+ }
391
+ // why: a resize drag fires many events; consecutive notifications collapse into one so the prompt repaints once per drained batch
392
+ if (token.type === TokenType.Resize && tokenQueue[tokenQueue.length - 1]?.type === TokenType.Resize) {
393
+ continue;
394
+ }
395
+ tokenQueue.push(token);
396
+ }
397
+ if (pendingWaiter !== undefined) {
398
+ const token = tokenQueue.shift();
399
+ if (token !== undefined) {
400
+ const waiter = pendingWaiter;
401
+ pendingWaiter = undefined;
402
+ waiter(token);
403
+ }
404
+ }
405
+ };
406
+ const onData = (data) => {
407
+ const text = decoder.write(data);
408
+ if (text !== '') {
409
+ deliver(parser.feed(text));
410
+ }
411
+ };
412
+ const onResize = () => {
413
+ deliver(freeze([freeze({ type: TokenType.Resize })]));
414
+ };
415
+ const openSession = () => {
416
+ if (sessionActive)
417
+ return;
418
+ sessionActive = true;
419
+ savedRawMode = input.isRaw === true;
113
420
  if (input.setRawMode) {
114
421
  input.setRawMode(true);
422
+ write(Ansi.BracketedPasteOn);
115
423
  }
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
- });
424
+ input.on('data', onData);
425
+ output.on('resize', onResize);
426
+ input.resume();
427
+ };
428
+ const closeSession = () => {
429
+ if (!sessionActive)
430
+ return;
431
+ sessionActive = false;
432
+ input.removeListener('data', onData);
433
+ output.removeListener('resize', onResize);
434
+ if (input.setRawMode) {
435
+ write(Ansi.BracketedPasteOff);
436
+ input.setRawMode(savedRawMode);
437
+ }
438
+ input.pause();
439
+ };
440
+ const readToken = () => {
441
+ openSession();
442
+ const queued = tokenQueue.shift();
443
+ if (queued !== undefined) {
444
+ return promiseResolve(queued);
445
+ }
446
+ return createPromise((resolve) => {
447
+ pendingWaiter = resolve;
448
+ });
449
+ };
450
+ const readKey = async () => {
451
+ let token = await readToken();
452
+ while (token.type === TokenType.Resize) {
453
+ token = await readToken();
454
+ }
455
+ return token.value;
456
+ };
129
457
  const readLine = () => createPromise((resolve) => {
130
458
  const readline = getReadline();
131
459
  readline.once('line', (line) => {
@@ -146,7 +474,15 @@ function createTerminal(config = {}) {
146
474
  }
147
475
  }
148
476
  };
477
+ const getSize = () => freeze({
478
+ columns: output.columns > 0 ? output.columns : 80,
479
+ rows: output.rows > 0 ? output.rows : 24,
480
+ });
149
481
  const close = () => {
482
+ if (closed)
483
+ return;
484
+ closed = true;
485
+ closeSession();
150
486
  if (rl) {
151
487
  rl.close();
152
488
  rl = undefined;
@@ -156,8 +492,10 @@ function createTerminal(config = {}) {
156
492
  return freeze({
157
493
  write,
158
494
  readKey,
495
+ readToken,
159
496
  readLine,
160
497
  clearLines,
498
+ getSize,
161
499
  close,
162
500
  isCancelled: () => cancelled,
163
501
  cancel: () => {
@@ -284,6 +622,139 @@ function renderCancelled() {
284
622
  return style.dim('(cancelled)');
285
623
  }
286
624
 
625
+ /**
626
+ * Hard-wraps one logical line into physical rows no wider than `width`
627
+ * display columns. ANSI escape sequences are zero-width and never split;
628
+ * surrogate pairs travel together.
629
+ *
630
+ * @param line - Logical line, possibly containing ANSI sequences
631
+ * @param width - Maximum display columns per row (at least 1)
632
+ * @returns Physical rows covering the line (always at least one row)
633
+ *
634
+ * @example Wrapping a long line
635
+ * ```typescript
636
+ * wrapLine('abcdefghij', 4)
637
+ * // => ['abcd', 'efgh', 'ij']
638
+ * ```
639
+ */
640
+ function wrapLine(line, width) {
641
+ const rows = [];
642
+ let current = '';
643
+ let col = 0;
644
+ let i = 0;
645
+ while (i < line.length) {
646
+ if (line.charAt(i) === Esc) {
647
+ const { length } = matchAnsiSequence(line, i);
648
+ current += line.slice(i, i + length);
649
+ i += length;
650
+ continue;
651
+ }
652
+ if (col === width) {
653
+ rows.push(current);
654
+ current = '';
655
+ col = 0;
656
+ }
657
+ const charCode = line.charCodeAt(i);
658
+ // why: keep surrogate pairs on the same row so code points never split
659
+ const charLength = charCode >= 0xd800 && charCode <= 0xdbff ? 2 : 1;
660
+ current += line.slice(i, i + charLength);
661
+ col++;
662
+ i += charLength;
663
+ }
664
+ rows.push(current);
665
+ return freeze(rows);
666
+ }
667
+ /**
668
+ * Creates a frame renderer bound to a terminal.
669
+ *
670
+ * @param terminal - Terminal used for size queries and output
671
+ * @returns Screen renderer
672
+ *
673
+ * @example Repainting a prompt line with a parked cursor
674
+ * ```typescript
675
+ * const screen = createScreen(terminal)
676
+ * screen.render({ lines: ['? Name: Jo'], cursor: { line: 0, col: 10 } })
677
+ * screen.render({ lines: ['? Name: Joe'], cursor: { line: 0, col: 11 } })
678
+ * ```
679
+ */
680
+ function createScreen(terminal) {
681
+ let lastRows = freeze([]);
682
+ let lastWidth = 0;
683
+ let lastCursorRow = 0;
684
+ let lastCursorCol = 0;
685
+ const reflowedCursorRow = (width) => {
686
+ let row = 0;
687
+ for (const paintedRow of lastRows.slice(0, lastCursorRow)) {
688
+ row += max(1, ceil(displayWidth(paintedRow) / width));
689
+ }
690
+ return row + floor(lastCursorCol / width);
691
+ };
692
+ const eraseLastFrame = (width, viewportRows) => {
693
+ if (lastRows.length === 0)
694
+ return;
695
+ // why: on a width change the terminal reflowed the old paint, so the cursor's row offset is recomputed against the new width
696
+ const cursorRow = width === lastWidth ? lastCursorRow : reflowedCursorRow(width);
697
+ // why: travel is capped at the viewport height so a reflow estimate can never climb past the frame into content above it
698
+ const travelRows = min(cursorRow, viewportRows - 1);
699
+ let travel = Ansi.CursorStart;
700
+ if (travelRows > 0)
701
+ travel += Ansi.cursorUp(travelRows);
702
+ terminal.write(travel + Ansi.ClearToEnd);
703
+ };
704
+ const resolveCursor = (frame, rowsPerLine, lastRowText, totalRows, width) => {
705
+ if (frame.cursor === undefined) {
706
+ return freeze({ row: totalRows - 1, col: displayWidth(lastRowText) });
707
+ }
708
+ let row = 0;
709
+ for (const count of rowsPerLine.slice(0, frame.cursor.line)) {
710
+ row += count;
711
+ }
712
+ let extraRows = floor(frame.cursor.col / width);
713
+ let col = frame.cursor.col - extraRows * width;
714
+ if (col === 0 && frame.cursor.col > 0) {
715
+ // why: a cursor at an exact wrap boundary parks at the end of the previous physical row rather than on an unpainted row
716
+ extraRows -= 1;
717
+ col = width;
718
+ }
719
+ return freeze({ row: row + extraRows, col });
720
+ };
721
+ const render = (frame) => {
722
+ const size = terminal.getSize();
723
+ const width = max(1, size.columns);
724
+ const viewportRows = max(1, size.rows);
725
+ eraseLastFrame(width, viewportRows);
726
+ const wrappedRows = [];
727
+ const rowsPerLine = [];
728
+ let lastRowText = '';
729
+ for (const line of frame.lines) {
730
+ const wrapped = wrapLine(line, width);
731
+ rowsPerLine.push(wrapped.length);
732
+ for (const row of wrapped) {
733
+ wrappedRows.push(row);
734
+ lastRowText = row;
735
+ }
736
+ }
737
+ // 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
738
+ const dropped = max(0, wrappedRows.length - viewportRows);
739
+ const physicalRows = dropped > 0 ? wrappedRows.slice(dropped) : wrappedRows;
740
+ terminal.write(physicalRows.join('\n'));
741
+ const rawCursor = resolveCursor(frame, rowsPerLine, lastRowText, wrappedRows.length, width);
742
+ const cursor = rawCursor.row >= dropped ? freeze({ row: rawCursor.row - dropped, col: rawCursor.col }) : freeze({ row: 0, col: 0 });
743
+ let travel = Ansi.CursorStart;
744
+ const rowsUp = physicalRows.length - 1 - cursor.row;
745
+ if (rowsUp > 0)
746
+ travel += Ansi.cursorUp(rowsUp);
747
+ if (cursor.col > 0)
748
+ travel += Ansi.cursorRight(cursor.col);
749
+ terminal.write(travel);
750
+ lastRows = freeze(physicalRows);
751
+ lastWidth = width;
752
+ lastCursorRow = cursor.row;
753
+ lastCursorCol = cursor.col;
754
+ };
755
+ return freeze({ render });
756
+ }
757
+
287
758
  /**
288
759
  * Core types for terminal prompts.
289
760
  *
@@ -315,11 +786,36 @@ function renderOptions(initial) {
315
786
  }
316
787
  return style.dim('(y/n)');
317
788
  }
789
+ /**
790
+ * Interprets a key or paste token as a yes/no answer. Keys accept `y`/`n`
791
+ * (any case); pastes accept a trimmed, lowercased `y`/`yes`/`n`/`no`.
792
+ *
793
+ * @internal
794
+ * @param token - Token to interpret
795
+ * @returns The boolean answer, or undefined when the token is not one
796
+ */
797
+ function parseAnswer(token) {
798
+ const normalized = token.value.trim().toLowerCase();
799
+ if (token.type === TokenType.Paste) {
800
+ if (normalized === 'y' || normalized === 'yes')
801
+ return true;
802
+ if (normalized === 'n' || normalized === 'no')
803
+ return false;
804
+ return undefined;
805
+ }
806
+ if (normalized === 'y')
807
+ return true;
808
+ if (normalized === 'n')
809
+ return false;
810
+ return undefined;
811
+ }
318
812
  /**
319
813
  * Prompts for yes/no confirmation.
320
814
  *
321
815
  * 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.
816
+ * Supports default values and responds to y/Y/n/N keys. A pasted
817
+ * `y`/`yes`/`n`/`no` (trimmed, case-insensitive) is accepted; any other
818
+ * paste is ignored. The prompt repaints on terminal resize.
323
819
  *
324
820
  * @param config - Confirm prompt configuration
325
821
  * @returns Promise resolving to boolean value or cancellation
@@ -342,40 +838,107 @@ function renderOptions(initial) {
342
838
  */
343
839
  async function confirm(config) {
344
840
  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) + ' ');
841
+ const screen = createScreen(term);
842
+ const finish = (value) => {
843
+ screen.render(freeze({ lines: freeze([renderMessage(config.message) + renderSubmitted(value ? 'Yes' : 'No')]) }));
844
+ term.write('\n');
845
+ return freeze({ result: PromptResult.Submitted, value });
348
846
  };
349
- const drawResult = (value) => {
350
- term.write(Ansi.CursorStart + Ansi.ClearLine);
351
- term.write(renderMessage(config.message) + renderSubmitted(value ? 'Yes' : 'No') + '\n');
847
+ const redraw = () => {
848
+ screen.render(freeze({ lines: freeze([renderMessage(config.message) + renderOptions(config.initial) + ' ']) }));
352
849
  };
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 });
362
- }
363
- if (lowerKey === 'y') {
364
- drawResult(true);
365
- term.close();
366
- return freeze({ result: PromptResult.Submitted, value: true });
850
+ try {
851
+ redraw();
852
+ while (true) {
853
+ const token = await term.readToken();
854
+ if (term.isCancelled()) {
855
+ screen.render(freeze({ lines: freeze([renderMessage(config.message) + renderCancelled()]) }));
856
+ term.write('\n');
857
+ return freeze({ result: PromptResult.Cancelled, value: undefined });
858
+ }
859
+ if (token.type === TokenType.Resize) {
860
+ redraw();
861
+ continue;
862
+ }
863
+ const answer = parseAnswer(token);
864
+ if (answer !== undefined) {
865
+ return finish(answer);
866
+ }
867
+ if (token.value === Key.Enter && config.initial !== undefined) {
868
+ return finish(config.initial);
869
+ }
367
870
  }
368
- if (lowerKey === 'n') {
369
- drawResult(false);
370
- term.close();
371
- return freeze({ result: PromptResult.Submitted, value: false });
871
+ }
872
+ finally {
873
+ term.close();
874
+ }
875
+ }
876
+
877
+ /**
878
+ * Sanitizers for pasted text before it enters prompt state.
879
+ *
880
+ * @internal
881
+ */
882
+ /**
883
+ * Sanitizes pasted text for insertion into a single-line input: interior
884
+ * runs of `\r\n`, `\r`, or `\n` collapse into a single space, leading and
885
+ * trailing newline runs are dropped, and all other C0 control characters
886
+ * (plus DEL) are removed. Pasting therefore never submits or triggers
887
+ * prompt actions.
888
+ *
889
+ * @param raw - Raw pasted text
890
+ * @returns Sanitized single-line text (possibly empty)
891
+ *
892
+ * @example Collapsing a multi-line paste
893
+ * ```typescript
894
+ * sanitizePasteText('first\r\nsecond\n')
895
+ * // => 'first second'
896
+ * ```
897
+ */
898
+ function sanitizePasteText(raw) {
899
+ let out = '';
900
+ let pendingBreak = false;
901
+ for (const char of raw) {
902
+ if (char === '\r' || char === '\n') {
903
+ pendingBreak = true;
904
+ continue;
372
905
  }
373
- if (key === Key.Enter && config.initial !== undefined) {
374
- drawResult(config.initial);
375
- term.close();
376
- return freeze({ result: PromptResult.Submitted, value: config.initial });
906
+ const code = char.charCodeAt(0);
907
+ if (code < 0x20 || code === 0x7f)
908
+ continue;
909
+ if (pendingBreak && out.length > 0) {
910
+ out += ' ';
377
911
  }
912
+ pendingBreak = false;
913
+ out += char;
914
+ }
915
+ return out;
916
+ }
917
+ /**
918
+ * Extracts the first line of a paste for search-query style inputs: content
919
+ * is cut at the first `\r` or `\n`, then remaining C0 control characters
920
+ * (plus DEL) are removed.
921
+ *
922
+ * @param raw - Raw pasted text
923
+ * @returns Printable characters of the first pasted line
924
+ *
925
+ * @example Keeping only the first pasted line
926
+ * ```typescript
927
+ * firstPasteLine('query\nsecond line')
928
+ * // => 'query'
929
+ * ```
930
+ */
931
+ function firstPasteLine(raw) {
932
+ let out = '';
933
+ for (const char of raw) {
934
+ if (char === '\r' || char === '\n')
935
+ break;
936
+ const code = char.charCodeAt(0);
937
+ if (code < 0x20 || code === 0x7f)
938
+ continue;
939
+ out += char;
378
940
  }
941
+ return out;
379
942
  }
380
943
 
381
944
  /**
@@ -482,72 +1045,75 @@ function renderChoice$1(choice, isSelected, isFocused) {
482
1045
  return `${pointer} ${checkbox} ${label}${hint}`;
483
1046
  }
484
1047
  /**
485
- * Renders the multiselect prompt to the terminal.
1048
+ * Builds the frame lines for the multiselect prompt.
486
1049
  *
487
1050
  * @internal
488
- * @param term - Terminal interface
489
1051
  * @param config - Prompt configuration
490
1052
  * @param state - Current prompt state
491
- * @param submitted - Whether the prompt has been submitted
492
- * @returns Number of lines rendered
1053
+ * @param maxVisible - Maximum number of visible choices
1054
+ * @returns Logical lines describing the prompt
493
1055
  */
494
- function render$2(term, config, state, submitted) {
495
- const maxVisible = config.maxVisible ?? 10;
1056
+ function buildLines$1(config, state, maxVisible) {
496
1057
  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
- }
1058
+ const lines = [];
1059
+ let header = renderMessage(config.message);
504
1060
  if (config.searchable && state.searchQuery) {
505
- output += style.cyan(state.searchQuery) + style.dim(' (type to filter)');
1061
+ header += style.cyan(state.searchQuery) + style.dim(' (type to filter)');
506
1062
  }
507
1063
  else if (config.searchable) {
508
- output += style.dim('(type to filter, space to toggle, enter to submit)');
1064
+ header += style.dim('(type to filter, space to toggle, enter to submit)');
509
1065
  }
510
1066
  else {
511
- output += style.dim('(space to toggle, enter to submit)');
1067
+ header += style.dim('(space to toggle, enter to submit)');
512
1068
  }
513
- term.write(output + '\n');
514
- let lineCount = 1;
1069
+ lines.push(header);
515
1070
  const minMax = [];
516
1071
  if (config.min !== undefined)
517
1072
  minMax.push(`min: ${config.min}`);
518
1073
  if (config.max !== undefined)
519
1074
  minMax.push(`max: ${config.max}`);
520
1075
  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++;
1076
+ lines.push(style.dim(` ${state.selected.length} selected${countHint}`));
1077
+ if (startIndex > 0) {
1078
+ lines.push(style.dim(` ${Symbol.Ellipsis} (${startIndex} more above)`));
528
1079
  }
529
1080
  visibleIndices.forEach((actualIndex, i) => {
530
1081
  const choice = state.choices[actualIndex];
531
1082
  /* istanbul ignore if -- @preserve defensive: actualIndex always valid from filteredIndices */
532
1083
  if (!choice)
533
1084
  return;
534
- const viewIndex = startIndex + i;
535
- const isFocused = viewIndex === state.cursor;
1085
+ const isFocused = startIndex + i === state.cursor;
536
1086
  const isSelected = arrayIncludes(state.selected, actualIndex);
537
- const line = renderChoice$1(choice, isSelected, isFocused);
538
- term.write(Ansi.ClearLine + line + '\n');
539
- lineCount++;
1087
+ lines.push(renderChoice$1(choice, isSelected, isFocused));
540
1088
  });
541
- if (showScrollDown) {
1089
+ if (startIndex + maxVisible < state.filteredIndices.length) {
542
1090
  const remaining = state.filteredIndices.length - (startIndex + maxVisible);
543
- term.write(Ansi.ClearLine + style.dim(` ${Symbol.Ellipsis} (${remaining} more below)`) + '\n');
544
- lineCount++;
1091
+ lines.push(style.dim(` ${Symbol.Ellipsis} (${remaining} more below)`));
545
1092
  }
546
1093
  if (state.filteredIndices.length === 0 && config.searchable) {
547
- term.write(Ansi.ClearLine + style.dim(' No matches found') + '\n');
548
- lineCount++;
1094
+ lines.push(style.dim(' No matches found'));
549
1095
  }
550
- return lineCount;
1096
+ return freeze(lines);
1097
+ }
1098
+ /**
1099
+ * Appends text to the search query, re-filtering choices.
1100
+ *
1101
+ * @internal
1102
+ * @param query - Text to append (a typed character or pasted line)
1103
+ * @param state - Current prompt state
1104
+ * @returns Updated state with the new query applied
1105
+ */
1106
+ function appendSearch$1(query, state) {
1107
+ if (query === '')
1108
+ return state;
1109
+ const newQuery = state.searchQuery + query;
1110
+ return freeze({
1111
+ ...state,
1112
+ searchQuery: newQuery,
1113
+ filteredIndices: filterChoices$1(state.choices, newQuery),
1114
+ cursor: 0,
1115
+ scrollOffset: 0,
1116
+ });
551
1117
  }
552
1118
  /**
553
1119
  * Processes a keypress and returns updated state.
@@ -556,10 +1122,10 @@ function render$2(term, config, state, submitted) {
556
1122
  * @param key - The key that was pressed
557
1123
  * @param state - Current prompt state
558
1124
  * @param config - Prompt configuration
1125
+ * @param maxVisible - Maximum number of visible choices
559
1126
  * @returns Updated state after processing the key
560
1127
  */
561
- function processKey$2(key, state, config) {
562
- const maxVisible = config.maxVisible ?? 10;
1128
+ function processKey$2(key, state, config, maxVisible) {
563
1129
  const total = state.filteredIndices.length;
564
1130
  if (total === 0 && key !== Key.Backspace && key !== '\b')
565
1131
  return state;
@@ -626,15 +1192,7 @@ function processKey$2(key, state, config) {
626
1192
  return state;
627
1193
  }
628
1194
  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
- });
1195
+ return appendSearch$1(key, state);
638
1196
  }
639
1197
  }
640
1198
  return state;
@@ -656,11 +1214,26 @@ function validateSelection(state, config) {
656
1214
  }
657
1215
  return undefined;
658
1216
  }
1217
+ /**
1218
+ * Resolves the visible-window size, capped so the frame fits the terminal
1219
+ * height (header and scroll-indicator rows reserved).
1220
+ *
1221
+ * @internal
1222
+ * @param term - Terminal used for size queries
1223
+ * @param configured - Configured maximum visible choices
1224
+ * @returns Effective maximum visible choices (at least 1)
1225
+ */
1226
+ function effectiveMaxVisible$1(term, configured) {
1227
+ return max(1, min(configured ?? 10, term.getSize().rows - 4));
1228
+ }
659
1229
  /**
660
1230
  * Prompts for multiple selections from a list of choices.
661
1231
  *
662
1232
  * Pure functional prompt with arrow key navigation, space to toggle,
663
- * scrolling support, min/max constraints, and optional type-to-filter search.
1233
+ * scrolling support, min/max constraints, and optional type-to-filter
1234
+ * search. In searchable mode, pasted text appends its first line to the
1235
+ * filter query; pasting never toggles or submits. The prompt repaints on
1236
+ * terminal resize, preserving cursor, selection, and scroll state.
664
1237
  *
665
1238
  * @param config - Multiselect prompt configuration
666
1239
  * @returns Promise resolving to array of selected values or cancellation
@@ -702,55 +1275,59 @@ function validateSelection(state, config) {
702
1275
  */
703
1276
  async function multiselect(config) {
704
1277
  const term = createTerminal({ input: config.input, output: config.output });
1278
+ const screen = createScreen(term);
705
1279
  let state = createInitialState$2(config);
706
- let lineCount = 0;
707
1280
  let errorMessage;
708
1281
  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++;
1282
+ const redraw = () => {
1283
+ const lines = [...buildLines$1(config, state, effectiveMaxVisible$1(term, config.maxVisible))];
1284
+ if (errorMessage) {
1285
+ lines.push(style.yellow(` ${errorMessage}`));
718
1286
  }
1287
+ screen.render(freeze({ lines: freeze(lines) }));
719
1288
  };
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);
1289
+ try {
1290
+ redraw();
1291
+ while (true) {
1292
+ const token = await term.readToken();
1293
+ if (term.isCancelled()) {
1294
+ screen.render(freeze({ lines: freeze([renderMessage(config.message) + renderCancelled()]) }));
1295
+ term.write('\n' + Ansi.ShowCursor);
1296
+ return freeze({ result: PromptResult.Cancelled, value: undefined });
726
1297
  }
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;
1298
+ if (token.type === TokenType.Resize) {
737
1299
  redraw();
738
1300
  continue;
739
1301
  }
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);
1302
+ if (token.type === TokenType.Paste) {
1303
+ if (config.searchable) {
1304
+ errorMessage = undefined;
1305
+ state = appendSearch$1(firstPasteLine(token.value), state);
1306
+ redraw();
1307
+ }
1308
+ continue;
1309
+ }
1310
+ if (token.value === Key.Enter) {
1311
+ const validationError = validateSelection(state, config);
1312
+ if (validationError) {
1313
+ errorMessage = validationError;
1314
+ redraw();
1315
+ continue;
1316
+ }
1317
+ const selectedValues = state.selected.map((i) => state.choices[i]?.value).filter((v) => v !== undefined);
1318
+ const selectedLabels = state.selected.map((i) => state.choices[i]?.label ?? '').join(', ');
1319
+ const submittedLine = renderMessage(config.message) + renderSubmitted(selectedLabels || 'none');
1320
+ screen.render(freeze({ lines: freeze([submittedLine]) }));
1321
+ term.write('\n' + Ansi.ShowCursor);
1322
+ return freeze({ result: PromptResult.Submitted, value: freeze(selectedValues) });
743
1323
  }
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) });
1324
+ errorMessage = undefined;
1325
+ state = processKey$2(token.value, state, config, effectiveMaxVisible$1(term, config.maxVisible));
1326
+ redraw();
750
1327
  }
751
- errorMessage = undefined;
752
- state = processKey$2(key, state, config);
753
- redraw();
1328
+ }
1329
+ finally {
1330
+ term.close();
754
1331
  }
755
1332
  }
756
1333
 
@@ -837,65 +1414,66 @@ function renderChoice(choice, isFocused) {
837
1414
  return `${pointer} ${label}${hint}`;
838
1415
  }
839
1416
  /**
840
- * Renders the select prompt to the terminal.
1417
+ * Builds the frame lines for the select prompt.
841
1418
  *
842
1419
  * @internal
843
- * @param term - Terminal interface
844
1420
  * @param config - Prompt configuration
845
1421
  * @param state - Current prompt state
846
- * @param submitted - Whether the prompt has been submitted
847
- * @returns Number of lines rendered
1422
+ * @param maxVisible - Maximum number of visible choices
1423
+ * @returns Logical lines describing the prompt
848
1424
  */
849
- function render$1(term, config, state, submitted) {
850
- const maxVisible = config.maxVisible ?? 10;
1425
+ function buildLines(config, state, maxVisible) {
851
1426
  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
- }
1427
+ const lines = [];
1428
+ let header = renderMessage(config.message);
861
1429
  if (config.searchable && state.searchQuery) {
862
- output += style.cyan(state.searchQuery) + style.dim(' (type to filter)');
1430
+ header += style.cyan(state.searchQuery) + style.dim(' (type to filter)');
863
1431
  }
864
1432
  else if (config.searchable) {
865
- output += style.dim('(type to filter, enter to select)');
1433
+ header += style.dim('(type to filter, enter to select)');
866
1434
  }
867
1435
  else {
868
- output += style.dim('(use arrows, enter to select)');
1436
+ header += style.dim('(use arrows, enter to select)');
869
1437
  }
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++;
1438
+ lines.push(header);
1439
+ if (startIndex > 0) {
1440
+ lines.push(style.dim(` ${Symbol.Ellipsis} (${startIndex} more above)`));
877
1441
  }
878
1442
  visibleIndices.forEach((actualIndex, i) => {
879
1443
  const choice = state.choices[actualIndex];
880
1444
  /* istanbul ignore if -- @preserve defensive: actualIndex always valid from filteredIndices */
881
1445
  if (!choice)
882
1446
  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++;
1447
+ lines.push(renderChoice(choice, startIndex + i === state.cursor));
888
1448
  });
889
- if (showScrollDown) {
1449
+ if (startIndex + maxVisible < state.filteredIndices.length) {
890
1450
  const remaining = state.filteredIndices.length - (startIndex + maxVisible);
891
- term.write(Ansi.ClearLine + style.dim(` ${Symbol.Ellipsis} (${remaining} more below)`) + '\n');
892
- lineCount++;
1451
+ lines.push(style.dim(` ${Symbol.Ellipsis} (${remaining} more below)`));
893
1452
  }
894
1453
  if (state.filteredIndices.length === 0 && config.searchable) {
895
- term.write(Ansi.ClearLine + style.dim(' No matches found') + '\n');
896
- lineCount++;
1454
+ lines.push(style.dim(' No matches found'));
897
1455
  }
898
- return lineCount;
1456
+ return freeze(lines);
1457
+ }
1458
+ /**
1459
+ * Appends text to the search query, re-filtering choices.
1460
+ *
1461
+ * @internal
1462
+ * @param query - Text to append (a typed character or pasted line)
1463
+ * @param state - Current prompt state
1464
+ * @returns Updated state with the new query applied
1465
+ */
1466
+ function appendSearch(query, state) {
1467
+ if (query === '')
1468
+ return state;
1469
+ const newQuery = state.searchQuery + query;
1470
+ return freeze({
1471
+ ...state,
1472
+ searchQuery: newQuery,
1473
+ filteredIndices: filterChoices(state.choices, newQuery),
1474
+ cursor: 0,
1475
+ scrollOffset: 0,
1476
+ });
899
1477
  }
900
1478
  /**
901
1479
  * Processes a keypress and returns updated state.
@@ -904,10 +1482,10 @@ function render$1(term, config, state, submitted) {
904
1482
  * @param key - The key that was pressed
905
1483
  * @param state - Current prompt state
906
1484
  * @param config - Prompt configuration
1485
+ * @param maxVisible - Maximum number of visible choices
907
1486
  * @returns Updated state after processing the key
908
1487
  */
909
- function processKey$1(key, state, config) {
910
- const maxVisible = config.maxVisible ?? 10;
1488
+ function processKey$1(key, state, config, maxVisible) {
911
1489
  const total = state.filteredIndices.length;
912
1490
  if (total === 0 && key !== Key.Backspace && key !== '\b')
913
1491
  return state;
@@ -953,15 +1531,7 @@ function processKey$1(key, state, config) {
953
1531
  return state;
954
1532
  }
955
1533
  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
- });
1534
+ return appendSearch(key, state);
965
1535
  }
966
1536
  }
967
1537
  return state;
@@ -970,7 +1540,10 @@ function processKey$1(key, state, config) {
970
1540
  * Prompts for single selection from a list of choices.
971
1541
  *
972
1542
  * Pure functional prompt with arrow key navigation, scrolling support,
973
- * optional disabled choices, and optional type-to-filter search.
1543
+ * optional disabled choices, and optional type-to-filter search. In
1544
+ * searchable mode, pasted text appends its first line to the filter query.
1545
+ * The prompt repaints on terminal resize, preserving cursor and scroll
1546
+ * state.
974
1547
  *
975
1548
  * @param config - Select prompt configuration
976
1549
  * @returns Promise resolving to selected value or cancellation
@@ -1014,52 +1587,66 @@ function processKey$1(key, state, config) {
1014
1587
  */
1015
1588
  async function select(config) {
1016
1589
  const term = createTerminal({ input: config.input, output: config.output });
1590
+ const screen = createScreen(term);
1017
1591
  let state = createInitialState$1(config);
1018
- let lineCount = 0;
1019
1592
  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);
1593
+ const redraw = () => {
1594
+ screen.render(freeze({ lines: buildLines(config, state, effectiveMaxVisible(term, config.maxVisible)) }));
1026
1595
  };
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);
1596
+ try {
1597
+ redraw();
1598
+ while (true) {
1599
+ const token = await term.readToken();
1600
+ if (term.isCancelled()) {
1601
+ screen.render(freeze({ lines: freeze([renderMessage(config.message) + renderCancelled()]) }));
1602
+ term.write('\n' + Ansi.ShowCursor);
1603
+ return freeze({ result: PromptResult.Cancelled, value: undefined });
1033
1604
  }
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) {
1605
+ if (token.type === TokenType.Resize) {
1606
+ redraw();
1043
1607
  continue;
1044
1608
  }
1045
- const selectedChoice = state.choices[actualIndex];
1046
- if (!selectedChoice || selectedChoice.disabled) {
1609
+ if (token.type === TokenType.Paste) {
1610
+ if (config.searchable) {
1611
+ state = appendSearch(firstPasteLine(token.value), state);
1612
+ redraw();
1613
+ }
1047
1614
  continue;
1048
1615
  }
1049
- if (lineCount > 0) {
1050
- term.write(Ansi.cursorUp(lineCount) + Ansi.CursorStart);
1616
+ if (token.value === Key.Enter) {
1617
+ const actualIndex = state.filteredIndices[state.cursor];
1618
+ if (actualIndex === undefined) {
1619
+ continue;
1620
+ }
1621
+ const selectedChoice = state.choices[actualIndex];
1622
+ if (!selectedChoice || selectedChoice.disabled) {
1623
+ continue;
1624
+ }
1625
+ const submittedLine = renderMessage(config.message) + renderSubmitted(selectedChoice.label);
1626
+ screen.render(freeze({ lines: freeze([submittedLine]) }));
1627
+ term.write('\n' + Ansi.ShowCursor);
1628
+ return freeze({ result: PromptResult.Submitted, value: selectedChoice.value });
1051
1629
  }
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 });
1630
+ state = processKey$1(token.value, state, config, effectiveMaxVisible(term, config.maxVisible));
1631
+ redraw();
1058
1632
  }
1059
- state = processKey$1(key, state, config);
1060
- redraw();
1633
+ }
1634
+ finally {
1635
+ term.close();
1061
1636
  }
1062
1637
  }
1638
+ /**
1639
+ * Resolves the visible-window size, capped so the frame fits the terminal
1640
+ * height (header and scroll-indicator rows reserved).
1641
+ *
1642
+ * @internal
1643
+ * @param term - Terminal used for size queries
1644
+ * @param configured - Configured maximum visible choices
1645
+ * @returns Effective maximum visible choices (at least 1)
1646
+ */
1647
+ function effectiveMaxVisible(term, configured) {
1648
+ return max(1, min(configured ?? 10, term.getSize().rows - 4));
1649
+ }
1063
1650
 
1064
1651
  /**
1065
1652
  * Creates initial state for text prompt.
@@ -1076,40 +1663,63 @@ function createInitialState(config) {
1076
1663
  };
1077
1664
  }
1078
1665
  /**
1079
- * Renders the text prompt to the terminal.
1666
+ * Builds the current prompt message text.
1667
+ *
1668
+ * @internal
1669
+ * @param config - Prompt configuration
1670
+ * @param value - Current input value
1671
+ * @returns Message text for the current value
1672
+ */
1673
+ function messageText(config, value) {
1674
+ return config.renderMessage ? config.renderMessage(value) : config.message;
1675
+ }
1676
+ /**
1677
+ * Builds the frame for the text prompt.
1080
1678
  *
1081
1679
  * @internal
1082
- * @param term - Terminal interface for output
1083
1680
  * @param config - Prompt configuration
1084
1681
  * @param state - Current prompt state
1085
1682
  * @param submitted - Whether the prompt has been submitted
1086
- * @returns Number of lines rendered
1683
+ * @returns Frame describing lines and cursor position
1087
1684
  */
1088
- function render(term, config, state, submitted) {
1685
+ function buildFrame(config, state, submitted) {
1089
1686
  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;
1687
+ let line = renderMessage(messageText(config, state.value));
1093
1688
  if (submitted) {
1094
- output += renderSubmitted(displayValue || config.initial || '');
1689
+ line += renderSubmitted(displayValue || config.initial || '');
1690
+ return freeze({ lines: freeze([line]) });
1095
1691
  }
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
- }
1692
+ line += displayValue;
1693
+ let trailingWidth = displayWidth(state.value.slice(state.cursorPos));
1694
+ if (config.initial && !state.value) {
1695
+ line += style.dim(config.initial);
1696
+ trailingWidth += displayWidth(config.initial);
1103
1697
  }
1104
- term.write(Ansi.CursorStart + Ansi.ClearLine + output);
1698
+ const cursor = freeze({ line: 0, col: displayWidth(line) - trailingWidth });
1105
1699
  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));
1700
+ return freeze({ lines: freeze([line, style.yellow(` ${state.error}`)]), cursor });
1111
1701
  }
1112
- return 1;
1702
+ return freeze({ lines: freeze([line]), cursor });
1703
+ }
1704
+ /**
1705
+ * Inserts text at the cursor position, moving the cursor past it.
1706
+ *
1707
+ * @internal
1708
+ * @param insert - Text to insert (already sanitized for pastes)
1709
+ * @param state - Current prompt state
1710
+ * @returns Updated state after insertion
1711
+ */
1712
+ function insertText(insert, state) {
1713
+ if (insert === '')
1714
+ return state;
1715
+ const before = state.value.slice(0, state.cursorPos);
1716
+ const after = state.value.slice(state.cursorPos);
1717
+ return {
1718
+ ...state,
1719
+ value: before + insert + after,
1720
+ cursorPos: state.cursorPos + insert.length,
1721
+ error: undefined,
1722
+ };
1113
1723
  }
1114
1724
  /**
1115
1725
  * Processes a keypress and returns updated state.
@@ -1121,14 +1731,7 @@ function render(term, config, state, submitted) {
1121
1731
  */
1122
1732
  function processKey(key, state) {
1123
1733
  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
- };
1734
+ return insertText(key, state);
1132
1735
  }
1133
1736
  if (key === Key.Backspace || key === '\b') {
1134
1737
  if (state.cursorPos > 0) {
@@ -1160,7 +1763,11 @@ function processKey(key, state) {
1160
1763
  * Prompts for text input with optional validation.
1161
1764
  *
1162
1765
  * Pure functional prompt that reads text from the user with support for
1163
- * default values, input validation, and display formatting.
1766
+ * default values, input validation, and display formatting. Pasted text is
1767
+ * sanitized (newlines collapse to spaces, control characters are removed)
1768
+ * and inserted at the cursor without ever auto-submitting. The prompt
1769
+ * repaints on terminal resize, preserving value, cursor, and any
1770
+ * validation error.
1164
1771
  *
1165
1772
  * @param config - Text prompt configuration
1166
1773
  * @returns Promise resolving to submitted value or cancellation
@@ -1194,40 +1801,49 @@ function processKey(key, state) {
1194
1801
  */
1195
1802
  async function text(config) {
1196
1803
  const term = createTerminal({ input: config.input, output: config.output });
1804
+ const screen = createScreen(term);
1197
1805
  let state = createInitialState(config);
1198
- let lineCount = 0;
1199
1806
  const redraw = (submitted = false) => {
1200
- if (lineCount > 0) {
1201
- term.clearLines(lineCount);
1202
- }
1203
- lineCount = render(term, config, state, submitted);
1807
+ screen.render(buildFrame(config, state, submitted));
1204
1808
  };
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;
1809
+ try {
1810
+ redraw();
1811
+ while (true) {
1812
+ const token = await term.readToken();
1813
+ if (term.isCancelled()) {
1814
+ screen.render(freeze({ lines: freeze([renderMessage(messageText(config, state.value)) + renderCancelled()]) }));
1815
+ term.write('\n');
1816
+ return freeze({ result: PromptResult.Cancelled, value: undefined });
1817
+ }
1818
+ if (token.type === TokenType.Resize) {
1819
+ redraw();
1820
+ continue;
1821
+ }
1822
+ if (token.type === TokenType.Paste) {
1823
+ state = insertText(sanitizePasteText(token.value), state);
1824
+ redraw();
1825
+ continue;
1826
+ }
1827
+ if (token.value === Key.Enter) {
1828
+ const value = state.value || config.initial || '';
1829
+ if (config.validate) {
1830
+ const errorMessage = config.validate(value);
1831
+ if (errorMessage) {
1832
+ state = { ...state, error: errorMessage };
1833
+ redraw();
1834
+ continue;
1835
+ }
1222
1836
  }
1837
+ redraw(true);
1838
+ term.write('\n');
1839
+ return freeze({ result: PromptResult.Submitted, value });
1223
1840
  }
1224
- redraw(true);
1225
- term.write('\n');
1226
- term.close();
1227
- return freeze({ result: PromptResult.Submitted, value });
1841
+ state = processKey(token.value, state);
1842
+ redraw();
1228
1843
  }
1229
- state = processKey(key, state);
1230
- redraw();
1844
+ }
1845
+ finally {
1846
+ term.close();
1231
1847
  }
1232
1848
  }
1233
1849