@cplieger/web-terminal-engine 2.7.0 → 3.0.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/src/keyboard.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // vt screen tracks both bracketed paste (?2004) and DECCKM (?1) and
15
15
  // emits a modes frame whenever they change.
16
16
 
17
- import { getKeyboardFlags, isApplicationCursor, isBracketedPaste } from "./modes.js";
17
+ import { getKeyboardFlags, isBracketedPaste } from "./modes.js";
18
18
 
19
19
  /** Result of mapping a keyboard event. */
20
20
  export type KeyboardResult =
@@ -53,27 +53,80 @@ function modifiersDigit(ev: KeyboardEvent): number {
53
53
  }
54
54
 
55
55
  // -- Cursor / navigation keys -----------------------------------------------
56
+
57
+ /** The cursor-key trailing letters that honor DECCKM (arrows + Home/End). */
58
+ type CursorKeyLetter = "A" | "B" | "C" | "D" | "H" | "F";
59
+
60
+ function isCursorKeyLetter(letter: string): letter is CursorKeyLetter {
61
+ return (
62
+ letter === "A" ||
63
+ letter === "B" ||
64
+ letter === "C" ||
65
+ letter === "D" ||
66
+ letter === "H" ||
67
+ letter === "F"
68
+ );
69
+ }
70
+
71
+ /**
72
+ * plainCursorKeySeq is THE encoding of an unmodified logical cursor-key press
73
+ * (arrows, Home, End) — the one home for the mode decision, shared by the
74
+ * physical-key path (csiLetter's modifier-less branch) and the mobile
75
+ * toolbar's arrow buttons so the two encoders cannot drift:
76
+ * - kitty disambiguate active: the CSI form regardless of DECCKM (the
77
+ * protocol supersedes cursor-key mode);
78
+ * - DECCKM (application cursor mode): the SS3 form (ESC O letter);
79
+ * - otherwise: the bare CSI form.
80
+ *
81
+ * Both mode bits are PARAMETERS (not read from the module-global mode state):
82
+ * mapKeyboardEvent honors an injected KeyboardModes, and a helper silently
83
+ * consulting the global would diverge from it in a tabbed shell.
84
+ */
85
+ export function plainCursorKeySeq(
86
+ letter: CursorKeyLetter,
87
+ kittyActive: boolean,
88
+ appCursor: boolean,
89
+ ): string {
90
+ if (kittyActive) {
91
+ return `${ESC}[${letter}`;
92
+ }
93
+ return appCursor ? `${ESC}O${letter}` : `${ESC}[${letter}`;
94
+ }
95
+
96
+ /**
97
+ * plainEscapeSeq is THE encoding of an unmodified logical Escape press: CSI
98
+ * 27 u under kitty disambiguate (the protocol's headline unambiguous-Esc
99
+ * feature — the app never has to distinguish a lone ESC byte from the start
100
+ * of an escape sequence), else the bare ESC byte. Shared by the physical-key
101
+ * path and the toolbar's ESC button. The flag is a parameter for the same
102
+ * injected-modes honesty as plainCursorKeySeq.
103
+ */
104
+ export function plainEscapeSeq(kittyActive: boolean): string {
105
+ return kittyActive ? `${ESC}[27u` : ESC;
106
+ }
107
+
108
+ /** kittyActiveIn reads the disambiguate flag from an INJECTED KeyboardModes
109
+ * (the seam mapKeyboardEvent honors), as opposed to kittyDisambiguateActive,
110
+ * which reads the module-global active-session facade used by the toolbar.
111
+ * connection.setSession restores that facade from the target session's
112
+ * snapshot synchronously before switch-window input can be encoded. */
113
+ function kittyActiveIn(modes: KeyboardModes): boolean {
114
+ return (modes.getKeyboardFlags() & KITTY_DISAMBIGUATE) !== 0;
115
+ }
116
+
56
117
  // Letter is the xterm trailing letter (ABCDEFGHPQRS for arrows / Home /
57
118
  // End / F1-F4 etc.). Without modifiers we send the bare CSI form; with
58
119
  // modifiers we send CSI 1;{mod}{letter}. xterm.js Keyboard.ts pattern.
59
120
  //
60
121
  // Application cursor mode (DECCKM, CSI ?1) is plumbed via modes.ts.
61
122
  // When the application has set DECCKM, the modifier-less form switches
62
- // from CSI to SS3 (ESC O letter); modifier-bearing forms stay on CSI
63
- // because they have no SS3 equivalent.
123
+ // from CSI to SS3 (ESC O letter) via plainCursorKeySeq; modifier-bearing
124
+ // forms stay on CSI because they have no SS3 equivalent.
64
125
  function csiLetter(letter: string, ev: KeyboardEvent, modes: KeyboardModes): string {
65
126
  const m = modifiersDigit(ev);
66
127
  if (m === 1) {
67
- if (
68
- modes.isApplicationCursor() &&
69
- (letter === "A" ||
70
- letter === "B" ||
71
- letter === "C" ||
72
- letter === "D" ||
73
- letter === "H" ||
74
- letter === "F")
75
- ) {
76
- return `${ESC}O${letter}`;
128
+ if (isCursorKeyLetter(letter)) {
129
+ return plainCursorKeySeq(letter, kittyActiveIn(modes), modes.isApplicationCursor());
77
130
  }
78
131
  return `${ESC}[${letter}`;
79
132
  }
@@ -101,6 +154,19 @@ const FN_TILDE: Record<string, number | undefined> = {
101
154
  F10: 21,
102
155
  F11: 23,
103
156
  F12: 24,
157
+ // F13-F20 use xterm's own extended tilde codes (25-34, with 27 and 30
158
+ // skipped historically). F21-F24 have NO standard legacy encoding — xterm
159
+ // itself stops at F20 — so they stay silent here and are covered only by
160
+ // the kitty path (dedicated codepoints; see KITTY_FN_HIGH). Note xterm.js
161
+ // supports none of F13+ (xtermjs/xterm.js#1426, open since 2018).
162
+ F13: 25,
163
+ F14: 26,
164
+ F15: 28,
165
+ F16: 29,
166
+ F17: 31,
167
+ F18: 32,
168
+ F19: 33,
169
+ F20: 34,
104
170
  };
105
171
 
106
172
  const ARROW_LETTER: Record<string, string | undefined> = {
@@ -183,6 +249,26 @@ const KITTY_TILDE: Record<string, number | undefined> = {
183
249
  F12: 24,
184
250
  };
185
251
 
252
+ // F13-F24 under kitty: the spec assigns them dedicated functional-key
253
+ // codepoints (57376-57387, contiguous), encoded as CSI {code};{mod} u like
254
+ // the keypad keys. The legacy path covers only F13-F20 (xterm tilde codes;
255
+ // see FN_TILDE) — these twelve make the kitty path the complete one, which
256
+ // exceeds xterm.js (no F13+ support at all, xtermjs/xterm.js#1426).
257
+ const KITTY_FN_HIGH: Record<string, number | undefined> = {
258
+ F13: 57376,
259
+ F14: 57377,
260
+ F15: 57378,
261
+ F16: 57379,
262
+ F17: 57380,
263
+ F18: 57381,
264
+ F19: 57382,
265
+ F20: 57383,
266
+ F21: 57384,
267
+ F22: 57385,
268
+ F23: 57386,
269
+ F24: 57387,
270
+ };
271
+
186
272
  // US-layout base (unshifted) codepoints for the non-alphanumeric physical keys,
187
273
  // keyed by ev.code. Used so a ctrl/alt+shift+symbol event reports the UNSHIFTED
188
274
  // key code the spec mandates (e.g. Ctrl+Shift+; is CSI 59;6u using ';'=59, not
@@ -202,6 +288,58 @@ const KITTY_CODE_BASE: Record<string, number | undefined> = {
202
288
  Quote: 39, // '
203
289
  };
204
290
 
291
+ // US-layout shifted character -> unshifted base codepoint, for input paths
292
+ // that only have the produced CHARACTER (the mobile toolbar's sticky-Ctrl),
293
+ // not a KeyboardEvent with ev.code. The character-level twin of
294
+ // KITTY_CODE_BASE: Ctrl+':' must encode as CSI 59;6u (unshifted ';' + the
295
+ // shift modifier), matching what the physical-keyboard path derives from
296
+ // ev.code — not 58;5u from the shifted glyph.
297
+ const KITTY_SHIFTED_CHAR_BASE: Record<string, number | undefined> = {
298
+ ":": 59, // ;
299
+ "<": 44, // ,
300
+ ">": 46, // .
301
+ "?": 47, // /
302
+ '"': 39, // '
303
+ "{": 91, // [
304
+ "}": 93, // ]
305
+ "|": 92, // \
306
+ "~": 96, // `
307
+ "+": 61, // =
308
+ _: 45, // -
309
+ "!": 49, // 1
310
+ "@": 50, // 2
311
+ "#": 51, // 3
312
+ $: 52, // 4
313
+ "%": 53, // 5
314
+ "^": 54, // 6
315
+ "&": 55, // 7
316
+ "*": 56, // 8
317
+ "(": 57, // 9
318
+ ")": 48, // 0
319
+ };
320
+
321
+ /**
322
+ * kittyCtrlCharSeq encodes Ctrl+<typed character> in the kitty CSI-u form
323
+ * using the spec's unshifted-key rule: a shifted glyph (':', 'A', '{') maps to
324
+ * its US-layout base codepoint with the shift bit added to the modifier
325
+ * (Ctrl+':' -> 59;6u), an unshifted one encodes directly (Ctrl+'s' -> 115;5u).
326
+ * Shared by every path that has only the character (sticky-Ctrl toolbar),
327
+ * keeping it byte-identical with the physical-keyboard encoder.
328
+ */
329
+ export function kittyCtrlCharSeq(ch: string): string | null {
330
+ let cp = KITTY_SHIFTED_CHAR_BASE[ch];
331
+ let shifted = cp !== undefined;
332
+ if (cp === undefined && ch >= "A" && ch <= "Z") {
333
+ cp = ch.toLowerCase().codePointAt(0);
334
+ shifted = true;
335
+ }
336
+ cp ??= ch.toLowerCase().codePointAt(0);
337
+ if (cp === undefined) {
338
+ return null;
339
+ }
340
+ return `${ESC}[${cp};${shifted ? 6 : 5}u`; // 5 = ctrl, 6 = ctrl+shift
341
+ }
342
+
205
343
  // Keypad NON-TEXT keys under disambiguate, keyed by ev.key (the key's current
206
344
  // function — NumLock flips a numpad key between its text digit, which stays text
207
345
  // under 0x1, and these navigation functions). ev.code identifies it as a keypad
@@ -303,6 +441,10 @@ function encodeKittyDisambiguate(ev: KeyboardEvent): KeyboardResult {
303
441
  if (tnum !== undefined) {
304
442
  return { kind: "send", bytes: kittyTildeSeq(tnum, ev) };
305
443
  }
444
+ const fnHigh = KITTY_FN_HIGH[key];
445
+ if (fnHigh !== undefined) {
446
+ return { kind: "send", bytes: kittyUSeq(fnHigh, ev) };
447
+ }
306
448
 
307
449
  // Enter / Tab / Backspace keep their legacy bytes when UNMODIFIED (so a user
308
450
  // can still type `reset` if a program leaves the mode on after crashing);
@@ -317,7 +459,11 @@ function encodeKittyDisambiguate(ev: KeyboardEvent): KeyboardResult {
317
459
  return { kind: "send", bytes: anyModifier(ev) ? kittyUSeq(127, ev) : DEL };
318
460
  }
319
461
  if (key === "Escape") {
320
- return { kind: "send", bytes: kittyUSeq(27, ev) };
462
+ // Unmodified Escape shares the logical-Escape home with the toolbar's ESC
463
+ // button; a modified Escape carries its modifier digit (no toolbar analog).
464
+ // Kitty is definitionally active on this path (mapKeyboardEvent routes
465
+ // here only when the injected modes carry the disambiguate flag).
466
+ return { kind: "send", bytes: anyModifier(ev) ? kittyUSeq(27, ev) : plainEscapeSeq(true) };
321
467
  }
322
468
 
323
469
  // Single-character keys: text stays text (deferred to the textarea) UNLESS a
@@ -325,6 +471,15 @@ function encodeKittyDisambiguate(ev: KeyboardEvent): KeyboardResult {
325
471
  // ambiguous (Ctrl+I == Tab, Alt+x == ESC x, …) so we send the CSI-u form with
326
472
  // the unshifted codepoint.
327
473
  if (key.length === 1) {
474
+ // Meta/Cmd-ONLY combos stay with the browser even under kitty
475
+ // disambiguate, mirroring the legacy path's deliberate carve-out: Cmd+C /
476
+ // Cmd+V / Cmd+R are browser/OS chrome, and the protocol's reference
477
+ // implementation (the kitty terminal itself) likewise consumes
478
+ // emulator-owned shortcuts rather than reporting them to the app. Without
479
+ // this, enabling the protocol silently broke copy-on-macOS.
480
+ if (ev.metaKey && !ev.ctrlKey && !ev.altKey) {
481
+ return { kind: "ignore" };
482
+ }
328
483
  if (ev.ctrlKey || ev.altKey || ev.metaKey) {
329
484
  const cp = kittyKeyCodepoint(ev);
330
485
  if (cp > 0) {
@@ -336,8 +491,12 @@ function encodeKittyDisambiguate(ev: KeyboardEvent): KeyboardResult {
336
491
  return { kind: "ignore" };
337
492
  }
338
493
 
339
- /** True when the kitty disambiguate flag is active in the global mode state. */
340
- function kittyDisambiguateActive(): boolean {
494
+ /** True when the kitty disambiguate flag is active in the GLOBAL
495
+ * active-session mode facade. Exported for the toolbar widget (toolbar.ts);
496
+ * connection.setSession synchronously restores the target session's cached
497
+ * snapshot before input resumes. Encoder paths in this module use
498
+ * kittyActiveIn (the injected-modes read) instead. */
499
+ export function kittyDisambiguateActive(): boolean {
341
500
  return (getKeyboardFlags() & KITTY_DISAMBIGUATE) !== 0;
342
501
  }
343
502
 
@@ -478,9 +637,11 @@ export function mapKeyboardEvent(ev: KeyboardEvent, modes: KeyboardModes): Keybo
478
637
  return { kind: "send", bytes: DEL };
479
638
  }
480
639
 
481
- // Escape — ESC. Alt+Escape = ESC ESC (xterm.js convention).
640
+ // Escape — ESC (via the shared logical-Escape home; kitty is definitionally
641
+ // inactive on this legacy path, so it yields the bare ESC byte).
642
+ // Alt+Escape = ESC ESC (xterm.js convention).
482
643
  if (ev.key === "Escape") {
483
- return { kind: "send", bytes: ev.altKey ? `${ESC}${ESC}` : ESC };
644
+ return { kind: "send", bytes: ev.altKey ? `${ESC}${ESC}` : plainEscapeSeq(false) };
484
645
  }
485
646
 
486
647
  // Space — \x00 with Ctrl (per xterm). Alt+Space = ESC ' '.
@@ -636,259 +797,15 @@ export function bracketTextForPaste(text: string): string {
636
797
  return `\x1b[200~${sanitised}\x1b[201~`;
637
798
  }
638
799
 
639
- /** Normalise CR/LF to a single CR before bracketing. xterm.js convention. */
640
- export function prepareTextForTerminal(text: string): string {
641
- return text.replace(/\r?\n/g, "\r");
642
- }
643
-
644
- // -- Mobile toolbar ---------------------------------------------------------
645
-
646
800
  /**
647
- * Default DOM ids for the on-screen mobile keyboard toolbar buttons,
648
- * inside the toolbar container element passed to `bindMobileToolbar`.
649
- * Override individual ids via `BindMobileToolbarOptions.ids`.
801
+ * Normalise CR/LF to a single CR and strip NUL bytes before bracketing.
802
+ * xterm.js convention on both counts: NUL in pasted text is never meaningful
803
+ * terminal input, and under the v3 wire framing a NUL-leading paste would
804
+ * otherwise be split or (on old servers) misread as a control frame.
650
805
  */
651
- export const DEFAULT_TOOLBAR_IDS = {
652
- toggle: "kb-toggle",
653
- ctrl: "kb-ctrl",
654
- up: "kb-up",
655
- down: "kb-down",
656
- left: "kb-left",
657
- right: "kb-right",
658
- tab: "kb-tab",
659
- enter: "kb-enter",
660
- esc: "kb-esc",
661
- } as const;
662
-
663
- /** Per-button id overrides for `bindMobileToolbar`. */
664
- export interface MobileToolbarIds {
665
- readonly toggle?: string;
666
- readonly ctrl?: string;
667
- readonly up?: string;
668
- readonly down?: string;
669
- readonly left?: string;
670
- readonly right?: string;
671
- readonly tab?: string;
672
- readonly enter?: string;
673
- readonly esc?: string;
674
- }
675
-
676
- /** Options for `bindMobileToolbar`. */
677
- export interface BindMobileToolbarOptions {
678
- /**
679
- * Toolbar container element. Buttons are looked up by id within this
680
- * element via `querySelector('#<id>')`. The same element is the one
681
- * `kb-toggle` adds/removes the `.collapsed` class on.
682
- */
683
- readonly toolbar: HTMLElement;
684
- /**
685
- * Send sink for arrow keys, Tab, Enter, Esc — i.e. for everything
686
- * the toolbar emits. Sticky-Ctrl input goes through `applyStickyCtrl`
687
- * on the controller; the consumer is responsible for routing
688
- * keyboard / paste / IME text through it before handing the result
689
- * to the same `send`.
690
- */
691
- readonly send: (bytes: string) => void;
692
- /** Optional id overrides. Missing keys fall back to defaults. */
693
- readonly ids?: MobileToolbarIds;
694
- /**
695
- * Fired whenever the sticky-Ctrl state changes (toolbar press,
696
- * `setCtrlArmed`, or auto-disarm after applying a Ctrl byte).
697
- */
698
- readonly onCtrlChange?: (armed: boolean) => void;
699
- }
700
-
701
- /** Returned from `bindMobileToolbar`; manages sticky-Ctrl state and tear-down. */
702
- export interface MobileToolbarController {
703
- /**
704
- * Apply sticky-Ctrl to a piece of input text.
705
- * - When NOT armed: returns `text` unchanged.
706
- * - Armed AND `text.length === 1`: returns the matching Ctrl byte
707
- * (`ctrlByteFor(text)`), or the original char when no mapping
708
- * exists. Always disarms.
709
- * - Armed AND longer (e.g. paste, IME commit): returns `text`
710
- * unchanged and disarms — applying Ctrl to a multi-char string
711
- * would garble it.
712
- */
713
- applyStickyCtrl(text: string): string;
714
- /** Programmatically arm/disarm Ctrl. Updates the toolbar button visuals (`.armed` class + `aria-pressed`) + fires `onCtrlChange`. */
715
- setCtrlArmed(on: boolean): void;
716
- /** Whether sticky-Ctrl is currently armed. */
717
- isCtrlArmed(): boolean;
718
- /** Detach all event listeners and reset the toolbar to a non-armed state. Idempotent. */
719
- dispose(): void;
806
+ export function prepareTextForTerminal(text: string): string {
807
+ // eslint-disable-next-line no-control-regex -- intentional: stripping NUL from pasted text
808
+ return text.replace(/\r?\n/g, "\r").replace(/\x00/g, "");
720
809
  }
721
810
 
722
- /**
723
- * Wire a mobile / touch toolbar of on-screen keyboard buttons (Ctrl,
724
- * arrows, Tab, Enter, Esc, plus a collapse toggle) to a vterm send
725
- * sink. This was duplicated across vibekit and web-terminal-kiro — same wire
726
- * sequences, same sticky-Ctrl semantics, same DECCKM nuance — so it
727
- * lives here.
728
- *
729
- * Each button's `pointerdown` is intercepted with `preventDefault()`
730
- * so the press never fires a focus change or scroll on the host page.
731
- *
732
- * Arrow keys consult `isApplicationCursor()` from `modes.ts` so apps
733
- * that have set DECCKM (vim, less, fzf, htop, …) get SS3 sequences
734
- * (`ESC O A..D`) and apps in the default mode get the bare CSI form
735
- * (`ESC [ A..D`). This matches what `mapKeyboardEvent` does for
736
- * physical-keyboard arrows.
737
- */
738
- export function bindMobileToolbar(opts: BindMobileToolbarOptions): MobileToolbarController {
739
- const ids = { ...DEFAULT_TOOLBAR_IDS, ...opts.ids };
740
- let armed = false;
741
-
742
- function findBtn(id: string): HTMLElement | null {
743
- // IDs are kebab-case ASCII; no CSS escaping needed.
744
- return opts.toolbar.querySelector<HTMLElement>(`#${id}`);
745
- }
746
-
747
- const toggleBtn = findBtn(ids.toggle);
748
- const ctrlBtn = findBtn(ids.ctrl);
749
- const upBtn = findBtn(ids.up);
750
- const downBtn = findBtn(ids.down);
751
- const leftBtn = findBtn(ids.left);
752
- const rightBtn = findBtn(ids.right);
753
- const tabBtn = findBtn(ids.tab);
754
- const enterBtn = findBtn(ids.enter);
755
- const escBtn = findBtn(ids.esc);
756
-
757
- function paintCtrlBtn(on: boolean): void {
758
- if (!ctrlBtn) {
759
- return;
760
- }
761
- ctrlBtn.classList.toggle("armed", on);
762
- // Keep the ARIA toggle state in sync with the visual `.armed` class so
763
- // assistive tech announces the sticky-Ctrl button as pressed/unpressed.
764
- // The scaffold ships kb-ctrl as `aria-pressed="false"`; consumers used to
765
- // own this in their own setCtrlArmed before delegating the toolbar here.
766
- ctrlBtn.setAttribute("aria-pressed", on ? "true" : "false");
767
- }
768
-
769
- function setCtrlArmed(on: boolean): void {
770
- if (armed === on) {
771
- // Still update visuals defensively (e.g. after dispose was called
772
- // and someone re-armed via setCtrlArmed) — but skip the change
773
- // notification to keep onCtrlChange edge-triggered.
774
- paintCtrlBtn(on);
775
- return;
776
- }
777
- armed = on;
778
- paintCtrlBtn(on);
779
- opts.onCtrlChange?.(on);
780
- }
781
-
782
- function arrowSeq(letter: "A" | "B" | "C" | "D"): string {
783
- // Under kitty disambiguate, arrows use the CSI form regardless of DECCKM
784
- // (the protocol supersedes cursor-key mode). Otherwise DECCKM chooses SS3.
785
- if (kittyDisambiguateActive()) {
786
- return `${ESC}[${letter}`;
787
- }
788
- return isApplicationCursor() ? `${ESC}O${letter}` : `${ESC}[${letter}`;
789
- }
790
-
791
- // Listener registry so dispose() can detach them all.
792
- const cleanups: (() => void)[] = [];
793
- function on(el: HTMLElement | null, handler: (e: PointerEvent) => void): void {
794
- if (!el) {
795
- return;
796
- }
797
- const wrapped = (e: Event): void => {
798
- handler(e as PointerEvent);
799
- };
800
- el.addEventListener("pointerdown", wrapped);
801
- cleanups.push(() => {
802
- el.removeEventListener("pointerdown", wrapped);
803
- });
804
- }
805
-
806
- on(toggleBtn, (e) => {
807
- e.preventDefault();
808
- // Show/hide the toolbar. Deliberately does NOT clear armed Ctrl —
809
- // toggling visibility shouldn't lose state.
810
- opts.toolbar.classList.toggle("collapsed");
811
- });
812
-
813
- on(ctrlBtn, (e) => {
814
- e.preventDefault();
815
- setCtrlArmed(!armed);
816
- });
817
-
818
- on(upBtn, (e) => {
819
- e.preventDefault();
820
- setCtrlArmed(false);
821
- opts.send(arrowSeq("A"));
822
- });
823
- on(downBtn, (e) => {
824
- e.preventDefault();
825
- setCtrlArmed(false);
826
- opts.send(arrowSeq("B"));
827
- });
828
- on(rightBtn, (e) => {
829
- e.preventDefault();
830
- setCtrlArmed(false);
831
- opts.send(arrowSeq("C"));
832
- });
833
- on(leftBtn, (e) => {
834
- e.preventDefault();
835
- setCtrlArmed(false);
836
- opts.send(arrowSeq("D"));
837
- });
838
-
839
- on(tabBtn, (e) => {
840
- e.preventDefault();
841
- setCtrlArmed(false);
842
- opts.send("\t");
843
- });
844
- on(enterBtn, (e) => {
845
- e.preventDefault();
846
- setCtrlArmed(false);
847
- opts.send("\r");
848
- });
849
- on(escBtn, (e) => {
850
- e.preventDefault();
851
- setCtrlArmed(false);
852
- // Under kitty disambiguate, Escape is CSI 27 u (so the app never has to
853
- // disambiguate a lone ESC byte from the start of an escape sequence).
854
- opts.send(kittyDisambiguateActive() ? `${ESC}[27u` : ESC);
855
- });
856
-
857
- function applyStickyCtrl(text: string): string {
858
- if (!armed) {
859
- return text;
860
- }
861
- if (text.length === 1) {
862
- setCtrlArmed(false);
863
- // Under kitty disambiguate, Ctrl+char is the CSI-u form (ctrl modifier =
864
- // 5) with the unshifted codepoint, matching the physical-keyboard path.
865
- if (kittyDisambiguateActive()) {
866
- const cp = text.toLowerCase().codePointAt(0);
867
- if (cp !== undefined) {
868
- return `${ESC}[${cp};5u`;
869
- }
870
- }
871
- const ctrl = ctrlByteFor(text);
872
- return ctrl ?? text;
873
- }
874
- // Multi-char input (paste, IME commit) — applying Ctrl to a string
875
- // would garble it. Disarm and pass through verbatim.
876
- setCtrlArmed(false);
877
- return text;
878
- }
879
-
880
- function dispose(): void {
881
- setCtrlArmed(false);
882
- for (const c of cleanups) {
883
- c();
884
- }
885
- cleanups.length = 0;
886
- }
887
-
888
- return {
889
- applyStickyCtrl,
890
- setCtrlArmed,
891
- isCtrlArmed: () => armed,
892
- dispose,
893
- };
894
- }
811
+ // -- Mobile toolbar ---------------------------------------------------------
package/src/modes.ts CHANGED
@@ -1,5 +1,11 @@
1
- // Current DEC private mode state, as last announced by the server via
2
- // a wireMsgModes frame. Used by:
1
+ // DEC private mode state — THE ACTIVE SESSION'S VIEW (P3). This singleton
2
+ // mirrors the modes of whichever session the live socket serves: the
3
+ // connection module is the single writer (it applies every inbound modes
4
+ // frame via applySnapshot AND, in a tabbed shell, synchronously restores the
5
+ // target session's cached snapshot inside setSession — power-on defaults for
6
+ // a session never seen). Readers therefore never observe another session's
7
+ // modes after setSession returns; the only staleness left is the inherent
8
+ // one-frame lag against the session's own server state. Used by:
3
9
  // - keyboard.ts arrow-key encoder: emits SS3 (ESC O letter) when
4
10
  // applicationCursor is true, CSI (ESC [ letter) otherwise.
5
11
  // - paste handling: wraps in \e[200~..\e[201~ only when
@@ -15,6 +21,41 @@
15
21
  // character-by-character). applicationCursor starts false (normal
16
22
  // mode) because that's the VT100 power-on default.
17
23
 
24
+ /**
25
+ * One session's complete DEC-mode mirror, as a typed value (P3). The
26
+ * connection module caches one per session and applies it via applySnapshot;
27
+ * the named-field shape (vs setModes' nine positional parameters) makes
28
+ * field-order drift impossible for production writers.
29
+ */
30
+ export interface ModeSnapshot {
31
+ bracketedPaste: boolean;
32
+ applicationCursor: boolean;
33
+ mouseSGR: boolean;
34
+ focusReporting: boolean;
35
+ mouseMode: number;
36
+ applicationKeypad: boolean;
37
+ reverseVideo: boolean;
38
+ mousePixels: boolean;
39
+ keyboardFlags: number;
40
+ }
41
+
42
+ /**
43
+ * The VT power-on mode state — what a session that has never announced modes
44
+ * is in. Matches this module's initial values (see the bracketedPaste
45
+ * default rationale above).
46
+ */
47
+ export const POWER_ON_MODES: Readonly<ModeSnapshot> = {
48
+ bracketedPaste: true,
49
+ applicationCursor: false,
50
+ mouseSGR: false,
51
+ focusReporting: false,
52
+ mouseMode: 0,
53
+ applicationKeypad: false,
54
+ reverseVideo: false,
55
+ mousePixels: false,
56
+ keyboardFlags: 0,
57
+ };
58
+
18
59
  let bracketedPaste = true;
19
60
  let applicationCursor = false;
20
61
  let applicationKeypad = false;
@@ -68,6 +109,39 @@ export function setModes(
68
109
  }
69
110
  }
70
111
 
112
+ /**
113
+ * Apply a complete mode snapshot — every field, no optional-leaves-unchanged
114
+ * semantics (that is setModes' positional back-compat contract). The
115
+ * connection module uses this for both inbound modes frames and the
116
+ * synchronous per-session restore in setSession.
117
+ */
118
+ export function applySnapshot(s: Readonly<ModeSnapshot>): void {
119
+ bracketedPaste = s.bracketedPaste;
120
+ applicationCursor = s.applicationCursor;
121
+ mouseSGR = s.mouseSGR;
122
+ focusReporting = s.focusReporting;
123
+ mouseMode = s.mouseMode;
124
+ applicationKeypad = s.applicationKeypad;
125
+ reverseVideo = s.reverseVideo;
126
+ mousePixels = s.mousePixels;
127
+ keyboardFlags = s.keyboardFlags;
128
+ }
129
+
130
+ /** The current mode state as a snapshot value (a copy; safe to retain). */
131
+ export function snapshot(): ModeSnapshot {
132
+ return {
133
+ bracketedPaste,
134
+ applicationCursor,
135
+ mouseSGR,
136
+ focusReporting,
137
+ mouseMode,
138
+ applicationKeypad,
139
+ reverseVideo,
140
+ mousePixels,
141
+ keyboardFlags,
142
+ };
143
+ }
144
+
71
145
  /** True when the server has DEC 2004 (bracketed paste) enabled. */
72
146
  export function isBracketedPaste(): boolean {
73
147
  return bracketedPaste;