@vincemakes/kiso-tui 0.9.0 → 0.11.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 +22 -11
- package/dist/compositor.d.ts +8 -9
- package/dist/compositor.js +274 -44
- package/dist/editor.d.ts +8 -1
- package/dist/editor.js +266 -17
- package/dist/index.d.ts +4 -3
- package/dist/index.js +9 -3
- package/dist/session-picker.d.ts +113 -0
- package/dist/session-picker.js +249 -0
- package/package.json +2 -2
package/dist/compositor.js
CHANGED
|
@@ -49,9 +49,11 @@ import { leadWidth } from "./width.js"; // W23: the ONE width authority (the edi
|
|
|
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
51
|
import { atPanelRows, bandHeader } from "./at-picker.js";
|
|
52
|
-
|
|
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, MdStream, 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";
|
|
54
|
-
import { keysSheetRows } from "./strings.js";
|
|
56
|
+
import { displayVerb, keysSheetRows } from "./strings.js";
|
|
55
57
|
/** The cursor marker — an APC private sequence the focus component
|
|
56
58
|
* embeds at the edit position; the compositor strips it and moves
|
|
57
59
|
* relatively (it never reaches the terminal). */
|
|
@@ -94,6 +96,11 @@ export class Body {
|
|
|
94
96
|
#lastTool = null;
|
|
95
97
|
#pendingCalls = new Map();
|
|
96
98
|
#pipeBuf = ""; // the passthrough's thinking buffer
|
|
99
|
+
/** TUI2-MD ⑤ — the markdown scanner of the message currently
|
|
100
|
+
* streaming, and the cell index its first block landed at. Null
|
|
101
|
+
* between messages: the scanner's life is one assistant message. */
|
|
102
|
+
#md = null;
|
|
103
|
+
#mdBase = 0;
|
|
97
104
|
#toolCells = new Map(); // callId → cell index (parallel tools)
|
|
98
105
|
// W15: the collapsed (cut) tool cells — committed cells whose last
|
|
99
106
|
// rendered row carried the "ctrl+r" affordance; the expand key's
|
|
@@ -134,6 +141,9 @@ export class Body {
|
|
|
134
141
|
// KC3 §4: the @ picker's bound state — the SAME band as the menu
|
|
135
142
|
// (see #menuRows: the two are mutually exclusive by construction).
|
|
136
143
|
#atState = null;
|
|
144
|
+
// TUI2-R2 ②: the session picker's bound state — the same band again
|
|
145
|
+
// (see #menuRows), and modal, so it takes the band first.
|
|
146
|
+
#pickState = null;
|
|
137
147
|
// W22: the pending-turn queue's bound state — the CLI's live slots
|
|
138
148
|
// (chat.ts); the chips render in the menu-rows family (above the
|
|
139
149
|
// box top), the live caps shrink by their rows, and the status
|
|
@@ -146,6 +156,16 @@ export class Body {
|
|
|
146
156
|
// v6: the single writer — the compositor IS the dock; the CLI's
|
|
147
157
|
// onDock callback (which used to re-pin the dock after a scroll)
|
|
148
158
|
// is retired with the split.
|
|
159
|
+
// TUI2-R2pre ③: taking the ref SUPERSEDES whatever held it, so the
|
|
160
|
+
// outgoing compositor's resize listener comes off here. It is not
|
|
161
|
+
// only listener hygiene: every Dock call now reaches THIS instance,
|
|
162
|
+
// so a resize heard by the old one would have a compositor that owns
|
|
163
|
+
// no part of the screen paint a full redraw over it.
|
|
164
|
+
// (an explicit null check, not `?.#` — TS18030: an optional chain
|
|
165
|
+
// cannot contain a private identifier, and vitest transpiles without
|
|
166
|
+
// type-checking, so only `npm run typecheck` sees the difference)
|
|
167
|
+
if (compositorRef !== null)
|
|
168
|
+
compositorRef.#detachResize();
|
|
149
169
|
compositorRef = this;
|
|
150
170
|
// the Dock façade's bindings may arrive BEFORE this construction
|
|
151
171
|
// (the CLI binds the editor state in makeLineInput, then constructs
|
|
@@ -162,6 +182,8 @@ export class Body {
|
|
|
162
182
|
this.#menuState = dockBindings.menu;
|
|
163
183
|
if (dockBindings.at !== null)
|
|
164
184
|
this.#atState = dockBindings.at;
|
|
185
|
+
if (dockBindings.pick !== null)
|
|
186
|
+
this.#pickState = dockBindings.pick;
|
|
165
187
|
this.#panelState = dockBindings.panel;
|
|
166
188
|
this.#sheetState = dockBindings.sheet;
|
|
167
189
|
if (dockBindings.queue !== null)
|
|
@@ -381,22 +403,71 @@ export class Body {
|
|
|
381
403
|
const turn = this.#turns[this.#turns.length - 1];
|
|
382
404
|
if (turn !== undefined)
|
|
383
405
|
turn.hasText = true;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
406
|
+
// TUI2-MD ⑤: assistant body text is MARKDOWN, scanned as it
|
|
407
|
+
// streams. The scanner yields CLOSED blocks (final source, final
|
|
408
|
+
// render) and one OPEN tail block; each becomes a cell, and the
|
|
409
|
+
// cell is the commit unit the compositor already had — so
|
|
410
|
+
// block-freeze needs no new commit machinery at all. A closed
|
|
411
|
+
// block is a DONE cell the natural loop freezes; the tail is the
|
|
412
|
+
// one cell left live, repainting in place.
|
|
413
|
+
if (this.#md === null) {
|
|
389
414
|
this.#closeOpenThinking();
|
|
390
415
|
this.#closeOpenText();
|
|
391
|
-
this.#
|
|
416
|
+
this.#md = new MdStream();
|
|
417
|
+
this.#mdBase = this.#cells.length;
|
|
392
418
|
}
|
|
419
|
+
this.#md.push(text);
|
|
420
|
+
this.#syncMd();
|
|
393
421
|
this.#mark();
|
|
394
422
|
}
|
|
423
|
+
/** TUI2-MD ⑤ — mirror the scanner's blocks onto cells. Append-only by
|
|
424
|
+
* construction: a block that has closed never changes, so a cell that
|
|
425
|
+
* is done is never touched again (and a committed one could not be).
|
|
426
|
+
* Only the trailing tail cell is rewritten per delta. */
|
|
427
|
+
#syncMd() {
|
|
428
|
+
if (this.#md === null)
|
|
429
|
+
return;
|
|
430
|
+
const blocks = this.#md.blocks();
|
|
431
|
+
const closed = this.#md.closed();
|
|
432
|
+
for (let i = 0; i < blocks.length; i += 1) {
|
|
433
|
+
const at = this.#mdBase + i;
|
|
434
|
+
const cell = this.#cells[at];
|
|
435
|
+
if (cell === undefined) {
|
|
436
|
+
this.#cells.push({ kind: "md", block: blocks[i], done: i < closed });
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (cell.kind !== "md" || cell.done)
|
|
440
|
+
continue;
|
|
441
|
+
cell.block = blocks[i];
|
|
442
|
+
cell.done = i < closed;
|
|
443
|
+
}
|
|
444
|
+
// the tail can vanish (a lone whitespace delta that turns out to be
|
|
445
|
+
// a blank line). Drop it only where dropping is safe: never below
|
|
446
|
+
// the commit frontier, where the bytes are already the terminal's.
|
|
447
|
+
const want = this.#mdBase + blocks.length;
|
|
448
|
+
while (this.#cells.length > Math.max(want, this.#committed) && this.#cells[this.#cells.length - 1].kind === "md")
|
|
449
|
+
this.#cells.pop();
|
|
450
|
+
}
|
|
451
|
+
/** TUI2-MD ⑤ — the message ends: the tail block closes and every md
|
|
452
|
+
* cell of this message is final. */
|
|
453
|
+
#endMd() {
|
|
454
|
+
if (this.#md === null)
|
|
455
|
+
return;
|
|
456
|
+
this.#md.end();
|
|
457
|
+
this.#syncMd();
|
|
458
|
+
for (let i = this.#mdBase; i < this.#cells.length; i += 1) {
|
|
459
|
+
const cell = this.#cells[i];
|
|
460
|
+
if (cell.kind === "md")
|
|
461
|
+
cell.done = true;
|
|
462
|
+
}
|
|
463
|
+
this.#md = null;
|
|
464
|
+
}
|
|
395
465
|
textEnd() {
|
|
396
466
|
if (!this.#isActive()) {
|
|
397
467
|
this.#write("\n");
|
|
398
468
|
return;
|
|
399
469
|
}
|
|
470
|
+
this.#endMd();
|
|
400
471
|
const last = this.#cells[this.#cells.length - 1];
|
|
401
472
|
if (last !== undefined && last.kind === "text" && !last.done)
|
|
402
473
|
last.done = true;
|
|
@@ -570,6 +641,30 @@ export class Body {
|
|
|
570
641
|
* cell: the pointer cycles the collapsed history, newest first, and
|
|
571
642
|
* the header names the target ("N turns back" — the user cells
|
|
572
643
|
* after it), so every press tells the user what they got. */
|
|
644
|
+
/**
|
|
645
|
+
* TUI2-R2 ⑤ — the cell the next ctrl+r will act on, or -1.
|
|
646
|
+
*
|
|
647
|
+
* The rule is expandNext's own first loop, extracted verbatim: the
|
|
648
|
+
* LAST live cell that can toggle. It is a separate method rather than
|
|
649
|
+
* a shared constant because the marker and the key must not merely
|
|
650
|
+
* agree today — the marker is a PROMISE about what the key will do,
|
|
651
|
+
* and the only way to keep it is to derive it from the same scan.
|
|
652
|
+
*
|
|
653
|
+
* The committed fallback (the #collapsed ring) is deliberately NOT
|
|
654
|
+
* marked: those rows are frozen history, never re-emitted, so a tint
|
|
655
|
+
* on them could not be moved when the pointer advances. A live target
|
|
656
|
+
* is the one the marker can tell the truth about.
|
|
657
|
+
*/
|
|
658
|
+
#focusIndex() {
|
|
659
|
+
for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
|
|
660
|
+
const cell = this.#cells[i];
|
|
661
|
+
if (cell.kind === "tool" && cell.state !== "pending")
|
|
662
|
+
return i;
|
|
663
|
+
if (cell.kind === "checklist" && !cell.done)
|
|
664
|
+
return i;
|
|
665
|
+
}
|
|
666
|
+
return -1;
|
|
667
|
+
}
|
|
573
668
|
expandNext() {
|
|
574
669
|
for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
|
|
575
670
|
const cell = this.#cells[i];
|
|
@@ -612,7 +707,7 @@ export class Body {
|
|
|
612
707
|
return { kind: "appended", lines: [header, ...exploreRows(cell.rolled.parts, this.#opts.width())] };
|
|
613
708
|
}
|
|
614
709
|
const noun = ROLLUP_NOUN[cell.name] ?? "calls";
|
|
615
|
-
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name
|
|
710
|
+
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${displayVerb(cell.name)} ${cell.rolled.count} ${noun}`)} · ${back}`;
|
|
616
711
|
return {
|
|
617
712
|
kind: "appended",
|
|
618
713
|
lines: [header, ...cell.rolled.targets.map((t) => ` ${p.dim}└ ${escapeTerminal(t)}${p.reset}`)],
|
|
@@ -628,14 +723,16 @@ export class Body {
|
|
|
628
723
|
}
|
|
629
724
|
const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
|
|
630
725
|
const p = palette();
|
|
631
|
-
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name
|
|
726
|
+
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${displayVerb(cell.name)} ${toolTarget(cell.name, input)}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
|
|
632
727
|
return {
|
|
633
728
|
kind: "appended",
|
|
634
729
|
lines: [
|
|
635
730
|
header,
|
|
636
|
-
|
|
731
|
+
// TUI2-R2pre ④: the SECTION HEADERS say the act; the payloads
|
|
732
|
+
// below them (inputFull, resultText) are RAW and byte-identical.
|
|
733
|
+
`--- ${displayVerb(cell.name)} input ---`,
|
|
637
734
|
cell.inputFull,
|
|
638
|
-
`--- ${cell.name} output${cell.isError ? " (error)" : ""} ---`,
|
|
735
|
+
`--- ${displayVerb(cell.name)} output${cell.isError ? " (error)" : ""} ---`,
|
|
639
736
|
cell.resultText,
|
|
640
737
|
],
|
|
641
738
|
};
|
|
@@ -645,13 +742,29 @@ export class Body {
|
|
|
645
742
|
get active() {
|
|
646
743
|
return this.#docked && this.#isActive();
|
|
647
744
|
}
|
|
745
|
+
/** TUI2-R2pre ③ — the ONE place a resize listener is installed, and it
|
|
746
|
+
* removes the previous one first. `process.stdout` is process-wide and
|
|
747
|
+
* its listeners outlive the object that added them, so "add" without
|
|
748
|
+
* "remove first" is a leak by construction: the old closure is
|
|
749
|
+
* unreachable the moment #resizeHandler is overwritten, and not even
|
|
750
|
+
* exit() can take it off. */
|
|
751
|
+
#attachResize() {
|
|
752
|
+
this.#detachResize();
|
|
753
|
+
this.#resizeHandler = () => this.onResize();
|
|
754
|
+
process.stdout.on("resize", this.#resizeHandler);
|
|
755
|
+
}
|
|
756
|
+
#detachResize() {
|
|
757
|
+
if (this.#resizeHandler === null)
|
|
758
|
+
return;
|
|
759
|
+
process.stdout.off("resize", this.#resizeHandler);
|
|
760
|
+
this.#resizeHandler = null;
|
|
761
|
+
}
|
|
648
762
|
enter() {
|
|
649
763
|
const rows = process.stdout.rows ?? 0;
|
|
650
764
|
if (process.stdout.isTTY !== true || palette().bold === "" || rows < 4)
|
|
651
765
|
return;
|
|
652
766
|
this.#docked = true;
|
|
653
|
-
this.#
|
|
654
|
-
process.stdout.on("resize", this.#resizeHandler);
|
|
767
|
+
this.#attachResize();
|
|
655
768
|
this.#fullRedraw = true;
|
|
656
769
|
this.#dirty = true;
|
|
657
770
|
this.render(); // the FIRST frame — the full-redraw path, no pre-clear
|
|
@@ -659,13 +772,16 @@ export class Body {
|
|
|
659
772
|
/** Teardown — CSI r (the "no broken terminal" contract byte), the
|
|
660
773
|
* chrome rows cleared, the cursor home at the input line. */
|
|
661
774
|
exit() {
|
|
662
|
-
if (!this.#docked)
|
|
775
|
+
if (!this.#docked) {
|
|
776
|
+
// TUI2-R2pre ③: an un-docked compositor can still hold a listener
|
|
777
|
+
// (it was superseded, or enter() ran and the dock was torn down by
|
|
778
|
+
// another path) — the teardown is unconditional, the CHROME clear
|
|
779
|
+
// below is not.
|
|
780
|
+
this.#detachResize();
|
|
663
781
|
return;
|
|
664
|
-
this.#docked = false;
|
|
665
|
-
if (this.#resizeHandler !== null) {
|
|
666
|
-
process.stdout.off("resize", this.#resizeHandler);
|
|
667
|
-
this.#resizeHandler = null;
|
|
668
782
|
}
|
|
783
|
+
this.#docked = false;
|
|
784
|
+
this.#detachResize();
|
|
669
785
|
const H = this.#lastH > 0 ? this.#lastH : process.stdout.rows ?? 24;
|
|
670
786
|
const out = [];
|
|
671
787
|
out.push("\x1b[r");
|
|
@@ -750,6 +866,11 @@ export class Body {
|
|
|
750
866
|
bindAt(state) {
|
|
751
867
|
this.#atState = state;
|
|
752
868
|
}
|
|
869
|
+
/** TUI2-R2 ②: bind the editor's session picker — the band's third
|
|
870
|
+
* occupant (see #menuRows for why they share one). */
|
|
871
|
+
bindPick(state) {
|
|
872
|
+
this.#pickState = state;
|
|
873
|
+
}
|
|
753
874
|
/** Bind the pending-turn queue — the CLI's live slots (chat.ts):
|
|
754
875
|
* the chips render in the menu-rows family, the live caps shrink
|
|
755
876
|
* by their rows, and the +N queued hint rides the status row. */
|
|
@@ -856,14 +977,13 @@ export class Body {
|
|
|
856
977
|
inputExtra +
|
|
857
978
|
queueRows.length);
|
|
858
979
|
}
|
|
859
|
-
const live = this.#cells.slice(this.#committed);
|
|
860
980
|
const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
|
|
861
981
|
const W = this.#opts.width();
|
|
862
982
|
let lines = 0;
|
|
863
983
|
let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
|
|
864
|
-
for (
|
|
865
|
-
const rows = cellComponent(
|
|
866
|
-
lines +=
|
|
984
|
+
for (let i = this.#committed; i < this.#cells.length; i += 1) {
|
|
985
|
+
const rows = cellComponent(this.#cells[i]).render(W, ctx);
|
|
986
|
+
lines += this.#space(i, prev, rows).length;
|
|
867
987
|
prev = rows;
|
|
868
988
|
}
|
|
869
989
|
return lines + CHROME_ROWS + inputExtra + this.#menuRows(W).length + queueRows.length;
|
|
@@ -896,7 +1016,7 @@ export class Body {
|
|
|
896
1016
|
const cell = this.#cells[i];
|
|
897
1017
|
const lines = cellComponent(cell).render(W, ctx);
|
|
898
1018
|
this.#lineCache[i] = lines; // the cell's OWN rows — the cache stays raw
|
|
899
|
-
this.#committedLines +=
|
|
1019
|
+
this.#committedLines += this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines).length;
|
|
900
1020
|
}
|
|
901
1021
|
}
|
|
902
1022
|
// 1. the natural commits — the leading DONE cells freeze: their
|
|
@@ -960,10 +1080,22 @@ export class Body {
|
|
|
960
1080
|
liveLines = panelRowsOf(panel, W, Math.max(1, H - 4 - inputExtra - queueRows.length));
|
|
961
1081
|
}
|
|
962
1082
|
else {
|
|
1083
|
+
// TUI2-R2 ⑤ (D, candidate 1): the FOCUS — the cell the next ctrl+r
|
|
1084
|
+
// will act on brightens its own token. The index is derived from
|
|
1085
|
+
// the SAME scan expandNext performs (#focusIndex shares its rule
|
|
1086
|
+
// by construction), so the marker can never point at a cell the
|
|
1087
|
+
// key would not take — which is the only way a focus marker is
|
|
1088
|
+
// worth having.
|
|
1089
|
+
const focus = this.#focusIndex();
|
|
963
1090
|
let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
|
|
964
|
-
for (
|
|
1091
|
+
for (let i = this.#committed; i < this.#cells.length; i += 1) {
|
|
1092
|
+
const cell = this.#cells[i];
|
|
965
1093
|
const rows = cellComponent(cell).render(W, ctx);
|
|
966
|
-
|
|
1094
|
+
// the head row carries the affordance; the tint lands on it and
|
|
1095
|
+
// nowhere else, which is what makes "exactly one" structural
|
|
1096
|
+
if (i === focus && rows.length > 0)
|
|
1097
|
+
rows[0] = focusToken(rows[0], W);
|
|
1098
|
+
liveLines.push(...this.#space(i, prev, rows));
|
|
967
1099
|
prev = rows;
|
|
968
1100
|
}
|
|
969
1101
|
}
|
|
@@ -975,10 +1107,16 @@ export class Body {
|
|
|
975
1107
|
this.#commitCell(this.#committed, W, ctx);
|
|
976
1108
|
liveLines = [];
|
|
977
1109
|
{
|
|
1110
|
+
// TUI2-R2 ⑤: the focus re-derives after a commit — the cell it
|
|
1111
|
+
// pointed at may have just left the live region
|
|
1112
|
+
const focus = this.#focusIndex();
|
|
978
1113
|
let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
|
|
979
|
-
for (
|
|
1114
|
+
for (let i = this.#committed; i < this.#cells.length; i += 1) {
|
|
1115
|
+
const cell = this.#cells[i];
|
|
980
1116
|
const rows = cellComponent(cell).render(W, ctx);
|
|
981
|
-
|
|
1117
|
+
if (i === focus && rows.length > 0)
|
|
1118
|
+
rows[0] = focusToken(rows[0], W);
|
|
1119
|
+
liveLines.push(...this.#space(i, prev, rows));
|
|
982
1120
|
prev = rows;
|
|
983
1121
|
}
|
|
984
1122
|
}
|
|
@@ -1021,6 +1159,23 @@ export class Body {
|
|
|
1021
1159
|
// composer (N = 1 ⇒ H−2, the retired hard-coded anchor).
|
|
1022
1160
|
this.#lastAnchorRow = H - 1 - editor.rows.length + editor.markerRow;
|
|
1023
1161
|
}
|
|
1162
|
+
/** TUI2-MD ⑤ — the join blank between cell i−1 and cell i.
|
|
1163
|
+
*
|
|
1164
|
+
* W11's formula ("a blank above a row that is itself a block, or
|
|
1165
|
+
* whose previous sibling was taller than one row") reads ROW COUNTS,
|
|
1166
|
+
* and markdown's rhythm is not a row count: a heading wants a blank
|
|
1167
|
+
* above and below it even between two one-row paragraphs, and two
|
|
1168
|
+
* rows of one fence want none even when a long code line folds to
|
|
1169
|
+
* two. So between two MARKDOWN cells the formula steps aside and the
|
|
1170
|
+
* block's own `gap` decides — the renderer owns the rhythm, which is
|
|
1171
|
+
* the only place that knows it. Every other pair is untouched,
|
|
1172
|
+
* including the boundary INTO a markdown message (the blank under the
|
|
1173
|
+
* user chip is still W11's). */
|
|
1174
|
+
#space(i, prev, rows) {
|
|
1175
|
+
if (i > 0 && this.#cells[i]?.kind === "md" && this.#cells[i - 1]?.kind === "md")
|
|
1176
|
+
return rows;
|
|
1177
|
+
return bodySpacing(prev, rows);
|
|
1178
|
+
}
|
|
1024
1179
|
/** Commit the cell at index i: render + cache its lines (immutable —
|
|
1025
1180
|
* the force-committed form freezes at the current render), advance
|
|
1026
1181
|
* the bookkeeping — and collect the lines for this frame's writes.
|
|
@@ -1043,7 +1198,7 @@ export class Body {
|
|
|
1043
1198
|
if (cell.kind === "tool" && lines.some((l) => l.includes("ctrl+r")))
|
|
1044
1199
|
this.#collapsed.unshift(i);
|
|
1045
1200
|
this.#lineCache[i] = lines;
|
|
1046
|
-
const placed =
|
|
1201
|
+
const placed = this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines);
|
|
1047
1202
|
this.#committed += 1;
|
|
1048
1203
|
this.#committedLines += placed.length;
|
|
1049
1204
|
this.#committedLinesThisFrame.push(...placed);
|
|
@@ -1289,6 +1444,15 @@ export class Body {
|
|
|
1289
1444
|
* occupant knowing the other exists.
|
|
1290
1445
|
*/
|
|
1291
1446
|
#menuRows(W) {
|
|
1447
|
+
// TUI2-R2 ②: the session picker is the band's THIRD occupant and
|
|
1448
|
+
// takes it first. It is modal — it opens before a session exists,
|
|
1449
|
+
// so neither the menu nor the @ picker can be up beside it — and
|
|
1450
|
+
// riding this channel buys it the same geometry every other band
|
|
1451
|
+
// occupant already has: counted in chromeRows, clamped with the
|
|
1452
|
+
// composer, redrawn with the frame.
|
|
1453
|
+
const pick = this.#pickState?.() ?? null;
|
|
1454
|
+
if (pick !== null)
|
|
1455
|
+
return sessionPickerRows(pick, W, Date.now());
|
|
1292
1456
|
const at = this.#atState?.() ?? null;
|
|
1293
1457
|
if (at !== null)
|
|
1294
1458
|
return atPanelRows(at, W);
|
|
@@ -1450,7 +1614,7 @@ export class Body {
|
|
|
1450
1614
|
// cell (the V6-1 frozen-loop finding — the banner vanished).
|
|
1451
1615
|
const frozen = [];
|
|
1452
1616
|
for (let i = 0; i < this.#committedAtFrameStart; i += 1) {
|
|
1453
|
-
frozen.push(...
|
|
1617
|
+
frozen.push(...this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, this.#lineCache[i]));
|
|
1454
1618
|
}
|
|
1455
1619
|
// A8: the march is the WINDOW — the model's last H rows. When the
|
|
1456
1620
|
// model total (committed + live + chrome) exceeds H, the window's
|
|
@@ -1503,7 +1667,20 @@ export class Body {
|
|
|
1503
1667
|
if (leaving < skip)
|
|
1504
1668
|
out.push(`\x1b[${leaving + 1};1H\x1b[0J`);
|
|
1505
1669
|
out.push(`\x1b[${H};1H`);
|
|
1506
|
-
|
|
1670
|
+
// TUI2-R2pre ② — scroll the rows that LEFT THE WINDOW SINCE THE
|
|
1671
|
+
// LAST FRAME (`leaving`), never `skip`, which is the window's
|
|
1672
|
+
// ABSOLUTE top. Scrolling the absolute top re-pushed the whole
|
|
1673
|
+
// history's worth of rows on EVERY full redraw — and a live-region
|
|
1674
|
+
// shrink takes this path, so that was most frames of a real
|
|
1675
|
+
// session. The ED above had just blanked everything below row
|
|
1676
|
+
// `leaving`, so what those surplus LFs carried into the terminal's
|
|
1677
|
+
// scrollback was blank rows: the large blank bands mid-history of
|
|
1678
|
+
// the owner's field report. The SCREEN never showed it because the
|
|
1679
|
+
// repaint below covers every row 1..H (the V6-1 rule), and the
|
|
1680
|
+
// house emulator drops scrolled rows on the floor — so no gate
|
|
1681
|
+
// could see it either. Measured on the 5-turn 80x24 repro: the
|
|
1682
|
+
// scrollback went from 162 blank rows of 168 to 14 of 52.
|
|
1683
|
+
for (let i = 0; i < leaving; i += 1)
|
|
1507
1684
|
out.push("\n");
|
|
1508
1685
|
}
|
|
1509
1686
|
if (!overlay)
|
|
@@ -1549,8 +1726,7 @@ export class Body {
|
|
|
1549
1726
|
// retired (the CHA is absolute — the base is irrelevant; the
|
|
1550
1727
|
// CUB's base was the LAST write's end column, which the steady
|
|
1551
1728
|
// frame's ELs leave at col 1 — the A3 finding)
|
|
1552
|
-
out
|
|
1553
|
-
out.push(`\x1b[${editor.markerCol}G`);
|
|
1729
|
+
this.#parkCursor(out, H, H - 2 - inputExtra + editor.markerRow, editor.markerCol);
|
|
1554
1730
|
}
|
|
1555
1731
|
/** The steady-state frame — RELATIVE moves only (invariant ②); the
|
|
1556
1732
|
* commits scroll via the CUP-free real LF at the last row, and the
|
|
@@ -1626,6 +1802,16 @@ export class Body {
|
|
|
1626
1802
|
if (H > anchorRow)
|
|
1627
1803
|
out.push(`\x1b[${H - anchorRow}B`);
|
|
1628
1804
|
}
|
|
1805
|
+
// TUI2-R2pre ②: this count is the COMMIT count on purpose, and it
|
|
1806
|
+
// stays. It reads like the same mistake the full path made, but the
|
|
1807
|
+
// two paths are doing different jobs: the full path REPAINTS every
|
|
1808
|
+
// row, so anything it scrolls is a duplicate of what it is about to
|
|
1809
|
+
// draw; the steady path does not repaint the frozen band, and the
|
|
1810
|
+
// rows it scrolls carry the PRE-FRAME live copies of the cells that
|
|
1811
|
+
// just committed — the A7 single-copy discipline (the old live band
|
|
1812
|
+
// is EL'd first, so the repaint below is the only copy left). Making
|
|
1813
|
+
// this `leaving` was measured: the A7 gate fails at 40x24 frame 106
|
|
1814
|
+
// with the greeting duplicated in the terminal.
|
|
1629
1815
|
for (let i = 0; i < committed.length; i += 1)
|
|
1630
1816
|
out.push("\n");
|
|
1631
1817
|
// the bottom-up repaint, from the last row up — V6-3 + W6 + KC1:
|
|
@@ -1672,6 +1858,11 @@ export class Body {
|
|
|
1672
1858
|
}
|
|
1673
1859
|
// 2. the STALE rows above the committed section — the scrolled old
|
|
1674
1860
|
// live copies (a live-drawn cell's pre-commit position): EL.
|
|
1861
|
+
// TUI2-R2pre ②: the old band's POST-SCROLL origin shifted up by
|
|
1862
|
+
// the rows that actually left — `leaving`. It read the commit
|
|
1863
|
+
// count only because the scroll above used to BE the commit
|
|
1864
|
+
// count; with the two decoupled, the old expression erases rows
|
|
1865
|
+
// of frozen content that never moved.
|
|
1675
1866
|
const staleFrom = Math.max(1, this.#lastLiveTop - committed.length);
|
|
1676
1867
|
for (let r = staleFrom; r < liveTop - committed.length; r += 1) {
|
|
1677
1868
|
out.push(`\x1b[${r};1H\x1b[0K`);
|
|
@@ -1708,21 +1899,47 @@ export class Body {
|
|
|
1708
1899
|
: H - 3 - inputExtra;
|
|
1709
1900
|
// the anchor: the MARKER'S row inside the composer (N = 1,
|
|
1710
1901
|
// markerRow 0 ⇒ the retired H−2)
|
|
1711
|
-
|
|
1712
|
-
if (down > 0)
|
|
1713
|
-
out.push(`\x1b[${down}B`);
|
|
1714
|
-
// W23: the CHA to the frame-derived column — the cursor rests AT
|
|
1715
|
-
// the marker from ANY base (the retired afterW CUB clamped at col
|
|
1716
|
-
// 1 — the steady frame's LAST write is the gap/stale EL: the A3
|
|
1717
|
-
// finding; the A5/A8 live lines end mid-row, the ELs at col 1 —
|
|
1718
|
-
// the CHA ignores the base by construction)
|
|
1719
|
-
out.push(`\x1b[${editor.markerCol}G`);
|
|
1902
|
+
this.#parkCursor(out, lastRow, H - 2 - inputExtra + editor.markerRow, editor.markerCol);
|
|
1720
1903
|
// A8b: the steady path moves the window too (the scroll + the
|
|
1721
1904
|
// repaint) — record its top so the next full-redraw's leaving count
|
|
1722
1905
|
// is the rows the window dropped since the last frame, whatever the
|
|
1723
1906
|
// path of the frames between (same formula as `skip` above).
|
|
1724
1907
|
this.#lastSkip = skip;
|
|
1725
1908
|
}
|
|
1909
|
+
/**
|
|
1910
|
+
* TUI2-R2 ⑤ (the R1.5 parked ⑩) — CURSOR AUTHORITY: the ONE frame-tail
|
|
1911
|
+
* positioning sequence, and the compositor's alone.
|
|
1912
|
+
*
|
|
1913
|
+
* Both draw paths ended with their own hand-rolled park — the full
|
|
1914
|
+
* path counting rows up from the status line, the steady path counting
|
|
1915
|
+
* down from a six-branch re-derivation of which write happened to be
|
|
1916
|
+
* last. Two implementations of one contract, each re-deriving byte
|
|
1917
|
+
* order the drawing code already knew, and the walkthrough found the
|
|
1918
|
+
* consequence three times over (the cursor resting in the status
|
|
1919
|
+
* line's "de▮ault", at the end of streamed text, inside an approval
|
|
1920
|
+
* panel's rule row). A terminal cursor is the product's claim about
|
|
1921
|
+
* where the next keystroke lands; a claim made in two places is a
|
|
1922
|
+
* claim that will eventually disagree with itself.
|
|
1923
|
+
*
|
|
1924
|
+
* One owner, one sequence: a single vertical move to the marker's row
|
|
1925
|
+
* — in EITHER direction, which the steady path could not do (its move
|
|
1926
|
+
* was `if (down > 0)`, so a cursor left BELOW the composer simply
|
|
1927
|
+
* stayed there) — then the CHA to the frame-derived column.
|
|
1928
|
+
*
|
|
1929
|
+
* Relative, not a CUP, and deliberately: invariant ② reserves absolute
|
|
1930
|
+
* addressing for the content area, and the composer is chrome. The
|
|
1931
|
+
* CHA is absolute in the COLUMN only, which is what makes the park
|
|
1932
|
+
* independent of wherever the last write ended (the A3 finding: the
|
|
1933
|
+
* retired CUB's base was the gap EL's column 1, left of the lead).
|
|
1934
|
+
*/
|
|
1935
|
+
#parkCursor(out, fromRow, toRow, col) {
|
|
1936
|
+
const delta = toRow - fromRow;
|
|
1937
|
+
if (delta > 0)
|
|
1938
|
+
out.push(`\x1b[${delta}B`);
|
|
1939
|
+
else if (delta < 0)
|
|
1940
|
+
out.push(`\x1b[${-delta}A`);
|
|
1941
|
+
out.push(`\x1b[${col}G`);
|
|
1942
|
+
}
|
|
1726
1943
|
/** Invariant ①: every emitted line fits the width — a violation is a
|
|
1727
1944
|
* CRASH with the diagnostic, never a silent truncate. */
|
|
1728
1945
|
#checked(line, W) {
|
|
@@ -1737,8 +1954,11 @@ export class Body {
|
|
|
1737
1954
|
return i === undefined ? null : (this.#cells[i] ?? null);
|
|
1738
1955
|
}
|
|
1739
1956
|
/** Close an open TEXT cell when a new cell starts (see v2d — the
|
|
1740
|
-
* runtime emits no text_end; the next cell is the close signal).
|
|
1957
|
+
* runtime emits no text_end; the next cell is the close signal).
|
|
1958
|
+
* TUI2-MD ⑤: that same signal ends the markdown message — the open
|
|
1959
|
+
* tail block closes and its cell becomes commit-eligible. */
|
|
1741
1960
|
#closeOpenText() {
|
|
1961
|
+
this.#endMd();
|
|
1742
1962
|
const last = this.#cells[this.#cells.length - 1];
|
|
1743
1963
|
if (last !== undefined && last.kind === "text" && !last.done)
|
|
1744
1964
|
last.done = true;
|
|
@@ -1825,6 +2045,16 @@ export class Dock {
|
|
|
1825
2045
|
}
|
|
1826
2046
|
compositorRef.bindAt(state);
|
|
1827
2047
|
}
|
|
2048
|
+
/** TUI2-R2 ②: bind the editor's session picker — the band's third
|
|
2049
|
+
* occupant. Unbound (every path but bare `kiso resume`), the picker
|
|
2050
|
+
* cannot render and every frame is byte-identical to before. */
|
|
2051
|
+
bindPick(state) {
|
|
2052
|
+
if (compositorRef === null) {
|
|
2053
|
+
dockBindings.pick = state;
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
compositorRef.bindPick(state);
|
|
2057
|
+
}
|
|
1828
2058
|
/** W22: bind the pending-turn queue — the chips + the +N queued
|
|
1829
2059
|
* hint (the CLI binds it from chat(); the editor's pop keys ride
|
|
1830
2060
|
* the LineInput's own bindQueue). */
|
|
@@ -1850,4 +2080,4 @@ let compositorRef = null;
|
|
|
1850
2080
|
* — the old snapshot froze `menu` at bindInput time and the slash-
|
|
1851
2081
|
* command menu silently never bound in the real CLI (the e2e gates
|
|
1852
2082
|
* bind the Body directly and could not see it). */
|
|
1853
|
-
const dockBindings = { state: null, prompt: "", menu: null, at: null, panel: null, sheet: null, queue: null };
|
|
2083
|
+
const dockBindings = { state: null, prompt: "", menu: null, at: null, pick: null, panel: null, sheet: null, queue: null };
|
package/dist/editor.d.ts
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { charWidth, displayWidth, widthOf } from "./width.js";
|
|
25
25
|
export { charWidth, displayWidth, widthOf };
|
|
26
|
-
import type
|
|
26
|
+
import { type PanelState, type PanelVerdict, type PanelView } from "./approval-panel.js";
|
|
27
27
|
import { type AtItem, type AtMatch } from "./at-picker.js";
|
|
28
|
+
import { type SessionCardView, type SessionPickState } from "./session-picker.js";
|
|
28
29
|
export declare const PROMPT = "\u258C ";
|
|
29
30
|
export declare const PROMPT_WIDTH: number;
|
|
30
31
|
/** v3 §04 — the slash-command menu's command table (English one-liners). */
|
|
@@ -101,6 +102,12 @@ export declare class Editor {
|
|
|
101
102
|
selected: number;
|
|
102
103
|
capped: boolean;
|
|
103
104
|
} | null;
|
|
105
|
+
/** Open the picker on a bound card source. The composer is cleared
|
|
106
|
+
* (the buffer becomes the filter query) and `onPick` receives the
|
|
107
|
+
* chosen id — or null when the human leaves without picking, which
|
|
108
|
+
* is a first-class outcome and not an error. */
|
|
109
|
+
beginPick(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void): void;
|
|
110
|
+
pickState(): SessionPickState | null;
|
|
104
111
|
/** One-shot question mode: the NEXT submit answers, not a turn. */
|
|
105
112
|
question(_query: string, cb: (answer: string) => void): void;
|
|
106
113
|
/** Cancel a pending question — the buffer stays (its text becomes the
|