@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "2.3.3",
3
+ "version": "2.4.0",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "2.3.3",
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