@hyperfrontend/questions 0.3.0 → 0.3.1

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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.1](https://github.com/AndrewRedican/hyperfrontend/compare/135d10985b6f139f816af06c6e3996abf338d21d...adf0a4f77dece2be855e7ab88185a4fe84e7b16b) - 2026-09-14
6
+
7
+ ### Bug Fixes
8
+
9
+ - reject a choice prompt no keypress could resolve
10
+ - submit the text before a trailing line ending in the same chunk
11
+ - cancel a waiting prompt when the input ends
12
+
5
13
  ## [0.3.0](https://github.com/AndrewRedican/hyperfrontend/compare/5f116abb8ba6355dfb283fa03b7481e5eb029480...7e783d3d9e6b6b3a42d226b44256b943958a5813) - 2026-08-04
6
14
 
7
15
  ### Features
package/FUNDING.md CHANGED
@@ -38,7 +38,7 @@ GitHub Sponsors benefits:
38
38
 
39
39
  You can also make a one-time contribution:
40
40
 
41
- **[Donate via hyperfrontend.dev](https://hyperfrontend.dev/sponsor)**
41
+ **[Make a one-time donation via GitHub Sponsors](https://github.com/sponsors/AndrewRedican)**
42
42
 
43
43
  ### 🏢 Corporate Sponsorship
44
44
 
package/README.md CHANGED
@@ -1,4 +1,8 @@
1
- # @hyperfrontend/questions
1
+ <p align="center">
2
+ <a href="https://www.hyperfrontend.dev/docs/libraries/questions/">
3
+ <img width="640" height="180" src="https://www.hyperfrontend.dev/media/banner-questions/banner.gif" alt="@hyperfrontend/questions">
4
+ </a>
5
+ </p>
2
6
 
3
7
  <p align="center">
4
8
  <a href="https://github.com/AndrewRedican/hyperfrontend/actions/workflows/ci-lib-questions.yml">
@@ -33,9 +37,16 @@
33
37
  <img src="https://img.shields.io/badge/tree%20shakeable-%E2%9C%93-success?style=flat-square" alt="Tree Shakeable">
34
38
  </p>
35
39
 
40
+ <p align="center">
41
+ <a href="https://www.hyperfrontend.dev/docs/libraries/questions/">
42
+ <img width="640" height="360" src="https://www.hyperfrontend.dev/media/questions-prompt/hero.gif" alt="A multiselect prompt being programmed on the left and answered on the right, ending in a submitted result object">
43
+ </a>
44
+ </p>
45
+
36
46
  Terminal prompting library with composable, functional API for text, select, confirm, and multiselect prompts
37
47
 
38
48
  • 👉 See [**documentation**](https://www.hyperfrontend.dev/docs/libraries/questions/)
49
+ • 👉 See [**guides & tutorials**](https://www.hyperfrontend.dev/docs/guides/?package=%40hyperfrontend%2Fquestions)
39
50
 
40
51
  ## What is @hyperfrontend/questions?
41
52
 
@@ -43,32 +54,30 @@ A terminal prompting library built on functional programming principles. Create
43
54
 
44
55
  ### Key Features
45
56
 
46
- - **Pure Functions** Every prompt is a pure function returning `Promise<PromptOutcome<T>>`, making results predictable and easily testable
47
- - **Composable API** Build complex interactive flows by combining simple prompt functions
48
- - **Type-Safe** Full TypeScript support with discriminated unions for prompt outcomes
49
- - **Zero External Dependencies** Uses only Node.js built-ins and `@hyperfrontend` utilities
50
- - **Searchable Multiselect** Type-to-filter functionality for large option lists
51
- - **Clipboard Paste** Bracketed paste mode on TTYs (with a multi-character-chunk fallback elsewhere); pasted text is sanitized and never auto-submits
52
- - **Resize-Aware Rendering** Prompts hard-wrap to the terminal width and repaint on resize, preserving value, cursor, selection, and validation state
57
+ - **Pure Functions**: Every prompt is a pure function returning `Promise<PromptOutcome<T>>`, making results predictable and easily testable
58
+ - **[Composable API](https://www.hyperfrontend.dev/docs/guides/build-a-setup-wizard-for-your-cli/)**: Build complex interactive flows by combining simple prompt functions
59
+ - **[Type-Safe](https://www.hyperfrontend.dev/docs/libraries/questions/#api-PromptOutcome)**: Full TypeScript support with discriminated unions for prompt outcomes
60
+ - **Zero External Dependencies**: Uses only Node.js built-ins and `@hyperfrontend` utilities
61
+ - **[Searchable Multiselect](https://www.hyperfrontend.dev/docs/libraries/questions/#api-multiselect)**: Type-to-filter functionality for large option lists
62
+ - **Clipboard Paste**: Bracketed paste mode on TTYs (with a multi-character-chunk fallback elsewhere); pasted text is sanitized, and a bracketed paste never auto-submits
63
+ - **End of input is a cancellation**: when the input stream ends, a waiting prompt resolves as cancelled instead of waiting for a key that can never arrive
64
+ - **Unanswerable configurations throw**: a choice prompt with no choices, or a starting index outside its list, rejects with an error rather than painting a frame nothing can answer
65
+ - **Resize-Aware Rendering**: Prompts hard-wrap to the terminal width and repaint on resize, preserving value, cursor, selection, and validation state
53
66
 
54
67
  ### Architecture Highlights
55
68
 
56
- Each prompt follows a functional state machine pattern:
57
-
58
- - **Immutable State** All prompt state is frozen; updates create new state objects
59
- - **Explicit Outcomes** — Prompts return either `{ result: 'submitted', value: T }` or `{ result: 'cancelled', value: undefined }`
60
- - **Terminal Abstraction** — Low-level I/O is encapsulated in a `Terminal` interface for testability
61
- - **Token Input Stream** — Raw input is tokenized into keys, pastes, and resize notifications by a persistent listener, with raw mode held for the whole prompt session and restored on close
62
- - **Frame Renderer** — A width-aware screen helper erases and repaints exact frames; on resize it recomputes the previous frame's height at the new width (assumes a reflowing terminal; display width is code-point based, east-asian double width out of scope)
69
+ - **Explicit outcomes**: prompts resolve to either `{ result: 'submitted', value: T }` or `{ result: 'cancelled', value: undefined }`, so Ctrl+C is an ordinary branch to handle rather than a rejection to catch
70
+ - **Terminal state is restored**: raw mode is taken once for the whole prompt session and given back when it closes, on cancel as well as on submit
71
+ - **Rendering assumptions**: repainting on resize assumes a reflowing terminal, and display width is counted in code points, so east-asian double-width characters are out of scope
63
72
 
64
73
  ## Why Use @hyperfrontend/questions?
65
74
 
66
75
  When building CLI tools, you need interactive prompts that are:
67
76
 
68
- 1. **Predictable** Know exactly what a prompt returns, always
69
- 2. **Composable** Chain prompts without callback hell
70
- 3. **Cancellable** Handle Ctrl+C gracefully with structured cancellation
71
- 4. **Lightweight** No large dependency trees for simple prompts
77
+ 1. **Predictable**: Know exactly what a prompt returns, always
78
+ 2. **Composable**: Chain prompts without callback hell
79
+ 3. **Cancellable**: Handle Ctrl+C gracefully with structured cancellation
80
+ 4. **Lightweight**: No large dependency trees for simple prompts
72
81
 
73
82
  This library provides all four while staying true to functional programming principles.
74
83
 
@@ -93,7 +102,7 @@ if (nameResult.result === PromptResult.Submitted) {
93
102
  console.log(`Hello, ${nameResult.value}!`)
94
103
  }
95
104
 
96
- // Text input with a live label `renderMessage` is recomputed on every keystroke
105
+ // Text input with a live label; `renderMessage` is recomputed on every keystroke
97
106
  import { style } from '@hyperfrontend/questions'
98
107
 
99
108
  await text({
@@ -135,33 +144,34 @@ const featuresResult = await multiselect({
135
144
 
136
145
  ## API Overview
137
146
 
138
- | Function | Description |
139
- | -------------- | ----------------------------------------------------------------------------- |
140
- | `text` | Free-form text input with optional validation and live-updating labels |
141
- | `confirm` | Yes/no confirmation prompt |
142
- | `select` | Single selection from a list of choices |
143
- | `multiselect` | Multiple selections with optional search |
144
- | `style` | ANSI colour helpers (`green`, `yellow`, `red`, `cyan`, `bold`, `dim`, `gray`) |
145
- | `PromptResult` | Discriminated union: `'submitted' \| 'cancelled'` |
146
-
147
- All prompts return `Promise<PromptOutcome<T>>` where:
147
+ Four prompts, one shape. [`text`](https://www.hyperfrontend.dev/docs/libraries/questions/#api-text), [`confirm`](https://www.hyperfrontend.dev/docs/libraries/questions/#api-confirm), [`select`](https://www.hyperfrontend.dev/docs/libraries/questions/#api-select) and [`multiselect`](https://www.hyperfrontend.dev/docs/libraries/questions/#api-multiselect) each take a config object and resolve to the same discriminated union, so the code that reads an answer is the same code whichever question asked it:
148
148
 
149
149
  ```typescript
150
150
  type PromptOutcome<T> = { result: 'submitted'; value: T } | { result: 'cancelled'; value: undefined }
151
151
  ```
152
152
 
153
+ Two things sit beside them. [`style`](https://www.hyperfrontend.dev/docs/libraries/questions/#api-style) is the ANSI colour helper the prompts use on their own labels, exposed so yours can match. And every config takes [`input`](https://www.hyperfrontend.dev/docs/libraries/questions/#api-PromptConfig-prop-input) and [`output`](https://www.hyperfrontend.dev/docs/libraries/questions/#api-PromptConfig-prop-output) streams, which is what makes a prompt testable without a TTY: hand it a pair of `PassThrough`s, write keystrokes into one and read frames out of the other.
154
+
155
+ Every config, option and outcome type is in the [API reference](https://www.hyperfrontend.dev/docs/libraries/questions/#api-reference).
156
+
153
157
  ## Compatibility
154
158
 
155
- | Environment | Supported |
156
- | ------------------------------ | --------- |
157
- | Node.js >= 18 | ✅ |
158
- | TTY Terminal | ✅ |
159
- | Bracketed paste (TTY) | ✅ |
160
- | Resize redraw (SIGWINCH) | ✅ |
161
- | Non-TTY streams (tests, pipes) | ✅ |
162
- | Tree Shakeable | ✅ |
159
+ <p align="center">
160
+ <a href="https://www.hyperfrontend.dev/docs/libraries/questions/#compatibility">
161
+ <img width="640" height="150" src="https://www.hyperfrontend.dev/media/runtimes-questions/runtimes.png" alt="Runs in Node.js 18 or later; not a target for evergreen browsers and web workers">
162
+ </a>
163
+ </p>
164
+
165
+ Prompts run on a TTY, where the session redraws when the terminal is resized, and on non-TTY streams such as tests and pipes. On TTY inputs a prompt session enables bracketed paste mode (`ESC[?2004h`) and restores it on close; terminals without bracketed paste still paste correctly because multi-character input chunks are treated as pastes. Single-line prompts collapse pasted newlines into spaces, so a bracketed paste can never submit a value. Where bracketed paste is unavailable, a chunk that ends in a line ending submits the text before it, which is how a line-buffered reader behaves and is what makes piped input work; line endings in the middle of such a chunk stay data.
166
+
167
+ ### Output Formats
168
+
169
+ | Format | File | Tree-Shakeable |
170
+ | ------ | -------------- | :------------: |
171
+ | ESM | `index.esm.js` | ✅ |
172
+ | CJS | `index.cjs.js` | ❌ |
163
173
 
164
- On TTY inputs a prompt session enables bracketed paste mode (`ESC[?2004h`) and restores it on close; terminals without bracketed paste still paste correctly because multi-character input chunks are treated as pastes. Single-line prompts collapse pasted newlines into spaces, so pasting can never submit a value.
174
+ No browser bundle is published: the library drives a terminal, so an IIFE or UMD build would have nothing to run against.
165
175
 
166
176
  ## License
167
177
 
package/SECURITY.md CHANGED
@@ -7,18 +7,18 @@ Before reporting or reviewing, read the model this project is built on:
7
7
  statement of what hyperfrontend defends against and what it does not, and it is the reference every
8
8
  other security claim in these docs points back to. In short:
9
9
 
10
- - **The named adversary is co-resident script** an analytics snippet, tag manager, compromised
10
+ - **The named adversary is co-resident script**: an analytics snippet, tag manager, compromised
11
11
  dependency, or unknown page that embeds a feature URL. A host that deliberately installs a feature
12
12
  is trusting it, the way it trusts any dependency; the controls exist to bound a trusted
13
13
  feature's bad day, not to treat its authors as hostile.
14
14
  - **Origin checks authenticate rooms, not speakers.** Once arbitrary script runs inside a page, no
15
15
  message check distinguishes it from the application. Threats inside a page need Content Security
16
- Policy, Trusted Types, dependency provenance, and server-side authorisation a different
16
+ Policy, Trusted Types, dependency provenance, and server-side authorisation. That is a different
17
17
  treatment, deliberately outside this model.
18
18
  - **Three parties carry the security of an integration.** The browser enforces document isolation
19
19
  and the frame's capability attributes; the protocol enforces the relationship (pinned
20
20
  counterparts, gated handshake, validated payloads, versioned contracts, an optional encrypted
21
- envelope); and **you** decide authorisation `frame-ancestors`, backend checks, HTTPS, the
21
+ envelope); and **you** decide authorisation: `frame-ancestors`, backend checks, HTTPS, the
22
22
  envelope you choose, and the containment posture you set.
23
23
 
24
24
  A vulnerability report is most useful when it names which of those three the issue defeats.
@@ -93,7 +93,7 @@ ends — is on by default and is not something you should be re-implementing by
93
93
  Protected work needs credentials the feature's own backend validates.
94
94
  3. **Choose the envelope deliberately.** `v2` with a pre-shared key is the confidentiality control;
95
95
  `v1` is time-window obfuscation and buys deterrence only. Provision and rotate the `v2` key
96
- yourself a key is never baked into a built artifact. A handshake that cannot agree on an
96
+ yourself; a key is never baked into a built artifact. A handshake that cannot agree on an
97
97
  encrypted transport falls back to plaintext; where that would be unacceptable, drive
98
98
  `@hyperfrontend/nexus` directly and set `security.mode: 'fail-closed'` on the channel so the
99
99
  connection is denied instead.
@@ -0,0 +1,6 @@
1
+ 'use strict';
2
+
3
+ const _Error = globalThis.Error;
4
+ const _Reflect = globalThis.Reflect;
5
+ const createError = (message, options) => _Reflect.construct(_Error, [message, options]);
6
+ exports.createError = createError;
@@ -0,0 +1,5 @@
1
+ const _Error = globalThis.Error;
2
+ const _Reflect = globalThis.Reflect;
3
+ const createError = (message, options) => _Reflect.construct(_Error, [message, options]);
4
+
5
+ export { createError };
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ const _Number = globalThis.Number;
4
+ const isInteger = _Number.isInteger;
5
+ exports.isInteger = isInteger;
@@ -0,0 +1,4 @@
1
+ const _Number = globalThis.Number;
2
+ const isInteger = _Number.isInteger;
3
+
4
+ export { isInteger };
package/index.cjs.js CHANGED
@@ -5,6 +5,8 @@ const node_readline = require('node:readline');
5
5
  const node_string_decoder = require('node:string_decoder');
6
6
  const index_cjs_js$2 = require('./_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/promise/index.cjs.js');
7
7
  const index_cjs_js$1 = require('./_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/math/index.cjs.js');
8
+ const index_cjs_js$3 = require('./_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/error/index.cjs.js');
9
+ const index_cjs_js$4 = require('./_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/number/index.cjs.js');
8
10
 
9
11
  /**
10
12
  * ANSI-aware text measurement helpers.
@@ -121,12 +123,17 @@ function displayWidth(text) {
121
123
  * Handles bracketed paste bodies (accumulated across chunks between
122
124
  * `ESC[200~` and `ESC[201~`), escape-sequence keys, and printable runs.
123
125
  * A multi-character printable run outside bracketed paste is treated as a
124
- * paste from a terminal without bracketed-paste support.
126
+ * paste from a terminal without bracketed-paste support, and the run's
127
+ * trailing line endings become Enter keys.
125
128
  *
126
129
  * @internal
127
130
  */
128
131
  /** Control character sent by Ctrl+C in raw mode. */
129
132
  const CtrlC = '\x03';
133
+ /** Carriage return, the character every prompt reads as Enter. */
134
+ const Enter = '\r';
135
+ /** Line feed, which a pipe sends where a terminal sends a carriage return. */
136
+ const LineFeed = '\n';
130
137
  /** Sequence a bracketed-paste-aware terminal sends before pasted content. */
131
138
  const PasteStart = '\x1B[200~';
132
139
  /** Sequence a bracketed-paste-aware terminal sends after pasted content. */
@@ -187,7 +194,11 @@ function partialMarkerStart(data, from, marker) {
187
194
  * trailing escape is likewise buffered (prompts do not act on a bare Escape
188
195
  * key, so delaying it until the next chunk is unobservable). Ctrl+C outside
189
196
  * a bracketed paste is always its own key token; inside a paste body it is
190
- * plain data.
197
+ * plain data. Outside a bracketed paste, the trailing run of carriage
198
+ * returns and line feeds is split off a printable run and emitted as one
199
+ * Enter key per line ending (`\r\n` counts once), so a single chunk
200
+ * carrying typed text and Enter submits; line endings inside the run stay
201
+ * data.
191
202
  *
192
203
  * @returns Stateful token parser
193
204
  *
@@ -251,9 +262,23 @@ function createTokenParser() {
251
262
  end++;
252
263
  }
253
264
  const run = data.slice(pos, end);
254
- // why: raw mode delivers one keystroke per chunk, so a longer run means the terminal pasted without bracketed-paste support
255
- tokens.push(run.length === 1 ? keyToken(run) : pasteToken(run));
256
265
  pos = end;
266
+ let bodyEnd = run.length;
267
+ // why: a chunk that ends in newlines carries a submit, so the newline run is peeled off the body before the body is classified
268
+ while (bodyEnd > 0 && (run.charAt(bodyEnd - 1) === Enter || run.charAt(bodyEnd - 1) === LineFeed)) {
269
+ bodyEnd--;
270
+ }
271
+ const body = run.slice(0, bodyEnd);
272
+ if (body.length > 0) {
273
+ // why: raw mode delivers one keystroke per chunk, so a longer run means the terminal pasted without bracketed-paste support
274
+ tokens.push(body.length === 1 ? keyToken(body) : pasteToken(body));
275
+ }
276
+ for (let index = bodyEnd; index < run.length; index++) {
277
+ // why: CRLF is one Enter and a lone line feed is the Enter a pipe sends, matching how readline normalises line endings
278
+ if (run.charAt(index) === LineFeed && run.charAt(index - 1) === Enter)
279
+ continue;
280
+ tokens.push(keyToken(Enter));
281
+ }
257
282
  }
258
283
  return index_cjs_js.freeze(tokens);
259
284
  };
@@ -350,7 +375,9 @@ const Ansi = index_cjs_js.freeze({
350
375
  * switched to raw mode for the whole session (restored on `close`),
351
376
  * bracketed paste mode is enabled on TTY inputs, and resize events from the
352
377
  * output surface as resize tokens. Input chunks are tokenized by a
353
- * persistent listener so no chunk is lost between reads.
378
+ * persistent listener so no chunk is lost between reads. When the input
379
+ * ends, the session cancels and delivers a Ctrl+C key so a waiting read
380
+ * resolves.
354
381
  *
355
382
  * @param config - Terminal configuration options
356
383
  * @returns Terminal interface with read/write methods
@@ -414,6 +441,10 @@ function createTerminal(config = {}) {
414
441
  const onResize = () => {
415
442
  deliver(index_cjs_js.freeze([index_cjs_js.freeze({ type: TokenType.Resize })]));
416
443
  };
444
+ const onEnd = () => {
445
+ // why: an ended input can never deliver another key, so a waiting prompt resolves through the cancellation branch instead of hanging
446
+ deliver(index_cjs_js.freeze([index_cjs_js.freeze({ type: TokenType.Key, value: Key.CtrlC })]));
447
+ };
417
448
  const openSession = () => {
418
449
  if (sessionActive)
419
450
  return;
@@ -424,6 +455,7 @@ function createTerminal(config = {}) {
424
455
  write(Ansi.BracketedPasteOn);
425
456
  }
426
457
  input.on('data', onData);
458
+ input.once('end', onEnd);
427
459
  output.on('resize', onResize);
428
460
  input.resume();
429
461
  };
@@ -432,6 +464,7 @@ function createTerminal(config = {}) {
432
464
  return;
433
465
  sessionActive = false;
434
466
  input.removeListener('data', onData);
467
+ input.removeListener('end', onEnd);
435
468
  output.removeListener('resize', onResize);
436
469
  if (input.setRawMode) {
437
470
  write(Ansi.BracketedPasteOff);
@@ -943,6 +976,44 @@ function firstPasteLine(raw) {
943
976
  return out;
944
977
  }
945
978
 
979
+ /**
980
+ * Configuration guards shared by the choice-list prompts.
981
+ *
982
+ * @internal
983
+ */
984
+ /**
985
+ * Rejects a choice-list configuration no keypress could ever resolve: an
986
+ * empty choice list, or a starting index that names no choice. Without the
987
+ * check the prompt paints a frame and waits forever.
988
+ *
989
+ * @param promptName - Prompt name quoted in the error message
990
+ * @param choices - Choices the prompt was configured with
991
+ * @param initial - Indices the prompt is asked to start on
992
+ * @throws {Error} When the choice list is empty, or an initial index is not a whole number inside the list
993
+ *
994
+ * @example Rejecting an empty choice list
995
+ * ```typescript
996
+ * assertResolvableChoices('select', [], [])
997
+ * // => throws Error: select requires at least one choice
998
+ * ```
999
+ *
1000
+ * @example Rejecting an index past the end of the list
1001
+ * ```typescript
1002
+ * assertResolvableChoices('select', [{ label: 'Red', value: 'red' }], [7])
1003
+ * // => throws Error: select initial must be an index between 0 and 0, received 7
1004
+ * ```
1005
+ */
1006
+ function assertResolvableChoices(promptName, choices, initial) {
1007
+ if (choices.length === 0) {
1008
+ throw index_cjs_js$3.createError(`${promptName} requires at least one choice`);
1009
+ }
1010
+ for (const index of initial) {
1011
+ if (!index_cjs_js$4.isInteger(index) || index < 0 || index >= choices.length) {
1012
+ throw index_cjs_js$3.createError(`${promptName} initial must be an index between 0 and ${choices.length - 1}, received ${index}`);
1013
+ }
1014
+ }
1015
+ }
1016
+
946
1017
  /**
947
1018
  * Checks if an array includes a value.
948
1019
  *
@@ -1081,7 +1152,7 @@ function buildLines$1(config, state, maxVisible) {
1081
1152
  }
1082
1153
  visibleIndices.forEach((actualIndex, i) => {
1083
1154
  const choice = state.choices[actualIndex];
1084
- /* istanbul ignore if -- @preserve defensive: actualIndex always valid from filteredIndices */
1155
+ // why: actualIndex always comes from filteredIndices, so this guard is defensive and never taken.
1085
1156
  if (!choice)
1086
1157
  return;
1087
1158
  const isFocused = startIndex + i === state.cursor;
@@ -1162,7 +1233,7 @@ function processKey$2(key, state, config, maxVisible) {
1162
1233
  if (actualIndex === undefined)
1163
1234
  return state;
1164
1235
  const choice = state.choices[actualIndex];
1165
- /* istanbul ignore if -- @preserve defensive: actualIndex validated above */
1236
+ // why: actualIndex is validated above, so this guard is defensive and never taken.
1166
1237
  if (!choice || choice.disabled)
1167
1238
  return state;
1168
1239
  const isSelected = arrayIncludes(state.selected, actualIndex);
@@ -1239,6 +1310,7 @@ function effectiveMaxVisible$1(term, configured) {
1239
1310
  *
1240
1311
  * @param config - Multiselect prompt configuration
1241
1312
  * @returns Promise resolving to array of selected values or cancellation
1313
+ * @throws {Error} When `choices` is empty, or an `initial` entry is not a whole number inside the choice list
1242
1314
  *
1243
1315
  * @example Basic multiselect
1244
1316
  * ```typescript
@@ -1276,6 +1348,7 @@ function effectiveMaxVisible$1(term, configured) {
1276
1348
  * ```
1277
1349
  */
1278
1350
  async function multiselect(config) {
1351
+ assertResolvableChoices('multiselect', config.choices, config.initial ?? []);
1279
1352
  const term = createTerminal({ input: config.input, output: config.output });
1280
1353
  const screen = createScreen(term);
1281
1354
  let state = createInitialState$2(config);
@@ -1443,7 +1516,7 @@ function buildLines(config, state, maxVisible) {
1443
1516
  }
1444
1517
  visibleIndices.forEach((actualIndex, i) => {
1445
1518
  const choice = state.choices[actualIndex];
1446
- /* istanbul ignore if -- @preserve defensive: actualIndex always valid from filteredIndices */
1519
+ // why: actualIndex always comes from filteredIndices, so this guard is defensive and never taken.
1447
1520
  if (!choice)
1448
1521
  return;
1449
1522
  lines.push(renderChoice(choice, startIndex + i === state.cursor));
@@ -1549,6 +1622,7 @@ function processKey$1(key, state, config, maxVisible) {
1549
1622
  *
1550
1623
  * @param config - Select prompt configuration
1551
1624
  * @returns Promise resolving to selected value or cancellation
1625
+ * @throws {Error} When `choices` is empty, or `initial` is not a whole number inside the choice list
1552
1626
  *
1553
1627
  * @example Basic select
1554
1628
  * ```typescript
@@ -1588,6 +1662,7 @@ function processKey$1(key, state, config, maxVisible) {
1588
1662
  * ```
1589
1663
  */
1590
1664
  async function select(config) {
1665
+ assertResolvableChoices('select', config.choices, config.initial === undefined ? [] : [config.initial]);
1591
1666
  const term = createTerminal({ input: config.input, output: config.output });
1592
1667
  const screen = createScreen(term);
1593
1668
  let state = createInitialState$1(config);
package/index.d.ts CHANGED
@@ -150,6 +150,7 @@ declare function confirm(config: ConfirmConfig): Promise<PromptOutcome<boolean>>
150
150
  *
151
151
  * @param config - Multiselect prompt configuration
152
152
  * @returns Promise resolving to array of selected values or cancellation
153
+ * @throws {Error} When `choices` is empty, or an `initial` entry is not a whole number inside the choice list
153
154
  *
154
155
  * @example Basic multiselect
155
156
  * ```typescript
@@ -199,6 +200,7 @@ declare function multiselect<T = string>(config: MultiselectConfig<T>): Promise<
199
200
  *
200
201
  * @param config - Select prompt configuration
201
202
  * @returns Promise resolving to selected value or cancellation
203
+ * @throws {Error} When `choices` is empty, or `initial` is not a whole number inside the choice list
202
204
  *
203
205
  * @example Basic select
204
206
  * ```typescript
package/index.esm.js CHANGED
@@ -3,6 +3,8 @@ import { createInterface } from 'node:readline';
3
3
  import { StringDecoder } from 'node:string_decoder';
4
4
  import { createPromise, promiseResolve } from './_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/promise/index.esm.js';
5
5
  import { min, max, floor, ceil } from './_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/math/index.esm.js';
6
+ import { createError } from './_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/error/index.esm.js';
7
+ import { isInteger } from './_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/number/index.esm.js';
6
8
 
7
9
  /**
8
10
  * ANSI-aware text measurement helpers.
@@ -119,12 +121,17 @@ function displayWidth(text) {
119
121
  * Handles bracketed paste bodies (accumulated across chunks between
120
122
  * `ESC[200~` and `ESC[201~`), escape-sequence keys, and printable runs.
121
123
  * A multi-character printable run outside bracketed paste is treated as a
122
- * paste from a terminal without bracketed-paste support.
124
+ * paste from a terminal without bracketed-paste support, and the run's
125
+ * trailing line endings become Enter keys.
123
126
  *
124
127
  * @internal
125
128
  */
126
129
  /** Control character sent by Ctrl+C in raw mode. */
127
130
  const CtrlC = '\x03';
131
+ /** Carriage return, the character every prompt reads as Enter. */
132
+ const Enter = '\r';
133
+ /** Line feed, which a pipe sends where a terminal sends a carriage return. */
134
+ const LineFeed = '\n';
128
135
  /** Sequence a bracketed-paste-aware terminal sends before pasted content. */
129
136
  const PasteStart = '\x1B[200~';
130
137
  /** Sequence a bracketed-paste-aware terminal sends after pasted content. */
@@ -185,7 +192,11 @@ function partialMarkerStart(data, from, marker) {
185
192
  * trailing escape is likewise buffered (prompts do not act on a bare Escape
186
193
  * key, so delaying it until the next chunk is unobservable). Ctrl+C outside
187
194
  * a bracketed paste is always its own key token; inside a paste body it is
188
- * plain data.
195
+ * plain data. Outside a bracketed paste, the trailing run of carriage
196
+ * returns and line feeds is split off a printable run and emitted as one
197
+ * Enter key per line ending (`\r\n` counts once), so a single chunk
198
+ * carrying typed text and Enter submits; line endings inside the run stay
199
+ * data.
189
200
  *
190
201
  * @returns Stateful token parser
191
202
  *
@@ -249,9 +260,23 @@ function createTokenParser() {
249
260
  end++;
250
261
  }
251
262
  const run = data.slice(pos, end);
252
- // why: raw mode delivers one keystroke per chunk, so a longer run means the terminal pasted without bracketed-paste support
253
- tokens.push(run.length === 1 ? keyToken(run) : pasteToken(run));
254
263
  pos = end;
264
+ let bodyEnd = run.length;
265
+ // why: a chunk that ends in newlines carries a submit, so the newline run is peeled off the body before the body is classified
266
+ while (bodyEnd > 0 && (run.charAt(bodyEnd - 1) === Enter || run.charAt(bodyEnd - 1) === LineFeed)) {
267
+ bodyEnd--;
268
+ }
269
+ const body = run.slice(0, bodyEnd);
270
+ if (body.length > 0) {
271
+ // why: raw mode delivers one keystroke per chunk, so a longer run means the terminal pasted without bracketed-paste support
272
+ tokens.push(body.length === 1 ? keyToken(body) : pasteToken(body));
273
+ }
274
+ for (let index = bodyEnd; index < run.length; index++) {
275
+ // why: CRLF is one Enter and a lone line feed is the Enter a pipe sends, matching how readline normalises line endings
276
+ if (run.charAt(index) === LineFeed && run.charAt(index - 1) === Enter)
277
+ continue;
278
+ tokens.push(keyToken(Enter));
279
+ }
255
280
  }
256
281
  return freeze(tokens);
257
282
  };
@@ -348,7 +373,9 @@ const Ansi = freeze({
348
373
  * switched to raw mode for the whole session (restored on `close`),
349
374
  * bracketed paste mode is enabled on TTY inputs, and resize events from the
350
375
  * output surface as resize tokens. Input chunks are tokenized by a
351
- * persistent listener so no chunk is lost between reads.
376
+ * persistent listener so no chunk is lost between reads. When the input
377
+ * ends, the session cancels and delivers a Ctrl+C key so a waiting read
378
+ * resolves.
352
379
  *
353
380
  * @param config - Terminal configuration options
354
381
  * @returns Terminal interface with read/write methods
@@ -412,6 +439,10 @@ function createTerminal(config = {}) {
412
439
  const onResize = () => {
413
440
  deliver(freeze([freeze({ type: TokenType.Resize })]));
414
441
  };
442
+ const onEnd = () => {
443
+ // why: an ended input can never deliver another key, so a waiting prompt resolves through the cancellation branch instead of hanging
444
+ deliver(freeze([freeze({ type: TokenType.Key, value: Key.CtrlC })]));
445
+ };
415
446
  const openSession = () => {
416
447
  if (sessionActive)
417
448
  return;
@@ -422,6 +453,7 @@ function createTerminal(config = {}) {
422
453
  write(Ansi.BracketedPasteOn);
423
454
  }
424
455
  input.on('data', onData);
456
+ input.once('end', onEnd);
425
457
  output.on('resize', onResize);
426
458
  input.resume();
427
459
  };
@@ -430,6 +462,7 @@ function createTerminal(config = {}) {
430
462
  return;
431
463
  sessionActive = false;
432
464
  input.removeListener('data', onData);
465
+ input.removeListener('end', onEnd);
433
466
  output.removeListener('resize', onResize);
434
467
  if (input.setRawMode) {
435
468
  write(Ansi.BracketedPasteOff);
@@ -941,6 +974,44 @@ function firstPasteLine(raw) {
941
974
  return out;
942
975
  }
943
976
 
977
+ /**
978
+ * Configuration guards shared by the choice-list prompts.
979
+ *
980
+ * @internal
981
+ */
982
+ /**
983
+ * Rejects a choice-list configuration no keypress could ever resolve: an
984
+ * empty choice list, or a starting index that names no choice. Without the
985
+ * check the prompt paints a frame and waits forever.
986
+ *
987
+ * @param promptName - Prompt name quoted in the error message
988
+ * @param choices - Choices the prompt was configured with
989
+ * @param initial - Indices the prompt is asked to start on
990
+ * @throws {Error} When the choice list is empty, or an initial index is not a whole number inside the list
991
+ *
992
+ * @example Rejecting an empty choice list
993
+ * ```typescript
994
+ * assertResolvableChoices('select', [], [])
995
+ * // => throws Error: select requires at least one choice
996
+ * ```
997
+ *
998
+ * @example Rejecting an index past the end of the list
999
+ * ```typescript
1000
+ * assertResolvableChoices('select', [{ label: 'Red', value: 'red' }], [7])
1001
+ * // => throws Error: select initial must be an index between 0 and 0, received 7
1002
+ * ```
1003
+ */
1004
+ function assertResolvableChoices(promptName, choices, initial) {
1005
+ if (choices.length === 0) {
1006
+ throw createError(`${promptName} requires at least one choice`);
1007
+ }
1008
+ for (const index of initial) {
1009
+ if (!isInteger(index) || index < 0 || index >= choices.length) {
1010
+ throw createError(`${promptName} initial must be an index between 0 and ${choices.length - 1}, received ${index}`);
1011
+ }
1012
+ }
1013
+ }
1014
+
944
1015
  /**
945
1016
  * Checks if an array includes a value.
946
1017
  *
@@ -1079,7 +1150,7 @@ function buildLines$1(config, state, maxVisible) {
1079
1150
  }
1080
1151
  visibleIndices.forEach((actualIndex, i) => {
1081
1152
  const choice = state.choices[actualIndex];
1082
- /* istanbul ignore if -- @preserve defensive: actualIndex always valid from filteredIndices */
1153
+ // why: actualIndex always comes from filteredIndices, so this guard is defensive and never taken.
1083
1154
  if (!choice)
1084
1155
  return;
1085
1156
  const isFocused = startIndex + i === state.cursor;
@@ -1160,7 +1231,7 @@ function processKey$2(key, state, config, maxVisible) {
1160
1231
  if (actualIndex === undefined)
1161
1232
  return state;
1162
1233
  const choice = state.choices[actualIndex];
1163
- /* istanbul ignore if -- @preserve defensive: actualIndex validated above */
1234
+ // why: actualIndex is validated above, so this guard is defensive and never taken.
1164
1235
  if (!choice || choice.disabled)
1165
1236
  return state;
1166
1237
  const isSelected = arrayIncludes(state.selected, actualIndex);
@@ -1237,6 +1308,7 @@ function effectiveMaxVisible$1(term, configured) {
1237
1308
  *
1238
1309
  * @param config - Multiselect prompt configuration
1239
1310
  * @returns Promise resolving to array of selected values or cancellation
1311
+ * @throws {Error} When `choices` is empty, or an `initial` entry is not a whole number inside the choice list
1240
1312
  *
1241
1313
  * @example Basic multiselect
1242
1314
  * ```typescript
@@ -1274,6 +1346,7 @@ function effectiveMaxVisible$1(term, configured) {
1274
1346
  * ```
1275
1347
  */
1276
1348
  async function multiselect(config) {
1349
+ assertResolvableChoices('multiselect', config.choices, config.initial ?? []);
1277
1350
  const term = createTerminal({ input: config.input, output: config.output });
1278
1351
  const screen = createScreen(term);
1279
1352
  let state = createInitialState$2(config);
@@ -1441,7 +1514,7 @@ function buildLines(config, state, maxVisible) {
1441
1514
  }
1442
1515
  visibleIndices.forEach((actualIndex, i) => {
1443
1516
  const choice = state.choices[actualIndex];
1444
- /* istanbul ignore if -- @preserve defensive: actualIndex always valid from filteredIndices */
1517
+ // why: actualIndex always comes from filteredIndices, so this guard is defensive and never taken.
1445
1518
  if (!choice)
1446
1519
  return;
1447
1520
  lines.push(renderChoice(choice, startIndex + i === state.cursor));
@@ -1547,6 +1620,7 @@ function processKey$1(key, state, config, maxVisible) {
1547
1620
  *
1548
1621
  * @param config - Select prompt configuration
1549
1622
  * @returns Promise resolving to selected value or cancellation
1623
+ * @throws {Error} When `choices` is empty, or `initial` is not a whole number inside the choice list
1550
1624
  *
1551
1625
  * @example Basic select
1552
1626
  * ```typescript
@@ -1586,6 +1660,7 @@ function processKey$1(key, state, config, maxVisible) {
1586
1660
  * ```
1587
1661
  */
1588
1662
  async function select(config) {
1663
+ assertResolvableChoices('select', config.choices, config.initial === undefined ? [] : [config.initial]);
1589
1664
  const term = createTerminal({ input: config.input, output: config.output });
1590
1665
  const screen = createScreen(term);
1591
1666
  let state = createInitialState$1(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperfrontend/questions",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Terminal prompting library with composable, functional API for text, select, confirm, and multiselect prompts",
5
5
  "license": "MIT",
6
6
  "exports": {