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/CLAUDE.md +2 -0
- package/README.md +21 -4
- package/bin/create-pathfinder.mjs +22 -0
- package/context/ai-interaction.md +1 -0
- package/package.json +1 -1
- package/skills/complete-feature/SKILL.md +3 -2
- package/skills/load-feature/SKILL.md +2 -1
- package/skills/setup-tracker/SKILL.md +74 -0
- package/skills/start-feature/SKILL.md +1 -0
- package/skills/sync-tracker/SKILL.md +120 -0
- package/skills/to-specs/SKILL.md +4 -0
- package/src/cells.mjs +324 -0
- package/src/cli.mjs +46 -18
- package/src/detect.mjs +1 -1
- package/src/kickstart-prompt.mjs +1 -1
- package/src/prompt.mjs +116 -11
- package/src/select.mjs +426 -0
- package/src/theme.mjs +241 -13
- package/templates/feature-spec.template.md +6 -0
- package/templates/tracker.template.md +359 -0
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
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
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
|
|
328
|
-
//
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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 (
|
|
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) {
|
package/src/kickstart-prompt.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
*
|