@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.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