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 CHANGED
@@ -84,9 +84,17 @@ Every install ends by printing the one prompt that starts a session, and the pro
84
84
  In a terminal you are then asked whether to copy it, in a question that says what it replaces:
85
85
 
86
86
  ```text
87
- ? Copy that prompt to your clipboard? This replaces what is on it now. [Y/n]
87
+ ? Copy that prompt to your clipboard? This replaces what is on it now.
88
+
89
+ ❯ Yes
90
+ No
91
+
92
+ ↑↓ move enter confirm
88
93
  ```
89
94
 
95
+ `y` and `n` still answer it in one keystroke, and under
96
+ `PATHFINDER_PROMPT=classic` it asks as `[Y/n]` on one line.
97
+
90
98
  - **Nothing is copied without an explicit yes.** Declining, an unanswered question, `--no-clipboard`, `--yes`, `--dry-run`, and any run without a terminal on both ends all leave your clipboard exactly as it was.
91
99
  - **The prompt is printed either way.** Copying is a convenience, never the only way to get it.
92
100
  - **No dependency, and no clipboard is ever read.** The copy uses whatever your system already has — `pbcopy`, `clip.exe` including under WSL, or `wl-copy`, `xclip`, or `xsel` — chosen by what is actually installed rather than by your platform's name. If none of them is there, or one of them fails, the installer says so in one line and still exits 0.
@@ -95,8 +103,8 @@ In a terminal you are then asked whether to copy it, in a question that says wha
95
103
 
96
104
  The last question is whether to open the project, and it is only ever about an editor you already have. The installer looks for `code` (VS Code) and `cursor` (Cursor) on your `PATH`:
97
105
 
98
- - **One found** — a yes/no naming it: `? Open this project in VS Code? [Y/n]`
99
- - **Several found** — a numbered list, alphabetical, ending in `Don't open`
106
+ - **One found** — a Yes/No naming it: `? Open this project in VS Code?`
107
+ - **Several found** — a list, alphabetical, ending in `Don't open`
100
108
  - **None found** — no question at all
101
109
 
102
110
  Neither editor is a Pathfinder requirement, and the alphabetical order is not a recommendation. There is no way to name an editor or pass a path. The launch is detached: the installer hands over the project directory and exits immediately.
@@ -118,6 +126,15 @@ Neither editor is a Pathfinder requirement, and the alphabetical order is not a
118
126
  | `--yes`, `--no-input` | Take the defaults and ask nothing. It does not authorize `git init`, configure any tool, touch your clipboard, or open an editor — pass `--git-init` and `--agents` for the first two |
119
127
  | `-h`, `--help` | Show usage |
120
128
 
129
+ ### Environment
130
+
131
+ | Variable | Effect |
132
+ | --- | --- |
133
+ | `PATHFINDER_PROMPT=classic` | Ask every question as a numbered list and `y`/`n` rather than an arrow-key selector |
134
+ | `NO_COLOR` | Print no colour. It does not disable the selector |
135
+
136
+ **Both prompt styles are supported.** By default a terminal answers questions with `↑`/`↓`, `Space`, and `Enter`. `PATHFINDER_PROMPT=classic` asks for typed numbers and `y`/`n` instead — the right choice for a screen reader, for a script driving the installer's stdin, and for anyone who simply prefers it. A terminal narrower than 49 columns and `TERM=dumb` select it on their own, and `y`/`n` keep working at a Yes/No question either way.
137
+
121
138
  Questions are asked only when stdin and stdout are both terminals. Piped, redirected, or in CI, nothing is asked and nothing is prompted for — so a directory that is not a repository needs `--git-init`, or the install is refused, no tool is configured without `--agents`, the clipboard is never touched at all, and no editor is ever launched.
122
139
 
123
140
  ## Requirements
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from "../src/cli.mjs";
3
3
  import { createPrompter } from "../src/prompt.mjs";
4
+ import { createTheme } from "../src/theme.mjs";
4
5
 
5
6
  // Everything the CLI learns about the outside world arrives through this call.
6
7
  // `run` reads no globals of its own, so a test can hand it a synthesized
@@ -13,10 +14,30 @@ import { createPrompter } from "../src/prompt.mjs";
13
14
  // is a run whose stdout is a pipe.
14
15
  const interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
15
16
 
17
+ // One theme for the whole run, built here because this is the only file allowed
18
+ // to read the process, and threaded into both consumers rather than built twice.
19
+ // Two themes over the same terminal would be two opinions about it, and they
20
+ // would differ in exactly the value that decides how a question is asked: `run`
21
+ // is handed no stdin, so a theme it built for itself would answer no to
22
+ // `selection` while the prompter's answered yes.
23
+ //
24
+ // Note what the capability is told about stdin and what it is not. Whether
25
+ // `setRawMode` exists is asked of the stream; it is never called, here or
26
+ // anywhere in this package — readline owns raw mode for the whole run.
27
+ const theme = createTheme({
28
+ env: process.env,
29
+ platform: process.platform,
30
+ isTTY: Boolean(process.stdout.isTTY),
31
+ inputIsTTY: Boolean(process.stdin.isTTY),
32
+ setRawMode: typeof process.stdin.setRawMode === "function",
33
+ columns: process.stdout.columns,
34
+ });
35
+
16
36
  const prompter = createPrompter({
17
37
  input: process.stdin,
18
38
  output: process.stdout,
19
39
  interactive,
40
+ theme,
20
41
  });
21
42
 
22
43
  try {
@@ -27,6 +48,7 @@ try {
27
48
  env: process.env,
28
49
  platform: process.platform,
29
50
  stdoutIsTTY: Boolean(process.stdout.isTTY),
51
+ theme,
30
52
  prompter,
31
53
  });
32
54
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pathfinder",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Install the Pathfinder AI-assisted, human-in-the-loop workflow kit into a Git repository.",
5
5
  "keywords": [
6
6
  "pathfinder",
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
  });