@vincemakes/kiso-tui 0.1.39 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,8 @@
8
8
  * The model:
9
9
  * - cells (the CLI's mutation surface) → components → lines;
10
10
  * - `lines[] + commitIndex` (the scrollback fork — the departure from
11
- * pi): a line COMMITS (leaves the live region) via the real-LF
11
+ * the reference implementation's model): a line COMMITS (leaves the
12
+ * live region) via the real-LF
12
13
  * scroll at the last row (`\x1b[1B\n` — CUP-free) when its cell is
13
14
  * DONE and the region needs the room. Committed bytes are never
14
15
  * re-emitted — the native scrollback gets them, reflow-safe, and
@@ -19,8 +20,8 @@
19
20
  * commits the oldest live line regardless of done-ness — the one
20
21
  * sharp edge (asserted by the VT-emulator gate);
21
22
  * - two crash invariants: ① every emitted line's visible width ≤ W
22
- * (components fold; a violation THROWS with diagnostics — pi
23
- * tui-main-screen.ts:447-473, no silent truncate); ② every steady-
23
+ * (components fold; a violation THROWS with diagnostics — the
24
+ * no-silent-truncate ruling); ② every steady-
24
25
  * frame CUP lands in the CONTENT area (rows ≤ H−4−menu — the
25
26
  * committed band, the stale/gap ELs, and the LIVE lines at their
26
27
  * model rows; fix C's sanctioned reinterpretation, ADR-0046) — the
@@ -43,7 +44,9 @@
43
44
  */
44
45
  import { truncateDiff } from "./diff.js";
45
46
  import { displayWidth } from "./editor.js";
46
- import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, foldLine, statusLine, turnFold, visibleWidth, } from "./components.js";
47
+ import { leadWidth } from "./width.js"; // W23: the ONE width authority (the editor, #inputRow, and editCol share it)
48
+ import { panelAffordance, panelBlockRows, panelLead, panelStatus } from "./approval-panel.js";
49
+ import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, foldLine, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
47
50
  import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
48
51
  /** The cursor marker — an APC private sequence the focus component
49
52
  * embeds at the edit position; the compositor strips it and moves
@@ -72,6 +75,7 @@ export class Body {
72
75
  #fullRedraw = false; // the first frame / resize — the CUP path
73
76
  #lastLiveTop = 0; // the recorded live region top — the resize clear starts here
74
77
  #lastLiveRows = 0; // the recorded live row count (incl. the chrome)
78
+ #lastSkip = 0; // the last frame's window top in model rows — the A8b scroll's leaving-count base
75
79
  #lastH = 0;
76
80
  #frameTimer = null;
77
81
  #spinnerTimer = null;
@@ -100,10 +104,19 @@ export class Body {
100
104
  #status = "";
101
105
  #statusHint = null;
102
106
  #tail = "";
103
- #question = null;
107
+ // W21: the panel's bound state — the PanelSelect slot occupant (the
108
+ // old ApprovalPrompt's question slot retires with it): while a
109
+ // panel is up it replaces the live region, owns the input lead, and
110
+ // derives the status row (the CLI's painting status yields).
111
+ #panelState = null;
104
112
  #inputState = () => ({ line: "", cursor: 0 });
105
113
  #inputPrompt = "";
106
114
  #menuState = null;
115
+ // W22: the pending-turn queue's bound state — the CLI's live slots
116
+ // (chat.ts); the chips render in the menu-rows family (above the
117
+ // box top), the live caps shrink by their rows, and the status
118
+ // row's right hint shows the count while any turn waits.
119
+ #queueState = () => [];
107
120
  constructor(opts) {
108
121
  this.#opts = opts;
109
122
  this.#write = opts.write ?? ((s) => process.stdout.write(s));
@@ -114,14 +127,20 @@ export class Body {
114
127
  compositorRef = this;
115
128
  // the Dock façade's bindings may arrive BEFORE this construction
116
129
  // (the CLI binds the editor state in makeLineInput, then constructs
117
- // the Body) — the buffered bindings apply here, or the input row
118
- // would never render the typed line.
119
- if (dockBindings !== null) {
130
+ // the Body) — the LIVE binding buffer applies here, or the input
131
+ // row would never render the typed line. W21: the buffer is a
132
+ // mutable object the bind methods update in place — order-agnostic
133
+ // (the old snapshot froze `menu` at bindInput time and the menu
134
+ // silently never bound in the real CLI; the e2e gates bind the
135
+ // Body directly and could not see it).
136
+ if (dockBindings.state !== null)
120
137
  this.#inputState = dockBindings.state;
121
- this.#inputPrompt = dockBindings.prompt;
138
+ this.#inputPrompt = dockBindings.prompt;
139
+ if (dockBindings.menu !== null)
122
140
  this.#menuState = dockBindings.menu;
123
- dockBindings = null;
124
- }
141
+ this.#panelState = dockBindings.panel;
142
+ if (dockBindings.queue !== null)
143
+ this.#queueState = dockBindings.queue;
125
144
  }
126
145
  /** Live re-check — a TTY whose size lands after construction flips in. */
127
146
  #isActive() {
@@ -140,8 +159,9 @@ export class Body {
140
159
  this.#closeOpenThinking();
141
160
  this.#closeOpenText();
142
161
  // W14: the turn boundary — the record the fold-hold's release
143
- // state machine reads; the cell carries the record's index.
144
- this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), folded: false });
162
+ // state machine reads; the cell carries the record's index. A9:
163
+ // the user's own words ride the record the fold's leading chip.
164
+ this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), words: text, folded: false });
145
165
  this.#cells.push({ kind: "user", text, done: true, turn: this.#turns.length - 1 });
146
166
  this.#mark();
147
167
  }
@@ -189,7 +209,7 @@ export class Body {
189
209
  }
190
210
  }
191
211
  this.#toolCells.set(callId, this.#cells.length);
192
- 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, rolled: null, reason: null });
212
+ 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, rolled: null, reason: null, verdict: null });
193
213
  // W14: the turn record's counts — the folded-turn line's terms
194
214
  // (reads = read_file, edits = edit_file, the rest in first-call
195
215
  // order). The CLI's recap counts the same way (edit_file).
@@ -216,6 +236,24 @@ export class Body {
216
236
  }
217
237
  this.#mark();
218
238
  }
239
+ /** A5: the approval verdict — the permission_decided event binds into
240
+ * the cell (the aggregated head row: name + status + decidedBy in
241
+ * ONE row; no free-standing ` approved` orphan). The decision lands
242
+ * after the panel closes (the streamed event) — the cell is usually
243
+ * running by then; the record rides the settled row (`· approved by
244
+ * X` on an extension's auto-approval, `· by X` on its denial). A
245
+ * verdict with no live cell is dropped — the registry holds only
246
+ * in-flight calls, and the committed cells already told their
247
+ * outcome. */
248
+ toolVerdict(callId, decision, decidedBy, reason) {
249
+ if (!this.#isActive())
250
+ return;
251
+ const cell = this.#toolCell(callId);
252
+ if (cell !== null && cell.kind === "tool") {
253
+ cell.verdict = { decision, ...(decidedBy !== undefined ? { decidedBy } : {}), ...(reason !== undefined ? { reason } : {}) };
254
+ this.#mark();
255
+ }
256
+ }
219
257
  toolRunning(callId) {
220
258
  if (!this.#isActive()) {
221
259
  const p = palette();
@@ -597,13 +635,27 @@ export class Body {
597
635
  this.#tail = tail;
598
636
  this.redraw();
599
637
  }
600
- showQuestion(question) {
601
- this.#question = question;
602
- this.redraw();
638
+ /** The status row's source — W21: while a panel is up, the panel's
639
+ * phase status + affordance REPLACE the CLI's painting status (the
640
+ * compositor derives both from the bound panel state; the old
641
+ * question slot's dim-pending shape is the normal branch's shape
642
+ * now). */
643
+ #statusSource() {
644
+ const panel = this.#panelState?.() ?? null;
645
+ if (panel !== null)
646
+ return { status: panelStatus(panel.view, panel.phase, panel.sel), hint: panelAffordance(panel.view, panel.phase, panel.sel) };
647
+ // W22: while turns wait in the queue, the right hint shows the
648
+ // count — the chips below carry the lines themselves.
649
+ const queued = this.#queueState?.().length ?? 0;
650
+ if (queued > 0)
651
+ return { status: this.#status, hint: `+${queued} queued` };
652
+ return { status: this.#status, hint: this.#statusHint ?? undefined };
603
653
  }
604
- clearQuestion() {
605
- this.#question = null;
606
- this.redraw();
654
+ /** Bind the editor's panel state — the PanelSelect slot occupant
655
+ * (W21: the panel replaces the live region + the input lead while
656
+ * up; the old ApprovalPrompt's question slot retires with it). */
657
+ bindApproval(state) {
658
+ this.#panelState = state;
607
659
  }
608
660
  /** Bind the CURRENT input line's state — the focus component reads it. */
609
661
  bindInput(state, prompt) {
@@ -615,14 +667,25 @@ export class Body {
615
667
  bindMenu(state) {
616
668
  this.#menuState = state;
617
669
  }
670
+ /** Bind the pending-turn queue — the CLI's live slots (chat.ts):
671
+ * the chips render in the menu-rows family, the live caps shrink
672
+ * by their rows, and the +N queued hint rides the status row. */
673
+ bindQueue(state) {
674
+ this.#queueState = state;
675
+ }
618
676
  /** The input line's edit column — the old dock's API. v6: the CURSOR
619
677
  * derives from the frame's marker; this is the same value computed
620
678
  * from the bound input state (the CLI's BodyOptions.editCol callback
621
679
  * reads it — the marker math never desyncs by construction). */
622
680
  editCol() {
623
- const inp = this.#inputState();
624
- // W6: the box's left wall (2 cols) prefixes the prompt
625
- return 2 + displayWidth(this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, "")) + inp.cursor + 1;
681
+ const st = this.#inputState();
682
+ const panel = this.#panelState?.() ?? null;
683
+ // W23: the frame-derived column — wallL + leadWidth(lead) + cells
684
+ // + 1 — the SAME formula the marker embeds at (the panel lead when
685
+ // the panel owns the row; the old prompt-only math desynced the
686
+ // panel rows' edit column; leadWidth is the ONE authority)
687
+ const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.sel) : this.#inputPrompt;
688
+ return 3 + leadWidth(lead) + st.cursor;
626
689
  }
627
690
  /** The old dock's redraw — the editor's onRender target: mark + the
628
691
  * scheduler (16ms coalescing — the old sync draw coalesces the same). */
@@ -686,6 +749,14 @@ export class Body {
686
749
  * blanks are join artifacts — the count includes them (they are real
687
750
  * screen rows), threaded against the previous sibling's OWN rows. */
688
751
  liveCount() {
752
+ const panel = this.#panelState?.() ?? null;
753
+ const queueRows = this.#queueRows(this.#opts.width(), this.#opts.height());
754
+ if (panel !== null) {
755
+ // W21: the panel's own rows (the cap is exact — the scalar
756
+ // reflects the screen). W22: the queue chips occupy their
757
+ // own band — the panel's cap shrinks by their rows.
758
+ return panelBlockRows(panel.view, panel.phase, panel.sel, this.#opts.width(), Math.max(1, this.#opts.height() - 4 - queueRows.length)).length + CHROME_ROWS + queueRows.length;
759
+ }
689
760
  const live = this.#cells.slice(this.#committed);
690
761
  const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
691
762
  const W = this.#opts.width();
@@ -696,7 +767,7 @@ export class Body {
696
767
  lines += bodySpacing(prev, rows).length;
697
768
  prev = rows;
698
769
  }
699
- return lines + CHROME_ROWS + this.#menuRows(W).length;
770
+ return lines + CHROME_ROWS + this.#menuRows(W).length + queueRows.length;
700
771
  }
701
772
  /** The lines committed THIS frame — the writes land in the frame's
702
773
  * committed section (the rows just above the live region). */
@@ -750,9 +821,22 @@ export class Body {
750
821
  // W11: the formula's blank above the first live cell hangs off
751
822
  // the last COMMITTED sibling (the join spans the boundary).
752
823
  const menuRows = this.#menuRows(W);
753
- const chromeRows = CHROME_ROWS + menuRows.length;
824
+ // W22: the queue chips are the menu-rows family's other occupant
825
+ // (the band above the box top) — the chrome rows and the live
826
+ // caps account for both.
827
+ const queueRows = this.#queueRows(W, H);
828
+ const chromeRows = CHROME_ROWS + menuRows.length + queueRows.length;
754
829
  let liveLines = [];
755
- {
830
+ const panel = this.#panelState?.() ?? null;
831
+ if (panel !== null) {
832
+ // W21: the panel REPLACES the running tool's live window — the
833
+ // bounded block, capped at H−4 (the panel IS the live region;
834
+ // the W11 blank would separate it from the frozen content).
835
+ // The cap is exact, so the force-commit loop never fires. W22:
836
+ // the queue band sits below the panel — the cap shrinks by it.
837
+ liveLines = panelBlockRows(panel.view, panel.phase, panel.sel, W, Math.max(1, H - 4 - queueRows.length));
838
+ }
839
+ else {
756
840
  let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
757
841
  for (const cell of this.#cells.slice(this.#committed)) {
758
842
  const rows = cellComponent(cell).render(W, ctx);
@@ -762,8 +846,9 @@ export class Body {
762
846
  }
763
847
  // 3. the FORCE commits — the live region's hard cap H−1: overflow
764
848
  // commits the oldest live cell UNCONDITIONALLY (the one sharp
765
- // edge — the cap scalar is asserted by the gates).
766
- while (liveLines.length > H - 4 && this.#committed < this.#cells.length) { // V6-3: the content cap H−4
849
+ // edge — the cap scalar is asserted by the gates). W22: the
850
+ // queue band shrinks the cap by its rows (empty queue H−4).
851
+ while (liveLines.length > H - 4 - queueRows.length && this.#committed < this.#cells.length) { // V6-3: the content cap H−4
767
852
  this.#commitCell(this.#committed, W, ctx);
768
853
  liveLines = [];
769
854
  {
@@ -783,12 +868,25 @@ export class Body {
783
868
  // 5. the frame bytes.
784
869
  const out = [];
785
870
  out.push("\x1b[?2026h"); // synchronized output ON (DEC 2026)
786
- if (this.#fullRedraw) {
787
- this.#drawFull(out, W, H, liveTop, liveLines, menuRows, ctx);
871
+ // A8: the bottom-anchored window (the model's last H rows) shifts
872
+ // DOWN when the live region SHRINKS — the done-fold, the fold-hold
873
+ // release at the terminal event. The steady path's scroll syncs
874
+ // exactly N committed lines, but the window moves N + liveDelta:
875
+ // a shrink with commits (liveTop grows by LESS than N — the scroll
876
+ // overshoots by liveDelta) slips past the pure-shrink trigger, the
877
+ // stale pass erases the old live rows, and nothing re-paints the
878
+ // band between the committed window and the new liveTop (finding
879
+ // #A8 — the W11-boundary pileup). A GROWTH is safe on the steady
880
+ // path (the new live's bottom-anchored extent covers the old — the
881
+ // erased rows are all re-painted); a shrink takes the full-redraw
882
+ // path: the window re-paints at the model's positions, every row
883
+ // covered (the V6-1 every-row rule).
884
+ if (this.#fullRedraw || liveTop > this.#lastLiveTop + this.#committedLinesThisFrame.length || liveRowsTotal < this.#lastLiveRows) {
885
+ this.#drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, ctx);
788
886
  this.#fullRedraw = false;
789
887
  }
790
888
  else {
791
- this.#drawSteady(out, W, H, liveTop, liveLines, menuRows, ctx);
889
+ this.#drawSteady(out, W, H, liveTop, liveLines, queueRows, menuRows, ctx);
792
890
  }
793
891
  out.push("\x1b[?2026l");
794
892
  this.#write(out.join(""));
@@ -846,12 +944,16 @@ export class Body {
846
944
  if (turn !== undefined && turn.ended && !turn.hasText) {
847
945
  if (!turn.folded) {
848
946
  turn.folded = true;
947
+ // A9 (ruling R2, mock A): the user chip rides the fold —
948
+ // the words take the fold's width budget (turnFold is
949
+ // W-aware — the ONE row never trips invariant ①).
849
950
  return turnFold({
951
+ words: turn.words,
850
952
  thoughtSeconds: turn.thoughtSeconds,
851
953
  reads: turn.reads,
852
954
  edits: turn.edits,
853
955
  others: [...turn.others],
854
- });
956
+ }, W);
855
957
  }
856
958
  return [];
857
959
  }
@@ -938,6 +1040,29 @@ export class Body {
938
1040
  /** The slot occupant's extra rows — the slash-command menu (above the
939
1041
  * status, in the rhythm gap + the content's spare rows — the old
940
1042
  * menu's position, slot-shaped). */
1043
+ /** W22: the pending-queue chips — the menu-rows family's other
1044
+ * occupant (the queue is dense, like the menu; each line is its
1045
+ * own chip with the □ gutter). */
1046
+ #queueRows(W, H) {
1047
+ const lines = this.#queueState?.() ?? [];
1048
+ if (lines.length === 0)
1049
+ return [];
1050
+ // A8b: the band CAPS so the content keeps its rows — an unbounded
1051
+ // band (the batch flood pastes the whole queue at once) overflowed
1052
+ // the screen: the content cap H−4−queue went negative, the march
1053
+ // painted nothing, and the leaving rows were never on the screen to
1054
+ // scroll — the scrollback lost the turns entirely (finding #A8b —
1055
+ // the queued-flood content loss). The band keeps the first H−9
1056
+ // chips + one "…N more" row (≤ H−8 rows — the content keeps ≥ 4);
1057
+ // the status hint's "+N queued" already carries the count, so the
1058
+ // cap hides nothing the status doesn't show.
1059
+ const keep = Math.max(1, H - 9);
1060
+ if (lines.length <= keep)
1061
+ return pendingQueueRows(lines, W);
1062
+ const p = palette();
1063
+ const hidden = lines.length - keep;
1064
+ return [...pendingQueueRows(lines.slice(0, keep), W), `${p.dim}□ …${hidden} more queued${p.reset}`];
1065
+ }
941
1066
  #menuRows(W) {
942
1067
  const menu = this.#menuState?.();
943
1068
  if (menu === null || menu === undefined || menu.items.length === 0)
@@ -956,32 +1081,32 @@ export class Body {
956
1081
  /** The focus component's input row — the marker embedded at the
957
1082
  * cursor's display column WITHIN THE ROW (the brick/question lead
958
1083
  * included), the question/editor/menu variants. The compositor
959
- * strips the marker and moves LEFT by the trailing width — the
960
- * cursor derives from the frame, never from side-channel math.
1084
+ * strips the marker and returns the frame-derived COLUMN — the
1085
+ * cursor move lands AT the marker (a CHA the column is absolute,
1086
+ * so the move's base is irrelevant; the retired afterW CUB's base
1087
+ * was the LAST write's end column, which the steady frame's
1088
+ * gap/stale ELs leave at col 1 — the A3 finding).
961
1089
  *
962
1090
  * W6: the row lives INSIDE the box — the walls are a prefix/suffix
963
1091
  * width only, composed AFTER the marker embed (the marker math is
964
1092
  * untouched; the marker's row column = the wall + the lead + the
965
1093
  * cursor). The content caps at W−4 (the walls' columns) and the
966
1094
  * pad completes the row to EXACTLY W — invariant ① throws on
967
- * overflow, so the box row is built full-width, never truncated. */
1095
+ * overflow, so the box row is built full-width, never truncated.
1096
+ * W23: the lead width is the ONE authority — leadWidth (width.ts),
1097
+ * shared with the editor's selfRender/#reflow and editCol. */
968
1098
  #inputRow(W, _ctx) {
969
1099
  const st = this.#inputState();
970
- let row;
971
- if (this.#question !== null) {
972
- // the ApprovalPrompt occupant the question IS the prompt (the
973
- // slot swap; the brick returns when the question clears)
974
- row = `${this.#question}${st.line}`;
975
- }
976
- else {
977
- row = `${this.#inputPrompt}${st.line}`;
978
- }
979
- // the lead (the prompt / the question) width — the marker's row
980
- // column = leadW + the line cursor (the dockState cursor counts
981
- // within the line only)
982
- const leadW = visibleWidth(row.slice(0, row.length - st.line.length));
1100
+ const panel = this.#panelState?.() ?? null;
1101
+ // the lead the panel's phase lead when the panel owns the row
1102
+ // (1-3> / the rule input's "2 Yes, don't ask again for " / the
1103
+ // amend "feedback (deny): "), the bound prompt otherwise
1104
+ const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.sel) : this.#inputPrompt;
1105
+ const leadW = leadWidth(lead);
1106
+ const row = `${lead}${st.line}`;
983
1107
  // embed the marker at the cursor's display column
984
1108
  let markerLine = "";
1109
+ let markerCell = 0; // the marker's 0-based cell — the walk's w at the embed
985
1110
  let w = 0;
986
1111
  {
987
1112
  let inserted = false;
@@ -997,6 +1122,7 @@ export class Body {
997
1122
  }
998
1123
  if (!inserted && w >= leadW + st.cursor) {
999
1124
  markerLine += CURSOR_MARKER;
1125
+ markerCell = w;
1000
1126
  inserted = true;
1001
1127
  }
1002
1128
  const cw = displayWidth(row[i]);
@@ -1007,28 +1133,28 @@ export class Body {
1007
1133
  i += 1;
1008
1134
  }
1009
1135
  if (!inserted) {
1136
+ // the walk ended before the cursor cell (the box edge) —
1137
+ // the marker rests at the row's end; the move still lands
1138
+ // AT it (the min() of the contract)
1010
1139
  markerLine += CURSOR_MARKER;
1140
+ markerCell = w;
1011
1141
  }
1012
1142
  }
1013
1143
  const stripped0 = markerLine.replace(CURSOR_MARKER, "");
1014
- const tailW = visibleWidth(markerLine.slice(markerLine.indexOf(CURSOR_MARKER) + CURSOR_MARKER.length));
1015
1144
  if (W < 4) {
1016
1145
  // the degenerate screen: the box cannot hold its walls — the
1017
1146
  // bare row (the pre-W6 bytes; the fold probe's pass-through
1018
1147
  // line still crashes invariant ① downstream, as before)
1019
- return { stripped: stripped0, afterW: tailW };
1148
+ return { stripped: stripped0, markerCol: 3 + markerCell };
1020
1149
  }
1021
1150
  // the pad completes the row to W — the content stopped at W−4,
1022
1151
  // so the pad is ≥ 1
1023
1152
  const padW = W - 3 - w;
1024
1153
  const stripped = `\x1b[2m│ \x1b[0m${stripped0}\x1b[2m${" ".repeat(padW)}│\x1b[0m`;
1025
- // the LEFT move = the width AFTER the cursor-rest cell — the
1026
- // full-width row parks the terminal cursor at the LAST cell (not
1027
- // one past the end), so the rest cell IS the first tail cell and
1028
- // the move = the tail + the pad (the right wall rides inside the
1029
- // pad's tail)
1030
- const afterW = tailW + padW;
1031
- return { stripped, afterW };
1154
+ // W23: the frame-derived column wallL (2) + the marker's cell
1155
+ // + 1 the CHA lands the cursor AT the marker from ANY base
1156
+ const markerCol = 3 + markerCell;
1157
+ return { stripped, markerCol };
1032
1158
  }
1033
1159
  /** The full-redraw path (the first frame, the resize repaint) — CUP
1034
1160
  * allowed here; zero LF; zero \x1b[3J; zero replay. The committed
@@ -1041,7 +1167,7 @@ export class Body {
1041
1167
  * survive anywhere the draw does not touch; a draw that covers
1042
1168
  * EVERY row is idempotent: N consecutive resizes end with the same
1043
1169
  * screen as a single jump to the same size. */
1044
- #drawFull(out, W, H, liveTop, liveLines, menuRows, ctx) {
1170
+ #drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, ctx) {
1045
1171
  const committed = this.#committedLinesThisFrame;
1046
1172
  // 0. the FROZEN rows — the re-folded committed content (re-flowed
1047
1173
  // at the new width by the terminal): re-painted at [1..frozen],
@@ -1058,11 +1184,55 @@ export class Body {
1058
1184
  for (let i = 0; i < this.#committedAtFrameStart; i += 1) {
1059
1185
  frozen.push(...bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, this.#lineCache[i]));
1060
1186
  }
1187
+ // A8: the march is the WINDOW — the model's last H rows. When the
1188
+ // model total (committed + live + chrome) exceeds H, the window's
1189
+ // first row is the model's (total − H + 1)-th line: the lines
1190
+ // above the window belong in the scrollback, and painting them at
1191
+ // rows 1.. would shift every row below by the same (total − H) —
1192
+ // the live region lands past its model position and the box eats
1193
+ // its tail. The bound skips them: the march covers exactly the
1194
+ // window (the committed share + the live + the chrome), r
1195
+ // monotone, every row 1..H re-painted (the V6-1 every-row rule).
1196
+ const all = [...frozen, ...committed, ...liveLines];
1197
+ const skip = Math.max(0, all.length + CHROME_ROWS + queueRows.length + menuRows.length - H);
1198
+ // A8b (the shrink-trigger's completion): the rows that LEAVE the
1199
+ // window scroll into the terminal's scrollback — the LF mechanism
1200
+ // (the steady path's own). Only the rows the paint re-covers (the
1201
+ // overlap with the new window) are EL'd first — the A7 single-copy
1202
+ // discipline; the purely-leaving rows scroll WITH their content
1203
+ // (they are never re-painted — the scrollback is their record).
1204
+ // Without the scroll the shrink frames overwrite the leaving rows
1205
+ // in place — a batch-fed session (the queue drains every turn — a
1206
+ // shrink EVERY frame) loses the scrolled-away turns from the
1207
+ // terminal's scrollback entirely (finding #A8b — the queued-flood
1208
+ // content loss).
1209
+ if (skip > 0) {
1210
+ const leaving = Math.max(0, skip - this.#lastSkip);
1211
+ // A8b (the fresh leaving share): a leaving row whose old-screen
1212
+ // copy is stale — the committed-this-frame lines (their old rows
1213
+ // held the previous live/chrome) — is pre-painted at its OLD row
1214
+ // so the LF scroll carries it into the scrollback; the frozen
1215
+ // leaving rows are already on screen and scroll as-is. The first
1216
+ // overflow frame of a batch: the window's top row is the freshly
1217
+ // committed line, NEVER on the old screen — without the
1218
+ // pre-paint the scroll pushes a blank and the line's only paint
1219
+ // (the clamped march at row 1) is overwritten by its neighbor.
1220
+ if (skip > frozen.length) {
1221
+ const fromIdx = Math.max(0, this.#lastSkip - frozen.length);
1222
+ const top = Math.min(skip - frozen.length, all.length - frozen.length);
1223
+ for (let i = fromIdx; i < top; i += 1) {
1224
+ out.push(`\x1b[${Math.max(1, frozen.length + i - this.#lastSkip + 1)};1H\x1b[0K${this.#checked(all[frozen.length + i], W)}`);
1225
+ }
1226
+ }
1227
+ if (leaving < skip)
1228
+ out.push(`\x1b[${leaving + 1};1H\x1b[0J`);
1229
+ out.push(`\x1b[${H};1H`);
1230
+ for (let i = 0; i < skip; i += 1)
1231
+ out.push("\n");
1232
+ }
1233
+ this.#lastSkip = skip;
1061
1234
  let r = 1;
1062
- // one march the frozen, the committed (this frame's), the live —
1063
- // in write order, r monotone (three byte-identical loops merged for
1064
- // the gate; the exact write sequence preserved)
1065
- for (const line of [...frozen, ...committed, ...liveLines]) {
1235
+ for (const line of all.slice(skip)) {
1066
1236
  out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
1067
1237
  r += 1;
1068
1238
  }
@@ -1070,7 +1240,13 @@ export class Body {
1070
1240
  for (let rr = r; rr <= H - 4; rr += 1) {
1071
1241
  out.push(`\x1b[${rr};1H\x1b[0K`);
1072
1242
  }
1073
- const menuTop = H - 3 - menuRows.length;
1243
+ // W22: the queue chips sit directly above the box top (the
1244
+ // "pre-render ABOVE the input row"), the menu above the queue.
1245
+ const queueTop = H - 3 - queueRows.length;
1246
+ const menuTop = H - 3 - queueRows.length - menuRows.length;
1247
+ for (let i = 0; i < queueRows.length; i += 1) {
1248
+ out.push(`\x1b[${queueTop + i};1H\x1b[0K${this.#checked(queueRows[i], W)}`);
1249
+ }
1074
1250
  for (let i = 0; i < menuRows.length; i += 1) {
1075
1251
  out.push(`\x1b[${menuTop + i};1H\x1b[0K${this.#checked(menuRows[i], W)}`);
1076
1252
  }
@@ -1080,36 +1256,97 @@ export class Body {
1080
1256
  const editor = this.#inputRow(W, ctx);
1081
1257
  out.push(`\x1b[${H - 2};1H\x1b[0K${this.#checked(editor.stripped, W)}`);
1082
1258
  out.push(`\x1b[${H - 1};1H\x1b[0K${boxBottom(W)}`);
1083
- out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W, this.#statusHint ?? undefined), W)}`);
1084
- // the cursor: up two (the input row at H−2) + left to the marker
1259
+ const statusRow = this.#statusSource();
1260
+ out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(statusRow.status, this.#tail, W, statusRow.hint), W)}`);
1261
+ // the cursor: up two (the input row at H−2) + the CHA to the
1262
+ // marker's frame-derived column — W23: the afterW CUB retired
1263
+ // (the CHA is absolute — the base is irrelevant; the CUB's base
1264
+ // was the LAST write's end column, which the steady frame's ELs
1265
+ // leave at col 1 — the A3 finding)
1085
1266
  out.push("\x1b[2A");
1086
- if (editor.afterW > 0)
1087
- out.push(`\x1b[${editor.afterW}D`);
1267
+ out.push(`\x1b[${editor.markerCol}G`);
1088
1268
  }
1089
1269
  /** The steady-state frame — RELATIVE moves only (invariant ②); the
1090
1270
  * commits scroll via the CUP-free real LF at the last row, and the
1091
1271
  * committed lines write in the march's top section (rows
1092
1272
  * [liveTop−N .. liveTop−1] — the frozen area's bottom). */
1093
- #drawSteady(out, W, H, liveTop, liveLines, menuRows, ctx) {
1273
+ #drawSteady(out, W, H, liveTop, liveLines, queueRows, menuRows, ctx) {
1094
1274
  const editor = this.#inputRow(W, ctx); // derived from the frame — the marker
1095
1275
  const committed = this.#committedLinesThisFrame;
1096
- // the jump from the anchor (H−2) straight to the bottom row H,
1097
- // then N real LFs scroll the screen exactly N rows ONE per
1098
- // committed line (the bookkeeping; the stale 1B anchor jumped to
1099
- // H−1 and the N LFs scrolled only N−1 the committed section sat
1100
- // one row short in the scrollback). The bottom-up repaint below
1101
- // overwrites the scrolled-in rows (the scroll count is screen-
1102
- // neutral proven by the emulator probe).
1103
- out.push("\x1b[2B");
1276
+ // A8b: the steady path's window geometry the same skip as the full
1277
+ // path's (#lastSkip's formula): the model rows above the window
1278
+ // belong in the scrollback. frozenCount = the committed lines BEFORE
1279
+ // this frame the lines this frame commits start at model row
1280
+ // frozenCount, so the lines at [frozenCount..skip−1] are the fresh
1281
+ // leaving share (their old-screen copies are stale).
1282
+ const frozenCount = this.#committedLines - committed.length;
1283
+ const skip = Math.max(0, this.#committedLines + liveLines.length + CHROME_ROWS + queueRows.length + menuRows.length - H);
1284
+ const leaving = Math.max(0, skip - this.#lastSkip);
1285
+ // the jump to the bottom row H, then N real LFs scroll the screen
1286
+ // exactly N rows — ONE per committed line (the bookkeeping; the
1287
+ // stale 1B anchor jumped to H−1 and the N LFs scrolled only N−1 —
1288
+ // the committed section sat one row short in the scrollback). The
1289
+ // bottom-up repaint below overwrites the scrolled-in rows (the
1290
+ // scroll count is screen-neutral — proven by the emulator probe).
1291
+ // A7: the scrolled rows carry the PRE-FRAME live copies (the
1292
+ // streamed rendering of the just-committed cells) into the
1293
+ // scrollback — a real terminal keeps them forever, and the
1294
+ // repaint's fresh copy below makes the two copies the reviewer
1295
+ // saw. The old live band is EL'd BEFORE the scroll: the rows that
1296
+ // scroll away are blank, the repaint is the only copy (the old
1297
+ // band's rows are all re-drawn this frame — live CUP, gap ELs,
1298
+ // chrome march — so the erase is invisible).
1299
+ if (committed.length > 0) {
1300
+ // A8b (the steady path's own leaving scroll): the fresh leaving
1301
+ // lines — the committed-this-frame/live lines at model rows
1302
+ // [frozenCount..skip−1], whose old-screen copies are stale (the
1303
+ // previous live/chrome) — are pre-painted at their OLD rows so
1304
+ // the LF scroll carries them into the scrollback. The queued
1305
+ // flood's first frame: the window's top row is the user chip,
1306
+ // never on the old screen — without the pre-paint the scroll
1307
+ // pushes a blank and the chip's ONLY paint (the clamped band at
1308
+ // row 1) is overwritten by its neighbor in the same frame
1309
+ // (finding #A8b — the user chips 1..8 lost from the terminal
1310
+ // state).
1311
+ if (leaving > 0 && skip > frozenCount) {
1312
+ const seq = [...committed, ...liveLines];
1313
+ const top = Math.min(skip - frozenCount, seq.length);
1314
+ for (let i = Math.max(0, this.#lastSkip - frozenCount); i < top; i += 1) {
1315
+ out.push(`\x1b[${Math.max(1, frozenCount + i - this.#lastSkip + 1)};1H\x1b[0K${this.#checked(seq[i], W)}`);
1316
+ }
1317
+ }
1318
+ const oldBottom = Math.min(H, this.#lastLiveTop + this.#lastLiveRows);
1319
+ // A8b: the EL covers the OVERLAP only — the old live band's rows
1320
+ // the repaint re-draws (the A7 single-copy discipline). The
1321
+ // leaving rows below the overlap scroll WITH their content —
1322
+ // they are never re-painted, the scrollback is their record (the
1323
+ // old code erased them and the scrolled-away rows came up blank —
1324
+ // the A8b content loss).
1325
+ const overlapFrom = Math.max(1, this.#lastLiveTop, leaving + 1);
1326
+ for (let r = overlapFrom; r <= oldBottom; r += 1) {
1327
+ out.push(`\x1b[${r};1H\x1b[0K`);
1328
+ }
1329
+ out.push(`\x1b[${H};1H`); // CUP to the bottom — the absolute scroll base (the ELs moved the cursor)
1330
+ }
1331
+ else {
1332
+ out.push("\x1b[2B"); // the anchor (H−2) to the bottom — no scroll
1333
+ }
1104
1334
  for (let i = 0; i < committed.length; i += 1)
1105
1335
  out.push("\n");
1106
1336
  // the bottom-up repaint, from the last row up — V6-3 + W6: the
1107
1337
  // design §03 chrome: status (H), box bottom (H−1), input (H−2),
1108
1338
  // box top (H−3)
1109
- out.push(`\x1b[1G\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W, this.#statusHint ?? undefined), W)}`); // H — the status
1339
+ const statusRow = this.#statusSource();
1340
+ out.push(`\x1b[1G\x1b[0K${this.#checked(statusLine(statusRow.status, this.#tail, W, statusRow.hint), W)}`); // H — the status
1110
1341
  out.push(`\x1b[1A\x1b[1G\x1b[0K${boxBottom(W)}`); // H−1 — the box bottom
1111
1342
  out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(editor.stripped, W)}`); // H−2 — the input
1112
1343
  out.push(`\x1b[1A\x1b[1G\x1b[0K${boxTop(W)}`); // H−3 — the box top
1344
+ // W22: the queue chips sit directly above the box top (the
1345
+ // "pre-render ABOVE the input row"), the menu above the queue —
1346
+ // the bottom-up order mirrors the row order.
1347
+ for (let i = queueRows.length - 1; i >= 0; i -= 1) {
1348
+ out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(queueRows[i], W)}`);
1349
+ }
1113
1350
  for (let i = menuRows.length - 1; i >= 0; i -= 1) {
1114
1351
  out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(menuRows[i], W)}`);
1115
1352
  }
@@ -1130,9 +1367,10 @@ export class Body {
1130
1367
  // could).
1131
1368
  // 1. the GAP rows (between the live content and the chrome) — EL'd
1132
1369
  // so the old content there cannot ghost; the range stops ABOVE
1133
- // the menu (the menu's rows at [H−3−menu..H−4] are marched and
1134
- // must survive — the unclamped geometry erased them).
1135
- for (let r = liveTop + liveLines.length; r <= H - 4 - menuRows.length; r += 1) {
1370
+ // the queue + menu bands (their rows at [H−3−queue−menu..H−4]
1371
+ // are marched and must survive — the unclamped geometry erased
1372
+ // them).
1373
+ for (let r = liveTop + liveLines.length; r <= H - 4 - queueRows.length - menuRows.length; r += 1) {
1136
1374
  out.push(`\x1b[${r};1H\x1b[0K`);
1137
1375
  }
1138
1376
  // 2. the STALE rows above the committed section — the scrolled old
@@ -1145,7 +1383,14 @@ export class Body {
1145
1383
  // CLAMP at 1: a super-tall force-commit's early lines have no
1146
1384
  // on-screen row (they would need a negative CUP — terminal
1147
1385
  // undefined behavior); their content stays in the scrollback.
1148
- for (let i = 0; i < committed.length; i += 1) {
1386
+ // A8b: the first (skip frozenCount) lines are ABOVE the new
1387
+ // window — they were pre-painted and scrolled; re-painting them
1388
+ // would re-clamp them into row 1 and the band's second line
1389
+ // overwrites the first in the same frame (the row-1 clamp pile —
1390
+ // the queued flood lost the user chips 1..8 that way). The
1391
+ // window's share starts at the line whose model row is the
1392
+ // window's top (skip).
1393
+ for (let i = Math.max(0, skip - frozenCount); i < committed.length; i += 1) {
1149
1394
  out.push(`\x1b[${Math.max(1, liveTop - committed.length + i)};1H\x1b[0K${this.#checked(committed[i], W)}`);
1150
1395
  }
1151
1396
  // the cursor: down to the anchor (H−2, the input row) + left to the marker —
@@ -1157,18 +1402,27 @@ export class Body {
1157
1402
  ? Math.max(1, liveTop - 1)
1158
1403
  : staleFrom < liveTop
1159
1404
  ? liveTop - 1
1160
- : liveTop + liveLines.length <= H - 4 - menuRows.length
1161
- ? H - 4 - menuRows.length
1405
+ : liveTop + liveLines.length <= H - 4 - queueRows.length - menuRows.length
1406
+ ? H - 4 - queueRows.length - menuRows.length
1162
1407
  : liveLines.length > 0
1163
1408
  ? liveTop + liveLines.length - 1
1164
1409
  : menuRows.length > 0
1165
- ? H - 3 - menuRows.length
1410
+ ? H - 3 - menuRows.length - queueRows.length
1166
1411
  : H - 3;
1167
1412
  const down = H - 2 - lastRow; // the anchor: the input row (H−2)
1168
1413
  if (down > 0)
1169
1414
  out.push(`\x1b[${down}B`);
1170
- if (editor.afterW > 0)
1171
- out.push(`\x1b[${editor.afterW}D`);
1415
+ // W23: the CHA to the frame-derived column — the cursor rests AT
1416
+ // the marker from ANY base (the retired afterW CUB clamped at col
1417
+ // 1 — the steady frame's LAST write is the gap/stale EL: the A3
1418
+ // finding; the A5/A8 live lines end mid-row, the ELs at col 1 —
1419
+ // the CHA ignores the base by construction)
1420
+ out.push(`\x1b[${editor.markerCol}G`);
1421
+ // A8b: the steady path moves the window too (the scroll + the
1422
+ // repaint) — record its top so the next full-redraw's leaving count
1423
+ // is the rows the window dropped since the last frame, whatever the
1424
+ // path of the frames between (same formula as `skip` above).
1425
+ this.#lastSkip = skip;
1172
1426
  }
1173
1427
  /** Invariant ①: every emitted line fits the width — a violation is a
1174
1428
  * CRASH with the diagnostic, never a silent truncate. */
@@ -1210,7 +1464,6 @@ export class Body {
1210
1464
  * input/menu bindings, which the CLI performs BEFORE the Body exists,
1211
1465
  * are buffered and applied by the Body's constructor. */
1212
1466
  export class Dock {
1213
- #menuState = null;
1214
1467
  get active() {
1215
1468
  return compositorRef !== null && compositorRef.active;
1216
1469
  }
@@ -1229,27 +1482,41 @@ export class Dock {
1229
1482
  setTail(tail) {
1230
1483
  compositorRef?.setTail(tail);
1231
1484
  }
1232
- showQuestion(question) {
1233
- compositorRef?.showQuestion(question);
1234
- }
1235
- clearQuestion() {
1236
- compositorRef?.clearQuestion();
1485
+ /** W21: bind the editor's panel state — the PanelSelect slot
1486
+ * occupant (the panel replaces the live region + the input lead
1487
+ * while up; the old ApprovalPrompt's question slot retires). */
1488
+ bindApproval(state) {
1489
+ if (compositorRef === null) {
1490
+ dockBindings.panel = state; // the live buffer — order-agnostic
1491
+ return;
1492
+ }
1493
+ compositorRef.bindApproval(state);
1237
1494
  }
1238
1495
  bindInput(state, prompt) {
1239
1496
  if (compositorRef === null) {
1240
- // the Body is constructed AFTER the CLI's makeLineInputbuffer
1241
- // the binding; the Body's constructor applies it
1242
- dockBindings = { state, prompt, menu: this.#menuState };
1497
+ dockBindings.state = state; // the live bufferorder-agnostic
1498
+ dockBindings.prompt = prompt;
1243
1499
  return;
1244
1500
  }
1245
1501
  compositorRef.bindInput(state, prompt);
1246
1502
  }
1247
1503
  bindMenu(state) {
1248
- this.#menuState = state;
1249
- if (compositorRef === null)
1504
+ if (compositorRef === null) {
1505
+ dockBindings.menu = state;
1250
1506
  return;
1507
+ }
1251
1508
  compositorRef.bindMenu(state);
1252
1509
  }
1510
+ /** W22: bind the pending-turn queue — the chips + the +N queued
1511
+ * hint (the CLI binds it from chat(); the editor's pop keys ride
1512
+ * the LineInput's own bindQueue). */
1513
+ bindQueue(state) {
1514
+ if (compositorRef === null) {
1515
+ dockBindings.queue = state;
1516
+ return;
1517
+ }
1518
+ compositorRef.bindQueue(state);
1519
+ }
1253
1520
  editCol() {
1254
1521
  return compositorRef?.editCol() ?? 1;
1255
1522
  }
@@ -1259,6 +1526,10 @@ export class Dock {
1259
1526
  }
1260
1527
  /** The one-compositor registry — the Dock façade routes to it. */
1261
1528
  let compositorRef = null;
1262
- /** The Dock's pre-compositor bindings — the CLI binds the editor state
1263
- * before the Body exists; the Body's constructor consumes them. */
1264
- let dockBindings = null;
1529
+ /** The Dock's pre-compositor bindings — a LIVE object the bind methods
1530
+ * mutate (the CLI binds the editor state before the Body exists; the
1531
+ * Body's constructor applies it). W21: order-agnostic by construction
1532
+ * — the old snapshot froze `menu` at bindInput time and the slash-
1533
+ * command menu silently never bound in the real CLI (the e2e gates
1534
+ * bind the Body directly and could not see it). */
1535
+ const dockBindings = { state: null, prompt: "", menu: null, panel: null, queue: null };