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/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: `node:readline` and printed
14
- * lines. No setRawMode, no keypress handler, no cursor control, no redraw. That
15
- * is what makes this work in a dumb terminal, over ssh, and inside an editor's
16
- * integrated console, and it is a decision rather than a default.
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({ input, output, interactive = false, retries = 3 }) {
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((choice, index) => ` ${index + 1}. ${choice.label}`),
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((choice, index) => ` ${index + 1}. ${choice.label}`),
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
- const readline = createInterface({ input, output });
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
+ }