@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.
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