@vincemakes/kiso-tui-cells 0.24.5 → 0.26.0

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,12 +25,12 @@
25
25
  * (untouched); render.ts supplies the original text (palette, escape,
26
26
  * tint, fold wording).
27
27
  */
28
- import { displayWidth, visibleWidth } from "./width.js";
28
+ import { displayWidth, visibleWidth, widthCut } from "./width.js";
29
29
  // TUI2-R2pre ④: the ONE display-verb table (strings.ts, beside
30
30
  // KEY_BINDINGS). strings.js imports only render/width here, so this edge
31
31
  // adds no cycle.
32
32
  import { displayVerb } from "./strings.js";
33
- import { bannerLines, breathFrame, escapeTerminal, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, currentGround, } from "./render.js";
33
+ import { bannerLines, breathFrame, cutLine, escapeTerminal, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, currentGround, } from "./render.js";
34
34
  // TUI2-MD: the markdown renderer's surface reaches the tui through this
35
35
  // module (the tui's components shim re-exports it) — one import edge,
36
36
  // and it points one way: md.ts measures with the width authority, never
@@ -113,7 +113,8 @@ export function foldLine(line, W) {
113
113
  * renderer can measure without importing this module back — the
114
114
  * re-export is verbatim, so every existing importer and the barrel see
115
115
  * exactly what they saw. */
116
- export { visibleWidth } from "./width.js";
116
+ export { visibleWidth, widthCut } from "./width.js";
117
+ export { cutLine } from "./render.js";
117
118
  /** The W11 spacing formula — "a row gets one blank line above it when
118
119
  * the row is itself a block, or when the previous sibling was taller
119
120
  * than one row". One-row siblings pack tight; anything multi-row
@@ -122,7 +123,7 @@ export { visibleWidth } from "./width.js";
122
123
  * `prev` is the previous sibling's OWN rows (raw — a cell's own blank
123
124
  * must never count toward its height). The blank is a JOIN artifact:
124
125
  * the cell's own render stays blank-free, so per-cell accounting
125
- * (heights, the fold cache) never sees a fake row. */
126
+ * (heights, the line cache) never sees a fake row. */
126
127
  export function bodySpacing(prev, rows) {
127
128
  if (rows.length === 0 || prev === null || prev.length === 0)
128
129
  return rows;
@@ -168,9 +169,9 @@ export function cellComponent(cell) {
168
169
  case "user":
169
170
  return new UserMessage(cell);
170
171
  case "thinking":
171
- // R7: the committed/live surface is the BLOCK. ThinkingFold
172
- // survives for the pipe path (render.ts's foldThinking), whose
173
- // bytes are asserted by the --plain identity gate.
172
+ // R7: the committed/live surface is the BLOCK; the pipe path
173
+ // prints render.ts's one-row foldThinking instead, whose bytes
174
+ // are asserted by the --plain identity gate.
174
175
  return new ThinkingBlock(cell);
175
176
  case "tool":
176
177
  return new ToolExecution(cell);
@@ -334,19 +335,14 @@ export function pendingQueueRows(lines, W) {
334
335
  }
335
336
  return out;
336
337
  }
337
- /** The thinking fold — one dim line, width-capped so the /think suffix
338
- * rides the fold's own row (the #17 fix's slice, componentized). The
339
- * slice is DISPLAY-WIDTH-based (the char-based slice overflowed with
340
- * CJK — 2 cells per char — and tripped invariant ① on a real
341
- * Chinese session). W2: the leading ⋯ is the thinking gutter — the
342
- * midline mark (the state), never the text ellipsis (the truncation). */
343
338
  /**
344
339
  * R7 — THINKING IS A BLOCK OF WORDS.
345
340
  *
346
341
  * The owner's ruling, arrived at from a side-by-side with pi's screen:
347
342
  * the model's reasoning reads as its own paragraphs — italic, dim,
348
- * indented two — and is never folded away. `ThinkingFold` (below, kept
349
- * for the pipe path) summarised it to one row with a key; four rounds
343
+ * indented two — and is never folded away. A one-row fold (the pipe
344
+ * path's `foldThinking` is what remains of it) stood for it, with a
345
+ * key; four rounds
350
346
  * of machinery were then built to hand the rest back, and the owner's
351
347
  * complaint through all of them was the same sentence: I cannot see
352
348
  * what it was thinking.
@@ -411,79 +407,18 @@ class ThinkingBlock {
411
407
  return rows;
412
408
  }
413
409
  }
414
- class ThinkingFold {
415
- cell;
416
- constructor(cell) {
417
- this.cell = cell;
418
- }
419
- render(W, _ctx) {
420
- const p = palette();
421
- const block = this.cell.text;
422
- // R3f — the fold is ONE ROW, so its text is one line.
423
- //
424
- // `escapeTerminal` strips C0 but KEEPS \n and \t, and
425
- // `charWidth(0x0A)` is 1 — so a multi-line thinking block sailed
426
- // through every width check as a legal single row, and the
427
- // terminal then wrapped it across the chrome. That shipped in
428
- // 0.16.6 and is what smashed the composer: a model whose thinking
429
- // opens with a numbered plan ("1. …\n2. …") produces exactly it.
430
- // Invariant ①b now catches the class at the emit; this stops
431
- // producing it. Whitespace collapses because the row is a
432
- // SUMMARY — the full text is one ctrl+o away, unchanged.
433
- const trimmed = escapeTerminal(block.trim()).replace(/\s+/g, " ");
434
- // R2 (owner, 2026-08-27) — three changes, each independent.
435
- //
436
- // ITALIC marks the row as not-the-answer without spending a colour,
437
- // on the same argument that admitted italic to the alphabet.
438
- //
439
- // The cut lands on a WORD. It used to cut on a byte, so the fold
440
- // read as `…the user's h` and the reader's eye had to reassemble a
441
- // word it already knew.
442
- //
443
- // The affordance moves to the RIGHT EDGE, so the left edge of every
444
- // row on screen is content. The char count goes with the move: it
445
- // told the reader nothing they could act on, and the row it was
446
- // crowding is the one thing this cell says.
447
- //
448
- // The ≤100 short-circuit stays width-aware: a short block at a
449
- // narrow width returned the line UNFOLDED and tripped invariant ①.
450
- // DC-15: the affordance is DROPPED, not squeezed. `room` floored at
451
- // 1 while `pad` floored at 1 independently, so a narrow terminal
452
- // produced 2 + cut + pad + 6 = 11 cells no matter what W was — and
453
- // invariant ① does not truncate, it THROWS. Measured 11 cells at
454
- // every W ≤ 10. Below the width where the tail and one word of
455
- // content can both stand, the row is the CONTENT: a cell that
456
- // cannot hold the key's name has nothing to say about the key.
457
- const tail = "/think";
458
- const room = W - 2 - tail.length - 1;
459
- // the belt: cutLine is SGR-aware and single-row, so the invariant
460
- // holds by CONSTRUCTION at every width rather than by arithmetic
461
- // that has to be re-proved every time a span moves.
462
- if (room < 2)
463
- return [cutLine(`${p.dim}⋯ ${p.italic}${wordCut(trimmed, Math.max(1, W - 2))}${p.italicEnd}${p.reset}`, W)];
464
- const cut = wordCut(trimmed, room);
465
- const body = `⋯ ${p.italic}${cut}${p.italicEnd}`;
466
- const pad = Math.max(1, W - 2 - visibleWidth(cut) - tail.length);
467
- return [cutLine(`${p.dim}${body}${" ".repeat(pad)}${tail}${p.reset}`, W)];
410
+ /** The SGR spans still open at the end of `text`, given those open at
411
+ * its start. A reset closes everything; anything else stacks. */
412
+ function spansOpenAfter(text, before) {
413
+ let open = [...before];
414
+ for (const m of text.matchAll(/\x1b\[[0-9;]*m/g)) {
415
+ if (m[0] === "\x1b[0m")
416
+ open = [];
417
+ else
418
+ open.push(m[0]);
468
419
  }
420
+ return open;
469
421
  }
470
- /** R2 — cut at a word boundary, with the honest ellipsis. widthCut cuts
471
- * at a cell, which is right for verbatim output and wrong for prose:
472
- * the reader has to reassemble "h" into "home". Falls back to the cell
473
- * cut when the first word alone overruns, because a row that cannot
474
- * hold one word has no boundary to find. */
475
- function wordCut(text, room) {
476
- if (visibleWidth(text) <= room)
477
- return text;
478
- const hard = widthCut(text, Math.max(1, room - 1));
479
- const at = hard.lastIndexOf(" ");
480
- return `${at > room / 3 ? hard.slice(0, at) : hard}\u2026`;
481
- }
482
- /** Fold a line's CONTENT at W−2 and prefix EVERY row with the gutter
483
- * (W2: a wrapped tool row keeps its state mark — the left edge alone
484
- * distinguishes the states at --plain; the UserMessage rail precedent,
485
- * v5 #16f). The gutter carries its own SGR (e.g. the bold ✓). W21:
486
- * exported for the approval panel's text args (the same │ gutter). */
487
422
  /**
488
423
  * TUI2-R1.5 ⑨ (VD-10) — the WORD-aware fold, for text a human reads.
489
424
  *
@@ -502,18 +437,6 @@ function wordCut(text, room) {
502
437
  * overflowing row would violate invariant ①, and a word that cannot fit
503
438
  * has to be broken somewhere.
504
439
  */
505
- /** The SGR spans still open at the end of `text`, given those open at
506
- * its start. A reset closes everything; anything else stacks. */
507
- function spansOpenAfter(text, before) {
508
- let open = [...before];
509
- for (const m of text.matchAll(/\x1b\[[0-9;]*m/g)) {
510
- if (m[0] === "\x1b[0m")
511
- open = [];
512
- else
513
- open.push(m[0]);
514
- }
515
- return open;
516
- }
517
440
  export function foldWords(line, W) {
518
441
  if (W < 1)
519
442
  return [line];
@@ -659,25 +582,17 @@ function toolTargetOf(c) {
659
582
  }
660
583
  return toolTarget(c.name, input);
661
584
  }
662
- /** The tool execution line + the bounded block — every state is its
663
- * own render; the lines fold (the summary gives way first). W7 (the
664
- * flow contract): the block's BODY (the rows below the header) is
665
- * capped in SCREEN rows AFTER the fold, at the current width — the
666
- * renderer-cut row (`└ +N … · ctrl+o`) sits INSIDE the cap (a
667
- * truncated block is cap−1 output rows + the cut row); the TOOL-cut
668
- * row (`└ capped by …` — the tool's OWN truncation note, W10) is a
669
- * DIFFERENT fact, never counted in the output cap. W3: the verb is
670
- * stripped of its "_file" suffix and padded to 5 columns — the target
671
- * paths line up (the pipe path strips the same suffix, render.ts —
672
- * both paths print the same verb; a verb ≥ 5 columns is not padded).
673
- * The block's cut note keeps the RAW name (it names the tool the
674
- * model should call again). W4: the settled row's parentheses hold
675
- * the human metadata (settledMeta) — the input summary lived in the
676
- * running row; the OUTCOME is what the settled row says. A4: the
677
- * settled row keeps the TARGET — verb + target + outcome, the running
678
- * row's summary column (the W19 pinned row keeps the full call name
679
- * instead). A5: the verdict rides the head row — a decidedBy present
680
- * on the cell appends `· approved by X` (extension auto-approvals) or
585
+ /** THE TOOL CARD — one per call, every state its own render (R13):
586
+ * pad · head row · blank · the preview, capped at CAP_PREVIEW rows and
587
+ * cut with a note that names the key · blank · the outcome row · pad;
588
+ * a call with nothing to show is the three-row bodiless card. The body
589
+ * is capped in SCREEN rows at the current width; the tool's OWN
590
+ * truncation note (W10, `capped by …`) is a different fact, never
591
+ * counted against the cap. W3: the verb drops its "_file" suffix and
592
+ * pads to 5 columns so the targets line up (the pipe path prints the
593
+ * same verb). A4: the settled head row is verb + target + outcome; the
594
+ * W19 pinned deny keeps the full call name instead. A5: a decidedBy on
595
+ * the cell appends `· approved by X` (an extension's auto-approval) or
681
596
  * `· by X` on the pinned deny; the human decision needs no marker. */
682
597
  class ToolExecution {
683
598
  cell;
@@ -689,10 +604,9 @@ class ToolExecution {
689
604
  const c = this.cell;
690
605
  const verb = escapeTerminal(displayVerb(c.name));
691
606
  const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
692
- // R13 — the W13 rollup row and TUI2-R1 (B)'s exploration row stood
693
- // here, ahead of everything else a settled call could be. Both are
694
- // retired with the `rolled` field they read (see the compositor's
695
- // #foldOrRollup for the reversal in full).
607
+ // DECLARED REVERSAL (R13): the W13 rollup row and TUI2-R1 (B)'s
608
+ // exploration row stood here, ahead of everything else a settled
609
+ // call could be; both retired with the `rolled` field they read.
696
610
  if (c.state === "done") {
697
611
  // R3i phase 5: an answered (or declined) ask_user renders its
698
612
  // OWN block — the questions and what the human said. The row
@@ -771,15 +685,6 @@ class ToolExecution {
771
685
  // a pipe with the colour stripped. A failure keeps its colour
772
686
  // AND its words — see settledMeta.
773
687
  const body = toolBlockParts(c, W, ctx).rows;
774
- if (body.length > 0 && c.expanded) {
775
- // An EXPANDED block is already showing everything, and its own
776
- // footer ("ctrl+o collapses") is what closes it. Giving it an
777
- // outcome row as well would put two closing rows on one block
778
- // and move the metadata off a head row every width gate pins.
779
- // It takes the SURFACE and nothing else.
780
- const head = c.isError ? ` ${p.red}${text}${p.reset}` : ` ${text}`;
781
- return slabBlock(appendSuffix(head, expandSuffix(hidden, W - visibleWidth(head))), body, null, W);
782
- }
783
688
  if (body.length > 0) {
784
689
  // R13 — THE CARD, when there is something to preview: the head
785
690
  // row names the call, the preview sits inside, and the outcome
@@ -804,8 +709,34 @@ class ToolExecution {
804
709
  // what happened and how long it took — is never cut open.
805
710
  const join = (...xs) => xs.filter((x) => x !== "").join(" · ");
806
711
  const attr = approvedBy.replace(/^ · /, "");
807
- const words = pickTier([join(meta, counted, `${elapsed}s`, attr), join(meta, counted, `${elapsed}s`), join(meta, `${elapsed}s`), meta], W - visibleWidth(noteIndent()));
808
- const outcome = c.isError ? `${p.red}${words}${p.reset}` : words;
712
+ // DC-50 / R14: the key is RESERVED (§7.5), so its width comes
713
+ // out of the budget BEFORE the tiers are picked — not
714
+ // appended after, which is what the first build did and
715
+ // which let a narrow row cut the affordance off entirely.
716
+ // The tier ladder is what gives way; the key never is.
717
+ const keySuffix = c.expanded ? ` · ${COLLAPSE_ROW}` : "";
718
+ const words = pickTier([join(meta, counted, `${elapsed}s`, attr), join(meta, counted, `${elapsed}s`), join(meta, `${elapsed}s`), meta], W - visibleWidth(noteIndent()) - keySuffix.length);
719
+ // DC-50 / R14 — ONE CARD, ONE SKELETON, expanded or not.
720
+ //
721
+ // The expanded card used to take a DIFFERENT shape: its head
722
+ // row carried the outcome inline (`shell npm test · exit 0 ·
723
+ // 40 lines · 0.1s`), it had no outcome row at all, and the
724
+ // `ctrl+o collapses` affordance was a row of its own at the
725
+ // end of the body. That was defensible while an expanded card
726
+ // was rare — reachable only for a live or approval-parked
727
+ // cell — and it stopped being defensible the moment ctrl+o
728
+ // became a switch that expands EVERY settled card at once: a
729
+ // page where the same call has two skeletons depending on a
730
+ // global toggle is the instability this round is named for.
731
+ //
732
+ // So both states are pad · head · blank · body · blank ·
733
+ // outcome · pad. The head row says WHAT was run and nothing
734
+ // else; the outcome row says what happened, how much, how
735
+ // long — and carries the affordance, because the affordance
736
+ // is a fact about this card's state and the outcome row is
737
+ // where this card's facts live.
738
+ const withKey = `${words}${keySuffix}`;
739
+ const outcome = c.isError ? `${p.red}${withKey}${p.reset}` : withKey;
809
740
  return slabBlock(head, body, outcome, W);
810
741
  }
811
742
  // R13 — nothing to preview: the SAME card, three rows, with the
@@ -863,7 +794,7 @@ class ToolExecution {
863
794
  // there is no card — the call keeps its head row until it
864
795
  // commits, which is the one form that fits anywhere.
865
796
  const liveRows = ctx.liveWindow ?? CAP_PREVIEW;
866
- const gutter = ctx.grouped === true ? " " : `${breathFrame(ctx.spinnerI)} `;
797
+ const gutter = `${breathFrame(ctx.spinnerI)} `;
867
798
  if (liveRows <= 0) {
868
799
  // the degraded form is the head row ALONE, so it keeps the
869
800
  // duration it would otherwise have lost with its card — cut
@@ -923,8 +854,8 @@ class ToolExecution {
923
854
  * The affordance is a statement about hidden content: a cell whose body
924
855
  * is already whole on screen must not advertise a key that would show it
925
856
  * the same thing, and a cell that already carries its own renderer cut
926
- * (`└ +N earlier rows · ctrl+o`, `└ +N more · ctrl+o`) already teaches
927
- * the key at the place the content stops. What is LEFT — and it is the
857
+ * (the `… N · ctrl+o expands` note) already teaches the key at the
858
+ * place the content stops. What is LEFT — and it is the
928
859
  * common case — is every settled non-shell call, whose collapsed body is
929
860
  * empty: the whole result sits behind the key with nothing on screen
930
861
  * saying so.
@@ -1012,23 +943,13 @@ function settledHeadText(verbCol, target, meta, attr, elapsed, room, counted = "
1012
943
  const core = join(meta, counted, `${elapsed}s`);
1013
944
  const withAttr = join(meta, counted, `${elapsed}s`, attr.replace(" · ", ""));
1014
945
  const lead = `${verbCol} `;
1015
- const fit = (t, tail) => {
1016
- const line = `${lead}${t}${tail === "" ? "" : ` · ${tail}`}`;
1017
- return visibleWidth(line) <= room ? line : null;
1018
- };
1019
- // 1. everything
1020
- const full = fit(target, withAttr);
1021
- if (full !== null)
1022
- return full;
1023
- // 2. the attribution gives way
1024
- const bare = fit(target, core);
1025
- if (bare !== null)
1026
- return bare;
1027
- // 2b. the COUNT gives way next (pin 4), where the suffix is not
1028
- // already carrying it
1029
- const short = fit(target, join(meta, `${elapsed}s`));
1030
- if (short !== null)
1031
- return short;
946
+ const row = (tail) => `${lead}${target}${tail === "" ? "" : ` · ${tail}`}`;
947
+ // 1. everything; 2. the attribution gives way; 2b. the COUNT gives
948
+ // way next (pin 4), where the suffix is not already carrying it —
949
+ // the target whole through all three
950
+ const whole = firstFit([row(withAttr), row(core), row(join(meta, `${elapsed}s`))], room);
951
+ if (whole !== null)
952
+ return whole;
1032
953
  // 3. the target truncates, the core stays whole
1033
954
  const stem = join(meta, `${elapsed}s`);
1034
955
  const budget = room - visibleWidth(lead) - visibleWidth(stem) - 4; // the ellipsis + " · "
@@ -1066,15 +987,12 @@ function attribution(c) {
1066
987
  return "";
1067
988
  return c.verdict.decision === "denied" ? " · denied" : " · approved";
1068
989
  }
1069
- export function expandSuffix(lines, room) {
990
+ /** The expand key as a row's tail, in two tiers; empty when there is
991
+ * nothing hidden. R13 moved the line COUNT off this suffix onto the
992
+ * head row's own `·` chain (VD-6: stated exactly once). */
993
+ function expandSuffix(lines, room) {
1070
994
  if (lines === null)
1071
995
  return "";
1072
- // R13 — the COUNT left this suffix for the head row's own `·` chain,
1073
- // where the bodied card keeps it too (`… · 10 lines · 0.1s`). It was
1074
- // here because the parenthesised core had nowhere to put it and the
1075
- // suffix was the only tail the row had; with one grammar for both
1076
- // cards there is one place, and VD-6's "stated exactly once" is what
1077
- // forbids leaving a copy behind.
1078
996
  for (const tier of [" · ctrl+o expands", " · ctrl+o"]) {
1079
997
  if (tier.length <= room)
1080
998
  return tier;
@@ -1089,186 +1007,43 @@ function appendSuffix(row, suffix) {
1089
1007
  const p = palette();
1090
1008
  return `${row}${p.dim}${suffix}${p.reset}`;
1091
1009
  }
1092
- /**
1093
- * TUI2-R2 ⑤ (D, candidate 1) — the FOCUS tint.
1094
- *
1095
- * The cell the next ctrl+o will act on brightens its own `ctrl+o` token
1096
- * to the code tint; the rest of the suffix — the separator, the count —
1097
- * stays dim, because what is being marked is the KEY's target, not the
1098
- * row. Zero new rows, zero new columns: the affordance the cell already
1099
- * prints is the marker.
1100
- *
1101
- * Applied to a row rather than composed into it on purpose. The token is
1102
- * emitted from several places (the settled suffix, the renderer's own
1103
- * `└ +N … · ctrl+o` cut rows) and threading a flag through all of them
1104
- * would put the invariant "exactly one bright token" in as many hands as
1105
- * there are emitters. Here it has exactly one.
1106
- *
1107
- * NO_COLOR: p.dim is empty, so the row's bytes are untouched.
1108
- */
1109
- export function focusToken(row, W) {
1110
- const p = palette();
1111
- const at = row.lastIndexOf(EXPAND_KEY);
1112
- if (at !== -1) {
1113
- // the row already names the key — brighten the token in place, and
1114
- // leave every other span exactly as it was
1115
- if (p.dim === "")
1116
- return row;
1117
- // DC-3: the marker takes the WASH. It used to take the inline-code
1118
- // tint and inherited its 1.54:1 — the cue naming the key that
1119
- // reveals a cell was itself the least readable thing on a white
1120
- // terminal. The wash is right for a second reason: this token has
1121
- // to be UNIQUE on the frame ("exactly one bright token"), and an
1122
- // attribute like bold is spent everywhere. It closes with washEnd
1123
- // rather than a reset, so the surrounding dim survives instead of
1124
- // having to be re-applied.
1125
- return `${row.slice(0, at)}${p.lift}${EXPAND_KEY}${p.dim}${row.slice(at + EXPAND_KEY.length)}`;
1126
- }
1127
- // A LIVE row does not carry the affordance today, and the live cell is
1128
- // the one ctrl+o takes FIRST (expandNext scans the live tail before
1129
- // the committed ring) — so the row the key is aimed at was the one row
1130
- // that never said the key existed. The affordance IS the marker here:
1131
- // it appears on the focused row and nowhere else, which is why no
1132
- // unfocused row's bytes move (every existing live-row assertion
1133
- // renders a cell with no focus and is untouched).
1134
- const room = W - visibleWidth(row);
1135
- if (room < SUFFIX_MIN)
1136
- return row; // never at the cost of invariant ①
1137
- return `${row}${p.dim} · ${p.lift}${EXPAND_KEY}${p.reset}`;
1138
- }
1139
- /** DC-41 — the label in ONE place. The key has moved once now, and
1140
- * a constant named after its binding is a comment that lies. */
1141
- const EXPAND_KEY = "ctrl+o";
1142
- /** TUI2-R1 (A) — the expanded block's last row: the way back. The
1143
- * rollup's expanded list carries a second clause (its members' full
1144
- * outputs live in /last, which the group row cannot show). */
1010
+ /* DECLARED REVERSAL (D-S2-1, owner-ruled 2026-09-06): `focusToken` and
1011
+ `EXPAND_KEY` stood here — TUI2-R2 ⑤'s bright ctrl+o token on the
1012
+ newest live card, "exactly one bright token per frame". DC-50 made
1013
+ ctrl+o a global switch, so there was no target left for a marker to
1014
+ name; the status row's idle hint names the switch (`idleHint`). */
1015
+ /** TUI2-R1 (A) — the expanded card's key suffix: the way back. */
1145
1016
  const COLLAPSE_ROW = "ctrl+o collapses";
1146
- /** W13 — the rollup opt-in table: which tools collapse, and the count
1147
- * NOUN (read_file calls → "5 files", list_dir → "5 dirs", search_text
1148
- * → "5 matches"). Only these tools opt in — a shell burst is never
1149
- * rolled up (its rows carry meaning). The folded-turn line (W14) reuses
1150
- * the plurals for its other-tool terms ("2 dirs", "1 match"). */
1151
- export const ROLLUP_NOUN = {
1152
- read_file: "files",
1153
- list_dir: "dirs",
1154
- search_text: "matches",
1155
- };
1156
- // ---- TUI2-R1 (B): the exploration rollup ----
1157
- /** TUI2-R1 (B) — the exploration row's nouns. Deliberately NOT
1158
- * ROLLUP_NOUN: that table says what a SINGLE-tool rollup counts
1159
- * ("5 matches"), and this row counts CALLS across tools, where
1160
- * "14 searches" is what happened. Both tables stay — changing the
1161
- * older one would move an assertion this round did not declare. */
1162
- const EXPLORE_NOUN = {
1163
- read_file: ["file", "files"],
1164
- list_dir: ["dir", "dirs"],
1165
- search_text: ["search", "searches"],
1166
- };
1167
- /** TUI2-R1 (B) — the verb column of the expanded list names the ACT.
1168
- * TUI2-R2pre ④: this used to be a private three-tool table saying the
1169
- * same thing as the card head's `_file` strip, in a different way and
1170
- * for a different set of tools. Both are `displayVerb` now — the whole
1171
- * point of the ruling is that there is ONE answer to "what does the
1172
- * screen call this". The cut note, which used to be the deliberate
1173
- * exception here, moved with it (see toolCutNote). */
1174
- /** Whether a tool joins an exploration run. Exactly the read-only set —
1175
- * writes, edits, shells and extension tools never group (a burst of
1176
- * side effects is a list of things that HAPPENED, and every row of it
1177
- * carries meaning). */
1178
- export function isExploreTool(name) {
1179
- return EXPLORE_NOUN[name] !== undefined;
1180
- }
1181
- /** W14 — the folded-turn line: a whole QUIET turn (no text), once it is
1182
- * scrollback, becomes ONE line — the work order's claimed shape
1183
- * (`▞ thought 19s · 5 reads · no edits`), the counts accumulated at
1184
- * toolStart: read_file → "reads", edit_file → "edits", the other tools
1185
- * as first-call-order terms (the ROLLUP_NOUN plurals when the tool opts
1186
- * in, the verb + "s" otherwise).
1187
- * A9 (ruling R2, mock A): the user chip rides the fold — the human's
1188
- * words LEAD the one line, `✦ <chip> · thought 19s · read 5 files` —
1189
- * the chip the SAME SGR-7 bracket as the live user row (#16f, side
1190
- * pads included). The words take the fold's width budget and width-cut
1191
- * at the end with the honest "…" (never a silent truncate — invariant
1192
- * ① holds on the ONE row by construction).
1193
- *
1194
- * DECLARED SUPERSESSION (R3g, 2026-08-28) — A9 also ruled that "the
1195
- * metadata terms give way LAST". They do not any more: the KEY does.
1196
- * A9 was taken when this line carried no key, and at a width where the
1197
- * chip, the full metadata and " · ctrl+o" cannot coexist, a fold with
1198
- * no key is the turn's work behind a line with no way back to it. So
1199
- * the order is now words, then metadata, then — never — the key. The
1200
- * glyph is ✦ and the zero terms are dropped (R3b), so the example above
1201
- * is written as the code renders it rather than as A9 first wrote it. */
1202
- /**
1203
- * R3b — what a run of work DID, in words. One definition, because two
1204
- * surfaces say it: the fold line (`turnFold`, above) and the expand
1205
- * header the compositor writes when that fold is opened. A second copy
1206
- * would be a second answer to the same question, and the first thing to
1207
- * drift would be the plurals — `search_text` is "matches", not
1208
- * "searchs", and only the ROLLUP_NOUN table knows that.
1209
- *
1210
- * Zero terms are dropped (owner ruling, R3b): a term earns its place by
1211
- * having a count.
1212
- */
1213
- /**
1214
- * R3g (2026-08-28) — the fold's own terms, VERB + COUNT + NOUN.
1215
- *
1216
- * DECLARED SUPERSESSION. R3b built these terms out of ROLLUP_NOUN,
1217
- * whose own comment says not to: that table names what a single-tool
1218
- * rollup COUNTS ("5 matches" — five matched lines), and this line
1219
- * counts CALLS. So one search_text call rendered `1 match`, a sentence
1220
- * that is false whenever the search matched any other number — which is
1221
- * almost always. `shell` fell through to the verb branch and read
1222
- * `4 shells`.
1223
- *
1224
- * The phrasing is the owner's, from the shape they asked for:
1225
- * "thought 17s · read 4 files · listed 1 directory · ran 4 shell
1226
- * commands". A tool with no entry says `3 × <verb>`, which counts calls
1227
- * without inventing a noun for them.
1228
- */
1017
+ /* DECLARED REVERSAL (R13, owner-ruled 2026-09-03): the W13 rollup's
1018
+ noun table (`ROLLUP_NOUN`) and TUI2-R1 (B)'s exploration-run set
1019
+ (`EXPLORE_NOUN`, `isExploreTool`) stood here. Both collapses retired
1020
+ with the fold; the verb column is `displayVerb` for every tool. */
1229
1021
  /**
1230
- * R3i — ONE TERM TABLE, TWO TENSES: [past, progressive, singular, plural].
1231
- *
1232
- * The stretch line is the same row at every instant of a turn: while
1233
- * the work runs it says what it is DOING, and at the settle it says
1234
- * what it DID. That sentence is only true if both tenses come from one
1235
- * table. The v9 review found the alternative already happening on a
1236
- * hand-written prototype — `searching 1 pattern` live against `ran 1
1237
- * search` settled, the NOUN swapping at the settle, and `running 4
1238
- * shells`, which is verbatim the R3g defect the previous round removed.
1239
- *
1240
- * FOLD_TERM below is derived from this, so the settled vocabulary
1241
- * cannot drift from the live one by construction.
1022
+ * THE TERM TABLE — [past, singular, plural], one row per tool the recap
1023
+ * names (R3g's phrasing, the owner's: "thought 17s · read 4 files ·
1024
+ * listed 1 directory · ran 4 shell commands"). A tool with no entry
1025
+ * says `3 × <verb>`, which counts calls without inventing a noun for
1026
+ * them; a zero term is dropped (R3b: a term earns its place by having a
1027
+ * count). The terms count CALLS, which is what the CLI hands them.
1028
+ *
1029
+ * DECLARED REVERSAL (R13, owner-ruled 2026-09-03): this table served
1030
+ * the folded-turn line (W14, `turnFold`) and R3i's stretch line, and
1031
+ * carried a PROGRESSIVE column so the live tense and the settled tense
1032
+ * could not drift apart. Nothing folds and nothing reads the
1033
+ * progressive; the recap (`foldTerms`) is the table's only reader.
1034
+ * R3h's object-vs-act distinction (`foldCountsObjects`) retired with
1035
+ * the fold that consumed it.
1242
1036
  */
1243
1037
  const TERM = {
1244
- read_file: ["read", "reading", "file", "files"],
1245
- edit_file: ["edited", "editing", "file", "files"],
1246
- write_file: ["wrote", "writing", "file", "files"],
1247
- list_dir: ["listed", "listing", "directory", "directories"],
1248
- search_text: ["ran", "running", "search", "searches"],
1249
- shell: ["ran", "running", "shell command", "shell commands"],
1038
+ read_file: ["read", "file", "files"],
1039
+ edit_file: ["edited", "file", "files"],
1040
+ write_file: ["wrote", "file", "files"],
1041
+ list_dir: ["listed", "directory", "directories"],
1042
+ search_text: ["ran", "search", "searches"],
1043
+ shell: ["ran", "shell command", "shell commands"],
1250
1044
  };
1251
- const FOLD_TERM = Object.fromEntries(Object.entries(TERM).map(([name, [past, , singular, plural]]) => [name, [past, singular, plural]]));
1252
- /**
1253
- * R3h (fable, 2026-08-29) — WHICH TERMS COUNT OBJECTS.
1254
- *
1255
- * The rule, one sentence: a term that counts OBJECTS counts distinct
1256
- * objects; a term that counts ACTS counts calls. `read 2 files` after
1257
- * reading ONE file twice is a false sentence, and law 1.3 does not
1258
- * become optional because the falsehood is small. `ran 2 searches`
1259
- * after searching the same pattern twice is TRUE — the acts happened.
1260
- *
1261
- * The table sits beside FOLD_TERM so the two cannot drift: a tool whose
1262
- * noun is a thing ("files", "directories") belongs here; a tool whose
1263
- * noun is an act ("searches", "shell commands") does not.
1264
- */
1265
- const FOLD_COUNTS_OBJECTS = new Set(["read_file", "edit_file", "write_file", "list_dir"]);
1266
- /** Does this tool's fold term count distinct targets rather than calls? */
1267
- export function foldCountsObjects(name) {
1268
- return FOLD_COUNTS_OBJECTS.has(name);
1269
- }
1270
1045
  function foldTerm(name, n) {
1271
- const t = FOLD_TERM[name];
1046
+ const t = TERM[name];
1272
1047
  if (t === undefined)
1273
1048
  return `${n} × ${displayVerb(name)}`;
1274
1049
  return `${t[0]} ${n} ${n === 1 ? t[1] : t[2]}`;
@@ -1286,41 +1061,6 @@ export function foldTerms(reads, edits, others) {
1286
1061
  }
1287
1062
  return parts;
1288
1063
  }
1289
- /**
1290
- * R3i — THE STRETCH LINE: the turn's one working row, in three phases.
1291
- *
1292
- * A STRETCH is the run of thinking and tool calls between two blocks of
1293
- * the model's prose. While it runs it is this line plus a bounded act
1294
- * window; when it closes it commits as this same line, frozen, with its
1295
- * key. The contract in one sentence: **the line you watch is the line
1296
- * you keep** — the settle changes the mark, the tense and the key, and
1297
- * nothing else.
1298
- *
1299
- * thinking ✧ thinking 4s
1300
- * acting ✶ reading 6 files · running 4 shell commands
1301
- * settled ✦ thought 9s · read 6 files · ran 4 shell commands · ctrl+o
1302
- *
1303
- * THE GIVE-WAY LADDER, in order, because at some width everything
1304
- * cannot fit:
1305
- *
1306
- * 1. the human's WORDS (the A9 chip on a quiet turn) — they are on
1307
- * screen above, in the chip band;
1308
- * 2. the NOUNS compact, cheapest word first, and stop as soon as the
1309
- * row fits — buying one cell must not spend every substitution;
1310
- * 3. the COUNTS cut, with the honest "…";
1311
- * 4. the TROUBLE CLAUSE cuts. The design first said it never gives
1312
- * way, and that was unimplementable: a long clause overflows after
1313
- * the counts have already cut to a bare "…", and invariant ①
1314
- * throws on that row;
1315
- * 5. the KEY gives way NEVER. A fold with no key is the turn's work
1316
- * behind a line with no way back to it, which is the one thing
1317
- * this row must not be.
1318
- *
1319
- * `…` in this file means CUT HERE and nothing else — which is why the
1320
- * live phases carry no trailing ellipsis for in-flight, though the
1321
- * reference implementation uses one. The moving mark and the present
1322
- * tense already say it twice.
1323
- */
1324
1064
  /**
1325
1065
  * R3i phase 5 — THE ANSWERED QUESTION'S BLOCK.
1326
1066
  *
@@ -1338,7 +1078,7 @@ export function foldTerms(reads, edits, others) {
1338
1078
  * is emphasis, never information). A typed answer says `(typed)`,
1339
1079
  * because where an answer came from is a fact about it.
1340
1080
  *
1341
- * It is WORDS, not work (law 1.7): it never folds into a stretch line,
1081
+ * It is WORDS, not work (law 1.7): no summary ever stands for it,
1342
1082
  * because the one thing a summary must not do is speak for the human.
1343
1083
  *
1344
1084
  * A result that is not the ask's own JSON yields NOTHING. This renderer
@@ -1376,45 +1116,11 @@ export function askedBlock(resultText, seconds, W) {
1376
1116
  }),
1377
1117
  ];
1378
1118
  }
1379
- const STRETCH_COMPACT = [
1380
- ["directories", "dirs"],
1381
- ["directory", "dir"],
1382
- ["shell commands", "commands"],
1383
- ["shell command", "command"],
1384
- ];
1385
- /** R3i — a stretch of exactly ONE call names its TARGET instead of its
1386
- * count. `thought 2s · read 1 file` replaces two rows — the thinking
1387
- * and the call — with a row that says less than either of them did,
1388
- * and "thinking plus one call" is the commonest shape a narrating
1389
- * model makes. This is the answer to the defect R3d killed R3b's
1390
- * per-segment folds over; the "absorbs at least two rows" rule alone
1391
- * does not answer it. */
1392
- function stretchTerms(t, live) {
1393
- // R4 — the tense is per TERM. A name with nothing in flight is in the
1394
- // past whatever the line's own phase is; with liveNames absent (the
1395
- // settled line) every term follows the line.
1396
- const tense = (name) => (live && (t.liveNames === undefined || t.liveNames.includes(name)) ? 1 : 0);
1397
- const total = t.calls.reduce((n, [, c]) => n + c, 0);
1398
- // R4 — the one-call TARGET form is the SETTLED line's. Live, the act
1399
- // slot directly below already names the target on its head row, so
1400
- // the line was printing the same words twice, one above the other
1401
- // (`running npm run check` over `shell npm run check`). Settled there
1402
- // is no slot, and naming the target is strictly more than counting to
1403
- // one — which is the R3i rule this keeps, where it applies.
1404
- if (total === 1 && t.targets.length === 1 && !live) {
1405
- const [name] = t.calls[0];
1406
- const e = TERM[name];
1407
- return [`${e === undefined ? name : e[0]} ${t.targets[0]}`];
1408
- }
1409
- return t.calls
1410
- .filter(([, n]) => n > 0)
1411
- .map(([name, n]) => {
1412
- const e = TERM[name];
1413
- if (e === undefined)
1414
- return `${n} × ${displayVerb(name)}`;
1415
- return `${e[tense(name)]} ${n} ${n === 1 ? e[2] : e[3]}`;
1416
- });
1417
- }
1119
+ /* DECLARED REVERSAL (R13, owner-ruled 2026-09-03): the stretch line's
1120
+ term machinery stood here — `StretchTerms`, `STRETCH_COMPACT`,
1121
+ `stretchTerms` (R3i's one-line-per-stretch, R4's per-term tense).
1122
+ The line retired with the fold; the TERM table above survives for the
1123
+ turn's recap alone. */
1418
1124
  // ---- the bounded-block flow contract (W7, W8, W10) ----
1419
1125
  /** The caps — screen rows counted AFTER the fold, at the current width
1420
1126
  * (the W7 table). The renderer-cut row is inside the cap. */
@@ -1424,14 +1130,7 @@ export const CAP_PREVIEW = 5;
1424
1130
  /** DC-46 — the running window's ceiling is the SETTLED preview's, and a
1425
1131
  * running card reaches it by growing rather than by being handed it.
1426
1132
  * `LIVE_WINDOW` (CAP_PREVIEW + 1) retires with the allocation it sized. */
1427
- /** The rows a card costs besides its window: two pads, the head, two
1428
- * blanks and the status row. Below this there is no card (DC-43). */
1429
- export const CARD_CHROME = 6;
1430
1133
  const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
1431
- /** The block body rows' prefixes (W2's gutter table): │ a bounded
1432
- * block's body, └ the block's last row — what was cut, where the rest
1433
- * is — at the LEFT EDGE (the gutter column: the left edge alone
1434
- * distinguishes the states at --plain). Structural (constraint 1). */
1435
1134
  /** R8a — A TOOL BLOCK'S ROWS ARE INDENTED, NOT GUTTERED.
1436
1135
  *
1437
1136
  * `│ ` on every row drew a bar down the left of every multi-row
@@ -1445,8 +1144,7 @@ const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
1445
1144
  * row (see openBlock). In-block notes take the same indent,
1446
1145
  * no glyph — because a second `└` inside one block would be the same
1447
1146
  * mark meaning two things (§4.1). CUT_ROW is unchanged for the
1448
- * surfaces that are not a tool block: the fold row's target list, the
1449
- * slot's overflow count. */
1147
+ * surfaces that are not a tool block. */
1450
1148
  /** R8a's four columns — off the surface. Inside a painted card every
1451
1149
  * row sits at column 2 (R13 E4): the head row and the outcome row
1452
1150
  * bracket the preview, so the indent is no longer what says "these
@@ -1516,10 +1214,18 @@ function slabRow(inner, W) {
1516
1214
  * metadata rows: the parts give way in a PINNED ORDER, and the part
1517
1215
  * that carries the semantics is the one reserved. */
1518
1216
  function pickTier(tiers, room) {
1217
+ return firstFit(tiers, room) ?? tiers[tiers.length - 1];
1218
+ }
1219
+ /** The widest form that fits the row, or null when none does — the
1220
+ * settled head row asks this with its target whole, and only then
1221
+ * cuts the target. The three rows that give way in a pinned order
1222
+ * (the settled head, the running head, the outcome row) share this
1223
+ * walk; their ORDERS differ by ruling and stay their own. */
1224
+ function firstFit(tiers, room) {
1519
1225
  for (const t of tiers)
1520
1226
  if (visibleWidth(t) <= room)
1521
1227
  return t;
1522
- return tiers[tiers.length - 1];
1228
+ return null;
1523
1229
  }
1524
1230
  function noteRow(text, W, tone) {
1525
1231
  const p = palette();
@@ -1570,30 +1276,20 @@ function slabBlock(head, body, outcome, W) {
1570
1276
  return [...top, slabRow("", W)];
1571
1277
  return [...top, slabRow("", W), ...noteRow(outcome, W, "body").map((r) => slabRow(r, W)), slabRow("", W)];
1572
1278
  }
1573
- /**
1574
- * 0.24.2 ③ — the appended expansion, as a CARD.
1575
- *
1576
- * `ctrl+o` used to append bare ground under a `✦` — the turn recap's own
1577
- * mark, one symbol for two meanings (§4.1) — in a page where every other
1578
- * piece of machine work is a card. And it lands after the recap, so the
1579
- * only tie to the call it came from was that mark's sentence.
1279
+ /*
1280
+ * RETIRED (DC-50 / R14, 2026-09-05) — `expandedCard`.
1580
1281
  *
1581
- * The card's head row names the call, which is the tie, so the mark is
1582
- * not needed for it. The body is the WHOLE result: an expansion that
1583
- * capped would be no expansion.
1282
+ * It drew the APPENDED block the old ctrl+o produced: a card printed far
1283
+ * below the call it belonged to, which is why its head row had to carry
1284
+ * `expanded · N turns back` and why its body carried section headers
1285
+ * around the raw input and output. All of that was addressing — a way
1286
+ * for a copy to say which original it was a copy of.
1584
1287
  *
1585
- * Expanding IN PLACE is a different problem — committed rows are final
1586
- * (§7.1) — and it waits for route B (DC-50).
1288
+ * DC-50 removes the copy. An expanded card is the ordinary `toolCard`
1289
+ * with `expanded` set, rendered where the call stands, so nothing needs
1290
+ * to say where it came from. Its shape is gated in
1291
+ * `r13-rhythm-surface.test.ts`.
1587
1292
  */
1588
- export function expandedCard(verb, target, meta, sections, outcome, W) {
1589
- const p = palette();
1590
- const head = cutLine(` ${verb} ${p.bold}${escapeTerminal(target)}${p.reset}${slabPaints() ? p.washDim : p.dim} · ${escapeTerminal(meta)}${slabPaints() ? p.washDimEnd : p.reset}`, W);
1591
- const body = [];
1592
- for (const raw of sections)
1593
- for (const row of blockRows(raw, W, slabPaints() ? "body" : "dim"))
1594
- body.push(row);
1595
- return slabBlock(head, body, outcome, W);
1596
- }
1597
1293
  /** 0.24.2 ② — the live region's `thinking…` placeholder: dim italic at
1598
1294
  * column 2, no glyph, the SAME shape a thinking paragraph takes so that
1599
1295
  * whatever arrives replaces it in place. Never committed — see the
@@ -1687,7 +1383,13 @@ function toolBlockParts(c, W, ctx) {
1687
1383
  // rides a block that HAS rows — an expanded delegate whose summary
1688
1384
  // marker is missing renders nothing, and a lone footer under a head
1689
1385
  // row would be an affordance for an empty block.
1690
- if (c.expanded && rows.length > 0)
1386
+ //
1387
+ // DC-50 / R14: a SETTLED card carries the affordance on its OUTCOME
1388
+ // row instead, so that both states have one skeleton. This footer is
1389
+ // for the states that have no outcome row to carry it — a card parked
1390
+ // for approval, or one still running that was expanded before it
1391
+ // settled.
1392
+ if (c.expanded && rows.length > 0 && c.state !== "done")
1691
1393
  rows.push(...foldLine(`${p.dim}${noteIndent()}${COLLAPSE_ROW}${p.reset}`, W));
1692
1394
  // R9 P2: `└` opens a block that has no surface. Inside a slab the
1693
1395
  // surface IS the container, and a corner in it is §1.3's empty mark
@@ -1861,91 +1563,12 @@ function liveCap(c, room) {
1861
1563
  liveHighWater.set(c, cap);
1862
1564
  return cap;
1863
1565
  }
1864
- /**
1865
- * R4 — the standing act slot.
1866
- *
1867
- * The stretch's ONE line sits above it; this is the region under it,
1868
- * and it STANDS: allocated when the stretch opens, released at the
1869
- * fold.
1870
- *
1871
- * R3i built the same window INTERMITTENTLY — a running call got its
1872
- * fixed 1+3 block (W8), a finished one got nothing — so the live
1873
- * region's height was a function of how many calls happened to be in
1874
- * flight this frame. Over one real stretch that is 2 rows, then 7,
1875
- * then 2, then 17 for a three-call batch, then 2 again, and every
1876
- * transition scrolls everything above it. The owner's report was that
1877
- * the screen "keeps jumping", and it was an accurate description of
1878
- * the design, not a defect in its execution.
1879
- *
1880
- * The cure is not a smaller window, it is a STANDING one: between two
1881
- * calls the slot keeps the call that just finished rather than
1882
- * collapsing, and before any call it keeps the thinking that is
1883
- * producing them — which is R3i ruling 5 ("thinking belongs on the
1884
- * stretch line, IN THE ACT WINDOW, and in full in expansions") finally
1885
- * wired, since R3i stated it while building no window for the thinking
1886
- * phase to live in.
1887
- *
1888
- * Four rows, deliberately the same 1+3 shape W8 gave a running call, so
1889
- * the commonest frame — exactly one call in flight — renders byte-for-
1890
- * byte what 0.17.0 shipped.
1891
- */
1892
- export const ACT_SLOT_ROWS = 4;
1893
- /**
1894
- * R4 — the slot's body rows: the tail of `text`, newest at the BOTTOM,
1895
- * bottom-padded to exactly `rows`.
1896
- *
1897
- * The same dim │ gutter a running call's window uses (W2's table), and
1898
- * the same two VD-4 rules: leading blank gutters are skipped, and the
1899
- * short-output pad goes at the BOTTOM so output starts under its own
1900
- * header and grows downward. The slot's CONTENTS change; its shape
1901
- * does not.
1902
- */
1903
- export function slotTail(text, W, rows) {
1904
- if (rows <= 0)
1905
- return [];
1906
- const p = palette();
1907
- const all = blockRows(text, W);
1908
- const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(bodyRow()));
1909
- const body = from < 0 ? [] : all.slice(from);
1910
- // R7a: no pad. The slot stopped padding (see slotPad) and this was
1911
- // the same pad by another route — three blank rows under a call with
1912
- // nothing to say yet, which is the hole a7's blank-run guard prices.
1913
- // R8a: the corner opens whatever slice survives the cap.
1914
- return openBlock(body.slice(Math.max(0, body.length - rows)));
1915
- }
1916
- /** R4 — clamp or pad assembled slot rows to EXACTLY `rows`. The padding
1917
- * is what makes the slot stand; the clamp is what keeps the slot from
1918
- * ever being the thing that trips the force-commit cap (a slot that
1919
- * could overflow would commit real cells to relieve blank rows). */
1920
- export function slotPad(content, rows) {
1921
- if (rows <= 0)
1922
- return [];
1923
- // R7a — THE SLOT NO LONGER PADS. It caps, and that is all.
1924
- //
1925
- // R4 padded to a fixed height because the slot's content came and
1926
- // went: a finished call left the block, the block shrank, and every
1927
- // row above it moved. The pad bought stability with rows drawn as
1928
- // `│`, which is why a tall empty gutter ran down the screen under
1929
- // every short block — the owner's own screenshot, and law 1.3's
1930
- // case: a mark on a row with nothing to mark.
1931
- //
1932
- // Blanking the gutter revealed the hole it had been covering, and
1933
- // the a7 replay priced the hole: blank runs over 2 in 653 of 733
1934
- // frames, the screen never durably filling. So the pad had to go —
1935
- // and the height it was buying is now bought by the CONTENT, since
1936
- // R7a keeps every call's row for the life of the stretch. A block
1937
- // whose rows only accumulate cannot shrink, so there is nothing
1938
- // left for a pad to hold up. Measured: 65 of 733 at 40x24, the
1939
- // pre-R7a number exactly, with the motion gates still green.
1940
- return content.slice(0, rows);
1941
- }
1942
- /** R4 — the slot's overflow row: the calls in flight beyond the head
1943
- * budget. It lives INSIDE the slot (it is one of the four rows), which
1944
- * is what keeps a parallel burst from growing the region. */
1945
- export function moreRunningRow(n, W) {
1946
- const p = palette();
1947
- return cutLine(` ${p.dim}${CUT_ROW}+${n} more running${p.reset}`, W);
1948
- }
1566
+ /* DECLARED REVERSAL (R13, owner-ruled 2026-09-03): R4's standing act
1567
+ slot stood here — `ACT_SLOT_ROWS`, `slotTail`, `slotPad`,
1568
+ `moreRunningRow` — a fixed four-row region that held the stretch's
1569
+ running call so the live region's height would not follow the call
1570
+ count. Every call is its own card now, allocated at its own height
1571
+ (E2, DC-46), so there is no slot for a call to occupy. */
1949
1572
  /** W12: the delegate's child sessions collapse to the tool row plus ONE
1950
1573
  * line — the height NEVER changes (running → settled replaces the row
1951
1574
  * in place). The running row derives from the INPUT: the parent has no
@@ -2146,45 +1769,6 @@ class Banner {
2146
1769
  * done-collapse. Every live row CUTS at W (never folds) — the block's
2147
1770
  * height is its row count. */
2148
1771
  export const CAP_TASK_LIVE = 6;
2149
- /** W20 — the live block's fixed-window row cut: an SGR-aware ONE-ROW
2150
- * truncation (foldLine wraps; a wrapped row would break the height
2151
- * cap — every live row is exactly one screen row at every width).
2152
- * A line that fits (≤ W) passes through whole; an overflow cuts the
2153
- * content at W−1 — the ellipsis's slot — and the ellipsis rides AFTER
2154
- * the reset (post-reset — the PTY needles' convention). The cut row
2155
- * never exceeds W (invariant ①). W21: exported for the approval
2156
- * panel's single-row lines (the rule line, the title, the divider,
2157
- * the options/affordance rows). */
2158
- export function cutLine(line, W) {
2159
- if (visibleWidth(line) <= W)
2160
- return line;
2161
- let out = "";
2162
- let width = 0;
2163
- for (let i = 0; i < line.length;) {
2164
- if (line[i] === "\x1b") {
2165
- // exec returns an ARRAY — copying m coerces it (the match), but
2166
- // m.length is the CAPTURE count (1), not the sequence length:
2167
- // the old `i += m.length` re-processed the sequence's bracket
2168
- // text as literal rows, doubling every code in a cut line
2169
- // (the W21 panel-slot red test). Index 0 is the sequence.
2170
- const m = /^\x1b\[[0-9;]*m/.exec(line.slice(i))?.[0] ?? line[i];
2171
- out += m;
2172
- i += m.length;
2173
- continue;
2174
- }
2175
- const cw = displayWidth(line[i]);
2176
- if (width + cw > W - 1)
2177
- break; // reserve the ellipsis's column
2178
- out += line[i];
2179
- width += cw;
2180
- i += 1;
2181
- }
2182
- // R8a: the reset comes from the PALETTE, not hardcoded. `\x1b[0m`
2183
- // here put an escape into every cut row under NO_COLOR and behind a
2184
- // pipe — the one context COLOR_OFF exists to keep clean (§1.2). A
2185
- // coloured palette is byte-identical, because its reset IS `\x1b[0m`.
2186
- return `${out}${palette().reset}…`;
2187
- }
2188
1772
  /** W20 — the settled block's duration, the `2h 14m` form (the task
2189
1773
  * narrative's long-horizon idiom): minutes+seconds under an hour,
2190
1774
  * hours+minutes past it. */
@@ -2259,7 +1843,7 @@ class Checklist {
2259
1843
  return [cutLine(header, W), ...itemRows.map((r) => cutLine(r, W))];
2260
1844
  }
2261
1845
  }
2262
- // ---- the chrome components (the status container, the slot, the footer) ----
1846
+ // ---- the chrome components (the status container, the footer) ----
2263
1847
  /** The status container's row: the status text (+ the tail) with the
2264
1848
  * right-aligned "/ commands · ↑ history" hint in the idle state —
2265
1849
  * the hint CUT FIRST when the width is short (the #16g rule); when
@@ -2282,20 +1866,28 @@ class Checklist {
2282
1866
  * survives longest because it is the door to everything; `ctrl+r`
2283
1867
  * outranks `↑ history` because pressing up is how a person finds the
2284
1868
  * history by accident, and nothing finds ctrl+r by accident. */
2285
- export function idleHint(room) {
1869
+ export function idleHint(room, expand = null) {
2286
1870
  // The third rung is today's hint, kept so that NO width loses
2287
1871
  // something that used to fit: without it, a room of 24-30 columns
2288
1872
  // fell all the way to `/ commands` even though the old form fitted.
2289
1873
  // So the ladder is not a strict ranking of the three affordances —
2290
1874
  // it is the widest honest form at each room, and ctrl+r is on the
2291
1875
  // first two rungs rather than on all of them.
2292
- for (const form of [" / commands · ↑ history · ctrl+r transcript", " / commands · ctrl+r transcript", " / commands · ↑ history", " / commands"]) {
1876
+ //
1877
+ // D-S2-1 (owner-ruled 2026-09-06): the ctrl+o SWITCH is named on the
1878
+ // two widest rungs, beside ctrl+r, and only while a card on screen
1879
+ // has something behind the key — the caller passes null otherwise,
1880
+ // and the ladder is then exactly the one above. It replaced the
1881
+ // per-card bright token (TUI2-R2 ⑤): a global switch has no single
1882
+ // target to mark.
1883
+ const switchRungs = expand === null ? [] : [` / commands · ↑ history · ctrl+o ${expand} · ctrl+r transcript`, ` / commands · ctrl+o ${expand} · ctrl+r transcript`];
1884
+ for (const form of [...switchRungs, " / commands · ↑ history · ctrl+r transcript", " / commands · ctrl+r transcript", " / commands · ↑ history", " / commands"]) {
2293
1885
  if (visibleWidth(form) <= room)
2294
1886
  return form;
2295
1887
  }
2296
1888
  return "";
2297
1889
  }
2298
- export function statusLine(status, tail, W, hint) {
1890
+ export function statusLine(status, tail, W, hint, expand = null) {
2299
1891
  const p = palette();
2300
1892
  const text = `${status}${tail === "" ? "" : ` · ${tail}`}`;
2301
1893
  // W18: the hint is a parameter — the compacting row right-aligns its
@@ -2306,25 +1898,12 @@ export function statusLine(status, tail, W, hint) {
2306
1898
  if (statusW > W) {
2307
1899
  return `${p.dim}${widthCut(text, W - 1)}…${p.reset}`;
2308
1900
  }
2309
- const hintText = hint ?? idleHint(Math.max(0, W - statusW));
1901
+ const hintText = hint ?? idleHint(Math.max(0, W - statusW), expand);
2310
1902
  const hintW = visibleWidth(hintText);
2311
1903
  if (hintW === 0 || statusW + hintW > W)
2312
1904
  return `${p.dim}${text}${p.reset}`;
2313
1905
  return `${p.dim}${text}${" ".repeat(Math.max(0, W - statusW - hintW))}${hintText}${p.reset}`;
2314
1906
  }
2315
- /** The display-width prefix of a plain (SGR-free) text. W21: exported
2316
- * for the approval panel's option-2 rule-name cut. */
2317
- export function widthCut(text, max) {
2318
- let w = 0;
2319
- let i = 0;
2320
- for (; i < text.length; i += 1) {
2321
- const cw = displayWidth(text[i]);
2322
- if (w + cw > max)
2323
- break;
2324
- w += cw;
2325
- }
2326
- return text.slice(0, i);
2327
- }
2328
1907
  /**
2329
1908
  * TUI2-R3v2 ① — THE selection bar. One engine, every selection surface.
2330
1909
  *