projectstore-claude 0.29.2 → 0.29.3

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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  3. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  4. package/node_modules/projectstore/README.md +27 -7
  5. package/node_modules/projectstore/commands/bar.md +30 -0
  6. package/node_modules/projectstore/commands/bind.md +14 -7
  7. package/node_modules/projectstore/commands/scaffold.md +24 -16
  8. package/node_modules/projectstore/commands/status.md +1 -1
  9. package/node_modules/projectstore/commands/statusline.md +1 -1
  10. package/node_modules/projectstore/docs/alongside.md +167 -0
  11. package/node_modules/projectstore/docs/extending.md +17 -3
  12. package/node_modules/projectstore/docs/getting-started.md +18 -9
  13. package/node_modules/projectstore/docs/harnesses.md +1 -1
  14. package/node_modules/projectstore/docs/how-it-works.md +7 -0
  15. package/node_modules/projectstore/harnesses/claude-code.json +18 -3
  16. package/node_modules/projectstore/harnesses/codex.json +12 -1
  17. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  18. package/node_modules/projectstore/hooks/band.tsx +594 -0
  19. package/node_modules/projectstore/hooks/hooks.json +1 -0
  20. package/node_modules/projectstore/hooks/session-start.mjs +6 -2
  21. package/node_modules/projectstore/package.json +2 -1
  22. package/node_modules/projectstore/scaffold/headings.json +8 -0
  23. package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
  24. package/node_modules/projectstore/scripts/band.mjs +156 -0
  25. package/node_modules/projectstore/scripts/binding.mjs +157 -17
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
  27. package/node_modules/projectstore/scripts/cli.mjs +545 -179
  28. package/node_modules/projectstore/scripts/convergence.mjs +468 -0
  29. package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
  30. package/node_modules/projectstore/scripts/doctor.mjs +80 -62
  31. package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
  32. package/node_modules/projectstore/scripts/harness.mjs +178 -7
  33. package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
  34. package/node_modules/projectstore/scripts/kanban.mjs +4 -20
  35. package/node_modules/projectstore/scripts/lib.mjs +257 -58
  36. package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
  37. package/node_modules/projectstore/scripts/query.mjs +83 -12
  38. package/node_modules/projectstore/scripts/run-host.mjs +94 -0
  39. package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
  40. package/node_modules/projectstore/scripts/stages.mjs +80 -0
  41. package/node_modules/projectstore/scripts/statusline.mjs +25 -2
  42. package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
  43. package/node_modules/projectstore/scripts/term.mjs +729 -39
  44. package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
  45. package/node_modules/projectstore/templates/de/strings.json +63 -1
  46. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  47. package/node_modules/projectstore/templates/en/strings.json +63 -1
  48. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  49. package/node_modules/projectstore/templates/es/strings.json +63 -1
  50. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  51. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  52. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  53. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  54. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  55. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  56. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  57. package/package.json +2 -2
@@ -4,57 +4,162 @@
4
4
  // One question decides everything here: is this a person at a terminal? Then
5
5
  // colour and in-place step lines. Otherwise — a pipe, an agent's tool call,
6
6
  // CI — the same layout as plain text, so a reader that is not a terminal loses
7
- // nothing but the escapes (install spec, contract 18).
7
+ // nothing but the escapes (install spec, contract 18). The layout itself —
8
+ // the glyph table, the colour roles and the primitives every screen is built
9
+ // from — is the presentation spec's (*Terminal presentation of the
10
+ // projectstore CLI: layout, glyphs, colour roles, questions and live lines*,
11
+ // contracts 2–6 and 8).
8
12
  //
9
- // caps(stream, env) → { color, live, width, ascii }
10
- // painter(caps) → paint(style, text)
11
- // icon(caps, name) → the glyph for an action or a result
13
+ // caps(stream, env) → { color, live, width, columns, ascii }
14
+ // painter(caps) → paint(style or role, text)
15
+ // icon(caps, name) → a glyph or separator from GLYPHS
16
+ // glyphText(caps, text) → the bin's own prose, its glyphs in caps' column
12
17
  // duration(ms) → "0.4s", "12s", "1m 03s"
13
18
  // wrap(text, width, indent) → prose broken at spaces, hanging indent
14
- // stepReporter(stream, caps) → { start(label), end(ok, note), abort() }
19
+ // badge(caps, head, parts) → a screen's first line
20
+ // heading(caps, title, counts) → "Plan — 3 to add · 5 unchanged"
21
+ // rows(caps, list, opts) → aligned glyph · action · target · note lines
22
+ // rowLines(caps, list, opts) → the same, one array of lines per row
23
+ // PLAIN → the caps of a reader that is not a terminal
24
+ // rule(caps, length) → a horizontal rule
25
+ // kv(caps, pairs, opts) → aligned key–value lines
26
+ // commandBlock(caps, cmds) → commands on their own lines (contract 6)
27
+ // stepReporter(stream, caps) → { start(label), end(ok, note, opts), finish(label, t0, opts), abort() }
28
+ // liveLine(stream, caps, label) → { end(ok, doneLabel, note), abort() }
15
29
  // askLine(question, in, out) → the answer, or null on end of input
30
+ // askApply(n, opts) → contract 9's question; true applies
31
+ // railAvailable(stdin, caps) → true when the question rail may be drawn
32
+ // openRail(opts) → { select, multi, confirm, path, answered, close, cancelled }
33
+ // CANCELLED, BACK → what a rail question resolves to on Ctrl+C or end of input, and on Esc
16
34
 
17
35
  import { createInterface } from "node:readline/promises";
36
+ import { emitKeypressEvents } from "node:readline";
18
37
 
19
38
  // FORCE_COLOR decides when it is set, as Node's own tty reads it: "", "1",
20
39
  // "2", "3" or "true" turn colour on even without a terminal, anything else
21
40
  // ("0", "false") turns it off. Otherwise NO_COLOR (https://no-color.org) turns
22
41
  // colour off when set to anything non-empty, and TERM=dumb means a terminal
23
42
  // that understands no escapes.
43
+ //
44
+ // Glyphs are a switch of their own (presentation spec contract 2): ASCII
45
+ // under TERM=dumb or PROJECTSTORE_ASCII set non-empty, Unicode otherwise, in
46
+ // every mode. PROJECTSTORE_ASCII changes glyphs only — colour, live lines and
47
+ // questions follow the stream — so a terminal that draws ambiguous-width
48
+ // characters wide still gets aligned rows, in colour.
24
49
  export function caps(stream = process.stdout, env = process.env) {
25
50
  const tty = Boolean(stream && stream.isTTY);
26
51
  const dumb = env.TERM === "dumb";
27
52
  const off = env.NO_COLOR !== undefined && env.NO_COLOR !== "";
28
53
  const color = env.FORCE_COLOR !== undefined ? ["", "1", "2", "3", "true"].includes(String(env.FORCE_COLOR)) : tty && !dumb && !off;
29
54
  const columns = tty && Number(stream.columns) > 0 ? Number(stream.columns) : 100;
55
+ const ascii = dumb || (env.PROJECTSTORE_ASCII !== undefined && env.PROJECTSTORE_ASCII !== "");
30
56
  // `width` lays out prose (never below 40); `columns` is the terminal's
31
57
  // real width, which a live line must not reach.
32
- return { color, live: tty && !dumb, width: Math.max(40, Math.min(columns, 120)), columns, ascii: dumb };
58
+ return { color, live: tty && !dumb, width: Math.max(40, Math.min(columns, 120)), columns, ascii };
33
59
  }
34
60
 
61
+ // The sixteen ANSI colours, bold and dim — never 256-colour or truecolor, so
62
+ // the reader's terminal theme decides the shades, light or dark.
35
63
  const SGR = {
36
64
  bold: [1, 22], dim: [2, 22], red: [31, 39], green: [32, 39], yellow: [33, 39],
37
65
  blue: [34, 39], magenta: [35, 39], cyan: [36, 39], gray: [90, 39],
38
66
  };
39
67
 
68
+ // The colour roles (presentation spec contract 3), each one style above.
69
+ // Colour never carries meaning alone: whatever wears a role also has a glyph
70
+ // or a word that says the same.
71
+ // action the active question, a command to run, a verb, a fetch
72
+ // done ✓, an added row's glyph, a zero-issue count
73
+ // attention ▲, a channel switch, a refusal, a warning count
74
+ // error ✕, a failed step, an issue count
75
+ // explanation notes, reasons, consent lines, the rail, the project path
76
+ // emphasis headings, a question's title, a finding's title
77
+ export const ROLES = Object.freeze({ action: "cyan", done: "green", attention: "yellow", error: "red", explanation: "gray", emphasis: "bold" });
78
+
40
79
  // Raw SGR rather than util.styleText: that arrived in Node 20.12 and 21.7, and
41
- // the package promises >=20.0.0.
80
+ // the package promises >=20.0.0. `style` is a role or a style name; anything
81
+ // else, like a null role, paints nothing.
42
82
  export function painter(c) {
43
83
  return (style, text) => {
44
84
  const s = String(text);
45
- if (!c.color || !SGR[style] || !s) return s;
46
- return `\x1b[${SGR[style][0]}m${s}\x1b[${SGR[style][1]}m`;
85
+ const sgr = SGR[ROLES[style] || style];
86
+ if (!c.color || !sgr || !s) return s;
87
+ return `\x1b[${sgr[0]}m${s}\x1b[${sgr[1]}m`;
47
88
  };
48
89
  }
49
90
 
50
- const ICONS = {
51
- create: ["+", "+"], update: ["↻", "~"], migrate: ["↻", "~"], refresh: ["↻", "~"],
52
- remove: ["✕", "x"], cleanup: ["✕", "x"], skip: ["·", "."], refuse: ["!", "!"],
53
- ok: ["✓", "ok"], fail: ["✗", "FAIL"], running: ["…", "..."], note: ["!", "!"],
54
- };
91
+ // The one glyph table (presentation spec contract 4): every glyph and
92
+ // separator a renderer composes, as [Unicode, ASCII]. The ASCII column is
93
+ // used when caps().ascii says so. Changes from 0.29.2: fail ✗ → ✕ (FAIL →
94
+ // x); remove and cleanup ✕ → − (x → -); refuse and note ! → ▲.
95
+ export const GLYPHS = Object.freeze({
96
+ ok: Object.freeze(["✓", "ok"]),
97
+ add: Object.freeze(["+", "+"]),
98
+ fail: Object.freeze(["✕", "x"]),
99
+ update: Object.freeze(["↻", "~"]),
100
+ warning: Object.freeze(["▲", "!"]),
101
+ switch: Object.freeze(["⇄", "<>"]),
102
+ unchanged: Object.freeze(["·", "."]),
103
+ remove: Object.freeze(["−", "-"]),
104
+ fetch: Object.freeze(["↓", "v"]),
105
+ running: Object.freeze(["…", "..."]),
106
+ ask: Object.freeze(["◆", "*"]),
107
+ answered: Object.freeze(["◇", "o"]),
108
+ choiceOn: Object.freeze(["●", "(*)"]),
109
+ choiceOff: Object.freeze(["○", "( )"]),
110
+ checkOn: Object.freeze(["◼", "[x]"]),
111
+ checkOff: Object.freeze(["◻", "[ ]"]),
112
+ rail: Object.freeze(["│", "|"]),
113
+ railTop: Object.freeze(["┌", "+"]),
114
+ railEnd: Object.freeze(["└", "+"]),
115
+ rule: Object.freeze(["─", "-"]),
116
+ progressDone: Object.freeze(["━", "#"]),
117
+ progressLeft: Object.freeze(["╺", "-"]),
118
+ dash: Object.freeze(["—", "-"]),
119
+ dot: Object.freeze(["·", "."]),
120
+ // A move or a state change in a step's text: `a → b` (decided 2026-10-10,
121
+ // added to the spec's table at the story's close-out).
122
+ transition: Object.freeze(["→", "->"]),
123
+ });
124
+
125
+ // The other names a glyph answers to: the spec's shared rows (fail / issue,
126
+ // warning / refusal / note, unchanged / skipped) and the plan's own action
127
+ // names, so a caller says what happened rather than which shape to draw.
128
+ export const GLYPH_ALIASES = Object.freeze({
129
+ issue: "fail", refuse: "warning", refusal: "warning", note: "warning",
130
+ skip: "unchanged", skipped: "unchanged", create: "add", migrate: "update", refresh: "update",
131
+ cleanup: "remove", prune: "remove",
132
+ // doctor's info finding: not a warning, so not ▲ — the two would differ by
133
+ // colour alone (decided 2026-10-11 with doctor's report).
134
+ info: "dot",
135
+ // The question rail's checklist cursor: the row it is on is marked by a
136
+ // glyph, not by colour alone (contract 3).
137
+ pointer: "transition",
138
+ });
139
+
140
+ // The spinner (presentation spec contract 4): braille frames, one every 80 ms.
141
+ // ASCII glyphs get none — a live line there shows "..." and the elapsed time.
142
+ export const SPINNER = Object.freeze(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]);
143
+ const SPIN_MS = 80;
144
+ // Nothing is drawn before this: a fetch from a warm npm cache ends in about
145
+ // half a second, and a spinner that flashes for one frame is noise.
146
+ const QUIET_MS = 300;
147
+ // A name the table does not know draws the update glyph, as 0.29.2's did: a
148
+ // plan row is never left without a mark.
55
149
  export function icon(c, name) {
56
- const pair = ICONS[name] || ICONS.update;
57
- return c.ascii ? pair[1] : pair[0];
150
+ const pair = GLYPHS[name] || GLYPHS[GLYPH_ALIASES[name]] || GLYPHS.update;
151
+ return c && c.ascii ? pair[1] : pair[0];
152
+ }
153
+
154
+ // Prose the bin keeps in its own words — help's summaries and options, a
155
+ // usage form's `<phrase…>` — with each of the table's glyphs in it drawn from
156
+ // the column caps() picks: under ASCII glyphs — reads -, … reads ..., ·
157
+ // reads . and → reads ->. Text from the vault, a manifest or a finding never
158
+ // goes through it; that prints as it is (presentation spec, Testing).
159
+ const TO_ASCII = new Map(Object.values(GLYPHS).map(([u, a]) => [u, a]));
160
+ export function glyphText(c, text) {
161
+ const s = String(text);
162
+ return c && c.ascii ? [...s].map((ch) => TO_ASCII.get(ch) ?? ch).join("") : s;
58
163
  }
59
164
 
60
165
  export function duration(ms) {
@@ -84,44 +189,169 @@ export function wrap(text, width, indent = "") {
84
189
  return lines.map((l, k) => (k ? indent + l : l)).join("\n");
85
190
  }
86
191
 
87
- // A live line must fit one physical row: "\r\x1b[2K" clears only the row the
88
- // cursor is on, so a label that wrapped would leave its first half behind.
89
- const fit = (text, room) => (text.length <= room ? text : text.slice(0, Math.max(1, room - 1)) + "…");
192
+ // An in-flight frame must fit one physical row: "\r\x1b[2K" clears only the
193
+ // row the cursor is on, so a label that wrapped would leave its first half
194
+ // behind. The cut is marked with the table's running glyph — "...", room
195
+ // reserved for all three, under ASCII glyphs.
196
+ const fit = (c, text, room) => {
197
+ if (text.length <= room) return text;
198
+ const mark = icon(c, "running");
199
+ return text.slice(0, Math.max(1, room - mark.length)) + mark;
200
+ };
201
+
202
+ // The columns a line may fill: one short of the terminal — a line that fills
203
+ // the last column leaves some terminals waiting to wrap — and never past 100.
204
+ const rowWidth = (c) => Math.min(c.width, c.columns || c.width, 100) - 1;
205
+
206
+ // One row of a step line: the mark, the label, and the right-hand note
207
+ // aligned to the row's end. Only an in-flight frame (`cut`) is cut to the
208
+ // row, and only on a live terminal (presentation spec contract 8): the
209
+ // finished line that replaces it carries the whole target, and the terminal
210
+ // wraps it where it must.
211
+ function rowFor(c, indent) {
212
+ const width = rowWidth(c);
213
+ return (mark, label, right, { cut = false } = {}) => {
214
+ const room = width - indent - plain(mark).length - 1 - plain(right).length - 1;
215
+ const left = `${" ".repeat(indent)}${mark} ${cut && c.live ? fit(c, label, room) : label}`;
216
+ const gap = Math.max(1, width - plain(left).length - plain(right).length);
217
+ return left + " ".repeat(gap) + right;
218
+ };
219
+ }
220
+
221
+ // ─── Layout primitives (presentation spec contracts 5–7) ─────────────────
222
+ //
223
+ // Pure: each returns text for its caller to print. Cells are measured on
224
+ // plain() width, so a painted cell and an unpainted one align alike.
225
+
226
+ const separated = (c) => ` ${icon(c, "dot")} `;
227
+
228
+ // A screen's first line: the head in the emphasis role, then its parts, each
229
+ // after the dot separator — `projectstore install · Claude Code, Codex`.
230
+ export function badge(c, head, parts = []) {
231
+ const shown = parts.filter((x) => x !== null && x !== undefined && x !== "");
232
+ return [painter(c)("emphasis", head), ...shown].join(separated(c));
233
+ }
234
+
235
+ // A heading: the title in the emphasis role and, when there are any, its
236
+ // counts after the dash separator, joined by the dot separator. A count is a
237
+ // string or [text, role] — `Plan — 3 to add · 1 to update · 5 unchanged`,
238
+ // each number in its role's colour.
239
+ export function heading(c, title, counts = []) {
240
+ const paint = painter(c);
241
+ const parts = counts.filter(Boolean).map((x) => (Array.isArray(x) ? paint(x[1], x[0]) : String(x)));
242
+ const head = paint("emphasis", title);
243
+ return parts.length ? `${head} ${icon(c, "dash")} ${parts.join(separated(c))}` : head;
244
+ }
245
+
246
+ // Aligned rows (contract 5): glyph · action word · target · note. Each column
247
+ // is as wide as its widest cell in the block — the target column over the
248
+ // rows that carry a note, since nothing else aligns after it — and an empty
249
+ // column takes no room. Nothing is boxed and nothing is cut: a target is
250
+ // never truncated or broken. On a live terminal a row that would pass the
251
+ // width moves its note to the next line, indented to the target column, where
252
+ // a long note wraps; anywhere else each row stays one line, whole, for a
253
+ // reader that greps. A row is { glyph, role, action, target, note }: `glyph`
254
+ // a name in GLYPHS or an alias, `role` the colour of the glyph and the action
255
+ // word; the note is in the explanation role. rowLines() gives each row's
256
+ // lines on their own, for a caller that prints more beneath each row.
257
+ // A row with `prose: true` has words for a target, not a path (status's story
258
+ // titles): on a live terminal one wider than the row wraps at its spaces,
259
+ // with the target column as its hanging indent, and a moved note goes beneath
260
+ // it at that column. Any other target is never wrapped.
261
+ export function rows(c, list, opts = {}) {
262
+ return rowLines(c, list, opts).flat();
263
+ }
264
+
265
+ export function rowLines(c, list, { indent = 2 } = {}) {
266
+ const paint = painter(c);
267
+ const cells = list.map((r) => ({ glyph: r.glyph ? icon(c, r.glyph) : "", role: r.role || null, action: String(r.action ?? ""), target: String(r.target ?? ""), note: String(r.note ?? ""), prose: Boolean(r.prose) }));
268
+ const widest = (xs) => Math.max(0, ...xs.map((s) => plain(s).length));
269
+ const gw = widest(cells.map((r) => r.glyph));
270
+ const aw = widest(cells.map((r) => r.action));
271
+ const tw = widest(cells.filter((r) => r.note).map((r) => r.target));
272
+ const width = c.live ? rowWidth(c) : 0;
273
+ const cell = (text, w, role) => paint(role, text) + " ".repeat(w - plain(text).length);
274
+ const out = [];
275
+ for (const r of cells) {
276
+ let lead = " ".repeat(indent);
277
+ if (gw) lead += cell(r.glyph, gw, r.role) + " ";
278
+ if (aw) lead += cell(r.action, aw, r.role) + " ";
279
+ const col = plain(lead).length;
280
+ const pad = " ".repeat(col);
281
+ const alone = (r.prose && width ? lead + wrap(r.target, width, pad) : lead + r.target).split("\n").map((l) => l.trimEnd());
282
+ if (!r.note) { out.push(alone); continue; }
283
+ const line = `${lead}${cell(r.target, tw, null)} ${paint("explanation", r.note)}`;
284
+ if (!width || plain(line).length <= width) { out.push([line]); continue; }
285
+ out.push([...alone, ...wrap(r.note, width, pad).split("\n").map((l, k) => pad + paint("explanation", k ? l.slice(col) : l))]);
286
+ }
287
+ return out;
288
+ }
289
+
290
+ // The capabilities of a reader that is not a terminal and asked for nothing:
291
+ // the renderers' default, so a caller that passes no caps gets plain text.
292
+ export const PLAIN = Object.freeze({ color: false, live: false, width: 100, columns: 100, ascii: false });
293
+
294
+ // A horizontal rule in the explanation role, as wide as a row may be.
295
+ export function rule(c, length = rowWidth(c)) {
296
+ return painter(c)("explanation", icon(c, "rule").repeat(Math.max(0, length)));
297
+ }
298
+
299
+ // Key–value lines (contracts 8 and 12): each key in the explanation role,
300
+ // padded to the widest key, then two spaces and the value as it is. A key
301
+ // with no value is printed alone, with nothing after it.
302
+ export function kv(c, pairs, { indent = 2 } = {}) {
303
+ const paint = painter(c);
304
+ const kw = Math.max(0, ...pairs.map(([k]) => plain(k).length));
305
+ return pairs.map(([k, v]) => {
306
+ const value = v === null || v === undefined ? "" : String(v);
307
+ const key = `${" ".repeat(indent)}${paint("explanation", k)}`;
308
+ return value ? `${key}${" ".repeat(kw - plain(k).length)} ${value}` : key;
309
+ });
310
+ }
311
+
312
+ // Commands the renderer composes (contract 6): a blank line, then each
313
+ // command on a line of its own in the action role, with nothing after it —
314
+ // no punctuation, no prose — so a copy takes exactly the command. Never
315
+ // wrapped: a command broken in two cannot be pasted back.
316
+ export function commandBlock(c, commands, { indent = 2 } = {}) {
317
+ const paint = painter(c);
318
+ return ["", ...commands.map((cmd) => `${" ".repeat(indent)}${paint("action", cmd)}`)];
319
+ }
90
320
 
91
321
  // One line per step. On a live terminal the line appears when the step starts
92
- // (ending in "…") and is rewritten in place with the result and duration when
93
- // it ends; anything else gets the finished line only. Steps run synchronously
322
+ // (marked "…", cut to one row) and is rewritten in place with the result, the
323
+ // whole label and the duration when it ends; anything else gets the finished
324
+ // line only. Steps run synchronously
94
325
  // (spawnSync), so nothing animates in between — the "…" is the honest state.
95
- export function stepReporter(stream = process.stdout, c = caps(stream), { indent = 2 } = {}) {
326
+ // `end` may name the finished line in other words than the running one
327
+ // (`label`: "adding …" while it runs, "added …" once it has); `now` is the
328
+ // test's clock. `finish` is the finished line of work whose own steps printed
329
+ // beneath its first line (a registration, a layout move): nothing of it is in
330
+ // flight, so its line is written on a line of its own, timed from `t0`.
331
+ export function stepReporter(stream = process.stdout, c = caps(stream), { indent = 2, now = Date.now } = {}) {
96
332
  const paint = painter(c);
97
333
  let open = null;
98
- // One column short of the terminal: a line that fills the last column
99
- // leaves some terminals waiting to wrap.
100
- const width = Math.min(c.width, c.columns || c.width, 100) - 1;
101
- const line = (mark, label, right) => {
102
- const room = width - indent - 2 - plain(right).length - 1;
103
- const left = `${" ".repeat(indent)}${mark} ${c.live ? fit(label, room) : label}`;
104
- const gap = Math.max(1, width - plain(left).length - plain(right).length);
105
- return left + " ".repeat(gap) + right;
334
+ const line = rowFor(c, indent);
335
+ const finished = (ok, note, kept, label, ms, over) => {
336
+ // A step that ran and left its target in place is neither ✓ nor ✕.
337
+ const mark = !ok ? paint("error", icon(c, "fail")) : kept ? paint("explanation", icon(c, "skip")) : paint("done", icon(c, "ok"));
338
+ const right = paint("explanation", [note, duration(ms)].filter(Boolean).join(" "));
339
+ stream.write((over && c.live ? "\r\x1b[2K" : "") + line(mark, label, right) + "\n");
106
340
  };
107
341
  const self = {
108
342
  start(label) {
109
343
  // A step started while another is still open (a host command run by a
110
344
  // rollback) closes the open line where it stands rather than overwrite it.
111
345
  if (open && c.live) stream.write("\n");
112
- open = { label, t0: Date.now() };
113
- if (c.live) stream.write(line(paint("gray", icon(c, "running")), label, ""));
346
+ open = { label, t0: now() };
347
+ if (c.live) stream.write(line(paint("explanation", icon(c, "running")), label, "", { cut: true }));
114
348
  },
115
- end(ok = true, note = "", { kept = false } = {}) {
349
+ end(ok = true, note = "", { kept = false, label = null } = {}) {
116
350
  if (!open) return;
117
- const ms = Date.now() - open.t0;
118
- // A step that ran and left its target in place is neither ✓ nor ✗.
119
- const mark = !ok ? paint("red", icon(c, "fail")) : kept ? paint("gray", icon(c, "skip")) : paint("green", icon(c, "ok"));
120
- const right = paint("gray", [note, duration(ms)].filter(Boolean).join(" "));
121
- const text = line(mark, open.label, right);
122
- stream.write((c.live ? "\r\x1b[2K" : "") + text + "\n");
351
+ finished(ok, note, kept, label ?? open.label, now() - open.t0, true);
123
352
  open = null;
124
353
  },
354
+ finish(label, t0, { ok = true, note = "" } = {}) { finished(ok, note, false, label, now() - t0, false); },
125
355
  // An exception left a step open: end its line as failed, so whatever is
126
356
  // printed next starts on a line of its own.
127
357
  abort() { if (open) self.end(false); },
@@ -129,6 +359,65 @@ export function stepReporter(stream = process.stdout, c = caps(stream), { indent
129
359
  return self;
130
360
  }
131
361
 
362
+ // A line for work that runs asynchronously, so it can animate (presentation
363
+ // spec contract 8, its asynchronous part; built for the shell fetch of
364
+ // install and upgrade, reused by an asynchronous APPLY). It starts on
365
+ // creation. On a live terminal nothing shows for the first 300 ms; then the
366
+ // spinner in the action role, the label and the elapsed time, redrawn in
367
+ // place every 80 ms and cut to one row. Under ASCII glyphs the frames are
368
+ // "..." with the elapsed time. PROJECTSTORE_NO_ANIMATION, set non-empty, draws
369
+ // the "…" line at once, as stepReporter does, and then only the finished one.
370
+ // On a pipe there is one finished line and no escape. `end` replaces the line
371
+ // with ✓ or the fail glyph, the whole past-tense label — never cut, wrapped by
372
+ // the terminal — and the duration. No byte
373
+ // bar: npm gives a child no byte counts, and a bar is shown only when the size
374
+ // is known. The timers are unref'd and cleared by both `end` and `abort`, so
375
+ // a forgotten line never holds the process; the cursor is never hidden, so no
376
+ // exit path has anything to restore. `now` and `timers` are the test's clock.
377
+ export function liveLine(stream = process.stdout, c = caps(stream), label = "", { env = process.env, now = Date.now, timers = globalThis, indent = 2 } = {}) {
378
+ const paint = painter(c);
379
+ const line = rowFor(c, indent);
380
+ const t0 = now();
381
+ const still = Boolean(env.PROJECTSTORE_NO_ANIMATION);
382
+ let drawn = false, frame = 0, wait = null, tick = null, settled = false;
383
+ const unref = (h) => { try { h?.unref?.(); } catch {} return h; };
384
+ const stop = () => {
385
+ if (wait !== null) { timers.clearTimeout(wait); wait = null; }
386
+ if (tick !== null) { timers.clearInterval(tick); tick = null; }
387
+ };
388
+ const draw = () => {
389
+ const mark = c.ascii ? icon(c, "running") : paint("action", SPINNER[frame++ % SPINNER.length]);
390
+ stream.write("\r\x1b[2K" + line(mark, label, paint("explanation", duration(now() - t0)), { cut: true }));
391
+ drawn = true;
392
+ };
393
+ if (c.live && still) { stream.write(line(paint("explanation", icon(c, "running")), label, "", { cut: true })); drawn = true; }
394
+ else if (c.live) {
395
+ wait = unref(timers.setTimeout(() => {
396
+ wait = null;
397
+ draw();
398
+ tick = unref(timers.setInterval(draw, SPIN_MS));
399
+ }, QUIET_MS));
400
+ }
401
+ return {
402
+ end(ok = true, doneLabel = label, note = "") {
403
+ if (settled) return;
404
+ settled = true;
405
+ stop();
406
+ const mark = ok ? paint("done", icon(c, "ok")) : paint("error", icon(c, "fail"));
407
+ const right = paint("explanation", [note, duration(now() - t0)].filter(Boolean).join(" "));
408
+ stream.write((c.live && drawn ? "\r\x1b[2K" : "") + line(mark, doneLabel, right) + "\n");
409
+ },
410
+ // An exception mid-work: the timers go and the drawn row is cleared, so
411
+ // whatever is printed next starts clean.
412
+ abort() {
413
+ if (settled) return;
414
+ settled = true;
415
+ stop();
416
+ if (c.live && drawn) stream.write("\r\x1b[2K");
417
+ },
418
+ };
419
+ }
420
+
132
421
  // One line of input, read the way a shell's own prompt is: in the terminal's
133
422
  // line mode, so readline writes nothing but the question (no cursor escapes),
134
423
  // the terminal does the editing, and Ctrl+C is a real SIGINT (exit 130, before
@@ -147,3 +436,404 @@ export async function askLine(question, input, output) {
147
436
  rl.question(question).then((a) => finish(a), () => finish(null));
148
437
  });
149
438
  }
439
+
440
+ // The install spec's contract 9 question, for every verb that asks it — the
441
+ // install family and `scaffold --write` at a terminal. Its bytes and its
442
+ // answer rule live here once, so two verbs cannot drift into two questions:
443
+ // `◆ Apply N changes? [Y/n] ` (`* Apply…` with ASCII glyphs), the question
444
+ // glyph in the action role (presentation spec contract 8); Enter, y or yes
445
+ // (any case) applies; anything else is a no, and so is end of input. Only
446
+ // the look is the presentation spec's: the line is still read by askLine, in
447
+ // line mode. Whether to ask at all is the caller's (install-harness.mjs's
448
+ // isInteractive). `caps` is the asking stream's; without one the question is
449
+ // unpainted, in Unicode. `ask` stands in for the terminal in tests and means
450
+ // "this is one"; it gets the question and returns the answer.
451
+ export async function askApply(n, { stdin = null, stdout = null, ask = null, caps: c = { color: false, ascii: false } } = {}) {
452
+ const paint = painter(c);
453
+ const question = `${paint("action", icon(c, "ask"))} ${paint("emphasis", `Apply ${n === 1 ? "1 change" : `${n} changes`}?`)} ${paint("explanation", "[Y/n]")} `;
454
+ const answer = ask ? await ask(question) : await askLine(question, stdin, stdout);
455
+ if (answer === null || answer === undefined) return false;
456
+ return /^(y(es)?)?$/i.test(String(answer).trim());
457
+ }
458
+
459
+ // ─── The question rail (presentation spec contract 9; built by the story
460
+ // "projectstore setup asks, shows one plan and installs every harness it
461
+ // finds, with the vault", its rule 2) ──────────────────────────────────────
462
+ //
463
+ // A run that asks more than one question draws them down one left margin:
464
+ // `┌` and the badge at the top, `◆` on the question being asked, `◇` and its
465
+ // answer on one line once it is answered, `└` at the end. Four kinds —
466
+ // select (one of n), multi (a checklist), confirm and path — each with its
467
+ // default, which Enter takes. The confirm that follows the rail is not one of
468
+ // them: it stays askApply's line-mode question, so every verb has one consent
469
+ // gate (contract 8).
470
+ //
471
+ // Keys come in raw mode, through node:readline's keypress decoder: ↑/↓ move,
472
+ // space toggles a checklist row, Enter confirms; Esc — reported after
473
+ // readline's escape timeout, since a bare Esc begins every arrow sequence —
474
+ // goes back from a path question; Ctrl+C, a byte in raw mode rather than a
475
+ // signal, and end of input cancel. Every other key is ignored.
476
+
477
+ // What a question resolves to when it is cancelled (Ctrl+C, or stdin ended)
478
+ // and when Esc leaves a path question. Once cancelled, a rail stays so: each
479
+ // later question resolves to CANCELLED at once and draws nothing.
480
+ export const CANCELLED = Symbol("projectstore.rail.cancelled");
481
+ export const BACK = Symbol("projectstore.rail.back");
482
+
483
+ const HIDE_CURSOR = "\x1b[?25l";
484
+ const SHOW_CURSOR = "\x1b[?25h";
485
+ // readline's own default: how long a lone Esc waits for the rest of a sequence.
486
+ const ESCAPE_MS = 500;
487
+ // End of input, as the key queue carries it, behind the keys typed before it.
488
+ const EOF = Object.freeze({ name: "end of input" });
489
+
490
+ // Whether a rail may be drawn: stdin is a terminal that can be put in raw
491
+ // mode, and the caps (stdout's) are a live terminal — not a pipe, not
492
+ // TERM=dumb. Without a rail the caller runs as without a terminal: no
493
+ // question is asked (the setup story's rule 2 amends contract 9 so). Whether
494
+ // the run is interactive at all — CI, --json, a host session — is the
495
+ // caller's question (install-harness.mjs's isInteractive), asked before this.
496
+ export function railAvailable(stdin, c) {
497
+ return Boolean(stdin && stdin.isTTY && typeof stdin.setRawMode === "function" && c && c.live);
498
+ }
499
+
500
+ // A choice as the rail draws and answers it: a string is its own value.
501
+ const choiceOf = (x) => (typeof x === "object" && x !== null
502
+ ? { value: x.value ?? x.label, label: String(x.label ?? x.value), reason: x.reason ? String(x.reason) : "", answer: String(x.answer ?? x.label ?? x.value), on: Boolean(x.on) }
503
+ : { value: x, label: String(x), reason: "", answer: String(x), on: false });
504
+
505
+ const isEnter = (k) => k.name === "return" || k.name === "enter";
506
+ const printable = (k) => typeof k.str === "string" && k.str !== "" && !k.ctrl && !k.meta && !/[\x00-\x1f\x7f]/.test(k.str);
507
+
508
+ // Open a rail on a terminal: draws its top line and holds the terminal for its
509
+ // questions until close(). Everything the run prints while the rail is open
510
+ // goes through it, since a redraw counts the rows it drew.
511
+ //
512
+ // rail.select(title, choices, { default, note }) → the chosen value
513
+ // rail.multi(title, choices, { note, min = 1 }) → the ticked values, in order
514
+ // rail.confirm(title, { default = true, yes, no, yesReason, noReason, note }) → true or false
515
+ // rail.path(title, { default, check, note, back = true }) → the path typed, or the default
516
+ // rail.answered(title, text) → draws a question a flag answered
517
+ // rail.close(note) → `└`, then the terminal is the caller's
518
+ //
519
+ // A choice is a string, or { value, label, reason, answer, on }: `reason` is
520
+ // shown beside the label (beneath it when the row would pass the width),
521
+ // `answer` is the answered line's text (the label by default), `on` pre-ticks
522
+ // a checklist row. `default` is the value Enter takes. A path's `check(value)`
523
+ // returns a reason to refuse it — shown under the question, which stays open —
524
+ // or nothing to accept it. Each question also resolves to CANCELLED; a path
525
+ // resolves to BACK on Esc, and then its own lines and the answered line
526
+ // before it are erased, so the caller asks that question again where it was.
527
+ //
528
+ // One keypress queue per rail: keys typed ahead of a question — in the same
529
+ // chunk as the Enter that answered the one before, or while the run worked
530
+ // between two — wait for it. Raw mode is on only while a question is asked:
531
+ // each question turns it off and shows the cursor in a `finally`, so a throw
532
+ // restores the terminal as an answer does, and stdin is paused between
533
+ // questions, so an open rail never holds the process. A one-time `exit`
534
+ // handler and a SIGTERM / SIGHUP / SIGINT handler restore it too; the signal handler
535
+ // then removes the rail's handlers and raises the signal again, so the
536
+ // process ends as the signal would have ended it. `proc` is the process those
537
+ // handlers are added to, a test's emitter in tests. Never process.exit: a
538
+ // cancel is an answer, and the caller returns 130 (contract 9).
539
+ export function openRail({ stdin = process.stdin, stdout = process.stdout, caps: c = caps(stdout), proc = process, head = "projectstore", parts = [], escapeMs = ESCAPE_MS } = {}) {
540
+ if (!railAvailable(stdin, c)) throw new Error("no question rail: stdin must be a terminal with setRawMode and stdout a live one (railAvailable)");
541
+ const paint = painter(c);
542
+ const bar = paint("explanation", icon(c, "rail"));
543
+ const sep = ` ${icon(c, "dot")} `;
544
+ const write = (s) => stdout.write(s);
545
+ // The terminal's width now, for the layout and for counting the rows a line
546
+ // fills: a line wider than the terminal fills more than one, and a redraw
547
+ // that miscounted them would leave half a frame behind.
548
+ const columns = () => (Number(stdout.columns) > 0 ? Number(stdout.columns) : c.columns || 100);
549
+ const room = () => Math.max(20, Math.min(columns(), 100) - 1);
550
+ const rowsOf = (lines) => { const w = columns(); return lines.reduce((n, l) => n + Math.max(1, Math.ceil([...plain(l)].length / w)), 0); };
551
+
552
+ // ── The keys ──
553
+ const queue = [];
554
+ let waiter = null;
555
+ const wake = () => { if (waiter) { const w = waiter; waiter = null; w(); } };
556
+ const onKey = (str, key) => { queue.push({ ...(key || {}), str }); wake(); };
557
+ const onEnd = () => { queue.push(EOF); wake(); };
558
+ const next = () => (queue.length ? Promise.resolve(queue.shift()) : new Promise((settle) => { waiter = () => settle(queue.shift()); }));
559
+ emitKeypressEvents(stdin, { escapeCodeTimeout: escapeMs });
560
+ stdin.on("keypress", onKey);
561
+ stdin.on("end", onEnd);
562
+ stdin.on("error", onEnd);
563
+ if (stdin.readableEnded) queue.push(EOF);
564
+ // Listening resumed the stream; it reads only while a question is asked.
565
+ stdin.pause();
566
+
567
+ // ── The restore, on every path ──
568
+ // `live`: the question being asked, or null between questions.
569
+ let closed = false, cancelled = false, live = null;
570
+ const restore = () => {
571
+ try { stdin.setRawMode(false); } catch {}
572
+ try { write(SHOW_CURSOR); } catch {}
573
+ };
574
+ const onExit = () => restore();
575
+ const onSignal = {};
576
+ const release = () => {
577
+ proc.removeListener("exit", onExit);
578
+ for (const [sig, f] of Object.entries(onSignal)) proc.removeListener(sig, f);
579
+ };
580
+ // SIGINT too: Ctrl+C typed during a question is a byte, but `kill -INT`
581
+ // from outside is a signal, and its default would leave the cursor hidden.
582
+ for (const sig of ["SIGTERM", "SIGHUP", "SIGINT"]) {
583
+ onSignal[sig] = () => {
584
+ restore();
585
+ if (live) try { write("\n"); } catch {}
586
+ release();
587
+ try { proc.kill(proc.pid, sig); } catch {}
588
+ };
589
+ }
590
+ proc.once("exit", onExit);
591
+ for (const [sig, f] of Object.entries(onSignal)) proc.on(sig, f);
592
+
593
+ // ── Drawing ──
594
+ // `shown`: the rows of the question on screen, written with no newline
595
+ // after it, so the cursor is on its last row. `last`: the rows of the line
596
+ // answered before it, which a path's Esc takes back.
597
+ let shown = 0, last = 0;
598
+ const erase = () => {
599
+ if (!shown) return;
600
+ write(`\r${shown > 1 ? `\x1b[${shown - 1}A` : ""}\x1b[J`);
601
+ shown = 0;
602
+ };
603
+ const frame = (lines) => { erase(); write(lines.join("\n")); shown = rowsOf(lines); };
604
+ const item = (lines) => { erase(); write(`${lines.join("\n")}\n`); last = rowsOf(lines); };
605
+
606
+ const titled = (title) => wrap(String(title), room() - 2).split("\n").map((t, k) => `${k ? bar : paint("action", icon(c, "ask"))} ${paint("emphasis", t)}`);
607
+ const prose = (text, role = "explanation") => wrap(String(text), room() - 2).split("\n").map((t) => `${bar} ${paint(role, t)}`);
608
+ const warning = (text) => prose(`${icon(c, "warning")} ${text}`, "attention");
609
+ // The key hint: one line, broken only between its keys on a terminal too
610
+ // narrow for it, each separator ending the line it closes.
611
+ const hint = (keys) => {
612
+ const parts = keys.filter(Boolean).map((k, i, all) => (i < all.length - 1 ? `${k}${sep.trimEnd()}` : k));
613
+ const lines = [];
614
+ for (const p of parts) {
615
+ if (lines.length && plain(`${lines.at(-1)} ${p}`).length <= room() - 2) lines[lines.length - 1] += ` ${p}`;
616
+ else lines.push(p);
617
+ }
618
+ return lines.map((t) => `${bar} ${paint("explanation", t)}`);
619
+ };
620
+ const answeredLine = (title, text, glyph = "answered", role = "done", answerRole = "explanation") =>
621
+ [bar, `${paint(role, icon(c, glyph))} ${title}${text ? ` ${paint(answerRole, text)}` : ""}`];
622
+ // Choice rows: the marks (one plain width), the label, and the reason in a
623
+ // column after the widest label that keeps its reason beside it. A row that
624
+ // would pass the width puts its reason beneath the label, wrapped, and is
625
+ // then left out of the column — so one wide label does not push every
626
+ // other reason down. A label is never cut.
627
+ const choiceLines = (marks, list) => {
628
+ const lead = (i) => `${bar} ${marks[i]} `;
629
+ const widest = (xs) => Math.max(0, ...xs.map((x) => plain(x.label).length));
630
+ const fits = (x, i, w) => plain(lead(i)).length + w + 2 + plain(x.reason).length <= room();
631
+ let beside = list.map((x, i) => Boolean(x.reason) && fits(x, i, plain(x.label).length));
632
+ for (;;) {
633
+ const w = widest(list.filter((_, i) => beside[i]));
634
+ const still = beside.map((b, i) => b && fits(list[i], i, w));
635
+ if (still.every((b, i) => b === beside[i])) break;
636
+ beside = still;
637
+ }
638
+ const lw = widest(list.filter((_, i) => beside[i]));
639
+ const out = [];
640
+ list.forEach((x, i) => {
641
+ if (beside[i]) { out.push(`${lead(i)}${x.label}${" ".repeat(lw - plain(x.label).length)} ${paint("explanation", x.reason)}`); return; }
642
+ out.push(lead(i) + x.label);
643
+ if (!x.reason) return;
644
+ const col = plain(lead(i)).length;
645
+ for (const t of wrap(x.reason, room() - col).split("\n")) out.push(`${bar} ${" ".repeat(col - 2)}${paint("explanation", t)}`);
646
+ });
647
+ return out;
648
+ };
649
+
650
+ // One question: raw mode and a hidden cursor while it is asked (a path
651
+ // shows the cursor, at the end of what is typed), its frame redrawn on each
652
+ // key that changes it, collapsed to one answered line when it is answered.
653
+ async function ask(q) {
654
+ if (closed) throw new Error("the question rail is closed");
655
+ if (live) throw new Error("the question rail is already asking");
656
+ if (cancelled) return CANCELLED;
657
+ live = q;
658
+ let settled = false;
659
+ const draw = () => frame(q.draw());
660
+ try {
661
+ stdin.setRawMode(true);
662
+ if (!q.cursor) write(HIDE_CURSOR);
663
+ stdin.resume();
664
+ draw();
665
+ for (;;) {
666
+ const k = await next();
667
+ // close() cancelled this question while it waited: it drew the line.
668
+ if (closed) { settled = true; return CANCELLED; }
669
+ if (k === EOF || (k.ctrl && k.name === "c")) {
670
+ cancelled = true;
671
+ item(answeredLine(q.title, "cancelled", "warning", "attention", "attention"));
672
+ settled = true;
673
+ return CANCELLED;
674
+ }
675
+ const r = await q.key(k);
676
+ if (closed) { settled = true; return CANCELLED; }
677
+ if (r === "redraw") { draw(); continue; }
678
+ if (!r) continue;
679
+ if (r.back) {
680
+ erase();
681
+ if (last) { write(`\x1b[${last}A\x1b[J`); last = 0; }
682
+ settled = true;
683
+ return BACK;
684
+ }
685
+ item(answeredLine(q.title, r.answer));
686
+ settled = true;
687
+ return r.value;
688
+ }
689
+ } finally {
690
+ live = null;
691
+ // A throw left the frame drawn: end its line, so what follows starts on its own.
692
+ if (!settled && shown) { try { write("\n"); } catch {} shown = 0; }
693
+ restore();
694
+ try { stdin.pause(); } catch {}
695
+ }
696
+ }
697
+
698
+ const choose = (title, list, at, note, keys) => {
699
+ if (!list.length) throw new TypeError(`${title}: no choices`);
700
+ return ask({
701
+ title,
702
+ draw: () => [bar, ...titled(title), ...(note ? prose(note) : []),
703
+ ...choiceLines(list.map((_, i) => (i === at ? paint("action", icon(c, "choiceOn")) : paint("explanation", icon(c, "choiceOff")))), list),
704
+ ...hint(keys)],
705
+ key: (k) => {
706
+ if (k.name === "up") { at = (at + list.length - 1) % list.length; return "redraw"; }
707
+ if (k.name === "down") { at = (at + 1) % list.length; return "redraw"; }
708
+ if (isEnter(k)) return { value: list[at].value, answer: list[at].answer };
709
+ return null;
710
+ },
711
+ });
712
+ };
713
+ const defaultAt = (list, value) => Math.max(0, list.findIndex((x) => Object.is(x.value, value)));
714
+
715
+ // The top: `┌` and the badge — `┌ projectstore setup` — in place of the
716
+ // badge line a screen without a rail starts with.
717
+ write(`${paint("explanation", icon(c, "railTop"))} ${badge(c, head, parts)}\n`);
718
+
719
+ return {
720
+ get cancelled() { return cancelled; },
721
+
722
+ select(title, choices, { default: value, note = "" } = {}) {
723
+ const list = choices.map(choiceOf);
724
+ return choose(title, list, defaultAt(list, value), note, ["up/down move", "enter choose", "ctrl+c cancel"]);
725
+ },
726
+
727
+ // The row under the cursor carries the transition mark, so the cursor is
728
+ // seen without colour (contract 3); a ticked row's box is ◼, an empty one ◻.
729
+ multi(title, choices, { note = "", min = 1 } = {}) {
730
+ const list = choices.map(choiceOf);
731
+ if (!list.length) throw new TypeError(`${title}: no choices`);
732
+ // More than the list holds could never be ticked: the question would never close.
733
+ const need = Math.min(Math.max(0, Number(min) || 0), list.length);
734
+ const on = list.map((x) => x.on);
735
+ const pointer = icon(c, "pointer");
736
+ let at = 0, refused = "";
737
+ return ask({
738
+ title,
739
+ draw: () => [bar, ...titled(title), ...(note ? prose(note) : []),
740
+ ...choiceLines(list.map((_, i) => `${i === at ? paint("action", pointer) : " ".repeat(pointer.length)} ${paint(on[i] ? "action" : "explanation", icon(c, on[i] ? "checkOn" : "checkOff"))}`), list),
741
+ ...(refused ? warning(refused) : []),
742
+ ...hint(["up/down move", "space toggle", "enter confirm", "ctrl+c cancel"])],
743
+ key: (k) => {
744
+ if (k.name === "up") { at = (at + list.length - 1) % list.length; return "redraw"; }
745
+ if (k.name === "down") { at = (at + 1) % list.length; return "redraw"; }
746
+ if (k.name === "space") { on[at] = !on[at]; refused = ""; return "redraw"; }
747
+ if (!isEnter(k)) return null;
748
+ const picked = list.filter((_, i) => on[i]);
749
+ if (picked.length < need) { refused = need === 1 ? "tick at least one with space" : `tick at least ${need} with space`; return "redraw"; }
750
+ return { value: picked.map((x) => x.value), answer: picked.length ? picked.map((x) => x.answer).join(", ") : "none" };
751
+ },
752
+ });
753
+ },
754
+
755
+ confirm(title, { default: yes = true, yes: yesLabel = "Yes", no: noLabel = "No", yesReason = "", noReason = "", note = "" } = {}) {
756
+ const list = [choiceOf({ value: true, label: yesLabel, reason: yesReason }), choiceOf({ value: false, label: noLabel, reason: noReason })];
757
+ return choose(title, list, yes ? 0 : 1, note, ["up/down move", "enter choose", "ctrl+c cancel"]);
758
+ },
759
+
760
+ // What is typed is drawn on the last line, the cursor after it. The
761
+ // default has a line of its own, in words — a placeholder told from typed
762
+ // text by its colour alone would not be told apart under NO_COLOR — and
763
+ // Enter with nothing typed takes it. Backspace takes back a character;
764
+ // Esc goes back.
765
+ path(title, { default: fallback = "", check = null, note = "", back = true } = {}) {
766
+ let value = "", refused = "";
767
+ return ask({
768
+ title,
769
+ cursor: true,
770
+ draw: () => [bar, ...titled(title), ...(note ? prose(note) : []), ...(refused ? warning(refused) : []),
771
+ ...(fallback ? prose(`default ${fallback}`) : []),
772
+ ...hint(["enter confirm", back ? "esc back" : null, "ctrl+c cancel"]),
773
+ `${bar} ${value}`],
774
+ key: async (k) => {
775
+ if (k.name === "escape") return back ? { back: true } : null;
776
+ if (isEnter(k)) {
777
+ const chosen = value.trim() || String(fallback);
778
+ if (!chosen) { refused = "type a path"; return "redraw"; }
779
+ const why = check ? await check(chosen) : null;
780
+ if (why) { refused = String(why); return "redraw"; }
781
+ return { value: chosen, answer: chosen };
782
+ }
783
+ if (k.name === "backspace") {
784
+ if (!value) return null;
785
+ value = [...value].slice(0, -1).join("");
786
+ refused = "";
787
+ return "redraw";
788
+ }
789
+ if (!printable(k)) return null;
790
+ value += k.str;
791
+ refused = "";
792
+ return "redraw";
793
+ },
794
+ });
795
+ },
796
+
797
+ // A question a flag answered, or one the run shows rather than asks: drawn
798
+ // as an answered one, so the rail always shows the whole set of answers.
799
+ // After a cancel it draws nothing: the run is stopping, and an answer
800
+ // drawn under the cancelled question would read as one given.
801
+ answered(title, text) {
802
+ if (closed) throw new Error("the question rail is closed");
803
+ if (cancelled) return;
804
+ item(answeredLine(title, text));
805
+ },
806
+
807
+ // `└` (and a closing note beside it), then the terminal is the caller's
808
+ // again: stdin paused and its listeners gone, so the line-mode confirm
809
+ // (askApply → askLine) reads the next line. Keys the decoder already took
810
+ // and no question asked for are dropped with the queue. Keys typed after
811
+ // this, while stdin is paused and the terminal back in line mode, wait in
812
+ // the terminal's own buffer and reach the next askLine: draining them
813
+ // before the confirm is the caller's.
814
+ //
815
+ // Closed while a question is still asked, it cancels that question
816
+ // first: its cancelled line drawn and the terminal restored here, then
817
+ // the question woken, so it resolves to CANCELLED and its `finally` runs.
818
+ close(note = "") {
819
+ if (closed) return;
820
+ closed = true;
821
+ if (live) {
822
+ cancelled = true;
823
+ item(answeredLine(live.title, "cancelled", "warning", "attention", "attention"));
824
+ restore();
825
+ }
826
+ erase();
827
+ write(`${paint("explanation", icon(c, "railEnd"))}${note ? ` ${note}` : ""}\n`);
828
+ stdin.removeListener("keypress", onKey);
829
+ stdin.removeListener("end", onEnd);
830
+ stdin.removeListener("error", onEnd);
831
+ queue.length = 0;
832
+ const woken = waiter;
833
+ waiter = null;
834
+ try { stdin.pause(); } catch {}
835
+ release();
836
+ if (woken) woken();
837
+ },
838
+ };
839
+ }