@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.
- package/dist/approval-panel.d.ts +6 -0
- package/dist/approval-panel.js +6 -0
- package/dist/components.d.ts +4 -222
- package/dist/components.js +4 -884
- package/dist/compositor.d.ts +31 -8
- package/dist/compositor.js +371 -100
- package/dist/diff.d.ts +4 -32
- package/dist/diff.js +4 -122
- package/dist/editor.d.ts +14 -0
- package/dist/editor.js +266 -7
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -1
- package/dist/render.d.ts +11 -122
- package/dist/render.js +15 -292
- package/dist/width.d.ts +4 -15
- package/dist/width.js +4 -59
- package/package.json +5 -2
package/dist/compositor.js
CHANGED
|
@@ -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
|
-
*
|
|
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 —
|
|
23
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
118
|
-
// would never render the typed line.
|
|
119
|
-
|
|
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
|
-
|
|
138
|
+
this.#inputPrompt = dockBindings.prompt;
|
|
139
|
+
if (dockBindings.menu !== null)
|
|
122
140
|
this.#menuState = dockBindings.menu;
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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
|
|
960
|
-
* cursor
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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,
|
|
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
|
|
1026
|
-
//
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
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
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
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 (
|
|
1134
|
-
// must survive — the unclamped geometry erased
|
|
1135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
compositorRef
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
dockBindings = { state, prompt, menu: this.#menuState };
|
|
1497
|
+
dockBindings.state = state; // the live buffer — order-agnostic
|
|
1498
|
+
dockBindings.prompt = prompt;
|
|
1243
1499
|
return;
|
|
1244
1500
|
}
|
|
1245
1501
|
compositorRef.bindInput(state, prompt);
|
|
1246
1502
|
}
|
|
1247
1503
|
bindMenu(state) {
|
|
1248
|
-
|
|
1249
|
-
|
|
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 —
|
|
1263
|
-
*
|
|
1264
|
-
|
|
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 };
|