@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.
@@ -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
- import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, foldLine, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
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.replace("_file", "")} ${cell.rolled.count} ${noun}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
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.replace("_file", "")} ${toolTarget(cell.name, input)}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
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
- `--- ${cell.name} input ---`,
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.#resizeHandler = () => this.onResize();
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
- if (panel !== null) {
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 (const cell of this.#cells.slice(this.#committed)) {
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 (const cell of this.#cells.slice(this.#committed)) {
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 last committed row carried the "ctrl+r"
944
- // affordance (the renderer cut "└ ctrl+r") joins the expand
945
- // history the detection is the renderer's OWN output, so the
946
- // read's "/last"-only cut note never lands here.
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" && /└ .*ctrl\+r/.test(lines[lines.length - 1] ?? ""))
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
- return !turn.ended && !turn.hasText;
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" || ROLLUP_NOUN[cell.name] === undefined)
1221
+ if (cell.kind !== "tool" || !isExploreTool(cell.name))
1000
1222
  return cellComponent(cell).render(W, ctx);
1001
- // the maximal same-name run around i forward/backward scans over
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 > 0) {
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 !== cell.name)
1249
+ if (prev.kind !== "tool" || !isExploreTool(prev.name))
1016
1250
  break;
1017
1251
  s -= 1;
1018
- head = s; // a same-name tool precedes — it is the group's head
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 !== cell.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 parts = shown.split("\n");
1051
- total += parts[parts.length - 1] === "" ? parts.length - 1 : parts.length;
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
- cell.rolled = { count: members.length, lines: total, elapsed, targets };
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
- const rows = [];
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
- const skip = Math.max(0, all.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
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
- for (let i = 0; i < skip; i += 1)
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
- this.#lastSkip = skip;
1616
+ if (!overlay)
1617
+ this.#lastSkip = skip;
1335
1618
  let r = 1;
1336
- for (const line of all.slice(skip)) {
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.push(`\x1b[${1 + editor.rows.length - editor.markerRow}A`);
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
- const skip = Math.max(0, this.#committedLines + liveLines.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
1388
- const leaving = Math.max(0, skip - this.#lastSkip);
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
- const down = H - 2 - inputExtra + editor.markerRow - lastRow;
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 };