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