@cplieger/web-terminal-engine 2.3.3 → 2.4.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/jsr.json +1 -1
- package/package.json +4 -1
- package/src/connection.ts +30 -1
- package/src/mouse.ts +21 -0
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-engine",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"typecheck": "tsc -p tsconfig.json",
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"@eslint/js": "10.0.1",
|
|
13
13
|
"@playwright/test": "1.61.1",
|
|
14
|
+
"@stryker-mutator/core": "9.6.1",
|
|
15
|
+
"@stryker-mutator/typescript-checker": "9.6.1",
|
|
16
|
+
"@stryker-mutator/vitest-runner": "9.6.1",
|
|
14
17
|
"@types/pngjs": "6.0.5",
|
|
15
18
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
16
19
|
"@vitest/coverage-v8": "4.1.10",
|
package/src/connection.ts
CHANGED
|
@@ -34,6 +34,13 @@ type ConnState =
|
|
|
34
34
|
| { status: "connected"; sock: WebSocket; abort: AbortController }
|
|
35
35
|
| { status: "reconnecting"; timer: ReturnType<typeof setTimeout>; delayMs: number };
|
|
36
36
|
|
|
37
|
+
// The server's application close code for "the session's child process has
|
|
38
|
+
// exited" (terminal/terminal.go statusProcessExited). It marks a close as
|
|
39
|
+
// definitive — the session cannot produce output again — as opposed to a
|
|
40
|
+
// transient network drop that the backoff reconnect should heal. Private
|
|
41
|
+
// application range (4000-4999) per RFC 6455.
|
|
42
|
+
const PROCESS_EXITED_CLOSE_CODE = 4001;
|
|
43
|
+
|
|
37
44
|
let connState: ConnState = { status: "disconnected" };
|
|
38
45
|
let reconnectDelay = INITIAL_DELAY_MS;
|
|
39
46
|
let lastSentCols = 0;
|
|
@@ -179,6 +186,18 @@ export interface Callbacks {
|
|
|
179
186
|
onClose(): void;
|
|
180
187
|
onConnecting?(): void;
|
|
181
188
|
onOutboxFull?(): void;
|
|
189
|
+
/** Fired instead of onClose when the server closes with the
|
|
190
|
+
* process-exited application close code (4001): the session's child
|
|
191
|
+
* process has ended, so the close is DEFINITIVE, not a transient drop.
|
|
192
|
+
* When this callback is wired, the module does NOT auto-reconnect that
|
|
193
|
+
* socket — reconnecting a dead session just replays its final screen and
|
|
194
|
+
* earns another 4001 (the endless "Reconnecting…" flash) — and does not
|
|
195
|
+
* call onClose for it. The consumer decides what "ended" looks like
|
|
196
|
+
* (banner, tab state) and may still reconnect explicitly (setSession /
|
|
197
|
+
* reconnectNow) to re-view the final screen. When absent, every close
|
|
198
|
+
* keeps the legacy transient treatment (onClose + backoff reconnect), so
|
|
199
|
+
* existing consumers are unaffected until they opt in. */
|
|
200
|
+
onProcessExit?(): void;
|
|
182
201
|
/** Fired when the server's boot-epoch in resumeAck differs from the
|
|
183
202
|
* one observed on a previous connection — i.e. the server has
|
|
184
203
|
* restarted. By the time this fires, the connection module has
|
|
@@ -696,7 +715,7 @@ export function connect(): void {
|
|
|
696
715
|
|
|
697
716
|
sock.addEventListener(
|
|
698
717
|
"close",
|
|
699
|
-
() => {
|
|
718
|
+
(ev: CloseEvent) => {
|
|
700
719
|
// Only the active sock's close should drive reconnect logic; an
|
|
701
720
|
// already-superseded sock has been aborted and this listener
|
|
702
721
|
// wouldn't fire (signal removes it). The check stays as a belt-
|
|
@@ -709,6 +728,16 @@ export function connect(): void {
|
|
|
709
728
|
}
|
|
710
729
|
stopHeartbeat();
|
|
711
730
|
connState = { status: "disconnected" };
|
|
731
|
+
// A process-exited close (4001) is definitive: the child is gone, so a
|
|
732
|
+
// backoff reconnect can only replay the final screen and collect another
|
|
733
|
+
// 4001 — an endless, pointless churn that reads as a flapping
|
|
734
|
+
// "Reconnecting…" banner. Route it to onProcessExit (no reconnect, no
|
|
735
|
+
// onClose) when the consumer wired it; without the callback, keep the
|
|
736
|
+
// legacy transient treatment so existing consumers see no change.
|
|
737
|
+
if (ev.code === PROCESS_EXITED_CLOSE_CODE && cb?.onProcessExit) {
|
|
738
|
+
cb.onProcessExit();
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
712
741
|
cb?.onClose();
|
|
713
742
|
scheduleReconnect();
|
|
714
743
|
},
|
package/src/mouse.ts
CHANGED
|
@@ -85,6 +85,13 @@ export interface MouseInputHandler {
|
|
|
85
85
|
|
|
86
86
|
let handler: MouseInputHandler | null = null;
|
|
87
87
|
let lastButton = -1;
|
|
88
|
+
// True while a Shift-initiated press is in flight. Shift+click/drag is the
|
|
89
|
+
// xterm convention for "bypass application mouse tracking": the gesture is
|
|
90
|
+
// reserved for the browser's native text selection instead of being reported
|
|
91
|
+
// to (and preventDefault-ed away from) the TUI. Tracked from press to release
|
|
92
|
+
// so the move/up of a bypassed drag stay bypassed even if Shift is lifted
|
|
93
|
+
// mid-drag.
|
|
94
|
+
let shiftBypass = false;
|
|
88
95
|
|
|
89
96
|
/**
|
|
90
97
|
* Initialize the mouse module by attaching pointer/wheel/focus listeners to
|
|
@@ -138,6 +145,12 @@ function onMouseDown(e: MouseEvent): void {
|
|
|
138
145
|
if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
|
|
139
146
|
return;
|
|
140
147
|
}
|
|
148
|
+
if (e.shiftKey) {
|
|
149
|
+
// Shift+press: leave the whole gesture to the browser (native selection).
|
|
150
|
+
shiftBypass = true;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
shiftBypass = false;
|
|
141
154
|
const pos = pixelToCell(e);
|
|
142
155
|
if (!pos) {
|
|
143
156
|
return;
|
|
@@ -153,6 +166,11 @@ function onMouseUp(e: MouseEvent): void {
|
|
|
153
166
|
if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
|
|
154
167
|
return;
|
|
155
168
|
}
|
|
169
|
+
if (shiftBypass) {
|
|
170
|
+
// Release of a Shift-initiated (native-selection) gesture: report nothing.
|
|
171
|
+
shiftBypass = false;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
156
174
|
const pos = pixelToCell(e);
|
|
157
175
|
if (!pos) {
|
|
158
176
|
return;
|
|
@@ -169,6 +187,9 @@ function onMouseMove(e: MouseEvent): void {
|
|
|
169
187
|
if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
|
|
170
188
|
return;
|
|
171
189
|
}
|
|
190
|
+
if (shiftBypass && e.buttons) {
|
|
191
|
+
return; // a Shift-initiated drag is a native selection; don't report it
|
|
192
|
+
}
|
|
172
193
|
// mode 1000: no motion events
|
|
173
194
|
// mode 1002: motion only while button held (drag)
|
|
174
195
|
// mode 1003: all motion events
|