@vincemakes/kiso-tui 0.8.0 → 0.10.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.
- package/dist/ask-panel.d.ts +8 -7
- package/dist/ask-panel.js +31 -12
- package/dist/at-picker.d.ts +4 -0
- package/dist/at-picker.js +41 -8
- package/dist/compositor.d.ts +28 -10
- package/dist/compositor.js +405 -53
- package/dist/context-ledger.d.ts +66 -0
- package/dist/context-ledger.js +87 -0
- package/dist/editor.d.ts +11 -1
- package/dist/editor.js +301 -17
- package/dist/index.d.ts +7 -5
- package/dist/index.js +16 -5
- package/dist/session-picker.d.ts +113 -0
- package/dist/session-picker.js +249 -0
- package/dist/status.d.ts +32 -2
- package/dist/status.js +24 -3
- package/package.json +2 -2
package/dist/compositor.js
CHANGED
|
@@ -48,9 +48,12 @@ import { leadWidth } from "./width.js"; // W23: the ONE width authority (the edi
|
|
|
48
48
|
// KC3.5: the panel-slot reads come from the DISPATCHERS — one source
|
|
49
49
|
// for four reads, so an ask can never render half as an approval.
|
|
50
50
|
import { panelAffordanceOf, panelLeadOf, panelRowsOf, panelStatusOf } from "./ask-panel.js";
|
|
51
|
-
import { atPanelRows } from "./at-picker.js";
|
|
52
|
-
|
|
51
|
+
import { atPanelRows, bandHeader } from "./at-picker.js";
|
|
52
|
+
// TUI2-R2 ②: the session picker's rows — the band's third occupant.
|
|
53
|
+
import { sessionPickerRows } from "./session-picker.js";
|
|
54
|
+
import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, focusToken, exploreRows, foldLine, isExploreTool, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
|
|
53
55
|
import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
|
|
56
|
+
import { displayVerb, keysSheetRows } from "./strings.js";
|
|
54
57
|
/** The cursor marker — an APC private sequence the focus component
|
|
55
58
|
* embeds at the edit position; the compositor strips it and moves
|
|
56
59
|
* relatively (it never reaches the terminal). */
|
|
@@ -118,12 +121,24 @@ export class Body {
|
|
|
118
121
|
// panel is up it replaces the live region, owns the input lead, and
|
|
119
122
|
// derives the status row (the CLI's painting status yields).
|
|
120
123
|
#panelState = null;
|
|
124
|
+
/** TUI2-R1 (D): the keys sheet's slot read — the editor's boolean.
|
|
125
|
+
* Unbound, the sheet cannot render and every frame is byte-identical
|
|
126
|
+
* to before the round. */
|
|
127
|
+
#sheetState = null;
|
|
128
|
+
/** TUI2-R1.5 7(a): the sheet's previous up/down state — a transition
|
|
129
|
+
* in either direction takes the full-redraw path. */
|
|
130
|
+
#sheetWasUp = false;
|
|
131
|
+
/** This frame is an overlay open or close — it must not scroll. */
|
|
132
|
+
#overlayFrame = false;
|
|
121
133
|
#inputState = () => ({ line: "", cursor: 0 });
|
|
122
134
|
#inputPrompt = "";
|
|
123
135
|
#menuState = null;
|
|
124
136
|
// KC3 §4: the @ picker's bound state — the SAME band as the menu
|
|
125
137
|
// (see #menuRows: the two are mutually exclusive by construction).
|
|
126
138
|
#atState = null;
|
|
139
|
+
// TUI2-R2 ②: the session picker's bound state — the same band again
|
|
140
|
+
// (see #menuRows), and modal, so it takes the band first.
|
|
141
|
+
#pickState = null;
|
|
127
142
|
// W22: the pending-turn queue's bound state — the CLI's live slots
|
|
128
143
|
// (chat.ts); the chips render in the menu-rows family (above the
|
|
129
144
|
// box top), the live caps shrink by their rows, and the status
|
|
@@ -136,6 +151,16 @@ export class Body {
|
|
|
136
151
|
// v6: the single writer — the compositor IS the dock; the CLI's
|
|
137
152
|
// onDock callback (which used to re-pin the dock after a scroll)
|
|
138
153
|
// is retired with the split.
|
|
154
|
+
// TUI2-R2pre ③: taking the ref SUPERSEDES whatever held it, so the
|
|
155
|
+
// outgoing compositor's resize listener comes off here. It is not
|
|
156
|
+
// only listener hygiene: every Dock call now reaches THIS instance,
|
|
157
|
+
// so a resize heard by the old one would have a compositor that owns
|
|
158
|
+
// no part of the screen paint a full redraw over it.
|
|
159
|
+
// (an explicit null check, not `?.#` — TS18030: an optional chain
|
|
160
|
+
// cannot contain a private identifier, and vitest transpiles without
|
|
161
|
+
// type-checking, so only `npm run typecheck` sees the difference)
|
|
162
|
+
if (compositorRef !== null)
|
|
163
|
+
compositorRef.#detachResize();
|
|
139
164
|
compositorRef = this;
|
|
140
165
|
// the Dock façade's bindings may arrive BEFORE this construction
|
|
141
166
|
// (the CLI binds the editor state in makeLineInput, then constructs
|
|
@@ -152,7 +177,10 @@ export class Body {
|
|
|
152
177
|
this.#menuState = dockBindings.menu;
|
|
153
178
|
if (dockBindings.at !== null)
|
|
154
179
|
this.#atState = dockBindings.at;
|
|
180
|
+
if (dockBindings.pick !== null)
|
|
181
|
+
this.#pickState = dockBindings.pick;
|
|
155
182
|
this.#panelState = dockBindings.panel;
|
|
183
|
+
this.#sheetState = dockBindings.sheet;
|
|
156
184
|
if (dockBindings.queue !== null)
|
|
157
185
|
this.#queueState = dockBindings.queue;
|
|
158
186
|
}
|
|
@@ -212,6 +240,19 @@ export class Body {
|
|
|
212
240
|
this.#write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
|
|
213
241
|
return;
|
|
214
242
|
}
|
|
243
|
+
// TUI2-R1.5 ① (VD-1): the tool's start CLOSES an open text block —
|
|
244
|
+
// the inactive path above has always done this; the active path
|
|
245
|
+
// forgot, and the consequence was structural. textAppend only ever
|
|
246
|
+
// grows the LAST cell, so a text block with a tool cell after it can
|
|
247
|
+
// never receive another byte: it is finished in fact while its
|
|
248
|
+
// `done` flag says otherwise. The commit loop takes leading DONE
|
|
249
|
+
// cells, so that one stale flag parked the whole rest of the turn
|
|
250
|
+
// behind it — every tool cell then reached the screen through the
|
|
251
|
+
// FORCE-commit path, which by design bypasses the fold-hold. That is
|
|
252
|
+
// why the walkthrough saw nine individual rows: not a fold that
|
|
253
|
+
// declined to form, a fold that was never consulted.
|
|
254
|
+
this.#closeOpenThinking();
|
|
255
|
+
this.#closeOpenText();
|
|
215
256
|
// W12: the cell carries the delegate's child roles from the FULL
|
|
216
257
|
// input — the display summary is sliced at 60 chars (unparseable);
|
|
217
258
|
// the roles are the only running-state data the parent holds (there
|
|
@@ -282,6 +323,30 @@ export class Body {
|
|
|
282
323
|
}
|
|
283
324
|
this.#mark();
|
|
284
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* TUI2-R1 (C) — the RUNNING call's observed output.
|
|
328
|
+
*
|
|
329
|
+
* The CLI tails the shell tool's progress sidecar and hands what it
|
|
330
|
+
* read to the cell. Deliberately narrow: only a cell that is still
|
|
331
|
+
* RUNNING accepts it, so an observation can never overwrite a real
|
|
332
|
+
* result, and an unchanged read costs no frame at all (a poller
|
|
333
|
+
* fires far more often than the output changes).
|
|
334
|
+
*
|
|
335
|
+
* This adds no event and no durable state. The text lands in the
|
|
336
|
+
* cell's live rendering and is replaced wholesale by the tool's own
|
|
337
|
+
* result at settle — which is the only text anything else ever reads.
|
|
338
|
+
*/
|
|
339
|
+
toolProgress(callId, text) {
|
|
340
|
+
if (!this.#isActive())
|
|
341
|
+
return; // the pipe path has no live region
|
|
342
|
+
const cell = this.#toolCell(callId);
|
|
343
|
+
if (cell === null || cell.kind !== "tool" || cell.state !== "running" || cell.done)
|
|
344
|
+
return;
|
|
345
|
+
if (cell.resultText === text)
|
|
346
|
+
return;
|
|
347
|
+
cell.resultText = text;
|
|
348
|
+
this.#mark();
|
|
349
|
+
}
|
|
285
350
|
toolSucceeded(callId) {
|
|
286
351
|
if (!this.#isActive())
|
|
287
352
|
this.#write(" ok\n");
|
|
@@ -492,7 +557,7 @@ export class Body {
|
|
|
492
557
|
this.#cells.push({ kind: "banner", version, extensionsText, resume, done: true });
|
|
493
558
|
this.#mark();
|
|
494
559
|
}
|
|
495
|
-
raw(lines) {
|
|
560
|
+
raw(lines, wrap) {
|
|
496
561
|
if (!this.#isActive()) {
|
|
497
562
|
this.#closeOpenThinking();
|
|
498
563
|
this.#closeOpenText();
|
|
@@ -502,7 +567,7 @@ export class Body {
|
|
|
502
567
|
}
|
|
503
568
|
this.#closeOpenThinking();
|
|
504
569
|
this.#closeOpenText();
|
|
505
|
-
this.#cells.push({ kind: "raw", lines, done: true });
|
|
570
|
+
this.#cells.push({ kind: "raw", lines, done: true, ...(wrap === undefined ? {} : { wrap }) });
|
|
506
571
|
this.#mark();
|
|
507
572
|
}
|
|
508
573
|
/** The last COMPLETE thinking block, for /think. */
|
|
@@ -522,6 +587,30 @@ export class Body {
|
|
|
522
587
|
* cell: the pointer cycles the collapsed history, newest first, and
|
|
523
588
|
* the header names the target ("N turns back" — the user cells
|
|
524
589
|
* after it), so every press tells the user what they got. */
|
|
590
|
+
/**
|
|
591
|
+
* TUI2-R2 ⑤ — the cell the next ctrl+r will act on, or -1.
|
|
592
|
+
*
|
|
593
|
+
* The rule is expandNext's own first loop, extracted verbatim: the
|
|
594
|
+
* LAST live cell that can toggle. It is a separate method rather than
|
|
595
|
+
* a shared constant because the marker and the key must not merely
|
|
596
|
+
* agree today — the marker is a PROMISE about what the key will do,
|
|
597
|
+
* and the only way to keep it is to derive it from the same scan.
|
|
598
|
+
*
|
|
599
|
+
* The committed fallback (the #collapsed ring) is deliberately NOT
|
|
600
|
+
* marked: those rows are frozen history, never re-emitted, so a tint
|
|
601
|
+
* on them could not be moved when the pointer advances. A live target
|
|
602
|
+
* is the one the marker can tell the truth about.
|
|
603
|
+
*/
|
|
604
|
+
#focusIndex() {
|
|
605
|
+
for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
|
|
606
|
+
const cell = this.#cells[i];
|
|
607
|
+
if (cell.kind === "tool" && cell.state !== "pending")
|
|
608
|
+
return i;
|
|
609
|
+
if (cell.kind === "checklist" && !cell.done)
|
|
610
|
+
return i;
|
|
611
|
+
}
|
|
612
|
+
return -1;
|
|
613
|
+
}
|
|
525
614
|
expandNext() {
|
|
526
615
|
for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
|
|
527
616
|
const cell = this.#cells[i];
|
|
@@ -555,8 +644,16 @@ export class Body {
|
|
|
555
644
|
// land as NEW content, history is never rewritten, ADR-0046).
|
|
556
645
|
const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
|
|
557
646
|
const p = palette();
|
|
647
|
+
const back = `${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
|
|
648
|
+
// TUI2-R1 (B): an EXPLORATION head lists per TOOL — the counts
|
|
649
|
+
// the row showed, then one row per tool with its subjects. The
|
|
650
|
+
// header keeps W15's shape; only the subject changes.
|
|
651
|
+
if (cell.rolled.parts !== undefined) {
|
|
652
|
+
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`explored ${exploreCounts(cell.rolled.parts)}`)} · ${back}`;
|
|
653
|
+
return { kind: "appended", lines: [header, ...exploreRows(cell.rolled.parts, this.#opts.width())] };
|
|
654
|
+
}
|
|
558
655
|
const noun = ROLLUP_NOUN[cell.name] ?? "calls";
|
|
559
|
-
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name
|
|
656
|
+
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${displayVerb(cell.name)} ${cell.rolled.count} ${noun}`)} · ${back}`;
|
|
560
657
|
return {
|
|
561
658
|
kind: "appended",
|
|
562
659
|
lines: [header, ...cell.rolled.targets.map((t) => ` ${p.dim}└ ${escapeTerminal(t)}${p.reset}`)],
|
|
@@ -572,14 +669,16 @@ export class Body {
|
|
|
572
669
|
}
|
|
573
670
|
const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
|
|
574
671
|
const p = palette();
|
|
575
|
-
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name
|
|
672
|
+
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${displayVerb(cell.name)} ${toolTarget(cell.name, input)}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
|
|
576
673
|
return {
|
|
577
674
|
kind: "appended",
|
|
578
675
|
lines: [
|
|
579
676
|
header,
|
|
580
|
-
|
|
677
|
+
// TUI2-R2pre ④: the SECTION HEADERS say the act; the payloads
|
|
678
|
+
// below them (inputFull, resultText) are RAW and byte-identical.
|
|
679
|
+
`--- ${displayVerb(cell.name)} input ---`,
|
|
581
680
|
cell.inputFull,
|
|
582
|
-
`--- ${cell.name} output${cell.isError ? " (error)" : ""} ---`,
|
|
681
|
+
`--- ${displayVerb(cell.name)} output${cell.isError ? " (error)" : ""} ---`,
|
|
583
682
|
cell.resultText,
|
|
584
683
|
],
|
|
585
684
|
};
|
|
@@ -589,13 +688,29 @@ export class Body {
|
|
|
589
688
|
get active() {
|
|
590
689
|
return this.#docked && this.#isActive();
|
|
591
690
|
}
|
|
691
|
+
/** TUI2-R2pre ③ — the ONE place a resize listener is installed, and it
|
|
692
|
+
* removes the previous one first. `process.stdout` is process-wide and
|
|
693
|
+
* its listeners outlive the object that added them, so "add" without
|
|
694
|
+
* "remove first" is a leak by construction: the old closure is
|
|
695
|
+
* unreachable the moment #resizeHandler is overwritten, and not even
|
|
696
|
+
* exit() can take it off. */
|
|
697
|
+
#attachResize() {
|
|
698
|
+
this.#detachResize();
|
|
699
|
+
this.#resizeHandler = () => this.onResize();
|
|
700
|
+
process.stdout.on("resize", this.#resizeHandler);
|
|
701
|
+
}
|
|
702
|
+
#detachResize() {
|
|
703
|
+
if (this.#resizeHandler === null)
|
|
704
|
+
return;
|
|
705
|
+
process.stdout.off("resize", this.#resizeHandler);
|
|
706
|
+
this.#resizeHandler = null;
|
|
707
|
+
}
|
|
592
708
|
enter() {
|
|
593
709
|
const rows = process.stdout.rows ?? 0;
|
|
594
710
|
if (process.stdout.isTTY !== true || palette().bold === "" || rows < 4)
|
|
595
711
|
return;
|
|
596
712
|
this.#docked = true;
|
|
597
|
-
this.#
|
|
598
|
-
process.stdout.on("resize", this.#resizeHandler);
|
|
713
|
+
this.#attachResize();
|
|
599
714
|
this.#fullRedraw = true;
|
|
600
715
|
this.#dirty = true;
|
|
601
716
|
this.render(); // the FIRST frame — the full-redraw path, no pre-clear
|
|
@@ -603,13 +718,16 @@ export class Body {
|
|
|
603
718
|
/** Teardown — CSI r (the "no broken terminal" contract byte), the
|
|
604
719
|
* chrome rows cleared, the cursor home at the input line. */
|
|
605
720
|
exit() {
|
|
606
|
-
if (!this.#docked)
|
|
721
|
+
if (!this.#docked) {
|
|
722
|
+
// TUI2-R2pre ③: an un-docked compositor can still hold a listener
|
|
723
|
+
// (it was superseded, or enter() ran and the dock was torn down by
|
|
724
|
+
// another path) — the teardown is unconditional, the CHROME clear
|
|
725
|
+
// below is not.
|
|
726
|
+
this.#detachResize();
|
|
607
727
|
return;
|
|
608
|
-
this.#docked = false;
|
|
609
|
-
if (this.#resizeHandler !== null) {
|
|
610
|
-
process.stdout.off("resize", this.#resizeHandler);
|
|
611
|
-
this.#resizeHandler = null;
|
|
612
728
|
}
|
|
729
|
+
this.#docked = false;
|
|
730
|
+
this.#detachResize();
|
|
613
731
|
const H = this.#lastH > 0 ? this.#lastH : process.stdout.rows ?? 24;
|
|
614
732
|
const out = [];
|
|
615
733
|
out.push("\x1b[r");
|
|
@@ -680,6 +798,11 @@ export class Body {
|
|
|
680
798
|
}
|
|
681
799
|
/** Bind the editor's slash-command menu state — the MenuSelect slot
|
|
682
800
|
* occupant (the menu replaces the editor's view while open). */
|
|
801
|
+
/** TUI2-R1 (D): bind the editor's keys-sheet flag. */
|
|
802
|
+
bindSheet(state) {
|
|
803
|
+
this.#sheetState = state;
|
|
804
|
+
this.#mark();
|
|
805
|
+
}
|
|
683
806
|
bindMenu(state) {
|
|
684
807
|
this.#menuState = state;
|
|
685
808
|
}
|
|
@@ -689,6 +812,11 @@ export class Body {
|
|
|
689
812
|
bindAt(state) {
|
|
690
813
|
this.#atState = state;
|
|
691
814
|
}
|
|
815
|
+
/** TUI2-R2 ②: bind the editor's session picker — the band's third
|
|
816
|
+
* occupant (see #menuRows for why they share one). */
|
|
817
|
+
bindPick(state) {
|
|
818
|
+
this.#pickState = state;
|
|
819
|
+
}
|
|
692
820
|
/** Bind the pending-turn queue — the CLI's live slots (chat.ts):
|
|
693
821
|
* the chips render in the menu-rows family, the live caps shrink
|
|
694
822
|
* by their rows, and the +N queued hint rides the status row. */
|
|
@@ -772,10 +900,20 @@ export class Body {
|
|
|
772
900
|
* screen rows), threaded against the previous sibling's OWN rows. */
|
|
773
901
|
liveCount() {
|
|
774
902
|
const panel = this.#panelState?.() ?? null;
|
|
903
|
+
const sheet = this.#sheetState?.() === true;
|
|
775
904
|
const queueRows = this.#queueRows(this.#opts.width(), this.#opts.height());
|
|
776
905
|
// KC1 §6: the composer's extra rows are chrome too — the scalar
|
|
777
906
|
// counts them exactly like the menu/queue bands (N = 1 ⇒ +0)
|
|
778
907
|
const inputExtra = this.#inputRows(this.#opts.width(), this.#opts.height(), this.#menuRows(this.#opts.width()).length, queueRows.length).rows.length - 1;
|
|
908
|
+
// TUI2-R1 (D): the sheet occupies the live region, exactly like the
|
|
909
|
+
// panel — the scalar must say so, or the cap arithmetic disagrees
|
|
910
|
+
// with the screen.
|
|
911
|
+
if (sheet) {
|
|
912
|
+
return (keysSheetRows(this.#opts.width()).slice(0, Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
|
|
913
|
+
CHROME_ROWS +
|
|
914
|
+
inputExtra +
|
|
915
|
+
queueRows.length);
|
|
916
|
+
}
|
|
779
917
|
if (panel !== null) {
|
|
780
918
|
// W21: the panel's own rows (the cap is exact — the scalar
|
|
781
919
|
// reflects the screen). W22: the queue chips occupy their
|
|
@@ -861,7 +999,26 @@ export class Body {
|
|
|
861
999
|
const chromeRows = CHROME_ROWS + inputExtra + menuRows.length + queueRows.length;
|
|
862
1000
|
let liveLines = [];
|
|
863
1001
|
const panel = this.#panelState?.() ?? null;
|
|
864
|
-
|
|
1002
|
+
// TUI2-R1.5 ⑦(a) (VD-8): the sheet is an OVERLAY, and the frame it
|
|
1003
|
+
// opens on — and the one it closes on — take the full-redraw path.
|
|
1004
|
+
// The sheet REPLACES the live region, so on an idle composer (where
|
|
1005
|
+
// the live region is empty) opening it GROWS the model by its own
|
|
1006
|
+
// height; the frame's skip grows with it and the difference is paid
|
|
1007
|
+
// in real LFs — rows scrolled permanently into the terminal's
|
|
1008
|
+
// scrollback, which closing cannot undo, because the scrollback is
|
|
1009
|
+
// not ours to rewrite. Measured: three rows per open on a full
|
|
1010
|
+
// screen. The overlay below displaces content on screen instead.
|
|
1011
|
+
const sheetUp = this.#sheetState?.() === true;
|
|
1012
|
+
this.#overlayFrame = sheetUp || this.#sheetWasUp;
|
|
1013
|
+
this.#sheetWasUp = sheetUp;
|
|
1014
|
+
if (sheetUp) {
|
|
1015
|
+
// TUI2-R1 (D): the sheet REPLACES the live region — the same
|
|
1016
|
+
// slot the panel uses, for the same reason (it is what the
|
|
1017
|
+
// human is reading right now). It cannot coexist with a panel:
|
|
1018
|
+
// the editor only opens it from an idle composer.
|
|
1019
|
+
liveLines = keysSheetRows(W).slice(0, Math.max(1, H - 4 - inputExtra - queueRows.length));
|
|
1020
|
+
}
|
|
1021
|
+
else if (panel !== null) {
|
|
865
1022
|
// W21: the panel REPLACES the running tool's live window — the
|
|
866
1023
|
// bounded block, capped at H−4 (the panel IS the live region;
|
|
867
1024
|
// the W11 blank would separate it from the frozen content).
|
|
@@ -870,9 +1027,21 @@ export class Body {
|
|
|
870
1027
|
liveLines = panelRowsOf(panel, W, Math.max(1, H - 4 - inputExtra - queueRows.length));
|
|
871
1028
|
}
|
|
872
1029
|
else {
|
|
1030
|
+
// TUI2-R2 ⑤ (D, candidate 1): the FOCUS — the cell the next ctrl+r
|
|
1031
|
+
// will act on brightens its own token. The index is derived from
|
|
1032
|
+
// the SAME scan expandNext performs (#focusIndex shares its rule
|
|
1033
|
+
// by construction), so the marker can never point at a cell the
|
|
1034
|
+
// key would not take — which is the only way a focus marker is
|
|
1035
|
+
// worth having.
|
|
1036
|
+
const focus = this.#focusIndex();
|
|
873
1037
|
let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
|
|
874
|
-
for (
|
|
1038
|
+
for (let i = this.#committed; i < this.#cells.length; i += 1) {
|
|
1039
|
+
const cell = this.#cells[i];
|
|
875
1040
|
const rows = cellComponent(cell).render(W, ctx);
|
|
1041
|
+
// the head row carries the affordance; the tint lands on it and
|
|
1042
|
+
// nowhere else, which is what makes "exactly one" structural
|
|
1043
|
+
if (i === focus && rows.length > 0)
|
|
1044
|
+
rows[0] = focusToken(rows[0], W);
|
|
876
1045
|
liveLines.push(...bodySpacing(prev, rows));
|
|
877
1046
|
prev = rows;
|
|
878
1047
|
}
|
|
@@ -885,9 +1054,15 @@ export class Body {
|
|
|
885
1054
|
this.#commitCell(this.#committed, W, ctx);
|
|
886
1055
|
liveLines = [];
|
|
887
1056
|
{
|
|
1057
|
+
// TUI2-R2 ⑤: the focus re-derives after a commit — the cell it
|
|
1058
|
+
// pointed at may have just left the live region
|
|
1059
|
+
const focus = this.#focusIndex();
|
|
888
1060
|
let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
|
|
889
|
-
for (
|
|
1061
|
+
for (let i = this.#committed; i < this.#cells.length; i += 1) {
|
|
1062
|
+
const cell = this.#cells[i];
|
|
890
1063
|
const rows = cellComponent(cell).render(W, ctx);
|
|
1064
|
+
if (i === focus && rows.length > 0)
|
|
1065
|
+
rows[0] = focusToken(rows[0], W);
|
|
891
1066
|
liveLines.push(...bodySpacing(prev, rows));
|
|
892
1067
|
prev = rows;
|
|
893
1068
|
}
|
|
@@ -940,14 +1115,17 @@ export class Body {
|
|
|
940
1115
|
#commitCell(i, W, ctx) {
|
|
941
1116
|
const cell = this.#cells[i];
|
|
942
1117
|
const lines = this.#foldOrRollup(cell, i, W, ctx);
|
|
943
|
-
// W15: a tool cell whose
|
|
944
|
-
// affordance
|
|
945
|
-
//
|
|
946
|
-
//
|
|
1118
|
+
// W15: a tool cell whose committed rows carried the "ctrl+r"
|
|
1119
|
+
// affordance joins the expand history — the detection is the
|
|
1120
|
+
// renderer's OWN output, so the read's "/last"-only cut note never
|
|
1121
|
+
// lands here. TUI2-R1 (A/B): the affordance is no longer only the
|
|
1122
|
+
// renderer cut's "└ … ctrl+r" — the self-naming head suffix and
|
|
1123
|
+
// the exploration row carry it on the HEAD row, and a promise the
|
|
1124
|
+
// key does not answer would be the one thing worse than silence.
|
|
947
1125
|
// unshift: the cells commit oldest-first, so the NEWEST cut lands
|
|
948
1126
|
// at the front — the expand pointer's "newest back" walk starts
|
|
949
1127
|
// where the user's last key press would aim.
|
|
950
|
-
if (cell.kind === "tool" &&
|
|
1128
|
+
if (cell.kind === "tool" && lines.some((l) => l.includes("ctrl+r")))
|
|
951
1129
|
this.#collapsed.unshift(i);
|
|
952
1130
|
this.#lineCache[i] = lines;
|
|
953
1131
|
const placed = bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines);
|
|
@@ -967,7 +1145,51 @@ export class Body {
|
|
|
967
1145
|
const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
|
|
968
1146
|
if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
|
|
969
1147
|
return false;
|
|
970
|
-
|
|
1148
|
+
if (!turn.ended && !turn.hasText)
|
|
1149
|
+
return true;
|
|
1150
|
+
// the turn's END releases every hold — the settle is where the run
|
|
1151
|
+
// is decided, and a held cell at settle would never commit at all.
|
|
1152
|
+
if (turn.ended)
|
|
1153
|
+
return false;
|
|
1154
|
+
return this.#growingRun(i);
|
|
1155
|
+
}
|
|
1156
|
+
/** TUI2-R1.5 ① (VD-1) — the explore-run hold. W14's hold covers the
|
|
1157
|
+
* QUIET turn only, and the model's own narration ("let me look at the
|
|
1158
|
+
* parser area") sets hasText before the first read even starts: from
|
|
1159
|
+
* there each completion committed in its OWN frame, the head committed
|
|
1160
|
+
* alone, and `members.every(done)` — the fold's gate — could never be
|
|
1161
|
+
* true again. Every real session therefore degraded to one row per
|
|
1162
|
+
* call while the unit suite, which feeds the burst synchronously,
|
|
1163
|
+
* stayed green (the walkthrough's frame s1-06).
|
|
1164
|
+
*
|
|
1165
|
+
* The hold is the smallest honest fix: a DONE explore cell whose run
|
|
1166
|
+
* can still GROW does not commit yet — its committed form is not
|
|
1167
|
+
* decided until the run is closed. The run closes at the first
|
|
1168
|
+
* non-explore cell (the model's next word, an edit, a shell) or at the
|
|
1169
|
+
* turn's end, and the whole run then commits in ONE frame, which is
|
|
1170
|
+
* exactly the shape the fold was written for.
|
|
1171
|
+
*
|
|
1172
|
+
* The force-commit path never consults this (see #held's callers): the
|
|
1173
|
+
* screen's hard cap still wins, so the screen never sticks — a run
|
|
1174
|
+
* under real screen pressure degrades mid-turn, and the rows it
|
|
1175
|
+
* already froze stay frozen (history is never rewritten, ADR-0046). */
|
|
1176
|
+
#growingRun(i) {
|
|
1177
|
+
const cell = this.#cells[i];
|
|
1178
|
+
if (cell.kind !== "tool" || !isExploreTool(cell.name))
|
|
1179
|
+
return false;
|
|
1180
|
+
// the run is still growing while NOTHING but explore cells follow —
|
|
1181
|
+
// the turn-less noise cells (permission raws, ⚠ notices) are
|
|
1182
|
+
// transparent here for the same reason the run scan sees through
|
|
1183
|
+
// them: the streaming execution interleaves them between the calls.
|
|
1184
|
+
for (let j = i + 1; j < this.#cells.length; j += 1) {
|
|
1185
|
+
const next = this.#cells[j];
|
|
1186
|
+
if (next.kind === "raw" || next.kind === "notice")
|
|
1187
|
+
continue;
|
|
1188
|
+
if (next.kind === "tool" && isExploreTool(next.name))
|
|
1189
|
+
continue;
|
|
1190
|
+
return false; // a non-explore cell closed the run — commit now
|
|
1191
|
+
}
|
|
1192
|
+
return true;
|
|
971
1193
|
}
|
|
972
1194
|
/** W14/W13 — the release-time decision at a commit, BEFORE the cell's
|
|
973
1195
|
* own render: the folded-turn fold first (a QUIET turn — ended, no
|
|
@@ -996,26 +1218,38 @@ export class Body {
|
|
|
996
1218
|
return [];
|
|
997
1219
|
}
|
|
998
1220
|
}
|
|
999
|
-
if (cell.kind !== "tool" ||
|
|
1221
|
+
if (cell.kind !== "tool" || !isExploreTool(cell.name))
|
|
1000
1222
|
return cellComponent(cell).render(W, ctx);
|
|
1001
|
-
//
|
|
1223
|
+
// TUI2-R1 (B): the run is over the READ-ONLY SET, not one name —
|
|
1224
|
+
// a model exploring mixes read/list/search, and the same-name scan
|
|
1225
|
+
// split every real burst into fragments. Writes, edits, shells and
|
|
1226
|
+
// extension tools still break the run at the first one.
|
|
1227
|
+
// the maximal read-only run around i — forward/backward scans over
|
|
1002
1228
|
// the cells. The turn-less noise cells (the permission raws, the ⚠
|
|
1003
1229
|
// notices) are TRANSPARENT: the streaming execution (loop.ts launch)
|
|
1004
1230
|
// interleaves them BETWEEN the calls of one burst, so the run must
|
|
1005
1231
|
// see through them. It never crosses a user/text/thinking cell —
|
|
1006
1232
|
// those separate turns and contexts.
|
|
1233
|
+
// TUI2-R1.5 ① (VD-1): the backward scan stops at the cells this
|
|
1234
|
+
// FRAME is committing. A cell committed in an earlier frame is
|
|
1235
|
+
// frozen — its rows are on the screen and in the scrollback — so it
|
|
1236
|
+
// can never become the head of a rollup now, and a run that
|
|
1237
|
+
// force-committed its first rows mid-turn must not have the rest
|
|
1238
|
+
// silently absorbed into a summary that was computed without them.
|
|
1239
|
+
// The degraded head keeps its individual row; the rest of the run
|
|
1240
|
+
// rolls on its own.
|
|
1007
1241
|
let s = i;
|
|
1008
1242
|
let head = i;
|
|
1009
|
-
while (s >
|
|
1243
|
+
while (s > this.#committedAtFrameStart) {
|
|
1010
1244
|
const prev = this.#cells[s - 1];
|
|
1011
1245
|
if (prev.kind === "raw" || prev.kind === "notice") {
|
|
1012
1246
|
s -= 1;
|
|
1013
1247
|
continue;
|
|
1014
1248
|
}
|
|
1015
|
-
if (prev.kind !== "tool" || prev.name
|
|
1249
|
+
if (prev.kind !== "tool" || !isExploreTool(prev.name))
|
|
1016
1250
|
break;
|
|
1017
1251
|
s -= 1;
|
|
1018
|
-
head = s; // a
|
|
1252
|
+
head = s; // a read-only tool precedes — it is the group's head
|
|
1019
1253
|
}
|
|
1020
1254
|
let e = i;
|
|
1021
1255
|
while (e + 1 < this.#cells.length) {
|
|
@@ -1024,7 +1258,7 @@ export class Body {
|
|
|
1024
1258
|
e += 1;
|
|
1025
1259
|
continue;
|
|
1026
1260
|
}
|
|
1027
|
-
if (next.kind !== "tool" || next.name
|
|
1261
|
+
if (next.kind !== "tool" || !isExploreTool(next.name))
|
|
1028
1262
|
break;
|
|
1029
1263
|
e += 1;
|
|
1030
1264
|
}
|
|
@@ -1042,13 +1276,18 @@ export class Body {
|
|
|
1042
1276
|
this.#rolledHeads.add(head);
|
|
1043
1277
|
let total = 0;
|
|
1044
1278
|
const targets = [];
|
|
1279
|
+
// TUI2-R1 (B): the per-tool parts, in first-call order — the
|
|
1280
|
+
// exploration row's counts and its expanded list both read them.
|
|
1281
|
+
// A search's subject is the PATTERN it looked for (quoted); a
|
|
1282
|
+
// read's or a list's is the path it named.
|
|
1283
|
+
const parts = [];
|
|
1045
1284
|
for (const m of members) {
|
|
1046
1285
|
// the lines count, excluding the tool's OWN truncation note
|
|
1047
1286
|
// (read_file's "… N more lines") — the per-cell meta's rule
|
|
1048
1287
|
const noteAt = m.resultText.lastIndexOf("\n… ");
|
|
1049
1288
|
const shown = noteAt >= 0 ? m.resultText.slice(0, noteAt) : m.resultText;
|
|
1050
|
-
const
|
|
1051
|
-
total +=
|
|
1289
|
+
const rows = shown.split("\n");
|
|
1290
|
+
total += rows[rows.length - 1] === "" ? rows.length - 1 : rows.length;
|
|
1052
1291
|
let input = {};
|
|
1053
1292
|
try {
|
|
1054
1293
|
input = JSON.parse(m.inputFull);
|
|
@@ -1059,11 +1298,19 @@ export class Body {
|
|
|
1059
1298
|
}
|
|
1060
1299
|
const target = toolTarget(m.name, input);
|
|
1061
1300
|
targets.push(target.split("/").pop() ?? target);
|
|
1301
|
+
const subject = m.name === "search_text" ? `"${String(input.pattern ?? "")}"` : target;
|
|
1302
|
+
const part = parts.find((x) => x.name === m.name);
|
|
1303
|
+
if (part === undefined)
|
|
1304
|
+
parts.push({ name: m.name, subjects: [subject] });
|
|
1305
|
+
else
|
|
1306
|
+
part.subjects.push(subject);
|
|
1062
1307
|
}
|
|
1063
1308
|
const first = members[0];
|
|
1064
1309
|
const last = members[members.length - 1];
|
|
1065
1310
|
const elapsed = first.startedAt !== null && last.doneAt !== null ? ((last.doneAt - first.startedAt) / 1000).toFixed(1) : "?";
|
|
1066
|
-
|
|
1311
|
+
// TUI2-R1 (B): `parts` rides ONLY a mixed run — a single-name
|
|
1312
|
+
// run keeps W13's row, byte for byte (the generalization adds).
|
|
1313
|
+
cell.rolled = { count: members.length, lines: total, elapsed, targets, ...(parts.length > 1 ? { parts } : {}) };
|
|
1067
1314
|
return cellComponent(cell).render(W, ctx);
|
|
1068
1315
|
}
|
|
1069
1316
|
// a MEMBER of an already-rolled run → [] (its rows live in the
|
|
@@ -1127,6 +1374,15 @@ export class Body {
|
|
|
1127
1374
|
* occupant knowing the other exists.
|
|
1128
1375
|
*/
|
|
1129
1376
|
#menuRows(W) {
|
|
1377
|
+
// TUI2-R2 ②: the session picker is the band's THIRD occupant and
|
|
1378
|
+
// takes it first. It is modal — it opens before a session exists,
|
|
1379
|
+
// so neither the menu nor the @ picker can be up beside it — and
|
|
1380
|
+
// riding this channel buys it the same geometry every other band
|
|
1381
|
+
// occupant already has: counted in chromeRows, clamped with the
|
|
1382
|
+
// composer, redrawn with the frame.
|
|
1383
|
+
const pick = this.#pickState?.() ?? null;
|
|
1384
|
+
if (pick !== null)
|
|
1385
|
+
return sessionPickerRows(pick, W, Date.now());
|
|
1130
1386
|
const at = this.#atState?.() ?? null;
|
|
1131
1387
|
if (at !== null)
|
|
1132
1388
|
return atPanelRows(at, W);
|
|
@@ -1134,7 +1390,11 @@ export class Body {
|
|
|
1134
1390
|
if (menu === null || menu === undefined || menu.items.length === 0)
|
|
1135
1391
|
return [];
|
|
1136
1392
|
const p = palette();
|
|
1137
|
-
|
|
1393
|
+
// TUI2-R1.5 ⑦(b) (VD-8): the band NAMES itself, the same way the @
|
|
1394
|
+
// picker's does. Both render frameless directly above the composer,
|
|
1395
|
+
// so with scrollback behind them there was nothing to say where the
|
|
1396
|
+
// surface began — the rows read as more history.
|
|
1397
|
+
const rows = [bandHeader("commands", W)];
|
|
1138
1398
|
for (let i = 0; i < menu.items.length; i += 1) {
|
|
1139
1399
|
const item = menu.items[i];
|
|
1140
1400
|
const text = i === menu.selected
|
|
@@ -1268,6 +1528,7 @@ export class Body {
|
|
|
1268
1528
|
* EVERY row is idempotent: N consecutive resizes end with the same
|
|
1269
1529
|
* screen as a single jump to the same size. */
|
|
1270
1530
|
#drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, editor) {
|
|
1531
|
+
const overlay = this.#overlayFrame;
|
|
1271
1532
|
const inputExtra = editor.rows.length - 1; // KC1: the composer's rows above the retired single input row
|
|
1272
1533
|
const committed = this.#committedLinesThisFrame;
|
|
1273
1534
|
// 0. the FROZEN rows — the re-folded committed content (re-flowed
|
|
@@ -1295,7 +1556,15 @@ export class Body {
|
|
|
1295
1556
|
// window (the committed share + the live + the chrome), r
|
|
1296
1557
|
// monotone, every row 1..H re-painted (the V6-1 every-row rule).
|
|
1297
1558
|
const all = [...frozen, ...committed, ...liveLines];
|
|
1298
|
-
|
|
1559
|
+
// TUI2-R1.5 7(a) (VD-8): while the sheet is up the window does NOT
|
|
1560
|
+
// move. skip is frozen at its pre-open value and #lastSkip is left
|
|
1561
|
+
// alone, so no LF is emitted and nothing enters the scrollback; the
|
|
1562
|
+
// march below is clamped to the window instead, which makes the
|
|
1563
|
+
// sheet displace content ON SCREEN. Closing takes the full-redraw
|
|
1564
|
+
// path with the same #lastSkip and every displaced row comes back.
|
|
1565
|
+
const skip = overlay
|
|
1566
|
+
? this.#lastSkip
|
|
1567
|
+
: Math.max(0, all.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
|
|
1299
1568
|
// A8b (the shrink-trigger's completion): the rows that LEAVE the
|
|
1300
1569
|
// window scroll into the terminal's scrollback — the LF mechanism
|
|
1301
1570
|
// (the steady path's own). Only the rows the paint re-covers (the
|
|
@@ -1307,7 +1576,7 @@ export class Body {
|
|
|
1307
1576
|
// shrink EVERY frame) loses the scrolled-away turns from the
|
|
1308
1577
|
// terminal's scrollback entirely (finding #A8b — the queued-flood
|
|
1309
1578
|
// content loss).
|
|
1310
|
-
if (skip > 0) {
|
|
1579
|
+
if (skip > 0 && !overlay) {
|
|
1311
1580
|
const leaving = Math.max(0, skip - this.#lastSkip);
|
|
1312
1581
|
// A8b (the fresh leaving share): a leaving row whose old-screen
|
|
1313
1582
|
// copy is stale — the committed-this-frame lines (their old rows
|
|
@@ -1328,12 +1597,32 @@ export class Body {
|
|
|
1328
1597
|
if (leaving < skip)
|
|
1329
1598
|
out.push(`\x1b[${leaving + 1};1H\x1b[0J`);
|
|
1330
1599
|
out.push(`\x1b[${H};1H`);
|
|
1331
|
-
|
|
1600
|
+
// TUI2-R2pre ② — scroll the rows that LEFT THE WINDOW SINCE THE
|
|
1601
|
+
// LAST FRAME (`leaving`), never `skip`, which is the window's
|
|
1602
|
+
// ABSOLUTE top. Scrolling the absolute top re-pushed the whole
|
|
1603
|
+
// history's worth of rows on EVERY full redraw — and a live-region
|
|
1604
|
+
// shrink takes this path, so that was most frames of a real
|
|
1605
|
+
// session. The ED above had just blanked everything below row
|
|
1606
|
+
// `leaving`, so what those surplus LFs carried into the terminal's
|
|
1607
|
+
// scrollback was blank rows: the large blank bands mid-history of
|
|
1608
|
+
// the owner's field report. The SCREEN never showed it because the
|
|
1609
|
+
// repaint below covers every row 1..H (the V6-1 rule), and the
|
|
1610
|
+
// house emulator drops scrolled rows on the floor — so no gate
|
|
1611
|
+
// could see it either. Measured on the 5-turn 80x24 repro: the
|
|
1612
|
+
// scrollback went from 162 blank rows of 168 to 14 of 52.
|
|
1613
|
+
for (let i = 0; i < leaving; i += 1)
|
|
1332
1614
|
out.push("\n");
|
|
1333
1615
|
}
|
|
1334
|
-
|
|
1616
|
+
if (!overlay)
|
|
1617
|
+
this.#lastSkip = skip;
|
|
1335
1618
|
let r = 1;
|
|
1336
|
-
|
|
1619
|
+
// the window's content rows: everything above the chrome. With the
|
|
1620
|
+
// overlay up `all` can exceed it, and the rows that give way are the
|
|
1621
|
+
// OLDEST on screen — they are still in the model and come back on
|
|
1622
|
+
// the close.
|
|
1623
|
+
const contentRows = Math.max(0, H - CHROME_ROWS - inputExtra - queueRows.length - menuRows.length);
|
|
1624
|
+
const march = all.slice(skip);
|
|
1625
|
+
for (const line of march.length > contentRows ? march.slice(march.length - contentRows) : march) {
|
|
1337
1626
|
out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
|
|
1338
1627
|
r += 1;
|
|
1339
1628
|
}
|
|
@@ -1367,8 +1656,7 @@ export class Body {
|
|
|
1367
1656
|
// retired (the CHA is absolute — the base is irrelevant; the
|
|
1368
1657
|
// CUB's base was the LAST write's end column, which the steady
|
|
1369
1658
|
// frame's ELs leave at col 1 — the A3 finding)
|
|
1370
|
-
out
|
|
1371
|
-
out.push(`\x1b[${editor.markerCol}G`);
|
|
1659
|
+
this.#parkCursor(out, H, H - 2 - inputExtra + editor.markerRow, editor.markerCol);
|
|
1372
1660
|
}
|
|
1373
1661
|
/** The steady-state frame — RELATIVE moves only (invariant ②); the
|
|
1374
1662
|
* commits scroll via the CUP-free real LF at the last row, and the
|
|
@@ -1384,8 +1672,12 @@ export class Body {
|
|
|
1384
1672
|
// frozenCount, so the lines at [frozenCount..skip−1] are the fresh
|
|
1385
1673
|
// leaving share (their old-screen copies are stale).
|
|
1386
1674
|
const frozenCount = this.#committedLines - committed.length;
|
|
1387
|
-
|
|
1388
|
-
|
|
1675
|
+
// TUI2-R1.5 7(a): an overlay frame never moves the window (see
|
|
1676
|
+
// #drawFull) — the sheet's rows displace content on screen instead
|
|
1677
|
+
// of pushing it into the scrollback.
|
|
1678
|
+
const overlay = this.#overlayFrame;
|
|
1679
|
+
const skip = overlay ? this.#lastSkip : Math.max(0, this.#committedLines + liveLines.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
|
|
1680
|
+
const leaving = overlay ? 0 : Math.max(0, skip - this.#lastSkip);
|
|
1389
1681
|
// the jump to the bottom row H, then N real LFs scroll the screen
|
|
1390
1682
|
// exactly N rows — ONE per committed line (the bookkeeping; the
|
|
1391
1683
|
// stale 1B anchor jumped to H−1 and the N LFs scrolled only N−1 —
|
|
@@ -1440,6 +1732,16 @@ export class Body {
|
|
|
1440
1732
|
if (H > anchorRow)
|
|
1441
1733
|
out.push(`\x1b[${H - anchorRow}B`);
|
|
1442
1734
|
}
|
|
1735
|
+
// TUI2-R2pre ②: this count is the COMMIT count on purpose, and it
|
|
1736
|
+
// stays. It reads like the same mistake the full path made, but the
|
|
1737
|
+
// two paths are doing different jobs: the full path REPAINTS every
|
|
1738
|
+
// row, so anything it scrolls is a duplicate of what it is about to
|
|
1739
|
+
// draw; the steady path does not repaint the frozen band, and the
|
|
1740
|
+
// rows it scrolls carry the PRE-FRAME live copies of the cells that
|
|
1741
|
+
// just committed — the A7 single-copy discipline (the old live band
|
|
1742
|
+
// is EL'd first, so the repaint below is the only copy left). Making
|
|
1743
|
+
// this `leaving` was measured: the A7 gate fails at 40x24 frame 106
|
|
1744
|
+
// with the greeting duplicated in the terminal.
|
|
1443
1745
|
for (let i = 0; i < committed.length; i += 1)
|
|
1444
1746
|
out.push("\n");
|
|
1445
1747
|
// the bottom-up repaint, from the last row up — V6-3 + W6 + KC1:
|
|
@@ -1486,6 +1788,11 @@ export class Body {
|
|
|
1486
1788
|
}
|
|
1487
1789
|
// 2. the STALE rows above the committed section — the scrolled old
|
|
1488
1790
|
// live copies (a live-drawn cell's pre-commit position): EL.
|
|
1791
|
+
// TUI2-R2pre ②: the old band's POST-SCROLL origin shifted up by
|
|
1792
|
+
// the rows that actually left — `leaving`. It read the commit
|
|
1793
|
+
// count only because the scroll above used to BE the commit
|
|
1794
|
+
// count; with the two decoupled, the old expression erases rows
|
|
1795
|
+
// of frozen content that never moved.
|
|
1489
1796
|
const staleFrom = Math.max(1, this.#lastLiveTop - committed.length);
|
|
1490
1797
|
for (let r = staleFrom; r < liveTop - committed.length; r += 1) {
|
|
1491
1798
|
out.push(`\x1b[${r};1H\x1b[0K`);
|
|
@@ -1522,21 +1829,47 @@ export class Body {
|
|
|
1522
1829
|
: H - 3 - inputExtra;
|
|
1523
1830
|
// the anchor: the MARKER'S row inside the composer (N = 1,
|
|
1524
1831
|
// markerRow 0 ⇒ the retired H−2)
|
|
1525
|
-
|
|
1526
|
-
if (down > 0)
|
|
1527
|
-
out.push(`\x1b[${down}B`);
|
|
1528
|
-
// W23: the CHA to the frame-derived column — the cursor rests AT
|
|
1529
|
-
// the marker from ANY base (the retired afterW CUB clamped at col
|
|
1530
|
-
// 1 — the steady frame's LAST write is the gap/stale EL: the A3
|
|
1531
|
-
// finding; the A5/A8 live lines end mid-row, the ELs at col 1 —
|
|
1532
|
-
// the CHA ignores the base by construction)
|
|
1533
|
-
out.push(`\x1b[${editor.markerCol}G`);
|
|
1832
|
+
this.#parkCursor(out, lastRow, H - 2 - inputExtra + editor.markerRow, editor.markerCol);
|
|
1534
1833
|
// A8b: the steady path moves the window too (the scroll + the
|
|
1535
1834
|
// repaint) — record its top so the next full-redraw's leaving count
|
|
1536
1835
|
// is the rows the window dropped since the last frame, whatever the
|
|
1537
1836
|
// path of the frames between (same formula as `skip` above).
|
|
1538
1837
|
this.#lastSkip = skip;
|
|
1539
1838
|
}
|
|
1839
|
+
/**
|
|
1840
|
+
* TUI2-R2 ⑤ (the R1.5 parked ⑩) — CURSOR AUTHORITY: the ONE frame-tail
|
|
1841
|
+
* positioning sequence, and the compositor's alone.
|
|
1842
|
+
*
|
|
1843
|
+
* Both draw paths ended with their own hand-rolled park — the full
|
|
1844
|
+
* path counting rows up from the status line, the steady path counting
|
|
1845
|
+
* down from a six-branch re-derivation of which write happened to be
|
|
1846
|
+
* last. Two implementations of one contract, each re-deriving byte
|
|
1847
|
+
* order the drawing code already knew, and the walkthrough found the
|
|
1848
|
+
* consequence three times over (the cursor resting in the status
|
|
1849
|
+
* line's "de▮ault", at the end of streamed text, inside an approval
|
|
1850
|
+
* panel's rule row). A terminal cursor is the product's claim about
|
|
1851
|
+
* where the next keystroke lands; a claim made in two places is a
|
|
1852
|
+
* claim that will eventually disagree with itself.
|
|
1853
|
+
*
|
|
1854
|
+
* One owner, one sequence: a single vertical move to the marker's row
|
|
1855
|
+
* — in EITHER direction, which the steady path could not do (its move
|
|
1856
|
+
* was `if (down > 0)`, so a cursor left BELOW the composer simply
|
|
1857
|
+
* stayed there) — then the CHA to the frame-derived column.
|
|
1858
|
+
*
|
|
1859
|
+
* Relative, not a CUP, and deliberately: invariant ② reserves absolute
|
|
1860
|
+
* addressing for the content area, and the composer is chrome. The
|
|
1861
|
+
* CHA is absolute in the COLUMN only, which is what makes the park
|
|
1862
|
+
* independent of wherever the last write ended (the A3 finding: the
|
|
1863
|
+
* retired CUB's base was the gap EL's column 1, left of the lead).
|
|
1864
|
+
*/
|
|
1865
|
+
#parkCursor(out, fromRow, toRow, col) {
|
|
1866
|
+
const delta = toRow - fromRow;
|
|
1867
|
+
if (delta > 0)
|
|
1868
|
+
out.push(`\x1b[${delta}B`);
|
|
1869
|
+
else if (delta < 0)
|
|
1870
|
+
out.push(`\x1b[${-delta}A`);
|
|
1871
|
+
out.push(`\x1b[${col}G`);
|
|
1872
|
+
}
|
|
1540
1873
|
/** Invariant ①: every emitted line fits the width — a violation is a
|
|
1541
1874
|
* CRASH with the diagnostic, never a silent truncate. */
|
|
1542
1875
|
#checked(line, W) {
|
|
@@ -1605,6 +1938,15 @@ export class Dock {
|
|
|
1605
1938
|
}
|
|
1606
1939
|
compositorRef.bindApproval(state);
|
|
1607
1940
|
}
|
|
1941
|
+
/** TUI2-R1 (D): bind the editor's keys-sheet flag — the slot read for
|
|
1942
|
+
* the ? overlay (the menu/picker binding pattern). */
|
|
1943
|
+
bindSheet(state) {
|
|
1944
|
+
if (compositorRef === null) {
|
|
1945
|
+
dockBindings.sheet = state;
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
compositorRef.bindSheet(state);
|
|
1949
|
+
}
|
|
1608
1950
|
bindInput(state, prompt) {
|
|
1609
1951
|
if (compositorRef === null) {
|
|
1610
1952
|
dockBindings.state = state; // the live buffer — order-agnostic
|
|
@@ -1630,6 +1972,16 @@ export class Dock {
|
|
|
1630
1972
|
}
|
|
1631
1973
|
compositorRef.bindAt(state);
|
|
1632
1974
|
}
|
|
1975
|
+
/** TUI2-R2 ②: bind the editor's session picker — the band's third
|
|
1976
|
+
* occupant. Unbound (every path but bare `kiso resume`), the picker
|
|
1977
|
+
* cannot render and every frame is byte-identical to before. */
|
|
1978
|
+
bindPick(state) {
|
|
1979
|
+
if (compositorRef === null) {
|
|
1980
|
+
dockBindings.pick = state;
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
compositorRef.bindPick(state);
|
|
1984
|
+
}
|
|
1633
1985
|
/** W22: bind the pending-turn queue — the chips + the +N queued
|
|
1634
1986
|
* hint (the CLI binds it from chat(); the editor's pop keys ride
|
|
1635
1987
|
* the LineInput's own bindQueue). */
|
|
@@ -1655,4 +2007,4 @@ let compositorRef = null;
|
|
|
1655
2007
|
* — the old snapshot froze `menu` at bindInput time and the slash-
|
|
1656
2008
|
* command menu silently never bound in the real CLI (the e2e gates
|
|
1657
2009
|
* bind the Body directly and could not see it). */
|
|
1658
|
-
const dockBindings = { state: null, prompt: "", menu: null, at: null, panel: null, queue: null };
|
|
2010
|
+
const dockBindings = { state: null, prompt: "", menu: null, at: null, pick: null, panel: null, sheet: null, queue: null };
|