create-pathfinder 1.6.0 → 1.8.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/cells.mjs ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * How much room a string takes up on a terminal, and how to make it take less.
3
+ *
4
+ * Two functions, and the reason they exist is one measurement being wrong:
5
+ * `theme.ok("✓ Git repository detected")` has a `.length` of 34 and occupies 25
6
+ * cells. The nine-character difference is the SGR pair, which `.length` counts
7
+ * as text because it is text — the terminal simply does not draw it.
8
+ *
9
+ * A renderer that repaints has to know the difference. If a line it believes is
10
+ * one row wraps to two, every cursor-up it issues afterwards is off by one, and
11
+ * the block it is redrawing walks down the screen leaving copies of itself
12
+ * behind. That is not hypothetical: it was reproduced at 24 columns during the
13
+ * prototype, five copies of the question for four keypresses.
14
+ *
15
+ * **Deliberately not a text-processing library.** No wrapping, no padding, no
16
+ * alignment, no layout. Wrapping in particular is the tempting one and is
17
+ * exactly what must not appear here: a function that breaks a line into several
18
+ * is the beginning of a layout engine, and this module exists so that a caller
19
+ * can guarantee *one* line stays one line.
20
+ *
21
+ * **Pure, and stricter than the theme about it.** `createTheme` at least reads
22
+ * an environment it was handed. This reads nothing — no `process`, no glyph
23
+ * table, no capability. The same string always measures the same, which is what
24
+ * lets the tests state cell counts as constants.
25
+ *
26
+ * ## The character model, and what it is honest about
27
+ *
28
+ * Correct for everything Pathfinder actually prints, and not claimed to be
29
+ * correct for everything. What it handles:
30
+ *
31
+ * - **ANSI escapes occupy nothing.** They are drawn by no terminal.
32
+ * - **Wide characters occupy two cells** — CJK, Hangul, fullwidth forms, and
33
+ * the emoji planes the glyph table draws from.
34
+ * - **Combining marks, zero-width joiners, and variation selectors occupy
35
+ * nothing**, because they modify the character before them rather than
36
+ * adding one.
37
+ * - **Everything else occupies one cell.**
38
+ *
39
+ * What it gets wrong, on purpose, because neither can occur in this CLI:
40
+ *
41
+ * - **Emoji joined by ZWJ** — `👨‍👩‍👧` is one glyph of two cells, and this counts
42
+ * three of two, giving 6.
43
+ * - **Skin-tone modifiers** — `👍🏽` is one glyph of two cells, counted as 4.
44
+ *
45
+ * Both **overcount**, and the direction is the whole reason they are tolerable.
46
+ * Overcounting clips a line early, which is cosmetic. Undercounting lets a line
47
+ * wrap, which is the corruption this module was written to prevent. Every
48
+ * string that reaches a measured surface comes from a frozen registry or a
49
+ * literal in this package, so neither case arises; if user text ever reaches
50
+ * one, it will clip a little early rather than break the display.
51
+ *
52
+ * ## East Asian Ambiguous width is a policy, and the policy is "narrow"
53
+ *
54
+ * **Seven of the fourteen glyphs in `theme.mjs`** — `·`, `▲`, `—`, `…`, `━`,
55
+ * `│`, `█` — are classified `Ambiguous` by Unicode, meaning they are one cell
56
+ * beside Latin text and two in a legacy CJK context. **Feature 23's selector
57
+ * adds five more glyphs, of which three are also Ambiguous** — `○`, `↑`, `↓`.
58
+ * Ten of nineteen, once that lands.
59
+ *
60
+ * There is no correct answer available to a process that cannot ask the
61
+ * terminal, so this is a decision rather than a lookup: **Ambiguous is
62
+ * narrow**, which is what every modern terminal outside a CJK locale renders
63
+ * and what the alternative implementations default to. It is asserted in the
64
+ * tests so that changing it is a decision someone makes rather than a
65
+ * regression someone ships.
66
+ *
67
+ * Note which glyphs are *not* on that list, because the grouping is not
68
+ * intuitive: `✓`, `✗`, `░`, and `◉` are unambiguously narrow, while `○` beside
69
+ * them is not. Ambiguity is a property of a character's history in legacy East
70
+ * Asian encodings, not of how it looks.
71
+ */
72
+
73
+ /**
74
+ * A CSI sequence: ESC `[`, parameter bytes, one final letter.
75
+ *
76
+ * Sticky rather than global, so it can be anchored at a position instead of
77
+ * searched for — the scanner below needs "is there an escape *here*", never
78
+ * "is there an escape somewhere after here".
79
+ *
80
+ * Only CSI is recognised, because only CSI is emitted: `theme.mjs` produces SGR
81
+ * colour codes and the two line primitives, all of which are CSI. An OSC
82
+ * sequence would be measured as its literal characters, which would be wrong —
83
+ * and is acceptable only because nothing in this package writes one. A stray
84
+ * `ESC` that begins no valid sequence falls through to the character path and
85
+ * measures zero, since it is a C0 control.
86
+ */
87
+ const CSI = /\u001B\[[0-9;?]*[A-Za-z]/y;
88
+
89
+ /** An SGR sequence — a CSI whose final byte is `m`. The only kind that paints. */
90
+ const SGR_FINAL = "m";
91
+
92
+ /** `ESC[0m` and its abbreviation `ESC[m`. Both end every span the theme opens. */
93
+ const RESET = "\u001B[0m";
94
+ const isReset = (sequence) => sequence === RESET || sequence === "\u001B[m";
95
+
96
+ /**
97
+ * Ranges rendered two cells wide.
98
+ *
99
+ * The standard Wide and Fullwidth set. Written as a sorted table of pairs and
100
+ * searched linearly: it has twenty-one entries and is consulted once per
101
+ * character of a handful of short lines, so a binary search would trade
102
+ * legibility for time nobody is waiting on.
103
+ *
104
+ * **Sorted ascending, and `inRanges` depends on it** — that function stops as
105
+ * soon as a range starts above the code point, so an entry inserted out of
106
+ * order would not merely slow the search, it would silently stop matching.
107
+ *
108
+ * Four of these ranges cover no character this CLI prints today: `1F004`,
109
+ * `1F200–1F2FF`, `1F7E0–1F7EB`, and `1FA70–1FAFF`. They are here anyway, and
110
+ * the reason is the module's one safety property rather than coverage. Every
111
+ * documented inaccuracy in this file *over*counts, which clips a line early and
112
+ * costs a character. A missing Wide range does the opposite: it undercounts, so
113
+ * a line that measured as fitting wraps, and a repainting caller's cursor-up
114
+ * count is then wrong for every frame after it. Overcounting is cosmetic;
115
+ * undercounting corrupts the display. A glyph chosen later from any of these
116
+ * blocks — a coloured circle for a status dot is an entirely plausible future —
117
+ * must not be able to introduce that quietly.
118
+ */
119
+ const WIDE = Object.freeze([
120
+ [0x1100, 0x115f], // Hangul Jamo initial consonants
121
+ [0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols
122
+ [0x3041, 0x33ff], // Hiragana, Katakana, Bopomofo, CJK compatibility
123
+ [0x3400, 0x4dbf], // CJK Extension A
124
+ [0x4e00, 0x9fff], // CJK Unified Ideographs
125
+ [0xa000, 0xa4cf], // Yi
126
+ [0xa960, 0xa97f], // Hangul Jamo Extended-A
127
+ [0xac00, 0xd7a3], // Hangul syllables
128
+ [0xf900, 0xfaff], // CJK compatibility ideographs
129
+ [0xfe10, 0xfe19], // vertical forms
130
+ [0xfe30, 0xfe6f], // CJK compatibility forms, small form variants
131
+ [0xff00, 0xff60], // fullwidth forms
132
+ [0xffe0, 0xffe6], // fullwidth signs
133
+ [0x1f004, 0x1f004], // mahjong tile red dragon
134
+ [0x1f200, 0x1f2ff], // enclosed ideographic supplement
135
+ [0x1f300, 0x1f64f], // symbols, pictographs, emoticons — `🔍` `📦` `📋` `🎉`
136
+ [0x1f680, 0x1f6ff], // transport and map symbols
137
+ [0x1f7e0, 0x1f7eb], // geometric shapes extended — the coloured circles
138
+ [0x1f900, 0x1f9ff], // supplemental symbols and pictographs
139
+ [0x1fa70, 0x1faff], // symbols and pictographs extended-A
140
+ [0x20000, 0x3fffd], // CJK Extension B and beyond
141
+ ]);
142
+
143
+ /**
144
+ * Ranges that add nothing to a line's width.
145
+ *
146
+ * Combining marks attach to the character before them, and the joiners and
147
+ * selectors steer how a sequence is drawn without being drawn themselves.
148
+ * Counting any of them would overstate every accented word.
149
+ */
150
+ const ZERO_WIDTH = Object.freeze([
151
+ [0x0300, 0x036f], // combining diacritical marks
152
+ [0x1ab0, 0x1aff], // combining diacriticals extended
153
+ [0x1dc0, 0x1dff], // combining diacriticals supplement
154
+ [0x200b, 0x200f], // zero-width space, ZWNJ, ZWJ, directional marks
155
+ [0x20d0, 0x20f0], // combining marks for symbols
156
+ [0xfe00, 0xfe0f], // variation selectors
157
+ [0xfe20, 0xfe2f], // combining half marks
158
+ [0xe0100, 0xe01ef], // variation selectors supplement
159
+ ]);
160
+
161
+ /** Is `code` inside any of these `[low, high]` pairs? */
162
+ function inRanges(code, ranges) {
163
+ for (const [low, high] of ranges) {
164
+ if (code < low) return false; // sorted, so nothing later can match
165
+ if (code <= high) return true;
166
+ }
167
+ return false;
168
+ }
169
+
170
+ /**
171
+ * Cells occupied by one code point.
172
+ *
173
+ * C0 and C1 controls measure zero. They are not printable, and a terminal that
174
+ * receives one either acts on it or discards it — either way it draws nothing,
175
+ * so counting it would make every string containing an escape too wide.
176
+ */
177
+ function codePointWidth(code) {
178
+ if (code < 0x20 || (code >= 0x7f && code < 0xa0)) return 0;
179
+ if (inRanges(code, ZERO_WIDTH)) return 0;
180
+ if (inRanges(code, WIDE)) return 2;
181
+ return 1;
182
+ }
183
+
184
+ /**
185
+ * Walk `text` as a sequence of escapes and code points.
186
+ *
187
+ * One scanner, shared by both public functions, so there is exactly one place
188
+ * that decides where an escape begins and ends. Two implementations of that
189
+ * question would eventually disagree, and the disagreement would show up as a
190
+ * clipped line that measures correctly and renders wrong.
191
+ *
192
+ * @param {string} text
193
+ * @yields {{escape: string|null, character: string|null, width: number}}
194
+ */
195
+ function* scan(text) {
196
+ let index = 0;
197
+
198
+ while (index < text.length) {
199
+ CSI.lastIndex = index;
200
+ const match = CSI.exec(text);
201
+
202
+ if (match !== null) {
203
+ yield { escape: match[0], character: null, width: 0 };
204
+ index = CSI.lastIndex;
205
+ continue;
206
+ }
207
+
208
+ // `codePointAt` reads a surrogate pair as one value, and `fromCodePoint`
209
+ // rebuilds both halves, so a character is never split down the middle.
210
+ const code = text.codePointAt(index);
211
+ const character = String.fromCodePoint(code);
212
+
213
+ yield { escape: null, character, width: codePointWidth(code) };
214
+ index += character.length;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * How many terminal cells `text` occupies when printed.
220
+ *
221
+ * @param {string} text
222
+ * @returns {number} cells, never negative
223
+ */
224
+ export function width(text) {
225
+ let total = 0;
226
+ for (const token of scan(String(text))) total += token.width;
227
+ return total;
228
+ }
229
+
230
+ /**
231
+ * Shorten `text` to at most `cells` columns, leaving the terminal consistent.
232
+ *
233
+ * Three things it will not do, each of which is a way a naive slice breaks a
234
+ * terminal rather than merely a line:
235
+ *
236
+ * - **Never cuts an escape sequence in half.** The tail of a severed `ESC[32m`
237
+ * is printed as literal text — `[32m` appears in the output — and the
238
+ * sequence never takes effect.
239
+ * - **Never splits a character.** Half a surrogate pair is not a character;
240
+ * it renders as a replacement glyph.
241
+ * - **Never leaves a colour open.** A cut inside a painted span drops that
242
+ * span's reset, and the colour then bleeds into everything printed
243
+ * afterwards, including output this package does not own. When that happens
244
+ * a reset is appended — the one byte sequence this function adds, and it adds
245
+ * it precisely so the damage stops at the clip.
246
+ *
247
+ * Escapes cost nothing against the budget, and every escape preceding text that
248
+ * survives the cut is kept — so a clipped line keeps its colour rather than
249
+ * losing its meaning. Severity in this CLI is carried by a glyph and a word as
250
+ * well, but a line that silently changed colour when the window narrowed would
251
+ * look like a different kind of message.
252
+ *
253
+ * What is *not* kept is an escape no visible character earned. See `pending`
254
+ * below: a colour that paints nothing is not decoration, it is noise.
255
+ *
256
+ * @param {string} text
257
+ * @param {number} cells - budget in columns. A budget of zero yields the empty
258
+ * string for text whose leading run is escapes and visible characters, which
259
+ * is every line this CLI renders. Two exceptions, both harmless and both
260
+ * stated rather than tidied away: a text made only of escapes occupies zero
261
+ * cells and is therefore already within any budget, so identity applies; and
262
+ * a *zero-width character* such as a leading `\r` costs nothing against the
263
+ * budget and is retained, so a string beginning with one clips to that
264
+ * character rather than to `""`. Nothing measured by this module begins with
265
+ * one — the progress bar does, and the progress bar is never clipped. A
266
+ * budget at or above the text's width yields the text unchanged.
267
+ * @returns {string}
268
+ */
269
+ export function clip(text, cells) {
270
+ const source = String(text);
271
+
272
+ // Identity when it already fits. Worth stating as its own case: the common
273
+ // call passes a line that fits, and it should come back the same object's
274
+ // value with nothing appended — including no reset.
275
+ if (width(source) <= cells) return source;
276
+
277
+ let kept = "";
278
+ let used = 0;
279
+ let painted = false;
280
+
281
+ // Escapes seen but not yet earned.
282
+ //
283
+ // An escape costs no cells, so the tempting thing is to emit it the moment it
284
+ // is seen. That produces two artefacts, both of which are output describing a
285
+ // colour that paints nothing: a zero budget returns `ESC[32mESC[0m` instead of
286
+ // the empty string, and a cut landing just after a paint leaves that paint
287
+ // and its reset bracketing no text at all.
288
+ //
289
+ // Holding them until a visible character is actually kept fixes both, and
290
+ // costs nothing else: escapes still never count against the budget, and every
291
+ // escape preceding retained text is still emitted, in order.
292
+ //
293
+ // Held as tokens rather than a concatenated string on purpose — flushing has
294
+ // to know which of them were SGR, and re-parsing a string to find out would
295
+ // be the second escape-parsing path this module exists to avoid.
296
+ let pending = [];
297
+
298
+ for (const token of scan(source)) {
299
+ if (token.escape !== null) {
300
+ pending.push(token.escape);
301
+ continue;
302
+ }
303
+
304
+ if (used + token.width > cells) break;
305
+
306
+ for (const escape of pending) {
307
+ kept += escape;
308
+ // Only SGR opens or closes a paint. A cursor movement is not a colour,
309
+ // and treating one as though it were would append resets to lines that
310
+ // never had a span to close.
311
+ if (escape.endsWith(SGR_FINAL)) painted = !isReset(escape);
312
+ }
313
+ pending = [];
314
+
315
+ kept += token.character;
316
+ used += token.width;
317
+ }
318
+
319
+ // Whatever is still pending trailed the last character we kept, so it is
320
+ // discarded — including, possibly, the reset that would have closed a span.
321
+ // That is exactly why `painted` is consulted here rather than trusted to the
322
+ // input: the span is closed by the reset appended below.
323
+ return painted ? kept + RESET : kept;
324
+ }
package/src/cli.mjs CHANGED
@@ -52,6 +52,15 @@ Options:
52
52
  Adapters are generated files Pathfinder owns and regenerates without --force.
53
53
  A file it did not generate is never replaced, at any path, without --force.
54
54
 
55
+ Environment:
56
+ PATHFINDER_PROMPT=classic
57
+ Ask every question as a numbered list and y/n instead of an
58
+ arrow-key selector. Both are supported; use this for screen
59
+ readers, for scripts, or simply if you prefer typing. It is
60
+ also what a terminal narrower than 49 columns and TERM=dumb
61
+ select on their own.
62
+ NO_COLOR Print no colour. It does not disable the selector.
63
+
55
64
  Without a terminal on both stdin and stdout, nothing is ever asked. In that
56
65
  case a directory that is not a Git repository needs --git-init, or the install
57
66
  is refused, and neither your clipboard nor an editor is touched — --yes does
@@ -67,6 +76,7 @@ export async function run(
67
76
  env = {},
68
77
  platform = process.platform,
69
78
  stdoutIsTTY = false,
79
+ theme: injectedTheme = null,
70
80
  prompter = nonInteractivePrompter(),
71
81
  },
72
82
  ) {
@@ -86,11 +96,18 @@ export async function run(
86
96
  // the user reads what the tool found before reading what it wants to do.
87
97
  const findings = detect({ cwd, env, platform });
88
98
 
89
- // Every capability question this run will ask is answered once, here, from
90
- // the three things this function was handed. Threaded downward as an argument
91
- // rather than reached for: a module-level theme would be a second opinion
92
- // about the terminal that no test could disagree with.
93
- const theme = createTheme({ env, platform, isTTY: stdoutIsTTY });
99
+ // Every capability question this run will ask is answered once and threaded
100
+ // downward as an argument rather than reached for: a module-level theme would
101
+ // be a second opinion about the terminal that no test could disagree with.
102
+ //
103
+ // Built here when nobody supplied one, which is every test and every caller
104
+ // that has nothing to say about stdin. `bin/` supplies one because it knows
105
+ // things this function is never handed — whether stdin is a terminal, whether
106
+ // it can be put into raw mode, how wide the window is — and those are exactly
107
+ // the three the selection capability is decided from. Accepting it keeps the
108
+ // property this comment has always claimed: one theme per run, not one per
109
+ // module that wants an opinion.
110
+ const theme = injectedTheme ?? createTheme({ env, platform, isTTY: stdoutIsTTY });
94
111
  const mark = theme.glyph;
95
112
 
96
113
  // The one branch in this file that chooses between whole presentations, and
@@ -324,25 +341,36 @@ async function selectHarnesses({ findings, options, prompter, out, theme }) {
324
341
  const entries = [...HARNESSES, SOMETHING_ELSE];
325
342
 
326
343
  // The sentinel's trailing ellipsis is the theme's, not a character baked into
327
- // the label and because it is the longest row, it also decides the column
328
- // the arrows line up in. So the width is measured on the rendered label
329
- // rather than the stored one: an ASCII terminal spends three characters on
330
- // `...` where a UTF-8 one spends one, and the arrows must follow.
344
+ // the label: an ASCII terminal spends three characters on `...` where a UTF-8
345
+ // one spends one.
331
346
  const labelOf = (entry) =>
332
347
  entry === SOMETHING_ELSE ? `${entry.label}${theme.glyph.ellipsis}` : entry.label;
333
- const width = Math.max(...entries.map((entry) => labelOf(entry).length));
334
348
 
349
+ // Three fields instead of one hand-built string.
350
+ //
351
+ // This used to compute the longest label and `padEnd` every other one to it,
352
+ // so that the `->` arrows lined up — a column of layout, measured in UTF-16
353
+ // units, inside a file whose job is deciding which tools to configure. It was
354
+ // wrong in the way `.length` is always wrong about a terminal, and it was
355
+ // wrong twice over: it produced a *rendering* that only one of the two
356
+ // question implementations could use.
357
+ //
358
+ // Now the call site says what each part *is* and the renderer decides where it
359
+ // goes. Both implementations get the same three fields and lay them out their
360
+ // own way, and neither this function nor any other in this file measures a
361
+ // string in cells.
335
362
  const answer = await prompter.chooseMany("Configure Pathfinder for which tools?", {
336
363
  options: entries.map((entry) => ({
337
364
  value: entry,
338
- label:
339
- `${labelOf(entry).padEnd(width)} -> ` +
340
- // The path is shown so nobody has to check a box to find out what it
341
- // writes. The last entry earns the same courtesy by admitting it
342
- // writes nothing, in the column where every other row names a file.
343
- (entry === SOMETHING_ELSE
344
- ? "nothing is generated"
345
- : `${entry.skillsDir}/` + (detected.includes(entry) ? " (detected)" : "")),
365
+ label: labelOf(entry),
366
+ // The path is shown so nobody has to check a box to find out what it
367
+ // writes. The last entry earns the same courtesy by admitting it writes
368
+ // nothing, in the column where every other row names a file.
369
+ hint: entry === SOMETHING_ELSE ? "nothing is generated" : `${entry.skillsDir}/`,
370
+ // Detection decides the default; this only says so out loud. It is the
371
+ // one part of a row a narrow terminal may drop, because the ENVIRONMENT
372
+ // block above has already reported it.
373
+ note: entry !== SOMETHING_ELSE && detected.includes(entry) ? "(detected)" : undefined,
346
374
  })),
347
375
  defaultSelection: detected,
348
376
  });
package/src/detect.mjs CHANGED
@@ -119,7 +119,7 @@ function detectTool(tool, { cwd, home, env, platform }) {
119
119
  * Decided by counting skill directories rather than by testing for `CLAUDE.md`,
120
120
  * which any agent-assisted project may have written for its own reasons.
121
121
  * A `skills/<name>/SKILL.md` is a far more specific signature, and the count is
122
- * worth having on its own — it is what makes "already installed (20 skills)"
122
+ * worth having on its own — it is what makes "already installed (N skills)"
123
123
  * checkable by the person reading it.
124
124
  */
125
125
  function detectPathfinder(cwd) {
@@ -4,7 +4,7 @@
4
4
  * This used to be one hardcoded string naming a file path, and that was right
5
5
  * for exactly as long as Pathfinder configured nothing. Once a run can generate
6
6
  * native adapters, the path form is no longer the best answer for someone who
7
- * just watched twenty skills be installed into their harness — it is the answer
7
+ * just watched the whole kit be installed into their harness — it is the answer
8
8
  * for someone whose tool cannot discover them.
9
9
  *
10
10
  * A pure function of the selection, deliberately: no filesystem, no detection,
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
  *