@vincemakes/kiso-tui-cells 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,15 @@
8
8
  * lives HERE: every line a component returns must fit the terminal
9
9
  * width — the compositor's crash-on-violation invariant backs it up
10
10
  * (a component that forgets to fold CRASHES with a diagnostic, never
11
- * silently truncates — the crash is the contract, not a symptom).
11
+ * silently truncates — the crash is the contract UNDER TEST; in the
12
+ * field the row is cut and the fact is said, once, through the notice
13
+ * channel. DECLARED REVERSAL of "the crash is the contract, not a
14
+ * symptom" (owner-lane, 2026-09-04): two instances of this class in two
15
+ * days, one caught by a gate (DC-45) and one by the owner on the first
16
+ * frame of an ordinary command (DC-48). In a gate the crash is right; in
17
+ * a human's hands it costs them the composer and the session to save
18
+ * them a row one column too wide. `KISO_INVARIANTS=throw` is what every
19
+ * suite here runs under).
12
20
  *
13
21
  * The fold is SGR-AWARE: a line whose bold/dim span would straddle a
14
22
  * fold boundary closes the span at the break and reopens it on the
@@ -118,9 +126,20 @@ export { visibleWidth } from "./width.js";
118
126
  export function bodySpacing(prev, rows) {
119
127
  if (rows.length === 0 || prev === null || prev.length === 0)
120
128
  return rows;
121
- if (rows.length > 1 || prev.length > 1)
122
- return ["", ...rows];
123
- return rows;
129
+ // R13 D1 — ONE blank between any two elements, whatever their height.
130
+ //
131
+ // W11 spaced by height: one-row siblings packed tight, anything
132
+ // multi-row breathed on both sides. A reader could not tell where the
133
+ // next blank would fall — and worse, the spacing was a function of a
134
+ // cell's CURRENT height, so a cell growing from one row to five moved
135
+ // everything around it. That is the mechanism behind R7a and behind
136
+ // R12 Round 2's settle shift, both of them "the screen moved under
137
+ // the reader".
138
+ //
139
+ // A constant cannot do that: a live block, its settled form and the
140
+ // card it becomes are spaced identically BY CONSTRUCTION, which is
141
+ // exactly what R7a's one-row stand-in was simulating.
142
+ return ["", ...rows];
124
143
  }
125
144
  /** The container — vertical concatenation with the W11 formula. No
126
145
  * component decides its own spacing: every blank in the body is the
@@ -212,6 +231,9 @@ export function cellComponent(cell) {
212
231
  * the same rule as three thousand short ones.
213
232
  */
214
233
  const USER_CHIP_ROWS = 12;
234
+ /** R13 D4 — the chip's inner pad: two columns, so its text begins in
235
+ * the same column as everything else on the page. */
236
+ const CHIP_PAD = " ";
215
237
  class UserMessage {
216
238
  cell;
217
239
  constructor(cell) {
@@ -219,7 +241,12 @@ class UserMessage {
219
241
  }
220
242
  render(W, _ctx) {
221
243
  const p = palette();
222
- const chipW = Math.max(1, W - 2);
244
+ // R13 D4 — TWO columns of inner pad, where R2 had one. The chip
245
+ // keeps its surface (reverse video, one row per folded line, no
246
+ // pad rows — R12 Round 2's ruling stands); what changes is where
247
+ // its text STARTS, so the human's words begin in the same column
248
+ // as the model's (E3) and as a card's rows (E4).
249
+ const chipW = Math.max(1, W - 2 * CHIP_PAD.length);
223
250
  const rows = [];
224
251
  // REL-0152-D13: fold only as far as the bound needs. A pasted file
225
252
  // has thousands of lines and folding all of them to show twelve is
@@ -264,7 +291,7 @@ class UserMessage {
264
291
  // row is two cells per character and pads by cells.
265
292
  const inner = chipW;
266
293
  for (const row of content) {
267
- rows.push(`${p.rv} ${row}${" ".repeat(Math.max(0, inner - displayWidth(row)))} ${p.rvEnd}`);
294
+ rows.push(`${p.rv}${CHIP_PAD}${row}${" ".repeat(Math.max(0, inner - displayWidth(row)))}${CHIP_PAD}${p.rvEnd}`);
268
295
  }
269
296
  if (!truncated)
270
297
  return rows;
@@ -276,7 +303,16 @@ class UserMessage {
276
303
  const shown = rows.length;
277
304
  const total = paras.length;
278
305
  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}`);
306
+ // DC-45: THE NOTICE FOLDS TOO. It was written at a fixed 30 columns
307
+ // and emitted verbatim at every width, so a paste of thirteen lines
308
+ // in a terminal narrower than the sentence tripped the compositor's
309
+ // invariant ① and killed the session. The tiers are TUI2-R1.5 ⑤'s
310
+ // discipline: `sent in full` is the SEMANTICS — the whole reason
311
+ // the row exists — so the count gives way before it, and `cutLine`
312
+ // is the backstop that holds at any width there is.
313
+ const count = more > 0 ? `+${more} more line${more === 1 ? "" : "s"}` : "cut here";
314
+ const short = more > 0 ? `+${more}` : "cut";
315
+ 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
316
  return rows;
281
317
  }
282
318
  }
@@ -338,7 +374,27 @@ class ThinkingBlock {
338
374
  const text = escapeTerminal(this.cell.text).trim();
339
375
  if (text === "")
340
376
  return [];
341
- const room = Math.max(1, W - 2);
377
+ // DC-47 — THINKING GOES ONE LEVEL DEEPER THAN PROSE, and the
378
+ // reason is a law rather than a taste.
379
+ //
380
+ // §7.2: the indent is the price of §1.2 — italic and dim are
381
+ // escape sequences, so a rendered frame with its colour stripped
382
+ // (a terminal capture, a paste out of the scrollback, a log of
383
+ // what was drawn) would lose the line between the model's
384
+ // reasoning and its answer. R13's E3 moved PROSE to column 2,
385
+ // which is where the thinking already was, so after
386
+ // `sed 's/\x1b\[[0-9;]*m//g'` the two became the same row.
387
+ // Measured, not reasoned: both rendered
388
+ // `" Weighing the two shapes."` exactly.
389
+ //
390
+ // NOT a pipe, though §7.2 used to say so: `thinkingEnd`'s inactive
391
+ // path writes `foldThinking` — one dim line — so a pipe never sees
392
+ // a thinking paragraph to confuse with prose.
393
+ //
394
+ // So the thinking takes the next column in. It is still the only
395
+ // carrier that survives a pipe, and it is still one indent step —
396
+ // what moved is which step, because prose took the one it had.
397
+ const room = Math.max(1, W - THINK_COL.length);
342
398
  const rows = [];
343
399
  for (const para of text.split(/\n\s*\n/)) {
344
400
  const flat = para.replace(/\s+/g, " ").trim();
@@ -350,7 +406,7 @@ class ThinkingBlock {
350
406
  // invariant ①b (a row is one physical row) holds by
351
407
  // construction rather than by remembering to split.
352
408
  for (const line of foldLine(flat, room))
353
- rows.push(` ${p.dim}${p.italic}${line}${p.italicEnd}${p.reset}`);
409
+ rows.push(`${THINK_COL}${p.dim}${p.italic}${line}${p.italicEnd}${p.reset}`);
354
410
  }
355
411
  return rows;
356
412
  }
@@ -633,39 +689,10 @@ class ToolExecution {
633
689
  const c = this.cell;
634
690
  const verb = escapeTerminal(displayVerb(c.name));
635
691
  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
- }
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).
669
696
  if (c.state === "done") {
670
697
  // R3i phase 5: an answered (or declined) ask_user renders its
671
698
  // OWN block — the questions and what the human said. The row
@@ -689,7 +716,7 @@ class ToolExecution {
689
716
  if (c.reason !== null) {
690
717
  const by = attribution(c);
691
718
  const out = gutterCut(" ", `${p.red}${escapeTerminal(`${c.name} ${toolTargetOf(c)}`)} (${escapeTerminal(c.reason)}${by})${p.reset}`, W);
692
- out.push(...toolBlockBody(c, W));
719
+ out.push(...toolBlockBody(c, W, ctx));
693
720
  return out;
694
721
  }
695
722
  const elapsed = c.startedAt !== null && c.doneAt !== null ? ((c.doneAt - c.startedAt) / 1000).toFixed(1) : "?";
@@ -703,8 +730,17 @@ class ToolExecution {
703
730
  // "200 of 250 lines", a diff's "+1 -1", a shell's "exit 0" — is a
704
731
  // different fact and stays.
705
732
  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);
733
+ // VD-6's suppression RETIRED with the thing it was suppressing.
734
+ // A read card said `(2 lines, 0.0s) · 2 lines · ctrl+o expands`
735
+ // because the parens and the suffix were written by different
736
+ // rounds, each unaware the other was counting; the fix blanked
737
+ // the meta whenever the suffix would repeat it. R13 takes the
738
+ // count OUT of the suffix, so there is one place again and the
739
+ // meta is it — blanking it now would drop the fact entirely.
740
+ // "Stated exactly once" is unchanged and is what `countedHead`
741
+ // below preserves: a meta that already counts lines gets no
742
+ // second count beside it.
743
+ const meta = escapeTerminal(rawMeta);
708
744
  // A4: the target rides the settled head row — the verb's
709
745
  // summary column (W3's 5-char pad keeps the paths lined up).
710
746
  // A5: an extension's auto-approval appends `· approved by
@@ -724,7 +760,9 @@ class ToolExecution {
724
760
  // TUI2-R1.5 ⑤: the shortest tier is RESERVED — the affordance is
725
761
  // the semantics. TUI2-R1.5 pin 4: and the parts give way in a
726
762
  // 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));
763
+ const nAll = countLines(c.resultText);
764
+ const countedHead = nAll > 0 && !/^\d+( of \d+)? lines?$/.test(rawMeta) ? `${nAll} line${nAll === 1 ? "" : "s"}` : "";
765
+ const text = settledHeadText(verbCol, escapeTerminal(toolTargetOf(c)), meta, approvedBy, elapsed, W - 2 - (hidden === null ? 0 : SUFFIX_MIN), countedHead);
728
766
  // R2 (owner, 2026-08-27): no tick, no cross. A symbol earns its
729
767
  // cell by carrying a fact the words do not, and a row that
730
768
  // already says `exit 0` does not need one more thing saying it
@@ -732,8 +770,7 @@ class ToolExecution {
732
770
  // metadata, in words, which is also the only form that survives
733
771
  // a pipe with the colour stripped. A failure keeps its colour
734
772
  // AND its words — see settledMeta.
735
- const parts = toolBlockParts(c, W);
736
- const body = parts.rows;
773
+ const body = toolBlockParts(c, W, ctx).rows;
737
774
  if (body.length > 0 && c.expanded) {
738
775
  // An EXPANDED block is already showing everything, and its own
739
776
  // footer ("ctrl+o collapses") is what closes it. Giving it an
@@ -743,13 +780,13 @@ class ToolExecution {
743
780
  const head = c.isError ? ` ${p.red}${text}${p.reset}` : ` ${text}`;
744
781
  return slabBlock(appendSuffix(head, expandSuffix(hidden, W - visibleWidth(head))), body, null, W);
745
782
  }
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.
783
+ if (body.length > 0) {
784
+ // R13 — THE CARD, when there is something to preview: the head
785
+ // row names the call, the preview sits inside, and the outcome
786
+ // CLOSES it on its own line (§7.5's words, moved off the head
787
+ // row because the head row is no longer the only row). D6: the
788
+ // target is bold there — the head row's job is to say WHAT was
789
+ // run, and the metadata has its own row now.
753
790
  //
754
791
  // A failure takes NO tint on the head row (R9): only the
755
792
  // outcome word is coloured, which is §1.2 exactly — the
@@ -767,28 +804,28 @@ class ToolExecution {
767
804
  // what happened and how long it took — is never cut open.
768
805
  const join = (...xs) => xs.filter((x) => x !== "").join(" · ");
769
806
  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));
807
+ const words = pickTier([join(meta, counted, `${elapsed}s`, attr), join(meta, counted, `${elapsed}s`), join(meta, `${elapsed}s`), meta], W - visibleWidth(noteIndent()));
771
808
  const outcome = c.isError ? `${p.red}${words}${p.reset}` : words;
772
809
  return slabBlock(head, body, outcome, W);
773
810
  }
774
- // No output on screen: the row is PLAIN, and the outcome stays on
775
- // it in the form every width gate already pins.
811
+ // R13 — nothing to preview: the SAME card, three rows, with the
812
+ // outcome riding the head row because there is nothing between
813
+ // them to close.
776
814
  //
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;
815
+ // DECLARED REVERSAL of the owner's 2026-09-02 narrowing ("a call
816
+ // with no output on screen has no slab at all"), which read §1.6
817
+ // as giving the wash to the machine's VERBATIM text only. The
818
+ // ruling of 2026-09-03 supersedes it: the surface says WORK, not
819
+ // VERBATIM, and a page where some calls are cards and others are
820
+ // loose rows is the instability the owner was pointing at. §1.6
821
+ // moves with it.
822
+ const bare = c.isError ? ` ${p.red}${text}${p.reset}` : ` ${text}`;
823
+ return slabBlock(appendSuffix(bare, expandSuffix(hidden, W - visibleWidth(bare))), [], null, W);
787
824
  }
788
825
  if (c.state === "approval") {
789
826
  // W2: the ❯ is the GUTTER (the left edge), never the line's tail
790
827
  const out = gutterCut(`${p.bold}❯${p.reset} `, `${verbCol} ${liveTarget(c)}`, W);
791
- out.push(...toolBlockBody(c, W));
828
+ out.push(...toolBlockBody(c, W, ctx));
792
829
  return out;
793
830
  }
794
831
  if (c.state === "running") {
@@ -809,10 +846,68 @@ class ToolExecution {
809
846
  // cannot be predicted: a turning mark implies progress the
810
847
  // product does not have. With no ground the breath freezes to a
811
848
  // 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;
849
+ // R13 E2 — A RUNNING CALL IS THE SAME CARD, allocated at the
850
+ // SETTLED card's height from its first frame. The settle then
851
+ // changes CONTENT and never position: the spinner becomes two
852
+ // spaces in the same two columns, the live window gives back
853
+ // the rows the result did not need, and the metadata row that
854
+ // said `3s` says `exit 0 · 90 lines · 3.2s`.
855
+ //
856
+ // The elapsed moves OFF the head row onto that metadata row,
857
+ // which is where the settled card keeps it — the head row's job
858
+ // is to say what is running, and it says the same thing before
859
+ // and after.
860
+ //
861
+ // DC-43: with too little room for the seven-row skeleton (two
862
+ // pads, the head, two blanks, one preview row and the metadata)
863
+ // there is no card — the call keeps its head row until it
864
+ // commits, which is the one form that fits anywhere.
865
+ const liveRows = ctx.liveWindow ?? CAP_PREVIEW;
866
+ const gutter = ctx.grouped === true ? " " : `${breathFrame(ctx.spinnerI)} `;
867
+ if (liveRows <= 0) {
868
+ // the degraded form is the head row ALONE, so it keeps the
869
+ // duration it would otherwise have lost with its card — cut
870
+ // against the room the duration leaves (VD-4: the duration
871
+ // is its own segment, never welded to a cut word).
872
+ const bare = gutterCut(gutter, `${verbCol} ${liveTarget(c)}`, Math.max(4, W - dur.length));
873
+ return [`${bare[0]}${p.dim}${dur}${p.reset}`];
874
+ }
875
+ // DC-46 — the two GESTURES ride the status row, where they cost
876
+ // nothing. They were a footer INSIDE the window, spending one of
877
+ // its rows on a sentence that is not output; with the window
878
+ // grown from its content, that row was the difference between a
879
+ // settle that swaps content and one that changes height. The
880
+ // row's shape is the settled outcome row's, so the settle
881
+ // rewrites it in place: `3s · esc stops` → `exit 0 · 90 lines ·
882
+ // 3.2s`.
883
+ const gestures = c.name === "shell" ? " · esc stops · alt+⏎ redirects" : "";
884
+ const status = pickTier([`${elapsed}s${gestures}`, `${elapsed}s`], Math.max(1, W - visibleWidth(noteIndent())));
885
+ const live = toolBlockBody(c, W, ctx);
886
+ if (live.length > 0)
887
+ return slabBlock(gutterCut(gutter, `${verbCol} ${liveTarget(c)}`, W)[0], live, status, W);
888
+ // DC-48 — THE THREE-ROW CARD IS ONE ROW, so it is assembled here
889
+ // against the room it actually has.
890
+ //
891
+ // This branch used to cut the head to `W` and hand it to
892
+ // `slabBlock`, which joins head and outcome and cut nothing —
893
+ // so the row came out `W` wide PLUS the whole status, and the
894
+ // compositor did what it promises: it threw. On the owner's
895
+ // 80-column terminal a long `find` produced a 113-column row on
896
+ // its FIRST FRAME, which is the first second of every command.
897
+ //
898
+ // Pin 4's order: the command is the cuttable span and the
899
+ // elapsed is never cut open, so the command takes what the
900
+ // status leaves — the `· ` between them and the two-column
901
+ // gutter included.
902
+ // the status takes its own tier against the room the row has, not
903
+ // against a whole width: on a narrow terminal the gestures give
904
+ // way so the COMMAND keeps something to say, and the elapsed —
905
+ // pin 4's core — never does.
906
+ const MIN_TARGET = 10; // the gutter, the verb column, a character of command
907
+ const oneRow = pickTier([`${elapsed}s${gestures}`, `${elapsed}s`], Math.max(1, W - MIN_TARGET - 3));
908
+ const room = Math.max(4, W - visibleWidth(oneRow) - 3);
909
+ const only = gutterCut(gutter, `${verbCol} ${liveTarget(c)}`, room)[0];
910
+ return slabBlock(`${only}${p.dim} · ${oneRow}${p.reset}`, [], null, W);
816
911
  }
817
912
  // W2: ◦ replaces → for QUEUED — · is the separator inside every
818
913
  // metadata group; a queued marker that is also the separator
@@ -839,7 +934,7 @@ class ToolExecution {
839
934
  * never a cap.
840
935
  */
841
936
  function hiddenLines(c, W) {
842
- if (c.expanded || c.state !== "done" || c.rolled !== null || c.reason !== null)
937
+ if (c.expanded || c.state !== "done" || c.reason !== null)
843
938
  return null;
844
939
  if (c.name === "delegate")
845
940
  return null; // its body is the one-line summary, always whole
@@ -848,13 +943,14 @@ function hiddenLines(c, W) {
848
943
  return null;
849
944
  if (c.isError)
850
945
  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.
946
+ // R13: a call that PREVIEWS carries the key on its own note row when
947
+ // something is cut, and needs no affordance at all when nothing is.
853
948
  // 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")
949
+ // one cell — the thing that rule exists to forbid. read_file is the
950
+ // one call with no preview (E1), so the key lives on its head row.
951
+ if (c.name !== "read_file")
856
952
  return null;
857
- return n; // every other settled call renders NO body — all of it is behind the key
953
+ return n;
858
954
  }
859
955
  /**
860
956
  * TUI2-R1 (A) — the suffix, in the width that is LEFT.
@@ -900,12 +996,24 @@ const SUFFIX_MIN = " · ctrl+o".length;
900
996
  * compressible thing on the row — a reader recognises a command
901
997
  * from its head — and it is the only part with a natural ellipsis.
902
998
  */
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)`;
999
+ function settledHeadText(verbCol, target, meta, attr, elapsed, room, counted = "") {
1000
+ // R13 — ONE GRAMMAR FOR BOTH CARDS. This row used to close with W4's
1001
+ // parentheses — `read a.ts (0.1s) · 10 lines · ctrl+o expands` —
1002
+ // while the bodied card's outcome row said `exit 0 · 90 lines · 0.4s`
1003
+ // in a `·` chain. Two shapes for the same facts, and which one a call
1004
+ // got depended on whether it happened to have a preview. The chain
1005
+ // wins: it is the one the outcome row already uses, it reads in one
1006
+ // direction, and it puts the elapsed where the other card puts it.
1007
+ //
1008
+ // The giving-way order is pin 4's, unchanged: the ATTRIBUTION drops
1009
+ // first, then the count, and the core — what happened and how long it
1010
+ // took — is never cut open; below that the target itself truncates.
1011
+ const join = (...xs) => xs.filter((x) => x !== "").join(" · ");
1012
+ const core = join(meta, counted, `${elapsed}s`);
1013
+ const withAttr = join(meta, counted, `${elapsed}s`, attr.replace(" · ", ""));
906
1014
  const lead = `${verbCol} `;
907
- const fit = (t, parens) => {
908
- const line = `${lead}${t}${parens === "" ? "" : ` ${parens}`}`;
1015
+ const fit = (t, tail) => {
1016
+ const line = `${lead}${t}${tail === "" ? "" : ` · ${tail}`}`;
909
1017
  return visibleWidth(line) <= room ? line : null;
910
1018
  };
911
1019
  // 1. everything
@@ -916,12 +1024,26 @@ function settledHeadText(verbCol, target, meta, attr, elapsed, room) {
916
1024
  const bare = fit(target, core);
917
1025
  if (bare !== null)
918
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;
919
1032
  // 3. the target truncates, the core stays whole
920
- const budget = room - visibleWidth(lead) - visibleWidth(core) - 2; // the space + the ellipsis
1033
+ const stem = join(meta, `${elapsed}s`);
1034
+ const budget = room - visibleWidth(lead) - visibleWidth(stem) - 4; // the ellipsis + " · "
921
1035
  if (budget >= 1)
922
- return `${lead}${widthCut(target, budget)}… ${core}`;
923
- // 4. below that even the core cannot ride: the row is the call's
924
- // identity and its affordance, and no half-open parenthesis.
1036
+ return `${lead}${widthCut(target, budget)}… · ${stem}`;
1037
+ // 4. DC-48 — the ELAPSED still rides, and the target takes what is
1038
+ // left. This used to return the target alone, on the argument that
1039
+ // a cut core would leave a half-open parenthesis; R13's chain has
1040
+ // no bracket to leave open, so the reason retired with the
1041
+ // parentheses and pin 4's own rule applies at every width: what
1042
+ // happened and how long it took is never cut away.
1043
+ const floor = `${elapsed}s`;
1044
+ const left = room - visibleWidth(lead) - visibleWidth(floor) - 4; // the ellipsis + " · "
1045
+ if (left >= 1)
1046
+ return `${lead}${widthCut(target, left)}… · ${floor}`;
925
1047
  return `${lead}${widthCut(target, Math.max(1, room - visibleWidth(lead)))}`;
926
1048
  }
927
1049
  /**
@@ -947,8 +1069,13 @@ function attribution(c) {
947
1069
  export function expandSuffix(lines, room) {
948
1070
  if (lines === null)
949
1071
  return "";
950
- const count = `${lines} line${lines === 1 ? "" : "s"}`;
951
- for (const tier of [` · ${count} · ctrl+o expands`, ` · ${count} · ctrl+o`, " · ctrl+o"]) {
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
+ for (const tier of [" · ctrl+o expands", " · ctrl+o"]) {
952
1079
  if (tier.length <= room)
953
1080
  return tier;
954
1081
  }
@@ -1051,58 +1178,6 @@ const EXPLORE_NOUN = {
1051
1178
  export function isExploreTool(name) {
1052
1179
  return EXPLORE_NOUN[name] !== undefined;
1053
1180
  }
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
1181
  /** W14 — the folded-turn line: a whole QUIET turn (no text), once it is
1107
1182
  * scrollback, becomes ONE line — the work order's claimed shape
1108
1183
  * (`▞ thought 19s · 5 reads · no edits`), the counts accumulated at
@@ -1340,252 +1415,19 @@ function stretchTerms(t, live) {
1340
1415
  return `${e[tense(name)]} ${n} ${n === 1 ? e[2] : e[3]}`;
1341
1416
  });
1342
1417
  }
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
1418
  // ---- the bounded-block flow contract (W7, W8, W10) ----
1583
1419
  /** The caps — screen rows counted AFTER the fold, at the current width
1584
1420
  * (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)
1421
+ /** R13 — ONE preview cap, every tool. It was the shell's alone while
1422
+ * the shell was the only settled call with rows on screen. */
1423
+ export const CAP_PREVIEW = 5;
1424
+ /** DC-46 — the running window's ceiling is the SETTLED preview's, and a
1425
+ * running card reaches it by growing rather than by being handed it.
1426
+ * `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;
1587
1430
  const CAP_DIFF = 12; // the approval diff: head + the named middle + tail
1588
- const CAP_ERROR = 3; // the error text head
1589
1431
  /** The block body rows' prefixes (W2's gutter table): │ a bounded
1590
1432
  * block's body, └ the block's last row — what was cut, where the rest
1591
1433
  * is — at the LEFT EDGE (the gutter column: the left edge alone
@@ -1600,13 +1442,35 @@ const CAP_ERROR = 3; // the error text head
1600
1442
  * prose (2). Bytes still tell them apart; no column of glyphs.
1601
1443
  *
1602
1444
  * `└` 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,
1445
+ * row (see openBlock). In-block notes take the same indent,
1604
1446
  * no glyph — because a second `└` inside one block would be the same
1605
1447
  * mark meaning two things (§4.1). CUT_ROW is unchanged for the
1606
1448
  * surfaces that are not a tool block: the fold row's target list, the
1607
1449
  * slot's overflow count. */
1608
- const BODY_ROW = " ";
1609
- const NOTE_ROW = " ";
1450
+ /** R8a's four columns — off the surface. Inside a painted card every
1451
+ * row sits at column 2 (R13 E4): the head row and the outcome row
1452
+ * bracket the preview, so the indent is no longer what says "these
1453
+ * rows are output". Where nothing paints, it is exactly that, and R8a
1454
+ * stands unchanged. */
1455
+ const BODY_ROW_FLAT = " ";
1456
+ const NOTE_ROW_FLAT = " ";
1457
+ const CARD_ROW = " ";
1458
+ /** R13 E3 — the column the model's words begin in, the same one the
1459
+ * card's rows and the chip's text begin in. */
1460
+ const PROSE_COL = " ";
1461
+ /** DC-47, ADJUDICATED — the model's THINKING begins in the SAME column
1462
+ * as everything else: two.
1463
+ *
1464
+ * It went to four when E3 moved prose to two, so that stripping the
1465
+ * escapes would still tell them apart (§1.2). The owner looked at it
1466
+ * and ruled against it: "the thinking area is not indented by the same
1467
+ * two as the first line — it needs to keep the same first-line indent
1468
+ * as everything else" (2026-09-04). §1.8's one left edge outranks the
1469
+ * distinction, and §1.2 takes a DECLARED EXCEPTION for this one pair —
1470
+ * see design.md §1.2 and §7.2 for what is given up and what is not. */
1471
+ const THINK_COL = " ";
1472
+ const bodyRow = () => (slabPaints() ? CARD_ROW : BODY_ROW_FLAT);
1473
+ const noteIndent = () => (slabPaints() ? CARD_ROW : NOTE_ROW_FLAT);
1610
1474
  const CUT_ROW = "└ ";
1611
1475
  /**
1612
1476
  * R9 P2 — THE SLAB: a single call's block is one washed object.
@@ -1665,7 +1529,7 @@ function noteRow(text, W, tone) {
1665
1529
  // its own. The row names a fact; a cut names it shorter.
1666
1530
  const open = tone === "body" ? p.washDim : p.dim;
1667
1531
  const close = tone === "body" ? p.washDimEnd : p.reset;
1668
- return [cutLine(`${open}${NOTE_ROW}${text}${close}`, W)];
1532
+ return [cutLine(`${open}${noteIndent()}${text}${close}`, W)];
1669
1533
  }
1670
1534
  /**
1671
1535
  * Assemble a call's block.
@@ -1682,18 +1546,29 @@ function slabBlock(head, body, outcome, W) {
1682
1546
  out.push(...noteRow(outcome, W, "dim"));
1683
1547
  return out;
1684
1548
  }
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));
1549
+ // R13 — THE CARD. pad · head · blank · preview · blank · outcome ·
1550
+ // pad, and a call with nothing to preview is three rows with its
1551
+ // outcome riding the head. Every row sits at column 2 (E4): R8a put
1552
+ // a block's body at column 4 so a pipe could tell output from prose,
1553
+ // and inside a painted card the head and the outcome bracket it
1554
+ // instead — off the surface R8a's indent is still the fact, which is
1555
+ // why the degradation above keeps it.
1556
+ if (body.length === 0) {
1557
+ // DC-48: the join makes a ROW, so it is cut like every other row.
1558
+ // The callers above size their parts against the room they have;
1559
+ // this is the backstop that makes invariant ① hold whatever they
1560
+ // do, and it is what was missing when a running card's head was
1561
+ // cut to W and then had a whole status appended to it.
1562
+ const only = cutLine(outcome === null ? head : `${head} ${outcome}`, W);
1563
+ return [slabRow("", W), slabRow(only, W), slabRow("", W)];
1695
1564
  }
1696
- return rows;
1565
+ const top = [slabRow("", W), slabRow(head, W), slabRow("", W), ...body.map((r) => slabRow(r, W))];
1566
+ // An EXPANDED block closes with its own footer and passes no outcome;
1567
+ // a blank row and an empty metadata row under it would be §1.3's
1568
+ // empty mark twice over.
1569
+ if (outcome === null)
1570
+ return [...top, slabRow("", W)];
1571
+ return [...top, slabRow("", W), ...noteRow(outcome, W, "body").map((r) => slabRow(r, W)), slabRow("", W)];
1697
1572
  }
1698
1573
  /** R8a — stamp `└` on a block's FIRST row, after every slice and note
1699
1574
  * has been assembled, so the mark is always on the first row actually
@@ -1703,25 +1578,26 @@ function openBlock(rows) {
1703
1578
  // or a blank leading output line can put an empty row first, and a
1704
1579
  // corner there would be a mark on a row with nothing to mark — law
1705
1580
  // 1.3, which is the rule this whole change is serving.
1706
- const i = rows.findIndex((r) => visibleWidth(r) > visibleWidth(BODY_ROW));
1581
+ const i = rows.findIndex((r) => visibleWidth(r) > visibleWidth(bodyRow()));
1707
1582
  if (i < 0)
1708
1583
  return rows;
1709
1584
  const first = rows[i];
1710
- const at = first.indexOf(BODY_ROW);
1585
+ const at = first.indexOf(BODY_ROW_FLAT);
1711
1586
  if (at < 0)
1712
1587
  return rows;
1713
1588
  // the corner REPLACES two of the four indent columns, so the text
1714
1589
  // 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)];
1590
+ return [...rows.slice(0, i), `${first.slice(0, at)} \u2514 ${first.slice(at + BODY_ROW_FLAT.length)}`, ...rows.slice(i + 1)];
1716
1591
  }
1717
1592
  const blockMemo = new WeakMap();
1718
1593
  /** The block's body rows below the header (memoized, W9). */
1719
- function toolBlockParts(c, W) {
1594
+ function toolBlockParts(c, W, ctx) {
1720
1595
  const memo = blockMemo.get(c);
1721
1596
  // the SURFACE is part of the key: the same cell renders different rows
1722
1597
  // painted and unpainted, and a ground resolved after the first frame
1723
1598
  // would otherwise be served the pre-ground shape forever.
1724
- const state = `${c.state}:${c.isError}:${c.name}:${c.expanded ? "x" : ""}:${slabPaints() ? "slab" : "flat"}`;
1599
+ const liveRows = ctx.liveWindow ?? CAP_PREVIEW;
1600
+ const state = `${c.state}:${c.isError}:${c.name}:${c.expanded ? "x" : ""}:${slabPaints() ? "slab" : "flat"}:${liveRows}`;
1725
1601
  const content = c.state === "approval" ? (c.diff ?? null) : c.resultText;
1726
1602
  if (memo !== undefined && memo.width === W && memo.state === state && memo.content === content)
1727
1603
  return memo;
@@ -1743,62 +1619,71 @@ function toolBlockParts(c, W) {
1743
1619
  ? errorBody(c, W)
1744
1620
  : c.name === "delegate"
1745
1621
  ? 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
- : []
1622
+ : // R13 — EVERY settled call previews. The shell's tail is
1623
+ // VD-5's own cap and direction (the conclusion is at the
1624
+ // end); everything else shows its head, because that is
1625
+ // where its answer is. read_file is the single exception
1626
+ // (E1): its result is the file, kiso has nothing to add by
1627
+ // showing five lines of it, and the head row's key opens
1628
+ // the whole thing.
1629
+ noPreview(c)
1630
+ ? []
1631
+ : c.name === "shell"
1632
+ ? shellTail(c.resultText, W, slabPaints() ? "body" : "dim")
1633
+ : previewHead(c.resultText, W, slabPaints() ? "body" : "dim")
1759
1634
  : c.state === "running"
1760
1635
  ? c.name === "delegate"
1761
1636
  ? delegateRunning(c, W)
1762
- : c.name === "shell"
1763
- ? shellLiveTail(c.resultText, W)
1764
- : liveWindow(c.resultText, W)
1637
+ : // R13 E1 — the call with no preview settled has none while
1638
+ // it runs either; its card is three rows the whole way.
1639
+ noPreview({ ...c, state: "done" })
1640
+ ? []
1641
+ : liveWindow(c, W, liveCap(c, liveRows), slabPaints() ? "body" : "dim")
1765
1642
  : c.state === "approval"
1766
1643
  ? diffBody(c.diff, W)
1767
1644
  : [];
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;
1645
+ // E1 governs the PREVIEW — five lines of a file kiso has nothing to
1646
+ // add to — not kiso's sentence about the result. `offset=201 for the
1647
+ // rest` is actionable and no other row says it (W10 pins exactly
1648
+ // that), so a read the TOOL capped takes one body row and a read it
1649
+ // did not takes none. That shape difference is information: the two
1650
+ // events are different, and the row is what says so.
1773
1651
  const note = c.expanded ? null : toolCutNote(c.name, c.resultText);
1774
1652
  if (note !== null)
1775
- rows.push(...foldLine(`${p.dim}${NOTE_ROW}${note}${p.reset}`, W));
1653
+ rows.push(...foldLine(`${p.dim}${noteIndent()}${note}${p.reset}`, W));
1776
1654
  // TUI2-R1 (A): an EXPANDED block says how to put it back. The footer
1777
1655
  // rides a block that HAS rows — an expanded delegate whose summary
1778
1656
  // marker is missing renders nothing, and a lone footer under a head
1779
1657
  // row would be an affordance for an empty block.
1780
1658
  if (c.expanded && rows.length > 0)
1781
- rows.push(...foldLine(`${p.dim}${NOTE_ROW}${COLLAPSE_ROW}${p.reset}`, W));
1659
+ rows.push(...foldLine(`${p.dim}${noteIndent()}${COLLAPSE_ROW}${p.reset}`, W));
1782
1660
  // R9 P2: `└` opens a block that has no surface. Inside a slab the
1783
1661
  // surface IS the container, and a corner in it is §1.3's empty mark
1784
1662
  // one scale up — so the corner and the slab are alternatives, never
1785
1663
  // both.
1786
1664
  const opened = slabPaints() ? rows : openBlock(rows);
1787
- const parts = { width: W, state, content, rows: opened, output };
1665
+ const parts = { width: W, state, content, rows: opened };
1788
1666
  blockMemo.set(c, parts);
1789
1667
  return parts;
1790
1668
  }
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;
1669
+ /** R13 E1 — the one settled call that previews NOTHING. A read's result
1670
+ * IS the file; five lines of it tell a reader less than the head row
1671
+ * already does, and the key opens the whole thing. (The reference
1672
+ * implementation makes the same call, for the same reason.) Everything
1673
+ * else previews: a shell its tail, the rest their head. */
1674
+ function noPreview(c) {
1675
+ return c.state === "done" && !c.expanded && !c.isError && c.reason === null && c.name === "read_file";
1676
+ }
1677
+ /** The block's body rows. */
1678
+ function toolBlockBody(c, W, ctx) {
1679
+ return toolBlockParts(c, W, ctx).rows;
1795
1680
  }
1796
- /** Fold result text into dim body rows (the BODY_ROW prefix): escape,
1681
+ /** Fold result text into body rows (the block's own indent): escape,
1797
1682
  * split, fold each line at W−prefix; trailing empty rows (the result's
1798
1683
  * final newline) drop. */
1799
1684
  function blockRows(text, W, tone = "dim") {
1800
1685
  const p = palette();
1801
- const textW = Math.max(1, W - visibleWidth(BODY_ROW));
1686
+ const textW = Math.max(1, W - visibleWidth(bodyRow()));
1802
1687
  const rows = [];
1803
1688
  // R9 P2: inside a SLAB the output rows are body strength, never dim —
1804
1689
  // §2.1 bars dim from the wash (3.91:1 light, 4.35:1 dark) and these
@@ -1808,9 +1693,9 @@ function blockRows(text, W, tone = "dim") {
1808
1693
  const close = tone === "dim" ? p.reset : "";
1809
1694
  for (const raw of escapeTerminal(text).split("\n")) {
1810
1695
  for (const row of foldLine(raw, textW))
1811
- rows.push(`${open}${BODY_ROW}${row}${close}`);
1696
+ rows.push(`${open}${bodyRow()}${row}${close}`);
1812
1697
  }
1813
- while (rows.length > 0 && visibleWidth(rows[rows.length - 1]) === visibleWidth(BODY_ROW))
1698
+ while (rows.length > 0 && visibleWidth(rows[rows.length - 1]) === visibleWidth(bodyRow()))
1814
1699
  rows.pop();
1815
1700
  return rows;
1816
1701
  }
@@ -1820,23 +1705,43 @@ function blockRows(text, W, tone = "dim") {
1820
1705
  * direction). */
1821
1706
  function shellTail(text, W, tone = "dim") {
1822
1707
  const rows = blockRows(text, W, tone);
1823
- if (rows.length <= CAP_SHELL_SETTLED)
1708
+ if (rows.length <= CAP_PREVIEW)
1824
1709
  return rows;
1825
1710
  // R9 P2 / D4: FIVE output rows, and the note is a row of its own. The
1826
1711
  // pre-slab arithmetic spent one of the five on the cut note, because
1827
1712
  // the note had nowhere else to live; the slab's metadata rows are not
1828
1713
  // output and are not counted against the output's cap.
1829
- const kept = CAP_SHELL_SETTLED;
1714
+ const kept = CAP_PREVIEW;
1830
1715
  // The note goes ABOVE the tail: it says what was cut, and what was
1831
1716
  // cut is what came BEFORE these rows. One position on both surfaces —
1832
1717
  // 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)];
1718
+ return [...cutNote(rows.length - kept, "earlier", W, tone), ...rows.slice(rows.length - kept)];
1719
+ }
1720
+ /** R13 — the preview every OTHER settled call gets: the FIRST rows,
1721
+ * capped at five, the cut note BELOW them. A shell's conclusion is at
1722
+ * the bottom of its output, so its preview is the tail and its note
1723
+ * opens the block (shellTail above); a list, a search, a fetch all
1724
+ * answer at the top, so the note closes it. One rule, two directions,
1725
+ * and the direction follows where the answer is.
1726
+ *
1727
+ * This is the DECLARED REVERSAL of VD-5 for every tool but the shell
1728
+ * (0.22.0 reversed the shell alone): VD-5 collapsed a settled call to
1729
+ * its head row because ungrounded output rows owned the screen, and
1730
+ * the card is what changes that arithmetic — the rows are inside a
1731
+ * surface that says where the call begins and ends. */
1732
+ function previewHead(text, W, tone) {
1733
+ const rows = blockRows(text, W, tone);
1734
+ if (rows.length <= CAP_PREVIEW)
1735
+ return rows;
1736
+ return [...rows.slice(0, CAP_PREVIEW), ...cutNote(rows.length - CAP_PREVIEW, "more", W, tone)];
1737
+ }
1738
+ /** The preview's cut note, in one place so the head and the tail
1739
+ * directions cannot drift apart. The KEY is reserved (TUI2-R1.5 ⑤): a
1740
+ * row that says how much is hidden without saying how to see it is the
1741
+ * silence the affordance exists to remove. */
1742
+ function cutNote(cut, word, W, tone) {
1743
+ const n = `${cut} ${word} line${cut === 1 ? "" : "s"}`;
1744
+ return noteRow(pickTier([`… ${n} · ctrl+o expands`, `… ${n} · ctrl+o`, `… ${cut} · ctrl+o`, "· ctrl+o"], W - visibleWidth(noteIndent())), W, tone);
1840
1745
  }
1841
1746
  /** The error text head: the FIRST rows, capped at 3 — the answer is at
1842
1747
  * the start (opencode's collapseToolOutput direction). The header row
@@ -1852,81 +1757,77 @@ function errorBody(c, W) {
1852
1757
  // FULL content including the "[Permission denied] " prefix (never
1853
1758
  // hide information — the folded body rides the pinned row).
1854
1759
  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];
1760
+ // R13 — a failure is a CARD like any other call: the same cap of
1761
+ // five, the same note wording, the same direction (an error's answer
1762
+ // is at the top). It kept a cap of three and `+2 more · ctrl+o` from
1763
+ // before the card existed, which made the one shape a reader most
1764
+ // wants to read the one shape that showed least of itself.
1765
+ return previewHead(c.resultText.split("\n").slice(skipFirst).join("\n"), W, slabPaints() ? "body" : "dim");
1887
1766
  }
1888
1767
  /**
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.
1768
+ * DC-46 — THE RUNNING WINDOW GROWS, and nothing pads it.
1769
+ *
1770
+ * DECLARED REVERSAL of W8's fixed window and of E2 as first written
1771
+ * ("allocated at the settled card's height, and only ever shrinks at
1772
+ * settle"). Both fixed a height so it would not move while a command
1773
+ * ran; both fixed it at a height the SETTLE then changed, and the settle
1774
+ * is where the cost landed. A card allocated at twelve rows and settling
1775
+ * at three gives nine rows back, the window's top is clamped and cannot
1776
+ * follow, and the difference is a blank band above the composer.
1777
+ * Measured on the a7 replay: hole-frames 8.9 / 13.5 / 3.8 percent at
1778
+ * 0.23.0 against 16.9 / 24.6 / 7.9 with the shrink. The only source is
1779
+ * the shrink, so the cure is to stop shrinking.
1780
+ *
1781
+ * So the window IS its content: one row while nothing has arrived, one
1782
+ * more per line to the cap, then the cut note above a scrolling tail.
1783
+ * R7a's "blank, not a bar" retires with the padding it governed — it was
1784
+ * about what to draw on rows a FIXED height reserved, and no height is
1785
+ * reserved now.
1786
+ *
1787
+ * The direction is the SETTLED card's, so a settle swaps content and
1788
+ * moves nothing: a shell shows its tail with the note above, everything
1789
+ * else its head with the note below.
1904
1790
  */
1905
- function shellLiveTail(text, W) {
1906
- if (text === "")
1907
- return liveWindow("", W);
1791
+ function liveWindow(c, W, cap, tone) {
1908
1792
  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)];
1793
+ // TUI2-R1.5 ④(b) (VD-4): leading empty lines in the sidecar (a
1794
+ // 4096-byte tail can begin on a line boundary) are skipped, so the
1795
+ // output starts under its own header.
1796
+ const all = blockRows(c.resultText, W, tone);
1797
+ const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(bodyRow()));
1798
+ // DC-46, derived — NOTHING YET IS NO WINDOW AT ALL, so a running call
1799
+ // with no output is the same THREE-ROW card as a settled one with
1800
+ // none. The ruling's skeleton put a `waiting for output` row here; a
1801
+ // command that returns nothing (`true`, a silent build) would then
1802
+ // settle from seven rows to three, which is the very shrink the ruling
1803
+ // exists to remove — its own rule cannot hold with that row in place.
1804
+ //
1805
+ // Nothing is lost: the breathing mark says the call is in flight and
1806
+ // the status row's elapsed says how long, so a row reading "waiting
1807
+ // for output" carries no fact they do not (§1.3). The card grows the
1808
+ // instant a line arrives, and growth is what this design permits.
1809
+ if (from < 0)
1810
+ return [];
1811
+ const rows = all.slice(from);
1812
+ if (rows.length <= cap)
1813
+ return rows;
1814
+ return c.name === "shell"
1815
+ ? [...cutNote(rows.length - cap, "earlier", W, tone), ...rows.slice(rows.length - cap)]
1816
+ : [...rows.slice(0, cap), ...cutNote(rows.length - cap, "more", W, tone)];
1817
+ }
1818
+ /** DC-46 — the window's HIGH-WATER, per cell: the room a frame leaves
1819
+ * caps how far a window may GROW, and never shrinks one that already
1820
+ * grew. Without this a second call starting would pull the first's
1821
+ * window in, which is the same shrink by another route. Keyed on the
1822
+ * cell, like `blockMemo`, and it only ever matters while the cell is
1823
+ * live — a settled cell renders from its result alone. */
1824
+ const liveHighWater = new WeakMap();
1825
+ function liveCap(c, room) {
1826
+ const want = Math.min(CAP_PREVIEW, Math.max(1, room));
1827
+ const held = liveHighWater.get(c) ?? 0;
1828
+ const cap = Math.max(want, held);
1829
+ liveHighWater.set(c, cap);
1830
+ return cap;
1930
1831
  }
1931
1832
  /**
1932
1833
  * R4 — the standing act slot.
@@ -1972,7 +1873,7 @@ export function slotTail(text, W, rows) {
1972
1873
  return [];
1973
1874
  const p = palette();
1974
1875
  const all = blockRows(text, W);
1975
- const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(BODY_ROW));
1876
+ const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(bodyRow()));
1976
1877
  const body = from < 0 ? [] : all.slice(from);
1977
1878
  // R7a: no pad. The slot stopped padding (see slotPad) and this was
1978
1879
  // the same pad by another route — three blank rows under a call with
@@ -2133,7 +2034,18 @@ class MarkdownBlock {
2133
2034
  this.cell = cell;
2134
2035
  }
2135
2036
  render(W, _ctx) {
2136
- return renderBlock(this.cell.block, W);
2037
+ // R13 E3 — THE MODEL'S WORDS MOVE TO COLUMN 2. A card's rows sit
2038
+ // there (E4) and the chip's text does too (D4), so prose at column
2039
+ // 0 would leave the page with three left edges for three registers
2040
+ // — the opposite of one rhythm. The registers are told apart by
2041
+ // SURFACE, which is §1.6's argument; the column is not one of the
2042
+ // things doing that work.
2043
+ //
2044
+ // The block folds in the room the indent leaves, so invariant ①
2045
+ // holds by construction. A block's own leading blank (its `gap`)
2046
+ // stays EMPTY: an indented blank row is trailing whitespace, and
2047
+ // §1.3 forbids a mark on a row with nothing to mark.
2048
+ return renderBlock(this.cell.block, Math.max(1, W - PROSE_COL.length)).map((r) => (r === "" ? r : `${PROSE_COL}${r}`));
2137
2049
  }
2138
2050
  }
2139
2051
  /** The notice lines — the error surface. */