@vincemakes/kiso-tui-cells 0.22.0 → 0.24.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.
@@ -118,9 +118,20 @@ export { visibleWidth } from "./width.js";
118
118
  export function bodySpacing(prev, rows) {
119
119
  if (rows.length === 0 || prev === null || prev.length === 0)
120
120
  return rows;
121
- if (rows.length > 1 || prev.length > 1)
122
- return ["", ...rows];
123
- return rows;
121
+ // R13 D1 — ONE blank between any two elements, whatever their height.
122
+ //
123
+ // W11 spaced by height: one-row siblings packed tight, anything
124
+ // multi-row breathed on both sides. A reader could not tell where the
125
+ // next blank would fall — and worse, the spacing was a function of a
126
+ // cell's CURRENT height, so a cell growing from one row to five moved
127
+ // everything around it. That is the mechanism behind R7a and behind
128
+ // R12 Round 2's settle shift, both of them "the screen moved under
129
+ // the reader".
130
+ //
131
+ // A constant cannot do that: a live block, its settled form and the
132
+ // card it becomes are spaced identically BY CONSTRUCTION, which is
133
+ // exactly what R7a's one-row stand-in was simulating.
134
+ return ["", ...rows];
124
135
  }
125
136
  /** The container — vertical concatenation with the W11 formula. No
126
137
  * component decides its own spacing: every blank in the body is the
@@ -212,6 +223,9 @@ export function cellComponent(cell) {
212
223
  * the same rule as three thousand short ones.
213
224
  */
214
225
  const USER_CHIP_ROWS = 12;
226
+ /** R13 D4 — the chip's inner pad: two columns, so its text begins in
227
+ * the same column as everything else on the page. */
228
+ const CHIP_PAD = " ";
215
229
  class UserMessage {
216
230
  cell;
217
231
  constructor(cell) {
@@ -219,7 +233,12 @@ class UserMessage {
219
233
  }
220
234
  render(W, _ctx) {
221
235
  const p = palette();
222
- const chipW = Math.max(1, W - 2);
236
+ // R13 D4 — TWO columns of inner pad, where R2 had one. The chip
237
+ // keeps its surface (reverse video, one row per folded line, no
238
+ // pad rows — R12 Round 2's ruling stands); what changes is where
239
+ // its text STARTS, so the human's words begin in the same column
240
+ // as the model's (E3) and as a card's rows (E4).
241
+ const chipW = Math.max(1, W - 2 * CHIP_PAD.length);
223
242
  const rows = [];
224
243
  // REL-0152-D13: fold only as far as the bound needs. A pasted file
225
244
  // has thousands of lines and folding all of them to show twelve is
@@ -264,7 +283,7 @@ class UserMessage {
264
283
  // row is two cells per character and pads by cells.
265
284
  const inner = chipW;
266
285
  for (const row of content) {
267
- rows.push(`${p.rv} ${row}${" ".repeat(Math.max(0, inner - displayWidth(row)))} ${p.rvEnd}`);
286
+ rows.push(`${p.rv}${CHIP_PAD}${row}${" ".repeat(Math.max(0, inner - displayWidth(row)))}${CHIP_PAD}${p.rvEnd}`);
268
287
  }
269
288
  if (!truncated)
270
289
  return rows;
@@ -276,7 +295,16 @@ class UserMessage {
276
295
  const shown = rows.length;
277
296
  const total = paras.length;
278
297
  const more = Math.max(0, total - shown);
279
- rows.push(`${p.dim}\u2514 ${more > 0 ? `+${more} more line${more === 1 ? "" : "s"}` : "cut here"} \u00b7 sent in full${p.reset}`);
298
+ // DC-45: THE NOTICE FOLDS TOO. It was written at a fixed 30 columns
299
+ // and emitted verbatim at every width, so a paste of thirteen lines
300
+ // in a terminal narrower than the sentence tripped the compositor's
301
+ // invariant ① and killed the session. The tiers are TUI2-R1.5 ⑤'s
302
+ // discipline: `sent in full` is the SEMANTICS — the whole reason
303
+ // the row exists — so the count gives way before it, and `cutLine`
304
+ // is the backstop that holds at any width there is.
305
+ const count = more > 0 ? `+${more} more line${more === 1 ? "" : "s"}` : "cut here";
306
+ const short = more > 0 ? `+${more}` : "cut";
307
+ rows.push(cutLine(`${p.dim}\u2514 ${pickTier([`${count} \u00b7 sent in full`, `${short} \u00b7 sent in full`, "sent in full", count], Math.max(1, W - 2))}${p.reset}`, W));
280
308
  return rows;
281
309
  }
282
310
  }
@@ -338,7 +366,27 @@ class ThinkingBlock {
338
366
  const text = escapeTerminal(this.cell.text).trim();
339
367
  if (text === "")
340
368
  return [];
341
- const room = Math.max(1, W - 2);
369
+ // DC-47 — THINKING GOES ONE LEVEL DEEPER THAN PROSE, and the
370
+ // reason is a law rather than a taste.
371
+ //
372
+ // §7.2: the indent is the price of §1.2 — italic and dim are
373
+ // escape sequences, so a rendered frame with its colour stripped
374
+ // (a terminal capture, a paste out of the scrollback, a log of
375
+ // what was drawn) would lose the line between the model's
376
+ // reasoning and its answer. R13's E3 moved PROSE to column 2,
377
+ // which is where the thinking already was, so after
378
+ // `sed 's/\x1b\[[0-9;]*m//g'` the two became the same row.
379
+ // Measured, not reasoned: both rendered
380
+ // `" Weighing the two shapes."` exactly.
381
+ //
382
+ // NOT a pipe, though §7.2 used to say so: `thinkingEnd`'s inactive
383
+ // path writes `foldThinking` — one dim line — so a pipe never sees
384
+ // a thinking paragraph to confuse with prose.
385
+ //
386
+ // So the thinking takes the next column in. It is still the only
387
+ // carrier that survives a pipe, and it is still one indent step —
388
+ // what moved is which step, because prose took the one it had.
389
+ const room = Math.max(1, W - THINK_COL.length);
342
390
  const rows = [];
343
391
  for (const para of text.split(/\n\s*\n/)) {
344
392
  const flat = para.replace(/\s+/g, " ").trim();
@@ -350,7 +398,7 @@ class ThinkingBlock {
350
398
  // invariant ①b (a row is one physical row) holds by
351
399
  // construction rather than by remembering to split.
352
400
  for (const line of foldLine(flat, room))
353
- rows.push(` ${p.dim}${p.italic}${line}${p.italicEnd}${p.reset}`);
401
+ rows.push(`${THINK_COL}${p.dim}${p.italic}${line}${p.italicEnd}${p.reset}`);
354
402
  }
355
403
  return rows;
356
404
  }
@@ -633,39 +681,10 @@ class ToolExecution {
633
681
  const c = this.cell;
634
682
  const verb = escapeTerminal(displayVerb(c.name));
635
683
  const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
636
- const parts = c.rolled?.parts;
637
- if (c.rolled !== null && parts !== undefined) {
638
- // TUI2-R1 (B) — the exploration row: a run that spans more than
639
- // one read-only tool. The counts are BOLD (what the reader is
640
- // being told), the timing and the affordance dim — the
641
- // prototype's placement. The affordance names what the key
642
- // SHOWS here ("lists them"), because a group row's expand is a
643
- // list of calls, not a body of output.
644
- const r = c.rolled;
645
- const counts = exploreCounts(parts);
646
- const head = ` explored ${p.bold}${counts}${p.reset}`; // R2: no tick
647
- const tail = ` (${r.elapsed}s)`;
648
- const room = W - visibleWidth(head) - tail.length;
649
- const affordance = " · ctrl+o lists them";
650
- return [cutLine(`${head}${p.dim}${tail}${affordance.length <= room ? affordance : ""}${p.reset}`, W)];
651
- }
652
- if (c.rolled !== null) {
653
- // W13 — the rolled-up group's ONE row + the target children:
654
- // the work order's claimed shape, verbatim — the verbCol's
655
- // 5-char pad reproduces the "read 5 files" double space, the
656
- // children are the first 3 basename targets, the overflow row
657
- // carries the ctrl+o affordance (its "└ … ctrl+o" joins the
658
- // W15 expand history — the head's commit captures it).
659
- const r = c.rolled;
660
- const noun = ROLLUP_NOUN[c.name] ?? "calls";
661
- const out = gutterCut(" ", `${verbCol} ${r.count} ${noun} (${kUnit(r.lines)} lines, ${r.elapsed}s)`, W); // R2: no tick
662
- const shown = r.targets.slice(0, 3);
663
- if (shown.length > 0)
664
- out.push(` ${p.dim}${CUT_ROW}${escapeTerminal(shown.join(" · "))}${p.reset}`);
665
- if (r.targets.length > 3)
666
- out.push(` ${p.dim}${CUT_ROW}+${r.targets.length - 3} more — ctrl+o expands${p.reset}`);
667
- return out;
668
- }
684
+ // R13 — the W13 rollup row and TUI2-R1 (B)'s exploration row stood
685
+ // here, ahead of everything else a settled call could be. Both are
686
+ // retired with the `rolled` field they read (see the compositor's
687
+ // #foldOrRollup for the reversal in full).
669
688
  if (c.state === "done") {
670
689
  // R3i phase 5: an answered (or declined) ask_user renders its
671
690
  // OWN block — the questions and what the human said. The row
@@ -689,7 +708,7 @@ class ToolExecution {
689
708
  if (c.reason !== null) {
690
709
  const by = attribution(c);
691
710
  const out = gutterCut(" ", `${p.red}${escapeTerminal(`${c.name} ${toolTargetOf(c)}`)} (${escapeTerminal(c.reason)}${by})${p.reset}`, W);
692
- out.push(...toolBlockBody(c, W));
711
+ out.push(...toolBlockBody(c, W, ctx));
693
712
  return out;
694
713
  }
695
714
  const elapsed = c.startedAt !== null && c.doneAt !== null ? ((c.doneAt - c.startedAt) / 1000).toFixed(1) : "?";
@@ -703,8 +722,17 @@ class ToolExecution {
703
722
  // "200 of 250 lines", a diff's "+1 -1", a shell's "exit 0" — is a
704
723
  // different fact and stays.
705
724
  const rawMeta = settledMeta(c);
706
- const dup = hiddenLines(c, W) !== null && new RegExp(`^${hiddenLines(c, W)} lines?$`).test(rawMeta);
707
- const meta = dup ? "" : escapeTerminal(rawMeta);
725
+ // VD-6's suppression RETIRED with the thing it was suppressing.
726
+ // A read card said `(2 lines, 0.0s) · 2 lines · ctrl+o expands`
727
+ // because the parens and the suffix were written by different
728
+ // rounds, each unaware the other was counting; the fix blanked
729
+ // the meta whenever the suffix would repeat it. R13 takes the
730
+ // count OUT of the suffix, so there is one place again and the
731
+ // meta is it — blanking it now would drop the fact entirely.
732
+ // "Stated exactly once" is unchanged and is what `countedHead`
733
+ // below preserves: a meta that already counts lines gets no
734
+ // second count beside it.
735
+ const meta = escapeTerminal(rawMeta);
708
736
  // A4: the target rides the settled head row — the verb's
709
737
  // summary column (W3's 5-char pad keeps the paths lined up).
710
738
  // A5: an extension's auto-approval appends `· approved by
@@ -724,7 +752,9 @@ class ToolExecution {
724
752
  // TUI2-R1.5 ⑤: the shortest tier is RESERVED — the affordance is
725
753
  // the semantics. TUI2-R1.5 pin 4: and the parts give way in a
726
754
  // PINNED ORDER, rather than whichever happened to be last.
727
- const text = settledHeadText(verbCol, escapeTerminal(toolTargetOf(c)), meta, approvedBy, elapsed, W - 2 - (hidden === null ? 0 : SUFFIX_MIN));
755
+ const nAll = countLines(c.resultText);
756
+ const countedHead = nAll > 0 && !/^\d+( of \d+)? lines?$/.test(rawMeta) ? `${nAll} line${nAll === 1 ? "" : "s"}` : "";
757
+ const text = settledHeadText(verbCol, escapeTerminal(toolTargetOf(c)), meta, approvedBy, elapsed, W - 2 - (hidden === null ? 0 : SUFFIX_MIN), countedHead);
728
758
  // R2 (owner, 2026-08-27): no tick, no cross. A symbol earns its
729
759
  // cell by carrying a fact the words do not, and a row that
730
760
  // already says `exit 0` does not need one more thing saying it
@@ -732,8 +762,7 @@ class ToolExecution {
732
762
  // metadata, in words, which is also the only form that survives
733
763
  // a pipe with the colour stripped. A failure keeps its colour
734
764
  // AND its words — see settledMeta.
735
- const parts = toolBlockParts(c, W);
736
- const body = parts.rows;
765
+ const body = toolBlockParts(c, W, ctx).rows;
737
766
  if (body.length > 0 && c.expanded) {
738
767
  // An EXPANDED block is already showing everything, and its own
739
768
  // footer ("ctrl+o collapses") is what closes it. Giving it an
@@ -743,13 +772,13 @@ class ToolExecution {
743
772
  const head = c.isError ? ` ${p.red}${text}${p.reset}` : ` ${text}`;
744
773
  return slabBlock(appendSuffix(head, expandSuffix(hidden, W - visibleWidth(head))), body, null, W);
745
774
  }
746
- if (parts.output > 0) {
747
- // R9 P2 — THE SLAB'S SHAPE. A call with rows on screen is one
748
- // object: the head row names it, the output sits inside, and
749
- // the outcome CLOSES it on its own line (§7.5's words, moved
750
- // off the head row because the head row is no longer the only
751
- // row). D6: the target is bold there — the head row's job is
752
- // to say WHAT was run, and the metadata has its own row now.
775
+ if (body.length > 0) {
776
+ // R13 — THE CARD, when there is something to preview: the head
777
+ // row names the call, the preview sits inside, and the outcome
778
+ // CLOSES it on its own line (§7.5's words, moved off the head
779
+ // row because the head row is no longer the only row). D6: the
780
+ // target is bold there — the head row's job is to say WHAT was
781
+ // run, and the metadata has its own row now.
753
782
  //
754
783
  // A failure takes NO tint on the head row (R9): only the
755
784
  // outcome word is coloured, which is §1.2 exactly — the
@@ -767,28 +796,28 @@ class ToolExecution {
767
796
  // what happened and how long it took — is never cut open.
768
797
  const join = (...xs) => xs.filter((x) => x !== "").join(" · ");
769
798
  const attr = approvedBy.replace(/^ · /, "");
770
- const words = pickTier([join(meta, counted, `${elapsed}s`, attr), join(meta, counted, `${elapsed}s`), join(meta, `${elapsed}s`), meta], W - visibleWidth(NOTE_ROW));
799
+ const words = pickTier([join(meta, counted, `${elapsed}s`, attr), join(meta, counted, `${elapsed}s`), join(meta, `${elapsed}s`), meta], W - visibleWidth(noteIndent()));
771
800
  const outcome = c.isError ? `${p.red}${words}${p.reset}` : words;
772
801
  return slabBlock(head, body, outcome, W);
773
802
  }
774
- // No output on screen: the row is PLAIN, and the outcome stays on
775
- // it in the form every width gate already pins.
803
+ // R13 — nothing to preview: the SAME card, three rows, with the
804
+ // outcome riding the head row because there is nothing between
805
+ // them to close.
776
806
  //
777
- // Owner ruling 2026-09-02, narrowing R9's "one-row slab": §1.6
778
- // gives the wash to the machine's VERBATIM text, and a row like
779
- // `read loop.ts · 412 lines · 0.1s` is kiso's summary of a result
780
- // — not one line of it. A surface with nothing verbatim on it is
781
- // a surface making a claim it cannot keep, so the wash appears
782
- // only where the call's own output does.
783
- const out = c.isError ? [` ${p.red}${text}${p.reset}`] : [` ${text}`];
784
- out[0] = appendSuffix(out[0], expandSuffix(hidden, W - visibleWidth(out[0])));
785
- out.push(...body);
786
- return out;
807
+ // DECLARED REVERSAL of the owner's 2026-09-02 narrowing ("a call
808
+ // with no output on screen has no slab at all"), which read §1.6
809
+ // as giving the wash to the machine's VERBATIM text only. The
810
+ // ruling of 2026-09-03 supersedes it: the surface says WORK, not
811
+ // VERBATIM, and a page where some calls are cards and others are
812
+ // loose rows is the instability the owner was pointing at. §1.6
813
+ // moves with it.
814
+ const bare = c.isError ? ` ${p.red}${text}${p.reset}` : ` ${text}`;
815
+ return slabBlock(appendSuffix(bare, expandSuffix(hidden, W - visibleWidth(bare))), [], null, W);
787
816
  }
788
817
  if (c.state === "approval") {
789
818
  // W2: the ❯ is the GUTTER (the left edge), never the line's tail
790
819
  const out = gutterCut(`${p.bold}❯${p.reset} `, `${verbCol} ${liveTarget(c)}`, W);
791
- out.push(...toolBlockBody(c, W));
820
+ out.push(...toolBlockBody(c, W, ctx));
792
821
  return out;
793
822
  }
794
823
  if (c.state === "running") {
@@ -809,10 +838,44 @@ class ToolExecution {
809
838
  // cannot be predicted: a turning mark implies progress the
810
839
  // product does not have. With no ground the breath freezes to a
811
840
  // static `●` and says the same thing more quietly.
812
- const out = gutterCut(ctx.grouped === true ? " " : `${breathFrame(ctx.spinnerI)} `, `${verbCol} ${liveTarget(c)}`, Math.max(4, W - dur.length));
813
- out[0] = `${out[0]}${p.dim}${dur}${p.reset}`;
814
- out.push(...toolBlockBody(c, W));
815
- return out;
841
+ // R13 E2 — A RUNNING CALL IS THE SAME CARD, allocated at the
842
+ // SETTLED card's height from its first frame. The settle then
843
+ // changes CONTENT and never position: the spinner becomes two
844
+ // spaces in the same two columns, the live window gives back
845
+ // the rows the result did not need, and the metadata row that
846
+ // said `3s` says `exit 0 · 90 lines · 3.2s`.
847
+ //
848
+ // The elapsed moves OFF the head row onto that metadata row,
849
+ // which is where the settled card keeps it — the head row's job
850
+ // is to say what is running, and it says the same thing before
851
+ // and after.
852
+ //
853
+ // DC-43: with too little room for the seven-row skeleton (two
854
+ // pads, the head, two blanks, one preview row and the metadata)
855
+ // there is no card — the call keeps its head row until it
856
+ // commits, which is the one form that fits anywhere.
857
+ const liveRows = ctx.liveWindow ?? CAP_PREVIEW;
858
+ const gutter = ctx.grouped === true ? " " : `${breathFrame(ctx.spinnerI)} `;
859
+ if (liveRows <= 0) {
860
+ // the degraded form is the head row ALONE, so it keeps the
861
+ // duration it would otherwise have lost with its card — cut
862
+ // against the room the duration leaves (VD-4: the duration
863
+ // is its own segment, never welded to a cut word).
864
+ const bare = gutterCut(gutter, `${verbCol} ${liveTarget(c)}`, Math.max(4, W - dur.length));
865
+ return [`${bare[0]}${p.dim}${dur}${p.reset}`];
866
+ }
867
+ const head = gutterCut(gutter, `${verbCol} ${liveTarget(c)}`, W)[0];
868
+ // DC-46 — the two GESTURES ride the status row, where they cost
869
+ // nothing. They were a footer INSIDE the window, spending one of
870
+ // its rows on a sentence that is not output; with the window
871
+ // grown from its content, that row was the difference between a
872
+ // settle that swaps content and one that changes height. The
873
+ // row's shape is the settled outcome row's, so the settle
874
+ // rewrites it in place: `3s · esc stops` → `exit 0 · 90 lines ·
875
+ // 3.2s`.
876
+ const gestures = c.name === "shell" ? " · esc stops · alt+⏎ redirects" : "";
877
+ const status = pickTier([`${elapsed}s${gestures}`, `${elapsed}s`], Math.max(1, W - visibleWidth(noteIndent())));
878
+ return slabBlock(head, toolBlockBody(c, W, ctx), status, W);
816
879
  }
817
880
  // W2: ◦ replaces → for QUEUED — · is the separator inside every
818
881
  // metadata group; a queued marker that is also the separator
@@ -839,7 +902,7 @@ class ToolExecution {
839
902
  * never a cap.
840
903
  */
841
904
  function hiddenLines(c, W) {
842
- if (c.expanded || c.state !== "done" || c.rolled !== null || c.reason !== null)
905
+ if (c.expanded || c.state !== "done" || c.reason !== null)
843
906
  return null;
844
907
  if (c.name === "delegate")
845
908
  return null; // its body is the one-line summary, always whole
@@ -848,13 +911,14 @@ function hiddenLines(c, W) {
848
911
  return null;
849
912
  if (c.isError)
850
913
  return null; // errorBody's own cut row is the affordance there
851
- // R9 P2 / D4: a settled shell has its tail back on screen, and when
852
- // the tail is cut the slab's own note row says so and names the key.
914
+ // R13: a call that PREVIEWS carries the key on its own note row when
915
+ // something is cut, and needs no affordance at all when nothing is.
853
916
  // A head-row suffix as well would be TUI2-R1's two affordances for
854
- // one cell — the thing that rule exists to forbid.
855
- if (c.name === "shell")
917
+ // one cell — the thing that rule exists to forbid. read_file is the
918
+ // one call with no preview (E1), so the key lives on its head row.
919
+ if (c.name !== "read_file")
856
920
  return null;
857
- return n; // every other settled call renders NO body — all of it is behind the key
921
+ return n;
858
922
  }
859
923
  /**
860
924
  * TUI2-R1 (A) — the suffix, in the width that is LEFT.
@@ -900,12 +964,24 @@ const SUFFIX_MIN = " · ctrl+o".length;
900
964
  * compressible thing on the row — a reader recognises a command
901
965
  * from its head — and it is the only part with a natural ellipsis.
902
966
  */
903
- function settledHeadText(verbCol, target, meta, attr, elapsed, room) {
904
- const core = `(${meta === "" ? "" : `${meta}, `}${elapsed}s)`;
905
- const withAttr = `(${[meta, attr.replace(" · ", "")].filter((x) => x !== "").join(" · ")}${meta === "" && attr === "" ? "" : ", "}${elapsed}s)`;
967
+ function settledHeadText(verbCol, target, meta, attr, elapsed, room, counted = "") {
968
+ // R13 — ONE GRAMMAR FOR BOTH CARDS. This row used to close with W4's
969
+ // parentheses — `read a.ts (0.1s) · 10 lines · ctrl+o expands` —
970
+ // while the bodied card's outcome row said `exit 0 · 90 lines · 0.4s`
971
+ // in a `·` chain. Two shapes for the same facts, and which one a call
972
+ // got depended on whether it happened to have a preview. The chain
973
+ // wins: it is the one the outcome row already uses, it reads in one
974
+ // direction, and it puts the elapsed where the other card puts it.
975
+ //
976
+ // The giving-way order is pin 4's, unchanged: the ATTRIBUTION drops
977
+ // first, then the count, and the core — what happened and how long it
978
+ // took — is never cut open; below that the target itself truncates.
979
+ const join = (...xs) => xs.filter((x) => x !== "").join(" · ");
980
+ const core = join(meta, counted, `${elapsed}s`);
981
+ const withAttr = join(meta, counted, `${elapsed}s`, attr.replace(" · ", ""));
906
982
  const lead = `${verbCol} `;
907
- const fit = (t, parens) => {
908
- const line = `${lead}${t}${parens === "" ? "" : ` ${parens}`}`;
983
+ const fit = (t, tail) => {
984
+ const line = `${lead}${t}${tail === "" ? "" : ` · ${tail}`}`;
909
985
  return visibleWidth(line) <= room ? line : null;
910
986
  };
911
987
  // 1. everything
@@ -916,10 +992,16 @@ function settledHeadText(verbCol, target, meta, attr, elapsed, room) {
916
992
  const bare = fit(target, core);
917
993
  if (bare !== null)
918
994
  return bare;
995
+ // 2b. the COUNT gives way next (pin 4), where the suffix is not
996
+ // already carrying it
997
+ const short = fit(target, join(meta, `${elapsed}s`));
998
+ if (short !== null)
999
+ return short;
919
1000
  // 3. the target truncates, the core stays whole
920
- const budget = room - visibleWidth(lead) - visibleWidth(core) - 2; // the space + the ellipsis
1001
+ const stem = join(meta, `${elapsed}s`);
1002
+ const budget = room - visibleWidth(lead) - visibleWidth(stem) - 4; // the ellipsis + " · "
921
1003
  if (budget >= 1)
922
- return `${lead}${widthCut(target, budget)}… ${core}`;
1004
+ return `${lead}${widthCut(target, budget)}… · ${stem}`;
923
1005
  // 4. below that even the core cannot ride: the row is the call's
924
1006
  // identity and its affordance, and no half-open parenthesis.
925
1007
  return `${lead}${widthCut(target, Math.max(1, room - visibleWidth(lead)))}`;
@@ -947,8 +1029,13 @@ function attribution(c) {
947
1029
  export function expandSuffix(lines, room) {
948
1030
  if (lines === null)
949
1031
  return "";
950
- const count = `${lines} line${lines === 1 ? "" : "s"}`;
951
- for (const tier of [` · ${count} · ctrl+o expands`, ` · ${count} · ctrl+o`, " · ctrl+o"]) {
1032
+ // R13 — the COUNT left this suffix for the head row's own `·` chain,
1033
+ // where the bodied card keeps it too (`… · 10 lines · 0.1s`). It was
1034
+ // here because the parenthesised core had nowhere to put it and the
1035
+ // suffix was the only tail the row had; with one grammar for both
1036
+ // cards there is one place, and VD-6's "stated exactly once" is what
1037
+ // forbids leaving a copy behind.
1038
+ for (const tier of [" · ctrl+o expands", " · ctrl+o"]) {
952
1039
  if (tier.length <= room)
953
1040
  return tier;
954
1041
  }
@@ -1051,58 +1138,6 @@ const EXPLORE_NOUN = {
1051
1138
  export function isExploreTool(name) {
1052
1139
  return EXPLORE_NOUN[name] !== undefined;
1053
1140
  }
1054
- /** "8 files · 14 searches" — the per-tool counts in first-call order. */
1055
- export function exploreCounts(parts) {
1056
- return parts
1057
- .map((part) => {
1058
- const [singular, plural] = EXPLORE_NOUN[part.name] ?? ["call", "calls"];
1059
- // R3h (fable, 2026-08-29): DISTINCT subjects. This counted calls
1060
- // while exploreRows — the very next function, the expansion of
1061
- // THIS row — deduped them with `×N`. So the head said "6 files"
1062
- // over a list showing four, one of them `a.ts ×3`. The head and
1063
- // the body are two views of one run and must count alike; the
1064
- // body was right (a file read twice is one file).
1065
- const n = foldCountsObjects(part.name) ? new Set(part.subjects).size : part.subjects.length;
1066
- return `${n} ${n === 1 ? singular : plural}`;
1067
- })
1068
- .join(" · ");
1069
- }
1070
- /** TUI2-R1 (B) — the expanded list: ONE row per tool, the verb column
1071
- * then the distinct subjects in first-call order, a repeated subject
1072
- * carrying its ×count, the first three shown and the rest counted.
1073
- * A search's subject is its PATTERN (quoted — the thing that was
1074
- * looked for); a read's or a list's is its path. */
1075
- export function exploreRows(parts, W) {
1076
- const p = palette();
1077
- const rows = [];
1078
- for (const part of parts) {
1079
- const counts = new Map();
1080
- for (const s of part.subjects)
1081
- counts.set(s, (counts.get(s) ?? 0) + 1);
1082
- const shown = [...counts.entries()].slice(0, 3).map(([s, n]) => (n > 1 ? `${s} ×${n}` : s));
1083
- const more = counts.size > 3 ? ` (+${counts.size - 3})` : "";
1084
- const verb = displayVerb(part.name);
1085
- rows.push(cutLine(`${p.dim}${BODY_ROW}${escapeTerminal(`${verb.padEnd(6)} ${shown.join(" · ")}${more}`)}${p.reset}`, W));
1086
- }
1087
- // TUI2-R1.5 ① (VD-15): the footer used to promise "/last shows the full
1088
- // outputs". /last shows the LAST call only — for a nine-call burst that
1089
- // is one output out of nine, and a footer that sends the human to a
1090
- // place the content is not is worse than a footer that says nothing.
1091
- // R8a: the footer is an in-block note — the same indent, no glyph —
1092
- // and the corner opens the block's first row, like every other one.
1093
- rows.push(cutLine(`${p.dim}${NOTE_ROW}${COLLAPSE_ROW}${p.reset}`, W));
1094
- return openBlock(rows);
1095
- }
1096
- /** The count term with the singular/plural forms — "no reads", "1 read",
1097
- * "5 reads". The noun's singular drops the plural suffix ("dirs" → "dir",
1098
- * "matches" → "match"). */
1099
- function countTerm(n, singular, plural) {
1100
- if (n === 0)
1101
- return `no ${plural}`;
1102
- if (n === 1)
1103
- return `1 ${singular}`;
1104
- return `${n} ${plural}`;
1105
- }
1106
1141
  /** W14 — the folded-turn line: a whole QUIET turn (no text), once it is
1107
1142
  * scrollback, becomes ONE line — the work order's claimed shape
1108
1143
  * (`▞ thought 19s · 5 reads · no edits`), the counts accumulated at
@@ -1340,252 +1375,19 @@ function stretchTerms(t, live) {
1340
1375
  return `${e[tense(name)]} ${n} ${n === 1 ? e[2] : e[3]}`;
1341
1376
  });
1342
1377
  }
1343
- /** R3i — the trouble clause: which call, and what happened, in WORDS.
1344
- * Law 1.3 says an outcome is stated in words, "the only form that
1345
- * survives a pipe"; the colour on this clause is emphasis over those
1346
- * words, never the fact itself. */
1347
- function troubleClause(t) {
1348
- return t.trouble
1349
- .filter(([, n]) => n > 0)
1350
- .map(([kind, n, what]) => (what === "" || kind === "interrupted" ? `${n} ${kind}` : `${n} ${kind}: ${what}`))
1351
- .join(" · ");
1352
- }
1353
- /**
1354
- * R4 (C1) — the fold NAMES ITS OWN TARGET.
1355
- *
1356
- * `ctrl+o` used to be printed identically on every fold on the screen,
1357
- * and the key walked a ring whose order nothing on screen expressed —
1358
- * so the owner's report was exact: "there is no way to know which
1359
- * stretch it opens". The tint that marks the next target can only be
1360
- * drawn on a LIVE row, and every fold worth reopening is, by
1361
- * construction, in the scrollback where nothing can be tinted.
1362
- *
1363
- * A pointer cannot fix this either, and the bound is worth stating
1364
- * once: SGR mouse reports address the VIEWPORT, so a fold that has
1365
- * scrolled into the terminal's own scrollback is unreachable by any
1366
- * pointer, permanently, on the primary screen. The ordinal is not a
1367
- * cheaper substitute for clicking — it is the form of the affordance
1368
- * that reaches every fold, and it survives a pipe as characters.
1369
- *
1370
- * The number rides the KEY, inside the width ladder, so it is paid for
1371
- * by the same give-way order as every other span (law: the key never
1372
- * gives way — it just got two characters longer).
1373
- */
1374
- export function stretchLine(t, W) {
1375
- const p = palette();
1376
- const live = t.phase !== "settled";
1377
- // DECLARED SUPERSESSION (R6/D3, owner-ruled) — THE STRETCH LINE WEARS
1378
- // NO MARK, in any phase.
1379
- //
1380
- // Law 1.3: a symbol earns its cell by carrying a fact the words do
1381
- // not. When every settled fold, the live line AND the status row all
1382
- // wear a star, none of them distinguishes anything — it is the tick
1383
- // and the cross again (R2 retired those on exactly this ground), at
1384
- // the stretch scale. design.md §7.4 had already ruled the principle
1385
- // one scale down: "only the call still running carries a mark,
1386
- // because only it is moving", with a settled call's mark "(none) —
1387
- // the outcome is in the words. SETTLED." A settled STRETCH wearing
1388
- // one contradicted a precedent the file had already ratified.
1389
- //
1390
- // Nothing settled is being reversed: §4 lists this mark PROPOSED and
1391
- // §8 lists it OPEN. This is that proposal's ruling arriving, as a
1392
- // decline, on the owner's own dogfood.
1393
- //
1394
- // The replacement is a two-space INDENT, not a column shift: the row
1395
- // joins the settled-call family's geometry, the indent survives a
1396
- // pipe as bytes (prose never starts at column 3), and both forms are
1397
- // 2 cells so the width ladder below does not reflow. The status row's
1398
- // twinkle survives as the ONE moving mark; `✦ took` survives as the
1399
- // turn's seal.
1400
- //
1401
- // R7a AMENDS this by ONE case: the live ACTING line takes the
1402
- // breathing mark, passed in. D3 declined a mark on the SETTLED fold,
1403
- // where the words already carry the outcome; a line that means "work
1404
- // is in flight RIGHT NOW" carries a fact its words do not, which is
1405
- // exactly the test law 1.3 sets. It is also where the mark was
1406
- // migrating TO: §7.4's "only the call still running carries one" now
1407
- // applies at the stretch scale, one mark for the activity instead of
1408
- // one per call. Owner-ruled 2026-08-31, on the ground that a fast
1409
- // call's per-row mark is gone before the eye lands.
1410
- const mark = t.mark ?? " ";
1411
- // R4a (owner ruling, 2026-08-30) — the fold row prints NO key.
1412
- //
1413
- // R4 printed `· ctrl+o 3` so the row could name its own target. The
1414
- // owner's objection is the right one: a number you cannot type is not
1415
- // a selector, it is decoration that costs a column — and the
1416
- // reference implementation, checked rather than assumed, prints
1417
- // nothing on the row either. Its expansion lives in a MODE you enter,
1418
- // where the pointer can reach every fold including the ones that have
1419
- // scrolled away; the row itself stays clean.
1420
- //
1421
- // So the affordance is retired here and owed to that mode. Until it
1422
- // exists, `ctrl+o` still opens the most recent fold — it is simply no
1423
- // longer advertised on a row that cannot say which one it means.
1424
- const key = "";
1425
- const lead = t.phase === "thinking" ? [`thinking ${t.thoughtSeconds}s`] : t.phase === "settled" && t.thoughtSeconds > 0 ? [`thought ${t.thoughtSeconds}s`] : [];
1426
- const clauseText = troubleClause(t);
1427
- let meta = [...lead, ...(t.phase === "thinking" ? [] : stretchTerms(t, live))].join(" · ");
1428
- let clause = clauseText === "" ? "" : ` · ${clauseText}`;
1429
- let words = t.words === undefined || t.words === "" ? "" : ` ${escapeTerminal(t.words).replace(/\s+/g, " ")} `;
1430
- const width = () => 2 + (words === "" ? 0 : visibleWidth(words) + 3) + visibleWidth(meta) + visibleWidth(clause) + visibleWidth(key);
1431
- const trim = (text, room, floor) => (room >= floor ? `${widthCut(text, room)}…` : "");
1432
- if (words !== "" && width() > W)
1433
- words = trim(words, W - (width() - visibleWidth(words)) - 1, 4);
1434
- if (width() > W) {
1435
- for (const [long, short] of STRETCH_COMPACT) {
1436
- meta = meta.replaceAll(long, short);
1437
- if (width() <= W)
1438
- break;
1439
- }
1440
- }
1441
- if (width() > W) {
1442
- const room = W - (width() - visibleWidth(meta)) - 1;
1443
- meta = room >= 2 ? `${widthCut(meta, room)}…` : "…";
1444
- }
1445
- if (width() > W && clause !== "")
1446
- clause = trim(clause, W - (width() - visibleWidth(clause)) - 1, 5);
1447
- // the degenerate floor: below the width where even the mark and one
1448
- // character fit, the row is a hard cut of what it would have said.
1449
- const row = `${mark}${words === "" ? "" : ` ${p.rv}${words}${p.rvEnd}${p.dim} ·${p.reset}`} ${meta}${clause === "" ? "" : `${p.red}${clause}${p.reset}`}${key === "" ? "" : `${p.dim}${key}${p.reset}`}`;
1450
- return [visibleWidth(row) <= W ? row : cutLine(row, W)];
1451
- }
1452
- /** R6/D3: the quiet turn's fold wears no mark either — the SECOND
1453
- * emission site, and the one the D3 brief did not name. Same ruling,
1454
- * same two-space indent; see stretchLine above for the argument. */
1455
- export function turnFold(t, W) {
1456
- const p = palette();
1457
- // R3b (owner, 2026-08-27): ZERO TERMS ARE DROPPED. W14 always wrote
1458
- // `no reads · no edits`, which is a sentence about things that did not
1459
- // happen — on a segment fold, where a run is usually all reads or all
1460
- // edits, half the row was the half that said nothing. A term earns its
1461
- // place by having a count.
1462
- // R3h (fable, 2026-08-29): the THOUGHT term obeys the same zero-drop
1463
- // rule every other term got at R3b. It was exempt by accident — it
1464
- // was written before the rule — so a model that emits no thinking
1465
- // folded every turn of its life under `thought 0s`, a sentence about
1466
- // something that did not happen, in the lead position.
1467
- const meta = [...(t.thoughtSeconds > 0 ? [`thought ${t.thoughtSeconds}s`] : []), ...foldTerms(t.reads, t.edits, t.others)].join(" · ");
1468
- // R3f: the same one-row rule. This one is worse than the thinking
1469
- // fold's — the fold is a COMMITTED row, so a multi-line user message
1470
- // would desync the committed-line accounting permanently rather than
1471
- // for one frame. (The live UserMessage chip keeps its multi-row form:
1472
- // it folds per paragraph and each row is already one row.)
1473
- const words = escapeTerminal(t.words).replace(/\s+/g, " ");
1474
- // R3g (2026-08-28) — THE KEY IS LOAD-BEARING, SO IT GIVES WAY LAST.
1475
- //
1476
- // R3b wrote "the suffix gives way first at a narrow width", by
1477
- // analogy with the settled card's affordance. The analogy does not
1478
- // hold: the card's rows are still on the screen when its hint is
1479
- // dropped, and a fold's are not. A fold with no key is the turn's
1480
- // work behind a line with no way back — the one thing the fold's own
1481
- // header says it must never be. So the META gives way first (a cut
1482
- // count is still a true count), and the key survives.
1483
- //
1484
- // Two live cases, both reachable at W=80: the wordless fold went
1485
- // keyless as soon as the terms grew (R3g's verb+noun phrasing pushed
1486
- // it over), and the CHIP fold never had a key at all — it was
1487
- // written without one, so every folded turn that carried the user's
1488
- // words was unreachable by the key its siblings advertise. Found by
1489
- // an independent review (fable) and then by the paced-rollup gate,
1490
- // which got `kind: "none"` back from expandNext.
1491
- // R3h: the COMPACT tier. The owner's own sentence — "thought 17s ·
1492
- // read 4 files · listed 1 directory · ran 4 shell commands" — is 81
1493
- // cells with the key at W=80: one over, so the tail was cut off the
1494
- // exact shape this round exists to produce. The nouns shorten before
1495
- // anything is lost, which is the same "degrade, never truncate"
1496
- // ladder every other row here walks.
1497
- // A LADDER, cheapest first, and it stops as soon as the row fits: the
1498
- // owner's canonical sentence is ONE cell over at W=80, and spending
1499
- // every substitution to buy one cell would shorten words that had
1500
- // room. "directory" is the cheapest to lose (nobody misreads "dir");
1501
- // "commands" is next and still says what happened.
1502
- const COMPACT = [
1503
- ["directories", "dirs"],
1504
- ["directory", "dir"],
1505
- ["shell commands", "commands"],
1506
- ["shell command", "command"],
1507
- ];
1508
- const compactAll = (text) => {
1509
- let out = text;
1510
- for (const [long, short] of COMPACT)
1511
- out = out.replaceAll(long, short);
1512
- return out;
1513
- };
1514
- const KEY = " · ctrl+o";
1515
- const keyW = KEY.length;
1516
- const key = `${p.dim}${KEY}${p.reset}`;
1517
- if (words === "") {
1518
- const keyed = ` ${meta}${key}`;
1519
- if (visibleWidth(keyed) <= W)
1520
- return [keyed];
1521
- let tightMeta = meta;
1522
- for (const [long, short] of COMPACT) {
1523
- tightMeta = tightMeta.replaceAll(long, short);
1524
- const tight = ` ${tightMeta}${key}`;
1525
- if (visibleWidth(tight) <= W)
1526
- return [tight];
1527
- }
1528
- // the meta cuts with the honest "…" — the COMPACT form of it, so a
1529
- // cut row still carries as many whole terms as the width allows;
1530
- // below the width where even the key fits there is nothing to
1531
- // preserve and the row is a cut.
1532
- const room = W - 2 - keyW - 1;
1533
- if (room < 2) {
1534
- // R3h: and below FOUR columns even that row is 4 cells wide (the
1535
- // gutter, one character, the "…"), so invariant ① threw AT the
1536
- // widths this branch exists to serve — the same class DC-15
1537
- // closed in the thinking fold, still open here. A width with no
1538
- // room for the mark is a width with nothing to say: the row is
1539
- // a hard cut of what it would have said.
1540
- const cut = ` ${widthCut(tightMeta, Math.max(1, W - 3))}…`;
1541
- return [visibleWidth(cut) <= W ? cut : cutLine(` ${tightMeta}`, W)];
1542
- }
1543
- return [` ${widthCut(tightMeta, room)}…${key}`];
1544
- }
1545
- // A9 (ruling R2, mock A): the user chip rides the fold — the human's
1546
- // words LEAD the one line, the same SGR-7 bracket as the live user
1547
- // row (#16f, side pads included). The words take the fold's width
1548
- // budget: W − the gutter (two spaces = 2, R6/D3) − the join (3) − the
1549
- // chip's side pads (2) − the cut-tail reserve (1, the "…") − the
1550
- // metadata's own width — the metadata survives, the words width-cut
1551
- // at the end with the honest "…" (the "…" alone is the honest floor:
1552
- // the words were there, cut).
1553
- const budget = Math.max(0, W - visibleWidth(` ${meta}`) - 6 - keyW);
1554
- const cut = visibleWidth(words) > budget ? `${widthCut(words, budget)}…` : words;
1555
- const row = ` ${p.rv} ${cut} ${p.rvEnd} · ${meta}${key}`;
1556
- if (visibleWidth(row) <= W)
1557
- return [row];
1558
- // R3h: the same COMPACT ladder the wordless branch walks — the nouns
1559
- // shorten before the words or the metadata lose anything.
1560
- let chipMeta = meta;
1561
- for (const [long, short] of COMPACT) {
1562
- chipMeta = chipMeta.replaceAll(long, short);
1563
- const tighter = ` ${p.rv} ${cut} ${p.rvEnd} · ${chipMeta}${key}`;
1564
- if (visibleWidth(tighter) <= W)
1565
- return [tighter];
1566
- }
1567
- // the last resort: the METADATA gives way — the words hold their
1568
- // budget, the key holds its nine cells, the meta cuts with the honest
1569
- // "…"; invariant ① never trips at ANY width (a degenerate W's fold is
1570
- // a cut, never a crash).
1571
- const room = W - 8 - keyW - visibleWidth(cut);
1572
- if (room < 2) {
1573
- // R3h: the chip branch's own degenerate floor. The gutter, the
1574
- // bracket's pads, the join and the "…" are ten cells before a
1575
- // single character of content, so every width below ten threw
1576
- // invariant ① — see the wordless branch above for the same class.
1577
- const tail = ` ${p.rv} ${cut} ${p.rvEnd} · ${widthCut(compactAll(meta), Math.max(1, W - 8 - visibleWidth(cut)))}…`;
1578
- return [visibleWidth(tail) <= W ? tail : cutLine(` ${p.rv} ${cut} ${p.rvEnd}`, W)];
1579
- }
1580
- return [` ${p.rv} ${cut} ${p.rvEnd} · ${widthCut(compactAll(meta), room)}…${key}`];
1581
- }
1582
1378
  // ---- the bounded-block flow contract (W7, W8, W10) ----
1583
1379
  /** The caps — screen rows counted AFTER the fold, at the current width
1584
1380
  * (the W7 table). The renderer-cut row is inside the cap. */
1585
- const CAP_SHELL_SETTLED = 5; // the shell output tail, settled
1586
- const CAP_LIVE_WINDOW = 3; // the running tool's FIXED window (W8)
1381
+ /** R13 — ONE preview cap, every tool. It was the shell's alone while
1382
+ * the shell was the only settled call with rows on screen. */
1383
+ export const CAP_PREVIEW = 5;
1384
+ /** DC-46 — the running window's ceiling is the SETTLED preview's, and a
1385
+ * running card reaches it by growing rather than by being handed it.
1386
+ * `LIVE_WINDOW` (CAP_PREVIEW + 1) retires with the allocation it sized. */
1387
+ /** The rows a card costs besides its window: two pads, the head, two
1388
+ * blanks and the status row. Below this there is no card (DC-43). */
1389
+ export const CARD_CHROME = 6;
1587
1390
  const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
1588
- const CAP_ERROR = 3; // the error text head
1589
1391
  /** The block body rows' prefixes (W2's gutter table): │ a bounded
1590
1392
  * block's body, └ the block's last row — what was cut, where the rest
1591
1393
  * is — at the LEFT EDGE (the gutter column: the left edge alone
@@ -1600,13 +1402,27 @@ const CAP_ERROR = 3; // the error text head
1600
1402
  * prose (2). Bytes still tell them apart; no column of glyphs.
1601
1403
  *
1602
1404
  * `└` survives as the mark that OPENS the block, once, on its first
1603
- * row (see openBlock). In-block notes take NOTE_ROW — the same indent,
1405
+ * row (see openBlock). In-block notes take the same indent,
1604
1406
  * no glyph — because a second `└` inside one block would be the same
1605
1407
  * mark meaning two things (§4.1). CUT_ROW is unchanged for the
1606
1408
  * surfaces that are not a tool block: the fold row's target list, the
1607
1409
  * slot's overflow count. */
1608
- const BODY_ROW = " ";
1609
- const NOTE_ROW = " ";
1410
+ /** R8a's four columns — off the surface. Inside a painted card every
1411
+ * row sits at column 2 (R13 E4): the head row and the outcome row
1412
+ * bracket the preview, so the indent is no longer what says "these
1413
+ * rows are output". Where nothing paints, it is exactly that, and R8a
1414
+ * stands unchanged. */
1415
+ const BODY_ROW_FLAT = " ";
1416
+ const NOTE_ROW_FLAT = " ";
1417
+ const CARD_ROW = " ";
1418
+ /** R13 E3 — the column the model's words begin in, the same one the
1419
+ * card's rows and the chip's text begin in. */
1420
+ const PROSE_COL = " ";
1421
+ /** DC-47 — the model's THINKING, one level deeper than its prose, so
1422
+ * the two are still told apart once the escapes are stripped (§1.2). */
1423
+ const THINK_COL = " ";
1424
+ const bodyRow = () => (slabPaints() ? CARD_ROW : BODY_ROW_FLAT);
1425
+ const noteIndent = () => (slabPaints() ? CARD_ROW : NOTE_ROW_FLAT);
1610
1426
  const CUT_ROW = "└ ";
1611
1427
  /**
1612
1428
  * R9 P2 — THE SLAB: a single call's block is one washed object.
@@ -1619,7 +1435,7 @@ const CUT_ROW = "└ ";
1619
1435
  *
1620
1436
  * THE DEGRADATION IS THE POINT OF THE PREDICATE. `wash` is a chosen
1621
1437
  * background on the two KNOWN grounds and reverse video on the third
1622
- * (§3 rung 4). A chip inverting for one row is the design working; eight
1438
+ * (§3's last rung). A chip inverting for one row is the design working; eight
1623
1439
  * output rows inverting is a blackboard in the middle of the transcript.
1624
1440
  * So a slab paints only where the wash is a real background, and where
1625
1441
  * it is not the block degrades to what it has always been — the R8a
@@ -1665,7 +1481,7 @@ function noteRow(text, W, tone) {
1665
1481
  // its own. The row names a fact; a cut names it shorter.
1666
1482
  const open = tone === "body" ? p.washDim : p.dim;
1667
1483
  const close = tone === "body" ? p.washDimEnd : p.reset;
1668
- return [cutLine(`${open}${NOTE_ROW}${text}${close}`, W)];
1484
+ return [cutLine(`${open}${noteIndent()}${text}${close}`, W)];
1669
1485
  }
1670
1486
  /**
1671
1487
  * Assemble a call's block.
@@ -1682,18 +1498,24 @@ function slabBlock(head, body, outcome, W) {
1682
1498
  out.push(...noteRow(outcome, W, "dim"));
1683
1499
  return out;
1684
1500
  }
1685
- const rows = [slabRow(head, W)];
1686
- if (body.length > 0)
1687
- rows.push(slabRow("", W));
1688
- for (const r of body)
1689
- rows.push(slabRow(r, W));
1690
- if (outcome !== null) {
1691
- if (body.length > 0)
1692
- rows.push(slabRow("", W));
1693
- for (const r of noteRow(outcome, W, "body"))
1694
- rows.push(slabRow(r, W));
1501
+ // R13 — THE CARD. pad · head · blank · preview · blank · outcome ·
1502
+ // pad, and a call with nothing to preview is three rows with its
1503
+ // outcome riding the head. Every row sits at column 2 (E4): R8a put
1504
+ // a block's body at column 4 so a pipe could tell output from prose,
1505
+ // and inside a painted card the head and the outcome bracket it
1506
+ // instead — off the surface R8a's indent is still the fact, which is
1507
+ // why the degradation above keeps it.
1508
+ if (body.length === 0) {
1509
+ const only = outcome === null ? head : `${head} ${outcome}`;
1510
+ return [slabRow("", W), slabRow(only, W), slabRow("", W)];
1695
1511
  }
1696
- return rows;
1512
+ const top = [slabRow("", W), slabRow(head, W), slabRow("", W), ...body.map((r) => slabRow(r, W))];
1513
+ // An EXPANDED block closes with its own footer and passes no outcome;
1514
+ // a blank row and an empty metadata row under it would be §1.3's
1515
+ // empty mark twice over.
1516
+ if (outcome === null)
1517
+ return [...top, slabRow("", W)];
1518
+ return [...top, slabRow("", W), ...noteRow(outcome, W, "body").map((r) => slabRow(r, W)), slabRow("", W)];
1697
1519
  }
1698
1520
  /** R8a — stamp `└` on a block's FIRST row, after every slice and note
1699
1521
  * has been assembled, so the mark is always on the first row actually
@@ -1703,25 +1525,26 @@ function openBlock(rows) {
1703
1525
  // or a blank leading output line can put an empty row first, and a
1704
1526
  // corner there would be a mark on a row with nothing to mark — law
1705
1527
  // 1.3, which is the rule this whole change is serving.
1706
- const i = rows.findIndex((r) => visibleWidth(r) > visibleWidth(BODY_ROW));
1528
+ const i = rows.findIndex((r) => visibleWidth(r) > visibleWidth(bodyRow()));
1707
1529
  if (i < 0)
1708
1530
  return rows;
1709
1531
  const first = rows[i];
1710
- const at = first.indexOf(BODY_ROW);
1532
+ const at = first.indexOf(BODY_ROW_FLAT);
1711
1533
  if (at < 0)
1712
1534
  return rows;
1713
1535
  // the corner REPLACES two of the four indent columns, so the text
1714
1536
  // stays in the same column as every other row of the block.
1715
- return [...rows.slice(0, i), `${first.slice(0, at)} \u2514 ${first.slice(at + BODY_ROW.length)}`, ...rows.slice(i + 1)];
1537
+ return [...rows.slice(0, i), `${first.slice(0, at)} \u2514 ${first.slice(at + BODY_ROW_FLAT.length)}`, ...rows.slice(i + 1)];
1716
1538
  }
1717
1539
  const blockMemo = new WeakMap();
1718
1540
  /** The block's body rows below the header (memoized, W9). */
1719
- function toolBlockParts(c, W) {
1541
+ function toolBlockParts(c, W, ctx) {
1720
1542
  const memo = blockMemo.get(c);
1721
1543
  // the SURFACE is part of the key: the same cell renders different rows
1722
1544
  // painted and unpainted, and a ground resolved after the first frame
1723
1545
  // would otherwise be served the pre-ground shape forever.
1724
- const state = `${c.state}:${c.isError}:${c.name}:${c.expanded ? "x" : ""}:${slabPaints() ? "slab" : "flat"}`;
1546
+ const liveRows = ctx.liveWindow ?? CAP_PREVIEW;
1547
+ const state = `${c.state}:${c.isError}:${c.name}:${c.expanded ? "x" : ""}:${slabPaints() ? "slab" : "flat"}:${liveRows}`;
1725
1548
  const content = c.state === "approval" ? (c.diff ?? null) : c.resultText;
1726
1549
  if (memo !== undefined && memo.width === W && memo.state === state && memo.content === content)
1727
1550
  return memo;
@@ -1743,62 +1566,71 @@ function toolBlockParts(c, W) {
1743
1566
  ? errorBody(c, W)
1744
1567
  : c.name === "delegate"
1745
1568
  ? delegateSettled(c, W)
1746
- : // R9 P2 / D4 — DECLARED REVERSAL of TUI2-R1.5 ④(c) (VD-5),
1747
- // owner-ruled. VD-5 collapsed a settled shell to its head
1748
- // row because six ungrounded rows per call let three
1749
- // shells own a screen. The slab is what changes that
1750
- // arithmetic: the rows are inside a surface that says
1751
- // where the call begins and ends, so five of them read as
1752
- // one object rather than as five loose lines. The cap and
1753
- // the tail direction are VD-5's own (CAP_SHELL_SETTLED,
1754
- // the conclusion at the end); only the emptiness is
1755
- // reversed.
1756
- c.name === "shell"
1757
- ? shellTail(c.resultText, W, slabPaints() ? "body" : "dim")
1758
- : []
1569
+ : // R13 — EVERY settled call previews. The shell's tail is
1570
+ // VD-5's own cap and direction (the conclusion is at the
1571
+ // end); everything else shows its head, because that is
1572
+ // where its answer is. read_file is the single exception
1573
+ // (E1): its result is the file, kiso has nothing to add by
1574
+ // showing five lines of it, and the head row's key opens
1575
+ // the whole thing.
1576
+ noPreview(c)
1577
+ ? []
1578
+ : c.name === "shell"
1579
+ ? shellTail(c.resultText, W, slabPaints() ? "body" : "dim")
1580
+ : previewHead(c.resultText, W, slabPaints() ? "body" : "dim")
1759
1581
  : c.state === "running"
1760
1582
  ? c.name === "delegate"
1761
1583
  ? delegateRunning(c, W)
1762
- : c.name === "shell"
1763
- ? shellLiveTail(c.resultText, W)
1764
- : liveWindow(c.resultText, W)
1584
+ : // R13 E1 — the call with no preview settled has none while
1585
+ // it runs either; its card is three rows the whole way.
1586
+ noPreview({ ...c, state: "done" })
1587
+ ? []
1588
+ : liveWindow(c, W, liveCap(c, liveRows), slabPaints() ? "body" : "dim")
1765
1589
  : c.state === "approval"
1766
1590
  ? diffBody(c.diff, W)
1767
1591
  : [];
1768
- // R9 P2: how many of these rows are the call's OWN OUTPUT. A cut note
1769
- // is kiso's sentence about a result the TOOL truncated, not a line of
1770
- // it — a read that has only that note has nothing verbatim on screen
1771
- // and stays a ONE-ROW slab, outcome inline, exactly as R9 draws it.
1772
- const output = rows.length;
1592
+ // E1 governs the PREVIEW — five lines of a file kiso has nothing to
1593
+ // add to — not kiso's sentence about the result. `offset=201 for the
1594
+ // rest` is actionable and no other row says it (W10 pins exactly
1595
+ // that), so a read the TOOL capped takes one body row and a read it
1596
+ // did not takes none. That shape difference is information: the two
1597
+ // events are different, and the row is what says so.
1773
1598
  const note = c.expanded ? null : toolCutNote(c.name, c.resultText);
1774
1599
  if (note !== null)
1775
- rows.push(...foldLine(`${p.dim}${NOTE_ROW}${note}${p.reset}`, W));
1600
+ rows.push(...foldLine(`${p.dim}${noteIndent()}${note}${p.reset}`, W));
1776
1601
  // TUI2-R1 (A): an EXPANDED block says how to put it back. The footer
1777
1602
  // rides a block that HAS rows — an expanded delegate whose summary
1778
1603
  // marker is missing renders nothing, and a lone footer under a head
1779
1604
  // row would be an affordance for an empty block.
1780
1605
  if (c.expanded && rows.length > 0)
1781
- rows.push(...foldLine(`${p.dim}${NOTE_ROW}${COLLAPSE_ROW}${p.reset}`, W));
1606
+ rows.push(...foldLine(`${p.dim}${noteIndent()}${COLLAPSE_ROW}${p.reset}`, W));
1782
1607
  // R9 P2: `└` opens a block that has no surface. Inside a slab the
1783
1608
  // surface IS the container, and a corner in it is §1.3's empty mark
1784
1609
  // one scale up — so the corner and the slab are alternatives, never
1785
1610
  // both.
1786
1611
  const opened = slabPaints() ? rows : openBlock(rows);
1787
- const parts = { width: W, state, content, rows: opened, output };
1612
+ const parts = { width: W, state, content, rows: opened };
1788
1613
  blockMemo.set(c, parts);
1789
1614
  return parts;
1790
1615
  }
1791
- /** The rows alone — every caller but the settled branch, which needs to
1792
- * know whether any of them are the call's own OUTPUT. */
1793
- function toolBlockBody(c, W) {
1794
- return toolBlockParts(c, W).rows;
1616
+ /** R13 E1 — the one settled call that previews NOTHING. A read's result
1617
+ * IS the file; five lines of it tell a reader less than the head row
1618
+ * already does, and the key opens the whole thing. (The reference
1619
+ * implementation makes the same call, for the same reason.) Everything
1620
+ * else previews: a shell its tail, the rest their head. */
1621
+ function noPreview(c) {
1622
+ return c.state === "done" && !c.expanded && !c.isError && c.reason === null && c.name === "read_file";
1795
1623
  }
1796
- /** Fold result text into dim body rows (the BODY_ROW prefix): escape,
1624
+ /** The block's body rows. */
1625
+ function toolBlockBody(c, W, ctx) {
1626
+ return toolBlockParts(c, W, ctx).rows;
1627
+ }
1628
+ /** Fold result text into body rows (the block's own indent): escape,
1797
1629
  * split, fold each line at W−prefix; trailing empty rows (the result's
1798
1630
  * final newline) drop. */
1799
1631
  function blockRows(text, W, tone = "dim") {
1800
1632
  const p = palette();
1801
- const textW = Math.max(1, W - visibleWidth(BODY_ROW));
1633
+ const textW = Math.max(1, W - visibleWidth(bodyRow()));
1802
1634
  const rows = [];
1803
1635
  // R9 P2: inside a SLAB the output rows are body strength, never dim —
1804
1636
  // §2.1 bars dim from the wash (3.91:1 light, 4.35:1 dark) and these
@@ -1808,9 +1640,9 @@ function blockRows(text, W, tone = "dim") {
1808
1640
  const close = tone === "dim" ? p.reset : "";
1809
1641
  for (const raw of escapeTerminal(text).split("\n")) {
1810
1642
  for (const row of foldLine(raw, textW))
1811
- rows.push(`${open}${BODY_ROW}${row}${close}`);
1643
+ rows.push(`${open}${bodyRow()}${row}${close}`);
1812
1644
  }
1813
- while (rows.length > 0 && visibleWidth(rows[rows.length - 1]) === visibleWidth(BODY_ROW))
1645
+ while (rows.length > 0 && visibleWidth(rows[rows.length - 1]) === visibleWidth(bodyRow()))
1814
1646
  rows.pop();
1815
1647
  return rows;
1816
1648
  }
@@ -1820,23 +1652,43 @@ function blockRows(text, W, tone = "dim") {
1820
1652
  * direction). */
1821
1653
  function shellTail(text, W, tone = "dim") {
1822
1654
  const rows = blockRows(text, W, tone);
1823
- if (rows.length <= CAP_SHELL_SETTLED)
1655
+ if (rows.length <= CAP_PREVIEW)
1824
1656
  return rows;
1825
1657
  // R9 P2 / D4: FIVE output rows, and the note is a row of its own. The
1826
1658
  // pre-slab arithmetic spent one of the five on the cut note, because
1827
1659
  // the note had nowhere else to live; the slab's metadata rows are not
1828
1660
  // output and are not counted against the output's cap.
1829
- const kept = CAP_SHELL_SETTLED;
1661
+ const kept = CAP_PREVIEW;
1830
1662
  // The note goes ABOVE the tail: it says what was cut, and what was
1831
1663
  // cut is what came BEFORE these rows. One position on both surfaces —
1832
1664
  // the surface degrades, the content shape does not.
1833
- const cut = rows.length - kept;
1834
- const n = `${cut} earlier line${cut === 1 ? "" : "s"}`;
1835
- // the KEY is reserved (TUI2-R1.5 ⑤): the count gives way before it,
1836
- // because a row that says how much is hidden without saying how to
1837
- // see it is the silence TUI2-R1 (A) set out to remove.
1838
- const note = noteRow(pickTier([`… ${n} · ctrl+o expands`, `… ${n} · ctrl+o`, `… ${cut} · ctrl+o`, "· ctrl+o"], W - visibleWidth(NOTE_ROW)), W, tone);
1839
- return [...note, ...rows.slice(rows.length - kept)];
1665
+ return [...cutNote(rows.length - kept, "earlier", W, tone), ...rows.slice(rows.length - kept)];
1666
+ }
1667
+ /** R13 — the preview every OTHER settled call gets: the FIRST rows,
1668
+ * capped at five, the cut note BELOW them. A shell's conclusion is at
1669
+ * the bottom of its output, so its preview is the tail and its note
1670
+ * opens the block (shellTail above); a list, a search, a fetch all
1671
+ * answer at the top, so the note closes it. One rule, two directions,
1672
+ * and the direction follows where the answer is.
1673
+ *
1674
+ * This is the DECLARED REVERSAL of VD-5 for every tool but the shell
1675
+ * (0.22.0 reversed the shell alone): VD-5 collapsed a settled call to
1676
+ * its head row because ungrounded output rows owned the screen, and
1677
+ * the card is what changes that arithmetic — the rows are inside a
1678
+ * surface that says where the call begins and ends. */
1679
+ function previewHead(text, W, tone) {
1680
+ const rows = blockRows(text, W, tone);
1681
+ if (rows.length <= CAP_PREVIEW)
1682
+ return rows;
1683
+ return [...rows.slice(0, CAP_PREVIEW), ...cutNote(rows.length - CAP_PREVIEW, "more", W, tone)];
1684
+ }
1685
+ /** The preview's cut note, in one place so the head and the tail
1686
+ * directions cannot drift apart. The KEY is reserved (TUI2-R1.5 ⑤): a
1687
+ * row that says how much is hidden without saying how to see it is the
1688
+ * silence the affordance exists to remove. */
1689
+ function cutNote(cut, word, W, tone) {
1690
+ const n = `${cut} ${word} line${cut === 1 ? "" : "s"}`;
1691
+ return noteRow(pickTier([`… ${n} · ctrl+o expands`, `… ${n} · ctrl+o`, `… ${cut} · ctrl+o`, "· ctrl+o"], W - visibleWidth(noteIndent())), W, tone);
1840
1692
  }
1841
1693
  /** The error text head: the FIRST rows, capped at 3 — the answer is at
1842
1694
  * the start (opencode's collapseToolOutput direction). The header row
@@ -1852,81 +1704,77 @@ function errorBody(c, W) {
1852
1704
  // FULL content including the "[Permission denied] " prefix (never
1853
1705
  // hide information — the folded body rides the pinned row).
1854
1706
  const skipFirst = c.name === "shell" && /^exit \d+/.test(c.resultText) ? 0 : c.reason !== null && c.reason !== undefined ? 0 : 1;
1855
- const rows = blockRows(c.resultText.split("\n").slice(skipFirst).join("\n"), W);
1856
- if (rows.length <= CAP_ERROR)
1857
- return rows;
1858
- const cut = foldLine(`${p.dim}${NOTE_ROW}+${rows.length - (CAP_ERROR - 1)} more · ctrl+o${p.reset}`, W);
1859
- return [...rows.slice(0, CAP_ERROR - 1), ...cut];
1860
- }
1861
- /** The running tool's FIXED-height window (W8): exactly 3 rows from
1862
- * the FIRST frame — blank-padded before output arrives, the renderer
1863
- * cut inside the window. The height changes exactly once, at settle —
1864
- * a cell that grows mid-list would shift every row after it on every
1865
- * delta (the parallel-tools jitter). */
1866
- function liveWindow(text, W) {
1867
- const p = palette();
1868
- if (text === "") {
1869
- // R7a: blank, not two bare gutters. A `│` marks a row that HAS
1870
- // content; two of them above "waiting for output" drew a tall
1871
- // empty bar under every command that had not printed yet — which
1872
- // is most of them, for their first second.
1873
- // ...and the waiting row sits DIRECTLY under its header, with the
1874
- // blanks below it — VD-4's own rule ("the output starts under its
1875
- // own header and grows downward"), which the gutter rows used to
1876
- // satisfy by accident and blanks made visible as a two-row gap.
1877
- return [`${p.dim}${NOTE_ROW}waiting for output${p.reset}`, "", ""];
1878
- }
1879
- const rows = blockRows(text, W);
1880
- if (rows.length <= CAP_LIVE_WINDOW) {
1881
- while (rows.length < CAP_LIVE_WINDOW)
1882
- rows.push(""); // R7a: blank, not a bar
1883
- return rows;
1884
- }
1885
- const cut = foldLine(`${p.dim}${NOTE_ROW}+${rows.length - (CAP_LIVE_WINDOW - 1)} earlier rows · ctrl+o${p.reset}`, W);
1886
- return [...rows.slice(rows.length - (CAP_LIVE_WINDOW - 1)), ...cut];
1707
+ // R13 — a failure is a CARD like any other call: the same cap of
1708
+ // five, the same note wording, the same direction (an error's answer
1709
+ // is at the top). It kept a cap of three and `+2 more · ctrl+o` from
1710
+ // before the card existed, which made the one shape a reader most
1711
+ // wants to read the one shape that showed least of itself.
1712
+ return previewHead(c.resultText.split("\n").slice(skipFirst).join("\n"), W, slabPaints() ? "body" : "dim");
1887
1713
  }
1888
1714
  /**
1889
- * TUI2-R1 (C) — the RUNNING shell's live tail.
1890
- *
1891
- * The rows are the sidecar's last lines, NEWEST AT THE BOTTOM (a tail
1892
- * grows downward, and the row nearest the footer is the newest thing the
1893
- * command said). The window is the SAME three rows W8 fixed: two tail
1894
- * rows and the footer, blank-padded before the output fills them, so the
1895
- * block's height still changes exactly once — at settle.
1896
- *
1897
- * With nothing observed the shape is exactly today's "waiting for
1898
- * output": a sidecar that never appeared, a command that has not
1899
- * printed, and a temp dir that refused the write are indistinguishable
1900
- * from here, and all three mean the same thing — nothing to show.
1901
- *
1902
- * The footer names the state AND the two gestures that apply while a
1903
- * command runs, because this is precisely when a human wants them.
1715
+ * DC-46 — THE RUNNING WINDOW GROWS, and nothing pads it.
1716
+ *
1717
+ * DECLARED REVERSAL of W8's fixed window and of E2 as first written
1718
+ * ("allocated at the settled card's height, and only ever shrinks at
1719
+ * settle"). Both fixed a height so it would not move while a command
1720
+ * ran; both fixed it at a height the SETTLE then changed, and the settle
1721
+ * is where the cost landed. A card allocated at twelve rows and settling
1722
+ * at three gives nine rows back, the window's top is clamped and cannot
1723
+ * follow, and the difference is a blank band above the composer.
1724
+ * Measured on the a7 replay: hole-frames 8.9 / 13.5 / 3.8 percent at
1725
+ * 0.23.0 against 16.9 / 24.6 / 7.9 with the shrink. The only source is
1726
+ * the shrink, so the cure is to stop shrinking.
1727
+ *
1728
+ * So the window IS its content: one row while nothing has arrived, one
1729
+ * more per line to the cap, then the cut note above a scrolling tail.
1730
+ * R7a's "blank, not a bar" retires with the padding it governed — it was
1731
+ * about what to draw on rows a FIXED height reserved, and no height is
1732
+ * reserved now.
1733
+ *
1734
+ * The direction is the SETTLED card's, so a settle swaps content and
1735
+ * moves nothing: a shell shows its tail with the note above, everything
1736
+ * else its head with the note below.
1904
1737
  */
1905
- function shellLiveTail(text, W) {
1906
- if (text === "")
1907
- return liveWindow("", W);
1738
+ function liveWindow(c, W, cap, tone) {
1908
1739
  const p = palette();
1909
- // TUI2-R1.5 ④(b) (VD-4): the tail's first row is never a blank gutter.
1910
- // Two sources, both fixed here, and the W8 fixed-window height is kept
1911
- // by both fixes:
1912
- // - leading empty lines in the sidecar (a 4096-byte tail can begin on
1913
- // a line boundary, and the reader's .trimEnd only trims the other
1914
- // end) are skipped;
1915
- // - the short-output pad moved from the TOP to the BOTTOM. It exists
1916
- // so the block's height never changes while the command runs (W8);
1917
- // at the top it put an empty row above the command's very first
1918
- // line, which is the frame the walkthrough filed. At the bottom the
1919
- // output starts under its own header and grows downward, and the
1920
- // height is just as fixed.
1921
- const all = blockRows(text, W);
1922
- const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(BODY_ROW));
1923
- const rows = from < 0 ? [] : all.slice(from);
1924
- if (rows.length === 0)
1925
- return liveWindow("", W);
1926
- const kept = rows.slice(Math.max(0, rows.length - (CAP_LIVE_WINDOW - 1)));
1927
- while (kept.length < CAP_LIVE_WINDOW - 1)
1928
- kept.push(""); // R7a: blank, not a bar
1929
- return [...kept, cutLine(`${p.dim}${NOTE_ROW}live tail · esc stop · alt+⏎ redirect${p.reset}`, W)];
1740
+ // TUI2-R1.5 ④(b) (VD-4): leading empty lines in the sidecar (a
1741
+ // 4096-byte tail can begin on a line boundary) are skipped, so the
1742
+ // output starts under its own header.
1743
+ const all = blockRows(c.resultText, W, tone);
1744
+ const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(bodyRow()));
1745
+ // DC-46, derived — NOTHING YET IS NO WINDOW AT ALL, so a running call
1746
+ // with no output is the same THREE-ROW card as a settled one with
1747
+ // none. The ruling's skeleton put a `waiting for output` row here; a
1748
+ // command that returns nothing (`true`, a silent build) would then
1749
+ // settle from seven rows to three, which is the very shrink the ruling
1750
+ // exists to remove — its own rule cannot hold with that row in place.
1751
+ //
1752
+ // Nothing is lost: the breathing mark says the call is in flight and
1753
+ // the status row's elapsed says how long, so a row reading "waiting
1754
+ // for output" carries no fact they do not (§1.3). The card grows the
1755
+ // instant a line arrives, and growth is what this design permits.
1756
+ if (from < 0)
1757
+ return [];
1758
+ const rows = all.slice(from);
1759
+ if (rows.length <= cap)
1760
+ return rows;
1761
+ return c.name === "shell"
1762
+ ? [...cutNote(rows.length - cap, "earlier", W, tone), ...rows.slice(rows.length - cap)]
1763
+ : [...rows.slice(0, cap), ...cutNote(rows.length - cap, "more", W, tone)];
1764
+ }
1765
+ /** DC-46 — the window's HIGH-WATER, per cell: the room a frame leaves
1766
+ * caps how far a window may GROW, and never shrinks one that already
1767
+ * grew. Without this a second call starting would pull the first's
1768
+ * window in, which is the same shrink by another route. Keyed on the
1769
+ * cell, like `blockMemo`, and it only ever matters while the cell is
1770
+ * live — a settled cell renders from its result alone. */
1771
+ const liveHighWater = new WeakMap();
1772
+ function liveCap(c, room) {
1773
+ const want = Math.min(CAP_PREVIEW, Math.max(1, room));
1774
+ const held = liveHighWater.get(c) ?? 0;
1775
+ const cap = Math.max(want, held);
1776
+ liveHighWater.set(c, cap);
1777
+ return cap;
1930
1778
  }
1931
1779
  /**
1932
1780
  * R4 — the standing act slot.
@@ -1972,7 +1820,7 @@ export function slotTail(text, W, rows) {
1972
1820
  return [];
1973
1821
  const p = palette();
1974
1822
  const all = blockRows(text, W);
1975
- const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(BODY_ROW));
1823
+ const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(bodyRow()));
1976
1824
  const body = from < 0 ? [] : all.slice(from);
1977
1825
  // R7a: no pad. The slot stopped padding (see slotPad) and this was
1978
1826
  // the same pad by another route — three blank rows under a call with
@@ -2133,7 +1981,18 @@ class MarkdownBlock {
2133
1981
  this.cell = cell;
2134
1982
  }
2135
1983
  render(W, _ctx) {
2136
- return renderBlock(this.cell.block, W);
1984
+ // R13 E3 — THE MODEL'S WORDS MOVE TO COLUMN 2. A card's rows sit
1985
+ // there (E4) and the chip's text does too (D4), so prose at column
1986
+ // 0 would leave the page with three left edges for three registers
1987
+ // — the opposite of one rhythm. The registers are told apart by
1988
+ // SURFACE, which is §1.6's argument; the column is not one of the
1989
+ // things doing that work.
1990
+ //
1991
+ // The block folds in the room the indent leaves, so invariant ①
1992
+ // holds by construction. A block's own leading blank (its `gap`)
1993
+ // stays EMPTY: an indented blank row is trailing whitespace, and
1994
+ // §1.3 forbids a mark on a row with nothing to mark.
1995
+ return renderBlock(this.cell.block, Math.max(1, W - PROSE_COL.length)).map((r) => (r === "" ? r : `${PROSE_COL}${r}`));
2137
1996
  }
2138
1997
  }
2139
1998
  /** The notice lines — the error surface. */