@vincemakes/kiso-tui 0.11.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,7 +47,8 @@ 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";
@@ -117,6 +118,9 @@ export class Body {
117
118
  #rolledHeads = new Set();
118
119
  #write;
119
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;
120
124
  // the chrome state (the Dock façade)
121
125
  #status = "";
122
126
  #statusHint = null;
@@ -772,6 +776,20 @@ export class Body {
772
776
  /** Teardown — CSI r (the "no broken terminal" contract byte), the
773
777
  * chrome rows cleared, the cursor home at the input line. */
774
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);
775
793
  if (!this.#docked) {
776
794
  // TUI2-R2pre ③: an un-docked compositor can still hold a listener
777
795
  // (it was superseded, or enter() ran and the dock was torn down by
@@ -793,6 +811,11 @@ export class Body {
793
811
  out.push(`\x1b[${Math.max(1, H - 1)};1H`);
794
812
  this.#write(out.join(""));
795
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
+ }
796
819
  /** SIGWINCH: clear the OLD live area (recorded geometry, ED only —
797
820
  * zero LF, zero \x1b[3J — the shell history untouched), then the
798
821
  * full-redraw path at the NEW geometry (O(height), zero replay).
@@ -1051,6 +1074,10 @@ export class Body {
1051
1074
  const inputExtra = editor.rows.length - 1;
1052
1075
  const chromeRows = CHROME_ROWS + inputExtra + menuRows.length + queueRows.length;
1053
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;
1054
1081
  const panel = this.#panelState?.() ?? null;
1055
1082
  // TUI2-R1.5 ⑦(a) (VD-8): the sheet is an OVERLAY, and the frame it
1056
1083
  // opens on — and the one it closes on — take the full-redraw path.
@@ -1077,7 +1104,12 @@ export class Body {
1077
1104
  // the W11 blank would separate it from the frozen content).
1078
1105
  // The cap is exact, so the force-commit loop never fires. W22:
1079
1106
  // the queue band sits below the panel — the cap shrinks by it.
1080
- 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;
1081
1113
  }
1082
1114
  else {
1083
1115
  // TUI2-R2 ⑤ (D, candidate 1): the FOCUS — the cell the next ctrl+r
@@ -1126,6 +1158,11 @@ export class Body {
1126
1158
  // shows the bottom H rows; the live region anchors to the bottom.
1127
1159
  const liveRowsTotal = liveLines.length + chromeRows;
1128
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 };
1129
1166
  // 5. the frame bytes.
1130
1167
  const out = [];
1131
1168
  out.push("\x1b[?2026h"); // synchronized output ON (DEC 2026)
@@ -1995,6 +2032,12 @@ export class Dock {
1995
2032
  onResize() {
1996
2033
  compositorRef?.onResize();
1997
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
+ }
1998
2041
  setStatus(text, hint) {
1999
2042
  compositorRef?.setStatus(text, hint ?? null);
2000
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
  }
package/dist/editor.js CHANGED
@@ -26,7 +26,7 @@ import { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
26
26
  // authority) — re-exported so the editor's public surface is unchanged.
27
27
  export { charWidth, displayWidth, widthOf };
28
28
  import { palette } from "./render.js";
29
- import { PICK_MAX } from "./approval-panel.js";
29
+ import { PICK_MAX, panelOptions, SAFER_DEGRADED, } from "./approval-panel.js";
30
30
  // KC3.5: the panel-slot dispatchers — the ask branch folded into the
31
31
  // W21 lead/rows, so this file keeps ONE panel and one key owner.
32
32
  import { askCommitCustom, askKey, askStart, panelLead } from "./ask-panel.js";
@@ -38,6 +38,17 @@ import { sessionFilter } from "./session-picker.js";
38
38
  // TUI v4 #16d: the input row is the blue brick + the edit area — the
39
39
  // "you>" text is gone (the brick IS the prompt; the pipe path's readline
40
40
  // prompt keeps its own "you> " — v2a line mode, byte-for-byte).
41
+ /**
42
+ * TUI2-R3v2 ② — the mouse-mode bytes, stated once.
43
+ *
44
+ * ?1000 is the button-event report and ?1006 is the SGR encoding that
45
+ * makes it parseable past column 95 (the legacy X10 encoding packs the
46
+ * coordinate into one byte and simply breaks on a wide terminal). Both
47
+ * go on together and come off together; a terminal left with either one
48
+ * set is a terminal that prints escape bytes at the shell prompt.
49
+ */
50
+ export const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
51
+ export const MOUSE_OFF = "\x1b[?1000l\x1b[?1006l";
41
52
  export const PROMPT = "▌ ";
42
53
  export const PROMPT_WIDTH = displayWidth(PROMPT);
43
54
  export const MENU_ITEMS = [
@@ -96,6 +107,18 @@ export class Editor {
96
107
  // user's next turn.
97
108
  #panel = null;
98
109
  #pasting = false;
110
+ /** TUI2-R3v2 ①: one-shot — a panel that just closed swallows the
111
+ * habitual trailing enter rather than submitting the restored draft. */
112
+ #swallowEnter = false;
113
+ /** TUI2-R3v2 ②: whether SGR 1006 reporting is currently enabled. */
114
+ #mouseOn = false;
115
+ /** TUI2-R3v2 ③: the safer ask's generation. A panel the human escaped
116
+ * must not be resurrected by a promise nobody is waiting for. */
117
+ #saferToken = 0;
118
+ /** TUI2-R3v2 ②: where the compositor put the panel's option rows this
119
+ * frame (absolute 1-based screen rows). The editor owns no geometry —
120
+ * it asks the surface that placed them. */
121
+ #panelRows = null;
99
122
  #lineCb = null;
100
123
  #pendingLines = []; // submits before onLine is wired (startup) — never dropped
101
124
  #sigintCb = null;
@@ -423,11 +446,13 @@ export class Editor {
423
446
  this.#atOpen = true;
424
447
  this.#atSel = 0;
425
448
  this.#atList = this.#atItems(); // §5: listed per OPEN, never per keystroke
449
+ this.#syncMouse();
426
450
  }
427
451
  #atClose() {
428
452
  this.#atOpen = false;
429
453
  this.#atSel = 0;
430
454
  this.#atList = null;
455
+ this.#syncMouse();
431
456
  }
432
457
  /**
433
458
  * KC3 §3 — accept: the token becomes `@<path> `.
@@ -470,6 +495,7 @@ export class Editor {
470
495
  this.#pickCards = cards;
471
496
  this.#pickCommit = onPick;
472
497
  this.#pickSel = 0;
498
+ this.#syncMouse();
473
499
  this.#chars = [];
474
500
  this.#cursor = 0;
475
501
  this.#reflow();
@@ -507,6 +533,7 @@ export class Editor {
507
533
  this.#pickCards = null;
508
534
  this.#pickCommit = null;
509
535
  this.#pickSel = 0;
536
+ this.#syncMouse();
510
537
  this.#chars = [];
511
538
  this.#cursor = 0;
512
539
  this.#reflow();
@@ -538,16 +565,18 @@ export class Editor {
538
565
  /** W21: open the approval panel. The current buffer is stashed
539
566
  * (restored at close — commit AND cancel), the panel takes the
540
567
  * keys and the input row's lead, the menu closes. */
541
- beginPanel(view, onCommit) {
568
+ beginPanel(view, onCommit, opts) {
542
569
  this.#panel = {
543
570
  view,
544
571
  phase: "options",
545
- sel: 0,
572
+ cursor: 0,
573
+ note: null,
574
+ safer: opts?.safer,
575
+ saferRun: null,
546
576
  ask: view.ask === undefined ? null : askStart(view.ask),
547
577
  // TUI2-R2 ④: the pick's walk — present exactly when the view is
548
578
  // a pick, the same contract the ask's runtime has.
549
579
  pick: view.pick === undefined ? null : { cursor: 0, phase: "options" },
550
- amend: "yes",
551
580
  onCommit,
552
581
  stash: { chars: this.#chars, cursor: this.#cursor, scroll: this.#scroll },
553
582
  };
@@ -559,6 +588,7 @@ export class Editor {
559
588
  this.#menuSel = 0;
560
589
  this.#queuePopMode = false; // W22: the panel owns the keys while up
561
590
  this.#atClose(); // KC3 §3: and the picker closes with everything else
591
+ this.#syncMouse();
562
592
  this.#onRender();
563
593
  }
564
594
  /** W21: cancel the panel — the SIGINT path's pair to beginPanel. */
@@ -574,7 +604,9 @@ export class Editor {
574
604
  return {
575
605
  view: panel.view,
576
606
  phase: panel.phase,
577
- sel: panel.sel,
607
+ cursor: panel.cursor,
608
+ ...(panel.note === null ? {} : { note: panel.note }),
609
+ ...(panel.saferRun === null ? {} : { safer: panel.saferRun }),
578
610
  ...(panel.ask === null ? {} : { ask: panel.ask }),
579
611
  ...(panel.pick === null ? {} : { pick: panel.pick }),
580
612
  };
@@ -584,6 +616,15 @@ export class Editor {
584
616
  return;
585
617
  this.#entered = true;
586
618
  process.stdin.setRawMode(true);
619
+ // TUI2-R3v2 ②: the DEFENSIVE reset, first byte out.
620
+ //
621
+ // Mouse reporting is process state the terminal keeps, not state we
622
+ // keep, so a previous kiso that died with a panel open (kill -9, a
623
+ // panic, a closed laptop) left the terminal reporting clicks to
624
+ // whatever ran next — and nothing in that dead process can ever
625
+ // clean up after it. A fresh process is the only thing left that
626
+ // can, so it does, unconditionally, before it draws anything.
627
+ process.stdout.write(MOUSE_OFF);
587
628
  process.stdout.write("\x1b[?2004h"); // bracketed paste ON
588
629
  process.stdin.on("data", this.#onData);
589
630
  this.#onRender();
@@ -593,10 +634,43 @@ export class Editor {
593
634
  return;
594
635
  this.#entered = false;
595
636
  process.stdin.off("data", this.#onData);
637
+ // TUI2-R3v2 ②: unconditional, and BEFORE raw mode goes away — a
638
+ // terminal left reporting mouse events prints escape bytes at the
639
+ // shell prompt on every click and every scroll, and the user's only
640
+ // fix is `reset`. The flag is not consulted: exit() is the last
641
+ // chance this process gets, and emitting six harmless bytes twice
642
+ // is not a cost worth reasoning about.
643
+ process.stdout.write(MOUSE_OFF);
644
+ this.#mouseOn = false;
596
645
  process.stdout.write("\x1b[?2004l"); // bracketed paste OFF
597
646
  process.stdin.setRawMode(false);
598
647
  this.#closedResolve();
599
648
  }
649
+ /**
650
+ * TUI2-R3v2 ② — mouse reporting follows the SELECTION SURFACES and
651
+ * nothing else.
652
+ *
653
+ * While it is on, the terminal's own text selection changes behaviour
654
+ * (shift+drag still selects on every terminal that matters, but plain
655
+ * drag-to-copy does not), so leaving it on for the whole session would
656
+ * tax every copy-paste in the product to pay for a gesture that only
657
+ * means something while a list is up. It goes on when one opens and
658
+ * off when it closes — and both calls are idempotent, because the
659
+ * surfaces nest (a panel can open over a picker) and the bytes must
660
+ * not depend on the order they unwind in.
661
+ */
662
+ #setMouse(on) {
663
+ if (this.#mouseOn === on)
664
+ return;
665
+ this.#mouseOn = on;
666
+ if (this.#entered)
667
+ process.stdout.write(on ? MOUSE_ON : MOUSE_OFF);
668
+ }
669
+ /** The surfaces that own a selection — the approval/ask/pick panel, the
670
+ * session picker and the @ picker. Any one of them up = reporting on. */
671
+ #syncMouse() {
672
+ this.#setMouse(this.#panel !== null || this.#pickCards !== null || this.#atUp());
673
+ }
600
674
  /** The row's own render when the dock is inactive (a TTY without a
601
675
  * real size): \r + clear + blue brick prompt + visible + cursor
602
676
  * column. */
@@ -607,7 +681,7 @@ export class Editor {
607
681
  // W21: the panel's lead owns the row while up (the brick returns
608
682
  // when the panel closes).
609
683
  const panel = this.#panel;
610
- const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.sel, panel.ask ?? undefined) : `${p.bold}${PROMPT}${p.reset}`;
684
+ const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.cursor, panel.ask ?? undefined) : `${p.bold}${PROMPT}${p.reset}`;
611
685
  // W23: the ONE width authority — leadWidth(lead), the ANSI-stripped
612
686
  // visible width (the styled panel lead / the styled brick measure
613
687
  // the same as their plain text — a lead can never measure
@@ -635,15 +709,25 @@ export class Editor {
635
709
  let i = 0;
636
710
  while (i < text.length) {
637
711
  const c = text[i];
712
+ // TUI2-R3v2 ①: the one-shot enter guard a just-closed panel arms
713
+ // (see #panelClose). It sits at the very top of the loop because
714
+ // the byte it must not let through is the FIRST byte after the
715
+ // close, and it disarms on anything else in the same breath.
716
+ if (this.#swallowEnter) {
717
+ this.#swallowEnter = false;
718
+ if (this.#panel === null && (c === "\x0d" || c === "\x0a")) {
719
+ i += 1;
720
+ continue;
721
+ }
722
+ }
638
723
  if (this.#panel !== null) {
639
- // W21: the panel owns the keys — the digits/y/n select in
640
- // the options phase (digit 2 jumps to the rule input), tab
641
- // opens the amend (approval only), esc backs out (rule/
642
- // amend options, selection rest, rest cancel), enter
643
- // commits by phase. CSI/SS3 and the editing keys still ride
644
- // the normal chain below (the rule/amend lines are free
645
- // text); ctrl-c still rides the SIGINT handler (which
646
- // cancels the panel).
724
+ // W21: the panel owns the keys — a digit CONFIRMS its row in
725
+ // the options phase, tab opens the amend (approval only), esc
726
+ // backs out (amend options, options cancel), enter takes
727
+ // the highlighted row. CSI/SS3 and the editing keys still ride
728
+ // the normal chain below (the amend line is free text);
729
+ // ctrl-c still rides the SIGINT handler (which cancels the
730
+ // panel).
647
731
  const panel = this.#panel;
648
732
  // KC3.5: an ASK panel routes its own keys — the digits pick
649
733
  // (single-select advances, multi toggles), space toggles at
@@ -738,46 +822,76 @@ export class Editor {
738
822
  i += 1;
739
823
  continue;
740
824
  }
741
- // TUI2-R2 the shortcut keys belong to the OPTIONS phase and
742
- // to it alone.
825
+ // TUI2-R3v2 ③: the safer list answers the SAME keys the approval
826
+ // list does — one interaction model means the new surface is not
827
+ // an exception to it. A digit takes its row (the way back
828
+ // included, as the last one).
829
+ if (panel.phase === "safer" && c !== undefined && c >= "1" && c <= "9") {
830
+ this.#saferConfirm(Number(c) - 1);
831
+ i += 1;
832
+ continue;
833
+ }
834
+ // while the ask is in flight the panel owns every printable key
835
+ // and answers to none of them — esc (above) is the only gesture
836
+ // with a meaning, and a stray letter must not reach the composer.
837
+ if (panel.phase === "asking" && c !== undefined && c >= " " && c !== "\x7f") {
838
+ i += 1;
839
+ continue;
840
+ }
841
+ // TUI2-R3v2 ① — the digit CONFIRMS, and it confirms on the
842
+ // keypress.
843
+ //
844
+ // The retired model made a digit a selection and Enter the
845
+ // commit, which meant the fastest path through an approval was
846
+ // two keys and the hint line had to teach both. The list makes
847
+ // the digit redundant as a selector — the bar is already showing
848
+ // what is selected — so the digit becomes what a human pressing
849
+ // a number on a numbered list means by it: THAT one.
850
+ //
851
+ // A digit past the list is INERT (the R2 pick panel's rule,
852
+ // inherited whole): an option nobody has is never taken, and a
853
+ // mistyped 7 must not fall through to the composer underneath.
743
854
  //
744
- // `1`/`y` select yes and `3`/`n` select no, and they used to be
745
- // applied in every phase of every flavour: the `i += 1;
746
- // continue;` sat OUTSIDE the phase check, so a phase where the
747
- // key meant nothing swallowed it anyway. A phase where a letter
748
- // means nothing is exactly a phase where a human is typing
749
- // prose — so every y, n, 1 and 3 vanished from the line,
750
- // silently, with no error and no visible cause. "yes, run it
751
- // now 13" committed as "es, ru it ow ".
855
+ // The guard is the options phase and nothing else. The typed
856
+ // phase is prose that is the R2 slice-⑧ finding, and it is
857
+ // why this branch sits below the enter/esc/tab handlers and
858
+ // above nothing at all: "yes, run 13 of them" keeps its digits.
859
+ if (panel.phase === "options" && panel.ask === null && panel.pick === null && c !== undefined && c >= "1" && c <= "9") {
860
+ this.#panelConfirm(Number(c) - 1);
861
+ i += 1;
862
+ continue;
863
+ }
864
+ // TUI2-R2 ⑧, carried forward — the shortcut keys belong to the
865
+ // OPTIONS phase and to it alone.
752
866
  //
753
- // Three typed phases were affected: the ask's custom answer,
754
- // the approval panel's rule input, and its amend/feedback line.
755
- // The rule input is the one that mattered mostit writes a
756
- // DURABLE don't-ask-again rule, so a dropped character persists
757
- // a rule the human never typed.
867
+ // `y`/`n` used to be applied in every phase of every flavour:
868
+ // the `i += 1; continue;` sat OUTSIDE the phase check, so a
869
+ // phase where the key meant nothing swallowed it anywayand a
870
+ // phase where a letter means nothing is exactly a phase where a
871
+ // human is typing prose. Every y and n vanished from the line,
872
+ // silently. "yes, run it now" committed as "es, ru it ow".
758
873
  //
759
- // Slice met the same mechanism on the new pick panel
760
- // ("openai/deepseek-reasoner" -> "opeai/deepseek-reasoer") and
761
- // guarded pick alone, because the rest was a behaviour change
762
- // owed its own red. This is that guard, stated once for every
763
- // flavour: the options phase keeps its keys, and every typed
764
- // phase keeps its text.
874
+ // The guard survives the migration unchanged in spirit and
875
+ // simpler in fact: there is now ONE typed phase instead of
876
+ // three, and the letters reach only the list.
877
+ //
878
+ // The letters stay because they are the two answers this panel
879
+ // has always taken and a decade of muscle memory types them.
880
+ // They are ALIASES for rows, not a second model: `y` is the
881
+ // first option, `n` is the last, and on an approval the last
882
+ // option opens the composer — so the old "n then enter" still
883
+ // lands the same bare denial it always did.
765
884
  const optionsPhase = panel.pick === null && // a pick has no yes and no no
766
- panel.phase === "options" && // the rule / amend lines are prose
885
+ panel.phase === "options" && // the amend line is prose
767
886
  (panel.ask === null || panel.ask.phase === "options"); // and so is a typed ask answer
768
- if (optionsPhase) {
769
- if (c === "1" || c === "y" || c === "Y") {
770
- this.#panelSelect(1);
771
- i += 1;
772
- continue;
773
- }
774
- if (c === "2" && panel.view.flavor === "approval") {
775
- this.#panelRule();
887
+ if (optionsPhase && panel.ask === null) {
888
+ if (c === "y" || c === "Y") {
889
+ this.#panelConfirm(0);
776
890
  i += 1;
777
891
  continue;
778
892
  }
779
- if (c === "3" || c === "n" || c === "N") {
780
- this.#panelSelect(3);
893
+ if (c === "n" || c === "N") {
894
+ this.#panelConfirm(panelOptions(panel.view).length - 1);
781
895
  i += 1;
782
896
  continue;
783
897
  }
@@ -786,7 +900,17 @@ export class Editor {
786
900
  if (c === "\x1b") {
787
901
  const rest = text.slice(i + 1);
788
902
  if (rest.startsWith("[")) {
789
- const m = rest.match(/^\[([0-9;?]*)([A-Za-z~])/);
903
+ // TUI2-R3v2 ②: `<` joins the parameter class.
904
+ //
905
+ // An SGR 1006 mouse report is `\x1b[<0;COL;ROWM`, and the
906
+ // retired character class ([0-9;?]) did not contain `<`. The
907
+ // match failed, the branch below PARKED the whole thing as an
908
+ // incomplete CSI, and #pending grew forever: every keystroke
909
+ // after the first click was appended to a sequence that could
910
+ // never complete. The editor went deaf. It never happened
911
+ // because nothing ever enabled reporting — which is exactly
912
+ // the kind of latent break turning a feature on discovers.
913
+ const m = rest.match(/^\[([0-9;?<]*)([A-Za-z~])/);
790
914
  if (m === null) {
791
915
  this.#pending = text.slice(i); // incomplete CSI — wait for more
792
916
  break;
@@ -968,7 +1092,58 @@ export class Editor {
968
1092
  }
969
1093
  }
970
1094
  }
1095
+ /**
1096
+ * TUI2-R3v2 ② — one gesture, and only one: a plain LEFT PRESS on an
1097
+ * option row is that row's digit.
1098
+ *
1099
+ * Everything else is dropped, and the list of everything else is the
1100
+ * point. A release (`m`) is not a second click. Button 64/65 is the
1101
+ * wheel — scrolling past a panel must not answer it. Bit 32 is a
1102
+ * motion report, so a drag over the list is a drag, not four
1103
+ * approvals. Buttons 1 and 2 are middle and right, which mean paste
1104
+ * and context-menu everywhere else and would mean "approve" here.
1105
+ * The stakes are a side effect the human did not ask for, and an
1106
+ * ambiguous mouse event is not consent.
1107
+ */
1108
+ #mouseEvent(params, press) {
1109
+ if (!press)
1110
+ return; // the press already decided; the release is noise
1111
+ const [button, , row] = params.slice(1).split(";").map(Number);
1112
+ if (button !== 0)
1113
+ return; // wheel (64/65), motion (32+), middle/right
1114
+ // TUI2-R3v2 ③: a click works on BOTH lists — one interaction model
1115
+ // means the safer alternatives are clickable for the same reason the
1116
+ // original choices are.
1117
+ if (this.#panel === null || (this.#panel.phase !== "options" && this.#panel.phase !== "safer"))
1118
+ return;
1119
+ const span = this.#panelRows?.();
1120
+ if (span == null || row === undefined || !Number.isFinite(row))
1121
+ return;
1122
+ const offset = row - span.top;
1123
+ if (offset < 0 || offset >= span.count)
1124
+ return; // outside the list — inert
1125
+ if (this.#panel.phase === "safer")
1126
+ this.#saferConfirm(offset);
1127
+ else
1128
+ this.#panelConfirm((span.first ?? 0) + offset);
1129
+ }
1130
+ /** TUI2-R3v2 ②: the compositor reports where it PUT the option rows.
1131
+ * The editor does no row arithmetic of its own — the surface that
1132
+ * placed them is the only thing that can say where they are, and a
1133
+ * second copy of that sum is how a hit-test comes to disagree with
1134
+ * the picture. */
1135
+ bindPanelRows(fn) {
1136
+ this.#panelRows = fn;
1137
+ }
971
1138
  #csi(params, final) {
1139
+ // TUI2-R3v2 ②: an SGR 1006 report — `\x1b[<b;col;rowM` (press) or
1140
+ // `...m` (release). It is routed FIRST because a `<` parameter is
1141
+ // never anything else, and because a mouse byte must never fall
1142
+ // through to a key handler.
1143
+ if (params.startsWith("<")) {
1144
+ this.#mouseEvent(params, final === "M");
1145
+ return;
1146
+ }
972
1147
  // KC1 §4 — Shift+Enter WHERE THE TERMINAL ENCODES IT: kitty's
973
1148
  // CSI-u (ESC [ 13;2 u) and xterm's modifyOtherKeys (ESC [ 27;2;13 ~).
974
1149
  // Never claimed universal — Ctrl+J is the everywhere baseline; a
@@ -1020,6 +1195,16 @@ export class Editor {
1020
1195
  }
1021
1196
  else if (this.#panel.ask !== null && this.#panel.ask.phase === "options")
1022
1197
  this.#askStep(final === "A" ? "up" : "down");
1198
+ // TUI2-R3v2 ①: the approval/simple panel joins them. It was the
1199
+ // one panel flavour with no ↑↓ role, because it had no cursor to
1200
+ // move; it has one now, and the gesture is the same one the
1201
+ // pick, the ask, the session picker and the @ picker already
1202
+ // answer to. ONE interaction model is the round's acceptance
1203
+ // criterion, and this branch is where it stops being four.
1204
+ else if (this.#panel.phase === "safer")
1205
+ this.#saferMove(final === "A" ? -1 : 1);
1206
+ else if (this.#panel.phase !== "asking")
1207
+ this.#panelMove(final === "A" ? -1 : 1);
1023
1208
  }
1024
1209
  else if (this.#pickUp()) {
1025
1210
  // TUI2-R2 ②: the session picker owns ↑↓ while up — the
@@ -1103,35 +1288,159 @@ export class Editor {
1103
1288
  this.#reflow();
1104
1289
  this.#verticalGoalCol = goal; // the walk re-arms it (the reflow's reset is for every OTHER key)
1105
1290
  }
1106
- // ---- W21: the panel state machine ----
1107
- #panelSelect(sel) {
1291
+ // ---- W21 / TUI2-R3v2 ①: the panel state machine ----
1292
+ /** ↑↓ — the bar walks the list and STOPS at both ends. A list that
1293
+ * wraps makes the fastest gesture (hold ↓ to reach the bottom) into
1294
+ * a gamble about where you landed, and the bottom option here is the
1295
+ * denial. */
1296
+ #panelMove(delta) {
1108
1297
  const panel = this.#panel;
1109
- if (panel === null)
1298
+ if (panel === null || panel.phase !== "options")
1110
1299
  return;
1111
- panel.sel = sel;
1300
+ const n = panelOptions(panel.view).length;
1301
+ panel.cursor = Math.max(0, Math.min(n - 1, panel.cursor + delta));
1112
1302
  this.#onRender();
1113
1303
  }
1114
- /** digit 2 — the rule input: the buffer prefilled with the tool name
1115
- * (the option-2 prefill; enter commits the rule). */
1116
- #panelRule() {
1304
+ /**
1305
+ * Take the option at `index` the ONE place a panel choice resolves,
1306
+ * whether the human pressed a digit, pressed ⏎ on the bar, typed the
1307
+ * y/n alias, or clicked the row (slice ②). Four gestures, one branch:
1308
+ * a click cannot mean something a digit does not.
1309
+ *
1310
+ * Every kind but `deny` on an approval resolves IMMEDIATELY. That is
1311
+ * the round's whole claim — the durable rule included, because the
1312
+ * rule the machinery supports is exactly "this tool", and asking the
1313
+ * human to confirm a value they cannot change was the old model's
1314
+ * ceremony, not a safeguard.
1315
+ */
1316
+ #panelConfirm(index) {
1317
+ const panel = this.#panel;
1318
+ if (panel === null || panel.phase !== "options")
1319
+ return;
1320
+ const options = panelOptions(panel.view);
1321
+ const option = options[index];
1322
+ if (option === undefined)
1323
+ return; // a digit past the list is inert
1324
+ panel.cursor = index;
1325
+ switch (option.kind) {
1326
+ case "allow":
1327
+ this.#panelClose({ action: "allow", reason: "" });
1328
+ return;
1329
+ case "rule":
1330
+ this.#panelClose({ action: "allow-rule", rule: panel.view.name });
1331
+ return;
1332
+ case "safer":
1333
+ this.#panelSafer();
1334
+ return;
1335
+ case "deny":
1336
+ // the approval flavor's denial is "let me tell it what to do
1337
+ // instead", so it opens the composer; the simple flavors have
1338
+ // nothing to tell anyone and resolve on the spot.
1339
+ if (panel.view.flavor === "approval")
1340
+ this.#panelAmend();
1341
+ else
1342
+ this.#panelClose({ action: "deny", reason: "" });
1343
+ return;
1344
+ }
1345
+ }
1346
+ /**
1347
+ * Option 3 — "show me safer ways to do this".
1348
+ *
1349
+ * The round's ONE new model request, and every branch here exists to
1350
+ * keep it honest.
1351
+ *
1352
+ * It fires ONLY from this method, which only this option reaches —
1353
+ * that is the entire mechanism behind the zero-ambient-rent claim,
1354
+ * and it is why the claim is checkable rather than asserted: a
1355
+ * session that never presses 3 never enters this branch, and the
1356
+ * trace shows no side-query line.
1357
+ *
1358
+ * The in-flight phase is VISIBLE because this is a network call: a
1359
+ * button that goes quiet for two seconds reads as broken, and the
1360
+ * human is standing in front of a paused run.
1361
+ *
1362
+ * Every failure — a throw, a null, an empty list, no provider bound
1363
+ * at all — lands on the SAME honest line and puts back every original
1364
+ * choice. There is deliberately no retry and no partial state: the
1365
+ * alternative to "I could not get them" is either a lie or a spinner
1366
+ * that never ends, and both are worse than the sentence.
1367
+ *
1368
+ * The generation token is the guard against a late answer: a panel
1369
+ * the human escaped (or that a SIGINT cancelled) must not be
1370
+ * resurrected two seconds later by a promise nobody is waiting for.
1371
+ */
1372
+ #panelSafer() {
1117
1373
  const panel = this.#panel;
1118
1374
  if (panel === null)
1119
1375
  return;
1120
- panel.phase = "rule";
1121
- panel.sel = 2;
1122
- this.#chars = [...panel.view.name].map((ch) => ch.codePointAt(0));
1123
- this.#cursor = this.#chars.length;
1124
- this.#scroll = 0;
1125
- this.#verticalGoalCol = null;
1376
+ const ask = panel.safer;
1377
+ panel.phase = "asking";
1378
+ panel.note = null;
1379
+ this.#onRender();
1380
+ const token = ++this.#saferToken;
1381
+ const settle = (options) => {
1382
+ // the panel that asked must still be the panel on screen
1383
+ if (this.#panel !== panel || token !== this.#saferToken)
1384
+ return;
1385
+ if (options === null || options.length === 0) {
1386
+ panel.phase = "options";
1387
+ panel.note = SAFER_DEGRADED;
1388
+ panel.cursor = 0;
1389
+ this.#onRender();
1390
+ return;
1391
+ }
1392
+ panel.phase = "safer";
1393
+ panel.saferRun = { options, cursor: 0 };
1394
+ this.#onRender();
1395
+ };
1396
+ if (ask === undefined) {
1397
+ settle(null); // no provider bound — the button says so rather than lying
1398
+ return;
1399
+ }
1400
+ void Promise.resolve()
1401
+ .then(ask)
1402
+ .then(settle)
1403
+ .catch(() => settle(null));
1404
+ }
1405
+ /** Take a row of the SAFER list. The alternatives route through the
1406
+ * EXISTING amend channel — choosing a safer command is a denial with
1407
+ * instructions, which is a verdict the product already has; the last
1408
+ * row is the way back and decides nothing. */
1409
+ #saferConfirm(index) {
1410
+ const panel = this.#panel;
1411
+ if (panel === null || panel.saferRun === null)
1412
+ return;
1413
+ const { options } = panel.saferRun;
1414
+ if (index === options.length) {
1415
+ // "back to the original choices"
1416
+ panel.phase = "options";
1417
+ panel.saferRun = null;
1418
+ panel.cursor = 0;
1419
+ this.#onRender();
1420
+ return;
1421
+ }
1422
+ const chosen = options[index];
1423
+ if (chosen === undefined)
1424
+ return; // past the list — inert
1425
+ this.#panelClose({ action: "deny", reason: `run this instead: ${chosen.command}` });
1426
+ }
1427
+ /** ↑↓ inside the safer list — the way back is its last row, so the
1428
+ * bar reaches it like any other. */
1429
+ #saferMove(delta) {
1430
+ const panel = this.#panel;
1431
+ if (panel === null || panel.saferRun === null)
1432
+ return;
1433
+ const last = panel.saferRun.options.length; // + the way-back row
1434
+ panel.saferRun = { options: panel.saferRun.options, cursor: Math.max(0, Math.min(last, panel.saferRun.cursor + delta)) };
1126
1435
  this.#onRender();
1127
1436
  }
1128
- /** tab — the amend phase on the selected option (yes/deny); the
1129
- * simple flavor never has it (options 1/3 only, no option 2). */
1130
- #panelTab() {
1437
+ /** The typed phase — the one place the panel takes prose. The buffer
1438
+ * starts empty and the cursor stays where the human left it, so esc
1439
+ * can put the bar back exactly where it was. */
1440
+ #panelAmend() {
1131
1441
  const panel = this.#panel;
1132
- if (panel === null || panel.view.flavor !== "approval")
1442
+ if (panel === null)
1133
1443
  return;
1134
- panel.amend = panel.sel === 3 ? "no" : "yes";
1135
1444
  panel.phase = "amend";
1136
1445
  this.#chars = [];
1137
1446
  this.#cursor = 0;
@@ -1139,15 +1448,39 @@ export class Editor {
1139
1448
  this.#verticalGoalCol = null;
1140
1449
  this.#onRender();
1141
1450
  }
1142
- /** escback out of the rule/amend to the options (the buffer
1143
- * clears), deselect, or cancel the panel at rest. */
1451
+ /** tabthe amend alias, unchanged as a GESTURE: it opens the same
1452
+ * typed phase the last option does, from anywhere in the list. The
1453
+ * simple flavors never had it and still do not. */
1454
+ #panelTab() {
1455
+ const panel = this.#panel;
1456
+ if (panel === null || panel.view.flavor !== "approval")
1457
+ return;
1458
+ panel.cursor = panelOptions(panel.view).length - 1;
1459
+ this.#panelAmend();
1460
+ }
1461
+ /** esc — back out of the typed phase to the list (the buffer clears,
1462
+ * the bar stays on the option that opened it), or cancel the panel.
1463
+ * The old model had a third step, the deselect, because a selection
1464
+ * could be "none"; a list always has a selection, so esc from the
1465
+ * list means what it means everywhere else in the product. */
1144
1466
  #panelEsc() {
1145
1467
  const panel = this.#panel;
1146
1468
  if (panel === null)
1147
1469
  return;
1470
+ // TUI2-R3v2 ③: esc out of the safer list — or out of the ask while
1471
+ // it is still in flight — returns to the original choices, exactly
1472
+ // as the way-back row does. The in-flight answer is orphaned by the
1473
+ // generation token; nothing it does can reopen this list.
1474
+ if (panel.phase === "safer" || panel.phase === "asking") {
1475
+ this.#saferToken += 1;
1476
+ panel.phase = "options";
1477
+ panel.saferRun = null;
1478
+ panel.cursor = 0;
1479
+ this.#onRender();
1480
+ return;
1481
+ }
1148
1482
  if (panel.phase !== "options") {
1149
1483
  panel.phase = "options";
1150
- panel.sel = 0;
1151
1484
  this.#chars = [];
1152
1485
  this.#cursor = 0;
1153
1486
  this.#scroll = 0;
@@ -1155,36 +1488,40 @@ export class Editor {
1155
1488
  this.#onRender();
1156
1489
  return;
1157
1490
  }
1158
- if (panel.sel !== 0) {
1159
- panel.sel = 0;
1160
- this.#onRender();
1161
- return;
1162
- }
1163
1491
  this.#panelClose({ action: "cancel" });
1164
1492
  }
1165
- /** enter — commit by phase: the rule input (the tool name when
1166
- * empty), the amend feedback (the bare verdict when empty), or the
1167
- * selected option (nothing at rest — an accidental enter never
1168
- * approves). Enter on the selected option 2 is the digit-2 key. */
1493
+ /**
1494
+ * enter send the typed note, or TAKE THE HIGHLIGHTED OPTION.
1495
+ *
1496
+ * The second half is the round. The retired model's enter-at-rest did
1497
+ * nothing at all, on the theory that an accidental return must never
1498
+ * approve; what it actually produced was a panel that ignored the key
1499
+ * every human presses first. The safeguard is real but it belongs on
1500
+ * WHERE THE BAR STARTS, not on whether the key works: the bar opens on
1501
+ * the option whose blast radius is one tool call the human is looking
1502
+ * at, and every irreversible-er choice is a deliberate ↑↓ away.
1503
+ *
1504
+ * An empty note in the typed phase is the bare denial — the W21
1505
+ * mapping, untouched: no words means the run aborts, words mean the
1506
+ * model gets them and proposes a new call.
1507
+ */
1169
1508
  #panelEnter() {
1170
1509
  const panel = this.#panel;
1171
1510
  if (panel === null)
1172
1511
  return;
1173
- const line = this.line();
1174
- if (panel.phase === "rule") {
1175
- this.#panelClose({ action: "allow-rule", rule: line === "" ? panel.view.name : line });
1512
+ if (panel.phase === "amend") {
1513
+ this.#panelClose({ action: "deny", reason: this.line() });
1176
1514
  return;
1177
1515
  }
1178
- if (panel.phase === "amend") {
1179
- this.#panelClose(panel.amend === "yes" ? { action: "allow", reason: line } : { action: "deny", reason: line });
1516
+ // TUI2-R3v2 ③: in the safer list, enter takes the highlighted
1517
+ // alternative the same gesture, one surface over.
1518
+ if (panel.phase === "safer" && panel.saferRun !== null) {
1519
+ this.#saferConfirm(panel.saferRun.cursor);
1180
1520
  return;
1181
1521
  }
1182
- if (panel.sel === 1)
1183
- this.#panelClose({ action: "allow", reason: "" });
1184
- else if (panel.sel === 2)
1185
- this.#panelRule();
1186
- else if (panel.sel === 3)
1187
- this.#panelClose({ action: "deny", reason: "" });
1522
+ if (panel.phase === "asking")
1523
+ return; // nothing to confirm yet
1524
+ this.#panelConfirm(panel.cursor);
1188
1525
  }
1189
1526
  /**
1190
1527
  * KC3.5 — one ask key: the pure reducer decides, this method applies.
@@ -1272,8 +1609,22 @@ export class Editor {
1272
1609
  if (panel === null)
1273
1610
  return;
1274
1611
  this.#panel = null;
1275
- // the pre-panel buffer returns — the panel's rule/feedback text
1276
- // never leaks into the user's next turn (commit AND cancel).
1612
+ this.#syncMouse();
1613
+ // TUI2-R3v2 ①: swallow ONE bare enter after the panel goes away.
1614
+ //
1615
+ // This is the hazard the instant confirm creates and it is not
1616
+ // hypothetical: "y⏎" and "1⏎" are what a decade of y/n prompts
1617
+ // taught everyone's fingers, and the panel used to need both bytes.
1618
+ // It needs one now — so the second one lands in a composer that has
1619
+ // just had the user's PRE-PANEL DRAFT restored into it, and submits
1620
+ // it. Answering an approval would send a half-written message.
1621
+ //
1622
+ // The guard is one-shot and expires on any other key, so it can
1623
+ // never eat an enter the user meant: by the time they have typed
1624
+ // anything at all, it is gone.
1625
+ this.#swallowEnter = true;
1626
+ // the pre-panel buffer returns — the panel's amend text never leaks
1627
+ // into the user's next turn (commit AND cancel).
1277
1628
  this.#chars = [...panel.stash.chars];
1278
1629
  this.#cursor = panel.stash.cursor;
1279
1630
  this.#scroll = panel.stash.scroll;
@@ -1523,7 +1874,7 @@ export class Editor {
1523
1874
  // W23: the ONE width authority — leadWidth(lead) — the cap follows
1524
1875
  // the lead the editor itself renders (the panel lead when the panel
1525
1876
  // owns the keys, the brick otherwise): maxW = W − walls − lead.
1526
- const lead = this.#panel !== null ? panelLead(this.#panel.view, this.#panel.phase, this.#panel.sel, this.#panel.ask ?? undefined) : PROMPT;
1877
+ const lead = this.#panel !== null ? panelLead(this.#panel.view, this.#panel.phase, this.#panel.cursor, this.#panel.ask ?? undefined) : PROMPT;
1527
1878
  const leadW = leadWidth(lead);
1528
1879
  const maxW = Math.max(1, W - leadW - 4); // W6: the box's walls (2+2) — the visible line fits the box's inner width; the "…" rides inside
1529
1880
  // KC1: the scroll is the CURSOR LINE's own offset — a single-line
package/dist/index.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * kiso-tui — the PURE terminal layer, extracted from the CLI (the
3
- * ADR-0041 escape hatch). Zero runtime dependencies: input is data,
3
+ * escape hatch of ADR-0043, which supersedes ADR-0041). Zero runtime
4
+ * dependencies: input is data,
4
5
  * output is bytes. TUI v6 (ADR-0046): the ONE compositor (the single
5
6
  * writer — body.ts + dock.ts retired), the component tree, the raw-mode
6
7
  * editor, the diff renderer, and the palette.
7
8
  */
8
9
  export { Body, Dock, CURSOR_MARKER, type BodyOptions } from "./compositor.js";
9
- export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, PICK_MAX, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, type PickOption, type PickResult, type PickRuntime, type PickSpec, type PanelArgs, type PanelFlavor, type PanelPhase, type PanelSel, type PanelState, type PanelVerdict, type PanelView, } from "./approval-panel.js";
10
+ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, PICK_MAX, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, type PickOption, type PickResult, type PickRuntime, type PickSpec, type PanelArgs, type PanelFlavor, type PanelPhase, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, type SaferOption, type SaferRuntime, panelOptions, type PanelOption, type PanelOptionKind, type PanelState, type PanelVerdict, type PanelView, } from "./approval-panel.js";
10
11
  export { Container, foldLine, foldWords, visibleWidth, SPINNER, type Component, type FrameCtx } from "./components.js";
11
12
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
12
13
  export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type PathResolver, type RecapStats, type ResumeMeta, type RenderInput, type RenderResult, type RunUsage, } from "./render.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * kiso-tui — the PURE terminal layer, extracted from the CLI (the
3
- * ADR-0041 escape hatch). Zero runtime dependencies: input is data,
3
+ * escape hatch of ADR-0043, which supersedes ADR-0041). Zero runtime
4
+ * dependencies: input is data,
4
5
  * output is bytes. TUI v6 (ADR-0046): the ONE compositor (the single
5
6
  * writer — body.ts + dock.ts retired), the component tree, the raw-mode
6
7
  * editor, the diff renderer, and the palette.
@@ -11,7 +12,7 @@ export { Body, Dock, CURSOR_MARKER } from "./compositor.js";
11
12
  // approval is pending (the shape authority is the committed preview).
12
13
  export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus,
13
14
  // TUI2-R2 ④: the pick payload — the panel slot's third occupant.
14
- PICK_MAX, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, } from "./approval-panel.js";
15
+ PICK_MAX, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, panelOptions, } from "./approval-panel.js";
15
16
  export { Container, foldLine, foldWords, visibleWidth, SPINNER } from "./components.js";
16
17
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, } from "./editor.js";
17
18
  export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
package/dist/status.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * KC2 §5 — the status line's FORMATTERS, extracted from the CLI (the
3
- * ADR-0041 escape hatch: extraction, never a fifth raise). The split is
3
+ * escape hatch of ADR-0043, which supersedes ADR-0041 this comment
4
+ * cited the superseded ADR as current AND repeated its retired "never a
5
+ * fifth raise" promise; ADR-0043 governs the ceiling now). The split is
4
6
  * the one the ADR names: the CLI keeps the STATE (the rotating glyph,
5
7
  * the run's start instant, the live usage, whether the dock is up) and
6
8
  * the REPAINT; what a status row SAYS is presentation, and presentation
package/dist/status.js CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * KC2 §5 — the status line's FORMATTERS, extracted from the CLI (the
3
- * ADR-0041 escape hatch: extraction, never a fifth raise). The split is
3
+ * escape hatch of ADR-0043, which supersedes ADR-0041 this comment
4
+ * cited the superseded ADR as current AND repeated its retired "never a
5
+ * fifth raise" promise; ADR-0043 governs the ceiling now). The split is
4
6
  * the one the ADR names: the CLI keeps the STATE (the rotating glyph,
5
7
  * the run's start instant, the live usage, whether the dock is up) and
6
8
  * the REPAINT; what a status row SAYS is presentation, and presentation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "kiso tui — the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
37
37
  "dependencies": {
38
- "@vincemakes/kiso-tui-cells": "0.11.0"
38
+ "@vincemakes/kiso-tui-cells": "0.12.0"
39
39
  }
40
40
  }