@vincemakes/kiso-tui-cells 0.46.2 → 0.47.1

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.
@@ -25,10 +25,10 @@
25
25
  * FOLD (a bounded block's body — content folds, metadata cuts).
26
26
  */
27
27
  import { displayWidth } from "./width.js";
28
- import { cutLine, diffBody, gutterFold, selectionBar, visibleWidth, widthCut } from "./components.js";
28
+ import { atEmbed, bandKeyRow, bandVisible, bandWindow, boxBottom, cutLine, diffBody, foldAtSpaces, foldWords, goldHits, gutterFold, moreMark, selectionBar, visibleWidth, widthCut } from "./components.js";
29
29
  // TUI2-R2pre ④: strings.js takes only a TYPE from this module, so the
30
30
  // import is erased at compile time and no runtime cycle exists.
31
- import { displayVerb } from "./strings.js";
31
+ import { bandHeader, displayVerb } from "./strings.js";
32
32
  import { escapeTerminal, palette } from "./render.js";
33
33
  /** The copy a failed ask owes the human. One line, dim, and it says what
34
34
  * is still true rather than what went wrong: the original choices are
@@ -216,6 +216,41 @@ export function stepLevel(o, from, dir) {
216
216
  }
217
217
  return from;
218
218
  }
219
+ export function pickList(spec, state) {
220
+ const query = spec.input === "filter" ? (state.query ?? "") : "";
221
+ const lq = query.toLowerCase();
222
+ const shown = [];
223
+ const hits = new Map();
224
+ spec.options.forEach((o, i) => {
225
+ if (lq === "") {
226
+ shown.push(i);
227
+ return;
228
+ }
229
+ // the label, then the first column (a profile's host), then the
230
+ // texts the row does not draw (its name) — the first that holds
231
+ // the query is where the gold goes
232
+ const drawn = [o.label, ...(o.cols ?? []).slice(0, 1)];
233
+ for (let c = 0; c < drawn.length; c += 1) {
234
+ const at = atEmbed(drawn[c].toLowerCase(), lq);
235
+ if (at !== null) {
236
+ shown.push(i);
237
+ hits.set(i, { col: c, at });
238
+ return;
239
+ }
240
+ }
241
+ if ((o.match ?? []).some((m) => atEmbed(m.toLowerCase(), lq) !== null))
242
+ shown.push(i);
243
+ });
244
+ const typed = query.trim();
245
+ const direct = spec.direct === true && shown.length === 0 && /^[^/\s]+\/\S+$/.test(typed) ? typed : null;
246
+ if (direct !== null)
247
+ shown.push(spec.options.length);
248
+ // a cursor the filter took away lands on the first row left, and the
249
+ // level comes with the row it lands on
250
+ const cursor = shown.includes(state.cursor) ? state.cursor : (shown[0] ?? -1);
251
+ const level = cursor === state.cursor ? state.level : startLevel(spec.options[cursor]);
252
+ return { shown, cursor, level, hits, direct, query };
253
+ }
219
254
  /** The rule line's text — the why-asked line (the R3 chain): the tool
220
255
  * name, the first non-abstain speaker, the fix hint (the §3.5 table,
221
256
  * code-accented). The simple flavor carries the CLI's own question
@@ -325,39 +360,85 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
325
360
  // block, and none of them the composer's. A rule SEPARATES, a gutter
326
361
  // SCOPES — the args keep their gutter because they are a verbatim
327
362
  // block; everything that was drawing a boundary is one rule now.
328
- rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
329
- rows.push(` ${cutLine(panelRuleText(view), Math.max(1, W - 2))}`);
330
- rows.push(` ${cutLine(`${p.bold}${escapeTerminal(view.title)}${p.reset}`, Math.max(1, W - 2))}`);
331
- // TUI2-R1.5 ⑤ (VD-11): the divider is a LABEL, not a design note. "the
332
- // full args — never truncated" is a sentence about the implementation,
333
- // addressed to whoever was building the panel; the human reading it
334
- // during an approval wants to know what the block below is.
335
- rows.push("");
363
+ // Graphite P1b: kiso's own question names the band itself — the
364
+ // question, its facts dim — and says its sentence once, at the content
365
+ // edge. The approval's three rows (the rule, the title, the divider)
366
+ // would say it three times: the rule line WAS the question, the title
367
+ // its facts, and the args its sentence again in a gutter that means
368
+ // "quoted verbatim".
369
+ const asked = view.asked;
370
+ const prose = asked === undefined ? [] : foldWords(escapeTerminal(asked.prose), Math.max(1, W - 4)).map((r) => `${gutter}${r}`);
371
+ // Graphite P4 (owner, 2026-10-04): an approval says the call ONCE, as its
372
+ // card will read in the transcript (`SHELL rm -rf build`), and the band's
373
+ // name carries who asked. DECLARED REVERSAL of the `<tool> needs approval
374
+ // — asked by <speaker>` row, of the bold title under it and of the blank
375
+ // row after them (R3a, TUI2-R1.5 ⑤). The body follows only when it adds
376
+ // something: a diff, JSON, or a command the head row cannot show whole.
377
+ const verb = escapeTerminal(displayVerb(view.name)).toUpperCase();
378
+ const verbHead = `${p.bold}${verb}${p.reset}${view.title === "" ? "" : ` ${escapeTerminal(view.title)}`}`;
379
+ const sayOnce = view.flavor === "approval" && asked === undefined;
380
+ // a call that is ONE line of text (a shell command) and is its own title:
381
+ // said once even when the row cannot hold it — the head row folds, its
382
+ // continuation rows under the command's first cell, breaking at a space
383
+ // where it can (the owner's capture, 2026-10-05: cut in the head and then
384
+ // repeated whole in a body, the first half read twice, `sessi|ons` split)
385
+ const oneLine = sayOnce && view.args.kind === "text" && view.args.lines.length === 1 && view.args.lines[0] === view.title;
386
+ const headRoom = Math.max(1, W - 2);
387
+ // (one cell of margin at the right, as the other rows keep)
388
+ const folds = oneLine && visibleWidth(verbHead) > headRoom && headRoom - verb.length - 2 >= 12 ? foldAtSpaces(escapeTerminal(view.title), headRoom - verb.length - 2) : null;
389
+ if (asked !== undefined) {
390
+ rows.push(bandHeader(askedLabel(asked.question, asked.facts, W), W));
391
+ rows.push(...prose);
392
+ }
393
+ else if (sayOnce) {
394
+ const facts = [...(view.amended === true ? ["amended"] : []), `asked by ${escapeTerminal(view.speaker)}`];
395
+ rows.push(bandHeader(`needs you \u00b7 ${facts.join(" \u00b7 ")}`, W));
396
+ rows.push(folds === null ? ` ${cutLine(verbHead, headRoom)}` : ` ${p.bold}${verb}${p.reset} ${folds[0]}`);
397
+ }
398
+ else {
399
+ // a question with no layout of its own (no caller in the product
400
+ // draws one since P1b): the old head, kept for that case
401
+ rows.push(bandHeader("needs you", W));
402
+ rows.push(` ${cutLine(panelRuleText(view), Math.max(1, W - 2))}`);
403
+ rows.push(` ${cutLine(`${p.bold}${escapeTerminal(view.title)}${p.reset}`, Math.max(1, W - 2))}`);
404
+ rows.push("");
405
+ }
336
406
  // the args — the bounded block's body: fold, then cap. The └ cut is
337
407
  // ONE row (the W20 discipline): when the args exceed the budget, one
338
408
  // notice row carries the count and where the rest is (the event log).
339
- const args = view.args.kind === "diff"
340
- ? diffBody(view.args.diff, W, true) // the expanded path — never the tool cell's capped copy
341
- : view.args.lines.flatMap((line) => gutterFold(`${p.dim}│${p.reset} `, escapeTerminal(line), W));
342
- // TUI2-R3v2 ①: the block now spends N rows on options instead of one,
343
- // so the args and the list SHARE what is left after the chrome (the
344
- // rule, the title, the divider, the affordance, the corner — five
345
- // rows, plus the note when there is one). The list wins the tie: a
346
- // human at an approval is choosing, and one more line of a command
347
- // they can also read in the event log is worth less than the row that
348
- // carries the choice. The args keep a floor of one row so the block
349
- // never claims to show what it is asking about and then shows nothing.
409
+ const repeats = oneLine && (visibleWidth(verbHead) <= headRoom || folds !== null);
410
+ // the folded command's continuation rows ride the args' budget, so a
411
+ // command taller than the screen still ends in the honest cut notice
412
+ const args = repeats
413
+ ? (folds ?? []).slice(1).map((r) => ` ${" ".repeat(verb.length + 1)}${r}`)
414
+ : view.args.kind === "diff"
415
+ ? diffBody(view.args.diff, W, true) // the expanded path — never the tool cell's capped copy
416
+ : view.args.lines.flatMap((line) => gutterFold(`${p.dim}│${p.reset} `, escapeTerminal(line), W));
417
+ // the speaker's fix hint (`/mode accept-edits approves edits`) rode the
418
+ // end of the retired rule row and was cut there; it has a row of its own
419
+ const hintRow = sayOnce && view.hint !== undefined && view.hint !== "" ? [`${gutter}${cutLine(`${p.dim}${escapeTerminal(view.hint)}${p.reset}`, Math.max(1, W - 2))}`] : [];
420
+ // the amend phase says where the note goes, where the person is looking —
421
+ // it was the status row's, and the bar stays under the panel now
422
+ const amendRow = phase === "amend" ? [`${gutter}${cutLine(`${p.dim}your note goes to the model \u2014 it will propose a new call${p.reset}`, Math.max(1, W - 2))}`] : [];
423
+ // TUI2-R3v2 ①: the block spends N rows on options instead of one, so
424
+ // the args and the list SHARE what is left after the chrome. The list
425
+ // wins the tie: a human at an approval is choosing, and one more line
426
+ // of a command they can also read in the event log is worth less than
427
+ // the row that carries the choice. The args keep a floor of one row so
428
+ // the block never claims to show what it is asking about and then
429
+ // shows nothing.
350
430
  const chrome =
351
- // R2: SIX rows of frame, not five — the block opens with a rule now
352
- // as well as closing with one, and the divider row became a blank.
353
- // The count is the same shape it always was: every row the block
354
- // spends on itself before the args and the list share what is left.
355
- 6 +
431
+ // P4: the band row, the call's head and the key row (no blank, no
432
+ // rule under it); kiso's own question: the band row, its sentences,
433
+ // the key row; the old head: four rows and the key row
434
+ (asked === undefined ? (sayOnce ? 3 : 5) : 2 + prose.length) +
435
+ hintRow.length +
436
+ amendRow.length +
356
437
  (phase === "options" && note !== undefined ? 1 : 0) +
357
438
  (view.riskHint !== undefined && view.riskHint !== "" ? 1 : 0) +
358
439
  (phase === "asking" ? 1 : 0) +
359
- // the safer list's rows + its way-back row
360
- (phase === "safer" && safer !== undefined ? safer.options.length + 1 : 0);
440
+ // the safer list's rows + its way-back row + the row that says so
441
+ (phase === "safer" && safer !== undefined ? safer.options.length + 2 : 0);
361
442
  const optionCount = phase === "options" ? panelOptions(view).length : 0;
362
443
  const optionsShown = Math.min(optionCount, Math.max(1, maxRows - chrome - 1));
363
444
  const argsBudget = Math.max(1, maxRows - chrome - optionsShown);
@@ -385,6 +466,7 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
385
466
  const risk = view.riskHint;
386
467
  if (risk !== undefined && risk !== "")
387
468
  rows.push(`${gutter}${cutLine(`${p.warn}${escapeTerminal(risk)}${p.reset}`, Math.max(1, W - 2))}`);
469
+ rows.push(...hintRow, ...amendRow);
388
470
  // TUI2-R3v2 ①: the option LIST. While the typed phase is open the list
389
471
  // stands down — the human is writing prose to the model, and a bar
390
472
  // hovering over "Yes, run it" while they do it claims a choice is still
@@ -403,6 +485,9 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
403
485
  // last row and is always present: an alternatives list you cannot back
404
486
  // out of would be a trap.
405
487
  if (phase === "safer" && safer !== undefined) {
488
+ // Graphite P4: what happened, said in the block — it was the status
489
+ // row's, and the bar stays under the panel now
490
+ rows.push(`${gutter}${cutLine(`${p.dim}asked the model for safer options${p.reset}`, Math.max(1, W - 2))}`);
406
491
  offset = rows.length;
407
492
  // R2: the `why` takes a COLUMN rather than running on after an em
408
493
  // dash — the commands are what is being chosen between, and they
@@ -438,18 +523,46 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
438
523
  count: phase === "options" ? optionsShown : phase === "safer" && safer !== undefined ? safer.options.length + 1 : 0,
439
524
  first,
440
525
  };
441
- rows.push(`${gutter}${p.dim}${cutLine(panelAffordance(view, phase, cursor, safer), Math.max(1, W - 2))}${p.reset}`);
442
- // TUI2-R1.5 11 (VD-13): a real bottom RULE, in the block's own edge
443
- // vocabulary — the same box-drawing run its divider already uses —
444
- // anchored at the gutter column. It used to be `\u2514 `: a two-cell stub
445
- // floating at column 1, with no rule running from it and no corner
446
- // above it to answer. Worse, `\u2514 ` is the cut-notice prefix everywhere
447
- // else in the product, so a CAPPED panel emitted two elbow rows in a
448
- // row meaning entirely different things. The rule reads as an edge,
449
- // and the cut notice above it reads as a notice.
450
- rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
526
+ // Graphite P4 (owner, 2026-10-04): one key row closes the band, with the
527
+ // selection's place at the right margin on a list (§8.2); the composer's
528
+ // rail sits under it. DECLARED REVERSAL of the panel's own bottom rule
529
+ // (TUI2-R1.5 ⑪, R2), which doubled the composer's.
530
+ const list = phase === "options" ? { at: cursor, of: panelOptions(view).length } : phase === "safer" && safer !== undefined ? { at: safer.cursor, of: safer.options.length + 1 } : null;
531
+ rows.push(panelKeyRow(panelAffordance(view, phase, cursor, safer), list, W));
451
532
  return { rows, ...layout };
452
533
  }
534
+ /** Graphite P4's fold for a one-line command; it lives with the card's
535
+ * head now, which folds a command the same way (the card round). */
536
+ export { foldAtSpaces };
537
+ /** The key row: the keys, cut by cells, and the counter at the right margin. */
538
+ export function panelKeyRow(keys, list, W) {
539
+ const p = palette();
540
+ const count = list === null ? "" : `${list.at + 1}/${list.of}`;
541
+ const room = Math.max(1, W - 1 - 2 - (count === "" ? 0 : count.length + 2));
542
+ const text = cutLine(keys, room);
543
+ const gap = count === "" ? "" : " ".repeat(Math.max(2, W - 1 - 2 - visibleWidth(text) - count.length));
544
+ return ` ${p.dim}${text}${gap}${count}${p.reset}`;
545
+ }
546
+ /** Graphite P4: kiso's own question names the band with its facts; when the
547
+ * label does not fit, the LAST fact (a path) is cut from the left, so its
548
+ * end — the folder's own name — and the rule after it stay on screen. */
549
+ function askedLabel(question, facts, W) {
550
+ const room = Math.max(1, W - 8); // `─── ` before the label, ` ───` after it
551
+ const whole = [question, ...facts].join(" \u00b7 ");
552
+ if (visibleWidth(whole) <= room || facts.length === 0)
553
+ return whole;
554
+ const head = [question, ...facts.slice(0, -1)].join(" \u00b7 ");
555
+ const left = room - visibleWidth(head) - 3 - 1; // ` · ` and the ellipsis
556
+ if (left < 4)
557
+ return whole; // too narrow to say anything useful of it — the band cuts as it always did
558
+ let tail = "";
559
+ for (const ch of [...facts[facts.length - 1]].reverse()) {
560
+ if (visibleWidth(ch + tail) > left)
561
+ break;
562
+ tail = ch + tail;
563
+ }
564
+ return `${head} \u00b7 \u2026${tail}`;
565
+ }
453
566
  /**
454
567
  * The input row's lead. In the options phase there is NOTHING to type,
455
568
  * so the lead stops pretending there is.
@@ -461,14 +574,11 @@ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
461
574
  * a human really is writing — leads with the word for what they are
462
575
  * writing.
463
576
  */
464
- export function panelLead(view, phase, cursor) {
465
- const p = palette();
466
- if (phase === "amend")
467
- return `${p.dim}amend› ${p.reset}`;
468
- // R2: an EMPTY lead emits no bytes at all — `dim + reset` around
469
- // nothing is eight bytes on the composer row of every frame a panel
470
- // is up, and the row it wraps has no content to style.
471
- return PANEL_IDLE_LEAD === "" ? "" : `${p.dim}${PANEL_IDLE_LEAD}${p.reset}`;
577
+ export function panelLead(_view, _phase, _cursor) {
578
+ // Graphite P4 (owner, 2026-10-04): a panel leaves the input row to the
579
+ // composer in every phase — DECLARED REVERSAL of the named `amend›` lead;
580
+ // the empty note row carries a dim hint instead (the tui's panelHintOf)
581
+ return PANEL_IDLE_LEAD;
472
582
  }
473
583
  /** The composer's lead while a selection list owns the keys.
474
584
  *
@@ -483,8 +593,8 @@ const PANEL_IDLE_LEAD = "";
483
593
  /** The lead's plain text — the editor's reflow width (the line must
484
594
  * fit the lead + the drawn cursor's own cell — R2 retired the box and
485
595
  * its walls with it). */
486
- export function panelLeadPlain(view, phase, cursor) {
487
- return phase === "amend" ? "amend› " : PANEL_IDLE_LEAD;
596
+ export function panelLeadPlain(_view, _phase, _cursor) {
597
+ return PANEL_IDLE_LEAD;
488
598
  }
489
599
  export function panelLeadWidth(view, phase, cursor) {
490
600
  return displayWidth(panelLeadPlain(view, phase, cursor));
@@ -521,8 +631,9 @@ export function panelStatus(view, phase, cursor) {
521
631
  * arrows are — an affordance nobody is told about is one nobody uses.
522
632
  */
523
633
  export function panelAffordance(view, phase, cursor, safer) {
634
+ // Graphite P4: the amend keys say where esc goes back to
524
635
  if (phase === "amend")
525
- return "⏎ send · esc back";
636
+ return "⏎ sends · esc back to the choices";
526
637
  // TUI2-R3v2 ③: the ask is in flight — the ONE key that still means
527
638
  // something is the one that gets you out of it.
528
639
  if (phase === "asking")
@@ -530,205 +641,199 @@ export function panelAffordance(view, phase, cursor, safer) {
530
641
  // the same sentence the approval list carries, counting the rows THIS
531
642
  // list has (the alternatives plus the way back)
532
643
  if (phase === "safer" && safer !== undefined) {
533
- return `↑↓ move · ⏎ or click confirms · 1-${safer.options.length + 1} instant · esc`;
644
+ return `↑↓ move · ⏎ or click confirms · 1–${safer.options.length + 1} instant · esc`;
534
645
  }
535
- return `↑↓ move · ⏎ or click confirms · 1-${panelOptions(view).length} instant · esc`;
646
+ // Graphite P4 (owner, 2026-10-04): on an approval esc is a denial (the CLI
647
+ // records it as one), so the key row says so; kiso's own questions keep
648
+ // the bare `esc`, which declines them
649
+ return `↑↓ move · ⏎ or click confirms · 1–${panelOptions(view).length} instant · ${view.flavor === "approval" ? "esc denies" : "esc"}`;
536
650
  }
537
- // ── TUI2-R2 ④: the pick block, its lead, its status, its affordance ──
651
+ // ── TUI2-R2 ④, Graphite P3: the pick block, its lead, its status ────
538
652
  /**
539
- * The pick block's rows — the prototype's C frame.
653
+ * Graphite P3 (owner, 2026-10-04) — the pick block takes the shape of every
654
+ * list band (§8.2, §8.13). The band names itself with its count or its
655
+ * current value; a window of eight rows from a 30-row terminal, five below,
656
+ * with dim more-marks in column 0; the rows a TABLE, each column measured
657
+ * over the whole list; the selected row the card head every list shares,
658
+ * OPENED into a second row on the same wash (the /resume shape) for the
659
+ * level strip and what the columns cannot hold; one key row with the
660
+ * counter. No closing rule: the composer's rail closes the band, as it does
661
+ * the command list.
540
662
  *
541
- * The header says what is in effect right now, because the first
542
- * question anyone opening this panel has is "what am I on?". The
543
- * options are numbered from 1 and the number IS the key. The `t` row is
544
- * last and always present: a profile list is a convenience, never the
545
- * set of models that exist, and a picker that can only offer what
546
- * someone remembered to configure is a smaller product than the one it
547
- * replaced.
663
+ * DECLARED REVERSALS (P3, owner, 2026-10-04): the `current:` row (its words
664
+ * ride the band's name), the bracketed level (the level in force is gold),
665
+ * the `↕ 1-9 / 11` row (the more-marks say it), PICK_MAX's nine-row window
666
+ * (§8.2's eight and five), and the `t` row with its typing phase (a typed
667
+ * `provider/model` is a row of the filter — PickSpec.direct).
548
668
  *
549
- * Single-row discipline: every row CUTS, never folds — the block's
550
- * height is its row count (the W20 rule the #checked throw demands).
669
+ * Single-row discipline: every row CUTS, never folds — the block's height
670
+ * is its row count (the W20 rule the #checked throw demands).
551
671
  */
552
- export function pickBlockRows(view, state, W, maxRows) {
672
+ export function pickBlockRows(view, state, W, maxRows, height = 24) {
553
673
  const p = palette();
554
674
  const spec = view.pick;
555
- const rows = [`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`]; // R2: the same rule the composer and the other panels use
556
- const room = Math.max(1, W - 2);
557
- rows.push(` ${cutLine(`${p.bold}${escapeTerminal(spec.header.split(" \u2014 ")[0] ?? spec.header)}${p.reset}${p.dim}${escapeTerminal(spec.header.slice((spec.header.split(" \u2014 ")[0] ?? "").length))}${p.reset}`, room)}`);
558
- if (spec.options.length === 0) {
559
- // the honest empty state \u2014 the caller's own copy, verbatim
560
- rows.push(` ${cutLine(`${p.dim} ${escapeTerminal(spec.emptyNote ?? "no options")}${p.reset}`, room)}`);
675
+ const list = pickList(spec, state);
676
+ const total = spec.options.length;
677
+ // a legacy `name — words` header: the name opens the band, the words join its facts
678
+ const name = spec.header.split(" — ")[0] ?? spec.header;
679
+ const said = spec.header.slice(name.length).replace(/^ — /, "");
680
+ const matched = list.shown.filter((i) => i < total).length;
681
+ const count = spec.noun === undefined ? "" : list.query !== "" ? `${matched} of ${total} match` : spec.noun === "" ? `${total}` : `${total} ${spec.noun}`;
682
+ const facts = [count, said, spec.facts ?? ""].filter((f) => f !== "").join(" · ");
683
+ const rows = [bandHeader(escapeTerminal(facts === "" ? name : `${name} · ${facts}`), W)];
684
+ const room = Math.max(1, W - 3); // the two-cell lead and one cell of margin
685
+ // the honest empty state — the caller's own copy, verbatim; a typed
686
+ // `provider/model` is still a row under it
687
+ if (total === 0)
688
+ rows.push(` ${cutLine(`${p.dim}${escapeTerminal(spec.emptyNote ?? "no options")}${p.reset}`, room)}`);
689
+ else if (list.shown.length === 0)
690
+ rows.push(` ${cutLine(`${p.dim}nothing matches "${escapeTerminal(list.query)}"${p.reset}`, room)}`);
691
+ if (list.shown.length === 0) {
692
+ // the last sweep (owner, 2026-10-06): a list with nothing in it has no
693
+ // place to count (it read `0/0` under "no profiles"); a filter that
694
+ // matches nothing still counts 0/0, as /resume's does
695
+ rows.push(total === 0 ? cutLine(`${p.dim} esc${p.reset}`, W) : bandKeyRow(["esc"], 0, 0, W));
696
+ return rows;
561
697
  }
562
- else {
563
- // the budget: the OPENING rule, the header, the t row, the
564
- // affordance and the CLOSING rule — five, not four. R2 added the
565
- // opening rule to this block and bumped the ask panel (5→6) and the
566
- // approval panel (5→6) to pay for it, and missed this one: the
567
- // block ran two rows over its budget, and two rows of committed
568
- // content were scrolled irreversibly into the scrollback every time
569
- // `/model` opened on a tight screen. The `+N more` row is a sixth
570
- // when it appears, so it is paid for too.
571
- // OR-7: the level strip is a row of its own under the highlighted
572
- // option, so it is chrome and it is paid for here. Inline after the
573
- // note was the coordination note's shape and it does not fit: five
574
- // levels need 42 columns and the note column has 48 at width 100,
575
- // which it already spends on `profile: <name>`. A second axis that
576
- // truncates on a normal terminal is not a second axis.
577
- const strip = spec.options[state.cursor]?.levels !== undefined && state.phase === "options" ? 1 : 0;
578
- // 2026-09-21 (finding DC-58, the owner's report): the window FOLLOWS the
579
- // cursor instead of being pinned to the top. It used to be
580
- // `slice(0, min(budget, PICK_MAX))`, so with a 60-profile config the
581
- // keyboard reached the first nine and the rest were reachable only by
582
- // typing the name.
583
- //
584
- // REVIEW of this round: the SIZE depends on the frame's budget, so the
585
- // renderer and the digit keys used to disagree on a short terminal
586
- // (drawn rows 1-2, keys computing against nine). `pickWindowOf` is the
587
- // one derivation both sides use — the renderer draws it, the input layer
588
- // is HANDED it (`visiblePickWindow`).
589
- const win = pickWindowOf(view, state.cursor, state.phase, maxRows);
590
- const shown = spec.options.slice(win.first, win.first + win.size);
591
- // R2: the note takes a COLUMN, not three spaces after a label of
592
- // whatever length this row happened to have, and the cursor row
593
- // wears the bar and the arrow like every other list in the
594
- // product. This panel was the last one still saying "selected"
595
- // with bold alone.
596
- // 0.40.1 (the owner's dogfood, 2026-09-21): the row NUMBER is gone. The
597
- // digits still pick by visible position (the keys sheet documents them),
598
- // but a number the person reads as decoration is noise the label pays
599
- // for at every width — and the owner picks with ↑↓ and the mouse.
600
- const lead = (o, _i, cursor) => `${cursor ? "\u2192" : " "} ${escapeTerminal(o.label)}`;
601
- const widest = Math.max(...shown.map((o, i) => visibleWidth(lead(o, i, false))));
602
- const stop = shown.some((o) => o.note !== undefined) && widest + 2 <= Math.floor(room / 2) && room - widest - 2 >= 18 ? widest + 2 : 0;
603
- for (let i = 0; i < shown.length; i += 1) {
604
- const o = shown[i];
605
- // DC-58: `i` is the row's position in the WINDOW, the cursor is an
606
- // option index — the mark lands on the cursor's own row after any
607
- // scroll only when the window's origin is added back.
608
- const mark = win.first + i === state.cursor && state.phase === "options";
609
- const plain = lead(o, i, mark);
610
- const head = `${mark ? p.bold : ""}${plain}${mark ? p.reset : ""}`;
611
- const note = o.note === undefined
612
- ? ""
613
- : stop > 0
614
- ? `${" ".repeat(Math.max(1, stop - visibleWidth(plain)))}${p.dim}${widthCut(escapeTerminal(o.note), Math.max(0, room - stop))}${p.reset}`
615
- : `${p.dim} ${escapeTerminal(o.note)}${p.reset}`;
616
- const text = cutLine(`${head}${note}`, room);
617
- // ONE space, like the approval and ask panels: the bar spends a
618
- // leading cell of its own, so a two-space unselected prefix
619
- // moves the digit column by one as the cursor walks — the exact
620
- // "a column that moves per row reads as damage" this file
621
- // quotes twice as its standard.
622
- rows.push(mark ? selectionBar(text, visibleWidth(text), W) : ` ${text}`);
623
- }
624
- if (spec.options.length > shown.length) {
625
- const to = win.first + shown.length;
626
- rows.push(` ${cutLine(`${p.dim} \u2195 ${win.first + 1}-${to} / ${spec.options.length} \u2014 \u2191\u2193 scrolls${p.reset}`, room)}`);
698
+ const win = pickWindowOf(view, state, maxRows, height);
699
+ const colsOf = (o) => (o.cols ?? (o.note === undefined ? [] : [o.note])).map(escapeTerminal);
700
+ // the columns measure over EVERY option, so a filter or a walked level
701
+ // never moves them
702
+ const labelW = Math.max(0, ...spec.options.map((o) => visibleWidth(escapeTerminal(o.label))));
703
+ const colW = [];
704
+ for (const o of spec.options) {
705
+ colsOf(o).forEach((c, j) => {
706
+ const levels = j === spec.levelColumn ? (o.levels ?? []).map((l) => visibleWidth(escapeTerminal(l))) : [];
707
+ colW[j] = Math.max(colW[j] ?? 0, visibleWidth(c), ...levels);
708
+ });
709
+ }
710
+ const lead = (mark) => (mark === null ? " " : `${p.dim}${mark}${p.reset}`);
711
+ for (let k = win.first; k < win.first + win.size; k += 1) {
712
+ const i = list.shown[k];
713
+ const on = i === list.cursor;
714
+ const mark = moreMark(k, win.first, win.size, list.shown.length);
715
+ if (i === total) {
716
+ // the direct row: what was typed, offered as a pick of its own
717
+ const text = cutLine(`use ${escapeTerminal(list.direct ?? "")} directly`, room);
718
+ rows.push(on ? selectionBar(`${p.bold}→${text}${p.reset}`, visibleWidth(text) + 1, W) : `${lead(mark)} ${p.dim}${text}${p.reset}`);
719
+ continue;
627
720
  }
628
- const axis = shown[state.cursor - win.first];
629
- if (strip === 1 && axis?.levels !== undefined) {
630
- const off = new Set(axis.disabled ?? []);
631
- // the RUNTIME cursor, not the option's starting index: the option
632
- // says where the cursor opens, the state says where it is now.
633
- // Reading the option here rendered a bracket that never moved
634
- // while the state underneath it did — the silent movement this
635
- // whole axis exists to replace.
636
- const cur = enabledLevel(axis, state.level);
637
- // the bracket means THE CURSOR IS HERE. It used to mean "the
638
- // registry default" in the note's text form; a row whose
639
- // default is null now marks nothing at all rather than
640
- // promoting the first level into one.
641
- const cells = axis.levels.map((l, i) => {
642
- const text = escapeTerminal(l);
643
- if (off.has(i))
644
- return `${p.dim}${text}${p.reset}`;
645
- return i === cur ? `${p.bold}[${text}]${p.reset}` : text;
646
- });
647
- const note = axis.levelNote === undefined ? "" : ` ${p.dim}\u2014 ${escapeTerminal(axis.levelNote)}${p.reset}`;
648
- rows.push(` ${cutLine(`${p.dim}effort: ${p.reset}${cells.join(`${p.dim} \u00b7 ${p.reset}`)}${note}`, room)}`);
721
+ const o = spec.options[i];
722
+ const hit = list.hits.get(i);
723
+ const label = escapeTerminal(o.label);
724
+ let text = `${goldHits(label, new Set(hit?.col === 0 ? hit.at : []), on ? p.bold : o.off === true ? p.dim : "")}${" ".repeat(Math.max(0, labelW - visibleWidth(label)))}`;
725
+ let width = labelW;
726
+ const cols = colsOf(o);
727
+ cols.forEach((c, j) => {
728
+ // a setting's value follows ←→ on the selected row
729
+ const shown = on && j === spec.levelColumn && o.levels !== undefined && list.level !== null ? escapeTerminal(o.levels[list.level] ?? c) : c;
730
+ const plain = spec.columns?.[j] === "plain";
731
+ const style = plain ? (on ? p.bold : o.off === true ? p.dim : "") : p.dim;
732
+ const last = j === cols.length - 1;
733
+ text += ` ${goldHits(shown, new Set(hit?.col === j + 1 ? hit.at : []), style)}${last ? "" : " ".repeat(Math.max(0, (colW[j] ?? 0) - visibleWidth(shown)))}`;
734
+ width += 2 + (last ? visibleWidth(shown) : (colW[j] ?? 0));
735
+ });
736
+ const cut = width > room ? cutLine(text, room) : text;
737
+ if (!on) {
738
+ rows.push(`${lead(mark)} ${cut}`);
739
+ continue;
649
740
  }
741
+ rows.push(selectionBar(`${p.bold}→${cut}`, Math.min(width, room) + 1, W));
742
+ const open = openedRow(o, list.level, room);
743
+ if (open !== null)
744
+ rows.push(selectionBar(` ${open.text}`, open.width + 1, W));
650
745
  }
651
- const typing = state.phase === "custom";
652
- if (spec.typeHint !== undefined) {
653
- const tText = cutLine(`${typing ? p.bold : ""}${typing ? "\u2192" : " "} t ${p.reset}${p.dim}${escapeTerminal(spec.typeHint)}${p.reset}`, room);
654
- rows.push(typing ? selectionBar(tText, visibleWidth(tText), W) : ` ${tText}`);
655
- }
656
- rows.push(` ${p.dim}${cutLine(pickAffordance(state, spec.options[state.cursor]?.levels !== undefined), room)}${p.reset}`);
657
- rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
746
+ const here = list.cursor >= 0 && list.cursor < total ? spec.options[list.cursor] : undefined;
747
+ const keys = ["↑↓ move"];
748
+ if (here?.levels !== undefined && here.levels.length > 0)
749
+ keys.push(`←→ ${here.axisLabel ?? "effort"}`);
750
+ if ((spec.input ?? "digits") === "digits" && win.size > 1)
751
+ keys.push(`1–${Math.min(win.size, PICK_MAX)} picks`);
752
+ keys.push(here?.enter ?? spec.enter ?? "⏎ confirms", "esc");
753
+ rows.push(bandKeyRow(keys, Math.max(0, list.shown.indexOf(list.cursor)), list.shown.length, W));
658
754
  return rows;
659
755
  }
660
- /** The digits are the keys, so what one SCREEN offers is still bounded by
661
- * the digits there are — but the list itself is no longer bounded by it.
662
- *
663
- * Finding DC-58 (the owner, 2026-09-21): this constant used to cap what the
664
- * KEYBOARD could reach (`/model` on a 60-profile config offered nine and
665
- * said `/model <name>` for the rest). The window now follows the cursor —
666
- * `pickWindow` — so PICK_MAX is only how many rows one screen shows, and
667
- * ↑↓ walks every option.
668
- */
756
+ /** Text cut to `room` cells by cells, with an ellipsis — plain text only. */
757
+ const fitCells = (t, room) => (visibleWidth(t) <= room ? t : room <= 1 ? "" : `${widthCut(t, room - 1)}…`);
758
+ /** The selected row's second row: the level strip (the level in force
759
+ * bold gold, a level the thinking mode forbids dim), then what the caller
760
+ * has to say — the strip stays whole and the words give way. Null when
761
+ * there is nothing to open into. */
762
+ function openedRow(o, level, room) {
763
+ const p = palette();
764
+ // off a known ground the warn tint carries the gold, as the typed letters do
765
+ const gold = p.gold !== "" ? p.gold : p.warn;
766
+ const tail = o.levelNote ?? o.opened ?? "";
767
+ if (o.levels === undefined || o.levels.length === 0) {
768
+ if (tail === "")
769
+ return null;
770
+ const t = fitCells(escapeTerminal(tail), room);
771
+ return { text: `${p.dim}${t}${p.reset}`, width: visibleWidth(t) };
772
+ }
773
+ const off = new Set(o.disabled ?? []);
774
+ // a named axis is the row's own value (a setting), so its strip needs no
775
+ // label; the model's effort is not the row's label, so it says `effort`
776
+ const label = o.axisLabel === undefined ? "effort " : "";
777
+ const levels = o.levels.map((l) => escapeTerminal(l));
778
+ const cells = levels.map((t, i) => (i === level ? `${p.bold}${gold}${t}${p.reset}` : off.has(i) ? `${p.dim}${t}${p.reset}` : t));
779
+ let text = `${label === "" ? "" : `${p.dim}${label}${p.reset}`}${cells.join(`${p.dim} · ${p.reset}`)}`;
780
+ let width = visibleWidth(`${label}${levels.join(" · ")}`);
781
+ const t = tail === "" ? "" : fitCells(escapeTerminal(tail), room - width - 5);
782
+ if (t !== "") {
783
+ text += `${p.dim} · ${t}${p.reset}`;
784
+ width += 5 + visibleWidth(t);
785
+ }
786
+ return width > room ? { text: cutLine(text, room), width: room } : { text, width };
787
+ }
788
+ /** The digits are the keys, so what one SCREEN offers is bounded by the
789
+ * digits there are. Finding DC-58 (the owner, 2026-09-21) made the window
790
+ * follow the cursor, so this is how many rows a digit can name, not how
791
+ * many rows the keyboard reaches. */
669
792
  export const PICK_MAX = 9;
670
- /** The option window the pick panel draws and the digits name: `size` rows
671
- * starting at `first`, with `first` derived from the cursor so it cannot
672
- * drift from it. Edge-following (the window starts moving only when the
673
- * cursor leaves it) and clamped at both ends, so the last page is full
674
- * rather than short.
675
- *
676
- * Pure, and the ONE copy: the renderer, the digit keys and the click
677
- * hit-test all ask this function rather than each deriving an offset.
678
- */
679
- /** The pick panel's window for THIS frame's budget — the one derivation the
680
- * renderer and the input layer share (review of this round).
793
+ /**
794
+ * The pick's window for THIS frame — the one derivation the renderer and
795
+ * the digit keys share (DC-58's review: a size derived twice is a size that
796
+ * can disagree). Positions are into `pickList(...).shown`.
681
797
  *
682
- * The renderer draws it; `Dock.visiblePickWindow` hands the SAME value to the
683
- * digit keys, because a size derived twice is a size that can disagree — and
684
- * on a short terminal it did (two rows drawn, nine assumed, so `1` could mean
685
- * a row nobody could see). `phase` is compared as a string so this module
686
- * keeps its zero-dependency contract.
798
+ * Graphite P3: §8.2's window (eight rows from a 30-row terminal, five
799
+ * below, the cursor kept a row inside an edge while more lies past it),
800
+ * within the frame's budget: the band's name, the key row, the opened row
801
+ * and a `nothing matches` row are the chrome.
687
802
  */
688
- export function pickWindowOf(view, cursor, phase, maxRows) {
689
- const count = view.pick?.options.length ?? 0;
690
- const strip = view.pick?.options[cursor]?.levels !== undefined && phase === "options" ? 1 : 0;
691
- const chrome = 5 + strip + (count > Math.min(Math.max(1, maxRows - 5 - strip), PICK_MAX) ? 1 : 0);
692
- const budget = Math.max(1, maxRows - chrome);
693
- return pickWindow(cursor, count, Math.min(budget, PICK_MAX));
694
- }
695
- export function pickWindow(cursor, count, size) {
696
- const win = Math.max(1, Math.min(size, count));
697
- if (count <= win)
698
- return { first: 0, size: win };
699
- const first = Math.max(0, Math.min(cursor - win + 1, count - win));
700
- return { first, size: win };
701
- }
702
- /** The input row's lead: the digit range while picking, the named
703
- * prompt while typing one out. */
704
- export function pickLeadPlain(view, state) {
705
- if (state.phase === "custom")
706
- return "provider/model: ";
707
- const n = Math.min(view.pick.options.length, PICK_MAX);
708
- return n === 0 ? "t> " : `1-${n}> `;
709
- }
710
- export function pickLead(view, state) {
711
- const p = palette();
712
- return `${p.bold}${pickLeadPlain(view, state)}${p.reset}`;
803
+ export function pickWindowOf(view, state, maxRows, height = 24) {
804
+ const spec = view.pick;
805
+ if (spec === undefined)
806
+ return { first: 0, size: 0 };
807
+ const list = pickList(spec, state);
808
+ const here = list.cursor >= 0 && list.cursor < spec.options.length ? spec.options[list.cursor] : undefined;
809
+ const opens = here !== undefined && ((here.levels?.length ?? 0) > 0 || (here.levelNote ?? here.opened ?? "") !== "") ? 1 : 0;
810
+ const notice = spec.options.length === 0 || list.shown.length === 0 ? 1 : 0;
811
+ const chrome = 2 + opens + notice;
812
+ const size = Math.max(1, Math.min(bandVisible(height), maxRows - chrome));
813
+ const { first, count } = bandWindow(list.shown.length, Math.max(0, list.shown.indexOf(list.cursor)), size);
814
+ return { first, size: count };
713
815
  }
714
- /** The status row's left text \u2014 the CALLER's, because only the caller
715
- * knows whether a run is paused behind this panel. */
816
+ /** The input row's lead. Graphite P3: a pick leaves the input row to the
817
+ * composer — the filter is typed there, and a digit list needs no lead
818
+ * to say so (its key row does). */
819
+ export function pickLeadPlain(_view, _state) {
820
+ return "";
821
+ }
822
+ export function pickLead(_view, _state) {
823
+ return "";
824
+ }
825
+ /** The status row's left text — the CALLER's, because only the caller
826
+ * knows whether a run is paused behind this panel. (Graphite P3: on a
827
+ * dock the bar stays under a pick; this is the dock-less text.) */
716
828
  export function pickStatus(view) {
717
829
  return view.statusText;
718
830
  }
719
- export function pickAffordance(state, axis = false) {
720
- // DC-36 — the row NAMES the arrows. TUI2-R2 ④ bound ↑↓ to the pick's
721
- // cursor and the keys sheet has said `panels: ↑↓ move` ever since,
722
- // but this row — the one a human is actually looking at while the
723
- // panel is up — advertised only the digits. The owner read it as
724
- // "type the answer", which is the same lesson DC-30 filed: a hint
725
- // that omits the gesture is why the gesture goes unused.
726
- // OR-7 names \u2190\u2192 here for the same reason DC-36 named \u2191\u2193: a gesture
727
- // this row omits is a gesture that goes unused. It appears only when
728
- // there is a second axis to walk.
729
- if (state.phase === "custom")
730
- return "enter commits \u00b7 esc backs out";
731
- return axis ? "\u2191\u2193 move \u00b7 \u2190\u2192 effort \u00b7 digits pick \u00b7 \u23ce confirms \u00b7 esc" : "\u2191\u2193 move \u00b7 digits pick \u00b7 \u23ce confirms \u00b7 esc";
831
+ /** The key words, for a caller that draws them outside the block. The
832
+ * block's own key row is the one on screen (P3). */
833
+ export function pickAffordance(_state, axis = false) {
834
+ // DC-36 — the row NAMES the arrows; OR-7 names ←→ for the same reason,
835
+ // and only when there is a second axis to walk
836
+ return axis !== false ? `↑↓ move · ←→ ${axis === true ? "effort" : axis} · ⏎ confirms · esc` : "↑↓ move · ⏎ confirms · esc";
732
837
  }
733
838
  /** Compose a pick view. The flavor/name/title/args fields exist for the
734
839
  * approval path and are given inert values here \u2014 the pick block
@@ -752,6 +857,20 @@ export function modePickView(spec, statusText) {
752
857
  pick: spec,
753
858
  };
754
859
  }
860
+ /** Graphite R3e — `/settings` as a pick panel: a row per setting, the
861
+ * session's own ones with a second axis to walk. */
862
+ export function settingsPickView(spec, statusText) {
863
+ return {
864
+ flavor: "simple",
865
+ name: "settings",
866
+ title: "settings",
867
+ speaker: "you",
868
+ statusText,
869
+ args: { kind: "text", lines: [] },
870
+ fallbackQuestion: "settings: (name) ",
871
+ pick: spec,
872
+ };
873
+ }
755
874
  /** ADR-0058 (3e): the `/tasks` list, a task's actions, and the exit
756
875
  * question — the same pick panel as /mode and /model, under its name. */
757
876
  export function namedPickView(name, spec, statusText, fallbackQuestion) {