projectstore-claude 0.30.1 → 0.30.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 (30) hide show
  1. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  2. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  3. package/node_modules/projectstore/AGENTS.md +7 -5
  4. package/node_modules/projectstore/README.md +5 -5
  5. package/node_modules/projectstore/agents/clerk.md +2 -2
  6. package/node_modules/projectstore/commands/agents.md +38 -26
  7. package/node_modules/projectstore/commands/bind.md +1 -1
  8. package/node_modules/projectstore/commands/doctor.md +15 -3
  9. package/node_modules/projectstore/commands/reconcile.md +2 -2
  10. package/node_modules/projectstore/commands/review.md +1 -1
  11. package/node_modules/projectstore/commands/story.md +2 -1
  12. package/node_modules/projectstore/docs/getting-started.md +1 -1
  13. package/node_modules/projectstore/docs/harnesses.md +3 -2
  14. package/node_modules/projectstore/harnesses/claude-code.json +92 -2
  15. package/node_modules/projectstore/harnesses/codex.json +81 -14
  16. package/node_modules/projectstore/package.json +1 -1
  17. package/node_modules/projectstore/scripts/agents-change.mjs +64 -10
  18. package/node_modules/projectstore/scripts/build-adapters.mjs +28 -13
  19. package/node_modules/projectstore/scripts/cli.mjs +32 -9
  20. package/node_modules/projectstore/scripts/configure-menu.mjs +716 -0
  21. package/node_modules/projectstore/scripts/configure.mjs +239 -91
  22. package/node_modules/projectstore/scripts/doctor.mjs +95 -31
  23. package/node_modules/projectstore/scripts/harness.mjs +13 -3
  24. package/node_modules/projectstore/scripts/install-harness.mjs +29 -0
  25. package/node_modules/projectstore/scripts/lib.mjs +32 -9
  26. package/node_modules/projectstore/scripts/menu.mjs +746 -0
  27. package/node_modules/projectstore/scripts/models.mjs +649 -0
  28. package/node_modules/projectstore/scripts/term.mjs +167 -0
  29. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +7 -5
  30. package/package.json +2 -2
@@ -0,0 +1,746 @@
1
+ // projectstore — menu.mjs: the settings menu (presentation spec contract 14;
2
+ // the story "configure at a terminal is one settings menu: arrow keys, a line
3
+ // that says what each row means, and model lists read from each harness").
4
+ //
5
+ // createMenu({ groups, onRefresh }) → the state machine: press(key) → what
6
+ // the driver does next; no terminal
7
+ // renderMenu(menu, { caps, columns, rows, head, path }) → the screen's lines
8
+ //
9
+ // Pure: no terminal, no file, no clock. The driver (term.mjs openScreen)
10
+ // feeds it keys and draws its lines; the caller (configure.mjs) describes the
11
+ // groups, settings and values, and decides what a review shows.
12
+ //
13
+ // A group is { title, footer, description, items }. An item is
14
+ // { id, label, kind: "choice" | "text" | "info", toggle, value,
15
+ // display(v), tag, describe(v, changed), options(), caption(),
16
+ // indent, path }
17
+ // where `indent` draws the label that many columns in, under the row it
18
+ // belongs to (an agent's effort under its model), and `path` names the item
19
+ // where its label alone would not: a frame's title and the path line.
20
+ // where options() answers the values, each
21
+ // { value, label, note, description, tag, section, kind: "value" | "text" | "refresh" }
22
+ // and caption() the values frame's footer, { text, busy } or null. A value
23
+ // of null is "none" (cleared); a pending change is kept by item id.
24
+
25
+ import { glyphText, icon, painter, plain } from "./term.mjs";
26
+
27
+ const same = (a, b) => (a ?? null) === (b ?? null);
28
+ const optKey = (o) => (o.kind === "refresh" ? "#refresh" : o.kind === "text" ? "#text" : `v:${o.value ?? ""}`);
29
+
30
+ export function createMenu({ groups, onRefresh = () => {}, onOpen = () => {} }) {
31
+ const m = {
32
+ groups,
33
+ pending: new Map(),
34
+ screen: "menu", // menu | review | done | cancelled
35
+ stack: [{ t: "groups", i: 0 }],
36
+ review: null, // { lines, ok } while the review screen is up
37
+ onRefresh,
38
+ };
39
+ m.top = () => m.stack[m.stack.length - 1];
40
+ m.eff = (it) => (m.pending.has(it.id) ? m.pending.get(it.id) : it.value);
41
+ m.changed = (it) => m.pending.has(it.id);
42
+ m.set = (it, v) => {
43
+ if (same(v, it.value)) m.pending.delete(it.id);
44
+ else m.pending.set(it.id, v ?? null);
45
+ };
46
+ m.itemAt = (f) => (f.t === "items" ? m.groups[f.g].items[f.i] : f.it || null);
47
+ m.list = (f) => {
48
+ if (f.t === "groups") return m.groups.map((g) => g.title);
49
+ if (f.t === "items") return m.groups[f.g].items.map((it) => it.label);
50
+ if (f.t === "values") return f.opts.map((o) => o.label ?? String(o.value ?? ""));
51
+ return [];
52
+ };
53
+ // A values frame's options again, after a background read: the cursor
54
+ // stays on the same value.
55
+ m.rebuild = () => {
56
+ for (const f of m.stack)
57
+ if (f.t === "values") {
58
+ const k = optKey(f.opts[f.i] || {});
59
+ f.opts = f.it.options();
60
+ f.i = Math.max(
61
+ 0,
62
+ f.opts.findIndex((o) => optKey(o) === k),
63
+ );
64
+ }
65
+ };
66
+ const valuesAt = (it) => {
67
+ const opts = it.options();
68
+ const v = m.eff(it);
69
+ return {
70
+ t: "values",
71
+ it,
72
+ opts,
73
+ i: Math.max(
74
+ 0,
75
+ opts.findIndex((o) => o.kind !== "text" && o.kind !== "refresh" && same(o.value, v)),
76
+ ),
77
+ };
78
+ };
79
+ const open = (f) => {
80
+ if (f.t === "groups") {
81
+ m.stack.push({ t: "items", g: f.i, i: 0 });
82
+ return;
83
+ }
84
+ if (f.t === "items") {
85
+ const it = m.itemAt(f);
86
+ if (it.kind === "text") m.stack.push({ t: "text", it, buf: String(m.eff(it) ?? "") });
87
+ else if (it.kind === "info") m.stack.push({ t: "info", it });
88
+ else {
89
+ m.stack.push(valuesAt(it));
90
+ onOpen(it);
91
+ }
92
+ return;
93
+ }
94
+ if (f.t === "values") {
95
+ const o = f.opts[f.i];
96
+ if (o?.kind === "text") m.stack.push({ t: "text", it: f.it, buf: String(m.eff(f.it) ?? "") });
97
+ else if (o?.kind === "refresh") m.onRefresh(f.it);
98
+ }
99
+ };
100
+ const finish = () => (m.pending.size ? "review" : "nothing");
101
+ const jump = (f, ch) => {
102
+ const L = m.list(f);
103
+ for (let d = 1; d <= L.length; d++) {
104
+ const j = (f.i + d) % L.length;
105
+ if (String(L[j]).toLowerCase().startsWith(ch)) {
106
+ f.i = j;
107
+ return;
108
+ }
109
+ }
110
+ };
111
+
112
+ // One key, as the driver names it: up, down, left, right, space, enter,
113
+ // escape, backspace, cancel, or { char }. Returns what the driver does:
114
+ // "redraw", "review" (ask the caller for the review screen), "write",
115
+ // "nothing" (leave: no change), "cancel", or null (ignored).
116
+ m.press = (k) => {
117
+ if (k === "cancel") {
118
+ m.screen = "cancelled";
119
+ return "cancel";
120
+ }
121
+ if (m.screen === "review") {
122
+ if (k === "enter" && m.review?.ok) return "write";
123
+ // A PLAN longer than the screen scrolls; renderMenu keeps the offset
124
+ // inside what is there.
125
+ if ((k === "up" || k === "down") && m.review?.long) {
126
+ m.review.top = Math.max(0, (m.review.top || 0) + (k === "up" ? -1 : 1));
127
+ return "redraw";
128
+ }
129
+ if (k === "left" || k === "escape") {
130
+ m.screen = "menu";
131
+ m.review = null;
132
+ return "redraw";
133
+ }
134
+ return null;
135
+ }
136
+ const f = m.top();
137
+ const ch = typeof k === "object" && k ? k.char : null;
138
+ if (f.t === "text") {
139
+ if (k === "enter") {
140
+ // Nothing typed clears a field that may be empty, and changes nothing
141
+ // in one that may not.
142
+ if (f.buf.trim() || f.it.allowEmpty) m.set(f.it, f.buf.trim() || null);
143
+ m.stack.pop();
144
+ if (m.top().t === "values") m.stack.pop();
145
+ return "redraw";
146
+ }
147
+ if (k === "escape" || k === "left") {
148
+ m.stack.pop();
149
+ return "redraw";
150
+ }
151
+ if (k === "backspace") {
152
+ f.buf = [...f.buf].slice(0, -1).join("");
153
+ return "redraw";
154
+ }
155
+ if (k === "space") {
156
+ f.buf += " ";
157
+ return "redraw";
158
+ }
159
+ if (ch) {
160
+ f.buf += ch;
161
+ return "redraw";
162
+ }
163
+ return null;
164
+ }
165
+ if (f.t === "info") {
166
+ if (k === "left" || k === "escape") {
167
+ m.stack.pop();
168
+ return "redraw";
169
+ }
170
+ if (k === "enter") return finish();
171
+ return null;
172
+ }
173
+ const n = m.list(f).length;
174
+ if (k === "up") {
175
+ if (f.i === 0) return null;
176
+ f.i -= 1;
177
+ return "redraw";
178
+ }
179
+ if (k === "down") {
180
+ if (f.i >= n - 1) return null;
181
+ f.i += 1;
182
+ return "redraw";
183
+ }
184
+ if (k === "right") {
185
+ open(f);
186
+ return "redraw";
187
+ }
188
+ if (k === "space") {
189
+ if (f.t === "values") {
190
+ const o = f.opts[f.i];
191
+ if (o.kind === "text" || o.kind === "refresh") open(f);
192
+ else {
193
+ m.set(f.it, o.value);
194
+ m.stack.pop();
195
+ }
196
+ return "redraw";
197
+ }
198
+ const it = m.itemAt(f);
199
+ if (it && it.toggle) {
200
+ const opts = it.options().filter((o) => o.kind === "value");
201
+ const at = opts.findIndex((o) => same(o.value, m.eff(it)));
202
+ m.set(it, opts[(at + 1) % opts.length].value);
203
+ } else open(f);
204
+ return "redraw";
205
+ }
206
+ if (k === "left" || k === "escape") {
207
+ if (m.stack.length > 1) {
208
+ m.stack.pop();
209
+ return "redraw";
210
+ }
211
+ return finish();
212
+ }
213
+ if (k === "enter") {
214
+ if (f.t === "values") {
215
+ const o = f.opts[f.i];
216
+ if (o.kind === "value") m.set(f.it, o.value);
217
+ }
218
+ return finish();
219
+ }
220
+ if (ch) {
221
+ jump(f, ch.toLowerCase());
222
+ return "redraw";
223
+ }
224
+ return null;
225
+ };
226
+
227
+ // The changes waiting, in the groups' order: { item, before, after }.
228
+ m.changes = () => {
229
+ const out = [];
230
+ for (const g of m.groups)
231
+ for (const it of g.items) if (m.changed(it)) out.push({ item: it, before: it.value, after: m.eff(it) });
232
+ return out;
233
+ };
234
+ return m;
235
+ }
236
+
237
+ // ─── Drawing ───────────────────────────────────────────────────────────
238
+ //
239
+ // A line is built from segments { t, r } — text and a colour role — so its
240
+ // width is measured before it is painted.
241
+
242
+ const S = (t, r = "") => ({ t: String(t), r });
243
+ export const seg = S;
244
+ const wlen = (s) => [...String(s)].length;
245
+ const segLen = (segs) => segs.reduce((n, s) => n + wlen(s.t), 0);
246
+ const padTo = (s, n) => (wlen(s) >= n ? s : s + " ".repeat(n - wlen(s)));
247
+
248
+ // A text in the glyph switch's column (contract 2): under ASCII glyphs every
249
+ // glyph of the table in it reads as its ASCII form, measured after.
250
+ const G = (c, t) => glyphText(c, String(t));
251
+
252
+ // Segments cut or padded to exactly `w` columns; a cut ends with the
253
+ // table's running glyph.
254
+ function fit(c, segs, w) {
255
+ const mark = icon(c, "running");
256
+ const out = [];
257
+ let n = 0;
258
+ for (const s0 of segs) {
259
+ const s = { t: G(c, s0.t), r: s0.r };
260
+ const cs = [...s.t];
261
+ if (n + cs.length <= w) {
262
+ out.push(s);
263
+ n += cs.length;
264
+ continue;
265
+ }
266
+ const room = w - n;
267
+ if (room > 0) out.push(S(cs.slice(0, Math.max(0, room - wlen(mark))).join("") + mark.slice(0, room), s.r));
268
+ n = w;
269
+ break;
270
+ }
271
+ if (n < w) out.push(S(" ".repeat(w - n)));
272
+ return out;
273
+ }
274
+
275
+ function wrapText(text, w) {
276
+ const out = [];
277
+ let line = "";
278
+ for (const word of String(text).split(" ")) {
279
+ if (!line) line = word;
280
+ else if (wlen(line) + 1 + wlen(word) <= w) line += ` ${word}`;
281
+ else {
282
+ out.push(line);
283
+ line = word;
284
+ }
285
+ }
286
+ if (line) out.push(line);
287
+ return out;
288
+ }
289
+
290
+ // A frame: a box of `w` columns around rows already laid out, its title in
291
+ // the top border and its footer in the bottom one. Taller than `h`, it shows
292
+ // a window that keeps row `cur` in view, and says how many rows it hides.
293
+ function frame(c, { title, rows, w, h, cur = 0, focus, footer, footerBusy }) {
294
+ const b = focus ? "action" : "explanation";
295
+ const inner = w - 4;
296
+ const dash = icon(c, "rule");
297
+ let top = 0;
298
+ let shown = rows;
299
+ const room = Math.max(1, h - 2);
300
+ if (rows.length > room) {
301
+ top = Math.min(Math.max(0, cur - Math.floor(room / 2)), rows.length - room);
302
+ shown = rows.slice(top, top + room);
303
+ }
304
+ const above = top,
305
+ below = rows.length - top - shown.length;
306
+ // A border: its label in `role`, then `extra` ("↑ N more") in the
307
+ // explanation role, cut together to the border's room.
308
+ const edge = (l, r, label, role, extra) => {
309
+ const max = w - 6;
310
+ const sep = extra && label ? ` ${icon(c, "dot")} ` : "";
311
+ let a = G(c, label || "");
312
+ const e = G(c, extra || "");
313
+ if (wlen(a) + wlen(sep) + wlen(e) > max) {
314
+ const room = Math.max(0, max - wlen(sep) - wlen(e));
315
+ a = wlen(a) > room ? [...a].slice(0, Math.max(0, room - 1)).join("") + icon(c, "running").slice(0, 1) : a;
316
+ }
317
+ const t = a + sep + e;
318
+ if (!t) return [S(l + dash.repeat(w - 2) + r, b)];
319
+ return [
320
+ S(`${l}${dash} `, b),
321
+ S(a, role),
322
+ S(sep + e, "explanation"),
323
+ S(` ${dash.repeat(Math.max(0, w - 5 - wlen(t)))}${r}`, b),
324
+ ];
325
+ };
326
+ const more = (glyph, n) => (n ? `${icon(c, glyph)} ${n} more` : "");
327
+ const lines = [
328
+ edge(
329
+ icon(c, "frameTopLeft"),
330
+ icon(c, "frameTopRight"),
331
+ title,
332
+ focus ? "emphasis" : "explanation",
333
+ more("moreAbove", above),
334
+ ),
335
+ ];
336
+ for (const r of shown) lines.push([S(`${icon(c, "rail")} `, b), ...fit(c, r, inner), S(` ${icon(c, "rail")}`, b)]);
337
+ lines.push(
338
+ edge(
339
+ icon(c, "frameBottomLeft"),
340
+ icon(c, "frameBottomRight"),
341
+ footer,
342
+ footerBusy ? "action" : "explanation",
343
+ more("moreBelow", below),
344
+ ),
345
+ );
346
+ return { w, lines };
347
+ }
348
+
349
+ // Frames side by side: each { b, x, y } placed in an area `height` rows tall.
350
+ function compose(blocks, height) {
351
+ const out = [];
352
+ for (let r = 0; r < height; r++) {
353
+ const line = [];
354
+ let cur = 0;
355
+ for (const o of blocks) {
356
+ const k = r - o.y;
357
+ if (k < 0 || k >= o.b.lines.length) continue;
358
+ if (o.x > cur) line.push(S(" ".repeat(o.x - cur)));
359
+ line.push(...o.b.lines[k]);
360
+ cur = o.x + o.b.w;
361
+ }
362
+ out.push(line);
363
+ }
364
+ return out;
365
+ }
366
+
367
+ const show = (v) => (v === null || v === undefined || v === "" ? "—" : String(v));
368
+
369
+ // The row the keys act on: `›` and emphasis. A level above it: the row it
370
+ // opened from in emphasis, the rest explanation. A preview: all explanation.
371
+ const labelRole = (isCur, mode) =>
372
+ mode === "focus"
373
+ ? isCur
374
+ ? "emphasis"
375
+ : ""
376
+ : mode === "parent"
377
+ ? isCur
378
+ ? "emphasis"
379
+ : "explanation"
380
+ : "explanation";
381
+ const cursorSeg = (c, isCur, mode) =>
382
+ S(isCur && mode !== "preview" ? icon(c, "cursor") : " ", mode === "focus" ? "action" : "explanation");
383
+
384
+ function valueSegs(c, m, it, mode, room) {
385
+ const base = mode === "focus" && it.kind !== "info" ? "" : "explanation";
386
+ const display = (v) => G(c, it.display ? it.display(v) : show(v));
387
+ const old = display(it.value);
388
+ if (!m.changed(it)) return [S(old, base)];
389
+ const now = display(m.eff(it));
390
+ const strong = mode === "preview" ? "explanation" : "emphasis";
391
+ const arrow = ` ${icon(c, "transition")} `;
392
+ if (wlen(old) + wlen(arrow) + wlen(now) > room) return [S(now, strong)];
393
+ return [S(old, "explanation"), S(arrow, "explanation"), S(now, strong)];
394
+ }
395
+
396
+ function itemRows(c, m, g, cur, mode, w) {
397
+ const lw = Math.max(...g.items.map((it) => (it.indent || 0) + wlen(it.label))) + 1;
398
+ const room = w - 4 - 3 - lw - 1;
399
+ return g.items.map((it, k) => [
400
+ cursorSeg(c, k === cur, mode),
401
+ S(m.changed(it) ? icon(c, "update") : " ", "action"),
402
+ S(" "),
403
+ S(padTo(" ".repeat(it.indent || 0) + G(c, it.label), lw), labelRole(k === cur, mode)),
404
+ S(" "),
405
+ ...valueSegs(c, m, it, mode, room),
406
+ ]);
407
+ }
408
+
409
+ function groupRows(c, m, cur, mode) {
410
+ return m.groups.map((g, k) => [
411
+ cursorSeg(c, k === cur, mode),
412
+ S(g.items.some((it) => m.changed(it)) ? icon(c, "update") : " ", "action"),
413
+ S(" "),
414
+ S(g.title, labelRole(k === cur, mode)),
415
+ ]);
416
+ }
417
+
418
+ function optionRows(c, m, it, opts, cur, mode, inner) {
419
+ const wide = inner >= 40;
420
+ const dash = icon(c, "rule");
421
+ const lw = Math.min(22, Math.max(0, ...opts.map((o) => wlen(G(c, o.label ?? show(o.value))))));
422
+ const rows = [];
423
+ const at = [];
424
+ opts.forEach((o, k) => {
425
+ if (o.section !== undefined) {
426
+ const name = G(c, o.section);
427
+ rows.push([
428
+ S(
429
+ name ? `${dash.repeat(2)} ${name} ${dash.repeat(Math.max(2, inner - 4 - wlen(name)))}` : dash.repeat(inner),
430
+ "explanation",
431
+ ),
432
+ ]);
433
+ }
434
+ const isCur = k === cur;
435
+ const on = o.kind === "value" && same(o.value, m.eff(it));
436
+ const glyph =
437
+ o.kind === "text"
438
+ ? icon(c, "add")
439
+ : o.kind === "refresh"
440
+ ? icon(c, "update")
441
+ : icon(c, on ? "choiceOn" : "choiceOff");
442
+ const gRole =
443
+ o.kind === "refresh" ? (o.busy ? "action" : "explanation") : on && mode !== "preview" ? "action" : "explanation";
444
+ const name = G(c, o.label ?? show(o.value));
445
+ at.push(rows.length);
446
+ const row = [
447
+ cursorSeg(c, isCur, mode),
448
+ S(" "),
449
+ S(glyph, gRole),
450
+ S(" "),
451
+ S(wide ? padTo(name, lw) : name, o.kind === "refresh" && o.busy ? "action" : labelRole(isCur, mode)),
452
+ ];
453
+ if (wide && o.note) row.push(S(` ${G(c, o.note)}`, "explanation"));
454
+ const tag = o.tag || (o.kind === "value" && m.changed(it) && same(o.value, it.value) ? "current" : "");
455
+ if (tag && wide) {
456
+ const room = inner - segLen(row);
457
+ if (room >= wlen(tag) + 2)
458
+ row.push(S(" ".repeat(room - wlen(tag)) + tag, tag === "new" ? "done" : "explanation"));
459
+ }
460
+ rows.push(row);
461
+ });
462
+ return { rows, cursorRow: cur >= 0 ? at[cur] : 0 };
463
+ }
464
+
465
+ function detailFrame(c, m, it, fr, w, h, focus) {
466
+ const inner = w - 4;
467
+ const mode = focus ? "focus" : "preview";
468
+ let rows,
469
+ cur = 0;
470
+ if (fr && fr.t === "text") {
471
+ let buf = G(c, fr.buf);
472
+ const caret = c.ascii ? "_" : "█";
473
+ if (wlen(buf) > inner - 3) buf = icon(c, "running").slice(0, 1) + [...buf].slice(-(inner - 4)).join("");
474
+ rows = [
475
+ [S(`${icon(c, "cursor")} `, "action"), S(buf), S(caret, "action")],
476
+ [],
477
+ [S("enter takes it · esc goes back", "explanation")],
478
+ ];
479
+ } else if (it.kind === "text") rows = [[S(" "), S(show(m.eff(it)), focus ? "" : "explanation")]];
480
+ else if (it.kind === "info")
481
+ rows = [
482
+ [S(show(it.value))],
483
+ [],
484
+ [S("Changed by another command,", "explanation")],
485
+ [S("named below.", "explanation")],
486
+ ];
487
+ else {
488
+ const vf = fr && fr.t === "values" ? fr : null;
489
+ const r = optionRows(c, m, it, vf ? vf.opts : it.options(), vf ? vf.i : -1, mode, inner);
490
+ rows = r.rows;
491
+ cur = r.cursorRow;
492
+ }
493
+ const cap = focus && it.caption ? it.caption() : null;
494
+ return frame(c, {
495
+ title: it.path || it.label,
496
+ rows,
497
+ w,
498
+ h,
499
+ cur,
500
+ focus,
501
+ footer: cap?.text || null,
502
+ footerBusy: Boolean(cap?.busy),
503
+ });
504
+ }
505
+
506
+ // What the cursor is on, for the panel under the frames: { title, tag, text }.
507
+ function about(m) {
508
+ const f = m.top();
509
+ if (f.t === "groups") {
510
+ const g = m.groups[f.i];
511
+ return { title: g.title, tag: "", text: g.description || "" };
512
+ }
513
+ if (f.t === "items") {
514
+ const it = m.itemAt(f);
515
+ return {
516
+ title: it.title || it.label,
517
+ tag: m.changed(it) ? "changed, not written yet" : it.tag || "",
518
+ tagRole: m.changed(it) ? "action" : "explanation",
519
+ text: it.describe ? it.describe(m.eff(it), m.changed(it)) : "",
520
+ };
521
+ }
522
+ if (f.t === "values") {
523
+ const o = f.opts[f.i];
524
+ const it = f.it;
525
+ const tag = o.tag || (o.kind === "value" && same(o.value, it.value) ? "in the file now" : "");
526
+ return {
527
+ title: o.label ?? show(o.value),
528
+ tag,
529
+ tagRole: o.tag === "new" ? "done" : "explanation",
530
+ text: o.description || "",
531
+ };
532
+ }
533
+ if (f.t === "text") return { title: f.it.title || f.it.label, tag: "typing", text: f.it.textHint || "" };
534
+ return {
535
+ title: f.it.title || f.it.label,
536
+ tag: "set by another command",
537
+ text: f.it.describe ? f.it.describe(f.it.value, false) : "",
538
+ };
539
+ }
540
+
541
+ function hintParts(m) {
542
+ if (m.screen === "review")
543
+ return [
544
+ ...(m.review?.ok ? ["enter writes it"] : []),
545
+ ...(m.review?.long ? ["↑↓ scroll"] : []),
546
+ "← back to the menu",
547
+ "ctrl+c cancel",
548
+ ];
549
+ const f = m.top();
550
+ if (f.t === "text") return ["type", "enter takes it", "esc back"];
551
+ if (f.t === "info") return ["← back", "enter review", "ctrl+c cancel"];
552
+ if (f.t === "values") return ["↑↓ move", "space choose", "← back", "enter review", "ctrl+c cancel"];
553
+ return [
554
+ "↑↓ move",
555
+ "→ open",
556
+ "space toggle",
557
+ m.stack.length === 1 ? "← leave" : "← back",
558
+ "enter review",
559
+ "ctrl+c cancel",
560
+ ];
561
+ }
562
+
563
+ // The hint's arrows in the glyph switch's column: ↑↓ ^v, → ->, ← <-.
564
+ const keyText = (c, s) => (c.ascii ? s.replace("↑↓", "up/down").replace("→", "right").replace("←", "left") : s);
565
+
566
+ function joinWrap(c, parts, w) {
567
+ const sep = ` ${icon(c, "dot")} `;
568
+ const out = [];
569
+ let line = "";
570
+ for (const p of parts) {
571
+ const add = line ? line + sep + p : p;
572
+ if (wlen(add) <= w) line = add;
573
+ else {
574
+ out.push(line);
575
+ line = p;
576
+ }
577
+ }
578
+ if (line) out.push(line);
579
+ return out;
580
+ }
581
+
582
+ // The staircase's columns (contract 14): groups, settings and a preview at
583
+ // the first two levels; settings and values deeper. 99 columns with the
584
+ // rail's margin, one short of a 100-column terminal.
585
+ export const STAIRCASE = Object.freeze({ min: 100, groups: 27, items: 40, preview: 28, values: 56 });
586
+ export const SMALLEST = Object.freeze({ columns: 40, rows: 16 });
587
+
588
+ // The screen: an array of painted lines, none wider than `columns` - 1.
589
+ export function renderMenu(m, { caps: c, columns = 100, rows = 30, head = "projectstore configure", path = "" }) {
590
+ const paint = painter(c);
591
+ const W = Math.min(columns, 100) - 1;
592
+ const bar = icon(c, "rail");
593
+ const lines = [];
594
+ const out = (segs) => lines.push(fit(c, segs, W));
595
+ out([S(`${icon(c, "railTop")} `, "explanation"), S(head, "emphasis"), S(path ? ` ${path}` : "", "explanation")]);
596
+
597
+ // Whether the review scrolls decides its hint: measured against the room
598
+ // a two-line hint leaves, before the hint is laid out.
599
+ if (m.screen === "review" && m.review)
600
+ m.review.long = (m.review.lines || []).length > Math.max(3, rows - 1 - 1 - 1 - 1 - 3 - 1 - 2 - 1 - 1) + 4;
601
+ const hints = joinWrap(
602
+ c,
603
+ hintParts(m).map((h) => keyText(c, h)),
604
+ W - 2,
605
+ );
606
+ // The rows around the frames: the title, a blank, the panel's title and
607
+ // three lines, the count, the hints and the end.
608
+ const area = Math.max(3, rows - 1 - 1 - 1 - 1 - 3 - 1 - hints.length - 1 - 1);
609
+
610
+ if (m.screen === "review") {
611
+ out([S(`${icon(c, "ask")} `, "action"), S(m.review?.title || "Plan", "emphasis")]);
612
+ // The PLAN's rows in a window: a PLAN longer than the room says how many
613
+ // rows it hides above and below, and ↑/↓ move the window, so the Enter
614
+ // that writes never confirms a row that could not be seen.
615
+ const all = m.review?.lines || [];
616
+ const room = area + 4;
617
+ const fits = all.length <= room;
618
+ const keep = fits ? all.length : room - 2;
619
+ const top = fits ? 0 : Math.min(Math.max(0, m.review.top || 0), all.length - keep);
620
+ if (m.review) Object.assign(m.review, { top, long: !fits });
621
+ const hidden = (glyph, n) => [S(`${bar} `, "explanation"), S(` ${icon(c, glyph)} ${n} more`, "explanation")];
622
+ if (!fits) out(top ? hidden("moreAbove", top) : [S(bar, "explanation")]);
623
+ for (const l of all.slice(top, top + keep)) lines.push(fit(c, [S(`${bar} `, "explanation"), ...l], W));
624
+ if (!fits) {
625
+ const below = all.length - top - keep;
626
+ out(below ? hidden("moreBelow", below) : [S(bar, "explanation")]);
627
+ }
628
+ out([S(bar, "explanation")]);
629
+ for (const h of hints) out([S(`${bar} `, "explanation"), S(h, "explanation")]);
630
+ out([S(icon(c, "railEnd"), "explanation")]);
631
+ return lines.map((l) =>
632
+ l
633
+ .map((s) => paint(s.r || null, s.t))
634
+ .join("")
635
+ .trimEnd(),
636
+ );
637
+ }
638
+
639
+ const st = m.stack;
640
+ const level = st.length - 1;
641
+ const gi = st[0].i;
642
+ const g = m.groups[gi];
643
+ const ki = level >= 1 ? st[1].i : -1;
644
+ const it = ki >= 0 ? g.items[ki] : null;
645
+ const sep = ` ${icon(c, "pathSeparator")} `;
646
+ const blocks = [];
647
+ const clampY = (y, h) => Math.max(0, Math.min(y, area - h));
648
+ const wideEnough = columns >= STAIRCASE.min;
649
+ if (!wideEnough) {
650
+ // One frame: the one the keys act on, under the whole path.
651
+ const names = ["Settings", ...(level >= 1 ? [g.title] : []), ...(level >= 2 ? [it.path || it.label] : [])];
652
+ out([
653
+ S(`${icon(c, "ask")} `, "action"),
654
+ S(names.slice(0, -1).join(sep) + (names.length > 1 ? sep : ""), "explanation"),
655
+ S(names.at(-1), "emphasis"),
656
+ ]);
657
+ const w = W - 2;
658
+ let b;
659
+ if (level === 0)
660
+ b = frame(c, { title: "Settings", w, h: area, cur: gi, focus: true, rows: groupRows(c, m, gi, "focus") });
661
+ else if (level === 1)
662
+ b = frame(c, {
663
+ title: g.title,
664
+ w,
665
+ h: area,
666
+ cur: ki,
667
+ focus: true,
668
+ footer: g.footer,
669
+ rows: itemRows(c, m, g, ki, "focus", w),
670
+ });
671
+ else b = detailFrame(c, m, it, st[3] || st[2], w, area, true);
672
+ blocks.push({ b, x: 0, y: 0 });
673
+ } else if (level < 2) {
674
+ out([S(`${icon(c, "ask")} `, "action"), S("What do you want to change?", "emphasis")]);
675
+ const m1 = level === 0 ? "focus" : "parent";
676
+ const m2 = level === 0 ? "preview" : "focus";
677
+ const c1 = frame(c, {
678
+ title: "Settings",
679
+ w: STAIRCASE.groups,
680
+ h: area,
681
+ cur: gi,
682
+ focus: level === 0,
683
+ rows: groupRows(c, m, gi, m1),
684
+ });
685
+ blocks.push({ b: c1, x: 0, y: 0 });
686
+ const c2 = frame(c, {
687
+ title: g.title,
688
+ w: STAIRCASE.items,
689
+ h: area,
690
+ cur: Math.max(0, ki),
691
+ focus: level === 1,
692
+ footer: g.footer,
693
+ rows: itemRows(c, m, g, ki, m2, STAIRCASE.items),
694
+ });
695
+ // Its top border level with the group row it opened from.
696
+ const y2 = clampY(1 + gi, c2.lines.length);
697
+ blocks.push({ b: c2, x: STAIRCASE.groups + 1, y: y2 });
698
+ if (level === 1) {
699
+ const c3 = detailFrame(c, m, it, null, STAIRCASE.preview, area, false);
700
+ blocks.push({ b: c3, x: STAIRCASE.groups + STAIRCASE.items + 2, y: clampY(y2 + 1 + ki, c3.lines.length) });
701
+ }
702
+ } else {
703
+ out([S(`${icon(c, "ask")} `, "action"), S(`Settings${sep}`, "explanation"), S(g.title, "emphasis")]);
704
+ const c2 = frame(c, {
705
+ title: g.title,
706
+ w: STAIRCASE.items,
707
+ h: area,
708
+ cur: ki,
709
+ focus: false,
710
+ footer: g.footer,
711
+ rows: itemRows(c, m, g, ki, "parent", STAIRCASE.items),
712
+ });
713
+ blocks.push({ b: c2, x: 0, y: 0 });
714
+ const c3 = detailFrame(c, m, it, st[3] || st[2], STAIRCASE.values, area, true);
715
+ blocks.push({ b: c3, x: STAIRCASE.items + 1, y: clampY(1 + ki, c3.lines.length) });
716
+ }
717
+ for (const l of compose(blocks, area)) out([S(`${bar} `, "explanation"), ...l]);
718
+
719
+ const a = about(m);
720
+ out([S(bar, "explanation")]);
721
+ out([S(`${bar} `, "explanation"), S(a.title, "emphasis"), S(a.tag ? ` ${a.tag}` : "", a.tagRole || "explanation")]);
722
+ const text = wrapText(G(c, a.text), W - 2).slice(0, 3);
723
+ while (text.length < 3) text.push("");
724
+ for (const t of text) out([S(`${bar} `, "explanation"), S(t)]);
725
+ const n = m.pending.size;
726
+ out([
727
+ S(`${bar} `, "explanation"),
728
+ ...(n
729
+ ? [
730
+ S(`${icon(c, "update")} ${n} ${n === 1 ? "change" : "changes"} waiting`, "action"),
731
+ S(` ${icon(c, "dot")} enter reviews`, "explanation"),
732
+ ]
733
+ : [S("nothing changed yet", "explanation")]),
734
+ ]);
735
+ for (const h of hints) out([S(`${bar} `, "explanation"), S(h, "explanation")]);
736
+ out([S(icon(c, "railEnd"), "explanation")]);
737
+ return lines.map((l) =>
738
+ l
739
+ .map((s) => paint(s.r || null, s.t))
740
+ .join("")
741
+ .trimEnd(),
742
+ );
743
+ }
744
+
745
+ // For tests and the driver: a painted line's width.
746
+ export const width = (line) => wlen(plain(line));