create-pathfinder 1.5.1 → 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/AGENTS.md +1 -1
- package/README.md +34 -27
- package/bin/create-pathfinder.mjs +22 -0
- package/package.json +2 -2
- package/src/cells.mjs +324 -0
- package/src/cli.mjs +816 -89
- package/src/install.mjs +30 -2
- package/src/kit.mjs +16 -0
- package/src/progress.mjs +144 -0
- package/src/prompt.mjs +116 -11
- package/src/select.mjs +426 -0
- package/src/theme.mjs +590 -0
package/src/theme.mjs
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every capability decision and every decorated byte the CLI emits.
|
|
3
|
+
*
|
|
4
|
+
* One seam, so that a second consumer or a second kind of decoration has
|
|
5
|
+
* somewhere to attach. Before this module, `marks()` and `supportsUnicode()`
|
|
6
|
+
* sat beside the one report they served, and two non-ASCII characters had
|
|
7
|
+
* already escaped the ASCII fallback entirely — which is the argument for the
|
|
8
|
+
* seam, not an accident of that particular pair.
|
|
9
|
+
*
|
|
10
|
+
* Three properties hold, and each one exists to stop a class of call-site bug:
|
|
11
|
+
*
|
|
12
|
+
* - **Total functions.** `theme.ok("text")` returns the plain string when
|
|
13
|
+
* colour is off; the line primitives return the empty string when repainting
|
|
14
|
+
* is not allowed. A call site never asks about capability, so a call site can
|
|
15
|
+
* never forget to. A call site that seems to need a conditional means this
|
|
16
|
+
* module is missing a helper.
|
|
17
|
+
* - **Pure.** `createTheme` reads `{ env, platform, isTTY }` and nothing else —
|
|
18
|
+
* no `process`, no `node:tty`, no filesystem. `run()` is already handed all
|
|
19
|
+
* three, so there is no second source to disagree with. The tests build
|
|
20
|
+
* several themes per file, which a module-level singleton would prevent.
|
|
21
|
+
* - **Decoration, not drawing.** No timer, no loop, no frame, no buffer, no
|
|
22
|
+
* state that survives a call. The line primitives are two escape strings and
|
|
23
|
+
* a boolean. If this file grows a concept of drawing, it has overreached.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately absent: cursor visibility control. A renderer that never hides
|
|
26
|
+
* the cursor has nothing to restore, so no signal handler and no interrupted
|
|
27
|
+
* run can leave a terminal broken. A cursor parked at the end of a progress bar
|
|
28
|
+
* is the accepted cost of that guarantee. Feature 23 adds a second repainting
|
|
29
|
+
* surface and does not weaken this: the selector parks the cursor visibly below
|
|
30
|
+
* its block for exactly the same reason.
|
|
31
|
+
*
|
|
32
|
+
* Newly present, and worth naming beside that absence: `width` and `clip`, from
|
|
33
|
+
* `cells.mjs`. They measure rather than decorate, which is why the algorithm
|
|
34
|
+
* lives in its own module and only the seam is published here — and they are
|
|
35
|
+
* the one part of this file that answers the same way in every tier, because a
|
|
36
|
+
* string's printed width is a fact about the string rather than a capability of
|
|
37
|
+
* the terminal.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { clip, width } from "./cells.mjs";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* SGR codes, written out rather than depended on.
|
|
44
|
+
*
|
|
45
|
+
* Eight ANSI colours, bold, and dim. Every *severity* in this module is one of
|
|
46
|
+
* these and will stay one of these: they render identically everywhere, and a
|
|
47
|
+
* level that means "this went wrong" must never depend on a colour that some
|
|
48
|
+
* terminal renders as something else.
|
|
49
|
+
*/
|
|
50
|
+
const SGR = Object.freeze({
|
|
51
|
+
reset: "\u001B[0m",
|
|
52
|
+
bold: "\u001B[1m",
|
|
53
|
+
dim: "\u001B[2m",
|
|
54
|
+
red: "\u001B[31m",
|
|
55
|
+
green: "\u001B[32m",
|
|
56
|
+
yellow: "\u001B[33m",
|
|
57
|
+
cyan: "\u001B[36m",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Blaze orange, `#E0611F`, in as many alphabets as terminals actually speak.
|
|
62
|
+
*
|
|
63
|
+
* This is the one colour in the CLI that is an *identity* rather than a level,
|
|
64
|
+
* and it is the only reason this module knows what a colour depth is. The
|
|
65
|
+
* severity paints are untouched by all of it, which is the containment that
|
|
66
|
+
* makes the extra depth affordable: a terminal that lies about its capability
|
|
67
|
+
* costs the brand its exact hue, and costs meaning nothing.
|
|
68
|
+
*
|
|
69
|
+
* Three renderings, best first:
|
|
70
|
+
*
|
|
71
|
+
* - **24-bit** - the real value, exactly. Nothing is approximated.
|
|
72
|
+
* - **256** - index 166, `#D75F00`. Chosen by computing the nearest cell of the
|
|
73
|
+
* 6x6x6 cube rather than by eye: `#E0611F` is (224, 97, 31), the cube's
|
|
74
|
+
* levels are 0/95/135/175/215/255, and (215, 95, 0) is nearest by squared
|
|
75
|
+
* distance at 1046 - well clear of the obvious rival 208 `#FF8700` at 3366.
|
|
76
|
+
* - **16** - bold yellow, and only here. This is the floor, never the
|
|
77
|
+
* preference: it is the only warm accent the eight ANSI values offer, so at
|
|
78
|
+
* this depth alone do the brand and `warn` share a hue. The collision is
|
|
79
|
+
* contained rather than waved away - at this depth the identity is carried by
|
|
80
|
+
* the mark's form, its letterspacing, and its position, and a warning is
|
|
81
|
+
* always additionally a glyph and a word. Closing that overlap is precisely
|
|
82
|
+
* what the two depths above are for.
|
|
83
|
+
*/
|
|
84
|
+
const BRAND = Object.freeze({
|
|
85
|
+
24: "\u001B[38;2;224;97;31m",
|
|
86
|
+
8: "\u001B[38;5;166m",
|
|
87
|
+
4: `${SGR.bold}${SGR.yellow}`,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The glyph table, in whichever alphabet this terminal can be trusted with.
|
|
92
|
+
*
|
|
93
|
+
* One table, so a finding and the action it leads to are marked the same way,
|
|
94
|
+
* and so that every character above U+007F the CLI prints has exactly one
|
|
95
|
+
* source. `ellipsis` and `dash` are here for the same reason the severity marks
|
|
96
|
+
* are: they were being printed unconditionally by call sites that had already
|
|
97
|
+
* been told the terminal only gets ASCII.
|
|
98
|
+
*
|
|
99
|
+
* `warn` is the one genuinely new choice. In ASCII it is `*` rather than the
|
|
100
|
+
* obvious `!`, because `!` already means `bad` — and a collision between the
|
|
101
|
+
* two would land precisely in the plain tier, where the glyph is doing the most
|
|
102
|
+
* work because there is no colour beside it.
|
|
103
|
+
*
|
|
104
|
+
* The second group is structural rather than severity: the marks a run's
|
|
105
|
+
* identity and phases are built from. `scan` and `box` are emoji in the
|
|
106
|
+
* decorated alphabet, which is a deliberate product decision and not a drift,
|
|
107
|
+
* and each has an ASCII counterpart that survives the fallback with its sense
|
|
108
|
+
* intact - a lens, and a crate.
|
|
109
|
+
*
|
|
110
|
+
* `barFull` and `barEmpty` are the progress bar's two cells. They only ever
|
|
111
|
+
* render in the expressive tier, since `dynamic` is false everywhere else and
|
|
112
|
+
* the bar draws nothing there - but they carry ASCII counterparts anyway, so
|
|
113
|
+
* that the alphabets stay the same size and the fallback stays a property of
|
|
114
|
+
* this table rather than a fact about who happens to call it.
|
|
115
|
+
*
|
|
116
|
+
* `rule` is both the stroke the Pathfinder mark is drawn from and the character
|
|
117
|
+
* any other horizontal device would use. `gutter` hangs a block together down
|
|
118
|
+
* its left edge. Both are drawn left to right from a fixed count and neither
|
|
119
|
+
* closes on the right, ~~so no caller ever has to know the printed width of a
|
|
120
|
+
* decorated string~~ — **superseded in Feature 22; see `width` and `clip`
|
|
121
|
+
* below.** That is why there is no corner, no box, and no border character in
|
|
122
|
+
* this table — a closed box cannot be aligned without width maths that emoji
|
|
123
|
+
* defeat, which the prototype demonstrated by failing to close its own.
|
|
124
|
+
*
|
|
125
|
+
* The struck clause was true of every device in this module and is still the
|
|
126
|
+
* reason none of them closes on the right. What falsified it is a surface this
|
|
127
|
+
* module did not have when the sentence was written: a *repainting* one. A
|
|
128
|
+
* renderer that redraws a block in place has to know how many rows that block
|
|
129
|
+
* occupies, and a line wider than the terminal silently becomes two — so the
|
|
130
|
+
* cursor-up count goes wrong and the block walks down the screen. Feature 23's
|
|
131
|
+
* prototype reproduced exactly that at 24 columns.
|
|
132
|
+
*
|
|
133
|
+
* So the conclusion is narrowed rather than reversed. Nothing here draws a box,
|
|
134
|
+
* and nothing should. But "no caller needs printed width" was a claim about
|
|
135
|
+
* *what this module happened to contain*, not a rule about terminals, and the
|
|
136
|
+
* honest correction is to publish the measurement rather than let a caller
|
|
137
|
+
* reach for `.length` and be wrong by the length of an escape sequence.
|
|
138
|
+
*
|
|
139
|
+
* The third group is the selector's, added by Feature 23, and it is five rather
|
|
140
|
+
* than the four a checkbox list looks like it needs. `pointer` marks the
|
|
141
|
+
* highlighted row, `checked` and `unchecked` carry a multi-select row's state,
|
|
142
|
+
* and `arrowUp` and `arrowDown` are the hint line's — printed as text in a
|
|
143
|
+
* sentence, and therefore glyphs like any other rather than the escape
|
|
144
|
+
* sequences the same arrows arrive as.
|
|
145
|
+
*
|
|
146
|
+
* Their ASCII counterparts are the reason the minimum-width floor is what it is.
|
|
147
|
+
* `[x]` is three cells where `◉` is one, and `...` is three where `…` is one, so
|
|
148
|
+
* ASCII is the *binding* alphabet for width — 5 to 8 cells wider per row — and
|
|
149
|
+
* the floor below is derived against it rather than against the pretty one.
|
|
150
|
+
*/
|
|
151
|
+
const GLYPHS = Object.freeze({
|
|
152
|
+
unicode: Object.freeze({
|
|
153
|
+
ok: "✓",
|
|
154
|
+
info: "·",
|
|
155
|
+
warn: "▲",
|
|
156
|
+
bad: "✗",
|
|
157
|
+
dash: "—",
|
|
158
|
+
ellipsis: "…",
|
|
159
|
+
scan: "🔍",
|
|
160
|
+
box: "📦",
|
|
161
|
+
clipboard: "📋",
|
|
162
|
+
party: "🎉",
|
|
163
|
+
rule: "━",
|
|
164
|
+
gutter: "│",
|
|
165
|
+
barFull: "█",
|
|
166
|
+
barEmpty: "░",
|
|
167
|
+
pointer: "❯",
|
|
168
|
+
checked: "◉",
|
|
169
|
+
unchecked: "○",
|
|
170
|
+
arrowUp: "↑",
|
|
171
|
+
arrowDown: "↓",
|
|
172
|
+
}),
|
|
173
|
+
ascii: Object.freeze({
|
|
174
|
+
ok: "+",
|
|
175
|
+
info: "-",
|
|
176
|
+
warn: "*",
|
|
177
|
+
bad: "!",
|
|
178
|
+
dash: "-",
|
|
179
|
+
ellipsis: "...",
|
|
180
|
+
scan: "(o)",
|
|
181
|
+
box: "(=)",
|
|
182
|
+
clipboard: "(:)",
|
|
183
|
+
party: "\\o/",
|
|
184
|
+
rule: "=",
|
|
185
|
+
gutter: "|",
|
|
186
|
+
barFull: "#",
|
|
187
|
+
barEmpty: ".",
|
|
188
|
+
pointer: ">",
|
|
189
|
+
checked: "[x]",
|
|
190
|
+
unchecked: "[ ]",
|
|
191
|
+
arrowUp: "^",
|
|
192
|
+
arrowDown: "v",
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Can this terminal be trusted with the decorated glyphs?
|
|
198
|
+
*
|
|
199
|
+
* Answered from the environment rather than attempted and hoped for, and biased
|
|
200
|
+
* hard toward "no": an unanswerable environment gets ASCII, which is readable
|
|
201
|
+
* everywhere, while a wrong "yes" leaves mojibake in the first output a new
|
|
202
|
+
* user ever sees from this tool.
|
|
203
|
+
*
|
|
204
|
+
* Moved from `cli.mjs` unchanged. The rules are not revisited here — a rewrite
|
|
205
|
+
* would be a behaviour change wearing a refactor's clothes.
|
|
206
|
+
*/
|
|
207
|
+
function detectUnicode(env, platform) {
|
|
208
|
+
if (platform === "win32") {
|
|
209
|
+
return Boolean(env.WT_SESSION) || env.TERM_PROGRAM === "vscode";
|
|
210
|
+
}
|
|
211
|
+
const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
|
|
212
|
+
return /utf-?8/i.test(locale);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* May this run emit colour?
|
|
217
|
+
*
|
|
218
|
+
* The order of these checks is the whole answer, so it is written as a sequence
|
|
219
|
+
* of refusals ending in the default:
|
|
220
|
+
*
|
|
221
|
+
* 1. `FORCE_COLOR=0` is an explicit "no" and outranks everything, including a
|
|
222
|
+
* terminal that would otherwise qualify.
|
|
223
|
+
* 2. `NO_COLOR` disables on presence, whatever its value — that is the
|
|
224
|
+
* convention, and honouring the value would make `NO_COLOR=` a surprise.
|
|
225
|
+
* It outranks `FORCE_COLOR`, which promises only to override TTY detection.
|
|
226
|
+
* 3. `TERM=dumb` is the terminal telling us what it is.
|
|
227
|
+
* 4. `FORCE_COLOR` set to anything else turns colour on with no TTY, which is
|
|
228
|
+
* how a CI job that renders ANSI in its log viewer asks for it.
|
|
229
|
+
* 5. Otherwise: colour if this is a terminal.
|
|
230
|
+
*/
|
|
231
|
+
function detectColor(env, isTTY) {
|
|
232
|
+
if (env.FORCE_COLOR === "0") return false;
|
|
233
|
+
if (env.NO_COLOR !== undefined) return false;
|
|
234
|
+
if (env.TERM === "dumb") return false;
|
|
235
|
+
if (env.FORCE_COLOR !== undefined) return true;
|
|
236
|
+
return isTTY;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* How many colours this terminal has *said* it can render: 0, 4, 8, or 24 bits.
|
|
241
|
+
*
|
|
242
|
+
* A third axis beside colour and Unicode, and deliberately not a fourth tier.
|
|
243
|
+
* The tier answers "what kind of presentation is this", and every tier already
|
|
244
|
+
* works at every depth — so a depth is a refinement of one colour, never a
|
|
245
|
+
* different rendering. Exactly one consumer exists, `brand`, and if a severity
|
|
246
|
+
* ever reads this value something has gone wrong upstream.
|
|
247
|
+
*
|
|
248
|
+
* Answered only from what the environment volunteers. Nothing is probed, no
|
|
249
|
+
* escape sequence is written and read back, and no reply is waited for: a
|
|
250
|
+
* capability query is a round trip with a terminal that may never answer, and
|
|
251
|
+
* this module is not allowed to block or to hold state.
|
|
252
|
+
*
|
|
253
|
+
* The order is a sequence of claims, strongest first, ending in the floor:
|
|
254
|
+
*
|
|
255
|
+
* 1. `FORCE_COLOR` at 2 or 3 is someone stating a depth outright. This is the
|
|
256
|
+
* convention the ecosystem settled on, and it is also the only way to
|
|
257
|
+
* exercise the upper depths in a test without pretending to be a terminal.
|
|
258
|
+
* 2. `COLORTERM` of `truecolor` or `24bit` is the de-facto announcement, set by
|
|
259
|
+
* every terminal that means it.
|
|
260
|
+
* 3. A `TERM` ending in `-direct` is the terminfo spelling of the same claim.
|
|
261
|
+
* 4. A `TERM` containing `256color` is the older, narrower claim.
|
|
262
|
+
* 5. Otherwise 4 — the floor, and the answer for every terminal that said
|
|
263
|
+
* nothing.
|
|
264
|
+
*
|
|
265
|
+
* Biased toward under-claiming, exactly as `detectUnicode` is, but the stakes
|
|
266
|
+
* are far lower here and worth stating plainly: a wrong "yes" about Unicode
|
|
267
|
+
* leaves mojibake in someone's first impression, whereas a wrong "yes" here
|
|
268
|
+
* renders one wordmark in an unintended colour or, on a terminal that ignores
|
|
269
|
+
* the sequence entirely, in the default one. `COLORTERM` does leak across ssh,
|
|
270
|
+
* tmux, and sudo, so being wrong is realistic — it is simply cheap.
|
|
271
|
+
*/
|
|
272
|
+
function detectColorDepth(env, color) {
|
|
273
|
+
if (!color) return 0;
|
|
274
|
+
if (env.FORCE_COLOR === "3") return 24;
|
|
275
|
+
if (env.FORCE_COLOR === "2") return 8;
|
|
276
|
+
|
|
277
|
+
const colorterm = env.COLORTERM || "";
|
|
278
|
+
if (/^(truecolor|24bit)$/i.test(colorterm)) return 24;
|
|
279
|
+
|
|
280
|
+
const term = env.TERM || "";
|
|
281
|
+
if (/-direct$/i.test(term)) return 24;
|
|
282
|
+
if (/256color/i.test(term)) return 8;
|
|
283
|
+
|
|
284
|
+
return 4;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Which presentation tier this run gets.
|
|
289
|
+
*
|
|
290
|
+
* Decided once, here, as a documented function of capability — the alternative
|
|
291
|
+
* is every call site guessing, and them disagreeing.
|
|
292
|
+
*
|
|
293
|
+
* - `contract` — not a terminal. A pipe, a redirect, a CI log. These bytes are
|
|
294
|
+
* a promise kept to scripts written against 1.4.1, so the tier is decided by
|
|
295
|
+
* the TTY alone and nothing else can promote a run into it or out of it.
|
|
296
|
+
* - `expressive` — a terminal that answered yes to both colour and Unicode.
|
|
297
|
+
* - `plain` — a terminal that did not. Not a degraded mode: it is a supported
|
|
298
|
+
* way to use this tool, and anything that reads correctly only in
|
|
299
|
+
* `expressive` is a defect.
|
|
300
|
+
*
|
|
301
|
+
* `FORCE_COLOR` with no TTY is the one combination worth stating outright: the
|
|
302
|
+
* tier is `contract` and colour is on. That is not a contradiction — the tier
|
|
303
|
+
* answers "is anyone watching this live", the capability answers "did they ask
|
|
304
|
+
* for colour", and someone setting `FORCE_COLOR` in a pipeline has answered the
|
|
305
|
+
* second question themselves.
|
|
306
|
+
*/
|
|
307
|
+
function selectTier({ isTTY, color, unicode }) {
|
|
308
|
+
if (!isTTY) return "contract";
|
|
309
|
+
return color && unicode ? "expressive" : "plain";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* How many columns a terminal that told us nothing is assumed to have.
|
|
314
|
+
*
|
|
315
|
+
* 80, because that is the width a terminal has when it has no opinion, and
|
|
316
|
+
* because guessing narrow is the safe direction: an over-wide guess lets a row
|
|
317
|
+
* wrap, which is the one failure the clipping in `cells.mjs` exists to prevent.
|
|
318
|
+
*/
|
|
319
|
+
const DEFAULT_COLUMNS = 80;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The narrowest terminal keyboard selection is offered on: **49 columns**.
|
|
323
|
+
*
|
|
324
|
+
* Measured rather than chosen. The derivation ran the real prompt corpus — every
|
|
325
|
+
* selection question the CLI can ask — through the width model in `cells.mjs`,
|
|
326
|
+
* in both alphabets, and asked what each candidate width still preserves:
|
|
327
|
+
*
|
|
328
|
+
* | Tier preserved | Columns |
|
|
329
|
+
* |---------------------------------|---------|
|
|
330
|
+
* | marker + full label | 24 |
|
|
331
|
+
* | + full hint line | 41 |
|
|
332
|
+
* | **+ `-> path` context** | **49** |
|
|
333
|
+
* | + `(detected)` suffix | 56 |
|
|
334
|
+
*
|
|
335
|
+
* 49 is the smallest width at which nothing load-bearing is lost: the marker,
|
|
336
|
+
* the whole label, the whole interaction hint, and the path each row would
|
|
337
|
+
* write to. An earlier estimate of 32 was falsified outright — at 32 the hint
|
|
338
|
+
* loses the word `confirm` and the path is cut mid-word, and even 40 loses one
|
|
339
|
+
* character.
|
|
340
|
+
*
|
|
341
|
+
* The floor is deliberately **not** 56. `(detected)` is the only thing 49 gives
|
|
342
|
+
* up, and it is already stated in the run's ENVIRONMENT block, so it duplicates
|
|
343
|
+
* information rather than carrying it. A suffix that says nothing new does not
|
|
344
|
+
* get to decide whether the whole interaction is available — it is omitted
|
|
345
|
+
* cleanly at narrow widths instead, never truncated to a fragment.
|
|
346
|
+
*
|
|
347
|
+
* Not configurable, and that is the decision rather than an omission. A floor
|
|
348
|
+
* anyone can lower is a floor that stops meaning what it was measured to mean.
|
|
349
|
+
*/
|
|
350
|
+
const SELECTION_MIN_COLUMNS = 49;
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* May this run ask its questions with the arrow keys?
|
|
354
|
+
*
|
|
355
|
+
* A capability in its own right, and specifically **not** derived from
|
|
356
|
+
* `dynamic`. `dynamic` is colour ∧ unicode, and neither is a claim about
|
|
357
|
+
* repainting: someone who set `NO_COLOR` asked for no decoration, not for no
|
|
358
|
+
* cursor movement, and someone in a Latin-1 locale said nothing at all about
|
|
359
|
+
* either. Deriving one from the other would take the keyboard away from users
|
|
360
|
+
* who never asked to lose it.
|
|
361
|
+
*
|
|
362
|
+
* Five conditions, written as refusals, in the order that makes each one's
|
|
363
|
+
* reason legible:
|
|
364
|
+
*
|
|
365
|
+
* 1. **`PATHFINDER_PROMPT=classic` outranks everything.** It is a person saying
|
|
366
|
+
* so, and it is the first-class answer for a screen reader — where a
|
|
367
|
+
* repainting block re-announces itself on every arrow press and the highlight
|
|
368
|
+
* is carried by position and colour, neither of which is conveyed. It also
|
|
369
|
+
* outranks capability so that scripts and anyone who simply prefers typing a
|
|
370
|
+
* number get a supported path rather than a workaround.
|
|
371
|
+
* 2. **Both ends must be terminals.** A selector needs somewhere to repaint
|
|
372
|
+
* *and* someone able to press a key. Either half missing and the question
|
|
373
|
+
* cannot be answered this way at all.
|
|
374
|
+
* 3. **`TERM=dumb` is the terminal telling us what it is.** Taken at its word,
|
|
375
|
+
* exactly as `detectColor` takes it.
|
|
376
|
+
* 4. **`setRawMode` must exist on the input.** Not called here or anywhere in
|
|
377
|
+
* this package — readline owns raw mode and keeps it — but its absence means
|
|
378
|
+
* the stream cannot deliver keypresses, so there is nothing to borrow.
|
|
379
|
+
* 5. **At least `SELECTION_MIN_COLUMNS`.** Below the floor the classic path is
|
|
380
|
+
* used, which is a supported way to answer the question rather than a
|
|
381
|
+
* degradation of this one.
|
|
382
|
+
*/
|
|
383
|
+
function detectSelection({ env, isTTY, inputIsTTY, setRawMode, columns }) {
|
|
384
|
+
if (env.PATHFINDER_PROMPT === "classic") return false;
|
|
385
|
+
if (!isTTY || !inputIsTTY) return false;
|
|
386
|
+
if (env.TERM === "dumb") return false;
|
|
387
|
+
if (!setRawMode) return false;
|
|
388
|
+
return columns >= SELECTION_MIN_COLUMNS;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Build a theme from what the process was able to observe about the outside
|
|
393
|
+
* world.
|
|
394
|
+
*
|
|
395
|
+
* @param {object} [options]
|
|
396
|
+
* @param {Record<string, string | undefined>} [options.env] - the environment,
|
|
397
|
+
* as `run()` received it.
|
|
398
|
+
* @param {string} [options.platform] - `process.platform`, as `run()` received
|
|
399
|
+
* it.
|
|
400
|
+
* @param {boolean} [options.isTTY] - whether stdout is a terminal.
|
|
401
|
+
* @param {boolean} [options.inputIsTTY] - whether stdin is a terminal. Defaults
|
|
402
|
+
* to false for the same reason every other capability here does: an
|
|
403
|
+
* unanswerable environment gets the conservative answer.
|
|
404
|
+
* @param {boolean} [options.setRawMode] - whether the input stream offers
|
|
405
|
+
* `setRawMode`. Passed as a boolean rather than the stream, so this module
|
|
406
|
+
* stays pure and no test has to synthesize a TTY to ask a question about one.
|
|
407
|
+
* @param {number} [options.columns] - the terminal's width. Anything that is
|
|
408
|
+
* not a positive integer — including the `undefined` a non-TTY stream reports
|
|
409
|
+
* — falls back to 80 rather than throwing.
|
|
410
|
+
* @returns {Readonly<object>} the theme
|
|
411
|
+
*/
|
|
412
|
+
export function createTheme({
|
|
413
|
+
env = {},
|
|
414
|
+
platform = "linux",
|
|
415
|
+
isTTY = false,
|
|
416
|
+
inputIsTTY = false,
|
|
417
|
+
setRawMode = false,
|
|
418
|
+
columns,
|
|
419
|
+
} = {}) {
|
|
420
|
+
const unicode = detectUnicode(env, platform);
|
|
421
|
+
const color = detectColor(env, isTTY);
|
|
422
|
+
const colorDepth = detectColorDepth(env, color);
|
|
423
|
+
const tier = selectTier({ isTTY, color, unicode });
|
|
424
|
+
|
|
425
|
+
// A width, always, and never a throw. `process.stdout.columns` is `undefined`
|
|
426
|
+
// whenever stdout is not a terminal, and every arithmetic done with it
|
|
427
|
+
// afterwards would be `NaN` — which compares false against every threshold and
|
|
428
|
+
// would silently answer "no" to questions nobody asked.
|
|
429
|
+
const terminalColumns =
|
|
430
|
+
Number.isInteger(columns) && columns > 0 ? columns : DEFAULT_COLUMNS;
|
|
431
|
+
|
|
432
|
+
// May this run repaint a line it has already written? Only where someone is
|
|
433
|
+
// watching it happen. A pipe keeps every byte ever written to it, so a
|
|
434
|
+
// progress treatment that repaints into a log file produces a transcript of
|
|
435
|
+
// its own animation.
|
|
436
|
+
const dynamic = tier === "expressive";
|
|
437
|
+
|
|
438
|
+
const selection = detectSelection({
|
|
439
|
+
env,
|
|
440
|
+
isTTY,
|
|
441
|
+
inputIsTTY,
|
|
442
|
+
setRawMode,
|
|
443
|
+
columns: terminalColumns,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Who is allowed the cursor escapes, and it is the union of the two surfaces
|
|
447
|
+
// that repaint rather than either one alone.
|
|
448
|
+
//
|
|
449
|
+
// This used to be `dynamic` by itself, and that was correct while the progress
|
|
450
|
+
// bar was the only repainting thing in the package. It stopped being correct
|
|
451
|
+
// the moment a second surface repainted under a *different* capability: a
|
|
452
|
+
// selector on a `NO_COLOR` terminal is offered — `NO_COLOR` says nothing about
|
|
453
|
+
// repainting — and would then have been handed `""` for the very sequences it
|
|
454
|
+
// needs, clearing no line and leaving the tail of every longer row behind.
|
|
455
|
+
//
|
|
456
|
+
// No existing output moves. `progress.mjs` tests `theme.dynamic` itself before
|
|
457
|
+
// it reaches for a primitive, so widening the gate cannot widen what the
|
|
458
|
+
// progress bar draws. What it does narrow is one promise worth stating
|
|
459
|
+
// outright rather than discovering: "a run without colour emits no escape
|
|
460
|
+
// byte" now means no *colour* escape byte. A plain-tier terminal driving a
|
|
461
|
+
// selector emits `ESC[2K` and `ESC[nA`, because that is what a selector is.
|
|
462
|
+
const repaints = dynamic || selection;
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Wrap `text` in an SGR pair, or hand it back untouched.
|
|
466
|
+
*
|
|
467
|
+
* The reset is unconditional rather than a matching "off" code, because a
|
|
468
|
+
* caller may nest and the cheap correct thing is to end every span the same
|
|
469
|
+
* way. Several codes may be given, which is how emphasis combines with a
|
|
470
|
+
* colour without a call site nesting two paints and emitting two resets.
|
|
471
|
+
*/
|
|
472
|
+
const paint =
|
|
473
|
+
(...codes) =>
|
|
474
|
+
(text) =>
|
|
475
|
+
color ? `${codes.join("")}${text}${SGR.reset}` : `${text}`;
|
|
476
|
+
|
|
477
|
+
const glyph = unicode ? GLYPHS.unicode : GLYPHS.ascii;
|
|
478
|
+
|
|
479
|
+
return Object.freeze({
|
|
480
|
+
// What was decided, exposed for tests and for the one place that may
|
|
481
|
+
// legitimately branch: a caller choosing between whole presentations.
|
|
482
|
+
tier,
|
|
483
|
+
color,
|
|
484
|
+
unicode,
|
|
485
|
+
dynamic,
|
|
486
|
+
|
|
487
|
+
// May this run's questions be answered with the arrow keys?
|
|
488
|
+
//
|
|
489
|
+
// Read by `prompt.mjs` to choose between two whole implementations of the
|
|
490
|
+
// same interface, which is the one legitimate reason to branch on a
|
|
491
|
+
// capability. False is not an error and not a degraded rendering: it selects
|
|
492
|
+
// the numbered/`y n` path, which is a supported way to use this tool and
|
|
493
|
+
// stays byte-identical to the one 1.6.0 shipped.
|
|
494
|
+
selection,
|
|
495
|
+
|
|
496
|
+
// The width the capability above was decided against, resolved and never
|
|
497
|
+
// undefined.
|
|
498
|
+
//
|
|
499
|
+
// A *snapshot*, and the distinction matters: a terminal can be resized while
|
|
500
|
+
// a question is on screen, so a renderer clipping its rows must read the
|
|
501
|
+
// live value from the stream it is writing to. This is here to answer "how
|
|
502
|
+
// wide did we think it was when we decided", which is what a test and a
|
|
503
|
+
// reviewer need, and nothing else.
|
|
504
|
+
columns: terminalColumns,
|
|
505
|
+
|
|
506
|
+
glyph,
|
|
507
|
+
|
|
508
|
+
// Severity, named for what it means and never for the colour it happens to
|
|
509
|
+
// use. Four levels: `warn` is new and nothing consumes it yet.
|
|
510
|
+
//
|
|
511
|
+
// Colour never carries meaning alone. Every one of these takes a string
|
|
512
|
+
// that already says something, and the call sites pair them with a glyph
|
|
513
|
+
// from the table above — so a `plain` terminal, a screen reader, and a
|
|
514
|
+
// colour-blind reader all lose the decoration and keep the message.
|
|
515
|
+
ok: paint(SGR.green),
|
|
516
|
+
info: paint(SGR.cyan),
|
|
517
|
+
warn: paint(SGR.yellow),
|
|
518
|
+
bad: paint(SGR.red),
|
|
519
|
+
|
|
520
|
+
// Emphasis, not severity. Kept separate so that "important" and "something
|
|
521
|
+
// went wrong" cannot be confused for one another at a call site.
|
|
522
|
+
bold: paint(SGR.bold),
|
|
523
|
+
dim: paint(SGR.dim),
|
|
524
|
+
|
|
525
|
+
// What depth the brand got, exposed for the same reason `tier` is: tests
|
|
526
|
+
// need to assert it, and a reviewer needs to be able to ask.
|
|
527
|
+
colorDepth,
|
|
528
|
+
|
|
529
|
+
// Identity, and the only place a colour is chosen to mean "Pathfinder"
|
|
530
|
+
// rather than to mean a level.
|
|
531
|
+
//
|
|
532
|
+
// Written against BRAND rather than through `paint`, because this is the
|
|
533
|
+
// one paint whose escape sequence is chosen at run time from what the
|
|
534
|
+
// terminal claimed. Everything else in this module has exactly one code for
|
|
535
|
+
// its whole life, and keeping those two facts in separate functions is what
|
|
536
|
+
// stops a future severity from quietly acquiring a depth.
|
|
537
|
+
//
|
|
538
|
+
// Note what is *not* here: any attempt to reproduce #E0611F on a terminal
|
|
539
|
+
// that did not say it could. At depth 4 the brand degrades to the warm
|
|
540
|
+
// accent the eight ANSI values offer and the mark's form carries the rest,
|
|
541
|
+
// which is the whole reason the mark is a drawing of the logo and not a
|
|
542
|
+
// coloured word.
|
|
543
|
+
brand: (text) => (colorDepth === 0 ? `${text}` : `${BRAND[colorDepth]}${text}${SGR.reset}`),
|
|
544
|
+
|
|
545
|
+
// The only escape sequences that are not colour, and the reason they live
|
|
546
|
+
// here: a progress renderer that hand-rolled its own would be a second
|
|
547
|
+
// place capable of writing bytes into a pipe. Exactly three — return to
|
|
548
|
+
// column zero, clear the current line, and move up — and every one of them
|
|
549
|
+
// is the empty string wherever repainting is not allowed, so a caller that
|
|
550
|
+
// never checks a capability still emits nothing.
|
|
551
|
+
//
|
|
552
|
+
// `up` is the *only* escape Feature 23 added, and the budget is the design
|
|
553
|
+
// rather than a coincidence. A selector needs to get back to the top of the
|
|
554
|
+
// block it drew and rewrite it; it does not need absolute positioning, a
|
|
555
|
+
// scroll region, an alternate screen, or — above all — cursor hiding. A
|
|
556
|
+
// fourth primitive appearing here means a renderer has started drawing
|
|
557
|
+
// something this package decided not to draw.
|
|
558
|
+
//
|
|
559
|
+
// `n` is a row count, so a non-positive or non-integer one is not an error
|
|
560
|
+
// to raise but a movement to decline: the first paint of a block has nothing
|
|
561
|
+
// above it to return to, and `ESC[0A` moves one row on some terminals rather
|
|
562
|
+
// than none.
|
|
563
|
+
line: Object.freeze({
|
|
564
|
+
start: () => (repaints ? "\r" : ""),
|
|
565
|
+
clear: () => (repaints ? "\u001B[2K" : ""),
|
|
566
|
+
up: (n) => (repaints && Number.isInteger(n) && n > 0 ? `\u001B[${n}A` : ""),
|
|
567
|
+
}),
|
|
568
|
+
|
|
569
|
+
// How wide a rendered string actually is, and how to make it narrower.
|
|
570
|
+
//
|
|
571
|
+
// Published here rather than imported directly by call sites, for the same
|
|
572
|
+
// reason every paint is: one seam, so a second opinion about what a
|
|
573
|
+
// decorated string measures cannot come into existence. A caller reaching
|
|
574
|
+
// past this into `cells.mjs` is doing the thing this module exists to
|
|
575
|
+
// prevent — and a caller reaching for `.length` is simply wrong, by the
|
|
576
|
+
// length of whatever escape sequence the theme just wrapped around it.
|
|
577
|
+
//
|
|
578
|
+
// Note what these are *not* conditioned on. Unlike `line`, they consult
|
|
579
|
+
// neither `dynamic` nor `color` nor the tier: how many cells a string
|
|
580
|
+
// occupies is a fact about the string, and a function that gave a different
|
|
581
|
+
// answer in a pipe would be lying about identical bytes. Capability decides
|
|
582
|
+
// what a caller *does* with the answer, not what the answer is.
|
|
583
|
+
//
|
|
584
|
+
// Deliberately thin. No padding, no alignment, and above all no wrapping —
|
|
585
|
+
// `cells.mjs` explains why wrapping is the one addition that would turn
|
|
586
|
+
// this into the layout engine neither module is allowed to become.
|
|
587
|
+
width,
|
|
588
|
+
clip,
|
|
589
|
+
});
|
|
590
|
+
}
|