@vincemakes/kiso-tui 0.25.0 → 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, 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,22 +163,6 @@ export class Body {
198
163
  #needsReset = false;
199
164
  #resizeTimer = null;
200
165
  #lastH = 0;
201
- /** DC-34 — did THIS FRAME refold the committed cells?
202
- *
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;
215
- /** R14 — the geometry the last SETTLE ran at. A winch back to the
216
- * same size is not a resize and must not erase the scrollback. */
217
166
  /** R14 — the geometry the last frame PAINTED, and the snapshot of it
218
167
  * taken when a winch opens a settle window.
219
168
  *
@@ -248,11 +197,6 @@ export class Body {
248
197
  #lastThinking = null;
249
198
  #lastTool = null;
250
199
  #pendingCalls = new Map();
251
- /** R3b — cell index → the index of the segment it belongs to, for
252
- * thinking/tool cells; -1 for every other kind. Parallel to #cells,
253
- * because a segment is the COMPOSITOR's bookkeeping and does not
254
- * belong on the cell type the renderer sees. */
255
- #cellSegment = [];
256
200
  #pipeBuf = ""; // the passthrough's thinking buffer
257
201
  /** TUI2-MD ⑤ — the markdown scanner of the message currently
258
202
  * streaming, and the cell index its first block landed at. Null
@@ -267,14 +211,9 @@ export class Body {
267
211
  // VIEWER's index now (ctrl+r, `#viewerEntries`) — the two surfaces
268
212
  // coexist and §9 says so. It is kept, not retired, for that reader.
269
213
  #collapsed = [];
270
- // W14: the turn records — one per userLine, the fold-hold's state
271
- // machine (ended / hasText / folded) plus the folded-turn line's
272
- // counts (accumulated at toolStart). The cells carry the record's
214
+ // the turn records — one per userLine; the cells carry the record's
273
215
  // index as their turn boundary.
274
216
  #turns = [];
275
- // W13: the rolled-up run heads — the commit-time scan's verdict:
276
- // the head's group summary renders, the members render [].
277
- #rolledHeads = new Set();
278
217
  #write;
279
218
  #resizeHandler = null;
280
219
  /** TUI2-R3v2 ②: the panel option rows' absolute screen span, as of the
@@ -414,10 +353,8 @@ export class Body {
414
353
  }
415
354
  this.#closeOpenThinking();
416
355
  this.#closeOpenText();
417
- // W14: the turn boundary the record the fold-hold's release
418
- // state machine reads; the cell carries the record's index. A9:
419
- // the user's own words ride the record — the fold's leading chip.
420
- 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 });
421
358
  this.#cells.push({ kind: "user", text, done: true, turn: this.#turns.length - 1 });
422
359
  this.#mark();
423
360
  }
@@ -432,42 +369,15 @@ export class Body {
432
369
  }
433
370
  else {
434
371
  this.#cells.push({ kind: "thinking", text, done: false, turn: this.#turns.length - 1 });
435
- // DECLARED SUPERSESSION (R7, owner-ruled 2026-08-31) — THINKING
436
- // IS WORDS, NOT WORK.
437
- //
438
- // R3b made thinking open a segment, on the reading that it is
439
- // work like a tool call. Four rounds of consequences followed
440
- // from that one classification: folded away with the calls, it
441
- // became unreachable, and R4's printed ordinal, R5's viewer,
442
- // R6's subject index and a look-back viewport were each built
443
- // to hand it back. The owner's ruling is to stop hiding it —
444
- // and then none of those mechanisms is answering a question
445
- // anyone still asks.
446
- //
447
- // So thinking CLOSES the open segment, exactly as text does
448
- // (see textAppend): a segment is what sits between two of
449
- // these. It must close rather than merely not-open, because
450
- // `#committed` is a PREFIX count — a thinking cell cannot
451
- // commit past a held call, so think → call → think would
452
- // otherwise flush at the segment's close with the second
453
- // thought printing BELOW the fold that contains the later
454
- // call.
455
- //
456
- // Consequence, and it is wanted: the segment's thinking clock
457
- // never starts, so `thought Ns` drops off every fold line by
458
- // R3h's own zero-term rule. The line stops claiming a fact the
459
- // paragraph above it already states in full.
372
+ // R7 (owner-ruled 2026-08-31): thinking is WORDS, not work a
373
+ // cell like prose.
460
374
  const t0 = this.#turns[this.#turns.length - 1];
461
- closeSegment(t0, Date.now());
462
375
  if (t0 !== undefined)
463
- t0.begun = true; // R6/D1: the block allocates here
464
- // R3i: and the beat starts HERE. Law 1.4 says "a running thought
465
- // twinkles", and `#armSpinner`'s own predicate has always
466
- // included an open thinking cell — but the only caller was
467
- // `toolRunning`, so a stretch that thought and did nothing else
468
- // never moved at all. The line's seconds are a frame-time
469
- // derivation, so without the beat they also never ticked: the
470
- // 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.
471
381
  this.#armSpinner();
472
382
  }
473
383
  this.#mark();
@@ -475,10 +385,6 @@ export class Body {
475
385
  thinkingEnd() {
476
386
  const last = this.#cells[this.#cells.length - 1];
477
387
  if (last !== undefined && last.kind === "thinking" && !last.done) {
478
- // R3i: every closer — text, a notice, a terminal label, the next
479
- // turn — routes through here, so the clock cannot keep running
480
- // past the thing that ended it.
481
- stopThinking(this.#turns[this.#turns.length - 1]?.segments.at(-1), Date.now());
482
388
  last.done = true;
483
389
  this.#lastThinking = last.text;
484
390
  if (!this.#isActive())
@@ -503,9 +409,7 @@ export class Body {
503
409
  // `done` flag says otherwise. The commit loop takes leading DONE
504
410
  // cells, so that one stale flag parked the whole rest of the turn
505
411
  // behind it — every tool cell then reached the screen through the
506
- // FORCE-commit path, which by design bypasses the fold-hold. That is
507
- // why the walkthrough saw nine individual rows: not a fold that
508
- // declined to form, a fold that was never consulted.
412
+ // FORCE-commit path.
509
413
  this.#closeOpenThinking();
510
414
  this.#closeOpenText();
511
415
  // W12: the cell carries the delegate's child roles from the FULL
@@ -520,73 +424,11 @@ export class Body {
520
424
  }
521
425
  this.#toolCells.set(callId, this.#cells.length);
522
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 });
523
- // W14: the turn record's counts the folded-turn line's terms
524
- // (reads = read_file, edits = edit_file, the rest in first-call
525
- // 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.
526
429
  const turn = this.#turns[this.#turns.length - 1];
527
- if (turn !== undefined) {
528
- // R3h (fable, 2026-08-29): an OBJECT-counting tool counts the
529
- // distinct thing, not the act. Reading one file twice used to
530
- // fold as `read 2 files` — a sentence law 1.3 forbids, and one
531
- // this product shipped. `bump` is false on the second sighting
532
- // of a target the term has already counted; an ACT-counting
533
- // tool (a search, a shell command) always bumps, because two
534
- // searches for the same pattern really are two searches.
535
- // R3i phase 5 — an ANSWER is words, and words do not fold (law
536
- // 1.7). `ask_user` closes the open stretch exactly as prose
537
- // does, and never joins one: absorbed into `1 × ask_user`,
538
- // what the human said would be gone from the screen — and the
539
- // one thing a summary must not do is speak for the human.
540
- if (name === "ask_user") {
541
- // no stamp: it belongs to NO stretch, so no fold can speak
542
- // for it — the same standing a block of prose has.
543
- closeSegment(turn, Date.now());
544
- this.#mark();
545
- return;
546
- }
547
- const target = foldCountsObjects(name) ? toolTarget(name, input) : null;
548
- const bump = (rec) => {
549
- if (target === null)
550
- return true;
551
- let set = rec.seen.get(name);
552
- if (set === undefined) {
553
- set = new Set();
554
- rec.seen.set(name, set);
555
- }
556
- if (set.has(target))
557
- return false;
558
- set.add(target);
559
- return true;
560
- };
561
- if (bump(turn)) {
562
- if (name === "read_file")
563
- turn.reads += 1;
564
- else if (name === "edit_file")
565
- turn.edits += 1;
566
- else
567
- turn.others.set(name, (turn.others.get(name) ?? 0) + 1);
568
- }
569
- // R3b: and into the SEGMENT, which opens here when this is the
570
- // first work since the last text block. Its set is its OWN — a
571
- // file read once per segment is one file in each segment's
572
- // terms and one file in the turn's.
573
- const seg = openSegment(turn, Date.now());
574
- // R3i: a tool call is a NON-thinking event — the clock stops,
575
- // exactly as the CLI's does at the same boundary.
576
- stopThinking(seg ?? undefined, Date.now());
577
- if (seg !== null && bump(seg)) {
578
- if (name === "read_file")
579
- seg.reads += 1;
580
- else if (name === "edit_file")
581
- seg.edits += 1;
582
- else
583
- seg.others.set(name, (seg.others.get(name) ?? 0) + 1);
584
- }
585
- }
586
- this.#stampSegment();
587
- const t1 = this.#turns[this.#turns.length - 1];
588
- if (t1 !== undefined)
589
- t1.begun = true; // R6/D1: the block allocates here
430
+ if (turn !== undefined && name !== "ask_user")
431
+ turn.begun = true;
590
432
  this.#mark();
591
433
  }
592
434
  toolApproval(callId, diff) {
@@ -702,17 +544,9 @@ export class Body {
702
544
  this.#write(escapeTerminal(text));
703
545
  return;
704
546
  }
705
- // W14: the text's arrival RELEASES the fold-hold — the turn now
706
- // has text, its held cells commit individually (with the W13
707
- // rollups; the fold is only for the QUIET turn).
708
547
  const turn = this.#turns[this.#turns.length - 1];
709
548
  if (turn !== undefined)
710
549
  turn.hasText = true;
711
- // R3b: text CLOSES the open segment. This is the boundary design.md
712
- // §8 names — "folding at every text boundary changes what commits
713
- // and when" — and it is the whole mechanism: a segment is what sits
714
- // between two of these.
715
- closeSegment(turn, Date.now());
716
550
  // TUI2-MD ⑤: assistant body text is MARKDOWN, scanned as it
717
551
  // streams. The scanner yields CLOSED blocks (final source, final
718
552
  // render) and one OPEN tail block; each becomes a cell, and the
@@ -780,14 +614,9 @@ export class Body {
780
614
  this.#endMd();
781
615
  this.#mark();
782
616
  }
783
- /** W14 — the turn boundary's END: the CLI calls this at the run's
784
- * terminal event, once per run, BEFORE the recap (so the fold line
785
- * commits before the recap in the cell order). `thoughtSeconds` is
786
- * the CLI's wall-clocked thinking window. The QUIET turn (ended, no
787
- * text) releases its held cells as the ONE fold line; a turn with
788
- * text releases them as individual commits (the W13 rollups). The
789
- * release is LAZY — the held cells commit at the next frame, when
790
- * 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. */
791
620
  endTurn(thoughtSeconds) {
792
621
  if (!this.#isActive())
793
622
  return;
@@ -796,9 +625,6 @@ export class Body {
796
625
  return;
797
626
  turn.ended = true;
798
627
  turn.thoughtSeconds = thoughtSeconds;
799
- // R3b: the settle closes the last open segment — the turn's end is
800
- // a boundary exactly as a text block is.
801
- closeSegment(turn, Date.now());
802
628
  // W20: the turn's live task block settles HERE — the ONE recap
803
629
  // block for the turn ("`task done · N items · <duration>", the
804
630
  // duration clocked compositor-side from the block's first call —
@@ -821,9 +647,7 @@ export class Body {
821
647
  // and only ever left it through the force-commit cap. The turn's
822
648
  // end is the boundary that closes them, exactly as it closes an
823
649
  // open thinking cell. `reason` is set so the row keeps its words
824
- // AND so #segmentHasTrouble holds the turn unfolded — an
825
- // interruption is trouble, and law 1.3 says trouble is never
826
- // summarised away.
650
+ // (law 1.3: trouble is never summarised away).
827
651
  for (const c of this.#cells) {
828
652
  if (c.kind === "tool" && !c.done) {
829
653
  c.state = "done";
@@ -833,10 +657,8 @@ export class Body {
833
657
  }
834
658
  }
835
659
  // the QUIET turn: an open thinking cell closes at the boundary —
836
- // its natural closer is the text's arrival (never comes here — the
837
- // text-less turn), so without this the fold could never commit AT
838
- // it (the commit loop only takes done cells — the fold would stall
839
- // 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.
840
662
  for (let i = this.#cells.length - 1; i >= 0; i -= 1) {
841
663
  const c = this.#cells[i];
842
664
  if (c.kind === "thinking" && !c.done) {
@@ -964,68 +786,24 @@ export class Body {
964
786
  lastTool() {
965
787
  return this.#lastTool;
966
788
  }
967
- /** W15 the expand key's target (ctrl+o). A cell still in the LIVE
968
- * region (the newest live tool) TOGGLES in place the compositor
969
- * owns those rows and redraws them (the body flips to the full
970
- * form, no cap). A committed cell can never toggle history is
971
- * never rewritten (ADR-0046) so the key APPENDS a fresh expanded
972
- * block at the bottom instead, the /last idiom aimed at a chosen
973
- * cell: the pointer cycles the collapsed history, newest first, and
974
- * the header names the target ("N turns back" — the user cells
975
- * after it), so every press tells the user what they got. */
976
- /**
977
- * TUI2-R2 ⑤ — the cell the next ctrl+o will act on, or -1.
978
- *
979
- * The rule is expandNext's own first loop, extracted verbatim: the
980
- * LAST live cell that can toggle. It is a separate method rather than
981
- * a shared constant because the marker and the key must not merely
982
- * agree today — the marker is a PROMISE about what the key will do,
983
- * and the only way to keep it is to derive it from the same scan.
984
- *
985
- * The committed fallback (the #collapsed ring) is deliberately NOT
986
- * marked: those rows are frozen history, never re-emitted, so a tint
987
- * on them could not be moved when the pointer advances. A live target
988
- * is the one the marker can tell the truth about.
989
- */
990
- #focusIndex() {
991
- for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
992
- const cell = this.#cells[i];
993
- if (cell.kind === "tool" && cell.state !== "pending")
994
- return i;
995
- if (cell.kind === "checklist" && !cell.done)
996
- return i;
997
- }
998
- return -1;
999
- }
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). */
1000
794
  /**
1001
- * R4 (C4d) — THE APPEND-ONLY RE-WRAP.
1002
- *
1003
- * The owner's report: resize the window and the reference
1004
- * implementation's text re-wraps to the new width while kiso's does
1005
- * not. It is true, and it is not a bug to be fixed — it is the price
1006
- * of ADR-0046, and the price is worth naming precisely.
1007
- *
1008
- * A terminal can only reflow a SOFT-wrapped line: one long logical
1009
- * line the terminal itself wrapped as the cursor flowed past the last
1010
- * column. Every row kiso commits is either painted by cursor
1011
- * addressing (#emitDiff) or scrolled out by a bare LF (#emitScroll),
1012
- * and frames run with autowrap OFF — so no byte kiso commits can ever
1013
- * carry a continuation flag, and nothing downstream can rejoin rows an
1014
- * application hard-split. That same LF is what makes the transcript
1015
- * the TERMINAL's: it survives kiso's death, a pipe, and tmux. A
1016
- * product whose transcript reflows is a product that repaints its
1017
- * transcript from its own memory, and that transcript dies with it.
1018
- *
1019
- * What kiso can do — and this is all it can do — is APPEND. The
1020
- * committed cells are still in memory; re-render them at the current
1021
- * width and put them at the BOTTOM, where writing is allowed. Nothing
1022
- * 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.
1023
803
  *
1024
804
  * Scoped to PROSE. Text is what reads badly at the wrong width — a
1025
805
  * paragraph folded for 120 columns and read at 60 is the complaint.
1026
- * Tool rows, folds and chips are short, already carry their own
1027
- * width ladders, and re-printing them would duplicate work the folds
1028
- * exist to state once.
806
+ * Cards and chips are short and carry their own width ladders.
1029
807
  */
1030
808
  rewrap() {
1031
809
  const W = this.#opts.width();
@@ -1152,8 +930,7 @@ export class Body {
1152
930
  continue;
1153
931
  // the tool card's FULL body — the same rows its own ctrl+o
1154
932
  // opens. The expanded flag is saved and restored inside this
1155
- // synchronous call, the pattern the rollup path has always
1156
- // used for head.rolled; it never outlives the render, so the
933
+ // synchronous call; it never outlives the render, so the
1157
934
  // committed geometry #committedLines derives can never see it.
1158
935
  const saved = cell.expanded;
1159
936
  cell.expanded = true;
@@ -1185,32 +962,13 @@ export class Body {
1185
962
  cutLine(`${p.dim} ${viewerHint(this.#viewer, entries)}${p.reset}`, W),
1186
963
  ];
1187
964
  }
1188
- /*
1189
- * RETIRED (DC-50 / R14, 2026-09-05) — `expandNext`, `#expandNextRaw`,
1190
- * `#lastAppend`, and the `#opened` bookkeeping that went with them.
1191
- *
1192
- * The whole apparatus served ONE constraint: ADR-0046 §3 forbade
1193
- * re-rendering a committed card, so the only way to show one's body
1194
- * was to APPEND a copy further down the transcript. That needed a ring
1195
- * to choose which card came next, a set to stop the ring repeating
1196
- * itself, and DC-35's guard to stop a HELD key printing the same four
1197
- * rows three times — a defect the owner met in the field, each copy
1198
- * closing with a footer that named something which did not exist.
1199
- *
1200
- * Amendment 1 removes the constraint. The terminal's scrollback is
1201
- * ours to erase, so a committed card is re-rendered where it stands:
1202
- * nothing to append, nothing to choose between, no repeat to guard.
1203
- * `toggleExpanded` is the whole of the feature now.
1204
- *
1205
- * `#collapsed` STAYS — the viewer (ctrl+r) reads it as its index of
1206
- * expandable cells, and §9 keeps the two surfaces side by side.
1207
- *
1208
- * `expandedCard` does NOT stay, and an earlier draft of this note said
1209
- * it did. An expanded card is drawn by the ORDINARY card renderer with
1210
- * `expanded` set — full body, `ctrl+o collapses` in place of the cut
1211
- * note — which `r14-global-expand` pins. `expandedCard` was the shape
1212
- * of the APPENDED block and has no caller once the append is gone.
1213
- */
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. */
1214
972
  // ---- the Dock façade (the CLI's chrome API — same shape as the old dock) ----
1215
973
  /** Docked = the chrome is live (a color TTY with a real size). */
1216
974
  get active() {
@@ -1545,7 +1303,7 @@ export class Body {
1545
1303
  // The terminal now holds nothing, so every record of what it held
1546
1304
  // is void. `#scrolledOff` is the frontier of what reached its
1547
1305
  // scrollback: after the erase, that is zero — which is also what
1548
- // releases the committed cells to be refolded at the new width
1306
+ // releases the committed cells to be re-rendered at the new width
1549
1307
  // without contradicting anything, the thing DC-34's frontier rule
1550
1308
  // existed to prevent.
1551
1309
  this.#scrolledOff = 0;
@@ -1638,13 +1396,21 @@ export class Body {
1638
1396
  // approval and the pick, ask-panel.ts for the ask), so nothing is
1639
1397
  // lost anywhere.
1640
1398
  if (panel !== null)
1641
- return { status: panelStatusOf(panel), hint: undefined };
1399
+ return { status: panelStatusOf(panel), hint: undefined, expand: null };
1642
1400
  // W22: while turns wait in the queue, the right hint shows the
1643
1401
  // count — the chips below carry the lines themselves.
1644
1402
  const queued = this.#queueState?.().length ?? 0;
1645
1403
  if (queued > 0)
1646
- return { status: this.#status, hint: `+${queued} queued` };
1647
- 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 };
1648
1414
  }
1649
1415
  /** Bind the editor's panel state — the PanelSelect slot occupant
1650
1416
  * (W21: the panel replaces the live region + the input lead while
@@ -1775,49 +1541,17 @@ export class Body {
1775
1541
  }
1776
1542
  // ---- the one writer ----
1777
1543
  /**
1778
- * R3i phase 2THE LIVE PROJECTION.
1779
- *
1780
- * One definition, called from the natural path and from inside the
1781
- * force-commit loop, because two copies of "what the live region
1782
- * looks like" is two answers to one question.
1783
- *
1784
- * The change this phase makes, and the ONLY one: the cells of the
1785
- * OPEN stretch no longer each hold a row. The stretch is one line —
1786
- * the same line the settle will keep, in the present tense — plus
1787
- * the calls actually in flight. A completed call renders nothing;
1788
- * its count rides the line.
1789
- *
1790
- * What it fixes: a 28-call turn used to spend 28 rows of a 30-row
1791
- * live region, so overflow was the NORM on real turns rather than
1792
- * the edge — and a turn that overflows may not fold (R3f: a line
1793
- * cannot claim rows already in the scrollback), which is why the
1794
- * fold missed exactly the turns it exists for. The block's height
1795
- * no longer depends on the call count at all.
1796
- *
1797
- * What it does NOT change: nothing about what commits or when. The
1798
- * hold is untouched, the force-commit cap is untouched, and the
1799
- * settle still produces the same fold it did before. That is the
1800
- * 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.
1801
1554
  */
1802
- /* DC-46 — `#liveRoom` RETIRED, and the reasoning with it.
1803
- It capped the live region at `H − chrome − #committedLines`, to
1804
- keep `skip` a function of `#committedLines` alone and therefore
1805
- monotone. The argument was right about `skip` and wrong about the
1806
- quantity: `#committedLines` is CUMULATIVE — re-derived over the
1807
- whole line cache every frame, counting rows that left for the
1808
- terminal's scrollback long ago — so one screenful into any session
1809
- it exceeds H, the room clamps to its floor of one row, and every
1810
- running call after that is a head row with its output gone.
1811
- Measured at 24 and 40 rows with eight blank rows still on screen.
1812
-
1813
- The live region is bounded by the SCREEN (the content cap the
1814
- force-commit loop already uses). What holds the window's top is
1815
- the `skip` clamp below: rows in [0, #scrolledOff) have reached the
1816
- terminal's scrollback and are immutable, so the paint may not go
1817
- back above them. A live region that GROWS scrolls committed rows
1818
- away through #emitScroll, which is an append and not an un-scroll.
1819
- The residue is a transient hole above the composer at a settle,
1820
- bounded by the shrink itself and filled by the next commit. */
1821
1555
  #liveProjection(W, ctx, cap) {
1822
1556
  const rows = this.#project(W, ctx, CAP_PREVIEW);
1823
1557
  if (cap === undefined || rows.length <= cap)
@@ -1858,39 +1592,19 @@ export class Body {
1858
1592
  return [...heads.slice(0, keep), cutLine(`${p.dim} +${hidden} more${p.reset}`, W)];
1859
1593
  }
1860
1594
  /**
1861
- * R13 — ONE PASS, AND EVERY CELL RENDERS ITSELF.
1862
- *
1863
- * DECLARED REVERSAL of R3i's stretch line, R4's standing activity
1864
- * slot and R6/D1's block-stands-for-the-turn, all owner-ruled on
1865
- * 2026-09-03 and all of them the same idea: the open stretch drew ONE
1866
- * line plus a fixed slot, and every other cell of the segment drew
1867
- * nothing, so the live region's height was independent of the call
1868
- * count. That was the answer to a 28-call turn spending 28 rows of a
1869
- * 30-row region.
1870
- *
1871
- * The card answers it differently, and the ruling prefers this
1872
- * answer: a running call is its own card at a FIXED height (E2), so
1873
- * the height is a function of how many calls are IN FLIGHT rather
1874
- * than of how many have happened — and the window shrinks to the room
1875
- * before anything is force-committed (above). The live form and the
1876
- * committed form are now the same form, which is what makes a settle
1877
- * a change of content and never of position.
1878
- *
1879
- * What this keeps from R4: the height never moves ON ITS OWN. What it
1880
- * gives up: the one-line summary of a stretch, which the owner ruled
1881
- * 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).
1882
1601
  */
1883
1602
  #project(W, ctx, budget) {
1884
1603
  const out = [];
1885
- const focus = this.#focusIndex();
1886
1604
  const live = { ...ctx, liveWindow: budget };
1887
1605
  let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
1888
1606
  for (let i = this.#committed; i < this.#cells.length; i += 1) {
1889
1607
  const rows = cellComponent(this.#cells[i]).render(W, live);
1890
- // the head row carries the affordance; the tint lands on it and
1891
- // nowhere else, which is what makes "exactly one" structural
1892
- if (i === focus && rows.length > 0)
1893
- rows[0] = focusToken(rows[0], W);
1894
1608
  out.push(...this.#space(i, prev, rows));
1895
1609
  prev = rows;
1896
1610
  }
@@ -1937,56 +1651,17 @@ export class Body {
1937
1651
  * blanks are join artifacts — the count includes them (they are real
1938
1652
  * screen rows), threaded against the previous sibling's OWN rows. */
1939
1653
  liveCount() {
1940
- const panel = this.#panelState?.() ?? null;
1941
- const sheet = this.#sheetState?.() === true;
1942
- const queueRows = this.#queueRows(this.#opts.width(), this.#opts.height());
1943
- // KC1 §6: the composer's extra rows are chrome too — the scalar
1944
- // counts them exactly like the menu/queue bands (N = 1 ⇒ +0)
1945
- const inputExtra = this.#inputRows(this.#opts.width(), this.#opts.height(), this.#menuRows(this.#opts.width()).length, queueRows.length).rows.length - 1;
1946
- // TUI2-R1 (D): the sheet occupies the live region, exactly like the
1947
- // panel — the scalar must say so, or the cap arithmetic disagrees
1948
- // with the screen.
1949
- // R5 — the viewer occupies the live region exactly like the sheet,
1950
- // so the scalar must say so, or the cap arithmetic disagrees with
1951
- // the screen (the same rule DC-27 was about).
1952
- if (this.#viewer !== null) {
1953
- const capV = Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length);
1954
- return this.#viewerBand(this.#opts.width()).slice(0, capV).length + CHROME_ROWS + inputExtra + queueRows.length;
1955
- }
1956
- if (sheet) {
1957
- return (keysSheetRows(this.#opts.width()).slice(0, Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
1958
- CHROME_ROWS +
1959
- inputExtra +
1960
- queueRows.length);
1961
- }
1962
- if (panel !== null) {
1963
- // W21: the panel's own rows (the cap is exact — the scalar
1964
- // reflects the screen). W22: the queue chips occupy their
1965
- // own band — the panel's cap shrinks by their rows.
1966
- return (panelRowsOf(panel, this.#opts.width(), Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
1967
- CHROME_ROWS +
1968
- inputExtra +
1969
- queueRows.length);
1970
- }
1971
- // DC-27 — the scalar measures the PROJECTION, not a second render
1972
- // of its own. This loop used to walk every live cell and render it
1973
- // in full: no open-segment collapse, no flight rule, no act-slot
1974
- // budget. After R3i that described a screen the compositor had
1975
- // stopped drawing — for an open stretch with five finished calls
1976
- // it counted five four-row blocks that were not there. Nothing
1977
- // broke, because the force-commit loop measures liveLines.length
1978
- // and the over-count is conservative; but the cap and geometry
1979
- // gates were asserting a property of a function nothing paints
1980
- // from, so a real regression in the region's height could not
1981
- // have moved them. The rule this file already states for the
1982
- // sheet ("the scalar must say so, or the cap arithmetic disagrees
1983
- // with the screen") is the same rule here.
1984
- const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
1985
1654
  const W = this.#opts.width();
1986
- // the SAME content cap the force-commit loop applies, so the
1987
- // scalar sees the same slot budget the screen gets.
1988
- const rows = this.#liveProjection(W, ctx, this.#opts.height() - 4 - inputExtra - queueRows.length);
1989
- 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;
1990
1665
  }
1991
1666
  /** The lines committed THIS frame — the writes land in the frame's
1992
1667
  * committed section (the rows just above the live region). */
@@ -2008,242 +1683,156 @@ export class Body {
2008
1683
  this.#paintedW = W;
2009
1684
  this.#paintedH = H;
2010
1685
  const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: H };
2011
- // V6-1 (the screen-state == frame-state rule): the resize's first
2012
- // frame — the terminal's reflow re-wrapped the committed content at
2013
- // the NEW width, so the cached folds are stale. Re-fold the
2014
- // committed cells so the every-row draw below re-paints them at the
2015
- // current geometry — the frame's model and the screen agree.
2016
- // DC-34 A WIDEN DOES NOT REFOLD WHAT IS ALREADY COMMITTED.
2017
- //
2018
- // Every count here is physical ROWS at the fold width in force
2019
- // when it was computed. Refolding the committed cells at a new W
2020
- // changes what every index MEANS while `#scrolledOff` is carried
2021
- // across untranslatedand no translation exists, because the
2022
- // row the scroll stopped at does not occur in the new fold. On a
2023
- // widen the stale count then points at text the terminal already
2024
- // holds, and the frame paints it a second time.
2025
- //
2026
- // A committed row is ink (ADR-0046): the rows still on screen are
2027
- // the same thing as the rows in the scrollback minus a scroll
2028
- // that has not happened, and no terminal reflows either. Leaving
2029
- // them folded as they were printed keeps every index valid.
2030
- //
2031
- // NARROWING still refolds — an old wide row does not FIT, and
2032
- // `#checked` would throw invariant ①. The comparison is against
2033
- // the CACHE's fold width, not the last render's: after 60 → 100
2034
- // (no refold, the cache is still 60) a narrowing to 80 must NOT
2035
- // refold, because 80 columns hold a 60-column row.
2036
- this.#refolded = false;
2037
- if (this.#fullRedraw) {
2038
- // DC-34 THE REFOLD IS SCOPED BY THE FRONTIER.
2039
- //
2040
- // `#scrolledOff` is the record of what reached the terminal:
2041
- // rows [0, #scrolledOff) are in its scrollback, immutable, and
2042
- // no path of ours may contradict them. A cell with any row
2043
- // down there keeps the fold it was COMMITTED at, forever — in
2044
- // either direction. A cell entirely above the frontier has
2045
- // never left the screen, so re-folding it is free.
2046
- //
2047
- // Two scalar predicates were tried before this and both
2048
- // failed, in different ways: the last-refold width crashed on
2049
- // 60 100 80 (a cell committed at 100 emitted into an
2050
- // 80-column screen), and the cache's widest fold fires a FULL
2051
- // refold at the first narrowing, which re-wraps rows the
2052
- // scrollback already holds — the original defect, alive in
2053
- // the other direction. The frontier is not an approximation
2054
- // of them; it is the question they were both approximating.
2055
- // A cell is refolded when EITHER is true:
2056
- // - it is entirely above the frontier (never left the
2057
- // screen, so re-wrapping it contradicts nothing), or
2058
- // - it does not FIT: some cached row is wider than W.
2059
- //
2060
- // The second is not a compromise of the first, it is the
2061
- // answer to a question the first cannot reach. A cell can
2062
- // STRADDLE the frontier — its head in the scrollback, its
2063
- // tail still on screen — and the tail must be painted at the
2064
- // current width. Holding its commit fold there emitted a
2065
- // 100-column row into an 80-column screen and invariant ①
2066
- // threw (60 → 100 → 80, measured). Fitting wins: a crash is
2067
- // worse than a seam, and the seam a narrowing leaves is
2068
- // rider 2's, stated rather than hidden.
2069
- let row = 0;
2070
- const refold = new Array(this.#committed).fill(false);
2071
- for (let i = 0; i < this.#committed; i += 1) {
2072
- const lines = this.#lineCache[i];
2073
- if (lines === null || lines === undefined) {
2074
- refold[i] = true;
2075
- continue;
2076
- }
2077
- const above = row >= this.#scrolledOff;
2078
- const fits = lines.every((l) => visibleWidth(l) <= W);
2079
- refold[i] = above || !fits;
2080
- const prev = i > 0 ? this.#lineCache[i - 1] : null;
2081
- row += this.#space(i, prev ?? null, lines).length;
2082
- }
2083
- for (let i = 0; i < this.#committed; i += 1) {
2084
- if (refold[i])
2085
- this.#lineCache[i] = cellComponent(this.#cells[i]).render(W, ctx);
2086
- }
2087
- // #committedLines is re-derived over the WHOLE cache, because
2088
- // the frozen prefix still occupies its own rows.
2089
- this.#committedLines = 0;
2090
- for (let i = 0; i < this.#committed; i += 1) {
2091
- const lines = this.#lineCache[i] ?? cellComponent(this.#cells[i]).render(W, ctx);
2092
- this.#lineCache[i] = lines;
2093
- this.#committedLines += this.#space(i, i > 0 ? (this.#lineCache[i - 1] ?? []) : null, lines).length;
2094
- }
2095
- this.#refolded = refold.some(Boolean);
2096
- }
2097
- // 1. the natural commits — the leading DONE cells freeze: their
2098
- // lines leave the live region, the scrolls + the committed
2099
- // writes below place them (the #17 "freeze as a real line",
2100
- // short sessions included — the frame coalescing keeps a
2101
- // cell's first frame its freeze frame, so the frozen bytes
2102
- // 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) {
2103
1735
  this.#committedAtFrameStart = this.#committed;
2104
1736
  this.#committedLinesThisFrame = [];
2105
- // W14: the natural loop HONORS the fold-hold — a thinking/tool
2106
- // cell of the OPEN quiet turn (no text yet) does not commit: its
2107
- // committed form is decided at the release (the turn's text →
2108
- // individual commits with the W13 rollups; the turn's end → the
2109
- // fold). The FORCE-commit path below bypasses the hold — the
2110
- // screen never sticks, the rollup degrades to individuals.
2111
- 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) {
2112
1738
  this.#commitCell(this.#committed, W, ctx);
2113
1739
  }
2114
- // 2. the live lines — the unfinished cells (the tail) + the chrome.
2115
- // W11: the formula's blank above the first live cell hangs off
2116
- // 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) {
2117
1749
  const menuRows = this.#menuRows(W);
2118
- // W22: the queue chips are the menu-rows family's other occupant
2119
- // (the band above the box top) — the chrome rows and the live
2120
- // caps account for both.
2121
1750
  const queueRows = this.#queueRows(W, H);
2122
- // KC1 §6: the input is N rows now (N = 1 ⇒ today's chrome exactly)
2123
- // — chromeRows = 3 + N + menu + queue, and the content cap loses
2124
- // the composer's EXTRA rows the same way it loses the bands.
2125
1751
  const editor = this.#inputRows(W, H, menuRows.length, queueRows.length);
2126
1752
  const inputExtra = editor.rows.length - 1;
2127
- const chromeRows = CHROME_ROWS + inputExtra + menuRows.length + queueRows.length;
2128
- let liveLines = [];
2129
- // TUI2-R3v2 ②: where this frame put the panel's option rows, relative
2130
- // to the live region's top. Resolved to ABSOLUTE screen rows once
2131
- // liveTop is known, below.
2132
- let panelSpan = null;
2133
- const panel = this.#panelState?.() ?? null;
2134
- // TUI2-R1.5 ⑦(a) (VD-8): the sheet is an OVERLAY, and the frame it
2135
- // opens on — and the one it closes on — take the full-redraw path.
2136
- // The sheet REPLACES the live region, so on an idle composer (where
2137
- // the live region is empty) opening it GROWS the model by its own
2138
- // height; the frame's skip grows with it and the difference is paid
2139
- // in real LFs rows scrolled permanently into the terminal's
2140
- // scrollback, which closing cannot undo, because the scrollback is
2141
- // not ours to rewrite. Measured: three rows per open on a full
2142
- // 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() {
2143
1777
  const sheetUp = this.#sheetState?.() === true;
2144
- // R5 — the viewer is an overlay of exactly the same kind, so it
2145
- // joins the same flag. That one word is what buys it the whole
2146
- // zero-litter discipline below: the window freezes, #emitScroll
2147
- // is skipped, and the close repaints from #lastSkip.
2148
1778
  const viewerUp = this.#viewer !== null;
2149
1779
  this.#overlayFrame = sheetUp || this.#sheetWasUp || viewerUp || this.#viewerWasUp;
2150
1780
  this.#sheetWasUp = sheetUp;
2151
1781
  this.#viewerWasUp = viewerUp;
2152
- if (viewerUp) {
2153
- // R5: the viewer REPLACES the live region the same slot the
2154
- // sheet and the panel use, for the same reason (it is what the
2155
- // human is reading right now). It is opened only from an idle
2156
- // composer, so it cannot coexist with a panel.
2157
- liveLines = this.#viewerBand(W).slice(0, Math.max(1, H - 4 - inputExtra - queueRows.length));
2158
- }
2159
- else if (sheetUp) {
2160
- // TUI2-R1 (D): the sheet REPLACES the live region the same
2161
- // slot the panel uses, for the same reason (it is what the
2162
- // human is reading right now). It cannot coexist with a panel:
2163
- // the editor only opens it from an idle composer.
2164
- liveLines = keysSheetRows(W).slice(0, Math.max(1, H - 4 - inputExtra - queueRows.length));
2165
- }
2166
- else if (panel !== null) {
2167
- // W21: the panel REPLACES the running tool's live window — the
2168
- // bounded block, capped at H−4 (the panel IS the live region;
2169
- // the W11 blank would separate it from the frozen content).
2170
- // The cap is exact, so the force-commit loop never fires. W22:
2171
- // the queue band sits below the panel the cap shrinks by it.
2172
- // TUI2-R3v2 ②: the rows and the CLICKABLE span come from one
2173
- // call, so the hit-test reads the arithmetic that placed the
2174
- // rows rather than a second copy of it.
2175
- const frame = panelFrameOf(panel, W, Math.max(1, H - 4 - inputExtra - queueRows.length));
2176
- liveLines = frame.rows;
2177
- panelSpan = frame.options;
2178
- }
2179
- else {
2180
- // TUI2-R2 (D, candidate 1): the FOCUS the cell the next ctrl+o
2181
- // will act on brightens its own token. The index is derived from
2182
- // the SAME scan expandNext performs (#focusIndex shares its rule
2183
- // by construction), so the marker can never point at a cell the
2184
- // key would not take which is the only way a focus marker is
2185
- // worth having.
2186
- liveLines = this.#liveProjection(W, ctx, H - 4 - inputExtra - queueRows.length);
2187
- }
2188
- // 3. the FORCE commits the live region's hard cap H−1: overflow
2189
- // commits the oldest live cell UNCONDITIONALLY (the one sharp
2190
- // edge the cap scalar is asserted by the gates). W22: the
2191
- // queue band shrinks the cap by its rows (empty queue → H−4).
2192
- // DC-53 AND NEVER A CELL THAT IS NOT DONE.
2193
- //
2194
- // This loop committed `#committed` unconditionally. R4's standing
2195
- // slot held the live region at a constant height, so the
2196
- // projection never grew past the cap on its own and the loop never
2197
- // reached a running cell; R13 retired the slot (DC-46) and it
2198
- // promptly did. Three parallel searches, the last two settling
2199
- // first: their cards pushed the region over the cap, the loop
2200
- // committed the FIRST call — still in flight — froze its
2201
- // three-row running card and its breathing mark into the
2202
- // scrollback, and stepped `#committed` past it. When the result
2203
- // arrived there was no live cell left to draw it into, so the work
2204
- // never reached the screen at all.
2205
- //
2206
- // A cell that is not done has no committed form yet; committing
2207
- // one is writing history that has not happened. When the head is
2208
- // running, the region gives way instead — the cards behind it
2209
- // degrade first, then the running window shrinks (DC-43's own
2210
- // ladder, in #liveProjection), and the remainder is counted.
2211
- // The guard is on a RUNNING TOOL CALL specifically, not on any
2212
- // unfinished cell. A streaming text or raw cell HAS a committed
2213
- // form — the rows it has already written are final, append-only —
2214
- // and spilling those is exactly what this loop is for. A running
2215
- // call does not: its card changes shape at the settle, so a
2216
- // committed one is a row that will never be corrected.
2217
- 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)
2218
1823
  this.#committed < this.#cells.length &&
2219
1824
  !(this.#cells[this.#committed].kind === "tool" && !this.#cells[this.#committed].done)) {
2220
- // R3f: the cell about to be force-committed marks its segment
2221
- // SPILLED. The rule was written at R3b — "a segment too big for
2222
- // the screen already has rows in the scrollback that cannot be
2223
- // taken back, so it renders normally and does not collapse" —
2224
- // and then never wired: `spilled` had a declaration, an
2225
- // initializer and a read, and nothing ever set it. The read was
2226
- // therefore vacuously true, so a 43-call turn force-committed
2227
- // thirty expanded rows and STILL printed `✦ thought 103s · 43
2228
- // reads` underneath them, claiming as folded the work standing
2229
- // visible above it.
2230
- this.#markSpilled(this.#committed);
2231
1825
  this.#commitCell(this.#committed, W, ctx);
2232
1826
  // TUI2-R2 ⑤: the focus re-derives after a commit — the cell it
2233
1827
  // pointed at may have just left the live region.
2234
- liveLines = this.#liveProjection(W, ctx, H - 4 - inputExtra - queueRows.length);
1828
+ lines = this.#liveProjection(W, ctx, cap);
2235
1829
  }
2236
- // 4. the geometry — the live region's first row:
2237
- // liveTop = min(totalCommitted, H - liveRows) + 1 — the screen
2238
- // shows the bottom H rows; the live region anchors to the bottom.
2239
- const liveRowsTotal = liveLines.length + chromeRows;
2240
- const liveTop = Math.min(this.#committedLines, H - liveRowsTotal) + 1;
2241
- // TUI2-R3v2 ②: the option rows' ABSOLUTE screen rows, recorded per
2242
- // frame. A click is answered against the frame the human was looking
2243
- // at when they clicked, which is this one.
2244
- this.#panelRowSpan =
2245
- panelSpan === null ? null : { top: liveTop + panelSpan.offset, count: panelSpan.count, first: panelSpan.first };
2246
- // 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) {
2247
1836
  const out = [];
2248
1837
  // REL-0152-D14 — AUTOWRAP OFF for the frame's duration.
2249
1838
  //
@@ -2324,28 +1913,12 @@ export class Body {
2324
1913
  // never paired with a ?25h. Sync (2026) still brackets the frame
2325
1914
  // where the terminal understands it.
2326
1915
  out.push(this.#conservative ? "\x1b[?25l" : "\x1b[?2026h\x1b[?25l");
2327
- // A8: the bottom-anchored window (the model's last H rows) shifts
2328
- // DOWN when the live region SHRINKS the done-fold, the fold-hold
2329
- // release at the terminal event. The steady path's scroll syncs
2330
- // exactly N committed lines, but the window moves N + liveDelta:
2331
- // a shrink with commits (liveTop grows by LESS than N — the scroll
2332
- // overshoots by liveDelta) slips past the pure-shrink trigger, the
2333
- // stale pass erases the old live rows, and nothing re-paints the
2334
- // band between the committed window and the new liveTop (finding
2335
- // #A8 — the W11-boundary pileup). A GROWTH is safe on the steady
2336
- // path (the new live's bottom-anchored extent covers the old — the
2337
- // erased rows are all re-painted); a shrink takes the full-redraw
2338
- // path: the window re-paints at the model's positions, every row
2339
- // covered (the V6-1 every-row rule).
2340
- // REL-0152-R1: ONE renderer. The steady/full split existed because
2341
- // the steady path moved rows by scrolling and could not handle a
2342
- // window that moved the wrong way, so the frames it could not draw
2343
- // were handed to a full repaint. A diff has no such frames: it
2344
- // emits the rows that differ, and on a frame where everything
2345
- // differs that IS a full repaint. The dispatch, the two geometries
2346
- // and the invariant about which moves each path may use all go
2347
- // with it.
2348
- 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);
2349
1922
  this.#fullRedraw = false;
2350
1923
  // REL-0161: the frame close no longer shows the cursor — hidden IS
2351
1924
  // the steady state (Terminal.app infers "a prompt line" from
@@ -2363,19 +1936,11 @@ export class Body {
2363
1936
  }
2364
1937
  this.#lastLiveTop = liveTop;
2365
1938
  this.#lastLiveRows = liveRowsTotal;
2366
- this.#lastInputRows = editor.rows.length;
1939
+ this.#lastInputRows = chrome.editor.rows.length;
2367
1940
  // KC1 §6: the next steady frame's relative moves start where THIS
2368
1941
  // frame's CHA parked the cursor — the marker's row inside the
2369
1942
  // composer (N = 1 ⇒ H−2, the retired hard-coded anchor).
2370
- this.#lastAnchorRow = H - 1 - editor.rows.length + editor.markerRow;
2371
- }
2372
- /** R13 D1 — the same constant as everything else. This used to compute
2373
- * the lead from a ONE-ROW STAND-IN so the live block would claim the
2374
- * spacing its fold was going to get; with the rhythm constant there
2375
- * is nothing to simulate, and R7a's device retires with the formula
2376
- * that needed it. */
2377
- #blockSpace(i, prev, rows) {
2378
- return bodySpacing(this.#lastDrawn(i, prev), rows);
1943
+ this.#lastAnchorRow = H - 1 - chrome.editor.rows.length + chrome.editor.markerRow;
2379
1944
  }
2380
1945
  #space(i, prev, rows) {
2381
1946
  if (i > 0 && this.#cells[i]?.kind === "md" && this.#cells[i - 1]?.kind === "md")
@@ -2384,18 +1949,9 @@ export class Body {
2384
1949
  // so a settle changes content and never position by construction.
2385
1950
  return bodySpacing(this.#lastDrawn(i, prev), rows);
2386
1951
  }
2387
- /**
2388
- * R3i the previous DRAWN sibling, not the previous cell.
2389
- *
2390
- * The spacing formula reads what stood above; a cell that rendered
2391
- * nothing did not stand above anything. Since R3d whole families of
2392
- * cells render `[]` — the members a fold speaks for — and the
2393
- * formula was reading that empty array as "a zero-row sibling", so a
2394
- * multi-row block following a fold lost the blank that belongs above
2395
- * it. The defect predates this round (any folded turn followed by a
2396
- * raw block has it); R3i's projection is what finally put a test on
2397
- * the path.
2398
- */
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). */
2399
1955
  #lastDrawn(i, prev) {
2400
1956
  if (prev !== null && prev.length > 0)
2401
1957
  return prev;
@@ -2414,236 +1970,36 @@ export class Body {
2414
1970
  * this cell's commit — the cache stays raw, the placed rows count. */
2415
1971
  #commitCell(i, W, ctx) {
2416
1972
  const cell = this.#cells[i];
2417
- const lines = this.#foldOrRollup(cell, i, W, ctx);
2418
- // W15: a tool cell whose committed rows carried the "ctrl+o"
2419
- // affordance joins the expand history — the detection is the
2420
- // renderer's OWN output, so the read's "/last"-only cut note never
2421
- // lands here. TUI2-R1 (A/B): the affordance is no longer only the
2422
- // renderer cut's "└ ctrl+o" the self-naming head suffix and
2423
- // the exploration row carry it on the HEAD row, and a promise the
2424
- // key does not answer would be the one thing worse than silence.
2425
- // unshift: the cells commit oldest-first, so the NEWEST cut lands
2426
- // at the front — the expand pointer's "newest back" walk starts
2427
- // where the user's last key press would aim.
2428
- // R3b: a fold HEAD joins the ring too. The test used to demand a
2429
- // tool cell, and a segment's fold can be emitted at a thinking
2430
- // cell — which would have left the whole segment unreachable by
2431
- // the very key its own row advertises.
2432
- // R4a — the ring captures by IDENTITY, not by searching our own
2433
- // printed bytes.
2434
- //
2435
- // This used to require the rendered rows to contain the literal
2436
- // "ctrl+o", which made the affordance LOAD-BEARING: retiring the
2437
- // printed key (the owner's ruling) would have silently emptied the
2438
- // ring and taken the expand key with it — not a missing hint, a
2439
- // missing feature. A fold head is a fold head because the segment
2440
- // says so; a tool cell is expandable when it is hiding rows.
2441
- const isFoldHead = this.#segmentOf(i)?.headCell === i;
2442
- const hidesRows = cell.kind === "tool" && lines.some((l) => l.includes("ctrl+o"));
2443
- 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")))
2444
1980
  this.#collapsed.unshift(i);
2445
- // R4a's walk reset retired with the walk (DC-50): there is no
2446
- // "which one does it open" any more — one press opens all of
2447
- // them.
2448
- }
2449
1981
  this.#lineCache[i] = lines;
2450
1982
  const placed = this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines);
2451
1983
  this.#committed += 1;
2452
1984
  this.#committedLines += placed.length;
2453
1985
  this.#committedLinesThisFrame.push(...placed);
2454
1986
  }
2455
- /** R3b record which segment the cell just pushed belongs to. Called
2456
- * right after the push, so #cells.length-1 is that cell. */
2457
- #stampSegment() {
2458
- const turn = this.#turns[this.#turns.length - 1];
2459
- const idx = turn === undefined ? -1 : turn.segments.length - 1;
2460
- const at = this.#cells.length - 1;
2461
- this.#cellSegment[at] = idx;
2462
- if (turn !== undefined && idx >= 0)
2463
- turn.segments[idx].cells.push(at);
2464
- }
2465
- /**
2466
- * R3b does the segment hold a call that FAILED or was DENIED?
2467
- *
2468
- * Such a segment does not fold. Routine work is what the fold is for;
2469
- * a refusal and an error are the opposite of routine, and putting
2470
- * either behind a key hides the one thing on the screen that most
2471
- * needs a human's eye. Law 1.3 makes the same call about marks — a
2472
- * failure keeps its colour AND its words — and this is that rule at
2473
- * the scale of a run.
2474
- *
2475
- * The cost, accepted: a turn that reads twenty files and hits one
2476
- * denial keeps all twenty rows. The alternative is a screen that says
2477
- * `✦ thought 3s · 20 reads` while a write was refused inside it.
2478
- */
2479
- /**
2480
- * R3i — the trouble the stretch met, as the line's own terms.
2481
- *
2482
- * Law 1.3: an outcome is stated in WORDS, "the only form that
2483
- * survives a pipe". So the kind is a different word, never a
2484
- * different colour — `2 failed`, `1 denied`, `1 interrupted` — and
2485
- * the failure's identity rides with it. In this phase the terms are
2486
- * only DRAWN (the live line names trouble the moment it happens);
2487
- * whether trouble still blocks the fold is the next phase's ruling.
2488
- */
2489
- /** R3i — is this cell one the fold must not count as work done? */
2490
- #cellInTrouble(i) {
2491
- const c = this.#cells[i];
2492
- if (c === undefined || c.kind !== "tool")
2493
- return false;
2494
- return c.isError || c.reason !== null || c.verdict?.decision === "denied";
2495
- }
2496
- #segmentTroubleTerms(seg) {
2497
- let failed = 0;
2498
- let denied = 0;
2499
- let interrupted = 0;
2500
- let what = "";
2501
- for (const j of seg.cells) {
2502
- const c = this.#cells[j];
2503
- if (c === undefined || c.kind !== "tool")
2504
- continue;
2505
- // WHICH call, and WHY. The target alone answers the first and
2506
- // not the second, and for a policy denial the second is the
2507
- // whole point: `sub/out.txt` does not tell a human that plan
2508
- // mode is read-only, and that sentence is the one they act
2509
- // on. Law 1.3's own words — an outcome is stated in words —
2510
- // and the ladder cuts this clause last, so it degrades to the
2511
- // target before it disappears.
2512
- const named = () => {
2513
- const t = toolTarget(c.name, JSON.parse(c.inputFull));
2514
- const why = c.verdict?.reason ?? c.reason;
2515
- return why === null || why === undefined || why === "" || why === "interrupted" ? t : `${t} (${why})`;
2516
- };
2517
- if (c.verdict?.decision === "denied" || (c.reason !== null && c.reason !== "interrupted" && /denied/i.test(c.reason))) {
2518
- denied += 1;
2519
- if (what === "")
2520
- what = named();
2521
- }
2522
- else if (c.reason === "interrupted") {
2523
- interrupted += 1;
2524
- }
2525
- else if (c.isError || c.reason !== null) {
2526
- failed += 1;
2527
- if (what === "")
2528
- what = named();
2529
- }
2530
- }
2531
- const out = [];
2532
- if (failed > 0)
2533
- out.push(["failed", failed, what]);
2534
- if (denied > 0)
2535
- out.push(["denied", denied, what]);
2536
- if (interrupted > 0)
2537
- out.push(["interrupted", interrupted, ""]);
2538
- return out;
2539
- }
2540
- #segmentHasTrouble(seg) {
2541
- // R3g (fable, 2026-08-28): a DENIED call is the case this rule
2542
- // exists for, and it was the one case the predicate could not
2543
- // see — a denial carrying no `reason` string leaves isError
2544
- // false and reason null, so `✦ thought 3s · 20 reads` could
2545
- // stand over a refused write. The verdict is the record of it.
2546
- return this.#segmentTools(seg).some((c) => c.isError || c.reason !== null || c.verdict?.decision === "denied");
2547
- }
2548
- /** R3b — the segment's TOOL cells, in order. */
2549
- #segmentTools(seg) {
2550
- const out = [];
2551
- for (const j of seg.cells) {
2552
- const c = this.#cells[j];
2553
- if (c.kind === "tool")
2554
- out.push(c);
2555
- }
2556
- return out;
2557
- }
2558
- /** R3f — the cell is leaving the live region under the screen's hard
2559
- * cap, so its segment can no longer be represented by a fold. */
2560
- #markSpilled(i) {
2561
- const seg = this.#segmentOf(i);
2562
- if (seg !== null)
2563
- seg.spilled = true;
2564
- }
2565
- /** R3b — the segment a committed cell belongs to, or null when it has
2566
- * none (a cell of the pipe path, or a kind that is not work). */
2567
- #segmentOf(i) {
2568
- const cell = this.#cells[i];
2569
- if (cell.kind !== "thinking" && cell.kind !== "tool")
2570
- return null;
2571
- const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
2572
- const si = this.#cellSegment[i];
2573
- if (turn === undefined || si === undefined || si < 0)
2574
- return null;
2575
- return turn.segments[si] ?? null;
2576
- }
2577
- /** W14 — the fold-hold: a thinking/tool cell of the OPEN quiet turn
2578
- * (no text yet) does not commit — its committed form is decided at
2579
- * the release. The cell's OWN turn must be the CURRENT one (a cell
2580
- * of a released turn commits normally). The force-commit path never
2581
- * consults this — the screen's hard cap wins over the hold. */
2582
- #held(i) {
2583
- // R13 — NOTHING IS HELD ANY MORE. DECLARED REVERSAL of W14's
2584
- // quiet-turn hold, R3b/R3i's segment hold and TUI2-R1.5 ①'s
2585
- // explore-run hold, all three of them owner-ruled away on
2586
- // 2026-09-03 with the mechanism they served.
2587
- //
2588
- // Every one of them existed for the same reason: a done cell's
2589
- // COMMITTED FORM was undecided while its segment or its run was
2590
- // still open, because a fold line or a rollup row might yet stand
2591
- // for it — and a committed row cannot be taken back (ADR-0046).
2592
- // With no fold and no rollup, a call's committed form is its own
2593
- // card and is settled the instant the call is: there is nothing
2594
- // left to wait for.
2595
- //
2596
- // It is not only dead weight. Holding done cells in the live
2597
- // region made the region carry work that was FINISHED, so a burst
2598
- // of four reads and a shell in flight competed for the same rows
2599
- // and DC-43's shrink took the running call's output away — the
2600
- // one thing on the screen the human is waiting for (R7a D). Let
2601
- // them commit and the room is there.
2602
- void i;
2603
- return false;
2604
- }
2605
- /* R13 — `#growingRun` retired with the rollup's commit hold (#held). */
2606
- /** W14/W13 — the release-time decision at a commit, BEFORE the cell's
2607
- * own render: the folded-turn fold first (a QUIET turn — ended, no
2608
- * text — becomes the ONE fold line; the rest of its thinking/tool
2609
- * cells render [] after the fold), then the W13 rollup (a text
2610
- * turn's N > 2 same-tool run: the HEAD renders the group summary,
2611
- * the members render [] — the scan is the work order's "group key",
2612
- * derived at commit time, never pre-stored). */
2613
- #foldOrRollup(cell, i, W, ctx) {
2614
- // R13 — DECLARED REVERSAL, three at once, all of them the same
2615
- // idea and all of them owner-ruled on 2026-09-03:
2616
- //
2617
- // · the SEGMENT FOLD (R3b–R3i, W14) — a closed stretch of two or
2618
- // more cells collapsed into one settled line;
2619
- // · the W13 ROLLUP and the `rolled` cell field — a run of three
2620
- // or more same-tool calls collapsed into a group summary;
2621
- // · TUI2-R1 (B)'s EXPLORATION ROW — the mixed read-only variant
2622
- // of the same collapse.
2623
- //
2624
- // Every one of them answered the same pressure: ungrounded output
2625
- // rows owned the screen, so work was collapsed into sentences
2626
- // ABOUT the work. The card is what changes that arithmetic — a
2627
- // call's rows sit inside a surface that says where it begins and
2628
- // ends, so five of them read as one object rather than five loose
2629
- // lines — and the owner's ruling is that the collapse costs more
2630
- // than it buys: a page where the machine's work is sometimes a
2631
- // card, sometimes a summary line and sometimes nothing at all is
2632
- // a page a reader cannot predict. One rhythm, one surface.
2633
- //
2634
- // So every cell renders itself, and this method is the record of
2635
- // what used to happen here. What the folds bought is bought
2636
- // differently now: the preview cap (five rows a call, E1's read
2637
- // showing none) is what keeps a burst from owning the screen, and
2638
- // it is a CONSTANT per call rather than a decision about runs.
2639
- //
2640
- // What goes with them: `#growingRun`'s commit hold existed only so
2641
- // a run's committed FORM could be decided once the run closed —
2642
- // with no run-level form left to decide, a done cell commits when
2643
- // it is done. `#rolledHeads`, `rolledOf`, `rolledTitle`,
2644
- // `rolledDetail` and the `rolled` field go with the rollup.
2645
- return cellComponent(cell).render(W, ctx);
2646
- }
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. */
2647
2003
  /** The slot occupant's extra rows — the slash-command menu (above the
2648
2004
  * status, in the rhythm gap + the content's spare rows — the old
2649
2005
  * menu's position, slot-shaped). */
@@ -3020,7 +2376,6 @@ export class Body {
3020
2376
  // history it had just erased, and everything above was simply
3021
2377
  // GONE: `dc34-widen-seam` measured 36 tokens missing from the
3022
2378
  // scrollback the first time this branch was left in place.
3023
- this.#refolded = false;
3024
2379
  this.#screen = new Array(H).fill(NOT_PAINTED);
3025
2380
  this.#resizeFrame = false;
3026
2381
  }
@@ -3058,26 +2413,12 @@ export class Body {
3058
2413
  // OLDEST on screen — they are still in the model and come back on
3059
2414
  // the close.
3060
2415
  const contentRows = Math.max(0, H - CHROME_ROWS - inputExtra - queueRows.length - menuRows.length);
3061
- // DC-34 THE MARCH NEVER REACHES BELOW THE FRONTIER.
3062
- //
3063
- // Rows [0, #scrolledOff) are in the terminal's scrollback and are
3064
- // immutable; painting one puts the same prose on screen twice,
3065
- // which is the owner's report. `skip` can drop below it whenever
3066
- // the model shrinks under a fixed screen — a widen refolding the
3067
- // cells above the frontier, or the live band collapsing — and
3068
- // nothing stopped it (rider 3's ungated reach-back).
3069
- //
3070
- // Clamping costs a gap under short content for one frame, which
3071
- // the next commit fills. Reaching back costs a duplicate that
3072
- // stands in the transcript forever.
3073
- // ...but only when the WIDTH moved. A height change re-indexes
3074
- // nothing — the folds are untouched, every row means what it
3075
- // meant — so reaching back there is the pre-existing behaviour a
3076
- // gate already covers (the A8 windowing case: grow the screen and
3077
- // the banner returns). The duplication measured in this round is
3078
- // width-driven, and so is the guard.
3079
- const march = all.slice(this.#lastW !== 0 && this.#lastW !== W ? Math.max(skip, this.#scrolledOff) : skip);
3080
- 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);
3081
2422
  for (const line of march.length > contentRows ? march.slice(march.length - contentRows) : march) {
3082
2423
  desired[r - 1] = this.#checked(line, W);
3083
2424
  r += 1;
@@ -3101,7 +2442,7 @@ export class Body {
3101
2442
  desired[H - 2 - inputExtra + i - 1] = this.#checked(editor.rows[i], W);
3102
2443
  desired[H - 1 - 1] = boxBottom(W);
3103
2444
  const statusRow = this.#statusSource();
3104
- 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);
3105
2446
  this.#emitDiff(out, W, H, desired);
3106
2447
  // REL-0152-R1: park from where the cursor ACTUALLY is — see
3107
2448
  // #cursorRow. It used to be parked from H, which the bottom-up