projectstore-claude 0.30.0 → 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.
@@ -0,0 +1,589 @@
1
+ // projectstore — configure-menu.mjs: configure's settings menu at a terminal
2
+ // (the story "configure at a terminal is one settings menu: arrow keys, a
3
+ // line that says what each row means, and model lists read from each
4
+ // harness"; the configure spec's contracts 2, 4 and 8 and the presentation
5
+ // spec's contract 14, as amended 2026-10-13).
6
+ //
7
+ // menuGroups(f, settings, catalogs) → the menu's groups: every shown
8
+ // setting, its values and what each means
9
+ // pendingSets(changes) → the menu's changes as --set strings
10
+ // runMenu(ctx) → { cancelled } | { nothing } | { sets }
11
+ //
12
+ // The menu decides nothing a --set run does not: its changes become --set
13
+ // strings, which configure resolves, plans and applies through the same
14
+ // writers. Every text a row shows is here, except a model's description,
15
+ // which comes from its harness's list (scripts/models.mjs).
16
+
17
+ import * as term from "./term.mjs";
18
+ import { createMenu, renderMenu, seg, SMALLEST } from "./menu.mjs";
19
+ import { knownModels, readLive } from "./models.mjs";
20
+ import { loadHarness } from "./harness.mjs";
21
+ import { readOverlayAt } from "./lib.mjs";
22
+
23
+ const CLEAR = "-";
24
+
25
+ // What each agent of the roster does: the settings panel's first sentence.
26
+ const ROLE = Object.freeze({
27
+ default: "The model for every agent that has none of its own.",
28
+ critic: "Reviews ADRs, specs, stories and designs before they are treated as final.",
29
+ planner: "Plans where an epic's or a story's code goes, before it is written.",
30
+ reviewer: "Checks that a diff closes its story's acceptance criteria, before a commit.",
31
+ librarian: "Looks for duplicates, contradictions and missing links across the vault.",
32
+ archaeologist: "Finds decisions an existing codebase made but never wrote down.",
33
+ clerk: "Copies an approved artifact into the vault and runs the write ceremony.",
34
+ });
35
+
36
+ // The bundled template languages' names; a code without one reads as itself.
37
+ const LANGUAGE = Object.freeze({
38
+ de: "German",
39
+ en: "English",
40
+ es: "Spanish",
41
+ fr: "French",
42
+ ru: "Russian",
43
+ zh: "Simplified Chinese",
44
+ });
45
+
46
+ // What a setting does, and what each of its values means.
47
+ const KEY = Object.freeze({
48
+ statusline: {
49
+ text: (h) =>
50
+ `projectstore's line under the prompt. Turning it on or off changes ${h}'s settings, so the change shows after a restart.`,
51
+ values: {
52
+ on: (h) => `Wires the line into ${h}'s settings. It shows after a restart.`,
53
+ off: (h) => `Removes the line from ${h}'s settings. It goes after a restart.`,
54
+ },
55
+ },
56
+ band: {
57
+ text: () => "The band above the prompt: this session's epic, how far it is, its story and the agent at work.",
58
+ values: {
59
+ on: () => "The band shows above the prompt within a few seconds.",
60
+ off: () => "The band goes, and the status line takes its place again within 30 seconds.",
61
+ },
62
+ },
63
+ "band.epic": { text: () => "The epic the band shows in a session that has no epic of its own." },
64
+ vault: {
65
+ text: () =>
66
+ "The vault this project is bound to. Another command changes it: npx projectstore bind <vault> --rebind.",
67
+ },
68
+ layout: {
69
+ text: () =>
70
+ "The vault's folder layout. It comes with the vault, so a new layout means a new vault: npx projectstore init <vault> --rebind.",
71
+ },
72
+ language: {
73
+ text: () =>
74
+ "The language of new artifacts, the derived views, the session header and the status line. Existing artifacts stay as they are.",
75
+ },
76
+ active_skills: {
77
+ text: () =>
78
+ "Skills that notice a decision or a finished story in the conversation and suggest the command. They never write.",
79
+ values: {
80
+ on: () => "The skills suggest a command when the conversation records a decision or finishes a story.",
81
+ off: () => "The skills stay quiet. The commands still work when you run them.",
82
+ },
83
+ },
84
+ auto_inject: {
85
+ text: () => "At session start, what is in flight and where the last session left off go into the agent's context.",
86
+ values: {
87
+ on: () => "Each session starts knowing what is in flight and where the last one left off.",
88
+ off: () => "Sessions start without it. The status command still shows what is in flight.",
89
+ },
90
+ },
91
+ default_author: { text: () => "The author written into new artifacts." },
92
+ tags: { text: () => "Tags every new artifact starts with, as a comma list: a, b." },
93
+ spec_policy: {
94
+ text: () => "Whether a story needs an active spec before implementation.",
95
+ values: {
96
+ required: () =>
97
+ "A story needs an active spec before implementation, and doctor checks it. Stories closed before now are exempt.",
98
+ optional: () => "Specs where they help. doctor does not require them.",
99
+ },
100
+ },
101
+ lifecycle_gates: {
102
+ text: () => "Whether doctor checks a story's plan and close sections.",
103
+ values: {
104
+ on: () => "doctor checks a story's plan when it starts, and its final summary and evidence when it closes.",
105
+ off: () => "The plan and close sections are still written; doctor does not check them.",
106
+ },
107
+ },
108
+ });
109
+
110
+ // Where a value comes from, as the panel's tag says it.
111
+ const SOURCE = Object.freeze({
112
+ per_agent: "set per agent",
113
+ overlay: "the overlay's default",
114
+ default: "not set",
115
+ frontmatter: "from its frontmatter",
116
+ binding: "from the binding",
117
+ vault_config: "from the vault's config",
118
+ unparseable: "not valid JSON: fix the file to change it",
119
+ });
120
+
121
+ const show = (v) =>
122
+ v === null || v === undefined || v === "" ? "—" : Array.isArray(v) ? v.join(", ") || "—" : String(v);
123
+
124
+ // A list's age, as a caption says it: today 13:50, yesterday, 3 days ago.
125
+ export function checkedWhen(at, now) {
126
+ const d = new Date(at),
127
+ n = new Date(now);
128
+ const day = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
129
+ const days = Math.round((day(n) - day(d)) / 86400000);
130
+ if (days <= 0) return `today ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
131
+ return days === 1 ? "yesterday" : `${days} days ago`;
132
+ }
133
+
134
+ // The menu's groups (contract 14), from the setting set. `catalogs` holds,
135
+ // per harness id, what its lists know and whether a read is out:
136
+ // { known, busy, error }; `menu()` answers the menu, for values that depend
137
+ // on another row's pending change (an agent's default).
138
+ export function menuGroups(f, settings, catalogs, { menu, now = Date.now, caps: c = term.PLAIN } = {}) {
139
+ const groups = [];
140
+ const byGroup = new Map();
141
+ for (const s of settings) {
142
+ if (!byGroup.has(s.group)) byGroup.set(s.group, []);
143
+ byGroup.get(s.group).push(s);
144
+ }
145
+ // The binding's file, as the settings name it: its basename is the footer.
146
+ const bindingFile = settings.find((s) => s.writer === "binding")?.file || "";
147
+ const bind = bindingFile.split("/").pop();
148
+ for (const [g, list] of byGroup) {
149
+ if (g.startsWith("agents:")) groups.push(agentsGroup(f, g.slice(7), list, catalogs, { menu, now, caps: c }));
150
+ else if (g === "surfaces") {
151
+ const host = (f.slot || f.band)?.display_name || "the harness";
152
+ groups.push({
153
+ title: "Status line and band",
154
+ footer: bind,
155
+ description: `What projectstore draws in ${host}: its line under the prompt, and the band above the prompt with this session's epic, story and the agent at work. Kept in ${bindingFile}, on this machine only.`,
156
+ items: list.map((s) => plainItem(f, s, host)),
157
+ });
158
+ } else if (g === "vault")
159
+ groups.push({
160
+ title: "Vault and templates",
161
+ footer: bind,
162
+ description: `Where this project's vault is, and how new artifacts are written: their language, author and tags, and whether the skills and the session-start context are on. Kept in ${bindingFile}, on this machine only.`,
163
+ items: list.map((s) => plainItem(f, s, null)),
164
+ });
165
+ else if (g === "policy")
166
+ groups.push({
167
+ title: "Policy",
168
+ footer: "<vault>/.projectstore.json",
169
+ description:
170
+ "The vault's own rules, shared by everyone who uses the vault: whether a story needs a spec, and whether doctor checks a story's plan and close. Kept in the vault's .projectstore.json; commit the vault after a change.",
171
+ items: list.map((s) => plainItem(f, s, null)),
172
+ });
173
+ }
174
+ return groups;
175
+ }
176
+
177
+ function plainItem(f, s, host) {
178
+ const k = KEY[s.key] || { text: () => "" };
179
+ const base = {
180
+ id: s.key,
181
+ key: s.key,
182
+ label: s.key,
183
+ value: s.value,
184
+ tag: SOURCE[s.source] || s.source,
185
+ display: show,
186
+ };
187
+ const now = (v, changed, it) =>
188
+ changed ? `In the file: ${show(it.value)}. After the change: ${show(v)}.` : `Now: ${show(v)}.`;
189
+ if (s.class === "other-verb" || s.source === "unparseable")
190
+ return {
191
+ ...base,
192
+ kind: "info",
193
+ tag: s.source === "unparseable" ? SOURCE.unparseable : "set by another command",
194
+ describe: () =>
195
+ s.source === "unparseable"
196
+ ? `${k.text(host)} ${s.file} is not valid JSON: fix it to change this.`
197
+ : k.text(host),
198
+ };
199
+ if (s.key === "default_author" || s.key === "tags") {
200
+ const item = {
201
+ ...base,
202
+ kind: "text",
203
+ allowEmpty: s.key === "tags",
204
+ value: s.key === "tags" ? (Array.isArray(s.value) && s.value.length ? s.value.join(", ") : null) : s.value,
205
+ textHint:
206
+ s.key === "tags"
207
+ ? "Type the tags as a comma list. Enter with nothing typed clears them."
208
+ : "Type the name to write into new artifacts.",
209
+ };
210
+ item.describe = (v, changed) => `${k.text(host)} ${now(v, changed, item)}`;
211
+ return item;
212
+ }
213
+ const valueText = (v) => k.values?.[v]?.(host) || "";
214
+ let options;
215
+ if (s.rail === "bool")
216
+ options = () => ["on", "off"].map((v) => ({ value: v, label: v, kind: "value", description: valueText(v) }));
217
+ else if (s.rail === "language")
218
+ options = () =>
219
+ f.languages.map((v) => ({
220
+ value: v,
221
+ label: v,
222
+ note: LANGUAGE[v] || "",
223
+ kind: "value",
224
+ description: `${LANGUAGE[v] || v}. ${k.text(host)}`,
225
+ }));
226
+ else if (s.rail === "policy")
227
+ options = () =>
228
+ ["optional", "required"].map((v) => ({ value: v, label: v, kind: "value", description: valueText(v) }));
229
+ else if (s.rail === "epic")
230
+ options = () => [
231
+ ...f.epics.map((v) => ({
232
+ value: v,
233
+ label: v,
234
+ kind: "value",
235
+ description: `Sessions that have no epic of their own show ${v} on the band.`,
236
+ })),
237
+ {
238
+ value: null,
239
+ label: "release the pin",
240
+ kind: "value",
241
+ section: "",
242
+ description: "No pin: a session without an epic of its own shows no band.",
243
+ },
244
+ ];
245
+ else return { ...base, kind: "info", describe: () => `${k.text(host)} Change it with ${s.set}.` };
246
+ const item = { ...base, kind: "choice", toggle: s.rail === "bool", options };
247
+ item.describe = (v, changed) => `${k.text(host)} ${now(v, changed, item)}`;
248
+ return item;
249
+ }
250
+
251
+ // One harness's agents: its overlay's own values (an agent's own model, or
252
+ // none), each row showing what runs today, and the model lists of
253
+ // scripts/models.mjs as the values.
254
+ function agentsGroup(f, id, list, catalogs, { menu, now, caps: c }) {
255
+ const h = loadHarness(id);
256
+ const display = h?.display_name || id;
257
+ const o = readOverlayAt(f.project, id);
258
+ const file = list[0]?.file || "";
259
+ const own = (key) => (key === "agents.default" ? o.agents.default || null : o.agents.per_agent[key.slice(7)] || null);
260
+ const items = [];
261
+ const defaultItem = () => items.find((x) => x.key === "agents.default");
262
+ const defaultNow = () => {
263
+ const d = defaultItem();
264
+ return d ? (menu?.() ? menu().eff(d) : d.value) : null;
265
+ };
266
+ for (const s of list) {
267
+ const name = s.key.slice(7);
268
+ const isDefault = name === "default";
269
+ if (s.source === "unparseable") {
270
+ items.push({
271
+ id: `${id}:${s.key}`,
272
+ key: s.key,
273
+ harness: id,
274
+ label: name,
275
+ title: `${display} · ${name}`,
276
+ kind: "info",
277
+ value: null,
278
+ tag: SOURCE.unparseable,
279
+ describe: () => `${file} is not valid JSON: fix it to change the agents' models.`,
280
+ });
281
+ continue;
282
+ }
283
+ const item = {
284
+ id: `${id}:${s.key}`,
285
+ key: s.key,
286
+ harness: id,
287
+ label: name,
288
+ title: `${display} · ${name}`,
289
+ kind: "choice",
290
+ value: own(s.key),
291
+ tag: own(s.key) ? (isDefault ? SOURCE.overlay : SOURCE.per_agent) : isDefault ? SOURCE.default : "",
292
+ display: (v) => {
293
+ if (v) return v;
294
+ if (isDefault) return "—";
295
+ const d = defaultNow();
296
+ return d ? `${d} (default)` : "frontmatter";
297
+ },
298
+ };
299
+ item.describe = (v, changed) => {
300
+ const role = ROLE[name] || "A projectstore agent.";
301
+ if (changed) return `${role} In the file: ${item.display(item.value)}. After the change: ${item.display(v)}.`;
302
+ if (isDefault)
303
+ return `${role} ${v ? `Now: ${v}, for every agent without its own.` : "Now: not set, so each agent's frontmatter decides."}`;
304
+ if (v) return `${role} Now: ${v}, its own.`;
305
+ const d = defaultNow();
306
+ return `${role} ${d ? `Now: ${d}, from default.` : "Now: its frontmatter decides."}`;
307
+ };
308
+ item.options = () =>
309
+ modelOptions(h, display, item, catalogs[id] || {}, { isDefault, defaultNow, menu, now, caps: c });
310
+ item.caption = () => caption(catalogs[id] || {}, now);
311
+ item.textHint = `Type the model's name as ${display} spells it. A name that is not in the list is written with a warning, not refused.`;
312
+ items.push(item);
313
+ }
314
+ return {
315
+ title: `Agents · ${display}`,
316
+ footer: file.split("/").pop(),
317
+ description: `Which model each projectstore agent runs on in ${display}${h?.agent_translation?.models?.spawn_takes === "id" ? `, in ${display}'s own model names` : ""}. A model set here goes to ${file}, which is committed, so it applies to everyone on this project. default covers every agent without a model of its own.`,
318
+ items,
319
+ };
320
+ }
321
+
322
+ // The values frame's footer: the list's source and age, the host being
323
+ // asked, or why nothing can be asked (the ADR, decisions 3 and 6).
324
+ function caption(cat, now) {
325
+ const k = cat.known;
326
+ if (!k) return null;
327
+ const name = k.caption || k.live?.caption || "";
328
+ if (cat.busy) return { text: `asking ${k.live?.host || k.live?.caption || name}…`, busy: true };
329
+ if (cat.error) return { text: `${k.live?.caption || name} · ${cat.error}` };
330
+ if (k.at) return { text: `${name} · checked ${checkedWhen(k.at, now())}` };
331
+ if (k.why && k.spawnTakes === "alias") return { text: `aliases only · ${k.why}` };
332
+ return k.why ? { text: k.why } : null;
333
+ }
334
+
335
+ // A model setting's values (the ADR's decisions 1 and 5): the list, the
336
+ // value in the file when the list lacks it, then "type a name…", "refresh
337
+ // the list" and the way back to the default.
338
+ function modelOptions(h, display, item, cat, { isDefault, defaultNow, menu, now, caps: c }) {
339
+ const k = cat.known;
340
+ const opts = [];
341
+ const source = k?.source || "";
342
+ if (k) {
343
+ k.offered.forEach((o, i) => {
344
+ const section =
345
+ i === 0 ? (k.spawnTakes === "alias" ? "follows the newest" : `from ${source || display}`) : undefined;
346
+ if (k.spawnTakes === "alias") {
347
+ // The newest of its line in the catalog: the best local account of
348
+ // what the alias starts; the harness resolves the alias itself.
349
+ const newest = o.follows
350
+ ? `Newest ${o.family} in ${source}: ${o.follows.label || o.follows.id}, ${o.follows.id}.`
351
+ : "";
352
+ opts.push({
353
+ value: o.id,
354
+ label: o.id,
355
+ note: o.follows ? `${term.icon(c, "transition")} ${o.follows.id}` : "",
356
+ kind: "value",
357
+ section,
358
+ description: [o.description, newest].filter(Boolean).join(" "),
359
+ });
360
+ } else
361
+ opts.push({
362
+ value: o.id,
363
+ label: o.id,
364
+ kind: "value",
365
+ section,
366
+ description: [o.label && o.label !== o.id ? `${o.label}.` : "", o.description].filter(Boolean).join(" "),
367
+ });
368
+ });
369
+ }
370
+ const m = menu?.();
371
+ for (const v of [item.value, m ? m.eff(item) : null])
372
+ if (v && !opts.some((o) => o.value === v))
373
+ opts.push({
374
+ value: v,
375
+ label: v,
376
+ note: k?.hidden?.has(v) ? `hidden by ${display}` : "not in the list",
377
+ kind: "value",
378
+ section: opts.length ? undefined : "",
379
+ description: k?.hidden?.has(v)
380
+ ? `${display} hides it from its own picker. It is kept as it is.`
381
+ : `Not in ${source ? `${source}'s list` : "any list"}. It is kept as it is.`,
382
+ });
383
+ // No free text where the spawn takes aliases: every other name fails at
384
+ // spawn, and --set is the way to name one (the ADR, decision 5).
385
+ if (k?.spawnTakes !== "alias")
386
+ opts.push({
387
+ kind: "text",
388
+ label: "type a name…",
389
+ section: "",
390
+ description: `Any name ${display} accepts. A name that is not in the list is written with a warning, not refused: the model may be newer than the list.`,
391
+ });
392
+ const liveSource = k?.live?.source || source;
393
+ if (k)
394
+ opts.push({
395
+ kind: "refresh",
396
+ ...(k.spawnTakes === "alias" ? { section: "" } : {}),
397
+ label: cat.busy ? "asking…" : "refresh the list",
398
+ busy: Boolean(cat.busy),
399
+ description: cat.busy
400
+ ? `Asking ${liveSource} for its list now.`
401
+ : !k?.live
402
+ ? `Nothing to ask: ${k?.why || "this harness declares no list to read"}.`
403
+ : cat.error
404
+ ? `The last ask failed: ${cat.error}. The list is as it was.`
405
+ : `Asks ${liveSource} for its list now.${k.at ? ` Last checked ${checkedWhen(k.at, now())}.` : ""} A list is kept for a day, so opening it never waits on the network.`,
406
+ });
407
+ const d = defaultNow();
408
+ opts.push(
409
+ isDefault
410
+ ? {
411
+ value: null,
412
+ label: "use frontmatter",
413
+ kind: "value",
414
+ description: "Drops the default: each agent without a model of its own runs on its frontmatter's model.",
415
+ }
416
+ : {
417
+ value: null,
418
+ label: "use the default",
419
+ kind: "value",
420
+ description: d
421
+ ? `Drops ${item.label}'s own model: it runs on the default, ${d}.`
422
+ : `Drops ${item.label}'s own model: it runs on its frontmatter's model.`,
423
+ },
424
+ );
425
+ return opts;
426
+ }
427
+
428
+ // The menu's changes as --set strings, each with its harness: configure
429
+ // resolves them as it resolves a named run's.
430
+ export function pendingSets(changes) {
431
+ return changes.map(({ item, after }) => {
432
+ const v = after === null || after === undefined || after === "" ? CLEAR : String(after);
433
+ return { set: `${item.key}=${v}`, harness: item.harness || null };
434
+ });
435
+ }
436
+
437
+ // The review screen's rows: the PLAN's, as configure's renderPlan reads.
438
+ export function reviewLines(items, { caps: c = term.PLAIN, twoHarnesses = false, width = 96 } = {}) {
439
+ const lines = [];
440
+ const kw = Math.max(0, ...items.map((i) => (twoHarnesses && i.display ? `${i.display} · ${i.key}` : i.key).length));
441
+ for (const i of items) {
442
+ const key = twoHarnesses && i.display ? `${i.display} · ${i.key}` : i.key;
443
+ lines.push([
444
+ seg(" "),
445
+ seg(term.icon(c, "update"), "action"),
446
+ seg(` ${key.padEnd(kw)} `),
447
+ seg(i.before, "explanation"),
448
+ seg(` ${term.icon(c, "transition")} `, "explanation"),
449
+ seg(i.after, "emphasis"),
450
+ seg(` ${i.file}${i.team ? " · team change" : ""}`, "explanation"),
451
+ ]);
452
+ // A note beneath its row, wrapped as the PLAN wraps it, never split at
453
+ // its own punctuation.
454
+ if (i.note)
455
+ for (const part of term.wrap(String(i.note), Math.max(30, width - 8)).split("\n"))
456
+ lines.push([seg(" "), seg(part, "explanation")]);
457
+ }
458
+ return lines;
459
+ }
460
+
461
+ // The menu at a terminal: open the screen, read the keys, draw, and answer
462
+ // what the person chose. `review(sets)` is configure's plan of those sets:
463
+ // { ok, items, message }. Background reads of a harness's model list are
464
+ // started here, bounded, and stopped when the menu closes.
465
+ export async function runMenu(ctx) {
466
+ const { f, settings, stdin, stdout, caps: c, proc, escapeMs, env, home, path, review, dryRun = false } = ctx;
467
+ const now = ctx.now || Date.now;
468
+ const catalogs = {};
469
+ for (const h of f.used) {
470
+ const manifest = loadHarness(h.id);
471
+ catalogs[h.id] = { known: knownModels(manifest, { env, home, now }), busy: false, error: null };
472
+ }
473
+ let menu = null;
474
+ const groups = menuGroups(f, settings, catalogs, { menu: () => menu, now, caps: c });
475
+ if (!groups.length) return { nothing: true };
476
+ const stops = new Set();
477
+ let screen = null;
478
+ const read = (id) => {
479
+ const cat = catalogs[id];
480
+ if (!cat || cat.busy || !cat.known?.live) return;
481
+ cat.busy = true;
482
+ cat.error = null;
483
+ const ac = new AbortController();
484
+ stops.add(ac);
485
+ readLive(loadHarness(id), {
486
+ env,
487
+ home,
488
+ now,
489
+ signal: ac.signal,
490
+ ...(ctx.fetch ? { fetch: ctx.fetch } : {}),
491
+ ...(ctx.spawn ? { spawn: ctx.spawn } : {}),
492
+ ...(ctx.timers ? { timers: ctx.timers } : {}),
493
+ }).then((r) => {
494
+ stops.delete(ac);
495
+ cat.busy = false;
496
+ if (r.error) cat.error = r.error;
497
+ else cat.known = knownModels(loadHarness(id), { env, home, now });
498
+ if (menu) menu.rebuild();
499
+ if (screen) screen.wake("models");
500
+ });
501
+ };
502
+ menu = createMenu({
503
+ groups,
504
+ onRefresh: (it) => it.harness && read(it.harness),
505
+ onOpen: (it) => {
506
+ const cat = it.harness ? catalogs[it.harness] : null;
507
+ if (cat?.known?.stale) read(it.harness);
508
+ },
509
+ });
510
+ screen = term.openScreen({ stdin, stdout, caps: c, proc, ...(escapeMs ? { escapeMs } : {}) });
511
+ let planned = null;
512
+ // After the review is drawn, keys already in the terminal's buffer were
513
+ // typed before it was seen: they are dropped (contract 14's review screen).
514
+ let drainAfterDraw = false;
515
+ try {
516
+ for (;;) {
517
+ const size = screen.size();
518
+ // A terminal made smaller than the menu after it opened: one line says
519
+ // what it needs, and no key but Ctrl+C acts until it grows again.
520
+ const small = size.columns < SMALLEST.columns || size.rows < SMALLEST.rows;
521
+ if (small) {
522
+ const line = `${term.icon(c, "warning")} The menu needs ${SMALLEST.columns} columns and ${SMALLEST.rows} rows. Ctrl+C leaves.`;
523
+ const room = Math.max(1, size.columns - 1);
524
+ screen.draw(
525
+ term
526
+ .wrap(line, Math.max(20, room))
527
+ .split("\n")
528
+ .map((l) => [...l].slice(0, room).join("")),
529
+ );
530
+ } else screen.draw(renderMenu(menu, { caps: c, ...size, path }));
531
+ if (drainAfterDraw) {
532
+ screen.drain(2);
533
+ drainAfterDraw = false;
534
+ }
535
+ const ev = await screen.next();
536
+ if (ev === "resize" || ev === "models") continue;
537
+ if (small && ev !== "cancel") continue;
538
+ const r = menu.press(ev);
539
+ if (r === "cancel") return { cancelled: true };
540
+ if (r === "nothing") return { nothing: true };
541
+ if (r === "write") return { sets: pendingSets(menu.changes()), planned: planned.plan };
542
+ if (r === "review") {
543
+ const sets = pendingSets(menu.changes());
544
+ if (dryRun) return { sets, dryRun: true };
545
+ planned = review(sets);
546
+ // Changes that plan to nothing (a value typed back as it was) are no
547
+ // change: the menu leaves as with nothing waiting (rule 4).
548
+ if (planned.ok && !planned.items.length) return { nothing: true };
549
+ // An Enter typed with the one that opened the review is not the
550
+ // second: what was queued is dropped now, and what reaches the
551
+ // terminal's buffer before the PLAN is on screen after the draw.
552
+ screen.drain();
553
+ drainAfterDraw = true;
554
+ const n = planned.ok ? planned.items.length : 0;
555
+ menu.screen = "review";
556
+ menu.review = planned.ok
557
+ ? {
558
+ ok: true,
559
+ title: `Plan ${term.icon(c, "dash")} ${n} ${n === 1 ? "change" : "changes"}`,
560
+ lines: reviewLines(planned.items, {
561
+ caps: c,
562
+ twoHarnesses: f.used.length > 1,
563
+ width: Math.min(screen.size().columns, 100) - 1,
564
+ }),
565
+ }
566
+ : {
567
+ ok: false,
568
+ title: "Plan",
569
+ // The refusal's lines wrapped to the screen, never cut: its
570
+ // first line is the reason, the rest its ways out.
571
+ lines: String(planned.message)
572
+ .split("\n")
573
+ .flatMap((l, k) =>
574
+ term
575
+ .wrap(l, Math.max(30, Math.min(screen.size().columns, 100) - 7))
576
+ .split("\n")
577
+ .map((part, n) => [
578
+ seg(k || n ? " " : ` ${term.icon(c, "warning")} `, "attention"),
579
+ seg(part, k ? "" : "attention"),
580
+ ]),
581
+ ),
582
+ };
583
+ }
584
+ }
585
+ } finally {
586
+ for (const ac of stops) ac.abort();
587
+ screen.close();
588
+ }
589
+ }