@vincemakes/kiso-tui 0.10.0 → 0.12.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, type PickRuntime } from "./approval-panel.js";
30
+ import { type SaferRuntime, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, type PanelPhase, 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,15 +77,39 @@ 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, 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;
80
+ export declare function panelBlockRows(view: PanelView, phase: PanelPhase, cursor: number, W: number, maxRows: number, ask?: AskRuntime, pick?: PickRuntime, note?: string, safer?: SaferRuntime): string[];
81
+ export declare function panelLead(view: PanelView, phase: PanelPhase, cursor: number, ask?: AskRuntime, pick?: PickRuntime): string;
82
+ export declare function panelLeadPlain(view: PanelView, phase: PanelPhase, cursor: number, ask?: AskRuntime, pick?: PickRuntime): string;
83
+ export declare function panelStatus(view: PanelView, phase: PanelPhase, cursor: number, ask?: AskRuntime, pick?: PickRuntime): string;
84
+ export declare function panelAffordance(view: PanelView, phase: PanelPhase, cursor: number, ask?: AskRuntime, pick?: PickRuntime, safer?: SaferRuntime): string;
85
85
  /** The whole panel state in one call — the compositor's four reads share
86
86
  * one source, so an ask can never render half as an approval (TUI2-R2
87
87
  * ④: nor a pick as either). */
88
88
  export declare const panelRowsOf: (s: PanelState, W: number, maxRows: number) => string[];
89
+ /**
90
+ * TUI2-R3v2 ② — the rows AND where the clickable ones are, from ONE
91
+ * call.
92
+ *
93
+ * The compositor needs both and must not compute the second from the
94
+ * first: the args cap, the note row and the option window all move the
95
+ * list, so a hit-test that re-derived the offset would drift from the
96
+ * picture exactly when the block is under pressure — which is when a
97
+ * misrouted click is most expensive.
98
+ *
99
+ * `options` is null for the ask and pick flavors: their rows are placed
100
+ * by their own renderers, and this round does not claim a click on them
101
+ * (the enable/disable invariant covers those surfaces; the gesture does
102
+ * not). A null here means every click is inert, which is the safe way
103
+ * to not-implement something.
104
+ */
105
+ export declare const panelFrameOf: (s: PanelState, W: number, maxRows: number) => {
106
+ rows: string[];
107
+ options: {
108
+ offset: number;
109
+ count: number;
110
+ first: number;
111
+ } | null;
112
+ };
89
113
  export declare const panelLeadOf: (s: PanelState) => string;
90
114
  export declare const panelStatusOf: (s: PanelState) => string;
91
115
  export declare const panelAffordanceOf: (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, pickAffordance, pickBlockRows, pickLead, pickLeadPlain, pickStatus, } from "./approval-panel.js";
30
+ import { panelBlockLayout, 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,49 +225,74 @@ 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, pick) {
228
+ export function panelBlockRows(view, phase, cursor, W, maxRows, ask, pick, note, safer) {
229
229
  if (view.pick !== undefined && pick !== undefined)
230
230
  return pickBlockRows(view, pick, W, maxRows);
231
231
  if (view.ask !== undefined && ask !== undefined)
232
232
  return askBlockRows(view, ask, W, maxRows);
233
- return basePanelBlockRows(view, phase, sel, W, maxRows);
233
+ return basePanelBlockRows(view, phase, cursor, W, maxRows, note, safer);
234
234
  }
235
- export function panelLead(view, phase, sel, ask, pick) {
235
+ export function panelLead(view, phase, cursor, ask, pick) {
236
236
  const p = palette();
237
237
  if (view.pick !== undefined && pick !== undefined)
238
238
  return pickLead(view, pick);
239
239
  if (view.ask !== undefined && ask !== undefined)
240
240
  return `${p.bold}${askLeadPlain(ask)}${p.reset}`;
241
- return basePanelLead(view, phase, sel);
241
+ return basePanelLead(view, phase, cursor);
242
242
  }
243
- export function panelLeadPlain(view, phase, sel, ask, pick) {
243
+ export function panelLeadPlain(view, phase, cursor, ask, pick) {
244
244
  if (view.pick !== undefined && pick !== undefined)
245
245
  return pickLeadPlain(view, pick);
246
246
  if (view.ask !== undefined && ask !== undefined)
247
247
  return askLeadPlain(ask);
248
- return basePanelLeadPlain(view, phase, sel);
248
+ return basePanelLeadPlain(view, phase, cursor);
249
249
  }
250
- export function panelStatus(view, phase, sel, ask, pick) {
250
+ export function panelStatus(view, phase, cursor, ask, pick) {
251
251
  if (view.pick !== undefined && pick !== undefined)
252
252
  return pickStatus(view);
253
253
  if (view.ask !== undefined && ask !== undefined)
254
254
  return askStatus(view, ask);
255
- return basePanelStatus(view, phase, sel);
255
+ return basePanelStatus(view, phase, cursor);
256
256
  }
257
- export function panelAffordance(view, phase, sel, ask, pick) {
257
+ export function panelAffordance(view, phase, cursor, ask, pick, safer) {
258
258
  if (view.pick !== undefined && pick !== undefined)
259
259
  return pickAffordance(pick);
260
260
  if (view.ask !== undefined && ask !== undefined)
261
261
  return askAffordance(ask);
262
- return basePanelAffordance(view, phase, sel);
262
+ return basePanelAffordance(view, phase, cursor, safer);
263
263
  }
264
264
  /** The whole panel state in one call — the compositor's four reads share
265
265
  * one source, so an ask can never render half as an approval (TUI2-R2
266
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);
267
+ export const panelRowsOf = (s, W, maxRows) => panelBlockRows(s.view, s.phase, s.cursor, W, maxRows, s.ask, s.pick, s.note, s.safer);
268
+ /**
269
+ * TUI2-R3v2 the rows AND where the clickable ones are, from ONE
270
+ * call.
271
+ *
272
+ * The compositor needs both and must not compute the second from the
273
+ * first: the args cap, the note row and the option window all move the
274
+ * list, so a hit-test that re-derived the offset would drift from the
275
+ * picture exactly when the block is under pressure — which is when a
276
+ * misrouted click is most expensive.
277
+ *
278
+ * `options` is null for the ask and pick flavors: their rows are placed
279
+ * by their own renderers, and this round does not claim a click on them
280
+ * (the enable/disable invariant covers those surfaces; the gesture does
281
+ * not). A null here means every click is inert, which is the safe way
282
+ * to not-implement something.
283
+ */
284
+ export const panelFrameOf = (s, W, maxRows) => {
285
+ if (s.view.pick !== undefined || s.view.ask !== undefined)
286
+ return { rows: panelRowsOf(s, W, maxRows), options: null };
287
+ const layout = panelBlockLayout(s.view, s.phase, s.cursor, W, maxRows, s.note, s.safer);
288
+ return {
289
+ rows: layout.rows,
290
+ options: layout.count === 0 ? null : { offset: layout.offset, count: layout.count, first: layout.first },
291
+ };
292
+ };
293
+ export const panelLeadOf = (s) => panelLead(s.view, s.phase, s.cursor, s.ask, s.pick);
294
+ export const panelStatusOf = (s) => panelStatus(s.view, s.phase, s.cursor, s.ask, s.pick);
295
+ export const panelAffordanceOf = (s) => panelAffordance(s.view, s.phase, s.cursor, s.ask, s.pick, s.safer);
271
296
  // ── the view: what the human reads when the model asks ────────────────
272
297
  /** The ask's PanelView. The dock-less fallback question is HONEST: a
273
298
  * terminal without a panel cannot walk options, so it says the ask is
@@ -193,6 +193,13 @@ export declare class Body {
193
193
  /** Teardown — CSI r (the "no broken terminal" contract byte), the
194
194
  * chrome rows cleared, the cursor home at the input line. */
195
195
  exit(): void;
196
+ /** TUI2-R3v2 ②: the panel's option rows as this frame placed them —
197
+ * absolute, 1-based. The click hit-test's single source. */
198
+ panelOptionRows(): {
199
+ top: number;
200
+ count: number;
201
+ first: number;
202
+ } | null;
196
203
  /** SIGWINCH: clear the OLD live area (recorded geometry, ED only —
197
204
  * zero LF, zero \x1b[3J — the shell history untouched), then the
198
205
  * full-redraw path at the NEW geometry (O(height), zero replay).
@@ -257,6 +264,14 @@ export declare class Dock {
257
264
  enter(): void;
258
265
  exit(): void;
259
266
  onResize(): void;
267
+ /** TUI2-R3v2 ②: where the last frame put the panel's clickable option
268
+ * rows (absolute screen rows). The editor binds this and does no row
269
+ * arithmetic of its own. */
270
+ panelOptionRows(): {
271
+ top: number;
272
+ count: number;
273
+ first: number;
274
+ } | null;
260
275
  setStatus(text: string, hint?: string | null): void;
261
276
  setTail(tail: string): void;
262
277
  /** W21: bind the editor's panel state — the PanelSelect slot
@@ -47,11 +47,12 @@ import { displayWidth } from "./editor.js";
47
47
  import { leadWidth } from "./width.js"; // W23: the ONE width authority (the editor, #inputRow, and editCol share it)
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
- import { panelAffordanceOf, panelLeadOf, panelRowsOf, panelStatusOf } from "./ask-panel.js";
50
+ import { panelAffordanceOf, panelFrameOf, panelLeadOf, panelRowsOf, panelStatusOf } from "./ask-panel.js";
51
+ import { MOUSE_OFF } from "./editor.js";
51
52
  import { atPanelRows, bandHeader } from "./at-picker.js";
52
53
  // TUI2-R2 ②: the session picker's rows — the band's third occupant.
53
54
  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";
55
+ import { Container, ROLLUP_NOUN, SPINNER, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, exploreCounts, focusToken, exploreRows, foldLine, isExploreTool, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
55
56
  import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
56
57
  import { displayVerb, keysSheetRows } from "./strings.js";
57
58
  /** The cursor marker — an APC private sequence the focus component
@@ -96,6 +97,11 @@ export class Body {
96
97
  #lastTool = null;
97
98
  #pendingCalls = new Map();
98
99
  #pipeBuf = ""; // the passthrough's thinking buffer
100
+ /** TUI2-MD ⑤ — the markdown scanner of the message currently
101
+ * streaming, and the cell index its first block landed at. Null
102
+ * between messages: the scanner's life is one assistant message. */
103
+ #md = null;
104
+ #mdBase = 0;
99
105
  #toolCells = new Map(); // callId → cell index (parallel tools)
100
106
  // W15: the collapsed (cut) tool cells — committed cells whose last
101
107
  // rendered row carried the "ctrl+r" affordance; the expand key's
@@ -112,6 +118,9 @@ export class Body {
112
118
  #rolledHeads = new Set();
113
119
  #write;
114
120
  #resizeHandler = null;
121
+ /** TUI2-R3v2 ②: the panel option rows' absolute screen span, as of the
122
+ * last frame. Null whenever no clickable list is on screen. */
123
+ #panelRowSpan = null;
115
124
  // the chrome state (the Dock façade)
116
125
  #status = "";
117
126
  #statusHint = null;
@@ -398,22 +407,71 @@ export class Body {
398
407
  const turn = this.#turns[this.#turns.length - 1];
399
408
  if (turn !== undefined)
400
409
  turn.hasText = true;
401
- const last = this.#cells[this.#cells.length - 1];
402
- if (last !== undefined && last.kind === "text" && !last.done) {
403
- last.text += text;
404
- }
405
- else {
410
+ // TUI2-MD ⑤: assistant body text is MARKDOWN, scanned as it
411
+ // streams. The scanner yields CLOSED blocks (final source, final
412
+ // render) and one OPEN tail block; each becomes a cell, and the
413
+ // cell is the commit unit the compositor already had — so
414
+ // block-freeze needs no new commit machinery at all. A closed
415
+ // block is a DONE cell the natural loop freezes; the tail is the
416
+ // one cell left live, repainting in place.
417
+ if (this.#md === null) {
406
418
  this.#closeOpenThinking();
407
419
  this.#closeOpenText();
408
- this.#cells.push({ kind: "text", text, done: false });
420
+ this.#md = new MdStream();
421
+ this.#mdBase = this.#cells.length;
409
422
  }
423
+ this.#md.push(text);
424
+ this.#syncMd();
410
425
  this.#mark();
411
426
  }
427
+ /** TUI2-MD ⑤ — mirror the scanner's blocks onto cells. Append-only by
428
+ * construction: a block that has closed never changes, so a cell that
429
+ * is done is never touched again (and a committed one could not be).
430
+ * Only the trailing tail cell is rewritten per delta. */
431
+ #syncMd() {
432
+ if (this.#md === null)
433
+ return;
434
+ const blocks = this.#md.blocks();
435
+ const closed = this.#md.closed();
436
+ for (let i = 0; i < blocks.length; i += 1) {
437
+ const at = this.#mdBase + i;
438
+ const cell = this.#cells[at];
439
+ if (cell === undefined) {
440
+ this.#cells.push({ kind: "md", block: blocks[i], done: i < closed });
441
+ continue;
442
+ }
443
+ if (cell.kind !== "md" || cell.done)
444
+ continue;
445
+ cell.block = blocks[i];
446
+ cell.done = i < closed;
447
+ }
448
+ // the tail can vanish (a lone whitespace delta that turns out to be
449
+ // a blank line). Drop it only where dropping is safe: never below
450
+ // the commit frontier, where the bytes are already the terminal's.
451
+ const want = this.#mdBase + blocks.length;
452
+ while (this.#cells.length > Math.max(want, this.#committed) && this.#cells[this.#cells.length - 1].kind === "md")
453
+ this.#cells.pop();
454
+ }
455
+ /** TUI2-MD ⑤ — the message ends: the tail block closes and every md
456
+ * cell of this message is final. */
457
+ #endMd() {
458
+ if (this.#md === null)
459
+ return;
460
+ this.#md.end();
461
+ this.#syncMd();
462
+ for (let i = this.#mdBase; i < this.#cells.length; i += 1) {
463
+ const cell = this.#cells[i];
464
+ if (cell.kind === "md")
465
+ cell.done = true;
466
+ }
467
+ this.#md = null;
468
+ }
412
469
  textEnd() {
413
470
  if (!this.#isActive()) {
414
471
  this.#write("\n");
415
472
  return;
416
473
  }
474
+ this.#endMd();
417
475
  const last = this.#cells[this.#cells.length - 1];
418
476
  if (last !== undefined && last.kind === "text" && !last.done)
419
477
  last.done = true;
@@ -718,6 +776,20 @@ export class Body {
718
776
  /** Teardown — CSI r (the "no broken terminal" contract byte), the
719
777
  * chrome rows cleared, the cursor home at the input line. */
720
778
  exit() {
779
+ // TUI2-R3v2 ②: the mouse disable rides the SAME teardown as CSI r,
780
+ // and rides it BEFORE the docked guard — an un-docked compositor is
781
+ // exactly the state a superseded or half-torn-down one is in, and
782
+ // that is when a leak survives. The editor disables it too; this is
783
+ // the second belt on the one contract the round calls blocker-class.
784
+ //
785
+ // TTY-GATED, and the gate is not a nicety. A pipe has no mouse mode
786
+ // to reset, so the bytes would be pure noise there — and the pipe
787
+ // path is byte-identical by ruling. The unguarded version put
788
+ // "[?1000l[?1006l" into piped stdout and four gates caught it
789
+ // (compact-cli, tui-modes, tui-v7-planmode, tui2-r2-resume-picker):
790
+ // the invariant is about terminals, and a pipe is not one.
791
+ if (process.stdout.isTTY === true)
792
+ this.#write(MOUSE_OFF);
721
793
  if (!this.#docked) {
722
794
  // TUI2-R2pre ③: an un-docked compositor can still hold a listener
723
795
  // (it was superseded, or enter() ran and the dock was torn down by
@@ -739,6 +811,11 @@ export class Body {
739
811
  out.push(`\x1b[${Math.max(1, H - 1)};1H`);
740
812
  this.#write(out.join(""));
741
813
  }
814
+ /** TUI2-R3v2 ②: the panel's option rows as this frame placed them —
815
+ * absolute, 1-based. The click hit-test's single source. */
816
+ panelOptionRows() {
817
+ return this.#panelRowSpan;
818
+ }
742
819
  /** SIGWINCH: clear the OLD live area (recorded geometry, ED only —
743
820
  * zero LF, zero \x1b[3J — the shell history untouched), then the
744
821
  * full-redraw path at the NEW geometry (O(height), zero replay).
@@ -923,14 +1000,13 @@ export class Body {
923
1000
  inputExtra +
924
1001
  queueRows.length);
925
1002
  }
926
- const live = this.#cells.slice(this.#committed);
927
1003
  const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
928
1004
  const W = this.#opts.width();
929
1005
  let lines = 0;
930
1006
  let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
931
- for (const cell of live) {
932
- const rows = cellComponent(cell).render(W, ctx);
933
- lines += bodySpacing(prev, rows).length;
1007
+ for (let i = this.#committed; i < this.#cells.length; i += 1) {
1008
+ const rows = cellComponent(this.#cells[i]).render(W, ctx);
1009
+ lines += this.#space(i, prev, rows).length;
934
1010
  prev = rows;
935
1011
  }
936
1012
  return lines + CHROME_ROWS + inputExtra + this.#menuRows(W).length + queueRows.length;
@@ -963,7 +1039,7 @@ export class Body {
963
1039
  const cell = this.#cells[i];
964
1040
  const lines = cellComponent(cell).render(W, ctx);
965
1041
  this.#lineCache[i] = lines; // the cell's OWN rows — the cache stays raw
966
- this.#committedLines += bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines).length;
1042
+ this.#committedLines += this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines).length;
967
1043
  }
968
1044
  }
969
1045
  // 1. the natural commits — the leading DONE cells freeze: their
@@ -998,6 +1074,10 @@ export class Body {
998
1074
  const inputExtra = editor.rows.length - 1;
999
1075
  const chromeRows = CHROME_ROWS + inputExtra + menuRows.length + queueRows.length;
1000
1076
  let liveLines = [];
1077
+ // TUI2-R3v2 ②: where this frame put the panel's option rows, relative
1078
+ // to the live region's top. Resolved to ABSOLUTE screen rows once
1079
+ // liveTop is known, below.
1080
+ let panelSpan = null;
1001
1081
  const panel = this.#panelState?.() ?? null;
1002
1082
  // TUI2-R1.5 ⑦(a) (VD-8): the sheet is an OVERLAY, and the frame it
1003
1083
  // opens on — and the one it closes on — take the full-redraw path.
@@ -1024,7 +1104,12 @@ export class Body {
1024
1104
  // the W11 blank would separate it from the frozen content).
1025
1105
  // The cap is exact, so the force-commit loop never fires. W22:
1026
1106
  // the queue band sits below the panel — the cap shrinks by it.
1027
- liveLines = panelRowsOf(panel, W, Math.max(1, H - 4 - inputExtra - queueRows.length));
1107
+ // TUI2-R3v2 ②: the rows and the CLICKABLE span come from one
1108
+ // call, so the hit-test reads the arithmetic that placed the
1109
+ // rows rather than a second copy of it.
1110
+ const frame = panelFrameOf(panel, W, Math.max(1, H - 4 - inputExtra - queueRows.length));
1111
+ liveLines = frame.rows;
1112
+ panelSpan = frame.options;
1028
1113
  }
1029
1114
  else {
1030
1115
  // TUI2-R2 ⑤ (D, candidate 1): the FOCUS — the cell the next ctrl+r
@@ -1042,7 +1127,7 @@ export class Body {
1042
1127
  // nowhere else, which is what makes "exactly one" structural
1043
1128
  if (i === focus && rows.length > 0)
1044
1129
  rows[0] = focusToken(rows[0], W);
1045
- liveLines.push(...bodySpacing(prev, rows));
1130
+ liveLines.push(...this.#space(i, prev, rows));
1046
1131
  prev = rows;
1047
1132
  }
1048
1133
  }
@@ -1063,7 +1148,7 @@ export class Body {
1063
1148
  const rows = cellComponent(cell).render(W, ctx);
1064
1149
  if (i === focus && rows.length > 0)
1065
1150
  rows[0] = focusToken(rows[0], W);
1066
- liveLines.push(...bodySpacing(prev, rows));
1151
+ liveLines.push(...this.#space(i, prev, rows));
1067
1152
  prev = rows;
1068
1153
  }
1069
1154
  }
@@ -1073,6 +1158,11 @@ export class Body {
1073
1158
  // shows the bottom H rows; the live region anchors to the bottom.
1074
1159
  const liveRowsTotal = liveLines.length + chromeRows;
1075
1160
  const liveTop = Math.min(this.#committedLines, H - liveRowsTotal) + 1;
1161
+ // TUI2-R3v2 ②: the option rows' ABSOLUTE screen rows, recorded per
1162
+ // frame. A click is answered against the frame the human was looking
1163
+ // at when they clicked, which is this one.
1164
+ this.#panelRowSpan =
1165
+ panelSpan === null ? null : { top: liveTop + panelSpan.offset, count: panelSpan.count, first: panelSpan.first };
1076
1166
  // 5. the frame bytes.
1077
1167
  const out = [];
1078
1168
  out.push("\x1b[?2026h"); // synchronized output ON (DEC 2026)
@@ -1106,6 +1196,23 @@ export class Body {
1106
1196
  // composer (N = 1 ⇒ H−2, the retired hard-coded anchor).
1107
1197
  this.#lastAnchorRow = H - 1 - editor.rows.length + editor.markerRow;
1108
1198
  }
1199
+ /** TUI2-MD ⑤ — the join blank between cell i−1 and cell i.
1200
+ *
1201
+ * W11's formula ("a blank above a row that is itself a block, or
1202
+ * whose previous sibling was taller than one row") reads ROW COUNTS,
1203
+ * and markdown's rhythm is not a row count: a heading wants a blank
1204
+ * above and below it even between two one-row paragraphs, and two
1205
+ * rows of one fence want none even when a long code line folds to
1206
+ * two. So between two MARKDOWN cells the formula steps aside and the
1207
+ * block's own `gap` decides — the renderer owns the rhythm, which is
1208
+ * the only place that knows it. Every other pair is untouched,
1209
+ * including the boundary INTO a markdown message (the blank under the
1210
+ * user chip is still W11's). */
1211
+ #space(i, prev, rows) {
1212
+ if (i > 0 && this.#cells[i]?.kind === "md" && this.#cells[i - 1]?.kind === "md")
1213
+ return rows;
1214
+ return bodySpacing(prev, rows);
1215
+ }
1109
1216
  /** Commit the cell at index i: render + cache its lines (immutable —
1110
1217
  * the force-committed form freezes at the current render), advance
1111
1218
  * the bookkeeping — and collect the lines for this frame's writes.
@@ -1128,7 +1235,7 @@ export class Body {
1128
1235
  if (cell.kind === "tool" && lines.some((l) => l.includes("ctrl+r")))
1129
1236
  this.#collapsed.unshift(i);
1130
1237
  this.#lineCache[i] = lines;
1131
- const placed = bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines);
1238
+ const placed = this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines);
1132
1239
  this.#committed += 1;
1133
1240
  this.#committedLines += placed.length;
1134
1241
  this.#committedLinesThisFrame.push(...placed);
@@ -1544,7 +1651,7 @@ export class Body {
1544
1651
  // cell (the V6-1 frozen-loop finding — the banner vanished).
1545
1652
  const frozen = [];
1546
1653
  for (let i = 0; i < this.#committedAtFrameStart; i += 1) {
1547
- frozen.push(...bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, this.#lineCache[i]));
1654
+ frozen.push(...this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, this.#lineCache[i]));
1548
1655
  }
1549
1656
  // A8: the march is the WINDOW — the model's last H rows. When the
1550
1657
  // model total (committed + live + chrome) exceeds H, the window's
@@ -1884,8 +1991,11 @@ export class Body {
1884
1991
  return i === undefined ? null : (this.#cells[i] ?? null);
1885
1992
  }
1886
1993
  /** Close an open TEXT cell when a new cell starts (see v2d — the
1887
- * runtime emits no text_end; the next cell is the close signal). */
1994
+ * runtime emits no text_end; the next cell is the close signal).
1995
+ * TUI2-MD ⑤: that same signal ends the markdown message — the open
1996
+ * tail block closes and its cell becomes commit-eligible. */
1888
1997
  #closeOpenText() {
1998
+ this.#endMd();
1889
1999
  const last = this.#cells[this.#cells.length - 1];
1890
2000
  if (last !== undefined && last.kind === "text" && !last.done)
1891
2001
  last.done = true;
@@ -1922,6 +2032,12 @@ export class Dock {
1922
2032
  onResize() {
1923
2033
  compositorRef?.onResize();
1924
2034
  }
2035
+ /** TUI2-R3v2 ②: where the last frame put the panel's clickable option
2036
+ * rows (absolute screen rows). The editor binds this and does no row
2037
+ * arithmetic of its own. */
2038
+ panelOptionRows() {
2039
+ return compositorRef?.panelOptionRows() ?? null;
2040
+ }
1925
2041
  setStatus(text, hint) {
1926
2042
  compositorRef?.setStatus(text, hint ?? null);
1927
2043
  }
package/dist/editor.d.ts CHANGED
@@ -23,9 +23,20 @@
23
23
  */
24
24
  import { charWidth, displayWidth, widthOf } from "./width.js";
25
25
  export { charWidth, displayWidth, widthOf };
26
- import { type PanelState, type PanelVerdict, type PanelView } from "./approval-panel.js";
26
+ import { type PanelState, type PanelVerdict, type PanelView, type SaferOption } from "./approval-panel.js";
27
27
  import { type AtItem, type AtMatch } from "./at-picker.js";
28
28
  import { type SessionCardView, type SessionPickState } from "./session-picker.js";
29
+ /**
30
+ * TUI2-R3v2 ② — the mouse-mode bytes, stated once.
31
+ *
32
+ * ?1000 is the button-event report and ?1006 is the SGR encoding that
33
+ * makes it parseable past column 95 (the legacy X10 encoding packs the
34
+ * coordinate into one byte and simply breaks on a wide terminal). Both
35
+ * go on together and come off together; a terminal left with either one
36
+ * set is a terminal that prints escape bytes at the shell prompt.
37
+ */
38
+ export declare const MOUSE_ON = "\u001B[?1000h\u001B[?1006h";
39
+ export declare const MOUSE_OFF = "\u001B[?1000l\u001B[?1006l";
29
40
  export declare const PROMPT = "\u258C ";
30
41
  export declare const PROMPT_WIDTH: number;
31
42
  /** v3 §04 — the slash-command menu's command table (English one-liners). */
@@ -116,7 +127,9 @@ export declare class Editor {
116
127
  /** W21: open the approval panel. The current buffer is stashed
117
128
  * (restored at close — commit AND cancel), the panel takes the
118
129
  * keys and the input row's lead, the menu closes. */
119
- beginPanel(view: PanelView, onCommit: (v: PanelVerdict) => void): void;
130
+ beginPanel(view: PanelView, onCommit: (v: PanelVerdict) => void, opts?: {
131
+ safer?: () => Promise<readonly SaferOption[] | null>;
132
+ }): void;
120
133
  /** W21: cancel the panel — the SIGINT path's pair to beginPanel. */
121
134
  cancelPanel(): void;
122
135
  /** W21: the compositor's bound view — the phase/selection while the
@@ -130,4 +143,14 @@ export declare class Editor {
130
143
  selfRender(): void;
131
144
  /** Feed raw stdin bytes — the parser. Public for unit tests. */
132
145
  feed(raw: Uint8Array): void;
146
+ /** TUI2-R3v2 ②: the compositor reports where it PUT the option rows.
147
+ * The editor does no row arithmetic of its own — the surface that
148
+ * placed them is the only thing that can say where they are, and a
149
+ * second copy of that sum is how a hit-test comes to disagree with
150
+ * the picture. */
151
+ bindPanelRows(fn: (() => {
152
+ top: number;
153
+ count: number;
154
+ first?: number;
155
+ } | null) | null): void;
133
156
  }