@vincemakes/kiso-tui 0.25.0 → 0.26.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,16 +26,22 @@ import { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
26
26
  // the width primitives moved to width.ts (W1, the single width
27
27
  // authority) — re-exported so the editor's public surface is unchanged.
28
28
  export { charWidth, displayWidth, widthOf };
29
- import { palette } from "./render.js";
30
- import { PICK_MAX, panelOptions, saferDegradedNote, } from "./approval-panel.js";
29
+ import { palette } from "./lines.js";
30
+ import {} from "./approval-panel.js";
31
31
  // KC3.5: the panel-slot dispatchers — the ask branch folded into the
32
32
  // W21 lead/rows, so this file keeps ONE panel and one key owner.
33
- import { askCommitCustom, askKey, askOnCustomRow, askStart, panelLead } from "./ask-panel.js";
33
+ import { panelLead } from "./ask-panel.js";
34
34
  import { AT_VISIBLE, atFilter } from "./at-picker.js";
35
35
  // TUI2-R2 ②: the session picker — the band's THIRD occupant. Its filter
36
36
  // is the @ picker's rank aimed at the session id; the editor owns the
37
37
  // keys, the compositor draws the rows.
38
- import { sessionFilter } from "./session-picker.js";
38
+ import {} from "./session-picker.js";
39
+ // S5 (C10): the two bands with state machines of their own — the
40
+ // approval/ask/pick panel and the session picker — are controllers.
41
+ // The editor parses bytes and lends them the composer through
42
+ // #bandHost; they answer keys.
43
+ import { PanelInput } from "./panel-input.js";
44
+ import { PickInput } from "./pick-input.js";
39
45
  // TUI v4 #16d: the input row is the blue brick + the edit area — the
40
46
  // "you>" text is gone (the brick IS the prompt; the pipe path's readline
41
47
  // prompt keeps its own "you> " — v2a line mode, byte-for-byte).
@@ -89,6 +95,16 @@ export function viewerCommand(text) {
89
95
  return null;
90
96
  }
91
97
  }
98
+ /** E1 §1 — the two-byte alt spellings of word motion and word deletion.
99
+ * A table because one gesture has several encodings, and a table is
100
+ * what keeps them from drifting into several features. */
101
+ const ALT_WORD = new Map([
102
+ ["b", "left"],
103
+ ["f", "right"],
104
+ ["d", "killFwd"],
105
+ ["\x7f", "killBack"],
106
+ ["\x08", "killBack"],
107
+ ]);
92
108
  export const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
93
109
  export const MOUSE_OFF = "\x1b[?1000l\x1b[?1006l";
94
110
  export const PROMPT = "▌ ";
@@ -139,6 +155,39 @@ const ellipsis = () => {
139
155
  const p = palette();
140
156
  return `${p.dim}\u2026${p.reset}`;
141
157
  };
158
+ /**
159
+ * DC-55 — the composer's DISPLAY form of the buffer.
160
+ *
161
+ * A tab is kept as U+0009 in `#chars`, because the submitted line and
162
+ * the durable event must carry the indentation the human pasted. It
163
+ * cannot be PAINTED as itself: the terminal expands it to the next tab
164
+ * stop while `charWidth(0x09)` returns 1, so the row on screen becomes
165
+ * wider than the row kiso measured — invariant ① in its literal form —
166
+ * and every cursor column after it is wrong by the same amount.
167
+ *
168
+ * So the projection shows `→`, ONE code point for one code point: every
169
+ * index and every cursor column the compositor computes from this string
170
+ * stays true. `line()` and `#chars` never see it.
171
+ *
172
+ * ONE CELL, not an expansion to a tab stop. A tab's width is a property
173
+ * of its POSITION, and `charWidth(cp)` takes a code point with no
174
+ * context to answer that from. CJK's two cells work because two is a
175
+ * property of the character; these are different problems, and reusing
176
+ * that path for this one would be a mis-fit that surfaces later as
177
+ * drift. The cost is stated in design §8: a pasted block's alignment in
178
+ * the composer is approximate, while the block itself is exact.
179
+ *
180
+ * The dim is safe for the reason the ellipsis above is: `cursorCol`
181
+ * COUNTS markers and sums the buffer's own widths — it never measures
182
+ * this string — so an SGR span in it has never been part of the
183
+ * arithmetic.
184
+ */
185
+ const shown = (chars) => {
186
+ if (!chars.includes(0x09))
187
+ return String.fromCodePoint(...chars);
188
+ const p = palette();
189
+ return chars.map((cp) => (cp === 0x09 ? `${p.dim}\u2192${p.reset}` : String.fromCodePoint(cp))).join("");
190
+ };
142
191
  /**
143
192
  * The editor. Raw mode + bracketed paste (?2004h) on enter, restored on
144
193
  * exit. The input row is rendered by `onRender` (the CLI wires it to the
@@ -157,14 +206,10 @@ export class Editor {
157
206
  // or delete. Never stashed — it is a walk's state, not the buffer's.
158
207
  #verticalGoalCol = null;
159
208
  #questionCb = null;
160
- // W21: the panel state machinethe approval/trust panel owns the
161
- // interaction while up: the digit/y/n/esc/tab routing, the rule
162
- // input, the tab-amend feedback, the phase/selection the compositor
163
- // renders. The menu never opens while a panel is up; the pre-panel
164
- // buffer is stashed at open and restored at close (commit AND
165
- // cancel) — the panel's rule/feedback text never leaks into the
166
- // user's next turn.
167
- #panel = null;
209
+ /** The approval / ask / pick panel its state machine and its keys
210
+ * live in PanelInput (S5); the editor lends it the composer through
211
+ * #bandHost. */
212
+ #panelInput = new PanelInput(this.#bandHost());
168
213
  #pasting = false;
169
214
  /**
170
215
  * REL-0152-D8 — the paste capsule.
@@ -254,9 +299,6 @@ export class Editor {
254
299
  #swallowEnter = false;
255
300
  /** TUI2-R3v2 ②: whether SGR 1006 reporting is currently enabled. */
256
301
  #mouseOn = false;
257
- /** TUI2-R3v2 ③: the safer ask's generation. A panel the human escaped
258
- * must not be resurrected by a promise nobody is waiting for. */
259
- #saferToken = 0;
260
302
  /** TUI2-R3v2 ②: where the compositor put the panel's option rows this
261
303
  * frame (absolute 1-based screen rows). The editor owns no geometry —
262
304
  * it asks the surface that placed them. */
@@ -280,6 +322,9 @@ export class Editor {
280
322
  // expanded block). Mirrors the escape list: multiple listeners can
281
323
  // coexist; the editor never interprets the key itself.
282
324
  #expandCbs = [];
325
+ /** E1 §3 — ctrl+x. Same shape as the expand key: the editor owns the
326
+ * KEY, the CLI owns what it means. */
327
+ #copyCbs = [];
283
328
  #onRender;
284
329
  /** TUI2-R1 (D): the keys sheet — a static one-screen overlay opened by
285
330
  * `?` on an empty composer and closed by the next key, whatever it
@@ -315,10 +360,8 @@ export class Editor {
315
360
  // a session exists, owns the whole composer, and the only ways out
316
361
  // are a pick and an esc. That is why the commit callback lives here
317
362
  // rather than on the line channel — the caller is waiting for an id,
318
- // not for a turn.
319
- #pickCards = null;
320
- #pickCommit = null;
321
- #pickSel = 0;
363
+ // not for a turn. S5: the state and the keys live in PickInput.
364
+ #pickInput = new PickInput(this.#bandHost());
322
365
  // A2 (the feel): the session-scoped input history — every submitted TURN
323
366
  // line (never a question answer), capped at 100, never persisted. ↑↓
324
367
  // navigate it ONLY from an empty input or while already browsing.
@@ -405,6 +448,10 @@ export class Editor {
405
448
  onModeCycle(cb) {
406
449
  this.#onModeCycle = cb;
407
450
  }
451
+ /** E1 §3 — the copy key (ctrl+x). */
452
+ onCopy(cb) {
453
+ this.#copyCbs.push(cb);
454
+ }
408
455
  onExpand(cb) {
409
456
  this.#expandCbs.push(cb);
410
457
  }
@@ -506,7 +553,7 @@ export class Editor {
506
553
  * the frame's clamp is the authority. */
507
554
  #visibleRows(lineCount) {
508
555
  const H = process.stdout.rows ?? 24;
509
- const bands = (this.#menuOpen ? this.#menuFiltered().length : 0) + this.#atRows() + this.#pickRows() + this.#queueState().length;
556
+ const bands = (this.#menuOpen ? this.#menuFiltered().length : 0) + this.#atRows() + this.#pickInput.rows() + this.#queueState().length;
510
557
  return Math.max(1, Math.min(lineCount, N_MAX, Math.max(1, H - 3 - bands)));
511
558
  }
512
559
  /** The dock's input-row state — ADDITIVE (§5): `line` + `cursor` keep
@@ -534,7 +581,7 @@ export class Editor {
534
581
  const scrolled = i === cursorLine && this.#scroll > 0 ? ellipsis() : "";
535
582
  const above = i === first && first > 0 ? ellipsis() : "";
536
583
  const below = i === first + n - 1 && first + n < bounds.length ? ellipsis() : "";
537
- lines.push(`${above}${scrolled}${String.fromCodePoint(...this.#chars.slice(from, b.end))}${below}`);
584
+ lines.push(`${above}${scrolled}${shown(this.#chars.slice(from, b.end))}${below}`);
538
585
  }
539
586
  const cursorRow = cursorLine - first;
540
587
  // the window trails the cursor, so the hidden-above marker can only
@@ -568,7 +615,7 @@ export class Editor {
568
615
  return MENU_ITEMS.filter((m) => m.name.startsWith(line));
569
616
  }
570
617
  #refreshMenu() {
571
- if (this.#panel !== null)
618
+ if (this.#panelInput.up())
572
619
  return; // W21: the menu never opens while the panel owns the keys
573
620
  const f = this.#menuFiltered();
574
621
  this.#menuOpen = f.length > 0;
@@ -620,7 +667,7 @@ export class Editor {
620
667
  #atView() {
621
668
  if (!this.#atOpen || this.#atList === null)
622
669
  return null;
623
- if (this.#panel !== null || this.#menuOpen)
670
+ if (this.#panelInput.up() || this.#menuOpen)
624
671
  return null;
625
672
  const token = this.#atToken();
626
673
  if (token === null)
@@ -656,9 +703,9 @@ export class Editor {
656
703
  // TUI2-R2 ②: not inside a session filter. An `@` typed into the
657
704
  // picker's query is a character in a session id, and a file picker
658
705
  // opening over a session picker would put two bands in one slot.
659
- if (this.#pickUp())
706
+ if (this.#pickInput.up())
660
707
  return;
661
- if (this.#panel !== null || this.#menuOpen || this.#questionCb !== null)
708
+ if (this.#panelInput.up() || this.#menuOpen || this.#questionCb !== null)
662
709
  return;
663
710
  if (this.#atToken() === null)
664
711
  return; // not at a word boundary
@@ -708,71 +755,12 @@ export class Editor {
708
755
  return view === null ? 0 : Math.min(view.matches.length, AT_VISIBLE) + 1;
709
756
  }
710
757
  // ── TUI2-R2 ② — the session picker ───────────────────────────────
711
- /** Open the picker on a bound card source. The composer is cleared
712
- * (the buffer becomes the filter query) and `onPick` receives the
713
- * chosen id — or null when the human leaves without picking, which
714
- * is a first-class outcome and not an error. */
758
+ /** Open the picker on a bound card source (PickInput, S5). */
715
759
  beginPick(cards, onPick) {
716
- this.#pickCards = cards;
717
- this.#pickCommit = onPick;
718
- this.#pickSel = 0;
719
- this.#syncMouse();
720
- this.#chars = [];
721
- this.#cursor = 0;
722
- this.#reflow();
723
- this.#onRender();
724
- }
725
- /** The picker's state, derived: the full card list (the id column
726
- * measures over ALL of them, so the columns never jump), the
727
- * filtered matches, and the selection CLAMPED at read time — the
728
- * same correction discipline the @ picker uses, for the same
729
- * reason: narrowing can only ever shrink the list. */
730
- #pickView() {
731
- if (this.#pickCards === null)
732
- return null;
733
- const cards = this.#pickCards();
734
- const matches = sessionFilter(cards, this.line());
735
- return { cards, matches, selected: Math.max(0, Math.min(this.#pickSel, matches.length - 1)) };
760
+ this.#pickInput.begin(cards, onPick);
736
761
  }
737
762
  pickState() {
738
- return this.#pickView();
739
- }
740
- #pickUp() {
741
- return this.#pickCards !== null;
742
- }
743
- /** The band's height estimate: the header + the windowed rows (or
744
- * the one "no match" row) + the counter. */
745
- #pickRows() {
746
- const view = this.#pickView();
747
- return view === null ? 0 : Math.min(Math.max(view.matches.length, 1), AT_VISIBLE) + 2;
748
- }
749
- /** Close and hand the verdict back. The callback fires AFTER the
750
- * state is cleared, so a caller that re-enters (a second picker, a
751
- * session that starts) never sees the closing picker's rows. */
752
- #pickClose(id) {
753
- const cb = this.#pickCommit;
754
- this.#pickCards = null;
755
- this.#pickCommit = null;
756
- this.#pickSel = 0;
757
- this.#syncMouse();
758
- this.#chars = [];
759
- this.#cursor = 0;
760
- this.#reflow();
761
- cb?.(id);
762
- this.#onRender();
763
- }
764
- /** Enter takes the SELECTED session. An empty match set takes
765
- * nothing and leaves the picker up: a picker that invented a pick
766
- * when the query matched nothing would resume the wrong session,
767
- * which is the one failure this surface must never have. */
768
- #pickAccept() {
769
- const view = this.#pickView();
770
- if (view === null)
771
- return;
772
- const card = view.matches[view.selected];
773
- if (card === undefined)
774
- return;
775
- this.#pickClose(card.id);
763
+ return this.#pickInput.state();
776
764
  }
777
765
  /** One-shot question mode: the NEXT submit answers, not a turn. */
778
766
  question(_query, cb) {
@@ -783,54 +771,20 @@ export class Editor {
783
771
  cancelQuestion() {
784
772
  this.#questionCb = null;
785
773
  }
786
- /** W21: open the approval panel. The current buffer is stashed
787
- * (restored at close — commit AND cancel), the panel takes the
788
- * keys and the input row's lead, the menu closes. */
774
+ /** W21: open the approval panel (PanelInput, S5): the buffer is
775
+ * stashed and restored at close, the panel takes the keys and the
776
+ * input row's lead, the composer's own bands close. */
789
777
  beginPanel(view, onCommit, opts) {
790
- this.#panel = {
791
- view,
792
- phase: "options",
793
- cursor: 0,
794
- note: null,
795
- safer: opts?.safer,
796
- saferRun: null,
797
- ask: view.ask === undefined ? null : askStart(view.ask),
798
- // TUI2-R2 ④: the pick's walk — present exactly when the view is
799
- // a pick, the same contract the ask's runtime has.
800
- pick: view.pick === undefined ? null : { cursor: 0, phase: "options" },
801
- onCommit,
802
- stash: { chars: this.#chars, cursor: this.#cursor, scroll: this.#scroll },
803
- };
804
- this.#chars = [];
805
- this.#cursor = 0;
806
- this.#scroll = 0;
807
- this.#verticalGoalCol = null;
808
- this.#menuOpen = false;
809
- this.#menuSel = 0;
810
- this.#queuePopMode = false; // W22: the panel owns the keys while up
811
- this.#atClose(); // KC3 §3: and the picker closes with everything else
812
- this.#syncMouse();
813
- this.#onRender();
778
+ this.#panelInput.begin(view, onCommit, opts);
814
779
  }
815
780
  /** W21: cancel the panel — the SIGINT path's pair to beginPanel. */
816
781
  cancelPanel() {
817
- this.#panelClose({ action: "cancel" });
782
+ this.#panelInput.cancel();
818
783
  }
819
784
  /** W21: the compositor's bound view — the phase/selection while the
820
785
  * panel is up, null otherwise. */
821
786
  panelState() {
822
- const panel = this.#panel;
823
- if (panel === null)
824
- return null;
825
- return {
826
- view: panel.view,
827
- phase: panel.phase,
828
- cursor: panel.cursor,
829
- ...(panel.note === null ? {} : { note: panel.note }),
830
- ...(panel.saferRun === null ? {} : { safer: panel.saferRun }),
831
- ...(panel.ask === null ? {} : { ask: panel.ask }),
832
- ...(panel.pick === null ? {} : { pick: panel.pick }),
833
- };
787
+ return this.#panelInput.state();
834
788
  }
835
789
  enter() {
836
790
  if (this.#entered)
@@ -896,7 +850,7 @@ export class Editor {
896
850
  /** The surfaces that own a selection — the approval/ask/pick panel, the
897
851
  * session picker and the @ picker. Any one of them up = reporting on. */
898
852
  #syncMouse() {
899
- this.#setMouse(this.#panel !== null || this.#pickCards !== null || this.#atUp());
853
+ this.#setMouse(this.#panelInput.up() || this.#pickInput.up() || this.#atUp());
900
854
  }
901
855
  /** The row's own render when the dock is inactive (a TTY without a
902
856
  * real size): \r + clear + blue brick prompt + visible + cursor
@@ -907,8 +861,8 @@ export class Editor {
907
861
  const W = (process.stdout.columns ?? 0) || 80; // a degenerate 0 size (no TIOCSWINSZ) falls back
908
862
  // W21: the panel's lead owns the row while up (the brick returns
909
863
  // when the panel closes).
910
- const panel = this.#panel;
911
- const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.cursor, panel.ask ?? undefined) : `${p.bold}${PROMPT}${p.reset}`;
864
+ const panel = this.#panelInput.state();
865
+ const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.cursor, panel.ask) : `${p.bold}${PROMPT}${p.reset}`;
912
866
  // W23: the ONE width authority — leadWidth(lead), the ANSI-stripped
913
867
  // visible width (the styled panel lead / the styled brick measure
914
868
  // the same as their plain text — a lead can never measure
@@ -954,246 +908,36 @@ export class Editor {
954
908
  // close, and it disarms on anything else in the same breath.
955
909
  if (this.#swallowEnter) {
956
910
  this.#swallowEnter = false;
957
- if (this.#panel === null && (c === "\x0d" || c === "\x0a")) {
911
+ if (!this.#panelInput.up() && (c === "\x0d" || c === "\x0a")) {
958
912
  i += 1;
959
913
  continue;
960
914
  }
961
915
  }
962
- if (this.#panel !== null) {
963
- // W21: the panel owns the keys — a digit CONFIRMS its row in
964
- // the options phase, tab opens the amend (approval only), esc
965
- // backs out (amend options, options cancel), enter takes
966
- // the highlighted row. CSI/SS3 and the editing keys still ride
967
- // the normal chain below (the amend line is free text);
968
- // ctrl-c still rides the SIGINT handler (which cancels the
969
- // panel).
970
- const panel = this.#panel;
971
- // KC3.5: an ASK panel routes its own keys — the digits pick
972
- // (single-select advances, multi toggles), space toggles at
973
- // the cursor, `t` opens the type-your-own line (the
974
- // rule-input phase's shape: the buffer is the editor's, so
975
- // only esc and enter are intercepted while typing), esc
976
- // declines the whole call. Everything else falls through to
977
- // the ordinary editing chain below.
978
- // TUI2-R2 ④: a PICK panel routes its own keys a digit moves
979
- // the cursor to that option (never commits: the choice is
980
- // CONFIRMED, so a mistyped digit is a mistake you can see
981
- // before it takes effect), `t` opens the type-it line, enter
982
- // commits, esc backs out then cancels. The swallow rule below
983
- // is the ask's, for the ask's reason: a typed `/` must not arm
984
- // the menu under a panel that owns the keys.
985
- if (panel.pick !== null) {
986
- const typing = panel.pick.phase === "custom";
987
- if (c === "\x1b" && !text.slice(i + 1).startsWith("[") && !text.slice(i + 1).startsWith("O")) {
988
- this.#pickPanelEsc();
989
- i += 1;
990
- continue;
991
- }
992
- if (c === "\x0d" || c === "\x0a") {
993
- // REL-0152-D10: a newline inside a PASTE is content, never a
994
- // commit. Bracketed paste marks its own boundaries, so a
995
- // \n between them is a line of the pasted text and nothing
996
- // else. Without this, pasting a stack trace into a typed
997
- // panel phase submitted the first line and dropped the
998
- // rest into the composer behind the closed panel — the
999
- // owner asked whether the type-your-own box takes a paste,
1000
- // and the answer was no, in the worst way.
1001
- if (this.#pasting) {
1002
- this.#insert(NEWLINE);
1003
- i += c === "\x0d" && text[i + 1] === "\x0a" ? 2 : 1;
1004
- continue;
1005
- }
1006
- this.#pickPanelEnter();
1007
- i += 1;
1008
- continue;
1009
- }
1010
- if (!typing && c !== undefined && c >= "1" && c <= "9") {
1011
- this.#pickPanelDigit(Number(c) - 1);
1012
- i += 1;
1013
- continue;
1014
- }
1015
- // DC-36: no `t` row means no custom phase to enter — the
1016
- // option list IS the world (a closed set), and a key
1017
- // that leads to a surface the panel does not draw is
1018
- // worse than an absent key.
1019
- if (!typing && (c === "t" || c === "T") && this.#panel?.view.pick?.typeHint !== undefined) {
1020
- panel.pick = { cursor: panel.pick.cursor, phase: "custom" };
1021
- this.#chars = [];
1022
- this.#cursor = 0;
1023
- this.#scroll = 0;
1024
- this.#onRender();
1025
- i += 1;
1026
- continue;
1027
- }
1028
- if (!typing && c !== undefined && c >= " " && c !== "\x7f") {
1029
- i += 1;
1030
- continue;
1031
- }
1032
- }
1033
- if (panel.ask !== null) {
1034
- const typing = panel.ask.phase === "custom";
1035
- if (c === "\x1b" && !text.slice(i + 1).startsWith("[") && !text.slice(i + 1).startsWith("O")) {
1036
- this.#askStep("esc");
1037
- i += 1;
1038
- continue;
1039
- }
1040
- if (c === "\x0d" || c === "\x0a") {
1041
- // REL-0152-D10: a newline inside a PASTE is content, never a
1042
- // commit. Bracketed paste marks its own boundaries, so a
1043
- // \n between them is a line of the pasted text and nothing
1044
- // else. Without this, pasting a stack trace into a typed
1045
- // panel phase submitted the first line and dropped the
1046
- // rest into the composer behind the closed panel — the
1047
- // owner asked whether the type-your-own box takes a paste,
1048
- // and the answer was no, in the worst way.
1049
- if (this.#pasting) {
1050
- this.#insert(NEWLINE);
1051
- i += c === "\x0d" && text[i + 1] === "\x0a" ? 2 : 1;
1052
- continue;
1053
- }
1054
- this.#askStep(typing ? "commit" : "enter");
1055
- i += 1;
1056
- continue;
1057
- }
1058
- // REL-0152-D4 — on the custom row a printable key is TEXT.
1059
- // The row names typing as its purpose and then swallowed
1060
- // the first thing you typed; only enter or `t` opened the
1061
- // phase. Now the keystroke opens it AND lands in the
1062
- // buffer, so the character you meant is the character you
1063
- // get. This is checked BEFORE the shortcut branch below on
1064
- // purpose: on this row "3" and "t" are the start of an
1065
- // answer, not a pick and not a mode key. Everywhere else
1066
- // in the list they keep their fast-path meaning exactly.
1067
- if (askOnCustomRow(panel.view.ask, panel.ask) && c !== undefined && c >= " " && c !== "\x7f") {
1068
- this.#askStep("type");
1069
- this.#insert(c.codePointAt(0));
1070
- this.#onRender();
1071
- i += 1;
1072
- continue;
1073
- }
1074
- if (!typing && (c === " " || (c !== undefined && c >= "1" && c <= "4") || c === "t" || c === "T")) {
1075
- this.#askStep(c === " " ? "space" : c === "T" ? "t" : c);
1076
- i += 1;
1077
- continue;
1078
- }
1079
- // an ask at rest swallows stray PRINTABLE keys — the panel
1080
- // owns them, and a typed "/" or "@" must not arm the menu
1081
- // or the picker underneath. Two things are never
1082
- // swallowed: the CSI/SS3 introducer, because ←/↑/↓ are
1083
- // the ask's own keys and the parser below routes them
1084
- // (the T-Q1 red), and the CONTROL characters, because
1085
- // ctrl-c must still reach the SIGINT handler that
1086
- // cancels the panel — W21's own rule, and what the T-Q6
1087
- // race red caught: an abort with the panel up did
1088
- // nothing at all.
1089
- if (!typing && c !== undefined && c >= " " && c !== "\x7f") {
1090
- i += 1;
1091
- continue;
1092
- }
1093
- }
1094
- if (c === "\x1b" && !text.slice(i + 1).startsWith("[") && !text.slice(i + 1).startsWith("O")) {
1095
- this.#panelEsc();
1096
- i += 1;
1097
- continue;
1098
- }
1099
- if (c === "\t") {
1100
- if (panel.phase === "options")
1101
- this.#panelTab();
1102
- i += 1;
1103
- continue;
1104
- }
1105
- if (c === "\x0d" || c === "\x0a") {
1106
- // REL-0152-D10: a newline inside a PASTE is content, never a
1107
- // commit. Bracketed paste marks its own boundaries, so a
1108
- // \n between them is a line of the pasted text and nothing
1109
- // else. Without this, pasting a stack trace into a typed
1110
- // panel phase submitted the first line and dropped the
1111
- // rest into the composer behind the closed panel — the
1112
- // owner asked whether the type-your-own box takes a paste,
1113
- // and the answer was no, in the worst way.
1114
- if (this.#pasting) {
1115
- this.#insert(NEWLINE);
1116
- i += c === "\x0d" && text[i + 1] === "\x0a" ? 2 : 1;
1117
- continue;
1118
- }
1119
- this.#panelEnter();
1120
- i += 1;
1121
- continue;
1122
- }
1123
- // TUI2-R3v2 ③: the safer list answers the SAME keys the approval
1124
- // list does — one interaction model means the new surface is not
1125
- // an exception to it. A digit takes its row (the way back
1126
- // included, as the last one).
1127
- if (panel.phase === "safer" && c !== undefined && c >= "1" && c <= "9") {
1128
- this.#saferConfirm(Number(c) - 1);
1129
- i += 1;
916
+ if (this.#panelInput.up()) {
917
+ // S5: the panel answers PARSED keys — a bare Esc, an Enter, a
918
+ // Tab, a character and says how many bytes it took, or null
919
+ // when the key falls through to the composer (the amend
920
+ // reason, a custom answer and a custom pick are typed through
921
+ // the ordinary path below; an unclaimed control byte keeps its
922
+ // meaning). An ESC that opens a sequence is not a key yet: it
923
+ // goes to the sequence parser like any other.
924
+ const ahead = text.slice(i + 1);
925
+ const key = c === undefined
926
+ ? null
927
+ : c === "\x1b"
928
+ ? ahead.startsWith("[") || ahead.startsWith("O")
929
+ ? null
930
+ : { kind: "esc" }
931
+ : c === "\x0d" || c === "\x0a"
932
+ ? { kind: "enter", crlf: c === "\x0d" && text[i + 1] === "\x0a" }
933
+ : c === "\t"
934
+ ? { kind: "tab" }
935
+ : { kind: "char", ch: c };
936
+ const took = key === null ? null : this.#panelInput.feed(key, this.#pasting);
937
+ if (took !== null) {
938
+ i += took;
1130
939
  continue;
1131
940
  }
1132
- // while the ask is in flight the panel owns every printable key
1133
- // and answers to none of them — esc (above) is the only gesture
1134
- // with a meaning, and a stray letter must not reach the composer.
1135
- if (panel.phase === "asking" && c !== undefined && c >= " " && c !== "\x7f") {
1136
- i += 1;
1137
- continue;
1138
- }
1139
- // TUI2-R3v2 ① — the digit CONFIRMS, and it confirms on the
1140
- // keypress.
1141
- //
1142
- // The retired model made a digit a selection and Enter the
1143
- // commit, which meant the fastest path through an approval was
1144
- // two keys and the hint line had to teach both. The list makes
1145
- // the digit redundant as a selector — the bar is already showing
1146
- // what is selected — so the digit becomes what a human pressing
1147
- // a number on a numbered list means by it: THAT one.
1148
- //
1149
- // A digit past the list is INERT (the R2 pick panel's rule,
1150
- // inherited whole): an option nobody has is never taken, and a
1151
- // mistyped 7 must not fall through to the composer underneath.
1152
- //
1153
- // The guard is the options phase and nothing else. The typed
1154
- // phase is prose — that is the R2 slice-⑧ finding, and it is
1155
- // why this branch sits below the enter/esc/tab handlers and
1156
- // above nothing at all: "yes, run 13 of them" keeps its digits.
1157
- if (panel.phase === "options" && panel.ask === null && panel.pick === null && c !== undefined && c >= "1" && c <= "9") {
1158
- this.#panelConfirm(Number(c) - 1);
1159
- i += 1;
1160
- continue;
1161
- }
1162
- // TUI2-R2 ⑧, carried forward — the shortcut keys belong to the
1163
- // OPTIONS phase and to it alone.
1164
- //
1165
- // `y`/`n` used to be applied in every phase of every flavour:
1166
- // the `i += 1; continue;` sat OUTSIDE the phase check, so a
1167
- // phase where the key meant nothing swallowed it anyway — and a
1168
- // phase where a letter means nothing is exactly a phase where a
1169
- // human is typing prose. Every y and n vanished from the line,
1170
- // silently. "yes, run it now" committed as "es, ru it ow".
1171
- //
1172
- // The guard survives the migration unchanged in spirit and
1173
- // simpler in fact: there is now ONE typed phase instead of
1174
- // three, and the letters reach only the list.
1175
- //
1176
- // The letters stay because they are the two answers this panel
1177
- // has always taken and a decade of muscle memory types them.
1178
- // They are ALIASES for rows, not a second model: `y` is the
1179
- // first option, `n` is the last, and on an approval the last
1180
- // option opens the composer — so the old "n then enter" still
1181
- // lands the same bare denial it always did.
1182
- const optionsPhase = panel.pick === null && // a pick has no yes and no no
1183
- panel.phase === "options" && // the amend line is prose
1184
- (panel.ask === null || panel.ask.phase === "options"); // and so is a typed ask answer
1185
- if (optionsPhase && panel.ask === null) {
1186
- if (c === "y" || c === "Y") {
1187
- this.#panelConfirm(0);
1188
- i += 1;
1189
- continue;
1190
- }
1191
- if (c === "n" || c === "N") {
1192
- this.#panelConfirm(panelOptions(panel.view).length - 1);
1193
- i += 1;
1194
- continue;
1195
- }
1196
- }
1197
941
  }
1198
942
  if (c === "\x1b") {
1199
943
  const rest = text.slice(i + 1);
@@ -1245,6 +989,39 @@ export class Editor {
1245
989
  else if (rest.startsWith("O")) {
1246
990
  i += 3; // SS3 (function keys) — ignored
1247
991
  }
992
+ else if (rest !== "" && ALT_WORD.has(rest[0]) && !this.#pasting && this.#composerIdle()) {
993
+ // E1 §1 — the two-byte alt spellings, SAME-CHUNK ONLY.
994
+ //
995
+ // This follows Alt+Enter below, which ruled the identical
996
+ // question for the identical byte shape: "A terminal sends
997
+ // Alt+X as ESC and X in ONE write, so SAME-CHUNK is the
998
+ // whole test: no timer, no hold, nothing parked. The
999
+ // identical two bytes arriving in SEPARATE chunks are NOT
1000
+ // combined — the bare Esc fires at once (its immediacy is
1001
+ // exactly what a hold would spend)."
1002
+ //
1003
+ // The work order asked for the split pair to be JOINED
1004
+ // through #pending. Built that way first, and it broke six
1005
+ // gates across four files — `dc7-osc-swallow`'s case is
1006
+ // titled "a chunk boundary between ESC and ] is known to
1007
+ // leak — Esc stays immediate", which is the same ruling
1008
+ // stated from the other side. Parking a lone ESC is what
1009
+ // joining requires, and esc immediacy is what parking
1010
+ // spends: esc interrupts a run. The conflict is reported
1011
+ // rather than resolved here.
1012
+ //
1013
+ // Inside a paste these bytes are CONTENT — `\x1bb` in
1014
+ // someone's text is an escape and a letter, not a motion.
1015
+ const op = ALT_WORD.get(rest[0]);
1016
+ if (op === "left" || op === "right")
1017
+ this.#moveWord(op === "left" ? -1 : 1);
1018
+ else if (op === "killBack")
1019
+ this.#killWord();
1020
+ else
1021
+ this.#killWordForward();
1022
+ this.#onRender();
1023
+ i += 2;
1024
+ }
1248
1025
  else if (rest.startsWith("\x0d") && this.#composerIdle()) {
1249
1026
  // KC2 §2 — Alt+Enter. A terminal sends Alt+X as ESC and X in
1250
1027
  // ONE write, so SAME-CHUNK is the whole test: no timer, no
@@ -1269,13 +1046,13 @@ export class Editor {
1269
1046
  this.#refreshMenu();
1270
1047
  i += 1;
1271
1048
  }
1272
- else if (this.#pickUp()) {
1049
+ else if (this.#pickInput.up()) {
1273
1050
  // TUI2-R2 ②: esc leaves the picker with nothing picked.
1274
1051
  // The caller reads null and exits 0 — declining to resume
1275
1052
  // is a normal thing to do, not a failure, so it must not
1276
1053
  // fall through to the escapeCbs (which mean "abort the
1277
1054
  // run" and there is no run yet).
1278
- this.#pickClose(null);
1055
+ this.#pickInput.close(null);
1279
1056
  i += 1;
1280
1057
  }
1281
1058
  else if (this.#atUp()) {
@@ -1432,6 +1209,21 @@ export class Editor {
1432
1209
  cb();
1433
1210
  i += 1;
1434
1211
  }
1212
+ else if (c === "\x18" && this.#composerIdle()) {
1213
+ // E1 §3 — ctrl+x copies the last answer. `\x18` was unbound
1214
+ // across the whole tree (checked before the round started),
1215
+ // so nothing is displaced.
1216
+ //
1217
+ // Composer-idle only, like `?` and ctrl+r: a panel, picker,
1218
+ // menu or question owns its keys first. The buffer is NOT
1219
+ // required to be empty — ctrl+x copies the ANSWER, not the
1220
+ // composer, so a half-written follow-up is no reason to
1221
+ // refuse. (ctrl+r requires an empty buffer because it opens
1222
+ // a surface OVER the composer; this prints one status row.)
1223
+ for (const cb of [...this.#copyCbs])
1224
+ cb();
1225
+ i += 1;
1226
+ }
1435
1227
  else if (c === "\x12" && this.#composerIdle() && this.#chars.length === 0) {
1436
1228
  // R5 — the transcript viewer, on ctrl+r since DC-41. The
1437
1229
  // reference binds ctrl+r to renaming a session, which kiso
@@ -1454,12 +1246,48 @@ export class Editor {
1454
1246
  this.#onRender();
1455
1247
  i += 1;
1456
1248
  }
1249
+ else if (c === "\t" && this.#pasting) {
1250
+ // DC-55 — a TAB is content, and the branch below would have
1251
+ // eaten it.
1252
+ //
1253
+ // `c < " "` discards every unclaimed control byte, and a
1254
+ // paste has no other way into the buffer, so pasting
1255
+ // indented code silently lost its indentation: `alpha\tbeta`
1256
+ // arrived as `alphabeta`.
1257
+ //
1258
+ // `#pasting` IS TESTED, and the first build left it out on the
1259
+ // reasoning that a typed Tab is claimed further up anyway.
1260
+ // It is claimed CONDITIONALLY: `\t && #menuOpen` completes a
1261
+ // command and `\t && #atUp()` completes a path, so with
1262
+ // neither surface open a typed Tab falls through to exactly
1263
+ // here. Without the guard it started inserting a tab — the
1264
+ // completion key silently became an insert key on an idle
1265
+ // composer, which the typed-Tab gate caught.
1266
+ //
1267
+ // A paste is the one context where a tab is CONTENT.
1268
+ this.#insert(0x09);
1269
+ i += 1;
1270
+ }
1457
1271
  else if (c !== undefined && c < " ") {
1458
1272
  i += 1; // other control — ignored
1459
1273
  }
1460
1274
  else {
1461
- this.#insert(text.codePointAt(i));
1462
- i += c.length;
1275
+ // E1 §1 (found by the word-op gate, PRE-EXISTING): advance by
1276
+ // the CODE POINT, not by `text[i]`.
1277
+ //
1278
+ // `c` is one UTF-16 unit, so `c.length` is always 1, while
1279
+ // `codePointAt` returns the whole astral code point. Typing
1280
+ // one emoji therefore inserted the code point AND then the
1281
+ // lone low surrogate left under the cursor — `"😀"` came back
1282
+ // as `"😀\ude00"`, three UTF-16 units for one glyph.
1283
+ //
1284
+ // Verified pre-existing by stashing this round's changes and
1285
+ // re-running: the baseline is identically wrong. It surfaces
1286
+ // now because word motion is the first feature that has to
1287
+ // STEP over a grapheme rather than only append to it.
1288
+ const cp = text.codePointAt(i);
1289
+ this.#insert(cp);
1290
+ i += cp > 0xffff ? 2 : 1;
1463
1291
  }
1464
1292
  }
1465
1293
  }
@@ -1485,18 +1313,7 @@ export class Editor {
1485
1313
  // TUI2-R3v2 ③: a click works on BOTH lists — one interaction model
1486
1314
  // means the safer alternatives are clickable for the same reason the
1487
1315
  // original choices are.
1488
- if (this.#panel === null || (this.#panel.phase !== "options" && this.#panel.phase !== "safer"))
1489
- return;
1490
- const span = this.#panelRows?.();
1491
- if (span == null || row === undefined || !Number.isFinite(row))
1492
- return;
1493
- const offset = row - span.top;
1494
- if (offset < 0 || offset >= span.count)
1495
- return; // outside the list — inert
1496
- if (this.#panel.phase === "safer")
1497
- this.#saferConfirm(offset);
1498
- else
1499
- this.#panelConfirm((span.first ?? 0) + offset);
1316
+ this.#panelInput.click(this.#panelRows?.(), row);
1500
1317
  }
1501
1318
  /** TUI2-R3v2 ②: the compositor reports where it PUT the option rows.
1502
1319
  * The editor does no row arithmetic of its own — the surface that
@@ -1531,6 +1348,21 @@ export class Editor {
1531
1348
  return;
1532
1349
  }
1533
1350
  }
1351
+ // E1 §1 — alt+←/→ and ctrl+←/→, the CSI spellings of word motion.
1352
+ //
1353
+ // `1;3` is alt (meta), `1;5` is ctrl. Terminal.app sends the CSI
1354
+ // form for alt+← unless "Use Option as Meta Key" is on, in which
1355
+ // case it sends `\x1bb` — handled in the escape branch. Both are
1356
+ // the same gesture and both route here, because a gesture with
1357
+ // three spellings is a table, not three features.
1358
+ //
1359
+ // Composer-idle only, for the back-tab's reason below: a panel,
1360
+ // picker, menu or question owns its keys first.
1361
+ if ((final === "D" || final === "C") && (params === "1;3" || params === "1;5") && this.#composerIdle()) {
1362
+ this.#moveWord(final === "D" ? -1 : 1);
1363
+ this.#onRender();
1364
+ return;
1365
+ }
1534
1366
  // R3a — Shift+Tab (CSI Z, the universal back-tab encoding) cycles
1535
1367
  // the approval tier. Composer-idle ONLY: a panel, picker, menu,
1536
1368
  // history browse or question owns its keys first (the W21 gate),
@@ -1582,37 +1414,11 @@ export class Editor {
1582
1414
  // the cursor semantics are unchanged (↑↓ do nothing). W21: the
1583
1415
  // panel owns the keys while up (↑↓ do nothing — the panel has no
1584
1416
  // ↑↓ role).
1585
- if (this.#panel !== null) {
1586
- // W21: the panel owns the keys. KC3.5: an ask uses ↑↓ for
1587
- // the option cursor (the approval panel still has no ↑↓ role).
1588
- if (this.#panel.pick !== null && this.#panel.pick.phase === "options") {
1589
- // TUI2-R2 ④: ↑↓ walk the pick's cursor — the same list the
1590
- // digits address, the other muscle.
1591
- const n = Math.min(this.#panel.view.pick.options.length, PICK_MAX);
1592
- const cur = this.#panel.pick.cursor;
1593
- this.#panel.pick = { cursor: final === "A" ? Math.max(0, cur - 1) : Math.min(Math.max(0, n - 1), cur + 1), phase: "options" };
1594
- }
1595
- else if (this.#panel.ask !== null && this.#panel.ask.phase === "options")
1596
- this.#askStep(final === "A" ? "up" : "down");
1597
- // TUI2-R3v2 ①: the approval/simple panel joins them. It was the
1598
- // one panel flavour with no ↑↓ role, because it had no cursor to
1599
- // move; it has one now, and the gesture is the same one the
1600
- // pick, the ask, the session picker and the @ picker already
1601
- // answer to. ONE interaction model is the round's acceptance
1602
- // criterion, and this branch is where it stops being four.
1603
- else if (this.#panel.phase === "safer")
1604
- this.#saferMove(final === "A" ? -1 : 1);
1605
- else if (this.#panel.phase !== "asking")
1606
- this.#panelMove(final === "A" ? -1 : 1);
1417
+ if (this.#panelInput.arrow(final === "A" ? "up" : "down")) {
1418
+ // the panel walked its own list (a phase without one swallows the key)
1607
1419
  }
1608
- else if (this.#pickUp()) {
1609
- // TUI2-R2 ②: the session picker owns ↑↓ while up — the
1610
- // SELECTION, never the composer's line walk and never the
1611
- // history browse. It sits above both because the picker is
1612
- // modal: there is no turn to recall and no second line to
1613
- // walk to while it is open.
1614
- const view = this.#pickView();
1615
- this.#pickSel = final === "A" ? Math.max(0, view.selected - 1) : Math.min(Math.max(0, view.matches.length - 1), view.selected + 1);
1420
+ else if (this.#pickInput.arrow(final === "A" ? "up" : "down")) {
1421
+ // the session picker moved its selection
1616
1422
  }
1617
1423
  else if (this.#menuOpen) {
1618
1424
  if (final === "A")
@@ -1652,9 +1458,7 @@ export class Editor {
1652
1458
  else if (final === "D") {
1653
1459
  // KC3.5: ← walks the ask BACK a question (the ‹ n/m › walk); at
1654
1460
  // question one it stays put — esc is the decline, never ←.
1655
- if (this.#panel?.ask != null && this.#panel.ask.phase === "options")
1656
- this.#askStep("left");
1657
- else
1461
+ if (!this.#panelInput.left())
1658
1462
  this.#move(-1);
1659
1463
  }
1660
1464
  else if (final === "C") {
@@ -1687,365 +1491,44 @@ export class Editor {
1687
1491
  this.#reflow();
1688
1492
  this.#verticalGoalCol = goal; // the walk re-arms it (the reflow's reset is for every OTHER key)
1689
1493
  }
1690
- // ---- W21 / TUI2-R3v2 ①: the panel state machine ----
1691
- /** ↑↓ the bar walks the list and STOPS at both ends. A list that
1692
- * wraps makes the fastest gesture (hold ↓ to reach the bottom) into
1693
- * a gamble about where you landed, and the bottom option here is the
1694
- * denial. */
1695
- #panelMove(delta) {
1696
- const panel = this.#panel;
1697
- if (panel === null || panel.phase !== "options")
1698
- return;
1699
- const n = panelOptions(panel.view).length;
1700
- panel.cursor = Math.max(0, Math.min(n - 1, panel.cursor + delta));
1701
- this.#onRender();
1702
- }
1703
- /**
1704
- * Take the option at `index` — the ONE place a panel choice resolves,
1705
- * whether the human pressed a digit, pressed ⏎ on the bar, typed the
1706
- * y/n alias, or clicked the row (slice ②). Four gestures, one branch:
1707
- * a click cannot mean something a digit does not.
1708
- *
1709
- * Every kind but `deny` on an approval resolves IMMEDIATELY. That is
1710
- * the round's whole claim — the durable rule included, because the
1711
- * rule the machinery supports is exactly "this tool", and asking the
1712
- * human to confirm a value they cannot change was the old model's
1713
- * ceremony, not a safeguard.
1714
- */
1715
- #panelConfirm(index) {
1716
- const panel = this.#panel;
1717
- if (panel === null || panel.phase !== "options")
1718
- return;
1719
- const options = panelOptions(panel.view);
1720
- const option = options[index];
1721
- if (option === undefined)
1722
- return; // a digit past the list is inert
1723
- panel.cursor = index;
1724
- switch (option.kind) {
1725
- case "allow":
1726
- this.#panelClose({ action: "allow", reason: "" });
1727
- return;
1728
- case "rule":
1729
- this.#panelClose({ action: "allow-rule", rule: panel.view.name });
1730
- return;
1731
- case "safer":
1732
- this.#panelSafer();
1733
- return;
1734
- case "deny":
1735
- // the approval flavor's denial is "let me tell it what to do
1736
- // instead", so it opens the composer; the simple flavors have
1737
- // nothing to tell anyone and resolve on the spot.
1738
- if (panel.view.flavor === "approval")
1739
- this.#panelAmend();
1740
- else
1741
- this.#panelClose({ action: "deny", reason: "" });
1742
- return;
1743
- }
1744
- }
1745
- /**
1746
- * Option 3 — "show me safer ways to do this".
1747
- *
1748
- * The round's ONE new model request, and every branch here exists to
1749
- * keep it honest.
1750
- *
1751
- * It fires ONLY from this method, which only this option reaches —
1752
- * that is the entire mechanism behind the zero-ambient-rent claim,
1753
- * and it is why the claim is checkable rather than asserted: a
1754
- * session that never presses 3 never enters this branch, and the
1755
- * trace shows no side-query line.
1756
- *
1757
- * The in-flight phase is VISIBLE because this is a network call: a
1758
- * button that goes quiet for two seconds reads as broken, and the
1759
- * human is standing in front of a paused run.
1760
- *
1761
- * Every failure — a throw, a null, an empty list, no provider bound
1762
- * at all — lands on the SAME honest branch and puts back every
1763
- * original choice. There is deliberately no retry and no partial
1764
- * state: the alternative to "I could not get them" is either a lie or
1765
- * a spinner that never ends, and both are worse than the sentence.
1766
- *
1767
- * R3v2-F1: one of those failures can now name its cause, and the
1768
- * sentence says it. That is a widening of the COPY, not of the
1769
- * branch — there is still exactly one failure path, it still restores
1770
- * every original choice, and a provider that has nothing to add still
1771
- * resolves `null` and still gets the line it always got. A cause is
1772
- * only ever spoken when the caller could prove it; a diagnosis the
1773
- * product cannot prove would be worse than the unqualified line it
1774
- * replaced.
1775
- *
1776
- * The generation token is the guard against a late answer: a panel
1777
- * the human escaped (or that a SIGINT cancelled) must not be
1778
- * resurrected two seconds later by a promise nobody is waiting for.
1779
- */
1780
- #panelSafer() {
1781
- const panel = this.#panel;
1782
- if (panel === null)
1783
- return;
1784
- const ask = panel.safer;
1785
- panel.phase = "asking";
1786
- panel.note = null;
1787
- this.#onRender();
1788
- const token = ++this.#saferToken;
1789
- const settle = (answer) => {
1790
- // the panel that asked must still be the panel on screen
1791
- if (this.#panel !== panel || token !== this.#saferToken)
1792
- return;
1793
- // R3v2-F1: a non-list answer is a failure, and it may name its
1794
- // cause. Which sentence that earns is decided where the
1795
- // sentences live; here we only route to the same one branch
1796
- // every failure has always taken.
1797
- const options = Array.isArray(answer) ? answer : null;
1798
- if (options === null || options.length === 0) {
1799
- panel.phase = "options";
1800
- panel.note = saferDegradedNote(answer);
1801
- panel.cursor = 0;
1802
- this.#onRender();
1803
- return;
1804
- }
1805
- panel.phase = "safer";
1806
- panel.saferRun = { options, cursor: 0 };
1807
- this.#onRender();
1494
+ /* DECLARED MOVE (S5, 2026-09-06): the panel state machine — the
1495
+ fourteen handlers from #panelMove to #panelClose lives in
1496
+ panel-input.ts (PanelInput); the session picker's in pick-input.ts
1497
+ (PickInput). The editor parses bytes and lends them the composer
1498
+ through #bandHost; nothing else of either band remains here. */
1499
+ /** S5 — the composer as the band controllers see it: the whole
1500
+ * surface a panel or the session picker may touch, in one place. */
1501
+ #bandHost() {
1502
+ return {
1503
+ line: () => this.line(),
1504
+ expandPastes: (line) => this.#expandPastes(line),
1505
+ clear: () => {
1506
+ this.#chars = [];
1507
+ this.#cursor = 0;
1508
+ this.#scroll = 0;
1509
+ this.#verticalGoalCol = null;
1510
+ },
1511
+ insert: (cp) => this.#insert(cp),
1512
+ newline: () => this.#insert(NEWLINE),
1513
+ stash: () => ({ chars: this.#chars, cursor: this.#cursor, scroll: this.#scroll }),
1514
+ restore: (st) => {
1515
+ this.#chars = [...st.chars];
1516
+ this.#cursor = st.cursor;
1517
+ this.#scroll = st.scroll;
1518
+ },
1519
+ reflow: () => this.#reflow(),
1520
+ render: () => this.#onRender(),
1521
+ syncMouse: () => this.#syncMouse(),
1522
+ closeBands: () => {
1523
+ this.#menuOpen = false;
1524
+ this.#menuSel = 0;
1525
+ this.#queuePopMode = false; // W22: the panel owns the keys while up
1526
+ this.#atClose(); // KC3 §3: and the picker closes with everything else
1527
+ },
1528
+ swallowNextEnter: () => {
1529
+ this.#swallowEnter = true;
1530
+ },
1808
1531
  };
1809
- if (ask === undefined) {
1810
- settle(null); // no provider bound — the button says so rather than lying
1811
- return;
1812
- }
1813
- void Promise.resolve()
1814
- .then(ask)
1815
- .then(settle)
1816
- .catch(() => settle(null));
1817
- }
1818
- /** Take a row of the SAFER list. The alternatives route through the
1819
- * EXISTING amend channel — choosing a safer command is a denial with
1820
- * instructions, which is a verdict the product already has; the last
1821
- * row is the way back and decides nothing. */
1822
- #saferConfirm(index) {
1823
- const panel = this.#panel;
1824
- if (panel === null || panel.saferRun === null)
1825
- return;
1826
- const { options } = panel.saferRun;
1827
- if (index === options.length) {
1828
- // "back to the original choices"
1829
- panel.phase = "options";
1830
- panel.saferRun = null;
1831
- panel.cursor = 0;
1832
- this.#onRender();
1833
- return;
1834
- }
1835
- const chosen = options[index];
1836
- if (chosen === undefined)
1837
- return; // past the list — inert
1838
- this.#panelClose({ action: "deny", reason: `run this instead: ${chosen.command}` });
1839
- }
1840
- /** ↑↓ inside the safer list — the way back is its last row, so the
1841
- * bar reaches it like any other. */
1842
- #saferMove(delta) {
1843
- const panel = this.#panel;
1844
- if (panel === null || panel.saferRun === null)
1845
- return;
1846
- const last = panel.saferRun.options.length; // + the way-back row
1847
- panel.saferRun = { options: panel.saferRun.options, cursor: Math.max(0, Math.min(last, panel.saferRun.cursor + delta)) };
1848
- this.#onRender();
1849
- }
1850
- /** The typed phase — the one place the panel takes prose. The buffer
1851
- * starts empty and the cursor stays where the human left it, so esc
1852
- * can put the bar back exactly where it was. */
1853
- #panelAmend() {
1854
- const panel = this.#panel;
1855
- if (panel === null)
1856
- return;
1857
- panel.phase = "amend";
1858
- this.#chars = [];
1859
- this.#cursor = 0;
1860
- this.#scroll = 0;
1861
- this.#verticalGoalCol = null;
1862
- this.#onRender();
1863
- }
1864
- /** tab — the amend alias, unchanged as a GESTURE: it opens the same
1865
- * typed phase the last option does, from anywhere in the list. The
1866
- * simple flavors never had it and still do not. */
1867
- #panelTab() {
1868
- const panel = this.#panel;
1869
- if (panel === null || panel.view.flavor !== "approval")
1870
- return;
1871
- panel.cursor = panelOptions(panel.view).length - 1;
1872
- this.#panelAmend();
1873
- }
1874
- /** esc — back out of the typed phase to the list (the buffer clears,
1875
- * the bar stays on the option that opened it), or cancel the panel.
1876
- * The old model had a third step, the deselect, because a selection
1877
- * could be "none"; a list always has a selection, so esc from the
1878
- * list means what it means everywhere else in the product. */
1879
- #panelEsc() {
1880
- const panel = this.#panel;
1881
- if (panel === null)
1882
- return;
1883
- // TUI2-R3v2 ③: esc out of the safer list — or out of the ask while
1884
- // it is still in flight — returns to the original choices, exactly
1885
- // as the way-back row does. The in-flight answer is orphaned by the
1886
- // generation token; nothing it does can reopen this list.
1887
- if (panel.phase === "safer" || panel.phase === "asking") {
1888
- this.#saferToken += 1;
1889
- panel.phase = "options";
1890
- panel.saferRun = null;
1891
- panel.cursor = 0;
1892
- this.#onRender();
1893
- return;
1894
- }
1895
- if (panel.phase !== "options") {
1896
- panel.phase = "options";
1897
- this.#chars = [];
1898
- this.#cursor = 0;
1899
- this.#scroll = 0;
1900
- this.#verticalGoalCol = null;
1901
- this.#onRender();
1902
- return;
1903
- }
1904
- this.#panelClose({ action: "cancel" });
1905
- }
1906
- /**
1907
- * enter — send the typed note, or TAKE THE HIGHLIGHTED OPTION.
1908
- *
1909
- * The second half is the round. The retired model's enter-at-rest did
1910
- * nothing at all, on the theory that an accidental return must never
1911
- * approve; what it actually produced was a panel that ignored the key
1912
- * every human presses first. The safeguard is real but it belongs on
1913
- * WHERE THE BAR STARTS, not on whether the key works: the bar opens on
1914
- * the option whose blast radius is one tool call the human is looking
1915
- * at, and every irreversible-er choice is a deliberate ↑↓ away.
1916
- *
1917
- * An empty note in the typed phase is the bare denial — the W21
1918
- * mapping, untouched: no words means the run aborts, words mean the
1919
- * model gets them and proposes a new call.
1920
- */
1921
- #panelEnter() {
1922
- const panel = this.#panel;
1923
- if (panel === null)
1924
- return;
1925
- if (panel.phase === "amend") {
1926
- this.#panelClose({ action: "deny", reason: this.line() });
1927
- return;
1928
- }
1929
- // TUI2-R3v2 ③: in the safer list, enter takes the highlighted
1930
- // alternative — the same gesture, one surface over.
1931
- if (panel.phase === "safer" && panel.saferRun !== null) {
1932
- this.#saferConfirm(panel.saferRun.cursor);
1933
- return;
1934
- }
1935
- if (panel.phase === "asking")
1936
- return; // nothing to confirm yet
1937
- this.#panelConfirm(panel.cursor);
1938
- }
1939
- /**
1940
- * KC3.5 — one ask key: the pure reducer decides, this method applies.
1941
- * The buffer is cleared on every phase change so the type-your-own
1942
- * line starts empty and its text never leaks back into the options
1943
- * (the rule-input phase's own discipline). A step that produced a
1944
- * RESULT closes the panel with it — the stash/restore is the W21
1945
- * path, identical for an answer and for a decline.
1946
- */
1947
- #askStep(key) {
1948
- const panel = this.#panel;
1949
- if (panel === null || panel.ask === null)
1950
- return;
1951
- const spec = panel.view.ask;
1952
- const before = panel.ask.phase;
1953
- // REL-0152-D8: a typed ask answer is a line leaving the editor too —
1954
- // pasting a stack trace into "type your own answer" must send the
1955
- // stack trace, not the capsule that stands for it.
1956
- const step = key === "commit" ? askCommitCustom(spec, panel.ask, this.#expandPastes(this.line())) : askKey(spec, panel.ask, key);
1957
- panel.ask = step.state;
1958
- if (step.state.phase !== before) {
1959
- this.#chars = [];
1960
- this.#cursor = 0;
1961
- this.#scroll = 0;
1962
- }
1963
- if (step.result !== undefined) {
1964
- this.#panelClose({ action: "answers", result: step.result });
1965
- return;
1966
- }
1967
- this.#onRender();
1968
- }
1969
- /**
1970
- * TUI2-R2 ④ — the pick panel's three keys.
1971
- *
1972
- * A digit MOVES the cursor rather than committing. The list is short
1973
- * and the digits are adjacent on the keyboard; a picker that acted on
1974
- * the keypress would make a mistyped 3 a model switch, and the whole
1975
- * point of a confirm step is that the choice is visible before it is
1976
- * taken.
1977
- */
1978
- #pickPanelDigit(index) {
1979
- const panel = this.#panel;
1980
- if (panel === null || panel.pick === null)
1981
- return;
1982
- // a digit past the list is INERT — an option nobody has is never
1983
- // selected, and the cursor stays where the human left it
1984
- if (index < 0 || index >= Math.min(panel.view.pick.options.length, PICK_MAX))
1985
- return;
1986
- panel.pick = { cursor: index, phase: "options" };
1987
- this.#onRender();
1988
- }
1989
- /** enter — the typed line when there is one (an EMPTY line is not a
1990
- * choice and commits nothing), else the option under the cursor. */
1991
- #pickPanelEnter() {
1992
- const panel = this.#panel;
1993
- if (panel === null || panel.pick === null)
1994
- return;
1995
- if (panel.pick.phase === "custom") {
1996
- const line = this.line().trim();
1997
- if (line === "")
1998
- return;
1999
- this.#panelClose({ action: "picked", result: { custom: line } });
2000
- return;
2001
- }
2002
- if (panel.view.pick.options.length === 0)
2003
- return; // nothing to take
2004
- this.#panelClose({ action: "picked", result: { index: panel.pick.cursor } });
2005
- }
2006
- /** esc — back out of the type-it line first, then cancel the panel.
2007
- * Two escapes, two meanings, exactly as the approval panel's
2008
- * rule/amend phases already work. */
2009
- #pickPanelEsc() {
2010
- const panel = this.#panel;
2011
- if (panel === null || panel.pick === null)
2012
- return;
2013
- if (panel.pick.phase === "custom") {
2014
- panel.pick = { cursor: panel.pick.cursor, phase: "options" };
2015
- this.#chars = [];
2016
- this.#cursor = 0;
2017
- this.#scroll = 0;
2018
- this.#onRender();
2019
- return;
2020
- }
2021
- this.#panelClose({ action: "cancel" });
2022
- }
2023
- #panelClose(verdict) {
2024
- const panel = this.#panel;
2025
- if (panel === null)
2026
- return;
2027
- this.#panel = null;
2028
- this.#syncMouse();
2029
- // TUI2-R3v2 ①: swallow ONE bare enter after the panel goes away.
2030
- //
2031
- // This is the hazard the instant confirm creates and it is not
2032
- // hypothetical: "y⏎" and "1⏎" are what a decade of y/n prompts
2033
- // taught everyone's fingers, and the panel used to need both bytes.
2034
- // It needs one now — so the second one lands in a composer that has
2035
- // just had the user's PRE-PANEL DRAFT restored into it, and submits
2036
- // it. Answering an approval would send a half-written message.
2037
- //
2038
- // The guard is one-shot and expires on any other key, so it can
2039
- // never eat an enter the user meant: by the time they have typed
2040
- // anything at all, it is gone.
2041
- this.#swallowEnter = true;
2042
- // the pre-panel buffer returns — the panel's amend text never leaks
2043
- // into the user's next turn (commit AND cancel).
2044
- this.#chars = [...panel.stash.chars];
2045
- this.#cursor = panel.stash.cursor;
2046
- this.#scroll = panel.stash.scroll;
2047
- this.#onRender();
2048
- panel.onCommit(verdict);
2049
1532
  }
2050
1533
  // ---- editing ----
2051
1534
  #insert(cp) {
@@ -2171,21 +1654,127 @@ export class Editor {
2171
1654
  if (!this.#pasting)
2172
1655
  this.#onRender();
2173
1656
  }
2174
- /** Ctrl+W — the word kill. The newline rides as a non-space code
2175
- * point (a kill at a line's start joins it to the one above, the
2176
- * readline behavior); A3 scopes A/E/U/K, not W. */
1657
+ /**
1658
+ * E1 §1 WHERE A WORD ENDS. One function, five operations.
1659
+ *
1660
+ * `#killWord` used to answer this inline, and only for `0x20`: no
1661
+ * tab, no punctuation, and a whole Chinese sentence was one word from
1662
+ * its first character to its last. Five operations asking the
1663
+ * question separately is five chances for a motion and a deletion to
1664
+ * disagree about the same text, so they ask here.
1665
+ *
1666
+ * Three classes over the CODE POINT:
1667
+ *
1668
+ * - SEPARATOR — whitespace, and the newline the flat buffer carries
1669
+ * as an ordinary code point;
1670
+ * - CJK — one character IS one word. A sentence is not a unit
1671
+ * anyone wants to move or delete by, and the owner types Chinese;
1672
+ * - otherwise WORD vs PUNCT, which split from each other: `foo.bar`
1673
+ * is three words, the readline behaviour and every editor's.
1674
+ *
1675
+ * Combining marks, ZWJ joins and variation selectors are NOT their
1676
+ * own class — they belong to whatever precedes them (`#glyphStart`),
1677
+ * so a family emoji deletes whole instead of shedding a member and
1678
+ * stranding a joiner.
1679
+ */
1680
+ #classOf(cp) {
1681
+ // U+3000 IS A SPACE, and it sits inside the CJK range below, so it
1682
+ // has to be named before the range test rather than after it. It
1683
+ // classed as "cjk" in the first build — an ideographic space
1684
+ // deleted as though it were a character, which is exactly what the
1685
+ // CJK-per-character rule is NOT about.
1686
+ if (cp === 0x20 || cp === 0x09 || cp === 0x0a || cp === 0x0d || cp === 0x3000)
1687
+ return "sep";
1688
+ // CJK ideographs, kana, Hangul, the fullwidth forms — one per word.
1689
+ if ((cp >= 0x1100 && cp <= 0x11ff) ||
1690
+ (cp >= 0x2e80 && cp <= 0x9fff) ||
1691
+ (cp >= 0xa960 && cp <= 0xa97f) ||
1692
+ (cp >= 0xac00 && cp <= 0xd7ff) ||
1693
+ (cp >= 0xf900 && cp <= 0xfaff) ||
1694
+ (cp >= 0xff00 && cp <= 0xffef) ||
1695
+ (cp >= 0x20000 && cp <= 0x3ffff)) {
1696
+ return "cjk";
1697
+ }
1698
+ const ch = String.fromCodePoint(cp);
1699
+ if (/[\p{L}\p{N}_]/u.test(ch))
1700
+ return "word";
1701
+ return "punct";
1702
+ }
1703
+ /** True when `i` is a continuation of the glyph before it — a
1704
+ * combining mark, a ZWJ, or a variation selector. Never a boundary. */
1705
+ #joins(cp) {
1706
+ return ((cp >= 0x0300 && cp <= 0x036f) || // combining diacriticals
1707
+ (cp >= 0x1ab0 && cp <= 0x1aff) ||
1708
+ (cp >= 0x20d0 && cp <= 0x20ff) ||
1709
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
1710
+ cp === 0x200d || // ZWJ
1711
+ (cp >= 0xe0100 && cp <= 0xe01ef));
1712
+ }
1713
+ /** The index one WORD away from `from`, in `dir`. Skips a run of
1714
+ * separators, then crosses one run of a single class. Joiners never
1715
+ * end a run, so a grapheme is never split. */
1716
+ #wordEdge(from, dir) {
1717
+ const at = (i) => this.#chars[dir < 0 ? i - 1 : i] ?? -1;
1718
+ let i = from;
1719
+ const end = dir < 0 ? 0 : this.#chars.length;
1720
+ const more = () => (dir < 0 ? i > end : i < end);
1721
+ while (more() && this.#classOf(at(i)) === "sep")
1722
+ i += dir;
1723
+ if (!more())
1724
+ return i;
1725
+ // A JOINER NEVER DECIDES THE RUN'S CLASS. Walking backwards, the
1726
+ // first thing seen at the end of `café` is the combining acute —
1727
+ // which is not a letter, so classifying from it made the run
1728
+ // "punct" and the kill stopped after one mark, leaving `e`.
1729
+ // Joiners belong to the character before them; step over them
1730
+ // first and classify from the base.
1731
+ while (more() && this.#joins(at(i)))
1732
+ i += dir;
1733
+ if (!more())
1734
+ return i;
1735
+ // CJK: exactly one character (plus anything joined to it).
1736
+ const cls = this.#classOf(at(i));
1737
+ if (cls === "cjk") {
1738
+ i += dir;
1739
+ while (more() && this.#joins(at(i)))
1740
+ i += dir;
1741
+ return i;
1742
+ }
1743
+ while (more()) {
1744
+ const c = at(i);
1745
+ if (!this.#joins(c) && this.#classOf(c) !== cls)
1746
+ break;
1747
+ i += dir;
1748
+ }
1749
+ return i;
1750
+ }
1751
+ /** Ctrl+W and alt+backspace — the word kill. A3 scopes A/E/U/K, not
1752
+ * W. UD-1: an archive point only when something is actually removed;
1753
+ * a no-op kill at the buffer's start must not eat the next ctrl+z. */
2177
1754
  #killWord() {
2178
- let i = this.#cursor;
2179
- while (i > 0 && this.#chars[i - 1] === 0x20)
2180
- i -= 1; // trailing spaces
2181
- while (i > 0 && this.#chars[i - 1] !== 0x20)
2182
- i -= 1; // the word
2183
- if (i < this.#cursor)
2184
- this.#checkpoint(); // UD-1
1755
+ const i = this.#wordEdge(this.#cursor, -1);
1756
+ if (i >= this.#cursor)
1757
+ return;
1758
+ this.#checkpoint(); // UD-1
2185
1759
  this.#chars.splice(i, this.#cursor - i);
2186
1760
  this.#cursor = i;
2187
1761
  this.#reflow();
2188
1762
  }
1763
+ /** alt+d — the word kill FORWARD. Same boundary, same archive rule. */
1764
+ #killWordForward() {
1765
+ const j = this.#wordEdge(this.#cursor, 1);
1766
+ if (j <= this.#cursor)
1767
+ return;
1768
+ this.#checkpoint(); // UD-1
1769
+ this.#chars.splice(this.#cursor, j - this.#cursor);
1770
+ this.#reflow();
1771
+ }
1772
+ /** alt+←/→ — the cursor one word over. No archive point: nothing is
1773
+ * destroyed. */
1774
+ #moveWord(dir) {
1775
+ this.#cursor = this.#wordEdge(this.#cursor, dir);
1776
+ this.#reflow();
1777
+ }
2189
1778
  /** KC1/KC2 — the buffer LEAVES: the flat chars, the cursor, the
2190
1779
  * horizontal scroll, the ↑/↓ goal, the menu and the pop-walk all
2191
1780
  * reset together (W22: a departing line ends the pop-walk, so the
@@ -2342,10 +1931,10 @@ export class Editor {
2342
1931
  * one of those states the two bytes fall through to today's
2343
1932
  * handling — two gestures, unchanged. */
2344
1933
  #composerIdle() {
2345
- return (this.#panel === null &&
1934
+ return (!this.#panelInput.up() &&
2346
1935
  !this.#menuOpen &&
2347
1936
  !this.#atUp() && // KC3 §3: the @ picker owns the keys while up, exactly like the menu
2348
- !this.#pickUp() && // TUI2-R2 ②: and so does the session picker — `?` is a query character there
1937
+ !this.#pickInput.up() && // TUI2-R2 ②: and so does the session picker — `?` is a query character there
2349
1938
  this.#historyIdx === null &&
2350
1939
  !this.#queuePopMode &&
2351
1940
  !this.#pasting &&
@@ -2380,8 +1969,8 @@ export class Editor {
2380
1969
  #submit() {
2381
1970
  // TUI2-R2 ②: the session picker takes Enter before anything else —
2382
1971
  // while it is up there is no turn to submit and no line to send.
2383
- if (this.#pickUp()) {
2384
- this.#pickAccept();
1972
+ if (this.#pickInput.up()) {
1973
+ this.#pickInput.accept();
2385
1974
  return;
2386
1975
  }
2387
1976
  // KC3 §3: Enter ACCEPTS while the picker is up — the same rule the
@@ -2485,7 +2074,8 @@ export class Editor {
2485
2074
  // W23: the ONE width authority — leadWidth(lead) — the cap follows
2486
2075
  // the lead the editor itself renders (the panel lead when the panel
2487
2076
  // owns the keys, the brick otherwise): maxW = W − walls − lead.
2488
- const lead = this.#panel !== null ? panelLead(this.#panel.view, this.#panel.phase, this.#panel.cursor, this.#panel.ask ?? undefined) : PROMPT;
2077
+ const ps = this.#panelInput.state();
2078
+ const lead = ps !== null ? panelLead(ps.view, ps.phase, ps.cursor, ps.ask) : PROMPT;
2489
2079
  const leadW = leadWidth(lead);
2490
2080
  // DC-17: ONE column, not four. W6's box took 2+2 and this kept
2491
2081
  // reserving them after law 1.1 retired it — so the horizontal