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