@cplieger/web-terminal-engine 1.2.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/modes.ts ADDED
@@ -0,0 +1,95 @@
1
+ // Current DEC private mode state, as last announced by the server via
2
+ // a wireMsgModes frame. Used by:
3
+ // - keyboard.ts arrow-key encoder: emits SS3 (ESC O letter) when
4
+ // applicationCursor is true, CSI (ESC [ letter) otherwise.
5
+ // - paste handling: wraps in \e[200~..\e[201~ only when
6
+ // bracketedPaste is on; otherwise sends raw text.
7
+ // - mouse.ts: encodes mouse events only when mouseMode > 0.
8
+ // - focus reporting: sends ESC[I / ESC[O when focusReporting is on.
9
+ //
10
+ // Defaults: bracketedPaste starts true because most modern shells
11
+ // (bash 4.4+, zsh, fish) enable it immediately on startup via
12
+ // CSI ?2004h. Starting false would cause the first paste before the
13
+ // server's modes frame arrives to be sent un-bracketed, which shells
14
+ // interpret as typed input (potentially executing pasted commands
15
+ // character-by-character). applicationCursor starts false (normal
16
+ // mode) because that's the VT100 power-on default.
17
+
18
+ let bracketedPaste = true;
19
+ let applicationCursor = false;
20
+ let applicationKeypad = false;
21
+ let mouseMode = 0; // 0=off, 1000=normal, 1002=button-event, 1003=any-event
22
+ let mouseSGR = false;
23
+ let focusReporting = false;
24
+ let reverseVideo = false;
25
+
26
+ /**
27
+ * Update the cached mode state. Called by the consumer whenever the server
28
+ * sends a `ModesMessage`. Pass-through arguments are optional so older
29
+ * server builds that don't include all fields don't reset them to defaults.
30
+ */
31
+ export function setModes(
32
+ bracketed: boolean,
33
+ appCursor: boolean,
34
+ mSGR?: boolean,
35
+ focus?: boolean,
36
+ mMode?: number,
37
+ appKeypad?: boolean,
38
+ revVideo?: boolean,
39
+ ): void {
40
+ bracketedPaste = bracketed;
41
+ applicationCursor = appCursor;
42
+ if (mSGR !== undefined) {
43
+ mouseSGR = mSGR;
44
+ }
45
+ if (focus !== undefined) {
46
+ focusReporting = focus;
47
+ }
48
+ if (mMode !== undefined) {
49
+ mouseMode = mMode;
50
+ }
51
+ if (appKeypad !== undefined) {
52
+ applicationKeypad = appKeypad;
53
+ }
54
+ if (revVideo !== undefined) {
55
+ reverseVideo = revVideo;
56
+ }
57
+ }
58
+
59
+ /** True when the server has DEC 2004 (bracketed paste) enabled. */
60
+ export function isBracketedPaste(): boolean {
61
+ return bracketedPaste;
62
+ }
63
+
64
+ /** True when the server has DECCKM (application cursor keys) enabled. */
65
+ export function isApplicationCursor(): boolean {
66
+ return applicationCursor;
67
+ }
68
+
69
+ /**
70
+ * Active mouse tracking mode (xterm DECSET): 0 = off, 1000 = normal,
71
+ * 1002 = button-event, 1003 = any-event.
72
+ */
73
+ export function getMouseMode(): number {
74
+ return mouseMode;
75
+ }
76
+
77
+ /** True when the server has DEC 1006 (SGR mouse encoding) enabled. */
78
+ export function isMouseSGR(): boolean {
79
+ return mouseSGR;
80
+ }
81
+
82
+ /** True when the server has DEC 1004 (focus event reporting) enabled. */
83
+ export function isFocusReporting(): boolean {
84
+ return focusReporting;
85
+ }
86
+
87
+ /** True when the server has DECKPAM (application keypad) enabled. */
88
+ export function isApplicationKeypad(): boolean {
89
+ return applicationKeypad;
90
+ }
91
+
92
+ /** True when the server has DEC 5 (reverse video / DECSCNM) enabled. */
93
+ export function isReverseVideo(): boolean {
94
+ return reverseVideo;
95
+ }
package/src/mouse.ts ADDED
@@ -0,0 +1,203 @@
1
+ // Mouse event encoding for terminal input (SGR 1006 protocol).
2
+ //
3
+ // Encodes mouse events as SGR sequences: ESC[<code;col;rowM (press/move)
4
+ // or ESC[<code;col;rowm (release). Coordinates are 1-based.
5
+ //
6
+ // Button encoding (matches xterm/xterm.js):
7
+ // 0=left, 1=middle, 2=right, 64=wheel-up, 65=wheel-down
8
+ // +4=shift, +8=alt, +16=ctrl, +32=motion
9
+ //
10
+ // Focus events: ESC[I (focus in), ESC[O (focus out).
11
+ //
12
+ // Out-of-scope (consumed but not implemented):
13
+ // - X10 mouse mode (mode 9): not supported.
14
+ // - urxvt encoding (mode 1015): not supported.
15
+ // - DEFAULT encoding (raw bytes): not supported; only SGR 1006.
16
+
17
+ import { getMouseMode, isMouseSGR, isFocusReporting } from "./modes.js";
18
+
19
+ const ESC = "\x1b";
20
+
21
+ /** Encode a mouse button code for SGR protocol. */
22
+ function buttonCode(
23
+ button: number,
24
+ motion: boolean,
25
+ shift: boolean,
26
+ alt: boolean,
27
+ ctrl: boolean,
28
+ ): number {
29
+ let code = button;
30
+ if (shift) {
31
+ code |= 4;
32
+ }
33
+ if (alt) {
34
+ code |= 8;
35
+ }
36
+ if (ctrl) {
37
+ code |= 16;
38
+ }
39
+ if (motion) {
40
+ code |= 32;
41
+ }
42
+ return code;
43
+ }
44
+
45
+ /**
46
+ * Encode a mouse event into an SGR 1006 escape sequence ready to send to the
47
+ * terminal as input. The returned string starts with ESC[< and ends with `M`
48
+ * for press/move or `m` for release.
49
+ *
50
+ * @param code Button code returned from `buttonCode` (button + modifier bits).
51
+ * @param col 1-based column.
52
+ * @param row 1-based row.
53
+ * @param release `true` for release events (terminator `m`), `false` otherwise.
54
+ */
55
+ export function encodeSGR(code: number, col: number, row: number, release: boolean): string {
56
+ const final = release ? "m" : "M";
57
+ return `${ESC}[<${code};${col};${row}${final}`;
58
+ }
59
+
60
+ /** Map DOM button index to xterm button number. */
61
+ function domButtonToXterm(button: number): number {
62
+ // DOM: 0=left, 1=middle, 2=right
63
+ // xterm: 0=left, 1=middle, 2=right
64
+ if (button <= 2) {
65
+ return button;
66
+ }
67
+ return 0; // fallback
68
+ }
69
+
70
+ /**
71
+ * Adapter the consumer wires into `init` so the mouse module can: send encoded
72
+ * input bytes to the server, query current cell pixel size for hit-testing,
73
+ * and access the terminal DOM element to attach listeners to.
74
+ */
75
+ export interface MouseInputHandler {
76
+ /** Sends raw input bytes (or escape sequences) to the server. */
77
+ send: (data: string) => void;
78
+ /** Returns the current cell pixel size for hit-testing pointer coordinates. */
79
+ cellSize: () => { width: number; height: number };
80
+ /** Returns the terminal DOM element to attach mouse listeners to. */
81
+ termElement: () => HTMLElement;
82
+ }
83
+
84
+ let handler: MouseInputHandler | null = null;
85
+ let lastButton = -1;
86
+
87
+ /**
88
+ * Initialize the mouse module by attaching pointer/wheel/focus listeners to
89
+ * the terminal element. Listeners gate on the active mouse mode + SGR 1006
90
+ * encoding; they are no-ops when the server hasn't enabled mouse tracking.
91
+ */
92
+ export function init(h: MouseInputHandler): void {
93
+ handler = h;
94
+ const el = h.termElement();
95
+ el.addEventListener("mousedown", onMouseDown);
96
+ el.addEventListener("mouseup", onMouseUp);
97
+ el.addEventListener("mousemove", onMouseMove);
98
+ el.addEventListener("wheel", onWheel, { passive: false });
99
+ el.addEventListener("focusin", onFocusIn);
100
+ el.addEventListener("focusout", onFocusOut);
101
+ }
102
+
103
+ function pixelToCell(e: MouseEvent): { col: number; row: number } | null {
104
+ if (!handler) {
105
+ return null;
106
+ }
107
+ const el = handler.termElement();
108
+ const rect = el.getBoundingClientRect();
109
+ const { width, height } = handler.cellSize();
110
+ if (width <= 0 || height <= 0) {
111
+ return null;
112
+ }
113
+ const col = Math.floor((e.clientX - rect.left) / width) + 1; // 1-based
114
+ const row = Math.floor((e.clientY - rect.top) / height) + 1; // 1-based
115
+ if (col < 1 || row < 1) {
116
+ return null;
117
+ }
118
+ return { col, row };
119
+ }
120
+
121
+ function onMouseDown(e: MouseEvent): void {
122
+ const mode = getMouseMode();
123
+ if (mode === 0 || !isMouseSGR() || !handler) {
124
+ return;
125
+ }
126
+ const pos = pixelToCell(e);
127
+ if (!pos) {
128
+ return;
129
+ }
130
+ lastButton = domButtonToXterm(e.button);
131
+ const code = buttonCode(lastButton, false, e.shiftKey, e.altKey, e.ctrlKey);
132
+ handler.send(encodeSGR(code, pos.col, pos.row, false));
133
+ e.preventDefault();
134
+ }
135
+
136
+ function onMouseUp(e: MouseEvent): void {
137
+ const mode = getMouseMode();
138
+ if (mode === 0 || !isMouseSGR() || !handler) {
139
+ return;
140
+ }
141
+ const pos = pixelToCell(e);
142
+ if (!pos) {
143
+ return;
144
+ }
145
+ const btn = domButtonToXterm(e.button);
146
+ const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
147
+ handler.send(encodeSGR(code, pos.col, pos.row, true));
148
+ lastButton = -1;
149
+ e.preventDefault();
150
+ }
151
+
152
+ function onMouseMove(e: MouseEvent): void {
153
+ const mode = getMouseMode();
154
+ if (mode === 0 || !isMouseSGR() || !handler) {
155
+ return;
156
+ }
157
+ // mode 1000: no motion events
158
+ // mode 1002: motion only while button held (drag)
159
+ // mode 1003: all motion events
160
+ if (mode === 1000) {
161
+ return;
162
+ }
163
+ if (mode === 1002 && !e.buttons) {
164
+ return;
165
+ }
166
+ const pos = pixelToCell(e);
167
+ if (!pos) {
168
+ return;
169
+ }
170
+ const btn = e.buttons ? (e.buttons & 1 ? 0 : e.buttons & 4 ? 1 : e.buttons & 2 ? 2 : 0) : 0;
171
+ const code = buttonCode(btn, true, e.shiftKey, e.altKey, e.ctrlKey);
172
+ handler.send(encodeSGR(code, pos.col, pos.row, false));
173
+ }
174
+
175
+ function onWheel(e: WheelEvent): void {
176
+ const mode = getMouseMode();
177
+ if (mode === 0 || !isMouseSGR() || !handler) {
178
+ return;
179
+ }
180
+ const pos = pixelToCell(e);
181
+ if (!pos) {
182
+ return;
183
+ }
184
+ // Wheel up = button 64, wheel down = button 65
185
+ const btn = e.deltaY < 0 ? 64 : 65;
186
+ const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
187
+ handler.send(encodeSGR(code, pos.col, pos.row, false));
188
+ e.preventDefault();
189
+ }
190
+
191
+ function onFocusIn(): void {
192
+ if (!isFocusReporting() || !handler) {
193
+ return;
194
+ }
195
+ handler.send(`${ESC}[I`);
196
+ }
197
+
198
+ function onFocusOut(): void {
199
+ if (!isFocusReporting() || !handler) {
200
+ return;
201
+ }
202
+ handler.send(`${ESC}[O`);
203
+ }
@@ -0,0 +1,64 @@
1
+ // Pure reconnect-delay scheduler for the terminal WebSocket.
2
+ //
3
+ // Reconnect strategy: exponential backoff capped at MAX_DELAY_MS, with
4
+ // uniform random jitter in [0, JITTER_MS) added to each scheduled
5
+ // attempt. The per-attempt jitter prevents thundering-herd reconnects
6
+ // when several browser tabs lose connectivity together; the cap keeps
7
+ // the worst-case wait bounded so a long network outage doesn't push
8
+ // the next retry an hour out.
9
+ //
10
+ // Pulled into a dedicated module so the formula is testable without
11
+ // real timers: callers inject `random()` (typically `Math.random`)
12
+ // and receive both the delay to wait this attempt and the new base
13
+ // delay for the next attempt. This separates the math from the
14
+ // timer-side-effect plumbing in the connection module.
15
+
16
+ /** Initial backoff delay in milliseconds. Used by the first retry after a clean connection. */
17
+ export const INITIAL_DELAY_MS = 500;
18
+
19
+ /** Maximum (capped) base delay in milliseconds. */
20
+ export const MAX_DELAY_MS = 8000;
21
+
22
+ /** Maximum jitter added per attempt (uniform random, exclusive upper bound). */
23
+ export const JITTER_MS = 250;
24
+
25
+ /** Result of computing the next backoff step. */
26
+ interface BackoffStep {
27
+ /** Milliseconds to wait before the next reconnect attempt. */
28
+ scheduledMs: number;
29
+ /** New base delay to feed into the subsequent call. */
30
+ nextBaseMs: number;
31
+ }
32
+
33
+ /**
34
+ * nextBackoffDelay computes the wait time for this attempt and the
35
+ * new base delay for the next attempt. Pure given a deterministic
36
+ * `random` function; pass `Math.random` for production.
37
+ *
38
+ * Formula:
39
+ * scheduledMs = currentBaseMs + random() * JITTER_MS
40
+ * nextBaseMs = min(currentBaseMs * 2, MAX_DELAY_MS)
41
+ *
42
+ * `random` is expected to return a number in [0, 1). When undefined
43
+ * or out of range, the implementation clamps to that interval so
44
+ * malformed inputs don't yield NaN/negative delays.
45
+ */
46
+ export function nextBackoffDelay(
47
+ currentBaseMs: number,
48
+ random: () => number = Math.random,
49
+ ): BackoffStep {
50
+ const r = clamp01(random());
51
+ const scheduledMs = currentBaseMs + r * JITTER_MS;
52
+ const nextBaseMs = Math.min(currentBaseMs * 2, MAX_DELAY_MS);
53
+ return { scheduledMs, nextBaseMs };
54
+ }
55
+
56
+ function clamp01(x: number): number {
57
+ if (!Number.isFinite(x) || x < 0) {
58
+ return 0;
59
+ }
60
+ if (x >= 1) {
61
+ return 0.999_999_999;
62
+ }
63
+ return x;
64
+ }