@cplieger/web-terminal-engine 5.0.8 → 5.1.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/README.md CHANGED
@@ -32,9 +32,17 @@ const out = document.getElementById("term-output") as HTMLElement;
32
32
  render.init({ output: out, termWrap: wrap }); // optional: maxLines (retained-line cap, default 5000)
33
33
  scroll.init({ scrollEl: wrap });
34
34
  mouse.init({
35
- send: (data) => ws.send(data),
36
- cellSize: () => ({ width: cellW, height: cellH }),
35
+ // Best-effort input: return whether the report reached a live socket, so a
36
+ // refused one is forgotten instead of retried. With the connection module this
37
+ // is `connection.sendEphemeral`.
38
+ sendReport: (data) => {
39
+ ws.send(data);
40
+ return true;
41
+ },
42
+ cellSize: render.cellSize,
37
43
  termElement: () => wrap,
44
+ gridElement: () => out, // the rows' own box: no padding, no scrollbar gutter
45
+ gridSize: render.gridSize, // the RENDERED grid, not the size this client would like
38
46
  });
39
47
 
40
48
  ws.binaryType = "arraybuffer";
@@ -77,15 +85,15 @@ document.addEventListener("keydown", (ev) => {
77
85
 
78
86
  ## API
79
87
 
80
- - **`render`** — DOM renderer driven by `ScreenMessage` / `ScrollMessage` frames. `init` (accepts `maxLines`, the retained-line cap — memory-constrained consumers pass a smaller budget; history above the cap is evicted from the top in batches, and the live screen is never evicted, so a cap at or below the terminal height keeps the full screen with no scrollback), `handleScreen`, `handleScroll`, `updateFontMetrics`, `computeSize`, `getCursorPx`, `setPredictedCursor`, `resetScreen`, `resetScrollback`, `getHighestIndex`, `noteResumeBounds`, `updateReverseVideo`.
88
+ - **`render`** — DOM renderer driven by `ScreenMessage` / `ScrollMessage` frames. `init` (accepts `maxLines`, the retained-line cap — memory-constrained consumers pass a smaller budget; history above the cap is evicted from the top in batches, and the live screen is never evicted, so a cap at or below the terminal height keeps the full screen with no scrollback), `handleScreen`, `handleScroll`, `updateFontMetrics`, `computeSize`, `cellSize`, `gridSize`, `getCursorPx`, `setPredictedCursor`, `resetScreen`, `resetScrollback`, `getHighestIndex`, `noteResumeBounds`, `updateReverseVideo`.
81
89
  - **`keyboard`** — Translates `KeyboardEvent` to terminal byte sequences. `mapKeyboardEvent`, `bracketTextForPaste`, `prepareTextForTerminal`, `ctrlByteFor`, plus the shared logical-key encodings (`plainCursorKeySeq`, `plainEscapeSeq`) the toolbar module reuses. Honors `applicationCursor`, `applicationKeypad`, `bracketedPaste`.
82
90
  - **`toolbar`** — On-screen mobile toolbar widget (moved out of `keyboard` in v3). `bindMobileToolbar({toolbar, send, ids?})` wires `pointerdown` handlers for an on-screen Ctrl/arrows/Tab/Enter/Esc toolbar (with sticky-Ctrl semantics and kitty/DECCKM-aware arrows byte-identical to the physical-key path), returning a `MobileToolbarController` exposing `applyStickyCtrl`, `setCtrlArmed`, `isCtrlArmed`, and `dispose`.
83
- - **`mouse`** — SGR 1006 mouse + focus reporting encoder. `init`, `encodeSGR`, `MouseInputHandler`. Auto-gates on `mouseMode > 0`.
91
+ - **`mouse`** — SGR 1006 mouse encoder. `init`, `encodeSGR`, `resyncGesture`, `disarmGesture`, `MouseInputHandler`. Auto-gates on `mouseMode > 0`. `MouseInputHandler` carries two optional members, both defaulting to today's behaviour: `gridElement` names the element whose box IS the grid (the scroll container's own box includes its padding and scrollbar gutter, so it is not that box), and `gridSize` supplies the dimensions of the grid ON SCREEN — `render.gridSize`, not the size this client measured — so every report is clamped into it and rows are measured up from the bottom edge (the screen window is the tail of the content). Its required `sendReport(data)` returns whether the report reached a live socket; wire it to `connection.sendEphemeral`. Four consequences of mouse input being best-effort: a release is emitted only for a press that was DELIVERED (a release whose press never arrived is its own desync, because the application never learned the button went down); a drag reports a held button only while the application believes that button is down, so motion for an undelivered gesture is dropped under mode 1002 and reports as no-button under 1003; `resyncGesture()`, called once the resumeAck has declared the server's capabilities (not from `onOpen`, where the ephemeral channel is not yet known, so the release would take the reliable fallback), emits one release for any button the last reading says is now up, so a drag is not left pressed across a reconnect; and motion is coalesced to one report per animation frame, latest wins, while press, release and wheel are never coalesced (a wheel report is a notch count against a screen, so coalescing loses scroll distance). `resyncGesture` shares one gate with the handlers, and each of them enforces it: a gesture stranded by the application turning tracking off is FORGOTTEN by whichever one next observes tracking off, because a release nothing asked for reaches the PTY as keystrokes. tmux answers the same way at attach, clearing `mouse_drag_flag` as it disables the modes. `disarmGesture()` forgets a held gesture without reporting anything — call it when pointing one terminal's socket at another session, since the record belongs to the session that saw the press. Focus reporting is NOT here: it is transport state, and it lives in `connection.setClientFocus`.
84
92
  - **`scroll`** — Auto-follow tracker for the scroll container. `init`, `stickToBottom`, `scrollToBottom`, `isUserScrolledUp`, `currentScrollTop`, `restoreView`, `adjustForContentShift`, plus two renderer seams: `noteContentShrink(scrollTopBefore)` says a row removal rather than a gesture caused the scroll event about to arrive, and `reconcileScrollRange()` moves the offset back inside the container's range when the container did not reconcile the shrink itself. The second exists because clamping an offset the content shrank out from under is an implementation behaviour and not a specified one: Blink and Gecko reconcile during layout, WebKit does not, and without the correction an iOS viewport is left parked past the end of the content after an application clears the screen, showing background with the content above it until the reader scrolls.
85
93
  - **`modes`** — DEC private mode state (synced from server's `ModesMessage`). `setModes`, `isBracketedPaste`, `isApplicationCursor`, `getMouseMode`, `isMouseSGR`, `isFocusReporting`, `isApplicationKeypad`, `isReverseVideo`.
86
94
  - **`decodeWireBinary(buf)`**: Top-level decoder for the binary WebSocket frames. Returns a `ServerMessage` or `null` for invalid/truncated frames.
87
95
  - **Wire compatibility metadata**: `WIRE_PROTOCOL_VERSION`, `MIN_SUPPORTED_SERVER_WIRE_VERSION`, `WIRE_INCOMPATIBLE_CLOSE_CODE`, and `WIRE_COMPATIBILITY` publish this client release's directional contract. The same values ship as a language-neutral JSON artifact for non-TypeScript consumers — see [Wire compatibility manifest](#wire-compatibility-manifest).
88
- - **`connection`**: Client → server WebSocket lifecycle: owns the socket, exponential-backoff reconnect, and the resume/inputAck reliability layer (outbox + server-restart detection). `init(callbacks)`, `connect`, `sendBinary(bytes)`, `sendResize`, `reconnectNow`. The callbacks expose `onMessage(ServerMessage)`, `onOpen`/`onClose`/`onConnecting`/`onOutboxFull`/`onServerRestart`, `onWireVersionMismatch`, and the definitive `onWireIncompatible`; a `computeSize()` provider; and an optional `wsPath` (defaults to `"/ws"`). An explicit below-floor server revision or close code 4002 stops automatic reconnects until `disconnect()` or a page reload. Version-silent and future-revision servers remain tolerated. The module decodes frames internally and applies `modes.setModes`, so a consumer only needs to dispatch screen/scroll to `render`. Prefer this over wiring `WebSocket` + `decodeWireBinary` by hand unless you need full control.
96
+ - **`connection`**: Client → server WebSocket lifecycle: owns the socket, exponential-backoff reconnect, and the resume/inputAck reliability layer (outbox + server-restart detection). `init(callbacks)`, `connect`, `sendBinary(bytes)`, `sendEphemeral(text)`, `setClientFocus(focused)`, `sendResize`, `reconnectNow`. `sendEphemeral` carries BEST-EFFORT input (mouse reports): it sends only when a socket is live right now, returns whether it did, and never touches the outbox or the byte counters, so a report is forgotten rather than replayed against a screen the resume has since repainted. Against a server that declares neither capability it falls back to `sendBinary`, because an unrecognized control is dropped post-latch and total silence is a worse failure than a stale report — on that path the report rides the reliable outbox and can be retransmitted after a reconnect. `setClientFocus` reports the terminal widget's focus: it sends the `focus` control when the server declared it derives the DEC 1004 answer (from attachment state, every attached client's report, and its own `terminal.WithKeepUnfocused`), and otherwise writes `CSI I` / `CSI O` itself while the application has 1004 enabled — including on the enable edge, so an application that turns focus reporting on while the tab is already focused is told so immediately. The callbacks expose `onMessage(ServerMessage)`, `onOpen`/`onClose`/`onConnecting`/`onOutboxFull`/`onServerRestart`, `onWireVersionMismatch`, and the definitive `onWireIncompatible`; a `computeSize()` provider; and an optional `wsPath` (defaults to `"/ws"`). An explicit below-floor server revision or close code 4002 stops automatic reconnects until `disconnect()` or a page reload. Version-silent and future-revision servers remain tolerated. The module decodes frames internally and applies `modes.setModes`, so a consumer only needs to dispatch screen/scroll to `render`. Prefer this over wiring `WebSocket` + `decodeWireBinary` by hand unless you need full control.
89
97
  - **`controlFrame(msg)` / `wsURL(proto, host, path?)`** — Low-level helpers for the client → server protocol (0x00-prefixed JSON control frames, WebSocket URL building). Used internally by `connection`; exported for advanced consumers.
90
98
 
91
99
  Wire types (`WireRun`, `ScreenMessage`, `ScrollMessage`, `ModesMessage`, `TitleMessage`, `ResumeAckMessage`, `ServerMessage`, `ControlMessage`) are re-exported from the package root and match the Go server's wire format byte-for-byte.
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "5.0.8",
3
+ "version": "5.1.0",
4
4
  "license": "MPL-2.0",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "5.0.8",
3
+ "version": "5.1.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsc -p tsconfig.json",
@@ -11,22 +11,24 @@
11
11
  "devDependencies": {
12
12
  "@cplieger/web-terminal-engine-v3": "npm:@cplieger/web-terminal-engine@2.8.0",
13
13
  "@eslint/js": "10.0.1",
14
- "@playwright/test": "1.62.1",
14
+ "@playwright/test": "1.63.0",
15
15
  "@stryker-mutator/core": "10.0.0",
16
16
  "@stryker-mutator/typescript-checker": "10.0.0",
17
17
  "@stryker-mutator/vitest-runner": "10.0.0",
18
18
  "@types/pngjs": "6.0.5",
19
19
  "@typescript/native": "npm:typescript@7.0.2",
20
- "@vitest/coverage-v8": "4.1.11",
20
+ "@vitest/browser": "5.0.0",
21
+ "@vitest/browser-playwright": "5.0.0",
22
+ "@vitest/coverage-v8": "5.0.0",
21
23
  "esbuild": "0.28.2",
22
- "eslint": "10.8.1",
24
+ "eslint": "10.10.0",
23
25
  "fast-check": "4.9.0",
24
- "happy-dom": "20.11.6",
26
+ "playwright": "1.63.0",
25
27
  "pngjs": "7.0.0",
26
28
  "prettier": "3.9.6",
27
29
  "typescript": "npm:@typescript/typescript6@6.0.2",
28
- "typescript-eslint": "8.67.0",
29
- "vitest": "4.1.11"
30
+ "typescript-eslint": "8.70.0",
31
+ "vitest": "5.0.0"
30
32
  },
31
33
  "overrides": {
32
34
  "qs": "^6.15.2"
package/src/connection.ts CHANGED
@@ -43,6 +43,7 @@ import * as modes from "./modes.js";
43
43
  // end of the gap fills.
44
44
  import { PAGE_SIZE } from "./store.js";
45
45
  import * as render from "./render.js";
46
+ import * as mouse from "./mouse.js";
46
47
  import type { ControlMessage, ScrollMessage, ServerMessage } from "./types.js";
47
48
  import { INITIAL_DELAY_MS, nextBackoffDelay } from "./reconnect.js";
48
49
 
@@ -304,6 +305,48 @@ function newHistoryState(): HistoryState {
304
305
 
305
306
  let history: HistoryState = newHistoryState();
306
307
 
308
+ /**
309
+ * The capabilities the server DECLARED on this socket's resume ack. Per-socket
310
+ * for the same reason as HistoryState: a carried-over capability would send a
311
+ * control to a server that never declared it, and post-latch an unrecognized
312
+ * text control is tolerated and DROPPED — so the message would vanish silently.
313
+ */
314
+ interface ServerCaps {
315
+ /** ackFlags bit3: the server serves the `ephemeralInput` control. */
316
+ ephemeralInput: boolean;
317
+ /** ackFlags bit2: the server derives the DEC 1004 answer from `focus`. */
318
+ serverFocus: boolean;
319
+ }
320
+
321
+ let caps: ServerCaps = { ephemeralInput: false, serverFocus: false };
322
+
323
+ /**
324
+ * Whether the consumer's terminal widget is focused. Per PAGE, not per socket:
325
+ * it is the widget's own state and must survive a reconnect, because the new
326
+ * socket has to be told the CURRENT value.
327
+ */
328
+ let clientFocused = false;
329
+
330
+ /**
331
+ * The focus value THIS socket has been told, or null when it has been told
332
+ * nothing. Per socket, so a reconnect re-asserts focus instead of suppressing it
333
+ * as a repeat.
334
+ */
335
+ let focusReported: boolean | null = null;
336
+
337
+ /**
338
+ * The 1004 state last seen in a modes frame on this socket, for the fallback
339
+ * path's enable-edge detection. Per socket: a fresh socket is re-announced its
340
+ * modes, and the edge has to be detected against nothing rather than against the
341
+ * previous socket's last value.
342
+ */
343
+ let lastFocusReportingSeen = false;
344
+
345
+ /** WebSocket.OPEN. Spelled out because a test's fake constructor has no statics. */
346
+ const WS_OPEN = 1;
347
+
348
+ const ephemeralEncoder = new TextEncoder();
349
+
307
350
  /** Cancel both timers and release single-flight. */
308
351
  function clearHistoryTimers(): void {
309
352
  if (history.dataTimer !== null) {
@@ -317,14 +360,19 @@ function clearHistoryTimers(): void {
317
360
  }
318
361
 
319
362
  /**
320
- * Reset the fetch state for a new socket: capability, single-flight, pacing and
321
- * the adaptive budget all go together, atomically. Splitting them is how a
322
- * client ends up paging against a server that never declared it, or bursting
323
- * into a depleted server bucket.
363
+ * Reset everything a new socket must not inherit: the fetch state (capability,
364
+ * single-flight, pacing and the adaptive budget), the declared server
365
+ * capabilities, and the focus reporting latches. All of it goes together,
366
+ * atomically. Splitting them is how a client ends up paging against a server
367
+ * that never declared it, bursting into a depleted server bucket, or holding a
368
+ * focus report back from the new socket as a repeat of what the old one was told.
324
369
  */
325
- function resetHistoryForSocket(): void {
370
+ function resetForNewSocket(): void {
326
371
  clearHistoryTimers();
327
372
  history = newHistoryState();
373
+ caps = { ephemeralInput: false, serverFocus: false };
374
+ focusReported = null;
375
+ lastFocusReportingSeen = false;
328
376
  }
329
377
 
330
378
  /**
@@ -642,6 +690,74 @@ function textControl(msg: ControlMessage): string {
642
690
  return JSON.stringify(msg);
643
691
  }
644
692
 
693
+ /**
694
+ * sendEphemeral delivers BEST-EFFORT input. Where the server declared
695
+ * `ephemeralInput` it sends only on a live socket, never queues, never
696
+ * retransmits, and never touches `outbox`, `outboxBytes`, `bytesSent`,
697
+ * `bytesAcked` or `onOutboxFull`; where it did NOT, the report falls back to
698
+ * reliable input, so it can be retransmitted after a resume and describe a
699
+ * screen since repainted. Returns whether it was sent; on false the caller's
700
+ * contract is to FORGET the event, because a mouse report carries no sequence
701
+ * number. Refusing on a closing socket is what xterm.js's AttachAddon and ttyd
702
+ * both do.
703
+ */
704
+ export function sendEphemeral(text: string): boolean {
705
+ if (connState.status !== "connected" || connState.sock.readyState !== WS_OPEN) {
706
+ return false;
707
+ }
708
+ if (!connState.upgraded || !caps.ephemeralInput) {
709
+ // Fall back to RELIABLE input rather than dropping the report. An
710
+ // unrecognized text control is tolerated and dropped post-latch, so the
711
+ // report would vanish entirely, and total silence is a worse failure than a
712
+ // stale report: a third-party or mid-upgrade server keeps a working mouse.
713
+ // What is DEGRADED on this path: the report rides the reliable outbox, so a
714
+ // click made before a disconnect is retransmitted after the resume has
715
+ // repainted the screen.
716
+ return sendBinary(ephemeralEncoder.encode(text));
717
+ }
718
+ connState.sock.send(textControl({ type: "ephemeralInput", data: text }));
719
+ return true;
720
+ }
721
+
722
+ /**
723
+ * setClientFocus reports whether the consumer's terminal widget is focused.
724
+ *
725
+ * Focus is STATE, so it is reconciled rather than replayed: the latest value
726
+ * wins, a repeat of the value already reported sends nothing, a report made
727
+ * while no socket is live is dropped rather than queued, and a reconnect
728
+ * re-asserts the current one. Where the server declared `serverFocus` this sends
729
+ * the `focus` control and the server derives the DEC 1004 answer for the whole
730
+ * session; otherwise the client writes the legacy CSI I / CSI O itself, and only
731
+ * while the application has 1004 enabled.
732
+ */
733
+ export function setClientFocus(focused: boolean): void {
734
+ clientFocused = focused;
735
+ if (connState.status !== "connected") {
736
+ // A focus report is never QUEUED. The fallback path below writes through the
737
+ // RELIABLE outbox, so a report made while the socket is down would be
738
+ // retransmitted on reconnect and re-assert a focus the widget may since have
739
+ // lost, which is the replay this whole derivation exists to remove. Nothing
740
+ // is lost by dropping it: both the resumeAck and the 1004 enable edge
741
+ // re-report the current value on the new socket.
742
+ return;
743
+ }
744
+ if (focused === focusReported) {
745
+ return;
746
+ }
747
+ focusReported = focused;
748
+ if (caps.serverFocus && connState.upgraded) {
749
+ sendControl({ type: "focus", focused });
750
+ return;
751
+ }
752
+ if (!modes.isFocusReporting()) {
753
+ return;
754
+ }
755
+ // The legacy path, and it rides the RELIABLE outbox: a focus report can be
756
+ // retransmitted after a blip, re-asserting a state that may no longer be true.
757
+ // That is one of the things the server-owned path fixes.
758
+ sendBinary(ephemeralEncoder.encode(focused ? "\x1b[I" : "\x1b[O"));
759
+ }
760
+
645
761
  function sendControl(msg: ControlMessage): void {
646
762
  if (connState.status !== "connected") {
647
763
  return;
@@ -700,10 +816,6 @@ export function sendResize(): void {
700
816
  sendControl({ type: "resize", cols, rows });
701
817
  }
702
818
 
703
- // resetLedger drops a session's reliable-input accounting. Called when the
704
- // server's boot epoch changes (restart) or when the server no longer recognizes
705
- // the session -- both invalidate the local bytesSent/bytesAcked/outbox state.
706
- // The three entry points below differ only in whether the UI hears about it.
707
819
  // resetLedger drops a session's reliable-input accounting. Called whenever the
708
820
  // server no longer holds the ledger these counters were keyed to (a restart, an
709
821
  // idle-GC'd resume key, a session the server does not recognize): its
@@ -885,7 +997,7 @@ function teardown(): void {
885
997
  }
886
998
  }
887
999
  stopHeartbeat();
888
- resetHistoryForSocket();
1000
+ resetForNewSocket();
889
1001
  cb?.clearSolicited?.();
890
1002
  cancelScheduledReconnect();
891
1003
  connState = { status: "disconnected" };
@@ -1341,7 +1453,7 @@ export function connect(): void {
1341
1453
  // exact values, so the replay-jump prediction must be computed from them
1342
1454
  // — never from store state, which a frame arriving between send and ack
1343
1455
  // could have moved, masking a real jump (docs/paged-scrollback.md §4.5).
1344
- resetHistoryForSocket();
1456
+ resetForNewSocket();
1345
1457
  const sentHaveThrough = cb?.getHaveThrough?.() ?? -1;
1346
1458
  // ALWAYS a number. A consumer may ask for fewer lines than the protocol
1347
1459
  // ceiling, but it cannot opt OUT of the bound: the server clamps to the
@@ -1555,6 +1667,18 @@ export function connect(): void {
1555
1667
  // nothing ever sent, and the store keeps its compatibility tail cap.
1556
1668
  history.paging = msg.historyPaging === true;
1557
1669
  history.acked = true;
1670
+ // The other two capabilities off the same tail, and the focus re-report
1671
+ // for this socket. Placed here — with the capability handling, BEFORE the
1672
+ // ledger-lost / session-forgotten early returns — for the same reason
1673
+ // onResumeTransition is: a ledger loss is not a capability event, and the
1674
+ // long-absence attach that loses its ledger still needs its focus state
1675
+ // asserted on the new socket.
1676
+ caps = {
1677
+ ephemeralInput: msg.ephemeralInput === true,
1678
+ serverFocus: msg.serverFocus === true,
1679
+ };
1680
+ focusReported = null;
1681
+ setClientFocus(clientFocused);
1558
1682
  // The store's ONE ack transition, dispatched BEFORE the early returns
1559
1683
  // below. A ledger loss is not a capability event, and the long-absence
1560
1684
  // attach that loses its ledger is exactly the one carrying a replay jump
@@ -1608,6 +1732,12 @@ export function connect(): void {
1608
1732
  connState.status === "connected" && connState.sock === sock && connState.upgraded,
1609
1733
  st,
1610
1734
  );
1735
+ // The transport answers for itself here, as it does for focus: the resume is
1736
+ // complete, `upgraded` and `caps` are both set, so this is the first instant
1737
+ // a best-effort report can leave. A gesture still recorded from before the
1738
+ // gap is cancelled now, or the application holds that button for the rest of
1739
+ // the session. Inert until a consumer has called `mouse.init`.
1740
+ mouse.resyncGesture();
1611
1741
  return;
1612
1742
  }
1613
1743
  if (msg.type === "ackOnly") {
@@ -1636,6 +1766,18 @@ export function connect(): void {
1636
1766
  };
1637
1767
  st.modes = snap;
1638
1768
  modes.applySnapshot(snap);
1769
+ if (!caps.serverFocus) {
1770
+ // The 1004 ENABLE edge on the fallback path. An application that enables
1771
+ // focus reporting while the tab is already focused otherwise gets nothing
1772
+ // until the user clicks away; xterm.js's _reportFocus reports the current
1773
+ // state at that instant, and this client did not.
1774
+ const rising = msg.focusReporting && !lastFocusReportingSeen;
1775
+ lastFocusReportingSeen = msg.focusReporting;
1776
+ if (rising) {
1777
+ focusReported = null;
1778
+ setClientFocus(clientFocused);
1779
+ }
1780
+ }
1639
1781
  if (typeof msg.inputAck === "number") {
1640
1782
  applyAck(st, msg.inputAck);
1641
1783
  }
@@ -1719,7 +1861,7 @@ export function connect(): void {
1719
1861
  // The fetch state belonged to THIS socket: kill its timers so neither
1720
1862
  // fires against a dead socket, and release the solicited window so the
1721
1863
  // store stops admitting lines below its stale-re-send watermark.
1722
- resetHistoryForSocket();
1864
+ resetForNewSocket();
1723
1865
  cb?.clearSolicited?.();
1724
1866
  if (ev.code === WIRE_INCOMPATIBLE_CLOSE_CODE) {
1725
1867
  const reason =
package/src/mouse.ts CHANGED
@@ -7,14 +7,18 @@
7
7
  // 0=left, 1=middle, 2=right, 64=wheel-up, 65=wheel-down
8
8
  // +4=shift, +8=alt, +16=ctrl, +32=motion
9
9
  //
10
- // Focus events: ESC[I (focus in), ESC[O (focus out).
10
+ // Mouse reports are BEST-EFFORT: a report carries no sequence number and
11
+ // describes a screen a resume may since have repainted, so `sendReport` may
12
+ // refuse one and a refused report is forgotten rather than retried. Focus is not
13
+ // mouse input at all — it is transport state the server derives — and lives in
14
+ // `connection.setClientFocus`.
11
15
  //
12
16
  // Out-of-scope (consumed but not implemented):
13
17
  // - X10 mouse mode (mode 9): not supported.
14
18
  // - urxvt encoding (mode 1015): not supported.
15
19
  // - DEFAULT encoding (raw bytes): not supported; only SGR 1006.
16
20
 
17
- import { getMouseMode, isMouseSGR, isMousePixels, isFocusReporting } from "./modes.js";
21
+ import { getMouseMode, isMouseSGR, isMousePixels } from "./modes.js";
18
22
 
19
23
  const ESC = "\x1b";
20
24
 
@@ -75,12 +79,32 @@ function domButtonToXterm(button: number): number {
75
79
  * and access the terminal DOM element to attach listeners to.
76
80
  */
77
81
  export interface MouseInputHandler {
78
- /** Sends raw input bytes (or escape sequences) to the server. */
79
- send: (data: string) => void;
82
+ /**
83
+ * Sends one encoded report as best-effort input. Returns whether it reached a
84
+ * live socket; `false` means it was NOT sent and must not be retried (see
85
+ * `connection.sendEphemeral`).
86
+ */
87
+ sendReport: (data: string) => boolean;
80
88
  /** Returns the current cell pixel size for hit-testing pointer coordinates. */
81
89
  cellSize: () => { width: number; height: number };
82
90
  /** Returns the terminal DOM element to attach mouse listeners to. */
83
91
  termElement: () => HTMLElement;
92
+ /**
93
+ * Returns the element whose box IS the grid: its top-left is cell (1,1)'s
94
+ * origin and its bottom edge is the last row's bottom. Defaults to
95
+ * `termElement()`, which is only the same box when that element carries no
96
+ * padding and no reserved scrollbar gutter.
97
+ */
98
+ gridElement?: () => HTMLElement;
99
+ /**
100
+ * Returns the dimensions of the grid currently on screen (the engine
101
+ * renderer's `gridSize`). Supplying it clamps every report into the grid and
102
+ * anchors the row arithmetic to the grid's bottom edge; omitting it keeps the
103
+ * top-anchored, unclamped hit test. Rows must be the RENDERED height, not the
104
+ * height this client would like: the hit test anchors on it, so a disagreement
105
+ * shifts every reported row rather than loosening a bound.
106
+ */
107
+ gridSize?: () => { cols: number; rows: number };
84
108
  }
85
109
 
86
110
  let handler: MouseInputHandler | null = null;
@@ -101,6 +125,81 @@ let lastMotion = "";
101
125
  // registrations on the SAME element, but a re-init on a NEW element would
102
126
  // otherwise leak the old one's listeners).
103
127
  let attachedEl: HTMLElement | null = null;
128
+ // One warning per wiring, not per event: a non-positive cell size makes every
129
+ // report vanish, which is indistinguishable from mouse-tracking being off, and
130
+ // that silence is why a consumer wiring cellSize to a guess went unnoticed.
131
+ let warnedDegenerateCell = false;
132
+ // The same silence for the other divisor, and it is worse: an empty grid clamps
133
+ // every coordinate to 0, so instead of vanishing the report becomes a confident
134
+ // (1,1) for every pointer position.
135
+ let warnedDegenerateGrid = false;
136
+ // The buttons whose PRESS was actually delivered, with the coordinates that
137
+ // press reported. This is what the APPLICATION believes is held, so it also
138
+ // decides which reports are truthful: a release is emitted only for a recorded
139
+ // button, and a motion report claims a held button only for a recorded one.
140
+ const pressed = new Map<number, { col: number; row: number }>();
141
+ // DOM MouseEvent.button index -> its bit in MouseEvent.buttons. Middle and right
142
+ // are SWAPPED between the two (button 1 = middle = bit 4; button 2 = right =
143
+ // bit 2), so `1 << button` is wrong.
144
+ const BUTTONS_BIT = [1, 4, 2, 8, 16] as const;
145
+ // The freshest `buttons` bitmask any mouse event gave this element, which is the
146
+ // only reading of present button state the DOM offers outside an event.
147
+ let lastButtons = 0;
148
+ // The motion report waiting for the next animation frame, and the frame handle
149
+ // that will flush it. Motion coalesces to one report per frame, latest wins.
150
+ let pendingMotion: string | null = null;
151
+ let motionFrame: number | null = null;
152
+
153
+ // The one predicate every read and every write of `pressed` is gated on: the
154
+ // application is asking for SGR-encoded reports right now. A record written
155
+ // under tracking outlives the mode that created it, and an SGR release written
156
+ // once tracking is off lands in the PTY as ordinary keystrokes.
157
+ function trackingActive(): boolean {
158
+ return getMouseMode() !== 0 && (isMouseSGR() || isMousePixels());
159
+ }
160
+
161
+ // Forget the held-gesture record and the button reading it is judged against.
162
+ function clearGesture(): void {
163
+ pressed.clear();
164
+ lastButtons = 0;
165
+ }
166
+
167
+ // True when the application is not tracking, and the shared gate for every
168
+ // handler and for resyncGesture. Forgetting the record here is what closes the
169
+ // stranded-gesture sequence: an SGR release written while nothing asked for one
170
+ // reaches the PTY as keystrokes, and carrying the record into a later re-enable
171
+ // would describe a press that program never saw. tmux answers the same way at
172
+ // attach, disabling every mouse mode and clearing mouse_drag_flag with it
173
+ // (tty.c, tty_start_tty).
174
+ function trackingOff(): boolean {
175
+ if (trackingActive()) {
176
+ return false;
177
+ }
178
+ clearGesture();
179
+ return true;
180
+ }
181
+
182
+ // Drop the pending motion report and disarm its frame. A press, release or wheel
183
+ // CANCELS rather than flushes: the button event carries a newer position, so
184
+ // ordering is correct by construction and the superseded motion is exactly what
185
+ // coalescing is entitled to drop.
186
+ function cancelPendingMotion(): void {
187
+ pendingMotion = null;
188
+ if (motionFrame !== null) {
189
+ cancelAnimationFrame(motionFrame);
190
+ motionFrame = null;
191
+ }
192
+ }
193
+
194
+ function flushMotion(): void {
195
+ motionFrame = null;
196
+ const seq = pendingMotion;
197
+ pendingMotion = null;
198
+ if (seq === null || !handler) {
199
+ return;
200
+ }
201
+ handler.sendReport(seq);
202
+ }
104
203
 
105
204
  function detach(): void {
106
205
  if (!attachedEl) {
@@ -110,14 +209,12 @@ function detach(): void {
110
209
  attachedEl.removeEventListener("mouseup", onMouseUp);
111
210
  attachedEl.removeEventListener("mousemove", onMouseMove);
112
211
  attachedEl.removeEventListener("wheel", onWheel);
113
- attachedEl.removeEventListener("focusin", onFocusIn);
114
- attachedEl.removeEventListener("focusout", onFocusOut);
115
212
  attachedEl = null;
116
213
  }
117
214
 
118
215
  /**
119
- * Initialize the mouse module by attaching pointer/wheel/focus listeners to
120
- * the terminal element. Listeners gate on the active mouse mode + SGR 1006
216
+ * Initialize the mouse module by attaching pointer/wheel listeners to the
217
+ * terminal element. Listeners gate on the active mouse mode + SGR 1006
121
218
  * encoding; they are no-ops when the server hasn't enabled mouse tracking.
122
219
  *
123
220
  * Returns an idempotent disposer that detaches the listeners and resets the
@@ -130,14 +227,16 @@ export function init(h: MouseInputHandler): () => void {
130
227
  handler = h;
131
228
  shiftBypass = false;
132
229
  lastMotion = "";
230
+ warnedDegenerateCell = false;
231
+ warnedDegenerateGrid = false;
232
+ clearGesture();
233
+ cancelPendingMotion();
133
234
  const el = h.termElement();
134
235
  attachedEl = el;
135
236
  el.addEventListener("mousedown", onMouseDown);
136
237
  el.addEventListener("mouseup", onMouseUp);
137
238
  el.addEventListener("mousemove", onMouseMove);
138
239
  el.addEventListener("wheel", onWheel, { passive: false });
139
- el.addEventListener("focusin", onFocusIn);
140
- el.addEventListener("focusout", onFocusOut);
141
240
  return function dispose(): void {
142
241
  if (attachedEl !== el) {
143
242
  return; // superseded by a later init — nothing of ours left to detach
@@ -146,43 +245,169 @@ export function init(h: MouseInputHandler): () => void {
146
245
  handler = null;
147
246
  shiftBypass = false;
148
247
  lastMotion = "";
248
+ clearGesture();
249
+ cancelPendingMotion();
149
250
  };
150
251
  }
151
252
 
253
+ /**
254
+ * resyncGesture cancels a gesture left in flight, called once the resume has
255
+ * declared the server's capabilities (the resumeAck, not the socket's open: the
256
+ * ephemeral channel is not yet known at open, so a release sent there would take
257
+ * the reliable fallback and become the one replayable mouse report). A button the
258
+ * server believes is still held is a stuck drag for the rest of the session. For
259
+ * each recorded button the last reading says is now UP it emits one release at
260
+ * that press's coordinates; a button still down is left alone, because it
261
+ * genuinely is.
262
+ *
263
+ * This reports PRESENT state, which is what RFB and Guacamole do implicitly with
264
+ * their next message, not a replay of the missed release. The synthesis is OUR
265
+ * JUDGEMENT — no reference addresses a producer-side resync (tmux resets its own
266
+ * gesture model at attach, but tmux is the CONSUMER and can) — chosen over
267
+ * emitting nothing and letting the next real click unstick the application. Its
268
+ * bound: the reading is the freshest the browser gave THIS element, so a release
269
+ * that happened off-element is invisible either way.
270
+ */
271
+ export function resyncGesture(): void {
272
+ if (!handler) {
273
+ return;
274
+ }
275
+ cancelPendingMotion();
276
+ if (trackingOff()) {
277
+ return;
278
+ }
279
+ for (const [button, pos] of [...pressed]) {
280
+ if ((lastButtons & (BUTTONS_BIT[button] ?? 0)) !== 0) {
281
+ continue; // still held
282
+ }
283
+ const code = buttonCode(domButtonToXterm(button), false, false, false, false);
284
+ if (handler.sendReport(encodeSGR(code, pos.col, pos.row, true))) {
285
+ pressed.delete(button); // only a DELIVERED release ends the gesture, as in onMouseUp
286
+ }
287
+ }
288
+ }
289
+
290
+ /**
291
+ * disarmGesture forgets the held gesture without reporting anything, for a
292
+ * consumer pointing one terminal's socket at a different session.
293
+ *
294
+ * The record says what the OUTGOING session's application believes is held, and
295
+ * this module is per terminal while sessions multiplex over it, so a release
296
+ * surviving the switch would reach a session that never saw the press — the
297
+ * desync `pressed` exists to prevent, arriving through resyncGesture. Nothing is
298
+ * emitted instead: the same treatment the consumer gives every other latched
299
+ * input class on a switch.
300
+ */
301
+ export function disarmGesture(): void {
302
+ cancelPendingMotion();
303
+ clearGesture();
304
+ }
305
+
152
306
  // pixelToCell returns the coordinate pair to report for a mouse event. Normally
153
307
  // that's the 1-based cell column/row; under DEC 1016 (SGR-pixels) it's instead
154
308
  // the 1-based pixel offset within the terminal element (same SGR grammar, so
155
309
  // encodeSGR is reused unchanged). The {col,row} field names carry whichever the
156
310
  // active mode reports.
157
311
  function pixelToCell(e: MouseEvent): { col: number; row: number } | null {
158
- if (!handler) {
312
+ const h = handler;
313
+ if (!h) {
159
314
  return null;
160
315
  }
161
- const el = handler.termElement();
316
+ const el = (h.gridElement ?? h.termElement)();
162
317
  const rect = el.getBoundingClientRect();
163
- if (isMousePixels()) {
164
- const px = Math.round(e.clientX - rect.left);
165
- const py = Math.round(e.clientY - rect.top);
166
- if (px < 0 || py < 0) {
318
+ const grid = h.gridSize?.() ?? null;
319
+ if (grid !== null && (grid.cols <= 0 || grid.rows <= 0)) {
320
+ // rows 0 is the documented pre-first-frame answer (render.gridSize): the
321
+ // server has not described the screen yet, so refuse without spending the
322
+ // warning latch that exists to make a genuine mis-wiring loud.
323
+ if (grid.cols > 0 && grid.rows === 0) {
167
324
  return null;
168
325
  }
169
- return { col: px + 1, row: py + 1 }; // 1-based pixel offsets
326
+ warnUnmeasuredGrid(grid.cols, grid.rows);
327
+ return null;
328
+ }
329
+ if (isMousePixels()) {
330
+ const box = grid === null ? null : pixelBox(h, grid);
331
+ if (box === null) {
332
+ const px = Math.round(e.clientX - rect.left);
333
+ const py = Math.round(e.clientY - rect.top);
334
+ if (px < 0 || py < 0) {
335
+ return null;
336
+ }
337
+ return { col: px + 1, row: py + 1 }; // 1-based pixel offsets
338
+ }
339
+ // Same origin as the cell branch below — the grid's BOTTOM edge — so one
340
+ // press resolves to the same row under either encoding. Measured from the
341
+ // box's top edge instead, a partial top row shifts every pixel report by up
342
+ // to a cell.
343
+ return {
344
+ col: clampToGrid(Math.round(e.clientX - rect.left), box.width) + 1,
345
+ row: clampToGrid(Math.round(e.clientY - (rect.bottom - box.height)), box.height) + 1,
346
+ };
170
347
  }
171
- const { width, height } = handler.cellSize();
348
+ const { width, height } = h.cellSize();
172
349
  if (width <= 0 || height <= 0) {
350
+ warnUnmeasuredCell(width, height);
173
351
  return null;
174
352
  }
175
- const col = Math.floor((e.clientX - rect.left) / width) + 1; // 1-based
176
- const row = Math.floor((e.clientY - rect.top) / height) + 1; // 1-based
177
- if (col < 1 || row < 1) {
353
+ const col0 = Math.floor((e.clientX - rect.left) / width);
354
+ if (grid === null) {
355
+ const row0 = Math.floor((e.clientY - rect.top) / height);
356
+ if (col0 < 0 || row0 < 0) {
357
+ return null;
358
+ }
359
+ return { col: col0 + 1, row: row0 + 1 };
360
+ }
361
+ // The screen window is the TAIL of the content, so the grid is bottom-anchored:
362
+ // on the primary screen a bottom-pinned view shows a PARTIAL row at the top and
363
+ // row 0 begins below the box's top edge, which top-anchored arithmetic reports
364
+ // one row out for.
365
+ const row0 = grid.rows - 1 - Math.floor((rect.bottom - e.clientY) / height);
366
+ return { col: clampToGrid(col0, grid.cols) + 1, row: clampToGrid(row0, grid.rows) + 1 };
367
+ }
368
+
369
+ /** Clamp a 0-based grid coordinate into `0..limit-1`, and never below 0. */
370
+ function clampToGrid(value: number, limit: number): number {
371
+ return Math.max(0, Math.min(value, limit - 1));
372
+ }
373
+
374
+ // The grid's own box in pixels, or null while the cell is unmeasured — in which
375
+ // case a pixel report goes out UNCLAMPED rather than being withheld: the offset
376
+ // is still the right offset, only its bound is unknown.
377
+ function pixelBox(
378
+ h: MouseInputHandler,
379
+ grid: { cols: number; rows: number },
380
+ ): { width: number; height: number } | null {
381
+ const { width, height } = h.cellSize();
382
+ if (width <= 0 || height <= 0) {
178
383
  return null;
179
384
  }
180
- return { col, row };
385
+ return { width: grid.cols * width, height: grid.rows * height };
386
+ }
387
+
388
+ function warnUnmeasuredCell(width: number, height: number): void {
389
+ if (warnedDegenerateCell) {
390
+ return;
391
+ }
392
+ warnedDegenerateCell = true;
393
+ console.warn(
394
+ `vterm: mouse reports suppressed, cellSize() answered ${String(width)}x${String(height)}`,
395
+ );
396
+ }
397
+
398
+ function warnUnmeasuredGrid(cols: number, rows: number): void {
399
+ if (warnedDegenerateGrid) {
400
+ return;
401
+ }
402
+ warnedDegenerateGrid = true;
403
+ console.warn(
404
+ `vterm: mouse reports suppressed, gridSize() answered ${String(cols)}x${String(rows)}`,
405
+ );
181
406
  }
182
407
 
183
408
  function onMouseDown(e: MouseEvent): void {
184
- const mode = getMouseMode();
185
- if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
409
+ lastButtons = e.buttons;
410
+ if (trackingOff() || !handler) {
186
411
  return;
187
412
  }
188
413
  if (e.shiftKey) {
@@ -196,37 +421,66 @@ function onMouseDown(e: MouseEvent): void {
196
421
  if (!pos) {
197
422
  return;
198
423
  }
424
+ cancelPendingMotion();
199
425
  const code = buttonCode(domButtonToXterm(e.button), false, e.shiftKey, e.altKey, e.ctrlKey);
200
- handler.send(encodeSGR(code, pos.col, pos.row, false));
426
+ if (handler.sendReport(encodeSGR(code, pos.col, pos.row, false))) {
427
+ // Only a DELIVERED press starts a gesture, so onMouseUp cannot report a
428
+ // release for a press the application never saw.
429
+ pressed.set(e.button, pos);
430
+ }
201
431
  e.preventDefault();
202
432
  }
203
433
 
204
434
  function onMouseUp(e: MouseEvent): void {
205
- const mode = getMouseMode();
206
- if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
207
- return;
208
- }
209
- if (shiftBypass) {
210
- // Release of a Shift-initiated (native-selection) gesture: report nothing.
211
- shiftBypass = false;
435
+ lastButtons = e.buttons;
436
+ if (trackingOff() || !handler) {
212
437
  return;
213
438
  }
439
+ const bypassed = shiftBypass;
440
+ shiftBypass = false;
441
+ cancelPendingMotion();
214
442
  const pos = pixelToCell(e);
215
443
  if (!pos) {
444
+ // The record SURVIVES a release that could not be reported. `pressed` is what
445
+ // the application believes is held, and it was told nothing here, so
446
+ // resyncGesture must still find the button and cancel it on the next open.
447
+ return;
448
+ }
449
+ if (pressed.has(e.button)) {
450
+ const btn = domButtonToXterm(e.button);
451
+ const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
452
+ if (handler.sendReport(encodeSGR(code, pos.col, pos.row, true))) {
453
+ pressed.delete(e.button); // only a DELIVERED release ends the gesture
454
+ }
455
+ }
456
+ if (bypassed) {
457
+ // The Shift-initiated gesture is the browser's (native selection), so its
458
+ // own default is left alone; a button recorded BEFORE the bypass began
459
+ // belongs to a delivered gesture and is still owed the release above.
216
460
  return;
217
461
  }
218
- const btn = domButtonToXterm(e.button);
219
- const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
220
- handler.send(encodeSGR(code, pos.col, pos.row, true));
221
462
  lastMotion = ""; // gesture over: next motion always reports
222
463
  e.preventDefault();
223
464
  }
224
465
 
466
+ // The button to report as held: the highest-priority DOM button that is both
467
+ // down now and recorded as delivered, or null when the application believes none
468
+ // is. Priority matches the DOM's own left/middle/right order.
469
+ function heldButton(buttons: number): number | null {
470
+ for (const button of [0, 1, 2]) {
471
+ if ((buttons & (BUTTONS_BIT[button] ?? 0)) !== 0 && pressed.has(button)) {
472
+ return button;
473
+ }
474
+ }
475
+ return null;
476
+ }
477
+
225
478
  function onMouseMove(e: MouseEvent): void {
226
- const mode = getMouseMode();
227
- if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
479
+ lastButtons = e.buttons;
480
+ if (trackingOff() || !handler) {
228
481
  return;
229
482
  }
483
+ const mode = getMouseMode();
230
484
  if (shiftBypass && e.buttons) {
231
485
  return; // a Shift-initiated drag is a native selection; don't report it
232
486
  }
@@ -236,7 +490,11 @@ function onMouseMove(e: MouseEvent): void {
236
490
  if (mode === 1000) {
237
491
  return;
238
492
  }
239
- if (mode === 1002 && !e.buttons) {
493
+ // A button the application never saw pressed is not held for reporting
494
+ // purposes: a drag whose press was refused (or began off the element) would
495
+ // otherwise start a drag the pairing rule above then refuses to end.
496
+ const held = heldButton(e.buttons);
497
+ if (mode === 1002 && held === null) {
240
498
  return;
241
499
  }
242
500
  const pos = pixelToCell(e);
@@ -245,25 +503,28 @@ function onMouseMove(e: MouseEvent): void {
245
503
  }
246
504
  // No button held (bare motion in mode 1003) reports xterm's "no button"
247
505
  // code 3, not 0 — code 0 would be read as a left-button drag during hover.
248
- const btn = e.buttons ? (e.buttons & 1 ? 0 : e.buttons & 4 ? 1 : e.buttons & 2 ? 2 : 0) : 3;
249
- const code = buttonCode(btn, true, e.shiftKey, e.altKey, e.ctrlKey);
506
+ const code = buttonCode(held ?? 3, true, e.shiftKey, e.altKey, e.ctrlKey);
250
507
  const seq = encodeSGR(code, pos.col, pos.row, false);
251
508
  if (seq === lastMotion) {
252
509
  return; // same cell, same buttons/modifiers: suppress the duplicate report
253
510
  }
254
511
  lastMotion = seq;
255
- handler.send(seq);
512
+ // Coalesce MOTION to one report per animation frame, latest wins. Under DEC
513
+ // 1016 the cell dedup above does nothing, so pixel mode would otherwise emit
514
+ // one report per DOM mousemove.
515
+ pendingMotion = seq;
516
+ motionFrame ??= requestAnimationFrame(flushMotion);
256
517
  }
257
518
 
258
519
  function onWheel(e: WheelEvent): void {
259
- const mode = getMouseMode();
260
- if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
520
+ if (trackingOff() || !handler) {
261
521
  return;
262
522
  }
263
523
  const pos = pixelToCell(e);
264
524
  if (!pos) {
265
525
  return;
266
526
  }
527
+ cancelPendingMotion();
267
528
  // Wheel up = button 64, wheel down = button 65. A gesture with NO vertical
268
529
  // component — a horizontal trackpad swipe, or shift-wheel where the browser
269
530
  // routes the delta to deltaX — has no wheel button to report: xterm.js's
@@ -273,24 +534,14 @@ function onWheel(e: WheelEvent): void {
273
534
  // wheel, and letting a sideways swipe through would scroll the page instead.
274
535
  // Reporting 65 here (the pre-fix behavior, `deltaY < 0 ? 64 : 65` with no
275
536
  // zero case) scrolled the TUI DOWN on a sideways gesture.
537
+ //
538
+ // A wheel report is never coalesced: it is a NOTCH COUNT against a screen, so
539
+ // dropping one loses scroll distance — which is why xterm.js expands a single
540
+ // WheelEvent into N discrete reports.
276
541
  if (e.deltaY !== 0) {
277
542
  const btn = e.deltaY < 0 ? 64 : 65;
278
543
  const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
279
- handler.send(encodeSGR(code, pos.col, pos.row, false));
544
+ handler.sendReport(encodeSGR(code, pos.col, pos.row, false));
280
545
  }
281
546
  e.preventDefault();
282
547
  }
283
-
284
- function onFocusIn(): void {
285
- if (!isFocusReporting() || !handler) {
286
- return;
287
- }
288
- handler.send(`${ESC}[I`);
289
- }
290
-
291
- function onFocusOut(): void {
292
- if (!isFocusReporting() || !handler) {
293
- return;
294
- }
295
- handler.send(`${ESC}[O`);
296
- }
package/src/render.ts CHANGED
@@ -1355,9 +1355,7 @@ function flushRender(): void {
1355
1355
  const anchor = captureReadAnchor();
1356
1356
  try {
1357
1357
  flushRenderInner();
1358
- // A clean pass means the error condition (if any) has cleared, so give a
1359
- // later transient error its full retry budget again. Any backlog reschedule
1360
- // was already issued from flushRenderInner's own end-of-body.
1358
+ // Clean pass: give a later transient error its full retry budget again.
1361
1359
  renderNoProgressStreak = 0;
1362
1360
  } catch (err) {
1363
1361
  console.error("vterm: render error", err);
@@ -1525,7 +1523,6 @@ function flushRenderInner(): void {
1525
1523
  }
1526
1524
  }
1527
1525
 
1528
- // Refresh cursor state from the window.
1529
1526
  const win = store.getWindow();
1530
1527
  const newCursorAbs = win.base + win.cursorRow;
1531
1528
  cursorAbs = newCursorAbs;
@@ -1538,11 +1535,9 @@ function flushRenderInner(): void {
1538
1535
  // resulting mode is unchanged.
1539
1536
  syncCursorBlink();
1540
1537
 
1541
- // Alt screen: render the ephemeral grid instead of the absolute buffer.
1542
- // Returning here skips the dirtyLines queueing below — safe by invariant:
1543
- // main-buffer rows never repaint while alt is active, and the alt-exit
1544
- // branch below rebuilds EVERYTHING via queueRowsViewportFirst, so any line
1545
- // dirtied during the alt session is repainted at exit.
1538
+ // Skips the dirtyLines queueing below — safe: main-buffer rows never
1539
+ // repaint while alt is active, and alt-exit rebuilds via
1540
+ // queueRowsViewportFirst, repainting any line dirtied during the alt session.
1546
1541
  if (store.isAlt()) {
1547
1542
  const altRows = store.getAltRows();
1548
1543
  renderAlt(altRows);
@@ -1570,7 +1565,6 @@ function flushRenderInner(): void {
1570
1565
  queueRowsViewportFirst();
1571
1566
  }
1572
1567
 
1573
- // Queue this frame's changed rows for building.
1574
1568
  for (const abs of ch.dirtyLines) {
1575
1569
  renderQueue.add(abs);
1576
1570
  }
@@ -2222,6 +2216,9 @@ export function updateFontMetrics(): void {
2222
2216
  const measuredW = measureCellWidth();
2223
2217
  cellWidth = Math.round(measuredW);
2224
2218
  cellHeight = parseFloat(cs.lineHeight) || 17;
2219
+ // The column count is derived from the cell, so the cache gridSize reads is
2220
+ // stale as of this call.
2221
+ measuredCols = 0;
2225
2222
  defaultSpacing = cellWidth - measuredW;
2226
2223
  output.style.letterSpacing = `${defaultSpacing}px`;
2227
2224
  document.documentElement.style.setProperty("--char-w", `${cellWidth}px`);
@@ -2255,6 +2252,13 @@ export function updateFontMetrics(): void {
2255
2252
  const MIN_COLS = 20;
2256
2253
  const MIN_ROWS = 5;
2257
2254
 
2255
+ // The column count of the most recent measurement, which is the count the
2256
+ // resize announce carried to the server. gridSize() reads it instead of
2257
+ // measuring, because it is called on every raw mousemove while an application
2258
+ // tracks motion and computeSize() costs a computed-style read plus two layout
2259
+ // reads. 0 until the first measurement.
2260
+ let measuredCols = 0;
2261
+
2258
2262
  /**
2259
2263
  * Compute the integer (cols, rows) the terminal element can fit at current
2260
2264
  * font metrics, clamped to a minimum of 20×5. Used to decide what dimensions
@@ -2268,9 +2272,36 @@ export function computeSize(): { cols: number; rows: number } {
2268
2272
  const contentH = termWrap.clientHeight - padY;
2269
2273
  const cols = Math.max(MIN_COLS, Math.floor(contentW / cellWidth));
2270
2274
  const rows = Math.max(MIN_ROWS, Math.floor(contentH / cellHeight));
2275
+ measuredCols = cols;
2271
2276
  return { cols, rows };
2272
2277
  }
2273
2278
 
2279
+ /**
2280
+ * The dimensions of the grid currently ON SCREEN — what
2281
+ * `mouse.MouseInputHandler.gridSize` wants, so a pointer resolves against the
2282
+ * rows the reader is actually looking at.
2283
+ *
2284
+ * Deliberately not `computeSize()`, whose rows are the size this client WOULD
2285
+ * like: the server's screen is last-writer-wins across every attached client
2286
+ * and relaxes to the smallest on disconnect, so a second attached device — or
2287
+ * any local resize before the next screen frame lands — leaves the measured
2288
+ * height describing a grid nothing is painting. The row hit test anchors on this
2289
+ * number rather than merely clamping to it, so a disagreement of N shifts every
2290
+ * reported row by N. The alt grid keeps its own height, and a TUI runs there.
2291
+ *
2292
+ * Columns stay the measured count: the store has none to answer with (a row's
2293
+ * trailing blank cells are trimmed off the wire) and the axis only bounds a
2294
+ * report, where over-reporting costs nothing.
2295
+ *
2296
+ * Rows are 0 until the first screen frame lands, which the mouse module treats
2297
+ * as "no grid yet" and refuses to report against — the honest answer, since
2298
+ * nothing knows the screen's height before the server describes it.
2299
+ */
2300
+ export function gridSize(): { cols: number; rows: number } {
2301
+ const rows = store.isAlt() ? store.getAltRows().length : store.getWindow().height;
2302
+ return { cols: measuredCols > 0 ? measuredCols : computeSize().cols, rows };
2303
+ }
2304
+
2274
2305
  // rowTopInTermWrap returns a row element's top in termWrap's coordinate space
2275
2306
  // (the space every absolutely-positioned overlay — caret, predicted cursor,
2276
2307
  // composition view — resolves against). A bare `offsetTop` is NOT that: it is
@@ -2355,6 +2386,16 @@ export function getCursorPx(): { left: number; top: number; cellH: number } {
2355
2386
  };
2356
2387
  }
2357
2388
 
2389
+ /**
2390
+ * The measured pixel size of one cell — what `mouse.MouseInputHandler.cellSize`
2391
+ * wants. Only meaningful once `updateFontMetrics` has measured this terminal's
2392
+ * font; before that it answers the module's power-on fallback, not the font in
2393
+ * use.
2394
+ */
2395
+ export function cellSize(): { width: number; height: number } {
2396
+ return { width: cellWidth, height: cellHeight };
2397
+ }
2398
+
2358
2399
  // --- Caret overlay ---
2359
2400
  //
2360
2401
  // The real cursor is a single absolutely-positioned element appended to
package/src/types.ts CHANGED
@@ -119,6 +119,23 @@ export interface ResumeAckMessage {
119
119
  * old server.
120
120
  */
121
121
  historyPaging?: boolean;
122
+ /**
123
+ * The server DECLARES that it owns DEC 1004 focus for this session: it derives
124
+ * the answer from attachment state, every attached client's reported focus and
125
+ * its own keep-unfocused declaration, and honors the `focus` control. Report
126
+ * the terminal widget's focus with `connection.setClientFocus` and write no
127
+ * focus bytes. Absent (or false) means the server does not, and the client
128
+ * writes CSI I / CSI O itself while the application has 1004 enabled.
129
+ */
130
+ serverFocus?: boolean;
131
+ /**
132
+ * The server DECLARES that it serves the `ephemeralInput` control: a
133
+ * best-effort input channel it does not count against the resume ledger, for
134
+ * input whose value expires (mouse reports). Absent (or false) means the
135
+ * server does not, and `connection.sendEphemeral` falls back to reliable
136
+ * input rather than dropping the report.
137
+ */
138
+ ephemeralInput?: boolean;
122
139
  /**
123
140
  * The server's wire-protocol revision (third length-gated tail, absent on
124
141
  * older servers). A value OUTSIDE the client's supported range means one
@@ -258,6 +275,20 @@ export type ControlMessage =
258
275
  */
259
276
  | { type: "history"; fromAbs: number; maxLines: number }
260
277
  | { type: "ping" }
278
+ /**
279
+ * The terminal widget's focus, reported so the server can derive the DEC 1004
280
+ * answer for the whole session (it sees every attached client, a socket dying
281
+ * without a focus-out, and its own keep-unfocused declaration; the browser sees
282
+ * none of that). Sent only to a server that declared `serverFocus`.
283
+ */
284
+ | { type: "focus"; focused: boolean }
285
+ /**
286
+ * Best-effort input the server does not count against the resume ledger:
287
+ * mouse reports, whose value expires because they describe a screen that may
288
+ * since have been repainted. Carried as a JSON string, so an ESC is \u001b.
289
+ * Sent only to a server that declared `ephemeralInput`.
290
+ */
291
+ | { type: "ephemeralInput"; data: string }
261
292
  /**
262
293
  * v4 typed-framing transition (see WIRE_PROTOCOL_VERSION in
263
294
  * wire-compatibility.ts): the first
@@ -144,7 +144,8 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
144
144
  // >= 17 bytes: + serverEpoch (restart detection)
145
145
  // >= 33 bytes: + committed + oldestIndex (resume gap detection)
146
146
  // >= 35 bytes: + serverWireVersion + ackFlags (bit0 = ledgerLost,
147
- // bit1 = historyPaging). Unknown bits are IGNORED, which
147
+ // bit1 = historyPaging, bit2 = serverFocus,
148
+ // bit3 = ephemeralInput). Unknown bits are IGNORED, which
148
149
  // is what lets the server add capabilities here without a
149
150
  // protocol bump.
150
151
  let serverEpoch: number | undefined;
@@ -153,6 +154,8 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
153
154
  let serverWireVersion: number | undefined;
154
155
  let ledgerLost: boolean | undefined;
155
156
  let historyPaging: boolean | undefined;
157
+ let serverFocus: boolean | undefined;
158
+ let ephemeralInput: boolean | undefined;
156
159
  if (buf.byteLength >= 17) {
157
160
  serverEpoch = c.u64();
158
161
  }
@@ -165,6 +168,8 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
165
168
  const ackFlags = c.u8();
166
169
  ledgerLost = (ackFlags & 1) !== 0;
167
170
  historyPaging = (ackFlags & 2) !== 0;
171
+ serverFocus = (ackFlags & 4) !== 0;
172
+ ephemeralInput = (ackFlags & 8) !== 0;
168
173
  }
169
174
  const msg: ResumeAckMessage = { type: "resumeAck", received: inputAck };
170
175
  if (serverEpoch !== undefined) {
@@ -185,6 +190,12 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
185
190
  if (historyPaging !== undefined) {
186
191
  msg.historyPaging = historyPaging;
187
192
  }
193
+ if (serverFocus !== undefined) {
194
+ msg.serverFocus = serverFocus;
195
+ }
196
+ if (ephemeralInput !== undefined) {
197
+ msg.ephemeralInput = ephemeralInput;
198
+ }
188
199
  return msg;
189
200
  }
190
201
  if (msgType === MSG_ACK_ONLY) {