@cplieger/web-terminal-engine 1.3.0 → 1.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/README.md CHANGED
@@ -77,10 +77,10 @@ document.addEventListener("keydown", (ev) => {
77
77
 
78
78
  ## API
79
79
 
80
- - **`render`** — DOM renderer driven by `ScreenMessage` / `ScrollMessage` frames. `init`, `handleScreen`, `handleScroll`, `updateFontMetrics`, `computeSize`, `getCursorPx`, `setPredictedCursor`, `resetScreen`, `resetScrollback`, `getScrollbackRowCount`, `updateReverseVideo`.
80
+ - **`render`** — DOM renderer driven by `ScreenMessage` / `ScrollMessage` frames. `init`, `handleScreen`, `handleScroll`, `updateFontMetrics`, `computeSize`, `getCursorPx`, `setPredictedCursor`, `resetScreen`, `resetScrollback`, `getHighestIndex`, `noteResumeBounds`, `updateReverseVideo`.
81
81
  - **`keyboard`** — Translates `KeyboardEvent` to terminal byte sequences. `mapKeyboardEvent`, `bracketTextForPaste`, `prepareTextForTerminal`, `ctrlByteFor`. Honors `applicationCursor`, `applicationKeypad`, `bracketedPaste`. For touch / mobile UIs, `bindMobileToolbar({toolbar, send, ids?})` wires `pointerdown` handlers for an on-screen Ctrl/arrows/Tab/Enter/Esc toolbar (with sticky-Ctrl semantics and DECCKM-aware arrows), returning a `MobileToolbarController` exposing `applyStickyCtrl`, `setCtrlArmed`, `isCtrlArmed`, and `dispose`.
82
82
  - **`mouse`** — SGR 1006 mouse + focus reporting encoder. `init`, `encodeSGR`, `MouseInputHandler`. Auto-gates on `mouseMode > 0`.
83
- - **`scroll`** — Auto-follow tracker for the scroll container. `init`, `scrollToBottom`, `suppressScroll`, `isUserScrolledUp`, `isInUserScroll`.
83
+ - **`scroll`** — Auto-follow tracker for the scroll container. `init`, `stickToBottom`, `scrollToBottom`, `isUserScrolledUp`.
84
84
  - **`modes`** — DEC private mode state (synced from server's `ModesMessage`). `setModes`, `isBracketedPaste`, `isApplicationCursor`, `getMouseMode`, `isMouseSGR`, `isFocusReporting`, `isApplicationKeypad`, `isReverseVideo`.
85
85
  - **`decodeWireBinary(buf)`** — Top-level decoder for the binary WebSocket frames. Returns a `ServerMessage` or `null` for invalid/truncated frames.
86
86
  - **`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`, a `computeSize()` provider, and an optional `wsPath` (defaults to `"/ws"`). It decodes frames internally and applies `modes.setModes` for you, so a consumer only needs to dispatch screen/scroll to `render`. Prefer this over wiring `WebSocket` + `decodeWireBinary` by hand unless you need full control.
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,21 +1,26 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsgo -p tsconfig.json",
7
7
  "typecheck:tests": "tsgo -p tsconfig.test.json",
8
- "test": "vitest --run"
8
+ "test": "vitest --run",
9
+ "test:e2e": "playwright test"
9
10
  },
10
11
  "devDependencies": {
11
12
  "@eslint/js": "10.0.1",
13
+ "@playwright/test": "1.61.1",
14
+ "@types/pngjs": "6.0.5",
12
15
  "@vitest/coverage-v8": "4.1.9",
16
+ "esbuild": "0.28.1",
13
17
  "eslint": "10.6.0",
14
18
  "fast-check": "4.8.0",
15
19
  "happy-dom": "20.10.6",
16
- "prettier": "3.8.5",
20
+ "pngjs": "7.0.0",
21
+ "prettier": "3.9.3",
17
22
  "typescript": "6.0.3",
18
- "typescript-eslint": "8.62.0",
23
+ "typescript-eslint": "8.62.1",
19
24
  "vitest": "4.1.9"
20
25
  },
21
26
  "description": "Browser terminal engine: VT500 screen buffer, DOM renderer, and binary wire protocol",
@@ -35,7 +40,7 @@
35
40
  "files": [
36
41
  "src/",
37
42
  "!src/**/*.test.ts",
38
- "!src/**/__test-helpers__",
43
+ "!src/**/test-helpers",
39
44
  "!src/**/fc-strict-setup.ts",
40
45
  "jsr.json"
41
46
  ]
package/src/connection.ts CHANGED
@@ -373,6 +373,14 @@ export function connect(): void {
373
373
  /* ignore */
374
374
  }
375
375
  }
376
+ // Re-entry while a backoff reconnect is pending (e.g. a consumer calling
377
+ // connect() to restore a panel during the 500ms-8s backoff window): clear the
378
+ // scheduled timer so it cannot fire later and spawn a SECOND socket alongside
379
+ // the one created below. The orphaned timer resets connState to disconnected
380
+ // and calls connect() again, while the existing socket's listeners stay bound
381
+ // (its abort never fired) -> a duplicate server connection + double delivery.
382
+ // cancelScheduledReconnect is a no-op in any non-reconnecting state.
383
+ cancelScheduledReconnect();
376
384
 
377
385
  cb?.onConnecting?.();
378
386
 
@@ -538,6 +546,7 @@ export function connect(): void {
538
546
  msg.mouseMode,
539
547
  msg.applicationKeypad,
540
548
  msg.reverseVideo,
549
+ msg.mousePixels,
541
550
  );
542
551
  if (typeof msg.inputAck === "number") {
543
552
  applyAck(msg.inputAck);
package/src/modes.ts CHANGED
@@ -20,6 +20,7 @@ let applicationCursor = false;
20
20
  let applicationKeypad = false;
21
21
  let mouseMode = 0; // 0=off, 1000=normal, 1002=button-event, 1003=any-event
22
22
  let mouseSGR = false;
23
+ let mousePixels = false; // DEC 1016: report pixel coords instead of cell coords
23
24
  let focusReporting = false;
24
25
  let reverseVideo = false;
25
26
 
@@ -36,6 +37,7 @@ export function setModes(
36
37
  mMode?: number,
37
38
  appKeypad?: boolean,
38
39
  revVideo?: boolean,
40
+ mPixels?: boolean,
39
41
  ): void {
40
42
  bracketedPaste = bracketed;
41
43
  applicationCursor = appCursor;
@@ -54,6 +56,9 @@ export function setModes(
54
56
  if (revVideo !== undefined) {
55
57
  reverseVideo = revVideo;
56
58
  }
59
+ if (mPixels !== undefined) {
60
+ mousePixels = mPixels;
61
+ }
57
62
  }
58
63
 
59
64
  /** True when the server has DEC 2004 (bracketed paste) enabled. */
@@ -79,6 +84,12 @@ export function isMouseSGR(): boolean {
79
84
  return mouseSGR;
80
85
  }
81
86
 
87
+ /** True when the server has DEC 1016 (SGR-pixels mouse) enabled: mouse reports
88
+ * carry pixel coordinates instead of cell coordinates. */
89
+ export function isMousePixels(): boolean {
90
+ return mousePixels;
91
+ }
92
+
82
93
  /** True when the server has DEC 1004 (focus event reporting) enabled. */
83
94
  export function isFocusReporting(): boolean {
84
95
  return focusReporting;
package/src/mouse.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // - urxvt encoding (mode 1015): not supported.
15
15
  // - DEFAULT encoding (raw bytes): not supported; only SGR 1006.
16
16
 
17
- import { getMouseMode, isMouseSGR, isFocusReporting } from "./modes.js";
17
+ import { getMouseMode, isMouseSGR, isMousePixels, isFocusReporting } from "./modes.js";
18
18
 
19
19
  const ESC = "\x1b";
20
20
 
@@ -59,12 +59,14 @@ export function encodeSGR(code: number, col: number, row: number, release: boole
59
59
 
60
60
  /** Map DOM button index to xterm button number. */
61
61
  function domButtonToXterm(button: number): number {
62
- // DOM: 0=left, 1=middle, 2=right
63
- // xterm: 0=left, 1=middle, 2=right
62
+ // DOM MouseEvent.button: 0=left, 1=middle, 2=right, 3=back, 4=forward.
63
+ // xterm: 0/1/2 for left/middle/right; the "additional buttons" (X11 8-11,
64
+ // reached via DOM back/forward) use the +128 extended-button encoding, so
65
+ // back (DOM 3 = X11 8) → 128 and forward (DOM 4 = X11 9) → 129.
64
66
  if (button <= 2) {
65
67
  return button;
66
68
  }
67
- return 0; // fallback
69
+ return 128 + (button - 3);
68
70
  }
69
71
 
70
72
  /**
@@ -100,12 +102,25 @@ export function init(h: MouseInputHandler): void {
100
102
  el.addEventListener("focusout", onFocusOut);
101
103
  }
102
104
 
105
+ // pixelToCell returns the coordinate pair to report for a mouse event. Normally
106
+ // that's the 1-based cell column/row; under DEC 1016 (SGR-pixels) it's instead
107
+ // the 1-based pixel offset within the terminal element (same SGR grammar, so
108
+ // encodeSGR is reused unchanged). The {col,row} field names carry whichever the
109
+ // active mode reports.
103
110
  function pixelToCell(e: MouseEvent): { col: number; row: number } | null {
104
111
  if (!handler) {
105
112
  return null;
106
113
  }
107
114
  const el = handler.termElement();
108
115
  const rect = el.getBoundingClientRect();
116
+ if (isMousePixels()) {
117
+ const px = Math.round(e.clientX - rect.left);
118
+ const py = Math.round(e.clientY - rect.top);
119
+ if (px < 0 || py < 0) {
120
+ return null;
121
+ }
122
+ return { col: px + 1, row: py + 1 }; // 1-based pixel offsets
123
+ }
109
124
  const { width, height } = handler.cellSize();
110
125
  if (width <= 0 || height <= 0) {
111
126
  return null;
@@ -120,7 +135,7 @@ function pixelToCell(e: MouseEvent): { col: number; row: number } | null {
120
135
 
121
136
  function onMouseDown(e: MouseEvent): void {
122
137
  const mode = getMouseMode();
123
- if (mode === 0 || !isMouseSGR() || !handler) {
138
+ if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
124
139
  return;
125
140
  }
126
141
  const pos = pixelToCell(e);
@@ -135,7 +150,7 @@ function onMouseDown(e: MouseEvent): void {
135
150
 
136
151
  function onMouseUp(e: MouseEvent): void {
137
152
  const mode = getMouseMode();
138
- if (mode === 0 || !isMouseSGR() || !handler) {
153
+ if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
139
154
  return;
140
155
  }
141
156
  const pos = pixelToCell(e);
@@ -151,7 +166,7 @@ function onMouseUp(e: MouseEvent): void {
151
166
 
152
167
  function onMouseMove(e: MouseEvent): void {
153
168
  const mode = getMouseMode();
154
- if (mode === 0 || !isMouseSGR() || !handler) {
169
+ if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
155
170
  return;
156
171
  }
157
172
  // mode 1000: no motion events
@@ -167,14 +182,16 @@ function onMouseMove(e: MouseEvent): void {
167
182
  if (!pos) {
168
183
  return;
169
184
  }
170
- const btn = e.buttons ? (e.buttons & 1 ? 0 : e.buttons & 4 ? 1 : e.buttons & 2 ? 2 : 0) : 0;
185
+ // No button held (bare motion in mode 1003) reports xterm's "no button"
186
+ // code 3, not 0 — code 0 would be read as a left-button drag during hover.
187
+ const btn = e.buttons ? (e.buttons & 1 ? 0 : e.buttons & 4 ? 1 : e.buttons & 2 ? 2 : 0) : 3;
171
188
  const code = buttonCode(btn, true, e.shiftKey, e.altKey, e.ctrlKey);
172
189
  handler.send(encodeSGR(code, pos.col, pos.row, false));
173
190
  }
174
191
 
175
192
  function onWheel(e: WheelEvent): void {
176
193
  const mode = getMouseMode();
177
- if (mode === 0 || !isMouseSGR() || !handler) {
194
+ if (mode === 0 || !(isMouseSGR() || isMousePixels()) || !handler) {
178
195
  return;
179
196
  }
180
197
  const pos = pixelToCell(e);
package/src/render.ts CHANGED
@@ -137,6 +137,9 @@ let cursorAbs = -1; // absolute index of the row the cursor is on
137
137
  let cursorCol = 0;
138
138
  let cursorHidden = false;
139
139
  let cursorStyleVal = 0; // 0-6: DECSCUSR
140
+ let prevCursorCol = -1;
141
+ let prevCursorHidden = false;
142
+ let prevCursorStyleVal = -1;
140
143
 
141
144
  function cursorClassName(): string {
142
145
  // DECSCUSR: 0/1=blinking block, 2=steady block, 3=blinking underline,
@@ -405,6 +408,12 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
405
408
  prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
406
409
  }
407
410
  }
411
+ // The spacer occupies the wide char's second cell, so it advances the
412
+ // column exactly like a printed cell. The engine reports cursor_col in
413
+ // true cell coordinates (a wide glyph moves curX by 2), so col must
414
+ // count this cell too — otherwise a visible cursor positioned after a
415
+ // wide char lands one cell too far right per preceding wide char.
416
+ col++;
408
417
  continue;
409
418
  }
410
419
  if (col === cursorAt) {
@@ -463,7 +472,7 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
463
472
  }
464
473
  if (out.length === 0) {
465
474
  const span = document.createElement("span");
466
- span.innerHTML = "&nbsp;";
475
+ span.textContent = "\u00a0";
467
476
  out.push(span);
468
477
  }
469
478
  return linkifySpans(out);
@@ -561,15 +570,28 @@ function flushRenderInner(): void {
561
570
  renderQueue.add(abs);
562
571
  }
563
572
 
564
- // The cursor rows are built every frame regardless of the budget so the
565
- // inline cursor span is always current (it moves off the old row and onto
566
- // the new one); a huge backlog must never make the caret lag.
573
+ // The cursor rows are built regardless of the budget so the inline cursor span
574
+ // is always current; a huge backlog must never make the caret lag. But skip
575
+ // rebuilding the current cursor row when neither the cursor's visual state nor
576
+ // the row content changed: an unconditional replaceChildren() would discard a
577
+ // text selection on that row, defeating the byte-identical selection-preserving
578
+ // guarantee the store idempotency gives every other row.
567
579
  if (prevCursorAbs !== newCursorAbs && prevCursorAbs >= 0) {
568
580
  upsertRow(prevCursorAbs);
569
581
  renderQueue.delete(prevCursorAbs);
570
582
  }
571
- upsertRow(newCursorAbs);
583
+ const cursorVisualChanged =
584
+ prevCursorAbs !== newCursorAbs ||
585
+ cursorCol !== prevCursorCol ||
586
+ cursorHidden !== prevCursorHidden ||
587
+ cursorStyleVal !== prevCursorStyleVal;
588
+ if (cursorVisualChanged || renderQueue.has(newCursorAbs)) {
589
+ upsertRow(newCursorAbs);
590
+ }
572
591
  renderQueue.delete(newCursorAbs);
592
+ prevCursorCol = cursorCol;
593
+ prevCursorHidden = cursorHidden;
594
+ prevCursorStyleVal = cursorStyleVal;
573
595
 
574
596
  // Drain up to MAX_ROWS_PER_FRAME queued rows this frame; the rest carry over
575
597
  // to the next frame (scheduled below) so one big burst never blocks paint.
package/src/store.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import type { ScreenMessage, ScrollMessage, WireRun } from "./types.js";
16
16
 
17
17
  /** Maximum lines retained client-side. Older lines are evicted from the top. */
18
- export const MAX_LINES = 5000;
18
+ const MAX_LINES = 5000;
19
19
 
20
20
  /** The live screen window: a fixed `height`-row block at the tail of the buffer. */
21
21
  export interface WindowState {
@@ -183,6 +183,12 @@ export class LineStore {
183
183
  return;
184
184
  }
185
185
  this.exitAltIfNeeded();
186
+ if (msg.scrollbackCleared) {
187
+ // ED3 (erase scrollback): the app cleared its saved lines (kiro-cli does
188
+ // this on every resize redraw). Drop our matching history below the live
189
+ // window so the previous frame doesn't accumulate beneath the new one.
190
+ this.dropHistoryBelow(msg.base);
191
+ }
186
192
  this.updateWindow(msg);
187
193
  for (const y of msg.changed) {
188
194
  const row = msg.rows[y];
@@ -190,6 +196,7 @@ export class LineStore {
190
196
  this.applyLine(msg.base + y, row);
191
197
  }
192
198
  }
199
+ this.truncateBelowWindow();
193
200
  }
194
201
 
195
202
  /** Apply a decoded scroll/history frame: each line at firstIndex + i. */
@@ -297,6 +304,70 @@ export class LineStore {
297
304
  this.enforceCap();
298
305
  }
299
306
 
307
+ // dropHistoryBelow evicts all retained lines with an absolute index below
308
+ // `base` — the scrollback history under the live window. Called when the
309
+ // server signals ED3 (erase scrollback): an inline TUI (kiro-cli) clears its
310
+ // saved lines on every resize redraw, so the client drops its matching
311
+ // history to stop the previous frame accumulating beneath the new one. Window
312
+ // rows (>= base) are kept and refreshed by the frame carrying the signal.
313
+ // everEvictedThrough is left untouched: the app discarded the lines
314
+ // deliberately (not a cap trim), so no "earlier output trimmed" marker fits.
315
+ private dropHistoryBelow(base: number): void {
316
+ if (this.oldest < 0 || this.oldest >= base) {
317
+ return;
318
+ }
319
+ for (let abs = this.oldest; abs < base; abs++) {
320
+ if (this.lines.delete(abs)) {
321
+ this.evicted.add(abs);
322
+ this.dirty.delete(abs);
323
+ }
324
+ }
325
+ if (base > this.highest) {
326
+ this.oldest = -1;
327
+ this.highest = -1;
328
+ } else {
329
+ this.oldest = base;
330
+ }
331
+ }
332
+
333
+ // truncateBelowWindow evicts every retained line past the window's bottom row.
334
+ // The window's bottom row is the most recent line in the terminal, so no line
335
+ // can exist at a higher absolute index. A resize that SHRINKS the screen (the
336
+ // iOS soft keyboard opening) moves the window bottom up while the taller
337
+ // screen's former bottom rows stay in the store at higher indices — stranded
338
+ // below the live window. Cap eviction only trims the top/oldest, so nothing
339
+ // removes them: they linger as phantom blank rows beneath the real content, an
340
+ // "empty" region the user can scroll into and the reason the content never
341
+ // appears to shrink to fit on a short viewport. Evicting them keeps the tail
342
+ // invariant (highest === window bottom); it is the tail-side complement to the
343
+ // top-side cap eviction.
344
+ private truncateBelowWindow(): void {
345
+ if (this.highest < 0) {
346
+ return;
347
+ }
348
+ const windowBottom = this.win.base + this.win.height - 1;
349
+ if (this.highest <= windowBottom) {
350
+ return;
351
+ }
352
+ for (let abs = windowBottom + 1; abs <= this.highest; abs++) {
353
+ if (this.lines.delete(abs)) {
354
+ this.evicted.add(abs);
355
+ this.dirty.delete(abs);
356
+ }
357
+ }
358
+ // New highest is the greatest retained index at or below the window bottom.
359
+ let h = windowBottom;
360
+ while (h >= this.oldest && !this.lines.has(h)) {
361
+ h--;
362
+ }
363
+ if (h >= this.oldest) {
364
+ this.highest = h;
365
+ } else {
366
+ this.highest = -1;
367
+ this.oldest = -1;
368
+ }
369
+ }
370
+
300
371
  private enforceCap(): void {
301
372
  while (this.lines.size > this.maxLines && this.oldest >= 0 && this.oldest < this.highest) {
302
373
  const victim = this.oldest;
@@ -305,12 +376,25 @@ export class LineStore {
305
376
  this.evicted.add(victim);
306
377
  this.dirty.delete(victim);
307
378
  }
308
- // Advance oldest past the evicted line and any hole.
309
- let next = victim + 1;
310
- while (next <= this.highest && !this.lines.has(next)) {
311
- next++;
379
+ // Advance oldest to the lowest remaining key. After evicting the current
380
+ // oldest, contiguous history (the common case: live scroll-off, resume
381
+ // replay, a cat-bigfile burst) leaves victim + 1 present -- an O(1) probe.
382
+ // Only a hole at the boundary falls back to a full key scan. That scan is
383
+ // bounded by map size (<= maxLines), NOT by the integer gap to the next
384
+ // retained line, so a malformed/compromised frame whose base jumps far
385
+ // ahead of a retained low index cannot make eviction walk billions of
386
+ // indices (an algorithmic-complexity DoS that freezes the tab).
387
+ if (this.lines.has(victim + 1)) {
388
+ this.oldest = victim + 1;
389
+ } else {
390
+ let min = -1;
391
+ for (const k of this.lines.keys()) {
392
+ if (min < 0 || k < min) {
393
+ min = k;
394
+ }
395
+ }
396
+ this.oldest = min;
312
397
  }
313
- this.oldest = next > this.highest && !this.lines.has(this.highest) ? -1 : next;
314
398
  if (this.lines.size === 0) {
315
399
  this.oldest = -1;
316
400
  this.highest = -1;
package/src/types.ts CHANGED
@@ -59,6 +59,13 @@ export interface ScreenMessage {
59
59
  cursorBlink?: boolean;
60
60
  /** True if the server emitted a BEL since the last screen update. */
61
61
  bell?: boolean;
62
+ /**
63
+ * True when the app issued ED3 (`CSI 3 J`, "erase saved lines") this frame:
64
+ * the client drops its scrollback history (all lines below `base`), matching
65
+ * a real terminal. Inline TUIs (kiro-cli) emit ED3 on every resize redraw to
66
+ * discard the previous frame; honoring it stops stale frames accumulating.
67
+ */
68
+ scrollbackCleared?: boolean;
62
69
  /** Server-confirmed bytesReceived for the input ACK protocol. */
63
70
  inputAck?: number;
64
71
  }
@@ -122,6 +129,8 @@ export interface ModesMessage {
122
129
  focusReporting: boolean;
123
130
  /** DEC 5: screen is in reverse-video mode. */
124
131
  reverseVideo: boolean;
132
+ /** DEC 1016: mouse reports carry pixel coordinates instead of cell coords. */
133
+ mousePixels: boolean;
125
134
  /** Mouse tracking: 0=off, 1000=normal, 1002=button-event, 1003=any-event. */
126
135
  mouseMode: number;
127
136
  /** Server-confirmed bytesReceived for the input ACK protocol. */
@@ -141,13 +150,23 @@ export interface TitleMessage {
141
150
  inputAck?: number;
142
151
  }
143
152
 
153
+ /**
154
+ * Clipboard text an app copied via OSC 52 (`OSC 52 ; c ; <base64> ST`). The
155
+ * client writes it to the system clipboard. Set-only: the query form is
156
+ * refused server-side, so this message never carries a read request.
157
+ */
158
+ export interface ClipboardMessage {
159
+ /** Discriminator — always `"clipboard"`. */
160
+ type: "clipboard";
161
+ /** Text to place on the system clipboard. */
162
+ text: string;
163
+ /** Server-confirmed bytesReceived for the input ACK protocol. */
164
+ inputAck?: number;
165
+ }
166
+
144
167
  /** Discriminated union of all messages the server can send to the client. */
145
168
  export type ServerMessage =
146
- | ScreenMessage
147
- | ScrollMessage
148
- | ResumeAckMessage
149
- | ModesMessage
150
- | TitleMessage;
169
+ ScreenMessage | ScrollMessage | ResumeAckMessage | ModesMessage | TitleMessage | ClipboardMessage;
151
170
 
152
171
  /**
153
172
  * Discriminated union of all control messages the client can send to the
@@ -10,6 +10,7 @@ import type {
10
10
  ResumeAckMessage,
11
11
  ModesMessage,
12
12
  TitleMessage,
13
+ ClipboardMessage,
13
14
  ServerMessage,
14
15
  } from "./types.js";
15
16
 
@@ -20,6 +21,7 @@ const MSG_RESUME_ACK = 2;
20
21
  const MSG_MODES = 3;
21
22
  const MSG_TITLE = 4;
22
23
  const MSG_PONG = 5;
24
+ const MSG_CLIPBOARD = 6;
23
25
 
24
26
  /**
25
27
  * Wire protocol version. Sent by the client in the `resume` control message so
@@ -35,6 +37,7 @@ const MODE_FLAG_MOUSE_SGR = 4;
35
37
  const MODE_FLAG_FOCUS_REPORTING = 8;
36
38
  const MODE_FLAG_APP_KEYPAD = 16;
37
39
  const MODE_FLAG_REVERSE_VIDEO = 32;
40
+ const MODE_FLAG_MOUSE_PIXELS = 64;
38
41
 
39
42
  class Cursor {
40
43
  view: DataView;
@@ -60,10 +63,16 @@ class Cursor {
60
63
  return v;
61
64
  }
62
65
  u64(): number {
63
- // Number(v) is safe here: all values this protocol encodes in u64
64
- // fields (bytesReceived, serverEpoch) fit within JavaScript's 53-bit
65
- // integer precision. If the protocol ever needs true 64-bit values,
66
- // this must return BigInt instead.
66
+ // Number(v) loses precision above 2^53. That is safe for every u64 this
67
+ // protocol decodes because of how each value is USED, not because they all fit:
68
+ // - base / firstIndex / committed / oldestIndex / inputAck (bytesReceived)
69
+ // stay well under 2^53 for any realistic session, so arithmetic is exact.
70
+ // - serverEpoch is the server's time.Now().UnixNano() (~1.77e18, far ABOVE
71
+ // 2^53), so it DOES drop low bits here. That is harmless: it is only ever
72
+ // equality-compared to detect a restart, and Number() is deterministic, so
73
+ // one epoch always maps to one double and two distinct boots differ by far
74
+ // more than the ~256ns rounding granularity at that magnitude.
75
+ // If a future u64 field needs exact values above 2^53, return BigInt instead.
67
76
  const v = this.view.getBigUint64(this.off, true);
68
77
  this.off += 8;
69
78
  return Number(v);
@@ -171,6 +180,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
171
180
  const bell = (cursorFlags & 2) !== 0;
172
181
  const cursorBlink = (cursorFlags & 4) !== 0;
173
182
  const altActive = (cursorFlags & 8) !== 0;
183
+ const scrollbackCleared = (cursorFlags & 16) !== 0;
174
184
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pre-allocated array filled below
175
185
  const rows: WireRun[][] = new Array(screenHeight);
176
186
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pre-allocated array filled below
@@ -191,6 +201,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
191
201
  cursorHidden,
192
202
  cursorBlink,
193
203
  bell,
204
+ scrollbackCleared,
194
205
  inputAck,
195
206
  };
196
207
  return msg;
@@ -217,6 +228,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
217
228
  mouseSGR: (flags & MODE_FLAG_MOUSE_SGR) !== 0,
218
229
  focusReporting: (flags & MODE_FLAG_FOCUS_REPORTING) !== 0,
219
230
  reverseVideo: (flags & MODE_FLAG_REVERSE_VIDEO) !== 0,
231
+ mousePixels: (flags & MODE_FLAG_MOUSE_PIXELS) !== 0,
220
232
  mouseMode,
221
233
  inputAck,
222
234
  };
@@ -228,6 +240,12 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
228
240
  const msg: TitleMessage = { type: "title", title, inputAck };
229
241
  return msg;
230
242
  }
243
+ if (msgType === MSG_CLIPBOARD) {
244
+ const textLen = c.u16();
245
+ const text = c.utf8(textLen);
246
+ const msg: ClipboardMessage = { type: "clipboard", text, inputAck };
247
+ return msg;
248
+ }
231
249
  if (msgType === MSG_PONG) {
232
250
  // Liveness pong: no payload to deliver. The message listener already
233
251
  // refreshed the activity clock on arrival, so there is nothing to do