@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.
@@ -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
- import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, exploreRows, foldLine, isExploreTool, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.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, 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
- const last = this.#cells[this.#cells.length - 1];
385
- if (last !== undefined && last.kind === "text" && !last.done) {
386
- last.text += text;
387
- }
388
- else {
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.#cells.push({ kind: "text", text, done: false });
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.replace("_file", "")} ${cell.rolled.count} ${noun}`)} · ${back}`;
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.replace("_file", "")} ${toolTarget(cell.name, input)}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
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
- `--- ${cell.name} input ---`,
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.#resizeHandler = () => this.onResize();
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 (const cell of live) {
865
- const rows = cellComponent(cell).render(W, ctx);
866
- lines += bodySpacing(prev, rows).length;
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 += bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines).length;
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 (const cell of this.#cells.slice(this.#committed)) {
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
- liveLines.push(...bodySpacing(prev, rows));
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 (const cell of this.#cells.slice(this.#committed)) {
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
- liveLines.push(...bodySpacing(prev, rows));
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 = bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines);
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(...bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, this.#lineCache[i]));
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
- for (let i = 0; i < skip; i += 1)
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.push(`\x1b[${1 + editor.rows.length - editor.markerRow}A`);
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
- const down = H - 2 - inputExtra + editor.markerRow - lastRow;
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 { PanelState, PanelVerdict, PanelView } from "./approval-panel.js";
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