create-pathfinder 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/theme.mjs CHANGED
@@ -25,9 +25,20 @@
25
25
  * Deliberately absent: cursor visibility control. A renderer that never hides
26
26
  * the cursor has nothing to restore, so no signal handler and no interrupted
27
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.
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.
29
38
  */
30
39
 
40
+ import { clip, width } from "./cells.mjs";
41
+
31
42
  /**
32
43
  * SGR codes, written out rather than depended on.
33
44
  *
@@ -105,11 +116,37 @@ const BRAND = Object.freeze({
105
116
  * `rule` is both the stroke the Pathfinder mark is drawn from and the character
106
117
  * any other horizontal device would use. `gutter` hangs a block together down
107
118
  * its left edge. Both are drawn left to right from a fixed count and neither
108
- * closes on the right, so no caller ever has to know the printed width of a
109
- * decorated string. That is why there is no corner, no box, and no border
110
- * character in this table a closed box cannot be aligned without width maths
111
- * that emoji defeat, which the prototype demonstrated by failing to close its
112
- * own.
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.
113
150
  */
114
151
  const GLYPHS = Object.freeze({
115
152
  unicode: Object.freeze({
@@ -127,6 +164,11 @@ const GLYPHS = Object.freeze({
127
164
  gutter: "│",
128
165
  barFull: "█",
129
166
  barEmpty: "░",
167
+ pointer: "❯",
168
+ checked: "◉",
169
+ unchecked: "○",
170
+ arrowUp: "↑",
171
+ arrowDown: "↓",
130
172
  }),
131
173
  ascii: Object.freeze({
132
174
  ok: "+",
@@ -143,6 +185,11 @@ const GLYPHS = Object.freeze({
143
185
  gutter: "|",
144
186
  barFull: "#",
145
187
  barEmpty: ".",
188
+ pointer: ">",
189
+ checked: "[x]",
190
+ unchecked: "[ ]",
191
+ arrowUp: "^",
192
+ arrowDown: "v",
146
193
  }),
147
194
  });
148
195
 
@@ -262,6 +309,85 @@ function selectTier({ isTTY, color, unicode }) {
262
309
  return color && unicode ? "expressive" : "plain";
263
310
  }
264
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
+
265
391
  /**
266
392
  * Build a theme from what the process was able to observe about the outside
267
393
  * world.
@@ -272,20 +398,69 @@ function selectTier({ isTTY, color, unicode }) {
272
398
  * @param {string} [options.platform] - `process.platform`, as `run()` received
273
399
  * it.
274
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.
275
410
  * @returns {Readonly<object>} the theme
276
411
  */
277
- export function createTheme({ env = {}, platform = "linux", isTTY = false } = {}) {
412
+ export function createTheme({
413
+ env = {},
414
+ platform = "linux",
415
+ isTTY = false,
416
+ inputIsTTY = false,
417
+ setRawMode = false,
418
+ columns,
419
+ } = {}) {
278
420
  const unicode = detectUnicode(env, platform);
279
421
  const color = detectColor(env, isTTY);
280
422
  const colorDepth = detectColorDepth(env, color);
281
423
  const tier = selectTier({ isTTY, color, unicode });
282
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
+
283
432
  // May this run repaint a line it has already written? Only where someone is
284
433
  // watching it happen. A pipe keeps every byte ever written to it, so a
285
434
  // progress treatment that repaints into a log file produces a transcript of
286
435
  // its own animation.
287
436
  const dynamic = tier === "expressive";
288
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
+
289
464
  /**
290
465
  * Wrap `text` in an SGR pair, or hand it back untouched.
291
466
  *
@@ -309,6 +484,25 @@ export function createTheme({ env = {}, platform = "linux", isTTY = false } = {}
309
484
  unicode,
310
485
  dynamic,
311
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
+
312
506
  glyph,
313
507
 
314
508
  // Severity, named for what it means and never for the colour it happens to
@@ -350,13 +544,47 @@ export function createTheme({ env = {}, platform = "linux", isTTY = false } = {}
350
544
 
351
545
  // The only escape sequences that are not colour, and the reason they live
352
546
  // here: a progress renderer that hand-rolled its own would be a second
353
- // place capable of writing bytes into a pipe. Exactly two — return to
354
- // column zero, and clear the current line — and both are the empty string
355
- // whenever repainting is not allowed, so a caller that never checks
356
- // `dynamic` still emits nothing.
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.
357
563
  line: Object.freeze({
358
- start: () => (dynamic ? "\r" : ""),
359
- clear: () => (dynamic ? "\u001B[2K" : ""),
564
+ start: () => (repaints ? "\r" : ""),
565
+ clear: () => (repaints ? "\u001B[2K" : ""),
566
+ up: (n) => (repaints && Number.isInteger(n) && n > 0 ? `\u001B[${n}A` : ""),
360
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,
361
589
  });
362
590
  }
@@ -22,6 +22,12 @@ State one user-visible or system-verifiable outcome.
22
22
 
23
23
  - Earlier feature, system, decision, prototype, or `None`.
24
24
 
25
+ ## Tags
26
+
27
+ - Optional. `namespace:value`, e.g. `area:cli`, `type:infra`, `agent:suitable`.
28
+ - Omit this section entirely, or write `None`, when the project does not use tags. Most do not.
29
+ - Only meaningful when the project has configured work tracking; nothing else reads them.
30
+
25
31
  ## Requirements
26
32
 
27
33
  - Requirement 1