@vincemakes/kiso-tui 0.24.5 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,14 +11,17 @@
11
11
  * the reference implementation's model): a line COMMITS (leaves the
12
12
  * live region) via the real-LF
13
13
  * scroll at the last row (`\x1b[1B\n` — CUP-free) when its cell is
14
- * DONE and the region needs the room. Committed bytes are never
15
- * re-emitted — the native scrollback gets them, reflow-safe, and
16
- * the user's shell history is never touched (zero \x1b[3J, zero
17
- * replay);
14
+ * DONE and the region needs the room. Within a geometry, committed
15
+ * bytes are never re-emitted — the native scrollback gets them. The
16
+ * one exception is a SETTLED RESIZE (ADR-0046 Amendment 1, R14):
17
+ * `2J H 3J`, then the whole session reprinted from the model, so the
18
+ * terminal holds exactly one rendering; the shell history the
19
+ * terminal held before kiso started is the declared cost;
18
20
  * - the live region (content + chrome + menu) is hard-capped: the
19
21
  * content at H−4 (V6-3 — the four-row chrome); overflow FORCE-
20
- * commits the oldest live line regardless of done-nessthe one
21
- * sharp edge (asserted by the VT-emulator gate);
22
+ * commits the oldest live cell that has a committed form never a
23
+ * tool call still running (DC-53); a running card's window shrinks
24
+ * to the room instead (DC-43);
22
25
  * - two crash invariants: ① every emitted line's visible width ≤ W
23
26
  * (components fold; a violation THROWS with diagnostics — the
24
27
  * no-silent-truncate ruling); ② every steady-
@@ -47,13 +50,13 @@ import { MENU_ITEMS, displayWidth } from "./editor.js";
47
50
  import { leadWidth } from "./width.js"; // W23: the ONE width authority (the editor, #inputRow, and editCol share it)
48
51
  // KC3.5: the panel-slot reads come from the DISPATCHERS — one source
49
52
  // for four reads, so an ask can never render half as an approval.
50
- import { panelFrameOf, panelLeadOf, panelRowsOf, panelStatusOf } from "./ask-panel.js";
53
+ import { panelFrameOf, panelLeadOf, panelStatusOf } from "./ask-panel.js";
51
54
  import { MOUSE_OFF } from "./editor.js";
52
55
  import { atPanelRows, bandHeader } from "./at-picker.js";
53
56
  // TUI2-R2 ②: the session picker's rows — the band's third occupant.
54
57
  import { sessionPickerRows } from "./session-picker.js";
55
- import { CAP_PREVIEW, thinkingRow, expandedCard, Container, ROLLUP_NOUN, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, foldCountsObjects, foldTerms, focusToken, foldLine, gutterCut, cutLine, isExploreTool, moreRunningRow, pendingQueueRows, slotPad, slotTail, statusLine, visibleWidth, breathFrame, } from "./components.js";
56
- import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
58
+ import { CAP_PREVIEW, thinkingRow, Container, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, gutterCut, cutLine, pendingQueueRows, statusLine, visibleWidth, breathFrame, } from "./components.js";
59
+ import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary } from "./lines.js";
57
60
  import { displayVerb, keysSheetRows } from "./strings.js";
58
61
  // R5 — the transcript viewer's PURE projection. The compositor supplies
59
62
  // the entries (it holds the cells); the arrangement lives there.
@@ -80,50 +83,12 @@ const LIVE_ACT_HEADS = 3;
80
83
  /** R8 — the command band's window: five rows plus a counter, the same
81
84
  * budget the composer's own ceiling can afford above it. */
82
85
  const MENU_WINDOW = 5;
83
- /* R13 — `rolledTitle`, `rolledDetail` and `rolledOf` retired with the
84
- W13 rollup and TUI2-R1 (B)'s exploration row (see #foldOrRollup). */
85
- /** R3b — the turn's open segment, opened on demand at the first cell of
86
- * work that follows a text block (or the turn's start). Returns null
87
- * only when there is no turn at all, which is the pipe path's shape. */
88
- function openSegment(turn, now) {
89
- if (turn === undefined)
90
- return null;
91
- const last = turn.segments[turn.segments.length - 1];
92
- if (last !== undefined && last.closedAt === null)
93
- return last;
94
- const fresh = { openedAt: now, closedAt: null, reads: 0, edits: 0, others: new Map(), seen: new Map(), thinkingMs: 0, thinkingSince: null, folded: false, spilled: false, headCell: null, cells: [] };
95
- turn.segments.push(fresh);
96
- return fresh;
97
- }
98
- /** R3b — close the turn's open segment, if it has one. Idempotent: text
99
- * arriving twice in a row closes nothing the second time, which is what
100
- * keeps a zero-cell segment from ever existing. */
101
- function closeSegment(turn, now) {
102
- const last = turn?.segments[turn.segments.length - 1];
103
- if (last === undefined || last.closedAt !== null)
104
- return;
105
- stopThinking(last, now);
106
- last.closedAt = now;
107
- }
108
- /** R3i — the segment's thinking clock stops. It runs from the first
109
- * thinking delta of a stretch and stops at the first NON-thinking
110
- * event, the same rule the CLI applies to the turn — so `thought Ns`
111
- * is thinking time at every scale and never a wall clock wearing the
112
- * word (the R3g defect, kept closed at the new scale). */
113
- function stopThinking(seg, now) {
114
- if (seg === undefined || seg.thinkingSince === null)
115
- return;
116
- seg.thinkingMs += Math.max(0, now - seg.thinkingSince);
117
- seg.thinkingSince = null;
118
- }
119
86
  /** W20 — the whole-table-replace comparison: the live task block only
120
87
  * redraws when the items actually changed (the task extension's
121
88
  * idempotent shape — an unchanged replace is a no-op, no frame). */
122
89
  function sameTask(a, b) {
123
90
  return a.length === b.length && a.every((x, i) => x.text === b[i].text && x.status === b[i].status);
124
91
  }
125
- /** The one compositor — implements the Body façade AND the Dock chrome
126
- * API (see the class comments on each method group). */
127
92
  export class Body {
128
93
  #opts;
129
94
  #cells = [];
@@ -198,20 +163,28 @@ export class Body {
198
163
  #needsReset = false;
199
164
  #resizeTimer = null;
200
165
  #lastH = 0;
201
- /** DC-34did THIS FRAME refold the committed cells?
166
+ /** R14the geometry the last frame PAINTED, and the snapshot of it
167
+ * taken when a winch opens a settle window.
202
168
  *
203
- * It must be reset where the question is asked, not only where it
204
- * is answered. Armed once and consumed later, it latched: the
205
- * session's FIRST frame ran a vacuous refold over zero committed
206
- * cells and set it, and nothing cleared it until the first resize —
207
- * so every session's first widen still ran the adopt it was
208
- * supposed to skip, and swallowed the live band's worth of
209
- * committed rows. Three paragraphs, in the measurement that found
210
- * it. */
211
- #refolded = false;
212
- /** DC-34 the previous frame's width; the reach-back guard is for a
213
- * WIDTH change, which re-indexes the model, not a height change. */
214
- #lastW = 0;
169
+ * Three versions of this question were wrong before this one.
170
+ * Seeding in `enter()` never ran under the unit pool (it bails on a
171
+ * non-TTY), so a same-size winch erased the scrollback. Comparing
172
+ * against the CURRENT frame's geometry let an ordinary frame render
173
+ * at the new size between the winch and the settle and eat the
174
+ * change, so a real resize silently stopped reprinting. Seeding once
175
+ * on the first frame had the same hole whenever the first frame had
176
+ * not run yet.
177
+ *
178
+ * The question the settle actually asks is "is the terminal holding
179
+ * a rendering made at a DIFFERENT geometry than the one we have
180
+ * now" so the comparison is against what was on screen when the
181
+ * winch arrived, snapshotted then, once per storm. */
182
+ #paintedW = 0;
183
+ #paintedH = 0;
184
+ #winchFromW = 0;
185
+ #winchFromH = 0;
186
+ /** DC-50 — the ONE expansion switch ctrl+o flips. */
187
+ #expandedAll = false;
215
188
  // KC1 §6: the composer's recorded extent — the row count the last
216
189
  // frame drew (exit's clear walks it) and the row its CHA parked the
217
190
  // cursor on (the steady frame's relative anchor). N = 1 reproduces
@@ -224,11 +197,6 @@ export class Body {
224
197
  #lastThinking = null;
225
198
  #lastTool = null;
226
199
  #pendingCalls = new Map();
227
- /** R3b — cell index → the index of the segment it belongs to, for
228
- * thinking/tool cells; -1 for every other kind. Parallel to #cells,
229
- * because a segment is the COMPOSITOR's bookkeeping and does not
230
- * belong on the cell type the renderer sees. */
231
- #cellSegment = [];
232
200
  #pipeBuf = ""; // the passthrough's thinking buffer
233
201
  /** TUI2-MD ⑤ — the markdown scanner of the message currently
234
202
  * streaming, and the cell index its first block landed at. Null
@@ -237,26 +205,15 @@ export class Body {
237
205
  #mdBase = 0;
238
206
  #toolCells = new Map(); // callId → cell index (parallel tools)
239
207
  // W15: the collapsed (cut) tool cells — committed cells whose last
240
- // rendered row carried the "ctrl+o" affordance; the expand key's
241
- // cycling pointer walks this list from the newest back.
208
+ // rendered row carried the "ctrl+o" affordance.
209
+ //
210
+ // DC-50 / R14: the expand key no longer WALKS this. It is the
211
+ // VIEWER's index now (ctrl+r, `#viewerEntries`) — the two surfaces
212
+ // coexist and §9 says so. It is kept, not retired, for that reader.
242
213
  #collapsed = [];
243
- /** R4 (C1)the ring walk is by IDENTITY, not by a modular pointer.
244
- * `#collapsed` is unshifted on every commit that carries the key, so
245
- * a numeric pointer's target silently CHANGED whenever a new fold
246
- * landed mid-cycle: the ring was not stable under itself, and the
247
- * next press opened something other than what the last press
248
- * implied. This set records what the current cycle has already
249
- * opened; the walk takes the newest entry not in it, and empties it
250
- * when every entry has been seen. */
251
- #opened = new Set();
252
- // W14: the turn records — one per userLine, the fold-hold's state
253
- // machine (ended / hasText / folded) plus the folded-turn line's
254
- // counts (accumulated at toolStart). The cells carry the record's
214
+ // the turn records one per userLine; the cells carry the record's
255
215
  // index as their turn boundary.
256
216
  #turns = [];
257
- // W13: the rolled-up run heads — the commit-time scan's verdict:
258
- // the head's group summary renders, the members render [].
259
- #rolledHeads = new Set();
260
217
  #write;
261
218
  #resizeHandler = null;
262
219
  /** TUI2-R3v2 ②: the panel option rows' absolute screen span, as of the
@@ -396,10 +353,8 @@ export class Body {
396
353
  }
397
354
  this.#closeOpenThinking();
398
355
  this.#closeOpenText();
399
- // W14: the turn boundary the record the fold-hold's release
400
- // state machine reads; the cell carries the record's index. A9:
401
- // the user's own words ride the record — the fold's leading chip.
402
- this.#turns.push({ ended: false, hasText: false, begun: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), seen: new Map(), words: text, folded: false, segments: [] });
356
+ // the turn boundary; the cell carries the record's index.
357
+ this.#turns.push({ ended: false, hasText: false, begun: false, thoughtSeconds: 0 });
403
358
  this.#cells.push({ kind: "user", text, done: true, turn: this.#turns.length - 1 });
404
359
  this.#mark();
405
360
  }
@@ -414,42 +369,15 @@ export class Body {
414
369
  }
415
370
  else {
416
371
  this.#cells.push({ kind: "thinking", text, done: false, turn: this.#turns.length - 1 });
417
- // DECLARED SUPERSESSION (R7, owner-ruled 2026-08-31) — THINKING
418
- // IS WORDS, NOT WORK.
419
- //
420
- // R3b made thinking open a segment, on the reading that it is
421
- // work like a tool call. Four rounds of consequences followed
422
- // from that one classification: folded away with the calls, it
423
- // became unreachable, and R4's printed ordinal, R5's viewer,
424
- // R6's subject index and a look-back viewport were each built
425
- // to hand it back. The owner's ruling is to stop hiding it —
426
- // and then none of those mechanisms is answering a question
427
- // anyone still asks.
428
- //
429
- // So thinking CLOSES the open segment, exactly as text does
430
- // (see textAppend): a segment is what sits between two of
431
- // these. It must close rather than merely not-open, because
432
- // `#committed` is a PREFIX count — a thinking cell cannot
433
- // commit past a held call, so think → call → think would
434
- // otherwise flush at the segment's close with the second
435
- // thought printing BELOW the fold that contains the later
436
- // call.
437
- //
438
- // Consequence, and it is wanted: the segment's thinking clock
439
- // never starts, so `thought Ns` drops off every fold line by
440
- // R3h's own zero-term rule. The line stops claiming a fact the
441
- // paragraph above it already states in full.
372
+ // R7 (owner-ruled 2026-08-31): thinking is WORDS, not work a
373
+ // cell like prose.
442
374
  const t0 = this.#turns[this.#turns.length - 1];
443
- closeSegment(t0, Date.now());
444
375
  if (t0 !== undefined)
445
- t0.begun = true; // R6/D1: the block allocates here
446
- // R3i: and the beat starts HERE. Law 1.4 says "a running thought
447
- // twinkles", and `#armSpinner`'s own predicate has always
448
- // included an open thinking cell — but the only caller was
449
- // `toolRunning`, so a stretch that thought and did nothing else
450
- // never moved at all. The line's seconds are a frame-time
451
- // derivation, so without the beat they also never ticked: the
452
- // row read `thinking 0s` for as long as the model thought.
376
+ t0.begun = true;
377
+ // R3i: the beat starts HERE. Law 1.4 says "a running thought
378
+ // twinkles", and `#armSpinner`'s predicate includes an open
379
+ // thinking cell — but its only caller used to be `toolRunning`,
380
+ // so a turn that thought and did nothing else never moved.
453
381
  this.#armSpinner();
454
382
  }
455
383
  this.#mark();
@@ -457,10 +385,6 @@ export class Body {
457
385
  thinkingEnd() {
458
386
  const last = this.#cells[this.#cells.length - 1];
459
387
  if (last !== undefined && last.kind === "thinking" && !last.done) {
460
- // R3i: every closer — text, a notice, a terminal label, the next
461
- // turn — routes through here, so the clock cannot keep running
462
- // past the thing that ended it.
463
- stopThinking(this.#turns[this.#turns.length - 1]?.segments.at(-1), Date.now());
464
388
  last.done = true;
465
389
  this.#lastThinking = last.text;
466
390
  if (!this.#isActive())
@@ -485,9 +409,7 @@ export class Body {
485
409
  // `done` flag says otherwise. The commit loop takes leading DONE
486
410
  // cells, so that one stale flag parked the whole rest of the turn
487
411
  // behind it — every tool cell then reached the screen through the
488
- // FORCE-commit path, which by design bypasses the fold-hold. That is
489
- // why the walkthrough saw nine individual rows: not a fold that
490
- // declined to form, a fold that was never consulted.
412
+ // FORCE-commit path.
491
413
  this.#closeOpenThinking();
492
414
  this.#closeOpenText();
493
415
  // W12: the cell carries the delegate's child roles from the FULL
@@ -502,73 +424,11 @@ export class Body {
502
424
  }
503
425
  this.#toolCells.set(callId, this.#cells.length);
504
426
  this.#cells.push({ kind: "tool", name, input: summary, inputFull: JSON.stringify(input, null, 2), childRoles, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false, expanded: false, turn: this.#turns.length - 1, reason: null, verdict: null });
505
- // W14: the turn record's counts the folded-turn line's terms
506
- // (reads = read_file, edits = edit_file, the rest in first-call
507
- // order). The CLI's recap counts the same way (edit_file).
427
+ // R3i phase 5 an ANSWER is words (law 1.7): `ask_user` never
428
+ // counted as the turn's work, so it does not mark the turn begun.
508
429
  const turn = this.#turns[this.#turns.length - 1];
509
- if (turn !== undefined) {
510
- // R3h (fable, 2026-08-29): an OBJECT-counting tool counts the
511
- // distinct thing, not the act. Reading one file twice used to
512
- // fold as `read 2 files` — a sentence law 1.3 forbids, and one
513
- // this product shipped. `bump` is false on the second sighting
514
- // of a target the term has already counted; an ACT-counting
515
- // tool (a search, a shell command) always bumps, because two
516
- // searches for the same pattern really are two searches.
517
- // R3i phase 5 — an ANSWER is words, and words do not fold (law
518
- // 1.7). `ask_user` closes the open stretch exactly as prose
519
- // does, and never joins one: absorbed into `1 × ask_user`,
520
- // what the human said would be gone from the screen — and the
521
- // one thing a summary must not do is speak for the human.
522
- if (name === "ask_user") {
523
- // no stamp: it belongs to NO stretch, so no fold can speak
524
- // for it — the same standing a block of prose has.
525
- closeSegment(turn, Date.now());
526
- this.#mark();
527
- return;
528
- }
529
- const target = foldCountsObjects(name) ? toolTarget(name, input) : null;
530
- const bump = (rec) => {
531
- if (target === null)
532
- return true;
533
- let set = rec.seen.get(name);
534
- if (set === undefined) {
535
- set = new Set();
536
- rec.seen.set(name, set);
537
- }
538
- if (set.has(target))
539
- return false;
540
- set.add(target);
541
- return true;
542
- };
543
- if (bump(turn)) {
544
- if (name === "read_file")
545
- turn.reads += 1;
546
- else if (name === "edit_file")
547
- turn.edits += 1;
548
- else
549
- turn.others.set(name, (turn.others.get(name) ?? 0) + 1);
550
- }
551
- // R3b: and into the SEGMENT, which opens here when this is the
552
- // first work since the last text block. Its set is its OWN — a
553
- // file read once per segment is one file in each segment's
554
- // terms and one file in the turn's.
555
- const seg = openSegment(turn, Date.now());
556
- // R3i: a tool call is a NON-thinking event — the clock stops,
557
- // exactly as the CLI's does at the same boundary.
558
- stopThinking(seg ?? undefined, Date.now());
559
- if (seg !== null && bump(seg)) {
560
- if (name === "read_file")
561
- seg.reads += 1;
562
- else if (name === "edit_file")
563
- seg.edits += 1;
564
- else
565
- seg.others.set(name, (seg.others.get(name) ?? 0) + 1);
566
- }
567
- }
568
- this.#stampSegment();
569
- const t1 = this.#turns[this.#turns.length - 1];
570
- if (t1 !== undefined)
571
- t1.begun = true; // R6/D1: the block allocates here
430
+ if (turn !== undefined && name !== "ask_user")
431
+ turn.begun = true;
572
432
  this.#mark();
573
433
  }
574
434
  toolApproval(callId, diff) {
@@ -684,17 +544,9 @@ export class Body {
684
544
  this.#write(escapeTerminal(text));
685
545
  return;
686
546
  }
687
- // W14: the text's arrival RELEASES the fold-hold — the turn now
688
- // has text, its held cells commit individually (with the W13
689
- // rollups; the fold is only for the QUIET turn).
690
547
  const turn = this.#turns[this.#turns.length - 1];
691
548
  if (turn !== undefined)
692
549
  turn.hasText = true;
693
- // R3b: text CLOSES the open segment. This is the boundary design.md
694
- // §8 names — "folding at every text boundary changes what commits
695
- // and when" — and it is the whole mechanism: a segment is what sits
696
- // between two of these.
697
- closeSegment(turn, Date.now());
698
550
  // TUI2-MD ⑤: assistant body text is MARKDOWN, scanned as it
699
551
  // streams. The scanner yields CLOSED blocks (final source, final
700
552
  // render) and one OPEN tail block; each becomes a cell, and the
@@ -762,14 +614,9 @@ export class Body {
762
614
  this.#endMd();
763
615
  this.#mark();
764
616
  }
765
- /** W14 — the turn boundary's END: the CLI calls this at the run's
766
- * terminal event, once per run, BEFORE the recap (so the fold line
767
- * commits before the recap in the cell order). `thoughtSeconds` is
768
- * the CLI's wall-clocked thinking window. The QUIET turn (ended, no
769
- * text) releases its held cells as the ONE fold line; a turn with
770
- * text releases them as individual commits (the W13 rollups). The
771
- * release is LAZY — the held cells commit at the next frame, when
772
- * the fold/rollup decision runs. */
617
+ /** The turn boundary's END: the CLI calls this at the run's terminal
618
+ * event, once per run, BEFORE the recap. `thoughtSeconds` is the
619
+ * CLI's wall-clocked thinking window. */
773
620
  endTurn(thoughtSeconds) {
774
621
  if (!this.#isActive())
775
622
  return;
@@ -778,9 +625,6 @@ export class Body {
778
625
  return;
779
626
  turn.ended = true;
780
627
  turn.thoughtSeconds = thoughtSeconds;
781
- // R3b: the settle closes the last open segment — the turn's end is
782
- // a boundary exactly as a text block is.
783
- closeSegment(turn, Date.now());
784
628
  // W20: the turn's live task block settles HERE — the ONE recap
785
629
  // block for the turn ("`task done · N items · <duration>", the
786
630
  // duration clocked compositor-side from the block's first call —
@@ -803,9 +647,7 @@ export class Body {
803
647
  // and only ever left it through the force-commit cap. The turn's
804
648
  // end is the boundary that closes them, exactly as it closes an
805
649
  // open thinking cell. `reason` is set so the row keeps its words
806
- // AND so #segmentHasTrouble holds the turn unfolded — an
807
- // interruption is trouble, and law 1.3 says trouble is never
808
- // summarised away.
650
+ // (law 1.3: trouble is never summarised away).
809
651
  for (const c of this.#cells) {
810
652
  if (c.kind === "tool" && !c.done) {
811
653
  c.state = "done";
@@ -815,10 +657,8 @@ export class Body {
815
657
  }
816
658
  }
817
659
  // the QUIET turn: an open thinking cell closes at the boundary —
818
- // its natural closer is the text's arrival (never comes here — the
819
- // text-less turn), so without this the fold could never commit AT
820
- // it (the commit loop only takes done cells — the fold would stall
821
- // forever behind the live thinking).
660
+ // its natural closer is the text's arrival, which a text-less turn
661
+ // never has, and the commit loop only takes done cells.
822
662
  for (let i = this.#cells.length - 1; i >= 0; i -= 1) {
823
663
  const c = this.#cells[i];
824
664
  if (c.kind === "thinking" && !c.done) {
@@ -946,68 +786,24 @@ export class Body {
946
786
  lastTool() {
947
787
  return this.#lastTool;
948
788
  }
949
- /** W15 the expand key's target (ctrl+o). A cell still in the LIVE
950
- * region (the newest live tool) TOGGLES in place the compositor
951
- * owns those rows and redraws them (the body flips to the full
952
- * form, no cap). A committed cell can never toggle history is
953
- * never rewritten (ADR-0046) so the key APPENDS a fresh expanded
954
- * block at the bottom instead, the /last idiom aimed at a chosen
955
- * cell: the pointer cycles the collapsed history, newest first, and
956
- * the header names the target ("N turns back" — the user cells
957
- * after it), so every press tells the user what they got. */
789
+ /* DECLARED REVERSAL (D-S2-1, owner-ruled 2026-09-06): `#focusIndex`
790
+ stood here TUI2-R2 ⑤'s bright ctrl+o token on the newest live
791
+ card, "the cell the next press will act on". DC-50 made ctrl+o a
792
+ global switch, so there was no target left for a marker to name;
793
+ the status row's idle hint names the switch instead (#statusSource). */
958
794
  /**
959
- * TUI2-R2 the cell the next ctrl+o will act on, or -1.
960
- *
961
- * The rule is expandNext's own first loop, extracted verbatim: the
962
- * LAST live cell that can toggle. It is a separate method rather than
963
- * a shared constant because the marker and the key must not merely
964
- * agree today the marker is a PROMISE about what the key will do,
965
- * and the only way to keep it is to derive it from the same scan.
966
- *
967
- * The committed fallback (the #collapsed ring) is deliberately NOT
968
- * marked: those rows are frozen history, never re-emitted, so a tint
969
- * on them could not be moved when the pointer advances. A live target
970
- * is the one the marker can tell the truth about.
971
- */
972
- #focusIndex() {
973
- for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
974
- const cell = this.#cells[i];
975
- if (cell.kind === "tool" && cell.state !== "pending")
976
- return i;
977
- if (cell.kind === "checklist" && !cell.done)
978
- return i;
979
- }
980
- return -1;
981
- }
982
- /**
983
- * R4 (C4d) — THE APPEND-ONLY RE-WRAP.
984
- *
985
- * The owner's report: resize the window and the reference
986
- * implementation's text re-wraps to the new width while kiso's does
987
- * not. It is true, and it is not a bug to be fixed — it is the price
988
- * of ADR-0046, and the price is worth naming precisely.
989
- *
990
- * A terminal can only reflow a SOFT-wrapped line: one long logical
991
- * line the terminal itself wrapped as the cursor flowed past the last
992
- * column. Every row kiso commits is either painted by cursor
993
- * addressing (#emitDiff) or scrolled out by a bare LF (#emitScroll),
994
- * and frames run with autowrap OFF — so no byte kiso commits can ever
995
- * carry a continuation flag, and nothing downstream can rejoin rows an
996
- * application hard-split. That same LF is what makes the transcript
997
- * the TERMINAL's: it survives kiso's death, a pipe, and tmux. A
998
- * product whose transcript reflows is a product that repaints its
999
- * transcript from its own memory, and that transcript dies with it.
1000
- *
1001
- * What kiso can do — and this is all it can do — is APPEND. The
1002
- * committed cells are still in memory; re-render them at the current
1003
- * width and put them at the BOTTOM, where writing is allowed. Nothing
1004
- * above is rewritten, so ADR-0046 holds exactly.
795
+ * R4 (C4d)`/rewrap`: the recent PROSE, re-rendered at the current
796
+ * width and APPENDED at the bottom. Committed rows carry no soft-wrap
797
+ * flag (autowrap is OFF; every row is painted or scrolled out by a
798
+ * bare LF), so the terminal cannot reflow them itself. R14
799
+ * (ADR-0046 Amendment 1, D-B3) made a settled resize reprint the
800
+ * whole session from the model on its own; this stays as the
801
+ * user-invoked form, and it still appends rather than erasing
802
+ * nothing above the append is rewritten.
1005
803
  *
1006
804
  * Scoped to PROSE. Text is what reads badly at the wrong width — a
1007
805
  * paragraph folded for 120 columns and read at 60 is the complaint.
1008
- * Tool rows, folds and chips are short, already carry their own
1009
- * width ladders, and re-printing them would duplicate work the folds
1010
- * exist to state once.
806
+ * Cards and chips are short and carry their own width ladders.
1011
807
  */
1012
808
  rewrap() {
1013
809
  const W = this.#opts.width();
@@ -1134,8 +930,7 @@ export class Body {
1134
930
  continue;
1135
931
  // the tool card's FULL body — the same rows its own ctrl+o
1136
932
  // opens. The expanded flag is saved and restored inside this
1137
- // synchronous call, the pattern the rollup path has always
1138
- // used for head.rolled; it never outlives the render, so the
933
+ // synchronous call; it never outlives the render, so the
1139
934
  // committed geometry #committedLines derives can never see it.
1140
935
  const saved = cell.expanded;
1141
936
  cell.expanded = true;
@@ -1167,112 +962,13 @@ export class Body {
1167
962
  cutLine(`${p.dim} ${viewerHint(this.#viewer, entries)}${p.reset}`, W),
1168
963
  ];
1169
964
  }
1170
- /** DC-35 the last block this key appended, and the cell count when
1171
- * it did. An expansion earns its rows by showing something the
1172
- * transcript does not already END with. */
1173
- #lastAppend = null;
1174
- /**
1175
- * DC-35 ctrl+o does not print the same expansion twice in a row.
1176
- *
1177
- * The ring walks newest-back and restarts its cycle once every entry
1178
- * has been opened (R4/C1, which is what makes the walk immune to the
1179
- * ring growing underneath it). With a ring of ONE the restart is
1180
- * immediate, so holding the key appended the identical block over
1181
- * and over — the owner got three copies of the same four rows, each
1182
- * closing with `ctrl+o opens the one before it`, a footer naming
1183
- * something that does not exist.
1184
- *
1185
- * The bar is the BOTTOM of the transcript, not "ever shown": once
1186
- * other content has arrived the expansion has scrolled up and
1187
- * re-opening it is the point of the key, so the guard clears itself
1188
- * the moment a cell is added.
1189
- */
1190
- expandNext() {
1191
- const out = this.#expandNextRaw();
1192
- if (out.kind !== "appended")
1193
- return out;
1194
- const lines = out.lines.join("\n");
1195
- if (this.#lastAppend !== null && this.#lastAppend.lines === lines && this.#lastAppend.atCells === this.#cells.length) {
1196
- // NOT the same answer as "nothing is folded". The caller says
1197
- // which, because a reader who pressed the key deserves to know
1198
- // whether there is nothing to open or whether they are already
1199
- // looking at it.
1200
- return { kind: "none", why: "already-last" };
1201
- }
1202
- this.#lastAppend = { lines, atCells: this.#cells.length };
1203
- return out;
1204
- }
1205
- #expandNextRaw() {
1206
- for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
1207
- const cell = this.#cells[i];
1208
- if (cell.kind === "tool" && cell.state !== "pending") {
1209
- cell.expanded = !cell.expanded;
1210
- this.#mark();
1211
- return { kind: "toggled" };
1212
- }
1213
- // W20: the LIVE task block toggles in place too — the capped
1214
- // form flips to the full list (the "done-collapse expands
1215
- // under ctrl+o" claim). The SETTLED block is already full —
1216
- // no toggle, and its rows carry no affordance, so it never
1217
- // joins #collapsed (the committed /last append is moot).
1218
- if (cell.kind === "checklist" && !cell.done) {
1219
- cell.expanded = !cell.expanded;
1220
- this.#mark();
1221
- return { kind: "toggled" };
1222
- }
1223
- }
1224
- if (this.#collapsed.length === 0)
1225
- return { kind: "none" };
1226
- // R4 (C1) — the newest entry this cycle has not opened yet. When
1227
- // every entry has been seen the cycle restarts, so the walk is
1228
- // still "newest back" — it is simply immune to the ring growing
1229
- // underneath it.
1230
- if (this.#collapsed.every((i) => this.#opened.has(i)))
1231
- this.#opened.clear();
1232
- const idx = this.#collapsed.find((i) => !this.#opened.has(i)) ?? this.#collapsed[0];
1233
- this.#opened.add(idx);
1234
- const cell = this.#cells[idx];
1235
- // R3b — a folded SEGMENT expands to the work it stands for.
1236
- //
1237
- // The fold line collapses a run of thinking and tool cells into
1238
- // one row; without this the run would be unreachable, which is
1239
- // hiding a durable record behind a summary. The rows are APPENDED
1240
- // (ADR-0046 — history is never rewritten), exactly as every other
1241
- // expand in this method does, and they are the cells' OWN renders,
1242
- // so the expansion cannot drift from what was folded.
1243
- // R13 — expandNext's FOLD branch retired with the segment fold.
1244
- // A fold line collapsed a run of cells into one row, so the key had
1245
- // to be able to open the run; with every call standing as its own
1246
- // card there is nothing collapsed for it to open, and `#collapsed`
1247
- // now holds cards alone.
1248
- if (cell.kind !== "tool")
1249
- return { kind: "none" };
1250
- // R13 — and the ROLLUP branch retired with `rolled`.
1251
- let input = {};
1252
- try {
1253
- input = JSON.parse(cell.inputFull);
1254
- }
1255
- catch {
1256
- // the full JSON is always parseable (it was stringified at
1257
- // toolStart) — the empty fallback never fires
1258
- }
1259
- const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
1260
- const p = palette();
1261
- // 0.24.2 ③ — the expansion is a CARD, and `✦` is not its mark: that
1262
- // glyph is the turn recap's, and one symbol with two meanings is
1263
- // §4.1. The head row names the call, which is the tie to it that
1264
- // the mark's sentence used to be.
1265
- const verb = displayVerb(cell.name);
1266
- const elapsed = cell.startedAt !== null && cell.doneAt !== null ? ((cell.doneAt - cell.startedAt) / 1000).toFixed(1) : "?";
1267
- const n = cell.resultText === "" ? 0 : cell.resultText.split("\n").length;
1268
- return {
1269
- kind: "appended",
1270
- lines: expandedCard(verb, toolTarget(cell.name, input), `expanded · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`,
1271
- // TUI2-R2pre ④: the SECTION HEADERS say the act; the payloads
1272
- // below them (inputFull, resultText) are RAW and byte-identical.
1273
- [`--- ${verb} input ---`, cell.inputFull, `--- ${verb} output${cell.isError ? " (error)" : ""} ---`, cell.resultText], [cell.isError ? "failed" : "exit 0", `${n} line${n === 1 ? "" : "s"}`, `${elapsed}s`].filter((x) => x !== "").join(" · "), this.#opts.width()),
1274
- };
1275
- }
965
+ /* DECLARED REVERSAL (DC-50 / R14, 2026-09-05): the APPENDED expansion
966
+ stood here `expandNext`, `#expandNextRaw`, `#lastAppend`, the
967
+ `#opened` set. ADR-0046 §3 forbade re-rendering a committed card,
968
+ so the only way to show a body was to print a copy further down.
969
+ Amendment 1 lets a settled reprint re-render every card where it
970
+ stands, and `toggleExpanded` is the whole feature. `#collapsed`
971
+ stays: the viewer (ctrl+r) reads it as its index of cut cells. */
1276
972
  // ---- the Dock façade (the CLI's chrome API — same shape as the old dock) ----
1277
973
  /** Docked = the chrome is live (a color TTY with a real size). */
1278
974
  get active() {
@@ -1506,6 +1202,14 @@ export class Body {
1506
1202
  onResize() {
1507
1203
  if (!this.#isActive())
1508
1204
  return;
1205
+ // R14 — the FIRST winch of a storm snapshots what the terminal is
1206
+ // currently holding. A drag fires many winches; the settle must
1207
+ // compare its end against the storm's beginning, not against the
1208
+ // previous winch.
1209
+ if (!this.#resizePending) {
1210
+ this.#winchFromW = this.#paintedW;
1211
+ this.#winchFromH = this.#paintedH;
1212
+ }
1509
1213
  this.#resizePending = true;
1510
1214
  if (this.#resizeTimer !== null)
1511
1215
  clearTimeout(this.#resizeTimer);
@@ -1533,20 +1237,128 @@ export class Body {
1533
1237
  this.#screen = [];
1534
1238
  this.onResize();
1535
1239
  }
1536
- /** The one repaint a drag earns, once its signals have stopped. */
1240
+ /**
1241
+ * R14 / route B — THE ONE REPAINT A DRAG EARNS IS A REPRINT.
1242
+ *
1243
+ * It used to be `ESC[from;1H ESC[0J` — erase from the recorded live
1244
+ * top down, then redraw the live area, leaving the committed rows
1245
+ * above to whatever the terminal's own reflow had made of them. That
1246
+ * is the shape R10 measured on the owner's real terminal, and it
1247
+ * lost: a grow dropped 16 rows of history (DC-39), a narrow
1248
+ * duplicated four tokens, and the scrolled-off transcript never
1249
+ * reflowed at all. All three are one fault — kiso doing window
1250
+ * arithmetic over rows the terminal had already reflowed underneath
1251
+ * it, with `#scrolledOff` carried across a fold change that makes
1252
+ * every index mean something else.
1253
+ *
1254
+ * So it stops arguing. `2J H 3J` — erase screen, home, erase
1255
+ * scrollback, in THAT order because on Apple Terminal a bare `2J`
1256
+ * scrolls the screen into history and only the following `3J` makes
1257
+ * the state clean — and then the whole committed transcript is
1258
+ * reprinted at the new geometry through the path a fresh terminal
1259
+ * already uses. The terminal ends holding exactly one rendering.
1260
+ *
1261
+ * The declared cost (ADR-0046 Amendment 1): everything the terminal
1262
+ * held before kiso started is erased at the first resize. Measured
1263
+ * on the reference implementation as 0/60 in every direction — this
1264
+ * is the behaviour being adopted, not a regression. kiso's own
1265
+ * record is untouched: the session log holds it, `--resume` replays
1266
+ * it, and G4' asserts the 0/60 so the cost cannot drift silently.
1267
+ *
1268
+ * D-B2: EVERY settled resize reprints, height-only included. A
1269
+ * SIGWINCH at the same geometry emits nothing at all.
1270
+ */
1537
1271
  #settleResize() {
1538
1272
  if (!this.#resizePending || !this.#isActive())
1539
1273
  return;
1540
1274
  this.#resizePending = false;
1541
1275
  const H = this.#opts.height();
1542
- const liveRows = this.#lastLiveRows > 0 ? this.#lastLiveRows : 3;
1543
- const from = Math.max(1, (this.#lastH > 0 ? this.#lastH : H) - liveRows + 1);
1544
- this.#write(`\x1b[${Math.min(from, Math.max(1, H))};1H\x1b[0J`);
1276
+ const W = this.#opts.width();
1277
+ // D-B2's other half: a winch that did not change the geometry is
1278
+ // not a resize. Emitting the erase for it would throw away the
1279
+ // terminal's scrollback for nothing (the V6-1 idempotence case).
1280
+ if (this.#winchFromW === W && this.#winchFromH === H)
1281
+ return;
1282
+ // §9.2 — the viewer is closed first. Nothing commits while it is
1283
+ // up and a reprint is a commit storm; the user reopens it. One
1284
+ // keypress, stated rather than hidden.
1285
+ if (this.#viewer !== null) {
1286
+ this.#viewer = null;
1287
+ this.#viewerWasUp = true;
1288
+ }
1289
+ this.#reprint();
1290
+ }
1291
+ /**
1292
+ * R14 — ERASE THE TERMINAL AND PRINT THE SESSION AGAIN.
1293
+ *
1294
+ * Two callers: a settled resize, and DC-50's ctrl+o. They are the
1295
+ * same act — the rendering the terminal holds is wrong (wrong
1296
+ * geometry, or wrong expansion state) and the model is the only
1297
+ * authority on what it should be — so they share the path rather
1298
+ * than growing two.
1299
+ */
1300
+ #reprint() {
1301
+ const H = this.#opts.height();
1302
+ this.#write("\x1b[2J\x1b[H\x1b[3J");
1303
+ // The terminal now holds nothing, so every record of what it held
1304
+ // is void. `#scrolledOff` is the frontier of what reached its
1305
+ // scrollback: after the erase, that is zero — which is also what
1306
+ // releases the committed cells to be re-rendered at the new width
1307
+ // without contradicting anything, the thing DC-34's frontier rule
1308
+ // existed to prevent.
1309
+ this.#scrolledOff = 0;
1310
+ for (let i = 0; i < this.#committed; i += 1)
1311
+ this.#lineCache[i] = null;
1312
+ this.#screen = new Array(Math.max(1, H)).fill(NOT_PAINTED);
1313
+ this.#cursorRow = 1;
1545
1314
  this.#fullRedraw = true;
1546
1315
  this.#resizeFrame = true;
1547
1316
  this.#dirty = true;
1548
1317
  this.render();
1549
1318
  }
1319
+ /**
1320
+ * DC-50 — ctrl+o flips ONE switch, and every settled card obeys it.
1321
+ *
1322
+ * The walk this replaces existed because ADR-0046 §3 forbade
1323
+ * re-rendering a committed card, so the only way to show one's body
1324
+ * was to APPEND a copy further down — with a ring to decide which
1325
+ * card was next, `#opened` to keep the ring from repeating, and
1326
+ * `#lastAppend` to stop a held key printing the same rows three times
1327
+ * (DC-35). Amendment 1 removes the premise: the scrollback is ours to
1328
+ * erase, so a card can simply be re-rendered where it stands.
1329
+ *
1330
+ * A RUNNING card is exempt. Its height is E2/DC-43's — it grows from
1331
+ * its own content — and a global "show everything" has no business
1332
+ * reaching into a card whose content is still arriving.
1333
+ */
1334
+ toggleExpanded() {
1335
+ this.#expandedAll = !this.#expandedAll;
1336
+ for (const cell of this.#cells) {
1337
+ // SETTLED CONTENT, not "done". A card parked for approval is
1338
+ // `state: "approval"`, `done: false` — and its content is not
1339
+ // still arriving: the diff is complete and the card is waiting
1340
+ // for a human to read it and decide. The mechanism this
1341
+ // replaces toggled it (any state but "pending"), and the
1342
+ // dispatch comment said why: "the approval pause is exactly
1343
+ // when the user reads a cut diff, and the key must answer then,
1344
+ // never after the run." A `done`-only switch answers with
1345
+ // nothing at the one moment the answer matters most.
1346
+ //
1347
+ // The exemption is for the card still GROWING — E2 and DC-43
1348
+ // own its height, and a global "show everything" has no
1349
+ // business reaching into content that is still arriving. That
1350
+ // is the ruling's reason; `done` alone was its wording.
1351
+ if (cell.kind !== "tool")
1352
+ continue;
1353
+ if (cell.done || cell.state === "approval")
1354
+ cell.expanded = this.#expandedAll;
1355
+ }
1356
+ this.#reprint();
1357
+ }
1358
+ /** DC-50 — the switch itself, for the CLI's affordance text. */
1359
+ expandedAll() {
1360
+ return this.#expandedAll;
1361
+ }
1550
1362
  /** W18: the status row's right-aligned hint is part of the status
1551
1363
  * state — the compacting row passes "esc to cancel" (the affordance
1552
1364
  * must survive repaints). */
@@ -1584,13 +1396,21 @@ export class Body {
1584
1396
  // approval and the pick, ask-panel.ts for the ask), so nothing is
1585
1397
  // lost anywhere.
1586
1398
  if (panel !== null)
1587
- return { status: panelStatusOf(panel), hint: undefined };
1399
+ return { status: panelStatusOf(panel), hint: undefined, expand: null };
1588
1400
  // W22: while turns wait in the queue, the right hint shows the
1589
1401
  // count — the chips below carry the lines themselves.
1590
1402
  const queued = this.#queueState?.().length ?? 0;
1591
1403
  if (queued > 0)
1592
- return { status: this.#status, hint: `+${queued} queued` };
1593
- return { status: this.#status, hint: this.#statusHint ?? undefined };
1404
+ return { status: this.#status, hint: `+${queued} queued`, expand: null };
1405
+ // D-S2-1 (owner-ruled 2026-09-06): the idle hint names the ctrl+o
1406
+ // SWITCH — `expand all` or `collapse all` by its state — and only
1407
+ // while a committed card has something behind the key (#collapsed
1408
+ // is the index of exactly those). A hint for a key that would
1409
+ // change nothing is a hint that lies. This replaced the per-card
1410
+ // bright token (TUI2-R2 ⑤), which had no single target left to
1411
+ // mark once DC-50 made the key global.
1412
+ const expand = this.#collapsed.length > 0 ? (this.#expandedAll ? "collapse all" : "expand all") : null;
1413
+ return { status: this.#status, hint: this.#statusHint ?? undefined, expand };
1594
1414
  }
1595
1415
  /** Bind the editor's panel state — the PanelSelect slot occupant
1596
1416
  * (W21: the panel replaces the live region + the input lead while
@@ -1721,49 +1541,17 @@ export class Body {
1721
1541
  }
1722
1542
  // ---- the one writer ----
1723
1543
  /**
1724
- * R3i phase 2THE LIVE PROJECTION.
1725
- *
1726
- * One definition, called from the natural path and from inside the
1727
- * force-commit loop, because two copies of "what the live region
1728
- * looks like" is two answers to one question.
1729
- *
1730
- * The change this phase makes, and the ONLY one: the cells of the
1731
- * OPEN stretch no longer each hold a row. The stretch is one line —
1732
- * the same line the settle will keep, in the present tense — plus
1733
- * the calls actually in flight. A completed call renders nothing;
1734
- * its count rides the line.
1735
- *
1736
- * What it fixes: a 28-call turn used to spend 28 rows of a 30-row
1737
- * live region, so overflow was the NORM on real turns rather than
1738
- * the edge — and a turn that overflows may not fold (R3f: a line
1739
- * cannot claim rows already in the scrollback), which is why the
1740
- * fold missed exactly the turns it exists for. The block's height
1741
- * no longer depends on the call count at all.
1742
- *
1743
- * What it does NOT change: nothing about what commits or when. The
1744
- * hold is untouched, the force-commit cap is untouched, and the
1745
- * settle still produces the same fold it did before. That is the
1746
- * charter's line between this phase and the next.
1544
+ * THE LIVE PROJECTIONthe uncommitted cells as rows, one definition
1545
+ * for the natural path and the force-commit loop alike (two copies of
1546
+ * "what the live region looks like" would be two answers to one
1547
+ * question). The live region is bounded by the SCREEN, and nothing
1548
+ * else: DC-46's `#liveRoom` capped it at `H − chrome − #committedLines`
1549
+ * to keep the window's top monotone, but `#committedLines` is
1550
+ * cumulative, so one screenful into any session every running call
1551
+ * was a head row with its output gone. The window's top is held by
1552
+ * the `skip` clamp in render() instead; a live region that grows
1553
+ * scrolls committed rows away, which is an append and not an un-scroll.
1747
1554
  */
1748
- /* DC-46 — `#liveRoom` RETIRED, and the reasoning with it.
1749
- It capped the live region at `H − chrome − #committedLines`, to
1750
- keep `skip` a function of `#committedLines` alone and therefore
1751
- monotone. The argument was right about `skip` and wrong about the
1752
- quantity: `#committedLines` is CUMULATIVE — re-derived over the
1753
- whole line cache every frame, counting rows that left for the
1754
- terminal's scrollback long ago — so one screenful into any session
1755
- it exceeds H, the room clamps to its floor of one row, and every
1756
- running call after that is a head row with its output gone.
1757
- Measured at 24 and 40 rows with eight blank rows still on screen.
1758
-
1759
- The live region is bounded by the SCREEN (the content cap the
1760
- force-commit loop already uses). What holds the window's top is
1761
- the `skip` clamp below: rows in [0, #scrolledOff) have reached the
1762
- terminal's scrollback and are immutable, so the paint may not go
1763
- back above them. A live region that GROWS scrolls committed rows
1764
- away through #emitScroll, which is an append and not an un-scroll.
1765
- The residue is a transient hole above the composer at a settle,
1766
- bounded by the shrink itself and filled by the next commit. */
1767
1555
  #liveProjection(W, ctx, cap) {
1768
1556
  const rows = this.#project(W, ctx, CAP_PREVIEW);
1769
1557
  if (cap === undefined || rows.length <= cap)
@@ -1804,39 +1592,19 @@ export class Body {
1804
1592
  return [...heads.slice(0, keep), cutLine(`${p.dim} +${hidden} more${p.reset}`, W)];
1805
1593
  }
1806
1594
  /**
1807
- * R13 — ONE PASS, AND EVERY CELL RENDERS ITSELF.
1808
- *
1809
- * DECLARED REVERSAL of R3i's stretch line, R4's standing activity
1810
- * slot and R6/D1's block-stands-for-the-turn, all owner-ruled on
1811
- * 2026-09-03 and all of them the same idea: the open stretch drew ONE
1812
- * line plus a fixed slot, and every other cell of the segment drew
1813
- * nothing, so the live region's height was independent of the call
1814
- * count. That was the answer to a 28-call turn spending 28 rows of a
1815
- * 30-row region.
1816
- *
1817
- * The card answers it differently, and the ruling prefers this
1818
- * answer: a running call is its own card at a FIXED height (E2), so
1819
- * the height is a function of how many calls are IN FLIGHT rather
1820
- * than of how many have happened — and the window shrinks to the room
1821
- * before anything is force-committed (above). The live form and the
1822
- * committed form are now the same form, which is what makes a settle
1823
- * a change of content and never of position.
1824
- *
1825
- * What this keeps from R4: the height never moves ON ITS OWN. What it
1826
- * gives up: the one-line summary of a stretch, which the owner ruled
1827
- * costs more than it buys.
1595
+ * ONE PASS, AND EVERY CELL RENDERS ITSELF. The live form and the
1596
+ * committed form are the same form, which is what makes a settle a
1597
+ * change of content and never of position (DECLARED REVERSAL, R13:
1598
+ * R3i's stretch line and R4's standing slot drew one line plus a
1599
+ * fixed block for the whole stretch and had every other cell draw
1600
+ * nothing; the owner ruled the summary costs more than it buys).
1828
1601
  */
1829
1602
  #project(W, ctx, budget) {
1830
1603
  const out = [];
1831
- const focus = this.#focusIndex();
1832
1604
  const live = { ...ctx, liveWindow: budget };
1833
1605
  let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
1834
1606
  for (let i = this.#committed; i < this.#cells.length; i += 1) {
1835
1607
  const rows = cellComponent(this.#cells[i]).render(W, live);
1836
- // the head row carries the affordance; the tint lands on it and
1837
- // nowhere else, which is what makes "exactly one" structural
1838
- if (i === focus && rows.length > 0)
1839
- rows[0] = focusToken(rows[0], W);
1840
1608
  out.push(...this.#space(i, prev, rows));
1841
1609
  prev = rows;
1842
1610
  }
@@ -1883,56 +1651,17 @@ export class Body {
1883
1651
  * blanks are join artifacts — the count includes them (they are real
1884
1652
  * screen rows), threaded against the previous sibling's OWN rows. */
1885
1653
  liveCount() {
1886
- const panel = this.#panelState?.() ?? null;
1887
- const sheet = this.#sheetState?.() === true;
1888
- const queueRows = this.#queueRows(this.#opts.width(), this.#opts.height());
1889
- // KC1 §6: the composer's extra rows are chrome too — the scalar
1890
- // counts them exactly like the menu/queue bands (N = 1 ⇒ +0)
1891
- const inputExtra = this.#inputRows(this.#opts.width(), this.#opts.height(), this.#menuRows(this.#opts.width()).length, queueRows.length).rows.length - 1;
1892
- // TUI2-R1 (D): the sheet occupies the live region, exactly like the
1893
- // panel — the scalar must say so, or the cap arithmetic disagrees
1894
- // with the screen.
1895
- // R5 — the viewer occupies the live region exactly like the sheet,
1896
- // so the scalar must say so, or the cap arithmetic disagrees with
1897
- // the screen (the same rule DC-27 was about).
1898
- if (this.#viewer !== null) {
1899
- const capV = Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length);
1900
- return this.#viewerBand(this.#opts.width()).slice(0, capV).length + CHROME_ROWS + inputExtra + queueRows.length;
1901
- }
1902
- if (sheet) {
1903
- return (keysSheetRows(this.#opts.width()).slice(0, Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
1904
- CHROME_ROWS +
1905
- inputExtra +
1906
- queueRows.length);
1907
- }
1908
- if (panel !== null) {
1909
- // W21: the panel's own rows (the cap is exact — the scalar
1910
- // reflects the screen). W22: the queue chips occupy their
1911
- // own band — the panel's cap shrinks by their rows.
1912
- return (panelRowsOf(panel, this.#opts.width(), Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
1913
- CHROME_ROWS +
1914
- inputExtra +
1915
- queueRows.length);
1916
- }
1917
- // DC-27 — the scalar measures the PROJECTION, not a second render
1918
- // of its own. This loop used to walk every live cell and render it
1919
- // in full: no open-segment collapse, no flight rule, no act-slot
1920
- // budget. After R3i that described a screen the compositor had
1921
- // stopped drawing — for an open stretch with five finished calls
1922
- // it counted five four-row blocks that were not there. Nothing
1923
- // broke, because the force-commit loop measures liveLines.length
1924
- // and the over-count is conservative; but the cap and geometry
1925
- // gates were asserting a property of a function nothing paints
1926
- // from, so a real regression in the region's height could not
1927
- // have moved them. The rule this file already states for the
1928
- // sheet ("the scalar must say so, or the cap arithmetic disagrees
1929
- // with the screen") is the same rule here.
1930
- const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
1931
1654
  const W = this.#opts.width();
1932
- // the SAME content cap the force-commit loop applies, so the
1933
- // scalar sees the same slot budget the screen gets.
1934
- const rows = this.#liveProjection(W, ctx, this.#opts.height() - 4 - inputExtra - queueRows.length);
1935
- return rows.length + CHROME_ROWS + inputExtra + this.#menuRows(W).length + queueRows.length;
1655
+ const H = this.#opts.height();
1656
+ const chrome = this.#chrome(W, H);
1657
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: H };
1658
+ // DC-27 the scalar measures what the SCREEN gets: the same
1659
+ // selector render() paints from, under the same chrome. A scalar
1660
+ // with rules of its own asserted a property of a function nothing
1661
+ // painted from, so the cap and geometry gates could not move on a
1662
+ // real regression ("the scalar must say so, or the cap arithmetic
1663
+ // disagrees with the screen" — TUI2-R1 D, R5, DC-27, one rule).
1664
+ return this.#liveRows(W, ctx, chrome.cap).lines.length + chrome.rows;
1936
1665
  }
1937
1666
  /** The lines committed THIS frame — the writes land in the frame's
1938
1667
  * committed section (the rows just above the live region). */
@@ -1949,243 +1678,161 @@ export class Body {
1949
1678
  if (H < 4)
1950
1679
  return;
1951
1680
  this.#lastH = H;
1681
+ // R14 — the geometry THIS frame painted. `onResize` snapshots it
1682
+ // at the moment the winch arrives; see `#winchFromW`.
1683
+ this.#paintedW = W;
1684
+ this.#paintedH = H;
1952
1685
  const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: H };
1953
- // V6-1 (the screen-state == frame-state rule): the resize's first
1954
- // frame — the terminal's reflow re-wrapped the committed content at
1955
- // the NEW width, so the cached folds are stale. Re-fold the
1956
- // committed cells so the every-row draw below re-paints them at the
1957
- // current geometry — the frame's model and the screen agree.
1958
- // DC-34 A WIDEN DOES NOT REFOLD WHAT IS ALREADY COMMITTED.
1959
- //
1960
- // Every count here is physical ROWS at the fold width in force
1961
- // when it was computed. Refolding the committed cells at a new W
1962
- // changes what every index MEANS while `#scrolledOff` is carried
1963
- // across untranslatedand no translation exists, because the
1964
- // row the scroll stopped at does not occur in the new fold. On a
1965
- // widen the stale count then points at text the terminal already
1966
- // holds, and the frame paints it a second time.
1967
- //
1968
- // A committed row is ink (ADR-0046): the rows still on screen are
1969
- // the same thing as the rows in the scrollback minus a scroll
1970
- // that has not happened, and no terminal reflows either. Leaving
1971
- // them folded as they were printed keeps every index valid.
1972
- //
1973
- // NARROWING still refolds — an old wide row does not FIT, and
1974
- // `#checked` would throw invariant ①. The comparison is against
1975
- // the CACHE's fold width, not the last render's: after 60 → 100
1976
- // (no refold, the cache is still 60) a narrowing to 80 must NOT
1977
- // refold, because 80 columns hold a 60-column row.
1978
- this.#refolded = false;
1979
- if (this.#fullRedraw) {
1980
- // DC-34 THE REFOLD IS SCOPED BY THE FRONTIER.
1981
- //
1982
- // `#scrolledOff` is the record of what reached the terminal:
1983
- // rows [0, #scrolledOff) are in its scrollback, immutable, and
1984
- // no path of ours may contradict them. A cell with any row
1985
- // down there keeps the fold it was COMMITTED at, forever — in
1986
- // either direction. A cell entirely above the frontier has
1987
- // never left the screen, so re-folding it is free.
1988
- //
1989
- // Two scalar predicates were tried before this and both
1990
- // failed, in different ways: the last-refold width crashed on
1991
- // 60 100 80 (a cell committed at 100 emitted into an
1992
- // 80-column screen), and the cache's widest fold fires a FULL
1993
- // refold at the first narrowing, which re-wraps rows the
1994
- // scrollback already holds — the original defect, alive in
1995
- // the other direction. The frontier is not an approximation
1996
- // of them; it is the question they were both approximating.
1997
- // A cell is refolded when EITHER is true:
1998
- // - it is entirely above the frontier (never left the
1999
- // screen, so re-wrapping it contradicts nothing), or
2000
- // - it does not FIT: some cached row is wider than W.
2001
- //
2002
- // The second is not a compromise of the first, it is the
2003
- // answer to a question the first cannot reach. A cell can
2004
- // STRADDLE the frontier — its head in the scrollback, its
2005
- // tail still on screen — and the tail must be painted at the
2006
- // current width. Holding its commit fold there emitted a
2007
- // 100-column row into an 80-column screen and invariant ①
2008
- // threw (60 → 100 → 80, measured). Fitting wins: a crash is
2009
- // worse than a seam, and the seam a narrowing leaves is
2010
- // rider 2's, stated rather than hidden.
2011
- let row = 0;
2012
- const refold = new Array(this.#committed).fill(false);
2013
- for (let i = 0; i < this.#committed; i += 1) {
2014
- const lines = this.#lineCache[i];
2015
- if (lines === null || lines === undefined) {
2016
- refold[i] = true;
2017
- continue;
2018
- }
2019
- const above = row >= this.#scrolledOff;
2020
- const fits = lines.every((l) => visibleWidth(l) <= W);
2021
- refold[i] = above || !fits;
2022
- const prev = i > 0 ? this.#lineCache[i - 1] : null;
2023
- row += this.#space(i, prev ?? null, lines).length;
2024
- }
2025
- for (let i = 0; i < this.#committed; i += 1) {
2026
- if (refold[i])
2027
- this.#lineCache[i] = cellComponent(this.#cells[i]).render(W, ctx);
2028
- }
2029
- // #committedLines is re-derived over the WHOLE cache, because
2030
- // the frozen prefix still occupies its own rows.
2031
- this.#committedLines = 0;
2032
- for (let i = 0; i < this.#committed; i += 1) {
2033
- const lines = this.#lineCache[i] ?? cellComponent(this.#cells[i]).render(W, ctx);
2034
- this.#lineCache[i] = lines;
2035
- this.#committedLines += this.#space(i, i > 0 ? (this.#lineCache[i - 1] ?? []) : null, lines).length;
2036
- }
2037
- this.#refolded = refold.some(Boolean);
2038
- }
2039
- // 1. the natural commits — the leading DONE cells freeze: their
2040
- // lines leave the live region, the scrolls + the committed
2041
- // writes below place them (the #17 "freeze as a real line",
2042
- // short sessions included — the frame coalescing keeps a
2043
- // cell's first frame its freeze frame, so the frozen bytes
2044
- // emit exactly once).
1686
+ if (this.#fullRedraw)
1687
+ this.#rederiveCommitted(W, ctx);
1688
+ // 1. the natural commits the leading DONE cells freeze.
1689
+ this.#commitDone(W, ctx);
1690
+ // 2. the live lines — the unfinished cells (the tail) + the chrome.
1691
+ const chrome = this.#chrome(W, H);
1692
+ this.#noteOverlay();
1693
+ const live = this.#liveRows(W, ctx, chrome.cap);
1694
+ // 3. the FORCE commits the live region's hard cap.
1695
+ const liveLines = this.#capLive(live.lines, W, ctx, chrome.cap);
1696
+ // 4. the geometry the live region's first row:
1697
+ // liveTop = min(totalCommitted, H - liveRows) + 1 the screen
1698
+ // shows the bottom H rows; the live region anchors to the bottom.
1699
+ const liveRowsTotal = liveLines.length + chrome.rows;
1700
+ const liveTop = Math.min(this.#committedLines, H - liveRowsTotal) + 1;
1701
+ // TUI2-R3v2 ②: the option rows' ABSOLUTE screen rows, recorded per
1702
+ // frame. A click is answered against the frame the human was looking
1703
+ // at when they clicked, which is this one.
1704
+ this.#panelRowSpan =
1705
+ live.panelSpan === null ? null : { top: liveTop + live.panelSpan.offset, count: live.panelSpan.count, first: live.panelSpan.first };
1706
+ // 5. the frame bytes.
1707
+ this.#paint(W, H, liveTop, liveLines, liveRowsTotal, chrome);
1708
+ }
1709
+ /** Phase 0 a full redraw re-derives the committed rows from the
1710
+ * cache. A cell whose cache was invalidated (a settled resize, R14)
1711
+ * is rendered again at the current width; every other cell keeps
1712
+ * the rows it was committed with, because those rows are already
1713
+ * the terminal's (ADR-0046) and this frame paints the same bytes.
1714
+ *
1715
+ * DECLARED REVERSAL (R14): DC-34's frontier-scoped REFOLD stood
1716
+ * here a widen never refolded a committed cell, a narrowing
1717
+ * refolded the cells that no longer fit. Route B reprints the
1718
+ * whole session from the model on a settled resize, so no
1719
+ * committed row is refolded in place any more, and the width
1720
+ * guard that decided which ones were went with it. */
1721
+ #rederiveCommitted(W, ctx) {
1722
+ this.#committedLines = 0;
1723
+ for (let i = 0; i < this.#committed; i += 1) {
1724
+ const lines = this.#lineCache[i] ?? cellComponent(this.#cells[i]).render(W, ctx);
1725
+ this.#lineCache[i] = lines;
1726
+ this.#committedLines += this.#space(i, i > 0 ? (this.#lineCache[i - 1] ?? []) : null, lines).length;
1727
+ }
1728
+ }
1729
+ /** Phase 1 the natural commits: the leading DONE cells freeze
1730
+ * their lines leave the live region, the scrolls + the committed
1731
+ * writes below place them (the #17 "freeze as a real line", short
1732
+ * sessions included the frame coalescing keeps a cell's first
1733
+ * frame its freeze frame, so the frozen bytes emit exactly once). */
1734
+ #commitDone(W, ctx) {
2045
1735
  this.#committedAtFrameStart = this.#committed;
2046
1736
  this.#committedLinesThisFrame = [];
2047
- // W14: the natural loop HONORS the fold-hold — a thinking/tool
2048
- // cell of the OPEN quiet turn (no text yet) does not commit: its
2049
- // committed form is decided at the release (the turn's text →
2050
- // individual commits with the W13 rollups; the turn's end → the
2051
- // fold). The FORCE-commit path below bypasses the hold — the
2052
- // screen never sticks, the rollup degrades to individuals.
2053
- while (this.#committed < this.#cells.length && this.#cells[this.#committed].done && !this.#held(this.#committed)) {
1737
+ while (this.#committed < this.#cells.length && this.#cells[this.#committed].done) {
2054
1738
  this.#commitCell(this.#committed, W, ctx);
2055
1739
  }
2056
- // 2. the live lines — the unfinished cells (the tail) + the chrome.
2057
- // W11: the formula's blank above the first live cell hangs off
2058
- // the last COMMITTED sibling (the join spans the boundary).
1740
+ }
1741
+ /** The chrome this frame wears: the menu band, the queue band (W22:
1742
+ * the menu-rows family's other occupant — the band above the box
1743
+ * top), the composer (KC1 §6: N rows; N = 1 ⇒ today's chrome
1744
+ * exactly — chromeRows = 3 + N + menu + queue, and the content cap
1745
+ * loses the composer's EXTRA rows the same way it loses the bands).
1746
+ * W11: the formula's blank above the first live cell hangs off the
1747
+ * last COMMITTED sibling (the join spans the boundary). */
1748
+ #chrome(W, H) {
2059
1749
  const menuRows = this.#menuRows(W);
2060
- // W22: the queue chips are the menu-rows family's other occupant
2061
- // (the band above the box top) — the chrome rows and the live
2062
- // caps account for both.
2063
1750
  const queueRows = this.#queueRows(W, H);
2064
- // KC1 §6: the input is N rows now (N = 1 ⇒ today's chrome exactly)
2065
- // — chromeRows = 3 + N + menu + queue, and the content cap loses
2066
- // the composer's EXTRA rows the same way it loses the bands.
2067
1751
  const editor = this.#inputRows(W, H, menuRows.length, queueRows.length);
2068
1752
  const inputExtra = editor.rows.length - 1;
2069
- const chromeRows = CHROME_ROWS + inputExtra + menuRows.length + queueRows.length;
2070
- let liveLines = [];
2071
- // TUI2-R3v2 ②: where this frame put the panel's option rows, relative
2072
- // to the live region's top. Resolved to ABSOLUTE screen rows once
2073
- // liveTop is known, below.
2074
- let panelSpan = null;
2075
- const panel = this.#panelState?.() ?? null;
2076
- // TUI2-R1.5 ⑦(a) (VD-8): the sheet is an OVERLAY, and the frame it
2077
- // opens on — and the one it closes on — take the full-redraw path.
2078
- // The sheet REPLACES the live region, so on an idle composer (where
2079
- // the live region is empty) opening it GROWS the model by its own
2080
- // height; the frame's skip grows with it and the difference is paid
2081
- // in real LFs rows scrolled permanently into the terminal's
2082
- // scrollback, which closing cannot undo, because the scrollback is
2083
- // not ours to rewrite. Measured: three rows per open on a full
2084
- // screen. The overlay below displaces content on screen instead.
1753
+ return {
1754
+ menuRows,
1755
+ queueRows,
1756
+ editor,
1757
+ inputExtra,
1758
+ rows: CHROME_ROWS + inputExtra + menuRows.length + queueRows.length,
1759
+ cap: H - 4 - inputExtra - queueRows.length,
1760
+ };
1761
+ }
1762
+ /** The overlay bookkeeping. TUI2-R1.5 ⑦(a) (VD-8): the sheet is an
1763
+ * OVERLAY, and the frame it opens on and the one it closes on —
1764
+ * take the full-redraw path. The sheet REPLACES the live region, so
1765
+ * on an idle composer (where the live region is empty) opening it
1766
+ * GROWS the model by its own height; the frame's skip grows with it
1767
+ * and the difference is paid in real LFs rows scrolled
1768
+ * permanently into the terminal's scrollback, which closing cannot
1769
+ * undo, because the scrollback is not ours to rewrite. Measured:
1770
+ * three rows per open on a full screen. The overlay displaces
1771
+ * content on screen instead. R5 — the viewer is an overlay of
1772
+ * exactly the same kind, so it joins the same flag. That one word
1773
+ * is what buys it the whole zero-litter discipline: the window
1774
+ * freezes, #emitScroll is skipped, and the close repaints from
1775
+ * #lastSkip. */
1776
+ #noteOverlay() {
2085
1777
  const sheetUp = this.#sheetState?.() === true;
2086
- // R5 — the viewer is an overlay of exactly the same kind, so it
2087
- // joins the same flag. That one word is what buys it the whole
2088
- // zero-litter discipline below: the window freezes, #emitScroll
2089
- // is skipped, and the close repaints from #lastSkip.
2090
1778
  const viewerUp = this.#viewer !== null;
2091
1779
  this.#overlayFrame = sheetUp || this.#sheetWasUp || viewerUp || this.#viewerWasUp;
2092
1780
  this.#sheetWasUp = sheetUp;
2093
1781
  this.#viewerWasUp = viewerUp;
2094
- if (viewerUp) {
2095
- // R5: the viewer REPLACES the live region the same slot the
2096
- // sheet and the panel use, for the same reason (it is what the
2097
- // human is reading right now). It is opened only from an idle
2098
- // composer, so it cannot coexist with a panel.
2099
- liveLines = this.#viewerBand(W).slice(0, Math.max(1, H - 4 - inputExtra - queueRows.length));
2100
- }
2101
- else if (sheetUp) {
2102
- // TUI2-R1 (D): the sheet REPLACES the live region the same
2103
- // slot the panel uses, for the same reason (it is what the
2104
- // human is reading right now). It cannot coexist with a panel:
2105
- // the editor only opens it from an idle composer.
2106
- liveLines = keysSheetRows(W).slice(0, Math.max(1, H - 4 - inputExtra - queueRows.length));
2107
- }
2108
- else if (panel !== null) {
2109
- // W21: the panel REPLACES the running tool's live window — the
2110
- // bounded block, capped at H−4 (the panel IS the live region;
2111
- // the W11 blank would separate it from the frozen content).
2112
- // The cap is exact, so the force-commit loop never fires. W22:
2113
- // the queue band sits below the panel the cap shrinks by it.
2114
- // TUI2-R3v2 ②: the rows and the CLICKABLE span come from one
2115
- // call, so the hit-test reads the arithmetic that placed the
2116
- // rows rather than a second copy of it.
2117
- const frame = panelFrameOf(panel, W, Math.max(1, H - 4 - inputExtra - queueRows.length));
2118
- liveLines = frame.rows;
2119
- panelSpan = frame.options;
2120
- }
2121
- else {
2122
- // TUI2-R2 (D, candidate 1): the FOCUS the cell the next ctrl+o
2123
- // will act on brightens its own token. The index is derived from
2124
- // the SAME scan expandNext performs (#focusIndex shares its rule
2125
- // by construction), so the marker can never point at a cell the
2126
- // key would not take which is the only way a focus marker is
2127
- // worth having.
2128
- liveLines = this.#liveProjection(W, ctx, H - 4 - inputExtra - queueRows.length);
2129
- }
2130
- // 3. the FORCE commits the live region's hard cap H−1: overflow
2131
- // commits the oldest live cell UNCONDITIONALLY (the one sharp
2132
- // edge the cap scalar is asserted by the gates). W22: the
2133
- // queue band shrinks the cap by its rows (empty queue → H−4).
2134
- // DC-53 AND NEVER A CELL THAT IS NOT DONE.
2135
- //
2136
- // This loop committed `#committed` unconditionally. R4's standing
2137
- // slot held the live region at a constant height, so the
2138
- // projection never grew past the cap on its own and the loop never
2139
- // reached a running cell; R13 retired the slot (DC-46) and it
2140
- // promptly did. Three parallel searches, the last two settling
2141
- // first: their cards pushed the region over the cap, the loop
2142
- // committed the FIRST call — still in flight — froze its
2143
- // three-row running card and its breathing mark into the
2144
- // scrollback, and stepped `#committed` past it. When the result
2145
- // arrived there was no live cell left to draw it into, so the work
2146
- // never reached the screen at all.
2147
- //
2148
- // A cell that is not done has no committed form yet; committing
2149
- // one is writing history that has not happened. When the head is
2150
- // running, the region gives way instead — the cards behind it
2151
- // degrade first, then the running window shrinks (DC-43's own
2152
- // ladder, in #liveProjection), and the remainder is counted.
2153
- // The guard is on a RUNNING TOOL CALL specifically, not on any
2154
- // unfinished cell. A streaming text or raw cell HAS a committed
2155
- // form — the rows it has already written are final, append-only —
2156
- // and spilling those is exactly what this loop is for. A running
2157
- // call does not: its card changes shape at the settle, so a
2158
- // committed one is a row that will never be corrected.
2159
- while (liveLines.length > H - 4 - inputExtra - queueRows.length && // V6-3: the content cap H−4 (KC1: −N's extra rows)
1782
+ }
1783
+ /** Phase 2 ONE overlay selector: what occupies the live region
1784
+ * this frame. The transcript viewer (R5), the keys sheet (TUI2-R1
1785
+ * D) and the approval panel (W21) each REPLACE the live region
1786
+ * it is what the human is reading right now — capped at the
1787
+ * content cap; the sheet and the viewer open only from an idle
1788
+ * composer, so neither can coexist with a panel. The panel also
1789
+ * reports where its clickable rows are (TUI2-R3v2 ②: the rows and
1790
+ * the span come from one call, so the hit-test reads the arithmetic
1791
+ * that placed the rows). Otherwise the projection of the live
1792
+ * cells. render() paints this and
1793
+ * liveCount() measures it the same rows, by construction. */
1794
+ #liveRows(W, ctx, cap) {
1795
+ const capped = Math.max(1, cap);
1796
+ if (this.#viewer !== null)
1797
+ return { lines: this.#viewerBand(W).slice(0, capped), panelSpan: null };
1798
+ if (this.#sheetState?.() === true)
1799
+ return { lines: keysSheetRows(W).slice(0, capped), panelSpan: null };
1800
+ const panel = this.#panelState?.() ?? null;
1801
+ if (panel !== null) {
1802
+ // W21: the panel's cap is exact, so the force-commit loop never
1803
+ // fires on it. W22: the queue band sits below the panel the
1804
+ // cap shrinks by it.
1805
+ const frame = panelFrameOf(panel, W, capped);
1806
+ return { lines: frame.rows, panelSpan: frame.options };
1807
+ }
1808
+ return { lines: this.#liveProjection(W, ctx, cap), panelSpan: null };
1809
+ }
1810
+ /** Phase 3 the FORCE commits: overflow past the content cap
1811
+ * commits the oldest live cell (W22: the queue band shrinks the
1812
+ * cap by its rows) but NEVER A TOOL CALL STILL RUNNING (DC-53).
1813
+ * A running card has no committed form yet: its shape changes at
1814
+ * the settle, so a committed one would be a row that is never
1815
+ * corrected measured as a burst's first call frozen into the
1816
+ * scrollback with its breathing mark, its result never drawn. A
1817
+ * streaming text or raw cell DOES have a committed form (its rows
1818
+ * are final, append-only) and spills normally. When the head is
1819
+ * running the region gives way instead: the cards behind it
1820
+ * degrade, then the window shrinks (#liveProjection). */
1821
+ #capLive(lines, W, ctx, cap) {
1822
+ while (lines.length > cap && // V6-3: the content cap H−4 (KC1: −N's extra rows)
2160
1823
  this.#committed < this.#cells.length &&
2161
1824
  !(this.#cells[this.#committed].kind === "tool" && !this.#cells[this.#committed].done)) {
2162
- // R3f: the cell about to be force-committed marks its segment
2163
- // SPILLED. The rule was written at R3b — "a segment too big for
2164
- // the screen already has rows in the scrollback that cannot be
2165
- // taken back, so it renders normally and does not collapse" —
2166
- // and then never wired: `spilled` had a declaration, an
2167
- // initializer and a read, and nothing ever set it. The read was
2168
- // therefore vacuously true, so a 43-call turn force-committed
2169
- // thirty expanded rows and STILL printed `✦ thought 103s · 43
2170
- // reads` underneath them, claiming as folded the work standing
2171
- // visible above it.
2172
- this.#markSpilled(this.#committed);
2173
1825
  this.#commitCell(this.#committed, W, ctx);
2174
1826
  // TUI2-R2 ⑤: the focus re-derives after a commit — the cell it
2175
1827
  // pointed at may have just left the live region.
2176
- liveLines = this.#liveProjection(W, ctx, H - 4 - inputExtra - queueRows.length);
1828
+ lines = this.#liveProjection(W, ctx, cap);
2177
1829
  }
2178
- // 4. the geometry — the live region's first row:
2179
- // liveTop = min(totalCommitted, H - liveRows) + 1 — the screen
2180
- // shows the bottom H rows; the live region anchors to the bottom.
2181
- const liveRowsTotal = liveLines.length + chromeRows;
2182
- const liveTop = Math.min(this.#committedLines, H - liveRowsTotal) + 1;
2183
- // TUI2-R3v2 ②: the option rows' ABSOLUTE screen rows, recorded per
2184
- // frame. A click is answered against the frame the human was looking
2185
- // at when they clicked, which is this one.
2186
- this.#panelRowSpan =
2187
- panelSpan === null ? null : { top: liveTop + panelSpan.offset, count: panelSpan.count, first: panelSpan.first };
2188
- // 5. the frame bytes.
1830
+ return lines;
1831
+ }
1832
+ /** Phase 5 — the frame bytes: the one-time reset, autowrap off, the
1833
+ * synchronised-update bracket, the ONE renderer, and the
1834
+ * bookkeeping the next frame's relative moves start from. */
1835
+ #paint(W, H, liveTop, liveLines, liveRowsTotal, chrome) {
2189
1836
  const out = [];
2190
1837
  // REL-0152-D14 — AUTOWRAP OFF for the frame's duration.
2191
1838
  //
@@ -2266,28 +1913,12 @@ export class Body {
2266
1913
  // never paired with a ?25h. Sync (2026) still brackets the frame
2267
1914
  // where the terminal understands it.
2268
1915
  out.push(this.#conservative ? "\x1b[?25l" : "\x1b[?2026h\x1b[?25l");
2269
- // A8: the bottom-anchored window (the model's last H rows) shifts
2270
- // DOWN when the live region SHRINKS the done-fold, the fold-hold
2271
- // release at the terminal event. The steady path's scroll syncs
2272
- // exactly N committed lines, but the window moves N + liveDelta:
2273
- // a shrink with commits (liveTop grows by LESS than N — the scroll
2274
- // overshoots by liveDelta) slips past the pure-shrink trigger, the
2275
- // stale pass erases the old live rows, and nothing re-paints the
2276
- // band between the committed window and the new liveTop (finding
2277
- // #A8 — the W11-boundary pileup). A GROWTH is safe on the steady
2278
- // path (the new live's bottom-anchored extent covers the old — the
2279
- // erased rows are all re-painted); a shrink takes the full-redraw
2280
- // path: the window re-paints at the model's positions, every row
2281
- // covered (the V6-1 every-row rule).
2282
- // REL-0152-R1: ONE renderer. The steady/full split existed because
2283
- // the steady path moved rows by scrolling and could not handle a
2284
- // window that moved the wrong way, so the frames it could not draw
2285
- // were handed to a full repaint. A diff has no such frames: it
2286
- // emits the rows that differ, and on a frame where everything
2287
- // differs that IS a full repaint. The dispatch, the two geometries
2288
- // and the invariant about which moves each path may use all go
2289
- // with it.
2290
- this.#drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, editor);
1916
+ // REL-0152-R1: ONE renderer. A diff emits the rows that differ, and
1917
+ // on a frame where everything differs that IS a full repaint — so
1918
+ // the old steady/full split (a scrolling path that could not draw
1919
+ // a window moving the wrong way, finding #A8) has nothing left to
1920
+ // dispatch between.
1921
+ this.#drawFull(out, W, H, liveTop, liveLines, chrome.queueRows, chrome.menuRows, chrome.editor);
2291
1922
  this.#fullRedraw = false;
2292
1923
  // REL-0161: the frame close no longer shows the cursor — hidden IS
2293
1924
  // the steady state (Terminal.app infers "a prompt line" from
@@ -2305,19 +1936,11 @@ export class Body {
2305
1936
  }
2306
1937
  this.#lastLiveTop = liveTop;
2307
1938
  this.#lastLiveRows = liveRowsTotal;
2308
- this.#lastInputRows = editor.rows.length;
1939
+ this.#lastInputRows = chrome.editor.rows.length;
2309
1940
  // KC1 §6: the next steady frame's relative moves start where THIS
2310
1941
  // frame's CHA parked the cursor — the marker's row inside the
2311
1942
  // composer (N = 1 ⇒ H−2, the retired hard-coded anchor).
2312
- this.#lastAnchorRow = H - 1 - editor.rows.length + editor.markerRow;
2313
- }
2314
- /** R13 D1 — the same constant as everything else. This used to compute
2315
- * the lead from a ONE-ROW STAND-IN so the live block would claim the
2316
- * spacing its fold was going to get; with the rhythm constant there
2317
- * is nothing to simulate, and R7a's device retires with the formula
2318
- * that needed it. */
2319
- #blockSpace(i, prev, rows) {
2320
- return bodySpacing(this.#lastDrawn(i, prev), rows);
1943
+ this.#lastAnchorRow = H - 1 - chrome.editor.rows.length + chrome.editor.markerRow;
2321
1944
  }
2322
1945
  #space(i, prev, rows) {
2323
1946
  if (i > 0 && this.#cells[i]?.kind === "md" && this.#cells[i - 1]?.kind === "md")
@@ -2326,18 +1949,9 @@ export class Body {
2326
1949
  // so a settle changes content and never position by construction.
2327
1950
  return bodySpacing(this.#lastDrawn(i, prev), rows);
2328
1951
  }
2329
- /**
2330
- * R3i the previous DRAWN sibling, not the previous cell.
2331
- *
2332
- * The spacing formula reads what stood above; a cell that rendered
2333
- * nothing did not stand above anything. Since R3d whole families of
2334
- * cells render `[]` — the members a fold speaks for — and the
2335
- * formula was reading that empty array as "a zero-row sibling", so a
2336
- * multi-row block following a fold lost the blank that belongs above
2337
- * it. The defect predates this round (any folded turn followed by a
2338
- * raw block has it); R3i's projection is what finally put a test on
2339
- * the path.
2340
- */
1952
+ /** The previous DRAWN sibling, not the previous cell: the spacing
1953
+ * formula reads what stood above, and a cell that rendered nothing
1954
+ * did not stand above anything (R3i). */
2341
1955
  #lastDrawn(i, prev) {
2342
1956
  if (prev !== null && prev.length > 0)
2343
1957
  return prev;
@@ -2356,238 +1970,36 @@ export class Body {
2356
1970
  * this cell's commit — the cache stays raw, the placed rows count. */
2357
1971
  #commitCell(i, W, ctx) {
2358
1972
  const cell = this.#cells[i];
2359
- const lines = this.#foldOrRollup(cell, i, W, ctx);
2360
- // W15: a tool cell whose committed rows carried the "ctrl+o"
2361
- // affordance joins the expand history — the detection is the
2362
- // renderer's OWN output, so the read's "/last"-only cut note never
2363
- // lands here. TUI2-R1 (A/B): the affordance is no longer only the
2364
- // renderer cut's "└ ctrl+o" the self-naming head suffix and
2365
- // the exploration row carry it on the HEAD row, and a promise the
2366
- // key does not answer would be the one thing worse than silence.
2367
- // unshift: the cells commit oldest-first, so the NEWEST cut lands
2368
- // at the front — the expand pointer's "newest back" walk starts
2369
- // where the user's last key press would aim.
2370
- // R3b: a fold HEAD joins the ring too. The test used to demand a
2371
- // tool cell, and a segment's fold can be emitted at a thinking
2372
- // cell — which would have left the whole segment unreachable by
2373
- // the very key its own row advertises.
2374
- // R4a — the ring captures by IDENTITY, not by searching our own
2375
- // printed bytes.
2376
- //
2377
- // This used to require the rendered rows to contain the literal
2378
- // "ctrl+o", which made the affordance LOAD-BEARING: retiring the
2379
- // printed key (the owner's ruling) would have silently emptied the
2380
- // ring and taken the expand key with it — not a missing hint, a
2381
- // missing feature. A fold head is a fold head because the segment
2382
- // says so; a tool cell is expandable when it is hiding rows.
2383
- const isFoldHead = this.#segmentOf(i)?.headCell === i;
2384
- const hidesRows = cell.kind === "tool" && lines.some((l) => l.includes("ctrl+o"));
2385
- if (isFoldHead || hidesRows) {
1973
+ const lines = cellComponent(cell).render(W, ctx);
1974
+ // W15: a tool cell whose committed rows hide something (its rows
1975
+ // carry the "ctrl+o" affordance — the renderer's OWN output, so the
1976
+ // read's "/last"-only cut note never lands here) joins the collapsed
1977
+ // index the viewer reads (ctrl+r). unshift: cells commit
1978
+ // oldest-first, so the newest cut is at the front.
1979
+ if (cell.kind === "tool" && lines.some((l) => l.includes("ctrl+o")))
2386
1980
  this.#collapsed.unshift(i);
2387
- // R4a — a new fold resets the walk, so the FIRST press after any
2388
- // new work always opens the most recent one. That is the whole
2389
- // of the owner's "which one does it open": the answer is always
2390
- // "the last one", and repeats walk back from there.
2391
- this.#opened.clear();
2392
- }
2393
1981
  this.#lineCache[i] = lines;
2394
1982
  const placed = this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines);
2395
1983
  this.#committed += 1;
2396
1984
  this.#committedLines += placed.length;
2397
1985
  this.#committedLinesThisFrame.push(...placed);
2398
1986
  }
2399
- /** R3b record which segment the cell just pushed belongs to. Called
2400
- * right after the push, so #cells.length-1 is that cell. */
2401
- #stampSegment() {
2402
- const turn = this.#turns[this.#turns.length - 1];
2403
- const idx = turn === undefined ? -1 : turn.segments.length - 1;
2404
- const at = this.#cells.length - 1;
2405
- this.#cellSegment[at] = idx;
2406
- if (turn !== undefined && idx >= 0)
2407
- turn.segments[idx].cells.push(at);
2408
- }
2409
- /**
2410
- * R3b does the segment hold a call that FAILED or was DENIED?
2411
- *
2412
- * Such a segment does not fold. Routine work is what the fold is for;
2413
- * a refusal and an error are the opposite of routine, and putting
2414
- * either behind a key hides the one thing on the screen that most
2415
- * needs a human's eye. Law 1.3 makes the same call about marks — a
2416
- * failure keeps its colour AND its words — and this is that rule at
2417
- * the scale of a run.
2418
- *
2419
- * The cost, accepted: a turn that reads twenty files and hits one
2420
- * denial keeps all twenty rows. The alternative is a screen that says
2421
- * `✦ thought 3s · 20 reads` while a write was refused inside it.
2422
- */
2423
- /**
2424
- * R3i — the trouble the stretch met, as the line's own terms.
2425
- *
2426
- * Law 1.3: an outcome is stated in WORDS, "the only form that
2427
- * survives a pipe". So the kind is a different word, never a
2428
- * different colour — `2 failed`, `1 denied`, `1 interrupted` — and
2429
- * the failure's identity rides with it. In this phase the terms are
2430
- * only DRAWN (the live line names trouble the moment it happens);
2431
- * whether trouble still blocks the fold is the next phase's ruling.
2432
- */
2433
- /** R3i — is this cell one the fold must not count as work done? */
2434
- #cellInTrouble(i) {
2435
- const c = this.#cells[i];
2436
- if (c === undefined || c.kind !== "tool")
2437
- return false;
2438
- return c.isError || c.reason !== null || c.verdict?.decision === "denied";
2439
- }
2440
- #segmentTroubleTerms(seg) {
2441
- let failed = 0;
2442
- let denied = 0;
2443
- let interrupted = 0;
2444
- let what = "";
2445
- for (const j of seg.cells) {
2446
- const c = this.#cells[j];
2447
- if (c === undefined || c.kind !== "tool")
2448
- continue;
2449
- // WHICH call, and WHY. The target alone answers the first and
2450
- // not the second, and for a policy denial the second is the
2451
- // whole point: `sub/out.txt` does not tell a human that plan
2452
- // mode is read-only, and that sentence is the one they act
2453
- // on. Law 1.3's own words — an outcome is stated in words —
2454
- // and the ladder cuts this clause last, so it degrades to the
2455
- // target before it disappears.
2456
- const named = () => {
2457
- const t = toolTarget(c.name, JSON.parse(c.inputFull));
2458
- const why = c.verdict?.reason ?? c.reason;
2459
- return why === null || why === undefined || why === "" || why === "interrupted" ? t : `${t} (${why})`;
2460
- };
2461
- if (c.verdict?.decision === "denied" || (c.reason !== null && c.reason !== "interrupted" && /denied/i.test(c.reason))) {
2462
- denied += 1;
2463
- if (what === "")
2464
- what = named();
2465
- }
2466
- else if (c.reason === "interrupted") {
2467
- interrupted += 1;
2468
- }
2469
- else if (c.isError || c.reason !== null) {
2470
- failed += 1;
2471
- if (what === "")
2472
- what = named();
2473
- }
2474
- }
2475
- const out = [];
2476
- if (failed > 0)
2477
- out.push(["failed", failed, what]);
2478
- if (denied > 0)
2479
- out.push(["denied", denied, what]);
2480
- if (interrupted > 0)
2481
- out.push(["interrupted", interrupted, ""]);
2482
- return out;
2483
- }
2484
- #segmentHasTrouble(seg) {
2485
- // R3g (fable, 2026-08-28): a DENIED call is the case this rule
2486
- // exists for, and it was the one case the predicate could not
2487
- // see — a denial carrying no `reason` string leaves isError
2488
- // false and reason null, so `✦ thought 3s · 20 reads` could
2489
- // stand over a refused write. The verdict is the record of it.
2490
- return this.#segmentTools(seg).some((c) => c.isError || c.reason !== null || c.verdict?.decision === "denied");
2491
- }
2492
- /** R3b — the segment's TOOL cells, in order. */
2493
- #segmentTools(seg) {
2494
- const out = [];
2495
- for (const j of seg.cells) {
2496
- const c = this.#cells[j];
2497
- if (c.kind === "tool")
2498
- out.push(c);
2499
- }
2500
- return out;
2501
- }
2502
- /** R3f — the cell is leaving the live region under the screen's hard
2503
- * cap, so its segment can no longer be represented by a fold. */
2504
- #markSpilled(i) {
2505
- const seg = this.#segmentOf(i);
2506
- if (seg !== null)
2507
- seg.spilled = true;
2508
- }
2509
- /** R3b — the segment a committed cell belongs to, or null when it has
2510
- * none (a cell of the pipe path, or a kind that is not work). */
2511
- #segmentOf(i) {
2512
- const cell = this.#cells[i];
2513
- if (cell.kind !== "thinking" && cell.kind !== "tool")
2514
- return null;
2515
- const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
2516
- const si = this.#cellSegment[i];
2517
- if (turn === undefined || si === undefined || si < 0)
2518
- return null;
2519
- return turn.segments[si] ?? null;
2520
- }
2521
- /** W14 — the fold-hold: a thinking/tool cell of the OPEN quiet turn
2522
- * (no text yet) does not commit — its committed form is decided at
2523
- * the release. The cell's OWN turn must be the CURRENT one (a cell
2524
- * of a released turn commits normally). The force-commit path never
2525
- * consults this — the screen's hard cap wins over the hold. */
2526
- #held(i) {
2527
- // R13 — NOTHING IS HELD ANY MORE. DECLARED REVERSAL of W14's
2528
- // quiet-turn hold, R3b/R3i's segment hold and TUI2-R1.5 ①'s
2529
- // explore-run hold, all three of them owner-ruled away on
2530
- // 2026-09-03 with the mechanism they served.
2531
- //
2532
- // Every one of them existed for the same reason: a done cell's
2533
- // COMMITTED FORM was undecided while its segment or its run was
2534
- // still open, because a fold line or a rollup row might yet stand
2535
- // for it — and a committed row cannot be taken back (ADR-0046).
2536
- // With no fold and no rollup, a call's committed form is its own
2537
- // card and is settled the instant the call is: there is nothing
2538
- // left to wait for.
2539
- //
2540
- // It is not only dead weight. Holding done cells in the live
2541
- // region made the region carry work that was FINISHED, so a burst
2542
- // of four reads and a shell in flight competed for the same rows
2543
- // and DC-43's shrink took the running call's output away — the
2544
- // one thing on the screen the human is waiting for (R7a D). Let
2545
- // them commit and the room is there.
2546
- void i;
2547
- return false;
2548
- }
2549
- /* R13 — `#growingRun` retired with the rollup's commit hold (#held). */
2550
- /** W14/W13 — the release-time decision at a commit, BEFORE the cell's
2551
- * own render: the folded-turn fold first (a QUIET turn — ended, no
2552
- * text — becomes the ONE fold line; the rest of its thinking/tool
2553
- * cells render [] after the fold), then the W13 rollup (a text
2554
- * turn's N > 2 same-tool run: the HEAD renders the group summary,
2555
- * the members render [] — the scan is the work order's "group key",
2556
- * derived at commit time, never pre-stored). */
2557
- #foldOrRollup(cell, i, W, ctx) {
2558
- // R13 — DECLARED REVERSAL, three at once, all of them the same
2559
- // idea and all of them owner-ruled on 2026-09-03:
2560
- //
2561
- // · the SEGMENT FOLD (R3b–R3i, W14) — a closed stretch of two or
2562
- // more cells collapsed into one settled line;
2563
- // · the W13 ROLLUP and the `rolled` cell field — a run of three
2564
- // or more same-tool calls collapsed into a group summary;
2565
- // · TUI2-R1 (B)'s EXPLORATION ROW — the mixed read-only variant
2566
- // of the same collapse.
2567
- //
2568
- // Every one of them answered the same pressure: ungrounded output
2569
- // rows owned the screen, so work was collapsed into sentences
2570
- // ABOUT the work. The card is what changes that arithmetic — a
2571
- // call's rows sit inside a surface that says where it begins and
2572
- // ends, so five of them read as one object rather than five loose
2573
- // lines — and the owner's ruling is that the collapse costs more
2574
- // than it buys: a page where the machine's work is sometimes a
2575
- // card, sometimes a summary line and sometimes nothing at all is
2576
- // a page a reader cannot predict. One rhythm, one surface.
2577
- //
2578
- // So every cell renders itself, and this method is the record of
2579
- // what used to happen here. What the folds bought is bought
2580
- // differently now: the preview cap (five rows a call, E1's read
2581
- // showing none) is what keeps a burst from owning the screen, and
2582
- // it is a CONSTANT per call rather than a decision about runs.
2583
- //
2584
- // What goes with them: `#growingRun`'s commit hold existed only so
2585
- // a run's committed FORM could be decided once the run closed —
2586
- // with no run-level form left to decide, a done cell commits when
2587
- // it is done. `#rolledHeads`, `rolledOf`, `rolledTitle`,
2588
- // `rolledDetail` and the `rolled` field go with the rollup.
2589
- return cellComponent(cell).render(W, ctx);
2590
- }
1987
+ /* DECLARED REVERSAL (R13, owner-ruled 2026-09-03): the segment
1988
+ bookkeeping stood here `#stampSegment`, `#segmentOf`,
1989
+ `#segmentHasTrouble`, `#segmentTroubleTerms`, `#markSpilled` — and
1990
+ with it the three commit HOLDS (`#held`: W14's quiet turn, R3b/R3i's
1991
+ segment, TUI2-R1.5 ①'s explore run). All of it decided whether a
1992
+ fold or a rollup would stand for a done cell. Nothing folds, so a
1993
+ done cell commits the instant it is done, and holding it back only
1994
+ made finished work compete with the running call for rows (R7a D). */
1995
+ /* DECLARED REVERSAL (R13, owner-ruled 2026-09-03): `#foldOrRollup`
1996
+ stood here — the commit-time decision whether a cell rendered
1997
+ itself, a segment's fold line (R3b–R3i, W14), a W13 rollup summary
1998
+ or TUI2-R1 (B)'s exploration row. All three collapses answered one
1999
+ pressure (ungrounded output rows owning the screen) by turning work
2000
+ into sentences ABOUT work; the card answers it with a constant
2001
+ preview cap per call, and a page whose work is always the same
2002
+ shape is one a reader can predict. Every cell renders itself. */
2591
2003
  /** The slot occupant's extra rows — the slash-command menu (above the
2592
2004
  * status, in the rhythm gap + the content's spare rows — the old
2593
2005
  * menu's position, slot-shaped). */
@@ -2944,38 +2356,30 @@ export class Body {
2944
2356
  // sit with the live band empty and the chrome at its minimum — the
2945
2357
  // one-way part of a movement that otherwise goes both ways.
2946
2358
  if (this.#resizeFrame) {
2947
- // REL-0152-R1: a resize scrolls NOTHING of ours. Shrinking the
2948
- // window is the terminal's own scroll — it reflows the old
2949
- // content and pushes the overflow into its scrollback before we
2950
- // are called — so emitting our own LFs on top put the same rows
2951
- // in twice (TT-1B: twelve rows of a forty-line burst). The
2952
- // counter adopts the terminal's work, and the held screen is
2953
- // discarded because a reflow invalidates every row of it: the
2954
- // next diff repaints the whole screen, which is exactly what a
2955
- // resize needs.
2956
- // DC-34 — NO HIGH-WATER MARK ON A RESIZE.
2359
+ // R14 / route B THE RESIZE FRAME SCROLLS LIKE ANY OTHER.
2957
2360
  //
2958
- // This was `max(#scrolledOff, …)`, which held a stale count
2959
- // whenever a widen made the fresh one smaller; `leaving` then
2960
- // stayed <= 0 and the text that marched past in the meantime
2961
- // never entered the scrollback at all the hole, the other
2962
- // half of the same off-by-a-refold.
2361
+ // Everything this branch used to hold is retired with the
2362
+ // window arithmetic it served. It adopted the terminal's own
2363
+ // reflow-scroll into `#scrolledOff` (REL-0152-R1), then had to
2364
+ // stop adopting it on a widen and keep adopting it on a narrow
2365
+ // (DC-34), because the counter it was feeding meant a different
2366
+ // thing at every fold width. There is no such counter to feed
2367
+ // now: `#settleResize` erased the terminal and set
2368
+ // `#scrolledOff` to zero, so the frontier is real and empty and
2369
+ // the frame simply reprints from the top.
2963
2370
  //
2964
- // The other implementation in this space reached the same
2965
- // conclusion independently and says so in its own source: a
2966
- // historical high-water mark "caused self-reinforcing
2967
- // inflation that pushed content into scrollback on terminal
2968
- // widen". Dropping it alone brings the DUPLICATE back — it is
2969
- // the pair with the no-refold rule above, not a substitute
2970
- // for it.
2971
- // PROBE 3: a widen leaves it ALONE; a narrow keeps REL-0152-R1.
2972
- if (this.#refolded)
2973
- this.#scrolledOff = Math.max(this.#scrolledOff, Math.max(0, Math.min(skip, all.length)));
2974
- this.#refolded = false;
2371
+ // Falling through to `#emitScroll` is not a convenience — it is
2372
+ // the whole point. The transcript above the last screenful has
2373
+ // to reach the scrollback, chunked and staged from the model,
2374
+ // exactly as a resumed session's replay does. Without it the
2375
+ // reprint painted the last screenful onto a terminal whose
2376
+ // history it had just erased, and everything above was simply
2377
+ // GONE: `dc34-widen-seam` measured 36 tokens missing from the
2378
+ // scrollback the first time this branch was left in place.
2975
2379
  this.#screen = new Array(H).fill(NOT_PAINTED);
2976
2380
  this.#resizeFrame = false;
2977
2381
  }
2978
- else if (!overlay) {
2382
+ if (!overlay) {
2979
2383
  // DC-46 — THE ROWS THAT LEAVE THE WINDOW SCROLL, and the target
2980
2384
  // is `skip` itself.
2981
2385
  //
@@ -3009,26 +2413,12 @@ export class Body {
3009
2413
  // OLDEST on screen — they are still in the model and come back on
3010
2414
  // the close.
3011
2415
  const contentRows = Math.max(0, H - CHROME_ROWS - inputExtra - queueRows.length - menuRows.length);
3012
- // DC-34 THE MARCH NEVER REACHES BELOW THE FRONTIER.
3013
- //
3014
- // Rows [0, #scrolledOff) are in the terminal's scrollback and are
3015
- // immutable; painting one puts the same prose on screen twice,
3016
- // which is the owner's report. `skip` can drop below it whenever
3017
- // the model shrinks under a fixed screen — a widen refolding the
3018
- // cells above the frontier, or the live band collapsing — and
3019
- // nothing stopped it (rider 3's ungated reach-back).
3020
- //
3021
- // Clamping costs a gap under short content for one frame, which
3022
- // the next commit fills. Reaching back costs a duplicate that
3023
- // stands in the transcript forever.
3024
- // ...but only when the WIDTH moved. A height change re-indexes
3025
- // nothing — the folds are untouched, every row means what it
3026
- // meant — so reaching back there is the pre-existing behaviour a
3027
- // gate already covers (the A8 windowing case: grow the screen and
3028
- // the banner returns). The duplication measured in this round is
3029
- // width-driven, and so is the guard.
3030
- const march = all.slice(this.#lastW !== 0 && this.#lastW !== W ? Math.max(skip, this.#scrolledOff) : skip);
3031
- this.#lastW = W;
2416
+ // The march starts at `skip`, which is already clamped to the
2417
+ // frontier above (rows in [0, #scrolledOff) are the terminal's and
2418
+ // are never painted again). DC-34's width-only reach-back guard
2419
+ // stood here until R14: a resize now reprints from the model, so
2420
+ // there is no stale fold for a width change to re-index.
2421
+ const march = all.slice(skip);
3032
2422
  for (const line of march.length > contentRows ? march.slice(march.length - contentRows) : march) {
3033
2423
  desired[r - 1] = this.#checked(line, W);
3034
2424
  r += 1;
@@ -3052,7 +2442,7 @@ export class Body {
3052
2442
  desired[H - 2 - inputExtra + i - 1] = this.#checked(editor.rows[i], W);
3053
2443
  desired[H - 1 - 1] = boxBottom(W);
3054
2444
  const statusRow = this.#statusSource();
3055
- desired[H - 1] = this.#checked(statusLine(statusRow.status, this.#tail, W, statusRow.hint), W);
2445
+ desired[H - 1] = this.#checked(statusLine(statusRow.status, this.#tail, W, statusRow.hint, statusRow.expand), W);
3056
2446
  this.#emitDiff(out, W, H, desired);
3057
2447
  // REL-0152-R1: park from where the cursor ACTUALLY is — see
3058
2448
  // #cursorRow. It used to be parked from H, which the bottom-up