@vincemakes/kiso-tui 0.9.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.
@@ -27,7 +27,7 @@
27
27
  * durability would need a new durable mechanism), no "chat about this"
28
28
  * hand-off, no timeout and no countdown.
29
29
  */
30
- import { type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, type PanelPhase, type PanelSel, type PanelState, type PanelView } from "./approval-panel.js";
30
+ import { type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, type PanelPhase, type PanelSel, type PanelState, type PanelView, type PickRuntime } from "./approval-panel.js";
31
31
  /** The schema's own bounds — the registry refuses anything outside them
32
32
  * (extensions/ask validates; these are the numbers it validates to). */
33
33
  export declare const ASK_MAX_QUESTIONS = 4;
@@ -77,13 +77,14 @@ export declare function askStatus(view: PanelView, state: AskRuntime): string;
77
77
  /** The input row's lead: the digit lead while picking, the typing lead
78
78
  * in the custom phase (the rule-input phase's shape, reused). */
79
79
  export declare function askLeadPlain(state: AskRuntime): string;
80
- export declare function panelBlockRows(view: PanelView, phase: PanelPhase, sel: PanelSel, W: number, maxRows: number, ask?: AskRuntime): string[];
81
- export declare function panelLead(view: PanelView, phase: PanelPhase, sel: PanelSel, ask?: AskRuntime): string;
82
- export declare function panelLeadPlain(view: PanelView, phase: PanelPhase, sel: PanelSel, ask?: AskRuntime): string;
83
- export declare function panelStatus(view: PanelView, phase: PanelPhase, sel: PanelSel, ask?: AskRuntime): string;
84
- export declare function panelAffordance(view: PanelView, phase: PanelPhase, sel: PanelSel, ask?: AskRuntime): string;
80
+ export declare function panelBlockRows(view: PanelView, phase: PanelPhase, sel: PanelSel, W: number, maxRows: number, ask?: AskRuntime, pick?: PickRuntime): string[];
81
+ export declare function panelLead(view: PanelView, phase: PanelPhase, sel: PanelSel, ask?: AskRuntime, pick?: PickRuntime): string;
82
+ export declare function panelLeadPlain(view: PanelView, phase: PanelPhase, sel: PanelSel, ask?: AskRuntime, pick?: PickRuntime): string;
83
+ export declare function panelStatus(view: PanelView, phase: PanelPhase, sel: PanelSel, ask?: AskRuntime, pick?: PickRuntime): string;
84
+ export declare function panelAffordance(view: PanelView, phase: PanelPhase, sel: PanelSel, ask?: AskRuntime, pick?: PickRuntime): string;
85
85
  /** The whole panel state in one call — the compositor's four reads share
86
- * one source, so an ask can never render half as an approval. */
86
+ * one source, so an ask can never render half as an approval (TUI2-R2
87
+ * ④: nor a pick as either). */
87
88
  export declare const panelRowsOf: (s: PanelState, W: number, maxRows: number) => string[];
88
89
  export declare const panelLeadOf: (s: PanelState) => string;
89
90
  export declare const panelStatusOf: (s: PanelState) => string;
package/dist/ask-panel.js CHANGED
@@ -27,7 +27,7 @@
27
27
  * durability would need a new durable mechanism), no "chat about this"
28
28
  * hand-off, no timeout and no countdown.
29
29
  */
30
- import { panelAffordance as basePanelAffordance, panelBlockRows as basePanelBlockRows, panelLead as basePanelLead, panelLeadPlain as basePanelLeadPlain, panelStatus as basePanelStatus, } from "./approval-panel.js";
30
+ import { panelAffordance as basePanelAffordance, panelBlockRows as basePanelBlockRows, panelLead as basePanelLead, panelLeadPlain as basePanelLeadPlain, panelStatus as basePanelStatus, pickAffordance, pickBlockRows, pickLead, pickLeadPlain, pickStatus, } from "./approval-panel.js";
31
31
  import { cutLine } from "@vincemakes/kiso-tui-cells/components";
32
32
  import { escapeTerminal, palette } from "./render.js";
33
33
  /** The schema's own bounds — the registry refuses anything outside them
@@ -225,38 +225,49 @@ export function askLeadPlain(state) {
225
225
  return state.phase === "custom" ? "your answer: " : "1-4> ";
226
226
  }
227
227
  // ── the dispatchers: the panel slot, with the ask branch folded in ────
228
- export function panelBlockRows(view, phase, sel, W, maxRows, ask) {
228
+ export function panelBlockRows(view, phase, sel, W, maxRows, ask, pick) {
229
+ if (view.pick !== undefined && pick !== undefined)
230
+ return pickBlockRows(view, pick, W, maxRows);
229
231
  if (view.ask !== undefined && ask !== undefined)
230
232
  return askBlockRows(view, ask, W, maxRows);
231
233
  return basePanelBlockRows(view, phase, sel, W, maxRows);
232
234
  }
233
- export function panelLead(view, phase, sel, ask) {
235
+ export function panelLead(view, phase, sel, ask, pick) {
234
236
  const p = palette();
237
+ if (view.pick !== undefined && pick !== undefined)
238
+ return pickLead(view, pick);
235
239
  if (view.ask !== undefined && ask !== undefined)
236
240
  return `${p.bold}${askLeadPlain(ask)}${p.reset}`;
237
241
  return basePanelLead(view, phase, sel);
238
242
  }
239
- export function panelLeadPlain(view, phase, sel, ask) {
243
+ export function panelLeadPlain(view, phase, sel, ask, pick) {
244
+ if (view.pick !== undefined && pick !== undefined)
245
+ return pickLeadPlain(view, pick);
240
246
  if (view.ask !== undefined && ask !== undefined)
241
247
  return askLeadPlain(ask);
242
248
  return basePanelLeadPlain(view, phase, sel);
243
249
  }
244
- export function panelStatus(view, phase, sel, ask) {
250
+ export function panelStatus(view, phase, sel, ask, pick) {
251
+ if (view.pick !== undefined && pick !== undefined)
252
+ return pickStatus(view);
245
253
  if (view.ask !== undefined && ask !== undefined)
246
254
  return askStatus(view, ask);
247
255
  return basePanelStatus(view, phase, sel);
248
256
  }
249
- export function panelAffordance(view, phase, sel, ask) {
257
+ export function panelAffordance(view, phase, sel, ask, pick) {
258
+ if (view.pick !== undefined && pick !== undefined)
259
+ return pickAffordance(pick);
250
260
  if (view.ask !== undefined && ask !== undefined)
251
261
  return askAffordance(ask);
252
262
  return basePanelAffordance(view, phase, sel);
253
263
  }
254
264
  /** The whole panel state in one call — the compositor's four reads share
255
- * one source, so an ask can never render half as an approval. */
256
- export const panelRowsOf = (s, W, maxRows) => panelBlockRows(s.view, s.phase, s.sel, W, maxRows, s.ask);
257
- export const panelLeadOf = (s) => panelLead(s.view, s.phase, s.sel, s.ask);
258
- export const panelStatusOf = (s) => panelStatus(s.view, s.phase, s.sel, s.ask);
259
- export const panelAffordanceOf = (s) => panelAffordance(s.view, s.phase, s.sel, s.ask);
265
+ * one source, so an ask can never render half as an approval (TUI2-R2
266
+ * ④: nor a pick as either). */
267
+ export const panelRowsOf = (s, W, maxRows) => panelBlockRows(s.view, s.phase, s.sel, W, maxRows, s.ask, s.pick);
268
+ export const panelLeadOf = (s) => panelLead(s.view, s.phase, s.sel, s.ask, s.pick);
269
+ export const panelStatusOf = (s) => panelStatus(s.view, s.phase, s.sel, s.ask, s.pick);
270
+ export const panelAffordanceOf = (s) => panelAffordance(s.view, s.phase, s.sel, s.ask, s.pick);
260
271
  // ── the view: what the human reads when the model asks ────────────────
261
272
  /** The ask's PanelView. The dock-less fallback question is HONEST: a
262
273
  * terminal without a panel cannot walk options, so it says the ask is
@@ -45,6 +45,7 @@
45
45
  import { type MenuItem } from "./editor.js";
46
46
  import type { PanelState } from "./approval-panel.js";
47
47
  import { type AtMatch } from "./at-picker.js";
48
+ import { type SessionPickState } from "./session-picker.js";
48
49
  /** KC3 §4 — the @ picker's bound state (the editor's atState()). */
49
50
  export interface AtPanelState {
50
51
  readonly matches: readonly AtMatch[];
@@ -178,15 +179,6 @@ export declare class Body {
178
179
  isError: boolean;
179
180
  };
180
181
  } | null;
181
- /** W15 — the expand key's target (ctrl+r). A cell still in the LIVE
182
- * region (the newest live tool) TOGGLES in place — the compositor
183
- * owns those rows and redraws them (the body flips to the full
184
- * form, no cap). A committed cell can never toggle — history is
185
- * never rewritten (ADR-0046) — so the key APPENDS a fresh expanded
186
- * block at the bottom instead, the /last idiom aimed at a chosen
187
- * cell: the pointer cycles the collapsed history, newest first, and
188
- * the header names the target ("N turns back" — the user cells
189
- * after it), so every press tells the user what they got. */
190
182
  expandNext(): {
191
183
  kind: "toggled";
192
184
  } | {
@@ -232,6 +224,9 @@ export declare class Body {
232
224
  * band — see #menuRows for why that is a decision and not a
233
225
  * shortcut. */
234
226
  bindAt(state: () => AtPanelState | null): void;
227
+ /** TUI2-R2 ②: bind the editor's session picker — the band's third
228
+ * occupant (see #menuRows for why they share one). */
229
+ bindPick(state: () => SessionPickState | null): void;
235
230
  /** Bind the pending-turn queue — the CLI's live slots (chat.ts):
236
231
  * the chips render in the menu-rows family, the live caps shrink
237
232
  * by their rows, and the +N queued hint rides the status row. */
@@ -280,6 +275,10 @@ export declare class Dock {
280
275
  * menu (see Body#menuRows). Unbound, the picker cannot render, and
281
276
  * every frame is byte-identical to before the round. */
282
277
  bindAt(state: () => AtPanelState | null): void;
278
+ /** TUI2-R2 ②: bind the editor's session picker — the band's third
279
+ * occupant. Unbound (every path but bare `kiso resume`), the picker
280
+ * cannot render and every frame is byte-identical to before. */
281
+ bindPick(state: () => SessionPickState | null): void;
283
282
  /** W22: bind the pending-turn queue — the chips + the +N queued
284
283
  * hint (the CLI binds it from chat(); the editor's pop keys ride
285
284
  * the LineInput's own bindQueue). */
@@ -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, 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). */
@@ -134,6 +136,9 @@ export class Body {
134
136
  // KC3 §4: the @ picker's bound state — the SAME band as the menu
135
137
  // (see #menuRows: the two are mutually exclusive by construction).
136
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;
137
142
  // W22: the pending-turn queue's bound state — the CLI's live slots
138
143
  // (chat.ts); the chips render in the menu-rows family (above the
139
144
  // box top), the live caps shrink by their rows, and the status
@@ -146,6 +151,16 @@ export class Body {
146
151
  // v6: the single writer — the compositor IS the dock; the CLI's
147
152
  // onDock callback (which used to re-pin the dock after a scroll)
148
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();
149
164
  compositorRef = this;
150
165
  // the Dock façade's bindings may arrive BEFORE this construction
151
166
  // (the CLI binds the editor state in makeLineInput, then constructs
@@ -162,6 +177,8 @@ export class Body {
162
177
  this.#menuState = dockBindings.menu;
163
178
  if (dockBindings.at !== null)
164
179
  this.#atState = dockBindings.at;
180
+ if (dockBindings.pick !== null)
181
+ this.#pickState = dockBindings.pick;
165
182
  this.#panelState = dockBindings.panel;
166
183
  this.#sheetState = dockBindings.sheet;
167
184
  if (dockBindings.queue !== null)
@@ -570,6 +587,30 @@ export class Body {
570
587
  * cell: the pointer cycles the collapsed history, newest first, and
571
588
  * the header names the target ("N turns back" — the user cells
572
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
+ }
573
614
  expandNext() {
574
615
  for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
575
616
  const cell = this.#cells[i];
@@ -612,7 +653,7 @@ export class Body {
612
653
  return { kind: "appended", lines: [header, ...exploreRows(cell.rolled.parts, this.#opts.width())] };
613
654
  }
614
655
  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}`;
656
+ const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${displayVerb(cell.name)} ${cell.rolled.count} ${noun}`)} · ${back}`;
616
657
  return {
617
658
  kind: "appended",
618
659
  lines: [header, ...cell.rolled.targets.map((t) => ` ${p.dim}└ ${escapeTerminal(t)}${p.reset}`)],
@@ -628,14 +669,16 @@ export class Body {
628
669
  }
629
670
  const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
630
671
  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`;
672
+ const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${displayVerb(cell.name)} ${toolTarget(cell.name, input)}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
632
673
  return {
633
674
  kind: "appended",
634
675
  lines: [
635
676
  header,
636
- `--- ${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 ---`,
637
680
  cell.inputFull,
638
- `--- ${cell.name} output${cell.isError ? " (error)" : ""} ---`,
681
+ `--- ${displayVerb(cell.name)} output${cell.isError ? " (error)" : ""} ---`,
639
682
  cell.resultText,
640
683
  ],
641
684
  };
@@ -645,13 +688,29 @@ export class Body {
645
688
  get active() {
646
689
  return this.#docked && this.#isActive();
647
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
+ }
648
708
  enter() {
649
709
  const rows = process.stdout.rows ?? 0;
650
710
  if (process.stdout.isTTY !== true || palette().bold === "" || rows < 4)
651
711
  return;
652
712
  this.#docked = true;
653
- this.#resizeHandler = () => this.onResize();
654
- process.stdout.on("resize", this.#resizeHandler);
713
+ this.#attachResize();
655
714
  this.#fullRedraw = true;
656
715
  this.#dirty = true;
657
716
  this.render(); // the FIRST frame — the full-redraw path, no pre-clear
@@ -659,13 +718,16 @@ export class Body {
659
718
  /** Teardown — CSI r (the "no broken terminal" contract byte), the
660
719
  * chrome rows cleared, the cursor home at the input line. */
661
720
  exit() {
662
- 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();
663
727
  return;
664
- this.#docked = false;
665
- if (this.#resizeHandler !== null) {
666
- process.stdout.off("resize", this.#resizeHandler);
667
- this.#resizeHandler = null;
668
728
  }
729
+ this.#docked = false;
730
+ this.#detachResize();
669
731
  const H = this.#lastH > 0 ? this.#lastH : process.stdout.rows ?? 24;
670
732
  const out = [];
671
733
  out.push("\x1b[r");
@@ -750,6 +812,11 @@ export class Body {
750
812
  bindAt(state) {
751
813
  this.#atState = state;
752
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
+ }
753
820
  /** Bind the pending-turn queue — the CLI's live slots (chat.ts):
754
821
  * the chips render in the menu-rows family, the live caps shrink
755
822
  * by their rows, and the +N queued hint rides the status row. */
@@ -960,9 +1027,21 @@ export class Body {
960
1027
  liveLines = panelRowsOf(panel, W, Math.max(1, H - 4 - inputExtra - queueRows.length));
961
1028
  }
962
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();
963
1037
  let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
964
- 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];
965
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);
966
1045
  liveLines.push(...bodySpacing(prev, rows));
967
1046
  prev = rows;
968
1047
  }
@@ -975,9 +1054,15 @@ export class Body {
975
1054
  this.#commitCell(this.#committed, W, ctx);
976
1055
  liveLines = [];
977
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();
978
1060
  let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
979
- 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];
980
1063
  const rows = cellComponent(cell).render(W, ctx);
1064
+ if (i === focus && rows.length > 0)
1065
+ rows[0] = focusToken(rows[0], W);
981
1066
  liveLines.push(...bodySpacing(prev, rows));
982
1067
  prev = rows;
983
1068
  }
@@ -1289,6 +1374,15 @@ export class Body {
1289
1374
  * occupant knowing the other exists.
1290
1375
  */
1291
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());
1292
1386
  const at = this.#atState?.() ?? null;
1293
1387
  if (at !== null)
1294
1388
  return atPanelRows(at, W);
@@ -1503,7 +1597,20 @@ export class Body {
1503
1597
  if (leaving < skip)
1504
1598
  out.push(`\x1b[${leaving + 1};1H\x1b[0J`);
1505
1599
  out.push(`\x1b[${H};1H`);
1506
- 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)
1507
1614
  out.push("\n");
1508
1615
  }
1509
1616
  if (!overlay)
@@ -1549,8 +1656,7 @@ export class Body {
1549
1656
  // retired (the CHA is absolute — the base is irrelevant; the
1550
1657
  // CUB's base was the LAST write's end column, which the steady
1551
1658
  // 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`);
1659
+ this.#parkCursor(out, H, H - 2 - inputExtra + editor.markerRow, editor.markerCol);
1554
1660
  }
1555
1661
  /** The steady-state frame — RELATIVE moves only (invariant ②); the
1556
1662
  * commits scroll via the CUP-free real LF at the last row, and the
@@ -1626,6 +1732,16 @@ export class Body {
1626
1732
  if (H > anchorRow)
1627
1733
  out.push(`\x1b[${H - anchorRow}B`);
1628
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.
1629
1745
  for (let i = 0; i < committed.length; i += 1)
1630
1746
  out.push("\n");
1631
1747
  // the bottom-up repaint, from the last row up — V6-3 + W6 + KC1:
@@ -1672,6 +1788,11 @@ export class Body {
1672
1788
  }
1673
1789
  // 2. the STALE rows above the committed section — the scrolled old
1674
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.
1675
1796
  const staleFrom = Math.max(1, this.#lastLiveTop - committed.length);
1676
1797
  for (let r = staleFrom; r < liveTop - committed.length; r += 1) {
1677
1798
  out.push(`\x1b[${r};1H\x1b[0K`);
@@ -1708,21 +1829,47 @@ export class Body {
1708
1829
  : H - 3 - inputExtra;
1709
1830
  // the anchor: the MARKER'S row inside the composer (N = 1,
1710
1831
  // 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`);
1832
+ this.#parkCursor(out, lastRow, H - 2 - inputExtra + editor.markerRow, editor.markerCol);
1720
1833
  // A8b: the steady path moves the window too (the scroll + the
1721
1834
  // repaint) — record its top so the next full-redraw's leaving count
1722
1835
  // is the rows the window dropped since the last frame, whatever the
1723
1836
  // path of the frames between (same formula as `skip` above).
1724
1837
  this.#lastSkip = skip;
1725
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
+ }
1726
1873
  /** Invariant ①: every emitted line fits the width — a violation is a
1727
1874
  * CRASH with the diagnostic, never a silent truncate. */
1728
1875
  #checked(line, W) {
@@ -1825,6 +1972,16 @@ export class Dock {
1825
1972
  }
1826
1973
  compositorRef.bindAt(state);
1827
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
+ }
1828
1985
  /** W22: bind the pending-turn queue — the chips + the +N queued
1829
1986
  * hint (the CLI binds it from chat(); the editor's pop keys ride
1830
1987
  * the LineInput's own bindQueue). */
@@ -1850,4 +2007,4 @@ let compositorRef = null;
1850
2007
  * — the old snapshot froze `menu` at bindInput time and the slash-
1851
2008
  * command menu silently never bound in the real CLI (the e2e gates
1852
2009
  * 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 };
2010
+ 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