@hyperfrontend/questions 0.2.0 → 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/CHANGELOG.md +13 -1
- package/README.md +14 -5
- package/SECURITY.md +50 -15
- package/_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/math/index.cjs.js +11 -0
- package/_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/math/index.esm.js +7 -0
- package/_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/object/index.cjs.js +5 -0
- package/_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/object/index.esm.js +4 -0
- package/_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/promise/index.cjs.js +8 -0
- package/_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/promise/index.esm.js +6 -0
- package/index.cjs.js +888 -350
- package/index.d.ts +339 -12
- package/index.esm.js +866 -328
- package/package.json +13 -3
- package/index.cjs.js.map +0 -1
- package/index.esm.js.map +0 -1
- package/render.d.ts +0 -120
- package/render.d.ts.map +0 -1
- package/terminal.d.ts +0 -118
- package/terminal.d.ts.map +0 -1
- package/types.d.ts +0 -112
- package/types.d.ts.map +0 -1
package/index.esm.js
CHANGED
|
@@ -1,72 +1,263 @@
|
|
|
1
|
+
import { freeze } from './_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/object/index.esm.js';
|
|
1
2
|
import { createInterface } from 'node:readline';
|
|
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';
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
|
-
*
|
|
8
|
+
* ANSI-aware text measurement helpers.
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
8
13
|
*
|
|
9
|
-
* @
|
|
14
|
+
* @internal
|
|
10
15
|
*/
|
|
11
|
-
|
|
16
|
+
/** Escape character introducing ANSI control sequences. */
|
|
17
|
+
const Esc = '\x1B';
|
|
12
18
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
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
|
|
20
27
|
*
|
|
21
|
-
* @
|
|
28
|
+
* @example Matching an arrow-key sequence
|
|
29
|
+
* ```typescript
|
|
30
|
+
* matchAnsiSequence('\x1B[A', 0)
|
|
31
|
+
* // => { length: 3, complete: true }
|
|
32
|
+
* ```
|
|
22
33
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
+
}
|
|
26
50
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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.
|
|
29
54
|
*
|
|
30
|
-
* @param
|
|
31
|
-
* @
|
|
55
|
+
* @param text - Text containing the sequence
|
|
56
|
+
* @param index - Position of the escape character
|
|
57
|
+
* @returns Sequence length and whether it is complete
|
|
32
58
|
*/
|
|
33
|
-
|
|
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
|
+
}
|
|
34
73
|
/**
|
|
35
|
-
*
|
|
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
|
+
* ```
|
|
36
84
|
*/
|
|
37
|
-
|
|
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
|
+
}
|
|
38
98
|
/**
|
|
39
|
-
*
|
|
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
|
+
* ```
|
|
40
111
|
*/
|
|
41
|
-
|
|
112
|
+
function displayWidth(text) {
|
|
113
|
+
return [...stripAnsi(text)].length;
|
|
114
|
+
}
|
|
115
|
+
|
|
42
116
|
/**
|
|
43
|
-
*
|
|
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.
|
|
123
|
+
*
|
|
124
|
+
* @internal
|
|
44
125
|
*/
|
|
45
|
-
|
|
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~';
|
|
46
132
|
/**
|
|
47
|
-
*
|
|
133
|
+
* Kinds of tokens produced while reading terminal input.
|
|
48
134
|
*/
|
|
49
|
-
|
|
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
|
+
});
|
|
50
143
|
/**
|
|
51
|
-
*
|
|
144
|
+
* Builds a key token.
|
|
145
|
+
*
|
|
146
|
+
* @param value - Key character or escape sequence
|
|
147
|
+
* @returns Frozen key token
|
|
52
148
|
*/
|
|
53
|
-
|
|
149
|
+
function keyToken(value) {
|
|
150
|
+
return freeze({ type: TokenType.Key, value });
|
|
151
|
+
}
|
|
54
152
|
/**
|
|
55
|
-
*
|
|
153
|
+
* Builds a paste token.
|
|
154
|
+
*
|
|
155
|
+
* @param value - Raw pasted text
|
|
156
|
+
* @returns Frozen paste token
|
|
56
157
|
*/
|
|
57
|
-
|
|
158
|
+
function pasteToken(value) {
|
|
159
|
+
return freeze({ type: TokenType.Paste, value });
|
|
160
|
+
}
|
|
58
161
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
61
171
|
*/
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
|
65
181
|
/**
|
|
66
|
-
*
|
|
182
|
+
* Creates an incremental input tokenizer.
|
|
67
183
|
*
|
|
68
|
-
*
|
|
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
|
+
* ```
|
|
69
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
|
+
|
|
70
261
|
/**
|
|
71
262
|
* Key codes for terminal navigation.
|
|
72
263
|
*/
|
|
@@ -112,6 +303,13 @@ const Ansi = freeze({
|
|
|
112
303
|
* @returns ANSI escape sequence string
|
|
113
304
|
*/
|
|
114
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`,
|
|
115
313
|
/** Escape code to hide cursor */
|
|
116
314
|
HideCursor: '\x1B[?25l',
|
|
117
315
|
/** Escape code to show cursor */
|
|
@@ -122,6 +320,10 @@ const Ansi = freeze({
|
|
|
122
320
|
RestoreCursor: '\x1B8',
|
|
123
321
|
/** Clear from cursor to end of screen */
|
|
124
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',
|
|
125
327
|
/** Escape code for bold text */
|
|
126
328
|
Bold: '\x1B[1m',
|
|
127
329
|
/** Escape code for dim text */
|
|
@@ -142,6 +344,12 @@ const Ansi = freeze({
|
|
|
142
344
|
/**
|
|
143
345
|
* Creates a terminal interface for interactive prompts.
|
|
144
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
|
+
*
|
|
145
353
|
* @param config - Terminal configuration options
|
|
146
354
|
* @returns Terminal interface with read/write methods
|
|
147
355
|
*
|
|
@@ -149,7 +357,7 @@ const Ansi = freeze({
|
|
|
149
357
|
* ```typescript
|
|
150
358
|
* const term = createTerminal()
|
|
151
359
|
* term.write('Enter name: ')
|
|
152
|
-
* const
|
|
360
|
+
* const token = await term.readToken()
|
|
153
361
|
* term.close()
|
|
154
362
|
* ```
|
|
155
363
|
*/
|
|
@@ -157,7 +365,15 @@ function createTerminal(config = {}) {
|
|
|
157
365
|
const input = config.input ?? process.stdin;
|
|
158
366
|
const output = config.output ?? process.stdout;
|
|
159
367
|
let cancelled = false;
|
|
368
|
+
let closed = false;
|
|
369
|
+
let sessionActive = false;
|
|
370
|
+
let savedRawMode = false;
|
|
160
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 = [];
|
|
161
377
|
const getReadline = () => {
|
|
162
378
|
if (!rl) {
|
|
163
379
|
rl = createInterface({ input, output, terminal: true });
|
|
@@ -167,24 +383,77 @@ function createTerminal(config = {}) {
|
|
|
167
383
|
const write = (text) => {
|
|
168
384
|
output.write(text);
|
|
169
385
|
};
|
|
170
|
-
const
|
|
171
|
-
const
|
|
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;
|
|
172
420
|
if (input.setRawMode) {
|
|
173
421
|
input.setRawMode(true);
|
|
422
|
+
write(Ansi.BracketedPasteOn);
|
|
174
423
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
};
|
|
188
457
|
const readLine = () => createPromise((resolve) => {
|
|
189
458
|
const readline = getReadline();
|
|
190
459
|
readline.once('line', (line) => {
|
|
@@ -205,7 +474,15 @@ function createTerminal(config = {}) {
|
|
|
205
474
|
}
|
|
206
475
|
}
|
|
207
476
|
};
|
|
477
|
+
const getSize = () => freeze({
|
|
478
|
+
columns: output.columns > 0 ? output.columns : 80,
|
|
479
|
+
rows: output.rows > 0 ? output.rows : 24,
|
|
480
|
+
});
|
|
208
481
|
const close = () => {
|
|
482
|
+
if (closed)
|
|
483
|
+
return;
|
|
484
|
+
closed = true;
|
|
485
|
+
closeSession();
|
|
209
486
|
if (rl) {
|
|
210
487
|
rl.close();
|
|
211
488
|
rl = undefined;
|
|
@@ -215,8 +492,10 @@ function createTerminal(config = {}) {
|
|
|
215
492
|
return freeze({
|
|
216
493
|
write,
|
|
217
494
|
readKey,
|
|
495
|
+
readToken,
|
|
218
496
|
readLine,
|
|
219
497
|
clearLines,
|
|
498
|
+
getSize,
|
|
220
499
|
close,
|
|
221
500
|
isCancelled: () => cancelled,
|
|
222
501
|
cancel: () => {
|
|
@@ -343,6 +622,139 @@ function renderCancelled() {
|
|
|
343
622
|
return style.dim('(cancelled)');
|
|
344
623
|
}
|
|
345
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
|
+
|
|
346
758
|
/**
|
|
347
759
|
* Core types for terminal prompts.
|
|
348
760
|
*
|
|
@@ -374,11 +786,36 @@ function renderOptions(initial) {
|
|
|
374
786
|
}
|
|
375
787
|
return style.dim('(y/n)');
|
|
376
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
|
+
}
|
|
377
812
|
/**
|
|
378
813
|
* Prompts for yes/no confirmation.
|
|
379
814
|
*
|
|
380
815
|
* Pure functional prompt that asks a yes/no question and returns a boolean.
|
|
381
|
-
* 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.
|
|
382
819
|
*
|
|
383
820
|
* @param config - Confirm prompt configuration
|
|
384
821
|
* @returns Promise resolving to boolean value or cancellation
|
|
@@ -401,40 +838,107 @@ function renderOptions(initial) {
|
|
|
401
838
|
*/
|
|
402
839
|
async function confirm(config) {
|
|
403
840
|
const term = createTerminal({ input: config.input, output: config.output });
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
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 });
|
|
407
846
|
};
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
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) + ' ']) }));
|
|
411
849
|
};
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
+
}
|
|
426
870
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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;
|
|
431
905
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
906
|
+
const code = char.charCodeAt(0);
|
|
907
|
+
if (code < 0x20 || code === 0x7f)
|
|
908
|
+
continue;
|
|
909
|
+
if (pendingBreak && out.length > 0) {
|
|
910
|
+
out += ' ';
|
|
436
911
|
}
|
|
912
|
+
pendingBreak = false;
|
|
913
|
+
out += char;
|
|
437
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;
|
|
940
|
+
}
|
|
941
|
+
return out;
|
|
438
942
|
}
|
|
439
943
|
|
|
440
944
|
/**
|
|
@@ -541,72 +1045,75 @@ function renderChoice$1(choice, isSelected, isFocused) {
|
|
|
541
1045
|
return `${pointer} ${checkbox} ${label}${hint}`;
|
|
542
1046
|
}
|
|
543
1047
|
/**
|
|
544
|
-
*
|
|
1048
|
+
* Builds the frame lines for the multiselect prompt.
|
|
545
1049
|
*
|
|
546
1050
|
* @internal
|
|
547
|
-
* @param term - Terminal interface
|
|
548
1051
|
* @param config - Prompt configuration
|
|
549
1052
|
* @param state - Current prompt state
|
|
550
|
-
* @param
|
|
551
|
-
* @returns
|
|
1053
|
+
* @param maxVisible - Maximum number of visible choices
|
|
1054
|
+
* @returns Logical lines describing the prompt
|
|
552
1055
|
*/
|
|
553
|
-
function
|
|
554
|
-
const maxVisible = config.maxVisible ?? 10;
|
|
1056
|
+
function buildLines$1(config, state, maxVisible) {
|
|
555
1057
|
const { indices: visibleIndices, startIndex } = getVisibleChoices$1(state, maxVisible);
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const selectedLabels = state.selected.map((i) => state.choices[i]?.label ?? '').join(', ');
|
|
559
|
-
output += renderSubmitted(selectedLabels || 'none');
|
|
560
|
-
term.write(output);
|
|
561
|
-
return 1;
|
|
562
|
-
}
|
|
1058
|
+
const lines = [];
|
|
1059
|
+
let header = renderMessage(config.message);
|
|
563
1060
|
if (config.searchable && state.searchQuery) {
|
|
564
|
-
|
|
1061
|
+
header += style.cyan(state.searchQuery) + style.dim(' (type to filter)');
|
|
565
1062
|
}
|
|
566
1063
|
else if (config.searchable) {
|
|
567
|
-
|
|
1064
|
+
header += style.dim('(type to filter, space to toggle, enter to submit)');
|
|
568
1065
|
}
|
|
569
1066
|
else {
|
|
570
|
-
|
|
1067
|
+
header += style.dim('(space to toggle, enter to submit)');
|
|
571
1068
|
}
|
|
572
|
-
|
|
573
|
-
let lineCount = 1;
|
|
1069
|
+
lines.push(header);
|
|
574
1070
|
const minMax = [];
|
|
575
1071
|
if (config.min !== undefined)
|
|
576
1072
|
minMax.push(`min: ${config.min}`);
|
|
577
1073
|
if (config.max !== undefined)
|
|
578
1074
|
minMax.push(`max: ${config.max}`);
|
|
579
1075
|
const countHint = minMax.length > 0 ? ` (${minMax.join(', ')})` : '';
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
const showScrollDown = startIndex + maxVisible < state.filteredIndices.length;
|
|
584
|
-
if (showScrollUp) {
|
|
585
|
-
term.write(Ansi.ClearLine + style.dim(` ${Symbol.Ellipsis} (${startIndex} more above)`) + '\n');
|
|
586
|
-
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)`));
|
|
587
1079
|
}
|
|
588
1080
|
visibleIndices.forEach((actualIndex, i) => {
|
|
589
1081
|
const choice = state.choices[actualIndex];
|
|
590
1082
|
/* istanbul ignore if -- @preserve defensive: actualIndex always valid from filteredIndices */
|
|
591
1083
|
if (!choice)
|
|
592
1084
|
return;
|
|
593
|
-
const
|
|
594
|
-
const isFocused = viewIndex === state.cursor;
|
|
1085
|
+
const isFocused = startIndex + i === state.cursor;
|
|
595
1086
|
const isSelected = arrayIncludes(state.selected, actualIndex);
|
|
596
|
-
|
|
597
|
-
term.write(Ansi.ClearLine + line + '\n');
|
|
598
|
-
lineCount++;
|
|
1087
|
+
lines.push(renderChoice$1(choice, isSelected, isFocused));
|
|
599
1088
|
});
|
|
600
|
-
if (
|
|
1089
|
+
if (startIndex + maxVisible < state.filteredIndices.length) {
|
|
601
1090
|
const remaining = state.filteredIndices.length - (startIndex + maxVisible);
|
|
602
|
-
|
|
603
|
-
lineCount++;
|
|
1091
|
+
lines.push(style.dim(` ${Symbol.Ellipsis} (${remaining} more below)`));
|
|
604
1092
|
}
|
|
605
1093
|
if (state.filteredIndices.length === 0 && config.searchable) {
|
|
606
|
-
|
|
607
|
-
lineCount++;
|
|
1094
|
+
lines.push(style.dim(' No matches found'));
|
|
608
1095
|
}
|
|
609
|
-
return
|
|
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
|
+
});
|
|
610
1117
|
}
|
|
611
1118
|
/**
|
|
612
1119
|
* Processes a keypress and returns updated state.
|
|
@@ -615,10 +1122,10 @@ function render$2(term, config, state, submitted) {
|
|
|
615
1122
|
* @param key - The key that was pressed
|
|
616
1123
|
* @param state - Current prompt state
|
|
617
1124
|
* @param config - Prompt configuration
|
|
1125
|
+
* @param maxVisible - Maximum number of visible choices
|
|
618
1126
|
* @returns Updated state after processing the key
|
|
619
1127
|
*/
|
|
620
|
-
function processKey$2(key, state, config) {
|
|
621
|
-
const maxVisible = config.maxVisible ?? 10;
|
|
1128
|
+
function processKey$2(key, state, config, maxVisible) {
|
|
622
1129
|
const total = state.filteredIndices.length;
|
|
623
1130
|
if (total === 0 && key !== Key.Backspace && key !== '\b')
|
|
624
1131
|
return state;
|
|
@@ -685,15 +1192,7 @@ function processKey$2(key, state, config) {
|
|
|
685
1192
|
return state;
|
|
686
1193
|
}
|
|
687
1194
|
if (key.length === 1 && key >= ' ' && key !== Key.Space) {
|
|
688
|
-
|
|
689
|
-
const newFiltered = filterChoices$1(state.choices, newQuery);
|
|
690
|
-
return freeze({
|
|
691
|
-
...state,
|
|
692
|
-
searchQuery: newQuery,
|
|
693
|
-
filteredIndices: newFiltered,
|
|
694
|
-
cursor: 0,
|
|
695
|
-
scrollOffset: 0,
|
|
696
|
-
});
|
|
1195
|
+
return appendSearch$1(key, state);
|
|
697
1196
|
}
|
|
698
1197
|
}
|
|
699
1198
|
return state;
|
|
@@ -715,11 +1214,26 @@ function validateSelection(state, config) {
|
|
|
715
1214
|
}
|
|
716
1215
|
return undefined;
|
|
717
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
|
+
}
|
|
718
1229
|
/**
|
|
719
1230
|
* Prompts for multiple selections from a list of choices.
|
|
720
1231
|
*
|
|
721
1232
|
* Pure functional prompt with arrow key navigation, space to toggle,
|
|
722
|
-
* scrolling support, min/max constraints, and optional type-to-filter
|
|
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.
|
|
723
1237
|
*
|
|
724
1238
|
* @param config - Multiselect prompt configuration
|
|
725
1239
|
* @returns Promise resolving to array of selected values or cancellation
|
|
@@ -761,55 +1275,59 @@ function validateSelection(state, config) {
|
|
|
761
1275
|
*/
|
|
762
1276
|
async function multiselect(config) {
|
|
763
1277
|
const term = createTerminal({ input: config.input, output: config.output });
|
|
1278
|
+
const screen = createScreen(term);
|
|
764
1279
|
let state = createInitialState$2(config);
|
|
765
|
-
let lineCount = 0;
|
|
766
1280
|
let errorMessage;
|
|
767
1281
|
term.write(Ansi.HideCursor);
|
|
768
|
-
const redraw = (
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
term.write(Ansi.ClearToEnd);
|
|
773
|
-
lineCount = render$2(term, config, state, submitted);
|
|
774
|
-
if (errorMessage && !submitted) {
|
|
775
|
-
term.write(Ansi.ClearLine + style.yellow(` ${errorMessage}`) + '\n');
|
|
776
|
-
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}`));
|
|
777
1286
|
}
|
|
1287
|
+
screen.render(freeze({ lines: freeze(lines) }));
|
|
778
1288
|
};
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
if (
|
|
784
|
-
|
|
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 });
|
|
785
1297
|
}
|
|
786
|
-
|
|
787
|
-
term.write(renderMessage(config.message) + renderCancelled() + '\n');
|
|
788
|
-
term.write(Ansi.ShowCursor);
|
|
789
|
-
term.close();
|
|
790
|
-
return freeze({ result: PromptResult.Cancelled, value: undefined });
|
|
791
|
-
}
|
|
792
|
-
if (key === Key.Enter) {
|
|
793
|
-
const validationError = validateSelection(state, config);
|
|
794
|
-
if (validationError) {
|
|
795
|
-
errorMessage = validationError;
|
|
1298
|
+
if (token.type === TokenType.Resize) {
|
|
796
1299
|
redraw();
|
|
797
1300
|
continue;
|
|
798
1301
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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) });
|
|
802
1323
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
term.write(Ansi.ShowCursor);
|
|
807
|
-
term.close();
|
|
808
|
-
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();
|
|
809
1327
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
1328
|
+
}
|
|
1329
|
+
finally {
|
|
1330
|
+
term.close();
|
|
813
1331
|
}
|
|
814
1332
|
}
|
|
815
1333
|
|
|
@@ -896,65 +1414,66 @@ function renderChoice(choice, isFocused) {
|
|
|
896
1414
|
return `${pointer} ${label}${hint}`;
|
|
897
1415
|
}
|
|
898
1416
|
/**
|
|
899
|
-
*
|
|
1417
|
+
* Builds the frame lines for the select prompt.
|
|
900
1418
|
*
|
|
901
1419
|
* @internal
|
|
902
|
-
* @param term - Terminal interface
|
|
903
1420
|
* @param config - Prompt configuration
|
|
904
1421
|
* @param state - Current prompt state
|
|
905
|
-
* @param
|
|
906
|
-
* @returns
|
|
1422
|
+
* @param maxVisible - Maximum number of visible choices
|
|
1423
|
+
* @returns Logical lines describing the prompt
|
|
907
1424
|
*/
|
|
908
|
-
function
|
|
909
|
-
const maxVisible = config.maxVisible ?? 10;
|
|
1425
|
+
function buildLines(config, state, maxVisible) {
|
|
910
1426
|
const { indices: visibleIndices, startIndex } = getVisibleChoices(state, maxVisible);
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
const actualIndex = state.filteredIndices[state.cursor];
|
|
914
|
-
const selectedChoice = actualIndex !== undefined ? state.choices[actualIndex] : undefined;
|
|
915
|
-
/* istanbul ignore next -- @preserve defensive: cursor always within bounds */
|
|
916
|
-
output += renderSubmitted(selectedChoice?.label ?? '');
|
|
917
|
-
term.write(output);
|
|
918
|
-
return 1;
|
|
919
|
-
}
|
|
1427
|
+
const lines = [];
|
|
1428
|
+
let header = renderMessage(config.message);
|
|
920
1429
|
if (config.searchable && state.searchQuery) {
|
|
921
|
-
|
|
1430
|
+
header += style.cyan(state.searchQuery) + style.dim(' (type to filter)');
|
|
922
1431
|
}
|
|
923
1432
|
else if (config.searchable) {
|
|
924
|
-
|
|
1433
|
+
header += style.dim('(type to filter, enter to select)');
|
|
925
1434
|
}
|
|
926
1435
|
else {
|
|
927
|
-
|
|
1436
|
+
header += style.dim('(use arrows, enter to select)');
|
|
928
1437
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
const showScrollDown = startIndex + maxVisible < state.filteredIndices.length;
|
|
933
|
-
if (showScrollUp) {
|
|
934
|
-
term.write(Ansi.ClearLine + style.dim(` ${Symbol.Ellipsis} (${startIndex} more above)`) + '\n');
|
|
935
|
-
lineCount++;
|
|
1438
|
+
lines.push(header);
|
|
1439
|
+
if (startIndex > 0) {
|
|
1440
|
+
lines.push(style.dim(` ${Symbol.Ellipsis} (${startIndex} more above)`));
|
|
936
1441
|
}
|
|
937
1442
|
visibleIndices.forEach((actualIndex, i) => {
|
|
938
1443
|
const choice = state.choices[actualIndex];
|
|
939
1444
|
/* istanbul ignore if -- @preserve defensive: actualIndex always valid from filteredIndices */
|
|
940
1445
|
if (!choice)
|
|
941
1446
|
return;
|
|
942
|
-
|
|
943
|
-
const isFocused = viewIndex === state.cursor;
|
|
944
|
-
const line = renderChoice(choice, isFocused);
|
|
945
|
-
term.write(Ansi.ClearLine + line + '\n');
|
|
946
|
-
lineCount++;
|
|
1447
|
+
lines.push(renderChoice(choice, startIndex + i === state.cursor));
|
|
947
1448
|
});
|
|
948
|
-
if (
|
|
1449
|
+
if (startIndex + maxVisible < state.filteredIndices.length) {
|
|
949
1450
|
const remaining = state.filteredIndices.length - (startIndex + maxVisible);
|
|
950
|
-
|
|
951
|
-
lineCount++;
|
|
1451
|
+
lines.push(style.dim(` ${Symbol.Ellipsis} (${remaining} more below)`));
|
|
952
1452
|
}
|
|
953
1453
|
if (state.filteredIndices.length === 0 && config.searchable) {
|
|
954
|
-
|
|
955
|
-
lineCount++;
|
|
1454
|
+
lines.push(style.dim(' No matches found'));
|
|
956
1455
|
}
|
|
957
|
-
return
|
|
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
|
+
});
|
|
958
1477
|
}
|
|
959
1478
|
/**
|
|
960
1479
|
* Processes a keypress and returns updated state.
|
|
@@ -963,10 +1482,10 @@ function render$1(term, config, state, submitted) {
|
|
|
963
1482
|
* @param key - The key that was pressed
|
|
964
1483
|
* @param state - Current prompt state
|
|
965
1484
|
* @param config - Prompt configuration
|
|
1485
|
+
* @param maxVisible - Maximum number of visible choices
|
|
966
1486
|
* @returns Updated state after processing the key
|
|
967
1487
|
*/
|
|
968
|
-
function processKey$1(key, state, config) {
|
|
969
|
-
const maxVisible = config.maxVisible ?? 10;
|
|
1488
|
+
function processKey$1(key, state, config, maxVisible) {
|
|
970
1489
|
const total = state.filteredIndices.length;
|
|
971
1490
|
if (total === 0 && key !== Key.Backspace && key !== '\b')
|
|
972
1491
|
return state;
|
|
@@ -1012,15 +1531,7 @@ function processKey$1(key, state, config) {
|
|
|
1012
1531
|
return state;
|
|
1013
1532
|
}
|
|
1014
1533
|
if (key.length === 1 && key >= ' ') {
|
|
1015
|
-
|
|
1016
|
-
const newFiltered = filterChoices(state.choices, newQuery);
|
|
1017
|
-
return freeze({
|
|
1018
|
-
...state,
|
|
1019
|
-
searchQuery: newQuery,
|
|
1020
|
-
filteredIndices: newFiltered,
|
|
1021
|
-
cursor: 0,
|
|
1022
|
-
scrollOffset: 0,
|
|
1023
|
-
});
|
|
1534
|
+
return appendSearch(key, state);
|
|
1024
1535
|
}
|
|
1025
1536
|
}
|
|
1026
1537
|
return state;
|
|
@@ -1029,7 +1540,10 @@ function processKey$1(key, state, config) {
|
|
|
1029
1540
|
* Prompts for single selection from a list of choices.
|
|
1030
1541
|
*
|
|
1031
1542
|
* Pure functional prompt with arrow key navigation, scrolling support,
|
|
1032
|
-
* 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.
|
|
1033
1547
|
*
|
|
1034
1548
|
* @param config - Select prompt configuration
|
|
1035
1549
|
* @returns Promise resolving to selected value or cancellation
|
|
@@ -1073,70 +1587,66 @@ function processKey$1(key, state, config) {
|
|
|
1073
1587
|
*/
|
|
1074
1588
|
async function select(config) {
|
|
1075
1589
|
const term = createTerminal({ input: config.input, output: config.output });
|
|
1590
|
+
const screen = createScreen(term);
|
|
1076
1591
|
let state = createInitialState$1(config);
|
|
1077
|
-
let lineCount = 0;
|
|
1078
1592
|
term.write(Ansi.HideCursor);
|
|
1079
|
-
const redraw = (
|
|
1080
|
-
|
|
1081
|
-
term.write(Ansi.cursorUp(lineCount) + Ansi.CursorStart);
|
|
1082
|
-
}
|
|
1083
|
-
term.write(Ansi.ClearToEnd);
|
|
1084
|
-
lineCount = render$1(term, config, state, submitted);
|
|
1593
|
+
const redraw = () => {
|
|
1594
|
+
screen.render(freeze({ lines: buildLines(config, state, effectiveMaxVisible(term, config.maxVisible)) }));
|
|
1085
1595
|
};
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
if (
|
|
1091
|
-
|
|
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 });
|
|
1092
1604
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
term.write(Ansi.ShowCursor);
|
|
1096
|
-
term.close();
|
|
1097
|
-
return freeze({ result: PromptResult.Cancelled, value: undefined });
|
|
1098
|
-
}
|
|
1099
|
-
if (key === Key.Enter) {
|
|
1100
|
-
const actualIndex = state.filteredIndices[state.cursor];
|
|
1101
|
-
if (actualIndex === undefined) {
|
|
1605
|
+
if (token.type === TokenType.Resize) {
|
|
1606
|
+
redraw();
|
|
1102
1607
|
continue;
|
|
1103
1608
|
}
|
|
1104
|
-
|
|
1105
|
-
|
|
1609
|
+
if (token.type === TokenType.Paste) {
|
|
1610
|
+
if (config.searchable) {
|
|
1611
|
+
state = appendSearch(firstPasteLine(token.value), state);
|
|
1612
|
+
redraw();
|
|
1613
|
+
}
|
|
1106
1614
|
continue;
|
|
1107
1615
|
}
|
|
1108
|
-
if (
|
|
1109
|
-
|
|
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 });
|
|
1110
1629
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
term.write('\n');
|
|
1114
|
-
term.write(Ansi.ShowCursor);
|
|
1115
|
-
term.close();
|
|
1116
|
-
return freeze({ result: PromptResult.Submitted, value: selectedChoice.value });
|
|
1630
|
+
state = processKey$1(token.value, state, config, effectiveMaxVisible(term, config.maxVisible));
|
|
1631
|
+
redraw();
|
|
1117
1632
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1633
|
+
}
|
|
1634
|
+
finally {
|
|
1635
|
+
term.close();
|
|
1120
1636
|
}
|
|
1121
1637
|
}
|
|
1122
|
-
|
|
1123
1638
|
/**
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
* These references are captured at module initialization time to protect against
|
|
1127
|
-
* prototype pollution attacks. Import only what you need for tree-shaking.
|
|
1639
|
+
* Resolves the visible-window size, capped so the frame fits the terminal
|
|
1640
|
+
* height (header and scroll-indicator rows reserved).
|
|
1128
1641
|
*
|
|
1129
|
-
* @
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
* (Safe copy) Returns the larger of zero or more numbers.
|
|
1134
|
-
*/
|
|
1135
|
-
const max = _Math.max;
|
|
1136
|
-
/**
|
|
1137
|
-
* (Safe copy) Returns the smaller of zero or more numbers.
|
|
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)
|
|
1138
1646
|
*/
|
|
1139
|
-
|
|
1647
|
+
function effectiveMaxVisible(term, configured) {
|
|
1648
|
+
return max(1, min(configured ?? 10, term.getSize().rows - 4));
|
|
1649
|
+
}
|
|
1140
1650
|
|
|
1141
1651
|
/**
|
|
1142
1652
|
* Creates initial state for text prompt.
|
|
@@ -1153,40 +1663,63 @@ function createInitialState(config) {
|
|
|
1153
1663
|
};
|
|
1154
1664
|
}
|
|
1155
1665
|
/**
|
|
1156
|
-
*
|
|
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.
|
|
1157
1678
|
*
|
|
1158
1679
|
* @internal
|
|
1159
|
-
* @param term - Terminal interface for output
|
|
1160
1680
|
* @param config - Prompt configuration
|
|
1161
1681
|
* @param state - Current prompt state
|
|
1162
1682
|
* @param submitted - Whether the prompt has been submitted
|
|
1163
|
-
* @returns
|
|
1683
|
+
* @returns Frame describing lines and cursor position
|
|
1164
1684
|
*/
|
|
1165
|
-
function
|
|
1685
|
+
function buildFrame(config, state, submitted) {
|
|
1166
1686
|
const displayValue = config.format ? config.format(state.value) : state.value;
|
|
1167
|
-
|
|
1168
|
-
let output = renderMessage(messageText);
|
|
1169
|
-
let trailingChars = 0;
|
|
1687
|
+
let line = renderMessage(messageText(config, state.value));
|
|
1170
1688
|
if (submitted) {
|
|
1171
|
-
|
|
1689
|
+
line += renderSubmitted(displayValue || config.initial || '');
|
|
1690
|
+
return freeze({ lines: freeze([line]) });
|
|
1172
1691
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
trailingChars += config.initial.length;
|
|
1179
|
-
}
|
|
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);
|
|
1180
1697
|
}
|
|
1181
|
-
|
|
1698
|
+
const cursor = freeze({ line: 0, col: displayWidth(line) - trailingWidth });
|
|
1182
1699
|
if (state.error) {
|
|
1183
|
-
|
|
1184
|
-
return 2;
|
|
1185
|
-
}
|
|
1186
|
-
if (!submitted && trailingChars > 0) {
|
|
1187
|
-
term.write(Ansi.cursorLeft(trailingChars));
|
|
1700
|
+
return freeze({ lines: freeze([line, style.yellow(` ${state.error}`)]), cursor });
|
|
1188
1701
|
}
|
|
1189
|
-
return
|
|
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
|
+
};
|
|
1190
1723
|
}
|
|
1191
1724
|
/**
|
|
1192
1725
|
* Processes a keypress and returns updated state.
|
|
@@ -1198,14 +1731,7 @@ function render(term, config, state, submitted) {
|
|
|
1198
1731
|
*/
|
|
1199
1732
|
function processKey(key, state) {
|
|
1200
1733
|
if (key.length === 1 && key >= ' ' && key !== Key.Backspace) {
|
|
1201
|
-
|
|
1202
|
-
const after = state.value.slice(state.cursorPos);
|
|
1203
|
-
return {
|
|
1204
|
-
...state,
|
|
1205
|
-
value: before + key + after,
|
|
1206
|
-
cursorPos: state.cursorPos + 1,
|
|
1207
|
-
error: undefined,
|
|
1208
|
-
};
|
|
1734
|
+
return insertText(key, state);
|
|
1209
1735
|
}
|
|
1210
1736
|
if (key === Key.Backspace || key === '\b') {
|
|
1211
1737
|
if (state.cursorPos > 0) {
|
|
@@ -1237,7 +1763,11 @@ function processKey(key, state) {
|
|
|
1237
1763
|
* Prompts for text input with optional validation.
|
|
1238
1764
|
*
|
|
1239
1765
|
* Pure functional prompt that reads text from the user with support for
|
|
1240
|
-
* 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.
|
|
1241
1771
|
*
|
|
1242
1772
|
* @param config - Text prompt configuration
|
|
1243
1773
|
* @returns Promise resolving to submitted value or cancellation
|
|
@@ -1271,42 +1801,50 @@ function processKey(key, state) {
|
|
|
1271
1801
|
*/
|
|
1272
1802
|
async function text(config) {
|
|
1273
1803
|
const term = createTerminal({ input: config.input, output: config.output });
|
|
1804
|
+
const screen = createScreen(term);
|
|
1274
1805
|
let state = createInitialState(config);
|
|
1275
|
-
let lineCount = 0;
|
|
1276
1806
|
const redraw = (submitted = false) => {
|
|
1277
|
-
|
|
1278
|
-
term.clearLines(lineCount);
|
|
1279
|
-
}
|
|
1280
|
-
lineCount = render(term, config, state, submitted);
|
|
1807
|
+
screen.render(buildFrame(config, state, submitted));
|
|
1281
1808
|
};
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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
|
+
}
|
|
1299
1836
|
}
|
|
1837
|
+
redraw(true);
|
|
1838
|
+
term.write('\n');
|
|
1839
|
+
return freeze({ result: PromptResult.Submitted, value });
|
|
1300
1840
|
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
term.close();
|
|
1304
|
-
return freeze({ result: PromptResult.Submitted, value });
|
|
1841
|
+
state = processKey(token.value, state);
|
|
1842
|
+
redraw();
|
|
1305
1843
|
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1844
|
+
}
|
|
1845
|
+
finally {
|
|
1846
|
+
term.close();
|
|
1308
1847
|
}
|
|
1309
1848
|
}
|
|
1310
1849
|
|
|
1311
1850
|
export { PromptResult, confirm, multiselect, select, style, text };
|
|
1312
|
-
//# sourceMappingURL=index.esm.js.map
|