create-pathfinder 1.6.0 → 1.7.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/README.md +20 -3
- package/bin/create-pathfinder.mjs +22 -0
- package/package.json +1 -1
- package/src/cells.mjs +324 -0
- package/src/cli.mjs +46 -18
- package/src/prompt.mjs +116 -11
- package/src/select.mjs +426 -0
- package/src/theme.mjs +241 -13
package/src/prompt.mjs
CHANGED
|
@@ -10,10 +10,31 @@
|
|
|
10
10
|
* nothing must also print nothing, and a guard bug that degrades silently would
|
|
11
11
|
* show up as a script that hangs on someone's CI runner months later.
|
|
12
12
|
*
|
|
13
|
-
* The second is the decided interaction model
|
|
14
|
-
* lines. No setRawMode, no keypress handler, no cursor control, no redraw
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* The second is the decided interaction model. ~~`node:readline` and printed
|
|
14
|
+
* lines. No setRawMode, no keypress handler, no cursor control, no redraw.~~
|
|
15
|
+
* **Superseded in Feature 23**, in two different ways, and the sentence was
|
|
16
|
+
* partly wrong when it was written:
|
|
17
|
+
*
|
|
18
|
+
* - **"No setRawMode" was never true of the process**, only of this file.
|
|
19
|
+
* `node:readline` turns raw mode on itself the moment its input is a TTY, and
|
|
20
|
+
* turns it off again on `close()` — verified across five Node majors during
|
|
21
|
+
* the spike. What is true, and is now the load-bearing statement, is that
|
|
22
|
+
* **readline owns raw mode and Pathfinder never touches it.** The distinction
|
|
23
|
+
* matters: the first is a claim about terminal state that a reader could check
|
|
24
|
+
* and find false, and the second is the reason no interrupt can leave a
|
|
25
|
+
* terminal broken.
|
|
26
|
+
* - **There is now a keypress handler and a redraw**, in `select.mjs`, borrowed
|
|
27
|
+
* from readline and given back. Which is a change of interaction, not of
|
|
28
|
+
* ownership.
|
|
29
|
+
*
|
|
30
|
+
* What has *not* changed is the reason the original sentence existed. The
|
|
31
|
+
* line-based path still works in a dumb terminal, over a pipe, and on a CI
|
|
32
|
+
* runner, and it is still here — every function below keeps its numbered/`y n`
|
|
33
|
+
* implementation intact and reaches for it whenever `theme.selection` is false.
|
|
34
|
+
* That is a supported way to use this tool, reachable deliberately with
|
|
35
|
+
* `PATHFINDER_PROMPT=classic`, and not a fallback anyone should feel they have
|
|
36
|
+
* been demoted to. `text()` never had anything to gain from a keypress loop and
|
|
37
|
+
* is untouched.
|
|
17
38
|
*
|
|
18
39
|
* Answers are bounded. Unparseable input is re-prompted a fixed number of times
|
|
19
40
|
* and then gives up, so an input stream that will never produce a `y` cannot
|
|
@@ -24,14 +45,25 @@
|
|
|
24
45
|
|
|
25
46
|
import { createInterface } from "node:readline";
|
|
26
47
|
|
|
48
|
+
import { alignmentWidth, optionRow, select } from "./select.mjs";
|
|
49
|
+
import { createTheme } from "./theme.mjs";
|
|
50
|
+
|
|
27
51
|
const YES = new Set(["y", "yes"]);
|
|
28
52
|
const NO = new Set(["n", "no"]);
|
|
29
53
|
|
|
30
54
|
/**
|
|
31
55
|
* A question-asker bound to one pair of streams.
|
|
32
56
|
*
|
|
57
|
+
* `theme` decides *how* a question is asked and nothing else. Every function
|
|
58
|
+
* below returns the same values through either path, which is what lets the
|
|
59
|
+
* choice be made here instead of at four call sites — and what lets the whole
|
|
60
|
+
* existing test suite drive the classic path unchanged by simply not supplying
|
|
61
|
+
* one. The default theme knows about no terminal at all, so it answers no to
|
|
62
|
+
* `selection`, which is the conservative answer and the right one for a
|
|
63
|
+
* prompter built from streams nobody has described.
|
|
64
|
+
*
|
|
33
65
|
* @param {{input: NodeJS.ReadableStream, output: NodeJS.WritableStream,
|
|
34
|
-
* interactive?: boolean, retries?: number}} options
|
|
66
|
+
* interactive?: boolean, retries?: number, theme?: object}} options
|
|
35
67
|
* @returns {{interactive: boolean,
|
|
36
68
|
* confirm: (question: string, options?: {defaultAnswer?: boolean}) => Promise<boolean|null>,
|
|
37
69
|
* chooseMany: (question: string, config?: object) => Promise<unknown[]|null>,
|
|
@@ -39,7 +71,13 @@ const NO = new Set(["n", "no"]);
|
|
|
39
71
|
* text: (question: string) => Promise<string|null>,
|
|
40
72
|
* close: () => void}}
|
|
41
73
|
*/
|
|
42
|
-
export function createPrompter({
|
|
74
|
+
export function createPrompter({
|
|
75
|
+
input,
|
|
76
|
+
output,
|
|
77
|
+
interactive = false,
|
|
78
|
+
retries = 3,
|
|
79
|
+
theme = createTheme(),
|
|
80
|
+
}) {
|
|
43
81
|
let reader = null;
|
|
44
82
|
|
|
45
83
|
// Created on first use, not here. A run that never reaches a question — a
|
|
@@ -47,10 +85,23 @@ export function createPrompter({ input, output, interactive = false, retries = 3
|
|
|
47
85
|
// reader to stdin, because attaching one resumes the stream and a process
|
|
48
86
|
// holding an open stdin does not exit on its own.
|
|
49
87
|
function ensureReader() {
|
|
50
|
-
if (reader === null) reader = createReader({ input, output });
|
|
88
|
+
if (reader === null) reader = createReader({ input, output, theme });
|
|
51
89
|
return reader;
|
|
52
90
|
}
|
|
53
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Ask one question with the arrow keys.
|
|
94
|
+
*
|
|
95
|
+
* The Interface is handed over rather than a pair of streams, because the
|
|
96
|
+
* selector's whole contract is that it borrows from an open readline and
|
|
97
|
+
* gives it back. Nothing here consults `input.isTTY`: the decision was made
|
|
98
|
+
* once, in the theme, and a module that re-derived it could disagree with the
|
|
99
|
+
* one place that is allowed to have an opinion — and could not be tested over
|
|
100
|
+
* a pipe at all.
|
|
101
|
+
*/
|
|
102
|
+
const ask = (question, config) =>
|
|
103
|
+
select({ readline: ensureReader().readline, theme, question, ...config });
|
|
104
|
+
|
|
54
105
|
return {
|
|
55
106
|
interactive,
|
|
56
107
|
|
|
@@ -59,6 +110,20 @@ export function createPrompter({ input, output, interactive = false, retries = 3
|
|
|
59
110
|
throw new Error(`refusing to ask "${question}": this prompter is not interactive`);
|
|
60
111
|
}
|
|
61
112
|
|
|
113
|
+
// Two rows rather than a typed letter. `y` and `n` still work and are
|
|
114
|
+
// deliberately not printed: the presented interaction is the one the hint
|
|
115
|
+
// line describes, and an accelerator that has to be advertised is a second
|
|
116
|
+
// thing to learn rather than a shortcut for people who already know it.
|
|
117
|
+
if (theme.selection) {
|
|
118
|
+
return ask(question, {
|
|
119
|
+
options: [
|
|
120
|
+
{ label: "Yes", value: true, key: "y" },
|
|
121
|
+
{ label: "No", value: false, key: "n" },
|
|
122
|
+
],
|
|
123
|
+
initial: [defaultAnswer],
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
62
127
|
const suffix = defaultAnswer ? "[Y/n]" : "[y/N]";
|
|
63
128
|
|
|
64
129
|
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
@@ -103,12 +168,28 @@ export function createPrompter({ input, output, interactive = false, retries = 3
|
|
|
103
168
|
}
|
|
104
169
|
if (choices.length === 0) return [];
|
|
105
170
|
|
|
171
|
+
// A checkbox list, which is what this question always was. Note what does
|
|
172
|
+
// *not* move: the caller still supplies `{label, value}` and still reads
|
|
173
|
+
// back the chosen values in list order, so no decision about what the
|
|
174
|
+
// options mean has crossed into this file.
|
|
175
|
+
if (theme.selection) {
|
|
176
|
+
return ask(question, { options: choices, multi: true, initial: defaultSelection });
|
|
177
|
+
}
|
|
178
|
+
|
|
106
179
|
const defaults = choices.filter((choice) => defaultSelection.includes(choice.value));
|
|
107
180
|
const defaultNumbers = defaults.map((choice) => choices.indexOf(choice) + 1);
|
|
108
181
|
|
|
182
|
+
// The same option grammar the selector uses, inside a numbered list
|
|
183
|
+
// instead of a repainted block. An option that names the path it writes
|
|
184
|
+
// to is not a decoration the keyboard path earned — it is the answer to
|
|
185
|
+
// "what does checking this box do", and both paths owe it.
|
|
186
|
+
const labelWidth = alignmentWidth(choices, theme);
|
|
187
|
+
|
|
109
188
|
const header = [
|
|
110
189
|
`? ${question}`,
|
|
111
|
-
...choices.map(
|
|
190
|
+
...choices.map(
|
|
191
|
+
(choice, index) => ` ${index + 1}. ${optionRow({ theme, option: choice, labelWidth })}`,
|
|
192
|
+
),
|
|
112
193
|
defaultNumbers.length > 0
|
|
113
194
|
? ` Numbers, comma-separated. Enter for the detected default [${defaultNumbers.join(",")}], or 0 for none.`
|
|
114
195
|
: " Numbers, comma-separated. Enter or 0 for none.",
|
|
@@ -158,12 +239,22 @@ export function createPrompter({ input, output, interactive = false, retries = 3
|
|
|
158
239
|
if (choices.length === 0) return null;
|
|
159
240
|
|
|
160
241
|
const fallback = choices.find((choice) => choice.value === defaultValue) ?? choices[0];
|
|
242
|
+
|
|
243
|
+
// The default becomes the highlighted row rather than a number in a
|
|
244
|
+
// sentence, so taking it still costs one keystroke.
|
|
245
|
+
if (theme.selection) {
|
|
246
|
+
return ask(question, { options: choices, initial: [fallback.value] });
|
|
247
|
+
}
|
|
248
|
+
|
|
161
249
|
const defaultNumber = choices.indexOf(fallback) + 1;
|
|
250
|
+
const labelWidth = alignmentWidth(choices, theme);
|
|
162
251
|
|
|
163
252
|
output.write(
|
|
164
253
|
[
|
|
165
254
|
`? ${question}`,
|
|
166
|
-
...choices.map(
|
|
255
|
+
...choices.map(
|
|
256
|
+
(choice, index) => ` ${index + 1}. ${optionRow({ theme, option: choice, labelWidth })}`,
|
|
257
|
+
),
|
|
167
258
|
` A number, or Enter for [${defaultNumber}].`,
|
|
168
259
|
"",
|
|
169
260
|
].join("\n"),
|
|
@@ -266,8 +357,16 @@ export function nonInteractivePrompter() {
|
|
|
266
357
|
* with a closed stdin must fall through to the caller's decision about an
|
|
267
358
|
* unanswered question, not hang.
|
|
268
359
|
*/
|
|
269
|
-
function createReader({ input, output }) {
|
|
270
|
-
|
|
360
|
+
function createReader({ input, output, theme }) {
|
|
361
|
+
// `terminal` is forced only where the selector will run, and only ever from
|
|
362
|
+
// false to true. On a real terminal readline works this out for itself and
|
|
363
|
+
// the flag changes nothing; over a pipe it is what makes the keypress decoder
|
|
364
|
+
// exist at all, which is the difference between a selector that can be tested
|
|
365
|
+
// and one that can only be tried. It is never forced *off*, so no classic run
|
|
366
|
+
// has its line editing altered by this.
|
|
367
|
+
const readline = createInterface(
|
|
368
|
+
theme?.selection ? { input, output, terminal: true } : { input, output },
|
|
369
|
+
);
|
|
271
370
|
const delivered = [];
|
|
272
371
|
const waiting = [];
|
|
273
372
|
let closed = false;
|
|
@@ -284,6 +383,12 @@ function createReader({ input, output }) {
|
|
|
284
383
|
});
|
|
285
384
|
|
|
286
385
|
return {
|
|
386
|
+
// The Interface itself, for the one caller that needs the *owner* rather
|
|
387
|
+
// than a line: the selector borrows this object's listeners and hands them
|
|
388
|
+
// back. Exposed rather than re-created, because a second Interface over the
|
|
389
|
+
// same stdin would be a second thing turning raw mode on and off.
|
|
390
|
+
readline,
|
|
391
|
+
|
|
287
392
|
/**
|
|
288
393
|
* Print `text` and resolve with the next line, or null at end of input.
|
|
289
394
|
*
|
package/src/select.mjs
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A vertical list you answer with the arrow keys, drawn on a terminal that
|
|
3
|
+
* belongs to somebody else.
|
|
4
|
+
*
|
|
5
|
+
* The whole design follows from one decision: **readline stays the owner of raw
|
|
6
|
+
* mode for the entire run.** `setRawMode` is never called here, or anywhere in
|
|
7
|
+
* this package. `createInterface` turns raw mode on because stdin is a TTY and
|
|
8
|
+
* `rl.close()` turns it off, and this module simply borrows the keyboard in
|
|
9
|
+
* between and gives it back. Owning raw mode would mean owning its restoration
|
|
10
|
+
* on every exit path including the ones nobody plans for, and the interrupt
|
|
11
|
+
* guarantee this package already has would become something to maintain rather
|
|
12
|
+
* than something that holds by construction.
|
|
13
|
+
*
|
|
14
|
+
* ## Two listeners are borrowed, not one
|
|
15
|
+
*
|
|
16
|
+
* An open Interface registers exactly one `keypress` listener on its input and
|
|
17
|
+
* exactly one `resize` listener on its output — verified identical on Node
|
|
18
|
+
* 18.12.1, 18.17.0, 20.20.2, 22.23.1 and 26.5.0.
|
|
19
|
+
*
|
|
20
|
+
* The first is obvious: removing it disengages readline's line editor
|
|
21
|
+
* completely — no echo, no history, no line buffer — while the Interface stays
|
|
22
|
+
* open and therefore still holds raw mode. The stream's keypress decoder keeps
|
|
23
|
+
* running because a listener of our own goes on immediately.
|
|
24
|
+
*
|
|
25
|
+
* The second was found by experiment and is **not optional**. On SIGWINCH an
|
|
26
|
+
* open Interface refreshes its own line, emitting `ESC[1G`, `ESC[0J` and
|
|
27
|
+
* `ESC[3G` — none of which are in this package's escape budget — and painting
|
|
28
|
+
* its default `"> "` prompt into the middle of the frame. Borrowing the
|
|
29
|
+
* keyboard without borrowing this leaves readline drawing on top of us the
|
|
30
|
+
* moment someone drags a window edge.
|
|
31
|
+
*
|
|
32
|
+
* ## The cursor is never hidden
|
|
33
|
+
*
|
|
34
|
+
* No `?25l`, ever. It parks visibly at column 0 below the block, which is why
|
|
35
|
+
* nothing needs restoring, why no signal handler exists, and why an interrupted
|
|
36
|
+
* run cannot leave a terminal with an invisible cursor. A repainting renderer
|
|
37
|
+
* is exactly the kind of code that reaches for cursor hiding, so the absence is
|
|
38
|
+
* stated here rather than left to be noticed.
|
|
39
|
+
*
|
|
40
|
+
* ## The question is printed once, above everything this module repaints
|
|
41
|
+
*
|
|
42
|
+
* The repainted block is the option rows, a blank line, and the hint — and
|
|
43
|
+
* nothing else. The question is written once before the block and never
|
|
44
|
+
* touched again.
|
|
45
|
+
*
|
|
46
|
+
* That is a width decision rather than an aesthetic one. The longest question
|
|
47
|
+
* the CLI asks is 70 cells, so a question inside the block would force a
|
|
48
|
+
* minimum terminal width of 71 and put keyboard selection out of reach of a
|
|
49
|
+
* split pane. Outside the block it is free to wrap across as many rows as it
|
|
50
|
+
* likes: the cursor-up count is computed over the block, the question is not in
|
|
51
|
+
* it, and a row count that is not part of the arithmetic cannot corrupt it.
|
|
52
|
+
*
|
|
53
|
+
* ## Every row is clipped, and `.length` is never a width
|
|
54
|
+
*
|
|
55
|
+
* The byte stream this renderer emits is width-independent — it always says
|
|
56
|
+
* `ESC[7A` for a seven-row block — so a row wider than the terminal takes two
|
|
57
|
+
* rows, the cursor moves up one row too few, and every repaint leaves a copy of
|
|
58
|
+
* the frame behind. Reproduced during the prototype at 24 columns: five copies
|
|
59
|
+
* of the question for four keypresses.
|
|
60
|
+
*
|
|
61
|
+
* Clipping every row to `columns - 1` makes the row count equal the line count
|
|
62
|
+
* by construction. It has to be `theme.clip` and `theme.width` rather than
|
|
63
|
+
* `String.prototype.slice` and `.length`: `theme.ok("✓ Git repository detected")`
|
|
64
|
+
* has a `.length` of 34 and occupies 25 cells, and a slice through an escape
|
|
65
|
+
* sequence prints its tail as literal text.
|
|
66
|
+
*
|
|
67
|
+
* ## What this module deliberately is not
|
|
68
|
+
*
|
|
69
|
+
* No filtering, no fuzzy search, no scrolling viewport, no mouse, no spinner,
|
|
70
|
+
* no animation, no colour of its own. The highlight is carried by a pointer
|
|
71
|
+
* glyph, which is the one presentation that survives every tier, both
|
|
72
|
+
* alphabets, and a terminal that renders no colour at all.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/** Terminal width to assume when the stream will not say. */
|
|
76
|
+
const FALLBACK_COLUMNS = 80;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How wide the terminal is *right now*, not when the theme was built.
|
|
80
|
+
*
|
|
81
|
+
* Deliberately read from the stream on every paint. A window can be dragged
|
|
82
|
+
* while a question is on screen, and a renderer clipping against a remembered
|
|
83
|
+
* width would clip against a terminal that no longer exists.
|
|
84
|
+
*/
|
|
85
|
+
function liveColumns(output, theme) {
|
|
86
|
+
const columns = output?.columns;
|
|
87
|
+
if (Number.isInteger(columns) && columns > 0) return columns;
|
|
88
|
+
return theme.columns ?? FALLBACK_COLUMNS;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Pad `text` on the right to `cells` columns of *rendered* width.
|
|
93
|
+
*
|
|
94
|
+
* Not `padEnd`, which counts UTF-16 units: it pads `[x]` and `◉` to different
|
|
95
|
+
* places, and would pad a coloured string by the length of its escape sequence.
|
|
96
|
+
*/
|
|
97
|
+
function padTo(text, cells, theme) {
|
|
98
|
+
const short = cells - theme.width(text);
|
|
99
|
+
return short > 0 ? text + " ".repeat(short) : text;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* How wide the label column has to be for the second column to line up.
|
|
104
|
+
*
|
|
105
|
+
* Zero when there is nothing to line up against. A list of editors has nothing
|
|
106
|
+
* to the right of its labels, and padding them would emit trailing whitespace
|
|
107
|
+
* that means nothing and clips first.
|
|
108
|
+
*
|
|
109
|
+
* @param {{label: string, hint?: string}[]} options
|
|
110
|
+
* @param {object} theme
|
|
111
|
+
* @returns {number}
|
|
112
|
+
*/
|
|
113
|
+
export function alignmentWidth(options, theme) {
|
|
114
|
+
if (!options.some((option) => Boolean(option.hint))) return 0;
|
|
115
|
+
return Math.max(...options.map((option) => theme.width(option.label)));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One option, as text: its label, the path it writes to, and whatever the
|
|
120
|
+
* detector had to say about it.
|
|
121
|
+
*
|
|
122
|
+
* Exported because **both** renderers need the same grammar. The classic
|
|
123
|
+
* numbered list and the keyboard selector draw completely different frames
|
|
124
|
+
* around an option, but the option itself reads the same in each — and it has
|
|
125
|
+
* to, because the classic path is a supported way to answer the question and
|
|
126
|
+
* not a reduced one. Two implementations of this sentence would drift, and the
|
|
127
|
+
* drift would be invisible until someone ran the same install twice under
|
|
128
|
+
* different terminals.
|
|
129
|
+
*
|
|
130
|
+
* What is *not* here: the pointer, the checkbox, and the clipping. Those belong
|
|
131
|
+
* to a frame, and only one of the two renderers draws them.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} args
|
|
134
|
+
* @param {object} args.theme
|
|
135
|
+
* @param {{label: string, hint?: string, note?: string}} args.option
|
|
136
|
+
* @param {number} [args.labelWidth] - from `alignmentWidth`
|
|
137
|
+
* @param {boolean} [args.withNote] - false to compose the row without its
|
|
138
|
+
* suffix, which is how a caller asks "how short can this row be".
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
export function optionRow({ theme, option, labelWidth = 0, withNote = true }) {
|
|
142
|
+
const label = labelWidth > 0 ? padTo(option.label, labelWidth, theme) : option.label;
|
|
143
|
+
const row = label + (option.hint ? ` -> ${option.hint}` : "");
|
|
144
|
+
return withNote && option.note ? `${row} ${option.note}` : row;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build the block this renderer repaints: the option rows, a blank line, and
|
|
149
|
+
* the hint.
|
|
150
|
+
*
|
|
151
|
+
* Pure, and exported for that reason — the no-wrap guarantee is a property of
|
|
152
|
+
* these strings at a given width, and asserting it should not require
|
|
153
|
+
* synthesizing a terminal and pressing keys at it.
|
|
154
|
+
*
|
|
155
|
+
* Every returned line is already clipped to `columns - 1`, so the caller may
|
|
156
|
+
* count them as rows without checking anything.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} args
|
|
159
|
+
* @param {object} args.theme
|
|
160
|
+
* @param {{label: string, value: unknown, hint?: string, note?: string}[]} args.options
|
|
161
|
+
* @param {number} args.cursor - index of the highlighted row
|
|
162
|
+
* @param {Set<number>} [args.selected] - checked indices, multi-select only
|
|
163
|
+
* @param {boolean} [args.multi]
|
|
164
|
+
* @param {number} args.columns
|
|
165
|
+
* @returns {string[]}
|
|
166
|
+
*/
|
|
167
|
+
export function renderBlock({ theme, options, cursor, selected = new Set(), multi = false, columns }) {
|
|
168
|
+
const budget = Math.max(0, columns - 1);
|
|
169
|
+
const glyph = theme.glyph;
|
|
170
|
+
|
|
171
|
+
// An unhighlighted row spends the same cells on nothing that a highlighted one
|
|
172
|
+
// spends on the pointer, so the labels never shift sideways as the cursor
|
|
173
|
+
// moves. Measured rather than assumed to be one: `❯` is one cell and `>` is
|
|
174
|
+
// one cell today, and a future pointer that is not would silently misalign
|
|
175
|
+
// every row.
|
|
176
|
+
const blank = " ".repeat(theme.width(glyph.pointer));
|
|
177
|
+
|
|
178
|
+
const labelWidth = alignmentWidth(options, theme);
|
|
179
|
+
|
|
180
|
+
const rows = options.map((option, index) => {
|
|
181
|
+
const pointer = index === cursor ? glyph.pointer : blank;
|
|
182
|
+
const box = multi ? `${selected.has(index) ? glyph.checked : glyph.unchecked} ` : "";
|
|
183
|
+
const frame = `${pointer} ${box}`;
|
|
184
|
+
|
|
185
|
+
// The suffix renders whole or not at all.
|
|
186
|
+
//
|
|
187
|
+
// `note` carries `(detected)`, which the ENVIRONMENT phase has already
|
|
188
|
+
// reported — so it duplicates information rather than carrying it, and a
|
|
189
|
+
// truncated `(detec` would be strictly worse than its absence. This is also
|
|
190
|
+
// why the minimum width is 49 rather than the wider terminal a guaranteed
|
|
191
|
+
// `note` would demand: a suffix that says nothing new does not get to decide
|
|
192
|
+
// whether the whole interaction is available.
|
|
193
|
+
const decorated = frame + optionRow({ theme, option, labelWidth });
|
|
194
|
+
const bare = frame + optionRow({ theme, option, labelWidth, withNote: false });
|
|
195
|
+
|
|
196
|
+
return theme.clip(theme.width(decorated) <= budget ? decorated : bare, budget);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const keys = multi
|
|
200
|
+
? `${glyph.arrowUp}${glyph.arrowDown} move space toggle enter confirm`
|
|
201
|
+
: `${glyph.arrowUp}${glyph.arrowDown} move enter confirm`;
|
|
202
|
+
|
|
203
|
+
return [...rows, "", theme.clip(` ${keys}`, budget)];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Ask one question with the arrow keys, over a readline Interface somebody else
|
|
208
|
+
* opened.
|
|
209
|
+
*
|
|
210
|
+
* Returns the chosen `value`, or an array of them when `multi`, or `null` when
|
|
211
|
+
* nobody answered — Escape, or a stream that ended. `null` is the same refusal
|
|
212
|
+
* every existing call site already reads from the classic path, which is what
|
|
213
|
+
* lets this be swapped in behind an unchanged interface.
|
|
214
|
+
*
|
|
215
|
+
* @param {object} args
|
|
216
|
+
* @param {import("node:readline").Interface} args.readline - the raw-mode owner
|
|
217
|
+
* @param {object} args.theme
|
|
218
|
+
* @param {string} args.question
|
|
219
|
+
* @param {{label: string, value: unknown, hint?: string, note?: string, key?: string}[]} args.options
|
|
220
|
+
* @param {boolean} [args.multi]
|
|
221
|
+
* @param {unknown[]} [args.initial] - values checked at the start in `multi`,
|
|
222
|
+
* and the value highlighted at the start otherwise. One shape for both, so a
|
|
223
|
+
* caller never has to remember which mode takes which.
|
|
224
|
+
* @param {() => void} [args.raiseInterrupt] - how Ctrl-C re-raises. Injected so
|
|
225
|
+
* that a test can observe the interrupt rather than be killed by it.
|
|
226
|
+
* @returns {Promise<unknown|unknown[]|null>}
|
|
227
|
+
*/
|
|
228
|
+
export function select({
|
|
229
|
+
readline,
|
|
230
|
+
theme,
|
|
231
|
+
question,
|
|
232
|
+
options,
|
|
233
|
+
multi = false,
|
|
234
|
+
initial = [],
|
|
235
|
+
raiseInterrupt = () => process.kill(process.pid, "SIGINT"),
|
|
236
|
+
}) {
|
|
237
|
+
// Nothing to choose between is not a question. Returning before anything is
|
|
238
|
+
// printed keeps an empty list from leaving a hint line on screen that no key
|
|
239
|
+
// can dismiss.
|
|
240
|
+
if (!Array.isArray(options) || options.length === 0) return Promise.resolve(null);
|
|
241
|
+
|
|
242
|
+
const input = readline.input;
|
|
243
|
+
const output = readline.output;
|
|
244
|
+
const write = (text) => output.write(text);
|
|
245
|
+
|
|
246
|
+
const selected = new Set();
|
|
247
|
+
if (multi) {
|
|
248
|
+
options.forEach((option, index) => {
|
|
249
|
+
if (initial.includes(option.value)) selected.add(index);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// In `multi` the cursor starts at the top whatever is checked; in a
|
|
254
|
+
// single-select it starts on the answer the caller would have defaulted to,
|
|
255
|
+
// which is what keeps the common case one keystroke.
|
|
256
|
+
const highlighted = multi ? -1 : options.findIndex((option) => initial.includes(option.value));
|
|
257
|
+
let cursor = highlighted >= 0 ? highlighted : 0;
|
|
258
|
+
|
|
259
|
+
// How many rows the block on screen occupies. Zero means nothing has been
|
|
260
|
+
// drawn yet, which is also what a resize resets it to.
|
|
261
|
+
let painted = 0;
|
|
262
|
+
|
|
263
|
+
const paint = () => {
|
|
264
|
+
const lines = renderBlock({
|
|
265
|
+
theme,
|
|
266
|
+
options,
|
|
267
|
+
cursor,
|
|
268
|
+
selected,
|
|
269
|
+
multi,
|
|
270
|
+
columns: liveColumns(output, theme),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Back to the top of the block drawn last time, then rewrite every row in
|
|
274
|
+
// place. Each row is cleared before it is written, so a shorter row can
|
|
275
|
+
// never leave the tail of a longer one behind it.
|
|
276
|
+
write(theme.line.up(painted));
|
|
277
|
+
for (const line of lines) write(theme.line.start() + theme.line.clear() + line + "\n");
|
|
278
|
+
painted = lines.length;
|
|
279
|
+
|
|
280
|
+
// The cursor is now parked at column 0 below the block, plainly visible.
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
return new Promise((resolve) => {
|
|
284
|
+
let settled = false;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Repaint from scratch, because the block on screen belongs to a width that
|
|
288
|
+
* no longer exists.
|
|
289
|
+
*
|
|
290
|
+
* `painted` counts rows at the *old* width, so moving up by it would be
|
|
291
|
+
* wrong however carefully it was counted. Forgetting it is the honest
|
|
292
|
+
* recovery: the stale frame stays in scrollback and a clean one is drawn
|
|
293
|
+
* below it. A resize costs one duplicated frame and never a corrupted
|
|
294
|
+
* terminal.
|
|
295
|
+
*/
|
|
296
|
+
const onResize = () => {
|
|
297
|
+
painted = 0;
|
|
298
|
+
paint();
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
function onKey(_sequence, key = {}) {
|
|
302
|
+
// Ctrl-C. Borrowing readline's keypress listener took its Ctrl-C handling
|
|
303
|
+
// with it, so that handling is owed back: give the keyboard up, let
|
|
304
|
+
// readline drop raw mode on close, then re-raise the signal so the process
|
|
305
|
+
// dies exactly the way it would have anyway — exit 130, no handler,
|
|
306
|
+
// nothing swallowed.
|
|
307
|
+
if (key.ctrl && key.name === "c") {
|
|
308
|
+
if (settled) return;
|
|
309
|
+
settled = true;
|
|
310
|
+
|
|
311
|
+
// Restore first, so that closing readline finds the terminal exactly as
|
|
312
|
+
// readline left it — and so the `close` listener below, which would
|
|
313
|
+
// otherwise fire from this very `close()`, is already gone.
|
|
314
|
+
restore();
|
|
315
|
+
write("\n");
|
|
316
|
+
readline.close();
|
|
317
|
+
raiseInterrupt();
|
|
318
|
+
|
|
319
|
+
// Reached only if the interrupt did not end the process, which is the
|
|
320
|
+
// case under test and never in a real run. Settling is strictly safer
|
|
321
|
+
// than leaving a promise nobody can resolve.
|
|
322
|
+
resolve(null);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (key.name === "up" || key.name === "k") {
|
|
327
|
+
cursor = (cursor - 1 + options.length) % options.length;
|
|
328
|
+
paint();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (key.name === "down" || key.name === "j") {
|
|
333
|
+
cursor = (cursor + 1) % options.length;
|
|
334
|
+
paint();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (multi && key.name === "space") {
|
|
339
|
+
if (selected.has(cursor)) selected.delete(cursor);
|
|
340
|
+
else selected.add(cursor);
|
|
341
|
+
paint();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (key.name === "escape") {
|
|
346
|
+
paint();
|
|
347
|
+
finish(null);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (key.name === "return" || key.name === "enter") {
|
|
352
|
+
paint();
|
|
353
|
+
finish(answer());
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Hidden accelerators, and hidden is the point.
|
|
358
|
+
//
|
|
359
|
+
// `confirm` is a two-row select, and `y`/`n` kept working there costs one
|
|
360
|
+
// lookup and saves anyone with the old habit a keystroke. They are not
|
|
361
|
+
// presented in the hint, because the presented interaction is the arrow
|
|
362
|
+
// keys — an accelerator advertised is a second interaction to learn.
|
|
363
|
+
// Single-select only: in a checkbox list every letter is a candidate for
|
|
364
|
+
// some future label and none of them should silently submit the form.
|
|
365
|
+
if (!multi) {
|
|
366
|
+
const accelerated = options.findIndex((option) => option.key !== undefined && option.key === key.name);
|
|
367
|
+
if (accelerated >= 0) {
|
|
368
|
+
cursor = accelerated;
|
|
369
|
+
paint();
|
|
370
|
+
finish(options[accelerated].value);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Everything else is ignored rather than echoed, which is what stops a
|
|
376
|
+
// stray escape byte from being printed as text into the middle of the
|
|
377
|
+
// block.
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** What the current state means as an answer. */
|
|
381
|
+
const answer = () =>
|
|
382
|
+
multi
|
|
383
|
+
? [...selected].sort((a, b) => a - b).map((index) => options[index].value)
|
|
384
|
+
: options[cursor].value;
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The stream ended while the question was on screen — Ctrl-D, or a pipe
|
|
388
|
+
* that closed under us.
|
|
389
|
+
*
|
|
390
|
+
* Resolving `null` rather than hanging: an unanswered question is a refusal
|
|
391
|
+
* every call site already knows how to read, and a prompt that waits
|
|
392
|
+
* forever on a closed stdin is how a CI job hangs at 3am.
|
|
393
|
+
*/
|
|
394
|
+
const onClose = () => finish(null);
|
|
395
|
+
|
|
396
|
+
// Take the keyboard, and the resize handler, remembering exactly what was
|
|
397
|
+
// taken so exactly that can be given back.
|
|
398
|
+
const borrowedKeypress = input.listeners("keypress");
|
|
399
|
+
input.removeAllListeners("keypress");
|
|
400
|
+
const borrowedResize = output.listeners("resize");
|
|
401
|
+
output.removeAllListeners("resize");
|
|
402
|
+
|
|
403
|
+
function restore() {
|
|
404
|
+
input.removeListener("keypress", onKey);
|
|
405
|
+
for (const listener of borrowedKeypress) input.on("keypress", listener);
|
|
406
|
+
output.removeListener("resize", onResize);
|
|
407
|
+
for (const listener of borrowedResize) output.on("resize", listener);
|
|
408
|
+
readline.removeListener("close", onClose);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function finish(value) {
|
|
412
|
+
if (settled) return;
|
|
413
|
+
settled = true;
|
|
414
|
+
restore();
|
|
415
|
+
resolve(value);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
input.on("keypress", onKey);
|
|
419
|
+
output.on("resize", onResize);
|
|
420
|
+
readline.on("close", onClose);
|
|
421
|
+
|
|
422
|
+
// Once, above the block, and never again. It may wrap; nothing counts it.
|
|
423
|
+
write(`? ${question}\n\n`);
|
|
424
|
+
paint();
|
|
425
|
+
});
|
|
426
|
+
}
|