@cplieger/web-terminal-engine 2.8.0 → 3.0.1
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/README.md +6 -4
- package/jsr.json +1 -1
- package/package.json +2 -1
- package/src/connection.ts +347 -72
- package/src/index.ts +8 -0
- package/src/keyboard.ts +187 -270
- package/src/modes.ts +76 -2
- package/src/mouse.ts +51 -6
- package/src/render.ts +275 -95
- package/src/scroll.ts +25 -0
- package/src/store.ts +11 -3
- package/src/toolbar.ts +270 -0
- package/src/types.ts +49 -2
- package/src/wire-binary.ts +20 -12
- package/src/wire-compatibility.ts +36 -0
- package/src/wire.ts +14 -6
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
340
|
-
|
|
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
|
|
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}` :
|
|
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
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
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
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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;
|