@hyperfrontend/questions 0.1.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 +9 -0
- package/FUNDING.md +141 -0
- package/LICENSE.md +21 -0
- package/README.md +147 -0
- package/SECURITY.md +82 -0
- package/index.cjs.js +1217 -0
- package/index.cjs.js.map +1 -0
- package/index.d.ts +12 -0
- package/index.d.ts.map +1 -0
- package/index.esm.js +1211 -0
- package/index.esm.js.map +1 -0
- package/package.json +54 -0
- package/render.d.ts +113 -0
- package/render.d.ts.map +1 -0
- package/terminal.d.ts +109 -0
- package/terminal.d.ts.map +1 -0
- package/types.d.ts +108 -0
- package/types.d.ts.map +1 -0
package/index.esm.js
ADDED
|
@@ -0,0 +1,1211 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Safe copies of Object built-in methods.
|
|
5
|
+
*
|
|
6
|
+
* These references are captured at module initialization time to protect against
|
|
7
|
+
* prototype pollution attacks. Import only what you need for tree-shaking.
|
|
8
|
+
*
|
|
9
|
+
* @module @hyperfrontend/immutable-api-utils/built-in-copy/object
|
|
10
|
+
*/
|
|
11
|
+
const _Object = globalThis.Object;
|
|
12
|
+
/**
|
|
13
|
+
* (Safe copy) Prevents modification of existing property attributes and values,
|
|
14
|
+
* and prevents the addition of new properties.
|
|
15
|
+
*/
|
|
16
|
+
const freeze = _Object.freeze;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Safe Promise factory and bound static methods.
|
|
20
|
+
*
|
|
21
|
+
* @module @hyperfrontend/immutable-api-utils/built-in-copy/promise
|
|
22
|
+
*/
|
|
23
|
+
/* eslint-disable workspace/lib-require-jsdoc-example */
|
|
24
|
+
const _Promise = globalThis.Promise;
|
|
25
|
+
const _Reflect = globalThis.Reflect;
|
|
26
|
+
/**
|
|
27
|
+
* (Safe copy) Creates a new Promise using the captured Promise constructor.
|
|
28
|
+
* Use this instead of `new Promise()`.
|
|
29
|
+
*
|
|
30
|
+
* @param executor - The executor function.
|
|
31
|
+
* @returns A new Promise instance.
|
|
32
|
+
*/
|
|
33
|
+
const createPromise = (executor) => _Reflect.construct(_Promise, [executor]);
|
|
34
|
+
/**
|
|
35
|
+
* (Safe copy) Returns a Promise that resolves with the given value.
|
|
36
|
+
*/
|
|
37
|
+
_Promise.resolve.bind(_Promise);
|
|
38
|
+
/**
|
|
39
|
+
* (Safe copy) Returns a Promise that rejects with the given reason.
|
|
40
|
+
*/
|
|
41
|
+
_Promise.reject.bind(_Promise);
|
|
42
|
+
/**
|
|
43
|
+
* (Safe copy) Returns a Promise that resolves when all promises resolve.
|
|
44
|
+
*/
|
|
45
|
+
_Promise.all.bind(_Promise);
|
|
46
|
+
/**
|
|
47
|
+
* (Safe copy) Returns a Promise that resolves/rejects with the first settled promise.
|
|
48
|
+
*/
|
|
49
|
+
_Promise.race.bind(_Promise);
|
|
50
|
+
/**
|
|
51
|
+
* (Safe copy) Returns a Promise that resolves when all promises settle.
|
|
52
|
+
*/
|
|
53
|
+
_Promise.allSettled.bind(_Promise);
|
|
54
|
+
/**
|
|
55
|
+
* (Safe copy) Returns a Promise that resolves with the first fulfilled promise.
|
|
56
|
+
*/
|
|
57
|
+
_Promise.any.bind(_Promise);
|
|
58
|
+
/**
|
|
59
|
+
* (Safe copy) Creates a Promise along with its resolve and reject functions.
|
|
60
|
+
* Note: Available only in ES2024+ environments.
|
|
61
|
+
*/
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
_Promise.withResolvers?.bind(_Promise);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Terminal I/O utilities using Node.js readline.
|
|
67
|
+
*
|
|
68
|
+
* @internal
|
|
69
|
+
*/
|
|
70
|
+
/**
|
|
71
|
+
* Key codes for terminal navigation.
|
|
72
|
+
*/
|
|
73
|
+
const Key = freeze({
|
|
74
|
+
Up: '\x1B[A',
|
|
75
|
+
Down: '\x1B[B',
|
|
76
|
+
Left: '\x1B[D',
|
|
77
|
+
Right: '\x1B[C',
|
|
78
|
+
Enter: '\r',
|
|
79
|
+
Space: ' ',
|
|
80
|
+
Tab: '\t',
|
|
81
|
+
Escape: '\x1B',
|
|
82
|
+
Backspace: '\x7F',
|
|
83
|
+
Delete: '\x1B[3~',
|
|
84
|
+
CtrlC: '\x03',
|
|
85
|
+
});
|
|
86
|
+
/**
|
|
87
|
+
* ANSI escape codes for terminal styling.
|
|
88
|
+
*/
|
|
89
|
+
const Ansi = freeze({
|
|
90
|
+
/** Clear entire line */
|
|
91
|
+
ClearLine: '\x1B[2K',
|
|
92
|
+
/** Move cursor to start of line */
|
|
93
|
+
CursorStart: '\r',
|
|
94
|
+
/**
|
|
95
|
+
* Generates ANSI escape code to move cursor up by specified lines.
|
|
96
|
+
*
|
|
97
|
+
* @param n - Number of lines to move up
|
|
98
|
+
* @returns ANSI escape sequence string
|
|
99
|
+
*/
|
|
100
|
+
cursorUp: (n) => `\x1B[${n}A`,
|
|
101
|
+
/**
|
|
102
|
+
* Generates ANSI escape code to move cursor down by specified lines.
|
|
103
|
+
*
|
|
104
|
+
* @param n - Number of lines to move down
|
|
105
|
+
* @returns ANSI escape sequence string
|
|
106
|
+
*/
|
|
107
|
+
cursorDown: (n) => `\x1B[${n}B`,
|
|
108
|
+
/** Escape code to hide cursor */
|
|
109
|
+
HideCursor: '\x1B[?25l',
|
|
110
|
+
/** Escape code to show cursor */
|
|
111
|
+
ShowCursor: '\x1B[?25h',
|
|
112
|
+
/** Escape code to save cursor position */
|
|
113
|
+
SaveCursor: '\x1B7',
|
|
114
|
+
/** Escape code to restore cursor position */
|
|
115
|
+
RestoreCursor: '\x1B8',
|
|
116
|
+
/** Clear from cursor to end of screen */
|
|
117
|
+
ClearToEnd: '\x1B[J',
|
|
118
|
+
/** Escape code for bold text */
|
|
119
|
+
Bold: '\x1B[1m',
|
|
120
|
+
/** Escape code for dim text */
|
|
121
|
+
Dim: '\x1B[2m',
|
|
122
|
+
/** Escape code to reset all styles */
|
|
123
|
+
Reset: '\x1B[0m',
|
|
124
|
+
/** Escape code for cyan foreground */
|
|
125
|
+
Cyan: '\x1B[36m',
|
|
126
|
+
/** Escape code for green foreground */
|
|
127
|
+
Green: '\x1B[32m',
|
|
128
|
+
/** Escape code for yellow foreground */
|
|
129
|
+
Yellow: '\x1B[33m',
|
|
130
|
+
/** Escape code for gray foreground */
|
|
131
|
+
Gray: '\x1B[90m',
|
|
132
|
+
});
|
|
133
|
+
/**
|
|
134
|
+
* Creates a terminal interface for interactive prompts.
|
|
135
|
+
*
|
|
136
|
+
* @param config - Terminal configuration options
|
|
137
|
+
* @returns Terminal interface with read/write methods
|
|
138
|
+
*
|
|
139
|
+
* @example Create terminal for prompts
|
|
140
|
+
* ```typescript
|
|
141
|
+
* const term = createTerminal()
|
|
142
|
+
* term.write('Enter name: ')
|
|
143
|
+
* const name = await term.readLine()
|
|
144
|
+
* term.close()
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
function createTerminal(config = {}) {
|
|
148
|
+
const input = config.input ?? process.stdin;
|
|
149
|
+
const output = config.output ?? process.stdout;
|
|
150
|
+
let cancelled = false;
|
|
151
|
+
let rl;
|
|
152
|
+
const getReadline = () => {
|
|
153
|
+
if (!rl) {
|
|
154
|
+
rl = createInterface({ input, output, terminal: true });
|
|
155
|
+
}
|
|
156
|
+
return rl;
|
|
157
|
+
};
|
|
158
|
+
const write = (text) => {
|
|
159
|
+
output.write(text);
|
|
160
|
+
};
|
|
161
|
+
const readKey = () => createPromise((resolve) => {
|
|
162
|
+
const wasRaw = input.isRaw;
|
|
163
|
+
if (input.setRawMode) {
|
|
164
|
+
input.setRawMode(true);
|
|
165
|
+
}
|
|
166
|
+
const onData = (data) => {
|
|
167
|
+
input.removeListener('data', onData);
|
|
168
|
+
if (input.setRawMode) {
|
|
169
|
+
input.setRawMode(wasRaw);
|
|
170
|
+
}
|
|
171
|
+
const key = data.toString();
|
|
172
|
+
if (key === Key.CtrlC) {
|
|
173
|
+
cancelled = true;
|
|
174
|
+
}
|
|
175
|
+
resolve(key);
|
|
176
|
+
};
|
|
177
|
+
input.once('data', onData);
|
|
178
|
+
});
|
|
179
|
+
const readLine = () => createPromise((resolve) => {
|
|
180
|
+
const readline = getReadline();
|
|
181
|
+
readline.once('line', (line) => {
|
|
182
|
+
resolve(line);
|
|
183
|
+
});
|
|
184
|
+
readline.once('close', () => {
|
|
185
|
+
cancelled = true;
|
|
186
|
+
resolve('');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
const clearLines = (count) => {
|
|
190
|
+
if (count <= 0)
|
|
191
|
+
return;
|
|
192
|
+
for (let i = 0; i < count; i++) {
|
|
193
|
+
write(Ansi.CursorStart + Ansi.ClearLine);
|
|
194
|
+
if (i < count - 1) {
|
|
195
|
+
write(Ansi.cursorUp(1));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const close = () => {
|
|
200
|
+
if (rl) {
|
|
201
|
+
rl.close();
|
|
202
|
+
rl = undefined;
|
|
203
|
+
}
|
|
204
|
+
write(Ansi.ShowCursor);
|
|
205
|
+
};
|
|
206
|
+
return freeze({
|
|
207
|
+
write,
|
|
208
|
+
readKey,
|
|
209
|
+
readLine,
|
|
210
|
+
clearLines,
|
|
211
|
+
close,
|
|
212
|
+
isCancelled: () => cancelled,
|
|
213
|
+
cancel: () => {
|
|
214
|
+
cancelled = true;
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Symbols and styling for prompt rendering.
|
|
221
|
+
*
|
|
222
|
+
* @internal
|
|
223
|
+
*/
|
|
224
|
+
/**
|
|
225
|
+
* Unicode symbols for prompt rendering.
|
|
226
|
+
*/
|
|
227
|
+
const Symbol = freeze({
|
|
228
|
+
Pointer: '❯',
|
|
229
|
+
Radio: '◯',
|
|
230
|
+
RadioSelected: '◉',
|
|
231
|
+
Checkbox: '☐',
|
|
232
|
+
CheckboxSelected: '☑',
|
|
233
|
+
Check: '✔',
|
|
234
|
+
Cross: '✖',
|
|
235
|
+
Question: '?',
|
|
236
|
+
Ellipsis: '…',
|
|
237
|
+
});
|
|
238
|
+
/**
|
|
239
|
+
* Style functions for ANSI text formatting.
|
|
240
|
+
*/
|
|
241
|
+
const style = freeze({
|
|
242
|
+
/**
|
|
243
|
+
* Applies bold styling to text.
|
|
244
|
+
*
|
|
245
|
+
* @param text - Text to style
|
|
246
|
+
* @returns Text wrapped in bold ANSI codes
|
|
247
|
+
*/
|
|
248
|
+
bold: (text) => `${Ansi.Bold}${text}${Ansi.Reset}`,
|
|
249
|
+
/**
|
|
250
|
+
* Applies dim styling to text.
|
|
251
|
+
*
|
|
252
|
+
* @param text - Text to style
|
|
253
|
+
* @returns Text wrapped in dim ANSI codes
|
|
254
|
+
*/
|
|
255
|
+
dim: (text) => `${Ansi.Dim}${text}${Ansi.Reset}`,
|
|
256
|
+
/**
|
|
257
|
+
* Applies cyan color to text.
|
|
258
|
+
*
|
|
259
|
+
* @param text - Text to style
|
|
260
|
+
* @returns Text wrapped in cyan ANSI codes
|
|
261
|
+
*/
|
|
262
|
+
cyan: (text) => `${Ansi.Cyan}${text}${Ansi.Reset}`,
|
|
263
|
+
/**
|
|
264
|
+
* Applies green color to text.
|
|
265
|
+
*
|
|
266
|
+
* @param text - Text to style
|
|
267
|
+
* @returns Text wrapped in green ANSI codes
|
|
268
|
+
*/
|
|
269
|
+
green: (text) => `${Ansi.Green}${text}${Ansi.Reset}`,
|
|
270
|
+
/**
|
|
271
|
+
* Applies yellow color to text.
|
|
272
|
+
*
|
|
273
|
+
* @param text - Text to style
|
|
274
|
+
* @returns Text wrapped in yellow ANSI codes
|
|
275
|
+
*/
|
|
276
|
+
yellow: (text) => `${Ansi.Yellow}${text}${Ansi.Reset}`,
|
|
277
|
+
/**
|
|
278
|
+
* Applies gray color to text.
|
|
279
|
+
*
|
|
280
|
+
* @param text - Text to style
|
|
281
|
+
* @returns Text wrapped in gray ANSI codes
|
|
282
|
+
*/
|
|
283
|
+
gray: (text) => `${Ansi.Gray}${text}${Ansi.Reset}`,
|
|
284
|
+
});
|
|
285
|
+
/**
|
|
286
|
+
* Renders a prompt message with consistent styling.
|
|
287
|
+
*
|
|
288
|
+
* @param message - The prompt message to display
|
|
289
|
+
* @returns Formatted message string with cyan question mark and bold text
|
|
290
|
+
*
|
|
291
|
+
* @example Render a prompt message
|
|
292
|
+
* ```typescript
|
|
293
|
+
* const output = renderMessage('Enter your name')
|
|
294
|
+
* // Returns "? Enter your name " with styling
|
|
295
|
+
* ```
|
|
296
|
+
*/
|
|
297
|
+
function renderMessage(message) {
|
|
298
|
+
return `${style.cyan(Symbol.Question)} ${style.bold(message)} `;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Renders a submitted value with visual confirmation styling.
|
|
302
|
+
*
|
|
303
|
+
* @param value - The value that was submitted
|
|
304
|
+
* @returns Value string styled in cyan
|
|
305
|
+
*
|
|
306
|
+
* @example Render submitted input
|
|
307
|
+
* ```typescript
|
|
308
|
+
* const output = renderSubmitted('John Doe')
|
|
309
|
+
* // Returns "John Doe" in cyan
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
312
|
+
function renderSubmitted(value) {
|
|
313
|
+
return style.cyan(value);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Renders a cancellation indicator.
|
|
317
|
+
*
|
|
318
|
+
* @returns Dimmed "(cancelled)" string
|
|
319
|
+
*
|
|
320
|
+
* @example Show cancellation
|
|
321
|
+
* ```typescript
|
|
322
|
+
* const output = renderCancelled()
|
|
323
|
+
* // Returns "(cancelled)" in dim style
|
|
324
|
+
* ```
|
|
325
|
+
*/
|
|
326
|
+
function renderCancelled() {
|
|
327
|
+
return style.dim('(cancelled)');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Core types for terminal prompts.
|
|
332
|
+
*
|
|
333
|
+
* @internal
|
|
334
|
+
*/
|
|
335
|
+
/**
|
|
336
|
+
* Result state of a prompt interaction.
|
|
337
|
+
*/
|
|
338
|
+
const PromptResult = freeze({
|
|
339
|
+
/** User submitted a value */
|
|
340
|
+
Submitted: 'submitted',
|
|
341
|
+
/** User cancelled the prompt (Ctrl+C) */
|
|
342
|
+
Cancelled: 'cancelled',
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Renders the confirm prompt hint based on default value.
|
|
347
|
+
*
|
|
348
|
+
* @internal
|
|
349
|
+
* @param initial - The default value for the confirmation
|
|
350
|
+
* @returns Styled hint string showing Y/n options
|
|
351
|
+
*/
|
|
352
|
+
function renderOptions(initial) {
|
|
353
|
+
if (initial === true) {
|
|
354
|
+
return style.dim('(Y/n)');
|
|
355
|
+
}
|
|
356
|
+
if (initial === false) {
|
|
357
|
+
return style.dim('(y/N)');
|
|
358
|
+
}
|
|
359
|
+
return style.dim('(y/n)');
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Prompts for yes/no confirmation.
|
|
363
|
+
*
|
|
364
|
+
* Pure functional prompt that asks a yes/no question and returns a boolean.
|
|
365
|
+
* Supports default values and responds to y/Y/n/N keys.
|
|
366
|
+
*
|
|
367
|
+
* @param config - Confirm prompt configuration
|
|
368
|
+
* @returns Promise resolving to boolean value or cancellation
|
|
369
|
+
*
|
|
370
|
+
* @example Basic confirmation
|
|
371
|
+
* ```typescript
|
|
372
|
+
* const outcome = await confirm({ message: 'Continue?' })
|
|
373
|
+
* if (outcome.result === 'submitted' && outcome.value) {
|
|
374
|
+
* console.log('Proceeding...')
|
|
375
|
+
* }
|
|
376
|
+
* ```
|
|
377
|
+
*
|
|
378
|
+
* @example With default value
|
|
379
|
+
* ```typescript
|
|
380
|
+
* const outcome = await confirm({
|
|
381
|
+
* message: 'Enable feature?',
|
|
382
|
+
* initial: true, // Default to yes
|
|
383
|
+
* })
|
|
384
|
+
* ```
|
|
385
|
+
*/
|
|
386
|
+
async function confirm(config) {
|
|
387
|
+
const term = createTerminal({ input: config.input, output: config.output });
|
|
388
|
+
const drawPrompt = () => {
|
|
389
|
+
term.write(Ansi.CursorStart + Ansi.ClearLine);
|
|
390
|
+
term.write(renderMessage(config.message) + renderOptions(config.initial) + ' ');
|
|
391
|
+
};
|
|
392
|
+
const drawResult = (value) => {
|
|
393
|
+
term.write(Ansi.CursorStart + Ansi.ClearLine);
|
|
394
|
+
term.write(renderMessage(config.message) + renderSubmitted(value ? 'Yes' : 'No') + '\n');
|
|
395
|
+
};
|
|
396
|
+
drawPrompt();
|
|
397
|
+
while (true) {
|
|
398
|
+
const key = await term.readKey();
|
|
399
|
+
const lowerKey = key.toLowerCase();
|
|
400
|
+
if (term.isCancelled()) {
|
|
401
|
+
term.write(Ansi.CursorStart + Ansi.ClearLine);
|
|
402
|
+
term.write(renderMessage(config.message) + renderCancelled() + '\n');
|
|
403
|
+
term.close();
|
|
404
|
+
return freeze({ result: PromptResult.Cancelled, value: undefined });
|
|
405
|
+
}
|
|
406
|
+
if (lowerKey === 'y') {
|
|
407
|
+
drawResult(true);
|
|
408
|
+
term.close();
|
|
409
|
+
return freeze({ result: PromptResult.Submitted, value: true });
|
|
410
|
+
}
|
|
411
|
+
if (lowerKey === 'n') {
|
|
412
|
+
drawResult(false);
|
|
413
|
+
term.close();
|
|
414
|
+
return freeze({ result: PromptResult.Submitted, value: false });
|
|
415
|
+
}
|
|
416
|
+
if (key === Key.Enter && config.initial !== undefined) {
|
|
417
|
+
drawResult(config.initial);
|
|
418
|
+
term.close();
|
|
419
|
+
return freeze({ result: PromptResult.Submitted, value: config.initial });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Checks if an array includes a value.
|
|
426
|
+
*
|
|
427
|
+
* @internal
|
|
428
|
+
* @param arr - Array to search
|
|
429
|
+
* @param value - Value to find
|
|
430
|
+
* @returns True if value is in array
|
|
431
|
+
*/
|
|
432
|
+
function arrayIncludes(arr, value) {
|
|
433
|
+
for (let i = 0; i < arr.length; i++) {
|
|
434
|
+
if (arr[i] === value)
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Filters choices based on search query.
|
|
441
|
+
*
|
|
442
|
+
* @internal
|
|
443
|
+
* @param choices - All available choices
|
|
444
|
+
* @param query - Search query string
|
|
445
|
+
* @returns Array of indices matching the query
|
|
446
|
+
*/
|
|
447
|
+
function filterChoices(choices, query) {
|
|
448
|
+
if (!query) {
|
|
449
|
+
return choices.map((_, i) => i);
|
|
450
|
+
}
|
|
451
|
+
const lowerQuery = query.toLowerCase();
|
|
452
|
+
const indices = [];
|
|
453
|
+
choices.forEach((choice, i) => {
|
|
454
|
+
if (choice.label.toLowerCase().includes(lowerQuery)) {
|
|
455
|
+
indices.push(i);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
return freeze(indices);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Creates initial state for multiselect prompt.
|
|
462
|
+
*
|
|
463
|
+
* @internal
|
|
464
|
+
* @param config - Prompt configuration
|
|
465
|
+
* @returns Initial multiselect state
|
|
466
|
+
*/
|
|
467
|
+
function createInitialState$2(config) {
|
|
468
|
+
return freeze({
|
|
469
|
+
cursor: 0,
|
|
470
|
+
selected: config.initial ? [...config.initial] : [],
|
|
471
|
+
choices: config.choices,
|
|
472
|
+
filteredIndices: config.choices.map((_, i) => i),
|
|
473
|
+
searchQuery: '',
|
|
474
|
+
scrollOffset: 0,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Calculates the visible window of choices for scrolling.
|
|
479
|
+
*
|
|
480
|
+
* @internal
|
|
481
|
+
* @param state - Current prompt state
|
|
482
|
+
* @param maxVisible - Maximum number of visible choices
|
|
483
|
+
* @returns Object containing visible indices and start index
|
|
484
|
+
*/
|
|
485
|
+
function getVisibleChoices$1(state, maxVisible) {
|
|
486
|
+
const total = state.filteredIndices.length;
|
|
487
|
+
if (total <= maxVisible) {
|
|
488
|
+
return { indices: state.filteredIndices, startIndex: 0 };
|
|
489
|
+
}
|
|
490
|
+
let startIndex = state.scrollOffset;
|
|
491
|
+
if (state.cursor < startIndex) {
|
|
492
|
+
startIndex = state.cursor;
|
|
493
|
+
}
|
|
494
|
+
else if (state.cursor >= startIndex + maxVisible) {
|
|
495
|
+
startIndex = state.cursor - maxVisible + 1;
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
indices: state.filteredIndices.slice(startIndex, startIndex + maxVisible),
|
|
499
|
+
startIndex,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Renders a single choice line for multiselect.
|
|
504
|
+
*
|
|
505
|
+
* @internal
|
|
506
|
+
* @param choice - The choice to render
|
|
507
|
+
* @param isSelected - Whether this choice is selected
|
|
508
|
+
* @param isFocused - Whether cursor is on this choice
|
|
509
|
+
* @returns Formatted choice string
|
|
510
|
+
*/
|
|
511
|
+
function renderChoice$1(choice, isSelected, isFocused) {
|
|
512
|
+
const pointer = isFocused ? style.cyan(Symbol.Pointer) : ' ';
|
|
513
|
+
const checkbox = isSelected ? style.green(Symbol.CheckboxSelected) : style.dim(Symbol.Checkbox);
|
|
514
|
+
let label = choice.label;
|
|
515
|
+
if (choice.disabled) {
|
|
516
|
+
label = style.dim(label + ' (disabled)');
|
|
517
|
+
}
|
|
518
|
+
else if (isFocused) {
|
|
519
|
+
label = isSelected ? style.green(label) : style.cyan(label);
|
|
520
|
+
}
|
|
521
|
+
else if (isSelected) {
|
|
522
|
+
label = style.green(label);
|
|
523
|
+
}
|
|
524
|
+
const hint = choice.hint ? style.dim(` — ${choice.hint}`) : '';
|
|
525
|
+
return `${pointer} ${checkbox} ${label}${hint}`;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Renders the multiselect prompt to the terminal.
|
|
529
|
+
*
|
|
530
|
+
* @internal
|
|
531
|
+
* @param term - Terminal interface
|
|
532
|
+
* @param config - Prompt configuration
|
|
533
|
+
* @param state - Current prompt state
|
|
534
|
+
* @param submitted - Whether the prompt has been submitted
|
|
535
|
+
* @returns Number of lines rendered
|
|
536
|
+
*/
|
|
537
|
+
function render$2(term, config, state, submitted) {
|
|
538
|
+
const maxVisible = config.maxVisible ?? 10;
|
|
539
|
+
const { indices: visibleIndices, startIndex } = getVisibleChoices$1(state, maxVisible);
|
|
540
|
+
let output = Ansi.CursorStart + Ansi.ClearLine + renderMessage(config.message);
|
|
541
|
+
if (submitted) {
|
|
542
|
+
const selectedLabels = state.selected.map((i) => state.choices[i]?.label ?? '').join(', ');
|
|
543
|
+
output += renderSubmitted(selectedLabels || 'none');
|
|
544
|
+
term.write(output);
|
|
545
|
+
return 1;
|
|
546
|
+
}
|
|
547
|
+
if (config.searchable && state.searchQuery) {
|
|
548
|
+
output += style.cyan(state.searchQuery) + style.dim(' (type to filter)');
|
|
549
|
+
}
|
|
550
|
+
else if (config.searchable) {
|
|
551
|
+
output += style.dim('(type to filter, space to toggle, enter to submit)');
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
output += style.dim('(space to toggle, enter to submit)');
|
|
555
|
+
}
|
|
556
|
+
term.write(output + '\n');
|
|
557
|
+
let lineCount = 1;
|
|
558
|
+
const minMax = [];
|
|
559
|
+
if (config.min !== undefined)
|
|
560
|
+
minMax.push(`min: ${config.min}`);
|
|
561
|
+
if (config.max !== undefined)
|
|
562
|
+
minMax.push(`max: ${config.max}`);
|
|
563
|
+
const countHint = minMax.length > 0 ? ` (${minMax.join(', ')})` : '';
|
|
564
|
+
term.write(Ansi.ClearLine + style.dim(` ${state.selected.length} selected${countHint}`) + '\n');
|
|
565
|
+
lineCount++;
|
|
566
|
+
const showScrollUp = startIndex > 0;
|
|
567
|
+
const showScrollDown = startIndex + maxVisible < state.filteredIndices.length;
|
|
568
|
+
if (showScrollUp) {
|
|
569
|
+
term.write(Ansi.ClearLine + style.dim(` ${Symbol.Ellipsis} (${startIndex} more above)`) + '\n');
|
|
570
|
+
lineCount++;
|
|
571
|
+
}
|
|
572
|
+
visibleIndices.forEach((actualIndex, i) => {
|
|
573
|
+
const choice = state.choices[actualIndex];
|
|
574
|
+
/* istanbul ignore if -- @preserve defensive: actualIndex always valid from filteredIndices */
|
|
575
|
+
if (!choice)
|
|
576
|
+
return;
|
|
577
|
+
const viewIndex = startIndex + i;
|
|
578
|
+
const isFocused = viewIndex === state.cursor;
|
|
579
|
+
const isSelected = arrayIncludes(state.selected, actualIndex);
|
|
580
|
+
const line = renderChoice$1(choice, isSelected, isFocused);
|
|
581
|
+
term.write(Ansi.ClearLine + line + '\n');
|
|
582
|
+
lineCount++;
|
|
583
|
+
});
|
|
584
|
+
if (showScrollDown) {
|
|
585
|
+
const remaining = state.filteredIndices.length - (startIndex + maxVisible);
|
|
586
|
+
term.write(Ansi.ClearLine + style.dim(` ${Symbol.Ellipsis} (${remaining} more below)`) + '\n');
|
|
587
|
+
lineCount++;
|
|
588
|
+
}
|
|
589
|
+
if (state.filteredIndices.length === 0 && config.searchable) {
|
|
590
|
+
term.write(Ansi.ClearLine + style.dim(' No matches found') + '\n');
|
|
591
|
+
lineCount++;
|
|
592
|
+
}
|
|
593
|
+
return lineCount;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Processes a keypress and returns updated state.
|
|
597
|
+
*
|
|
598
|
+
* @internal
|
|
599
|
+
* @param key - The key that was pressed
|
|
600
|
+
* @param state - Current prompt state
|
|
601
|
+
* @param config - Prompt configuration
|
|
602
|
+
* @returns Updated state after processing the key
|
|
603
|
+
*/
|
|
604
|
+
function processKey$2(key, state, config) {
|
|
605
|
+
const maxVisible = config.maxVisible ?? 10;
|
|
606
|
+
const total = state.filteredIndices.length;
|
|
607
|
+
if (total === 0 && key !== Key.Backspace && key !== '\b')
|
|
608
|
+
return state;
|
|
609
|
+
if (key === Key.Up) {
|
|
610
|
+
let newCursor = state.cursor - 1;
|
|
611
|
+
while (newCursor >= 0 && state.choices[state.filteredIndices[newCursor] ?? -1]?.disabled) {
|
|
612
|
+
newCursor--;
|
|
613
|
+
}
|
|
614
|
+
if (newCursor < 0)
|
|
615
|
+
return state;
|
|
616
|
+
let newScrollOffset = state.scrollOffset;
|
|
617
|
+
if (newCursor < state.scrollOffset) {
|
|
618
|
+
newScrollOffset = newCursor;
|
|
619
|
+
}
|
|
620
|
+
return freeze({ ...state, cursor: newCursor, scrollOffset: newScrollOffset });
|
|
621
|
+
}
|
|
622
|
+
if (key === Key.Down) {
|
|
623
|
+
let newCursor = state.cursor + 1;
|
|
624
|
+
while (newCursor < total && state.choices[state.filteredIndices[newCursor] ?? -1]?.disabled) {
|
|
625
|
+
newCursor++;
|
|
626
|
+
}
|
|
627
|
+
if (newCursor >= total)
|
|
628
|
+
return state;
|
|
629
|
+
let newScrollOffset = state.scrollOffset;
|
|
630
|
+
if (newCursor >= state.scrollOffset + maxVisible) {
|
|
631
|
+
newScrollOffset = newCursor - maxVisible + 1;
|
|
632
|
+
}
|
|
633
|
+
return freeze({ ...state, cursor: newCursor, scrollOffset: newScrollOffset });
|
|
634
|
+
}
|
|
635
|
+
if (key === Key.Space) {
|
|
636
|
+
const actualIndex = state.filteredIndices[state.cursor];
|
|
637
|
+
if (actualIndex === undefined)
|
|
638
|
+
return state;
|
|
639
|
+
const choice = state.choices[actualIndex];
|
|
640
|
+
/* istanbul ignore if -- @preserve defensive: actualIndex validated above */
|
|
641
|
+
if (!choice || choice.disabled)
|
|
642
|
+
return state;
|
|
643
|
+
const isSelected = arrayIncludes(state.selected, actualIndex);
|
|
644
|
+
if (isSelected) {
|
|
645
|
+
const newSelected = state.selected.filter((i) => i !== actualIndex);
|
|
646
|
+
return freeze({ ...state, selected: newSelected });
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
if (config.max !== undefined && state.selected.length >= config.max) {
|
|
650
|
+
return state;
|
|
651
|
+
}
|
|
652
|
+
const newSelected = [...state.selected, actualIndex];
|
|
653
|
+
return freeze({ ...state, selected: newSelected });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (config.searchable) {
|
|
657
|
+
if (key === Key.Backspace || key === '\b') {
|
|
658
|
+
if (state.searchQuery.length > 0) {
|
|
659
|
+
const newQuery = state.searchQuery.slice(0, -1);
|
|
660
|
+
const newFiltered = filterChoices(state.choices, newQuery);
|
|
661
|
+
return freeze({
|
|
662
|
+
...state,
|
|
663
|
+
searchQuery: newQuery,
|
|
664
|
+
filteredIndices: newFiltered,
|
|
665
|
+
cursor: 0,
|
|
666
|
+
scrollOffset: 0,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return state;
|
|
670
|
+
}
|
|
671
|
+
if (key.length === 1 && key >= ' ' && key !== Key.Space) {
|
|
672
|
+
const newQuery = state.searchQuery + key;
|
|
673
|
+
const newFiltered = filterChoices(state.choices, newQuery);
|
|
674
|
+
return freeze({
|
|
675
|
+
...state,
|
|
676
|
+
searchQuery: newQuery,
|
|
677
|
+
filteredIndices: newFiltered,
|
|
678
|
+
cursor: 0,
|
|
679
|
+
scrollOffset: 0,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return state;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Validates selection count against min/max constraints.
|
|
687
|
+
*
|
|
688
|
+
* @internal
|
|
689
|
+
* @param state - Current prompt state
|
|
690
|
+
* @param config - Prompt configuration with min/max constraints
|
|
691
|
+
* @returns Error message string if validation fails, undefined if valid
|
|
692
|
+
*/
|
|
693
|
+
function validateSelection(state, config) {
|
|
694
|
+
if (config.min !== undefined && state.selected.length < config.min) {
|
|
695
|
+
return `Select at least ${config.min} option${config.min === 1 ? '' : 's'}`;
|
|
696
|
+
}
|
|
697
|
+
if (config.max !== undefined && state.selected.length > config.max) {
|
|
698
|
+
return `Select at most ${config.max} option${config.max === 1 ? '' : 's'}`;
|
|
699
|
+
}
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Prompts for multiple selections from a list of choices.
|
|
704
|
+
*
|
|
705
|
+
* Pure functional prompt with arrow key navigation, space to toggle,
|
|
706
|
+
* scrolling support, min/max constraints, and optional type-to-filter search.
|
|
707
|
+
*
|
|
708
|
+
* @param config - Multiselect prompt configuration
|
|
709
|
+
* @returns Promise resolving to array of selected values or cancellation
|
|
710
|
+
*
|
|
711
|
+
* @example Basic multiselect
|
|
712
|
+
* ```typescript
|
|
713
|
+
* const outcome = await multiselect({
|
|
714
|
+
* message: 'Select toppings:',
|
|
715
|
+
* choices: [
|
|
716
|
+
* { label: 'Cheese', value: 'cheese' },
|
|
717
|
+
* { label: 'Pepperoni', value: 'pepperoni' },
|
|
718
|
+
* { label: 'Mushrooms', value: 'mushrooms' },
|
|
719
|
+
* ],
|
|
720
|
+
* })
|
|
721
|
+
* if (outcome.result === 'submitted') {
|
|
722
|
+
* console.log(`You selected: ${outcome.value.join(', ')}`)
|
|
723
|
+
* }
|
|
724
|
+
* ```
|
|
725
|
+
*
|
|
726
|
+
* @example With search and constraints
|
|
727
|
+
* ```typescript
|
|
728
|
+
* const outcome = await multiselect({
|
|
729
|
+
* message: 'Select features:',
|
|
730
|
+
* choices: features.map((f) => ({ label: f.name, value: f.id })),
|
|
731
|
+
* searchable: true,
|
|
732
|
+
* min: 1,
|
|
733
|
+
* max: 5,
|
|
734
|
+
* })
|
|
735
|
+
* ```
|
|
736
|
+
*
|
|
737
|
+
* @example Pre-selected values
|
|
738
|
+
* ```typescript
|
|
739
|
+
* const outcome = await multiselect({
|
|
740
|
+
* message: 'Select permissions:',
|
|
741
|
+
* choices: permissions,
|
|
742
|
+
* initial: [0, 2], // First and third choices pre-selected
|
|
743
|
+
* })
|
|
744
|
+
* ```
|
|
745
|
+
*/
|
|
746
|
+
async function multiselect(config) {
|
|
747
|
+
const term = createTerminal({ input: config.input, output: config.output });
|
|
748
|
+
let state = createInitialState$2(config);
|
|
749
|
+
let lineCount = 0;
|
|
750
|
+
let errorMessage;
|
|
751
|
+
term.write(Ansi.HideCursor);
|
|
752
|
+
const redraw = (submitted = false) => {
|
|
753
|
+
if (lineCount > 0) {
|
|
754
|
+
term.write(Ansi.cursorUp(lineCount - 1) + Ansi.CursorStart);
|
|
755
|
+
}
|
|
756
|
+
term.write(Ansi.ClearToEnd);
|
|
757
|
+
lineCount = render$2(term, config, state, submitted);
|
|
758
|
+
if (errorMessage && !submitted) {
|
|
759
|
+
term.write(Ansi.ClearLine + style.yellow(` ${errorMessage}`) + '\n');
|
|
760
|
+
lineCount++;
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
redraw();
|
|
764
|
+
while (true) {
|
|
765
|
+
const key = await term.readKey();
|
|
766
|
+
if (term.isCancelled()) {
|
|
767
|
+
if (lineCount > 0) {
|
|
768
|
+
term.write(Ansi.cursorUp(lineCount - 1) + Ansi.CursorStart);
|
|
769
|
+
}
|
|
770
|
+
term.write(Ansi.ClearToEnd);
|
|
771
|
+
term.write(renderMessage(config.message) + renderCancelled() + '\n');
|
|
772
|
+
term.write(Ansi.ShowCursor);
|
|
773
|
+
term.close();
|
|
774
|
+
return freeze({ result: PromptResult.Cancelled, value: undefined });
|
|
775
|
+
}
|
|
776
|
+
if (key === Key.Enter) {
|
|
777
|
+
const validationError = validateSelection(state, config);
|
|
778
|
+
if (validationError) {
|
|
779
|
+
errorMessage = validationError;
|
|
780
|
+
redraw();
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
const selectedValues = state.selected.map((i) => state.choices[i]?.value).filter((v) => v !== undefined);
|
|
784
|
+
if (lineCount > 0) {
|
|
785
|
+
term.write(Ansi.cursorUp(lineCount - 1) + Ansi.CursorStart);
|
|
786
|
+
}
|
|
787
|
+
term.write(Ansi.ClearToEnd);
|
|
788
|
+
render$2(term, config, state, true);
|
|
789
|
+
term.write('\n');
|
|
790
|
+
term.write(Ansi.ShowCursor);
|
|
791
|
+
term.close();
|
|
792
|
+
return freeze({ result: PromptResult.Submitted, value: freeze(selectedValues) });
|
|
793
|
+
}
|
|
794
|
+
errorMessage = undefined;
|
|
795
|
+
state = processKey$2(key, state, config);
|
|
796
|
+
redraw();
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Creates initial state for select prompt.
|
|
802
|
+
*
|
|
803
|
+
* @internal
|
|
804
|
+
* @param config - Prompt configuration
|
|
805
|
+
* @returns Initial select state
|
|
806
|
+
*/
|
|
807
|
+
function createInitialState$1(config) {
|
|
808
|
+
return freeze({
|
|
809
|
+
cursor: config.initial ?? 0,
|
|
810
|
+
choices: config.choices,
|
|
811
|
+
scrollOffset: 0,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Calculates the visible window of choices for scrolling.
|
|
816
|
+
*
|
|
817
|
+
* @internal
|
|
818
|
+
* @param state - Current prompt state
|
|
819
|
+
* @param maxVisible - Maximum number of visible choices
|
|
820
|
+
* @returns Visible choices and their start index
|
|
821
|
+
*/
|
|
822
|
+
function getVisibleChoices(state, maxVisible) {
|
|
823
|
+
const total = state.choices.length;
|
|
824
|
+
if (total <= maxVisible) {
|
|
825
|
+
return { choices: state.choices, startIndex: 0 };
|
|
826
|
+
}
|
|
827
|
+
let startIndex = state.scrollOffset;
|
|
828
|
+
if (state.cursor < startIndex) {
|
|
829
|
+
startIndex = state.cursor;
|
|
830
|
+
}
|
|
831
|
+
else if (state.cursor >= startIndex + maxVisible) {
|
|
832
|
+
startIndex = state.cursor - maxVisible + 1;
|
|
833
|
+
}
|
|
834
|
+
return {
|
|
835
|
+
choices: state.choices.slice(startIndex, startIndex + maxVisible),
|
|
836
|
+
startIndex,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Renders a single choice line with selection styling.
|
|
841
|
+
*
|
|
842
|
+
* @internal
|
|
843
|
+
* @param choice - The choice to render
|
|
844
|
+
* @param isSelected - Whether this choice is selected
|
|
845
|
+
* @param isFocused - Whether cursor is on this choice
|
|
846
|
+
* @returns Formatted choice string
|
|
847
|
+
*/
|
|
848
|
+
function renderChoice(choice, isSelected, isFocused) {
|
|
849
|
+
const pointer = isFocused ? style.cyan(Symbol.Pointer) : ' ';
|
|
850
|
+
const radio = isSelected ? style.green(Symbol.RadioSelected) : style.dim(Symbol.Radio);
|
|
851
|
+
let label = choice.label;
|
|
852
|
+
if (choice.disabled) {
|
|
853
|
+
label = style.dim(label + ' (disabled)');
|
|
854
|
+
}
|
|
855
|
+
else if (isFocused) {
|
|
856
|
+
label = style.cyan(label);
|
|
857
|
+
}
|
|
858
|
+
const hint = choice.hint ? style.dim(` — ${choice.hint}`) : '';
|
|
859
|
+
return `${pointer} ${radio} ${label}${hint}`;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Renders the select prompt to the terminal.
|
|
863
|
+
*
|
|
864
|
+
* @internal
|
|
865
|
+
* @param term - Terminal interface
|
|
866
|
+
* @param config - Prompt configuration
|
|
867
|
+
* @param state - Current prompt state
|
|
868
|
+
* @param submitted - Whether the prompt has been submitted
|
|
869
|
+
* @returns Number of lines rendered
|
|
870
|
+
*/
|
|
871
|
+
function render$1(term, config, state, submitted) {
|
|
872
|
+
const maxVisible = config.maxVisible ?? 10;
|
|
873
|
+
const { choices: visibleChoices, startIndex } = getVisibleChoices(state, maxVisible);
|
|
874
|
+
let output = Ansi.CursorStart + Ansi.ClearLine + renderMessage(config.message);
|
|
875
|
+
if (submitted) {
|
|
876
|
+
const selectedChoice = state.choices[state.cursor];
|
|
877
|
+
/* istanbul ignore next -- @preserve defensive: cursor always within bounds */
|
|
878
|
+
output += renderSubmitted(selectedChoice?.label ?? '');
|
|
879
|
+
term.write(output);
|
|
880
|
+
return 1;
|
|
881
|
+
}
|
|
882
|
+
output += style.dim('(use arrows, enter to select)');
|
|
883
|
+
term.write(output + '\n');
|
|
884
|
+
let lineCount = 1;
|
|
885
|
+
const showScrollUp = startIndex > 0;
|
|
886
|
+
const showScrollDown = startIndex + maxVisible < state.choices.length;
|
|
887
|
+
if (showScrollUp) {
|
|
888
|
+
term.write(Ansi.ClearLine + style.dim(` ${Symbol.Ellipsis} (${startIndex} more above)`) + '\n');
|
|
889
|
+
lineCount++;
|
|
890
|
+
}
|
|
891
|
+
visibleChoices.forEach((choice, i) => {
|
|
892
|
+
const actualIndex = startIndex + i;
|
|
893
|
+
const isFocused = actualIndex === state.cursor;
|
|
894
|
+
const line = renderChoice(choice, isFocused, isFocused);
|
|
895
|
+
term.write(Ansi.ClearLine + line + '\n');
|
|
896
|
+
lineCount++;
|
|
897
|
+
});
|
|
898
|
+
if (showScrollDown) {
|
|
899
|
+
const remaining = state.choices.length - (startIndex + maxVisible);
|
|
900
|
+
term.write(Ansi.ClearLine + style.dim(` ${Symbol.Ellipsis} (${remaining} more below)`) + '\n');
|
|
901
|
+
lineCount++;
|
|
902
|
+
}
|
|
903
|
+
return lineCount;
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Processes a keypress and returns updated state.
|
|
907
|
+
*
|
|
908
|
+
* @internal
|
|
909
|
+
* @param key - The key that was pressed
|
|
910
|
+
* @param state - Current prompt state
|
|
911
|
+
* @param maxVisible - Maximum visible choices for scroll calculation
|
|
912
|
+
* @returns Updated state after processing the key
|
|
913
|
+
*/
|
|
914
|
+
function processKey$1(key, state, maxVisible) {
|
|
915
|
+
const total = state.choices.length;
|
|
916
|
+
if (total === 0)
|
|
917
|
+
return state;
|
|
918
|
+
if (key === Key.Up) {
|
|
919
|
+
let newCursor = state.cursor - 1;
|
|
920
|
+
while (newCursor >= 0 && state.choices[newCursor]?.disabled) {
|
|
921
|
+
newCursor--;
|
|
922
|
+
}
|
|
923
|
+
if (newCursor < 0)
|
|
924
|
+
return state;
|
|
925
|
+
let newScrollOffset = state.scrollOffset;
|
|
926
|
+
if (newCursor < state.scrollOffset) {
|
|
927
|
+
newScrollOffset = newCursor;
|
|
928
|
+
}
|
|
929
|
+
return freeze({ ...state, cursor: newCursor, scrollOffset: newScrollOffset });
|
|
930
|
+
}
|
|
931
|
+
if (key === Key.Down) {
|
|
932
|
+
let newCursor = state.cursor + 1;
|
|
933
|
+
while (newCursor < total && state.choices[newCursor]?.disabled) {
|
|
934
|
+
newCursor++;
|
|
935
|
+
}
|
|
936
|
+
if (newCursor >= total)
|
|
937
|
+
return state;
|
|
938
|
+
let newScrollOffset = state.scrollOffset;
|
|
939
|
+
if (newCursor >= state.scrollOffset + maxVisible) {
|
|
940
|
+
newScrollOffset = newCursor - maxVisible + 1;
|
|
941
|
+
}
|
|
942
|
+
return freeze({ ...state, cursor: newCursor, scrollOffset: newScrollOffset });
|
|
943
|
+
}
|
|
944
|
+
return state;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Prompts for single selection from a list of choices.
|
|
948
|
+
*
|
|
949
|
+
* Pure functional prompt with arrow key navigation, scrolling support,
|
|
950
|
+
* and optional disabled choices.
|
|
951
|
+
*
|
|
952
|
+
* @param config - Select prompt configuration
|
|
953
|
+
* @returns Promise resolving to selected value or cancellation
|
|
954
|
+
*
|
|
955
|
+
* @example Basic select
|
|
956
|
+
* ```typescript
|
|
957
|
+
* const outcome = await select({
|
|
958
|
+
* message: 'Choose a color:',
|
|
959
|
+
* choices: [
|
|
960
|
+
* { label: 'Red', value: 'red' },
|
|
961
|
+
* { label: 'Green', value: 'green' },
|
|
962
|
+
* { label: 'Blue', value: 'blue' },
|
|
963
|
+
* ],
|
|
964
|
+
* })
|
|
965
|
+
* if (outcome.result === 'submitted') {
|
|
966
|
+
* console.log(`You chose: ${outcome.value}`)
|
|
967
|
+
* }
|
|
968
|
+
* ```
|
|
969
|
+
*
|
|
970
|
+
* @example With hints and disabled options
|
|
971
|
+
* ```typescript
|
|
972
|
+
* const outcome = await select({
|
|
973
|
+
* message: 'Select plan:',
|
|
974
|
+
* choices: [
|
|
975
|
+
* { label: 'Free', value: 'free', hint: '$0/month' },
|
|
976
|
+
* { label: 'Pro', value: 'pro', hint: '$10/month' },
|
|
977
|
+
* { label: 'Enterprise', value: 'enterprise', disabled: true },
|
|
978
|
+
* ],
|
|
979
|
+
* initial: 1, // Start on Pro
|
|
980
|
+
* })
|
|
981
|
+
* ```
|
|
982
|
+
*/
|
|
983
|
+
async function select(config) {
|
|
984
|
+
const term = createTerminal({ input: config.input, output: config.output });
|
|
985
|
+
const maxVisible = config.maxVisible ?? 10;
|
|
986
|
+
let state = createInitialState$1(config);
|
|
987
|
+
let lineCount = 0;
|
|
988
|
+
term.write(Ansi.HideCursor);
|
|
989
|
+
const redraw = (submitted = false) => {
|
|
990
|
+
if (lineCount > 0) {
|
|
991
|
+
term.write(Ansi.cursorUp(lineCount - 1) + Ansi.CursorStart);
|
|
992
|
+
}
|
|
993
|
+
lineCount = render$1(term, config, state, submitted);
|
|
994
|
+
};
|
|
995
|
+
redraw();
|
|
996
|
+
while (true) {
|
|
997
|
+
const key = await term.readKey();
|
|
998
|
+
if (term.isCancelled()) {
|
|
999
|
+
if (lineCount > 0) {
|
|
1000
|
+
term.write(Ansi.cursorUp(lineCount - 1) + Ansi.CursorStart);
|
|
1001
|
+
}
|
|
1002
|
+
term.write(Ansi.ClearToEnd);
|
|
1003
|
+
term.write(renderMessage(config.message) + renderCancelled() + '\n');
|
|
1004
|
+
term.write(Ansi.ShowCursor);
|
|
1005
|
+
term.close();
|
|
1006
|
+
return freeze({ result: PromptResult.Cancelled, value: undefined });
|
|
1007
|
+
}
|
|
1008
|
+
if (key === Key.Enter) {
|
|
1009
|
+
const selectedChoice = state.choices[state.cursor];
|
|
1010
|
+
if (!selectedChoice || selectedChoice.disabled) {
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
if (lineCount > 0) {
|
|
1014
|
+
term.write(Ansi.cursorUp(lineCount - 1) + Ansi.CursorStart);
|
|
1015
|
+
}
|
|
1016
|
+
term.write(Ansi.ClearToEnd);
|
|
1017
|
+
render$1(term, config, state, true);
|
|
1018
|
+
term.write('\n');
|
|
1019
|
+
term.write(Ansi.ShowCursor);
|
|
1020
|
+
term.close();
|
|
1021
|
+
return freeze({ result: PromptResult.Submitted, value: selectedChoice.value });
|
|
1022
|
+
}
|
|
1023
|
+
state = processKey$1(key, state, maxVisible);
|
|
1024
|
+
redraw();
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Safe copies of Math built-in methods.
|
|
1030
|
+
*
|
|
1031
|
+
* These references are captured at module initialization time to protect against
|
|
1032
|
+
* prototype pollution attacks. Import only what you need for tree-shaking.
|
|
1033
|
+
*
|
|
1034
|
+
* @module @hyperfrontend/immutable-api-utils/built-in-copy/math
|
|
1035
|
+
*/
|
|
1036
|
+
const _Math = globalThis.Math;
|
|
1037
|
+
/**
|
|
1038
|
+
* (Safe copy) Returns the larger of zero or more numbers.
|
|
1039
|
+
*/
|
|
1040
|
+
const max = _Math.max;
|
|
1041
|
+
/**
|
|
1042
|
+
* (Safe copy) Returns the smaller of zero or more numbers.
|
|
1043
|
+
*/
|
|
1044
|
+
const min = _Math.min;
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Creates initial state for text prompt.
|
|
1048
|
+
*
|
|
1049
|
+
* @internal
|
|
1050
|
+
* @param config - Prompt configuration containing initial value
|
|
1051
|
+
* @returns Initial text state object
|
|
1052
|
+
*/
|
|
1053
|
+
function createInitialState(config) {
|
|
1054
|
+
return {
|
|
1055
|
+
value: config.initial ?? '',
|
|
1056
|
+
cursorPos: (config.initial ?? '').length,
|
|
1057
|
+
error: undefined,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Renders the text prompt to the terminal.
|
|
1062
|
+
*
|
|
1063
|
+
* @internal
|
|
1064
|
+
* @param term - Terminal interface for output
|
|
1065
|
+
* @param config - Prompt configuration
|
|
1066
|
+
* @param state - Current prompt state
|
|
1067
|
+
* @param submitted - Whether the prompt has been submitted
|
|
1068
|
+
* @returns Number of lines rendered
|
|
1069
|
+
*/
|
|
1070
|
+
function render(term, config, state, submitted) {
|
|
1071
|
+
const displayValue = config.format ? config.format(state.value) : state.value;
|
|
1072
|
+
let output = renderMessage(config.message);
|
|
1073
|
+
if (submitted) {
|
|
1074
|
+
output += renderSubmitted(displayValue || config.initial || '');
|
|
1075
|
+
}
|
|
1076
|
+
else {
|
|
1077
|
+
output += displayValue;
|
|
1078
|
+
if (config.initial && !state.value) {
|
|
1079
|
+
output += style.dim(config.initial);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
term.write(Ansi.CursorStart + Ansi.ClearLine + output);
|
|
1083
|
+
if (state.error) {
|
|
1084
|
+
term.write('\n' + style.yellow(` ${state.error}`));
|
|
1085
|
+
return 2;
|
|
1086
|
+
}
|
|
1087
|
+
return 1;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Processes a keypress and returns updated state.
|
|
1091
|
+
*
|
|
1092
|
+
* @internal
|
|
1093
|
+
* @param key - The key that was pressed
|
|
1094
|
+
* @param state - Current prompt state
|
|
1095
|
+
* @returns Updated state after processing the key
|
|
1096
|
+
*/
|
|
1097
|
+
function processKey(key, state) {
|
|
1098
|
+
if (key.length === 1 && key >= ' ' && key !== Key.Backspace) {
|
|
1099
|
+
const before = state.value.slice(0, state.cursorPos);
|
|
1100
|
+
const after = state.value.slice(state.cursorPos);
|
|
1101
|
+
return {
|
|
1102
|
+
...state,
|
|
1103
|
+
value: before + key + after,
|
|
1104
|
+
cursorPos: state.cursorPos + 1,
|
|
1105
|
+
error: undefined,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
if (key === Key.Backspace || key === '\b') {
|
|
1109
|
+
if (state.cursorPos > 0) {
|
|
1110
|
+
const before = state.value.slice(0, state.cursorPos - 1);
|
|
1111
|
+
const after = state.value.slice(state.cursorPos);
|
|
1112
|
+
return {
|
|
1113
|
+
...state,
|
|
1114
|
+
value: before + after,
|
|
1115
|
+
cursorPos: state.cursorPos - 1,
|
|
1116
|
+
error: undefined,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
if (key === Key.Left) {
|
|
1121
|
+
return {
|
|
1122
|
+
...state,
|
|
1123
|
+
cursorPos: max(0, state.cursorPos - 1),
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
if (key === Key.Right) {
|
|
1127
|
+
return {
|
|
1128
|
+
...state,
|
|
1129
|
+
cursorPos: min(state.value.length, state.cursorPos + 1),
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
return state;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Prompts for text input with optional validation.
|
|
1136
|
+
*
|
|
1137
|
+
* Pure functional prompt that reads text from the user with support for
|
|
1138
|
+
* default values, input validation, and display formatting.
|
|
1139
|
+
*
|
|
1140
|
+
* @param config - Text prompt configuration
|
|
1141
|
+
* @returns Promise resolving to submitted value or cancellation
|
|
1142
|
+
*
|
|
1143
|
+
* @example Basic text input
|
|
1144
|
+
* ```typescript
|
|
1145
|
+
* const outcome = await text({ message: 'What is your name?' })
|
|
1146
|
+
* if (outcome.result === 'submitted') {
|
|
1147
|
+
* console.log(`Hello, ${outcome.value}!`)
|
|
1148
|
+
* }
|
|
1149
|
+
* ```
|
|
1150
|
+
*
|
|
1151
|
+
* @example With validation
|
|
1152
|
+
* ```typescript
|
|
1153
|
+
* const outcome = await text({
|
|
1154
|
+
* message: 'Enter email:',
|
|
1155
|
+
* validate: (value) => {
|
|
1156
|
+
* if (!value.includes('@')) return 'Must be a valid email'
|
|
1157
|
+
* return undefined
|
|
1158
|
+
* },
|
|
1159
|
+
* })
|
|
1160
|
+
* ```
|
|
1161
|
+
*
|
|
1162
|
+
* @example Password input with masking
|
|
1163
|
+
* ```typescript
|
|
1164
|
+
* const outcome = await text({
|
|
1165
|
+
* message: 'Password:',
|
|
1166
|
+
* format: (value) => '*'.repeat(value.length),
|
|
1167
|
+
* })
|
|
1168
|
+
* ```
|
|
1169
|
+
*/
|
|
1170
|
+
async function text(config) {
|
|
1171
|
+
const term = createTerminal({ input: config.input, output: config.output });
|
|
1172
|
+
let state = createInitialState(config);
|
|
1173
|
+
let lineCount = 0;
|
|
1174
|
+
term.write(Ansi.HideCursor);
|
|
1175
|
+
const redraw = (submitted = false) => {
|
|
1176
|
+
if (lineCount > 0) {
|
|
1177
|
+
term.clearLines(lineCount);
|
|
1178
|
+
}
|
|
1179
|
+
lineCount = render(term, config, state, submitted);
|
|
1180
|
+
};
|
|
1181
|
+
redraw();
|
|
1182
|
+
while (true) {
|
|
1183
|
+
const key = await term.readKey();
|
|
1184
|
+
if (term.isCancelled()) {
|
|
1185
|
+
redraw();
|
|
1186
|
+
term.write(renderCancelled() + '\n');
|
|
1187
|
+
term.close();
|
|
1188
|
+
return freeze({ result: PromptResult.Cancelled, value: undefined });
|
|
1189
|
+
}
|
|
1190
|
+
if (key === Key.Enter) {
|
|
1191
|
+
const value = state.value || config.initial || '';
|
|
1192
|
+
if (config.validate) {
|
|
1193
|
+
const errorMessage = config.validate(value);
|
|
1194
|
+
if (errorMessage) {
|
|
1195
|
+
state = { ...state, error: errorMessage };
|
|
1196
|
+
redraw();
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
redraw(true);
|
|
1201
|
+
term.write('\n');
|
|
1202
|
+
term.close();
|
|
1203
|
+
return freeze({ result: PromptResult.Submitted, value });
|
|
1204
|
+
}
|
|
1205
|
+
state = processKey(key, state);
|
|
1206
|
+
redraw();
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
export { PromptResult, confirm, multiselect, select, text };
|
|
1211
|
+
//# sourceMappingURL=index.esm.js.map
|