@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 +8 -0
- package/FUNDING.md +1 -1
- package/README.md +49 -39
- package/SECURITY.md +4 -4
- package/_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/error/index.cjs.js +6 -0
- package/_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/error/index.esm.js +5 -0
- package/_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/number/index.cjs.js +5 -0
- package/_dependencies/@hyperfrontend/immutable-api-utils/built-in-copy/number/index.esm.js +4 -0
- package/index.cjs.js +83 -8
- package/index.d.ts +2 -0
- package/index.esm.js +83 -8
- package/package.json +1 -1
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
|
-
**[
|
|
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
|
-
|
|
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
|
|
47
|
-
- **Composable API
|
|
48
|
-
- **Type-Safe
|
|
49
|
-
- **Zero External Dependencies
|
|
50
|
-
- **Searchable Multiselect
|
|
51
|
-
- **Clipboard Paste
|
|
52
|
-
- **
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
- **
|
|
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
|
|
69
|
-
2. **Composable
|
|
70
|
-
3. **Cancellable
|
|
71
|
-
4. **Lightweight
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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