@cplieger/web-terminal-engine 1.3.2 → 2.0.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": "1.3.2",
3
+ "version": "2.0.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.2",
3
+ "version": "2.0.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.4",
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
@@ -40,20 +40,58 @@ let lastSentCols = 0;
40
40
  let lastSentRows = 0;
41
41
  let wsPath = "/ws";
42
42
 
43
- // Resume-protocol state. sessionId persists across iOS tab-suspend/reload
44
- // via sessionStorage. Without this persistence, an iOS Safari tab unload
45
- // (which Safari does aggressively when the user backgrounds the tab) and
46
- // subsequent reconstruction from history triggers a fresh JS module load
47
- // → new sessionId → server treats it as a new session → resumeAck.received
48
- // returns 0 → applyAck(0) doesn't trim the outbox → retransmitOutbox sends
49
- // every queued chunk again, causing the duplicate-message-resend bug.
43
+ // --- Per-session resume state (the switching cache's connection half) ---
44
+ //
45
+ // Each server session carries its own reliable-input accounting: an outbox of
46
+ // unacked bytes, byte counters, and the last server boot-epoch it saw. Scoping
47
+ // this per session (rather than one module-global set) is what lets a tab switch
48
+ // reconnect to a different session without replaying the previous tab's unacked
49
+ // bytes onto it and without firing a false server-restart reset, because each
50
+ // session's epoch is compared only against its own bootEpoch (design section 8).
51
+ interface ResumeState {
52
+ id: string; // server session id: both the routing id (?session=) and the resume id
53
+ bytesSent: number; // total bytes ever passed to sendBinary for this session
54
+ bytesAcked: number; // confirmed by server inputAck/resumeAck
55
+ outbox: Uint8Array[]; // unacked chunks (sum of lengths = bytesSent - bytesAcked)
56
+ outboxBytes: number; // running sum of outbox chunk lengths; keeps applyAck O(n) not O(n²)
57
+ lastServerEpoch: number | null; // process-start nanos last seen for this session
58
+ }
59
+
60
+ const sessions = new Map<string, ResumeState>();
61
+ // The session the live socket currently serves. null until the first connect or
62
+ // setSession; the unmanaged single-terminal path lazily creates a default
63
+ // session with a sessionStorage-backed id.
64
+ let activeId: string | null = null;
65
+ // managed = a consumer selected sessions explicitly via setSession, so the WS URL
66
+ // carries ?session=<id>. Unmanaged keeps the bare wsPath and a sessionStorage id,
67
+ // preserving the original single-terminal behavior and its iOS-resume semantics
68
+ // (sessionStorage survives iOS tab-suspend/BFCache, so an unmanaged reload
69
+ // resumes rather than orphaning its outbox). A tabbed shell is managed; it
70
+ // rebuilds tabs from GET /api/sessions on reload (section 17), so it needs no
71
+ // client-side id persistence.
72
+ let managed = false;
73
+
50
74
  const SESSION_ID_KEY = "vterm-session-id";
51
- const sessionId = loadOrCreateSessionId();
52
- let bytesSent = 0; // total bytes ever passed to sendBinary
53
- let bytesAcked = 0; // confirmed by server inputAck/resumeAck
54
- const outbox: Uint8Array[] = []; // chunks of unacked bytes (sum = bytesSent - bytesAcked)
55
- let outboxBytes = 0; // running sum of outbox chunk lengths; keeps applyAck O(n) instead of O(n²)
56
- let lastServerEpoch: number | null = null; // process-start nanos of the last connected server
75
+
76
+ function newResumeState(id: string): ResumeState {
77
+ return { id, bytesSent: 0, bytesAcked: 0, outbox: [], outboxBytes: 0, lastServerEpoch: null };
78
+ }
79
+
80
+ function ensureState(id: string): ResumeState {
81
+ let s = sessions.get(id);
82
+ if (s === undefined) {
83
+ s = newResumeState(id);
84
+ sessions.set(id, s);
85
+ }
86
+ return s;
87
+ }
88
+
89
+ // activeState returns the ResumeState the live socket serves, lazily creating
90
+ // the default (unmanaged) session from a sessionStorage-backed id on first use.
91
+ function activeState(): ResumeState {
92
+ activeId ??= loadOrCreateSessionId();
93
+ return ensureState(activeId);
94
+ }
57
95
 
58
96
  // --- Client-side liveness (bug 2 defense-in-depth) ---
59
97
  //
@@ -181,17 +219,18 @@ export function init(callbacks: Callbacks): void {
181
219
  * buffer reuse.
182
220
  */
183
221
  export function sendBinary(data: Uint8Array): boolean {
184
- if (outboxBytes + data.length > MAX_OUTBOX_BYTES) {
222
+ const st = activeState();
223
+ if (st.outboxBytes + data.length > MAX_OUTBOX_BYTES) {
185
224
  cb?.onOutboxFull?.();
186
225
  return false;
187
226
  }
188
- // Always go through the outbox. Bytes leave it only when the server
189
- // explicitly acks them — guarantees correct retransmission after a
227
+ // Always go through the active session's outbox. Bytes leave it only when the
228
+ // server explicitly acks them — guarantees correct retransmission after a
190
229
  // network blip even if ws.send() reported success.
191
230
  const copy = new Uint8Array(data); // defensive copy (caller may reuse buffer)
192
- outbox.push(copy);
193
- outboxBytes += copy.length;
194
- bytesSent += copy.length;
231
+ st.outbox.push(copy);
232
+ st.outboxBytes += copy.length;
233
+ st.bytesSent += copy.length;
195
234
  if (connState.status === "connected") {
196
235
  connState.sock.send(copy.buffer.slice(copy.byteOffset, copy.byteOffset + copy.byteLength));
197
236
  }
@@ -218,40 +257,37 @@ export function sendResize(): void {
218
257
  sendControl({ type: "resize", cols, rows });
219
258
  }
220
259
 
221
- // applyAck drops chunks from the front of the outbox until the
222
- // running total of unacked bytes matches (bytesSent - newAck). Runs
223
- // in O(chunks_dropped) by tracking outboxBytes incrementally rather
224
- // than re-summing on every loop iteration.
225
- function applyAck(received: number): void {
226
- if (received <= bytesAcked) {
260
+ // applyAck drops chunks from the front of the session's outbox until the
261
+ // running total of unacked bytes matches (bytesSent - newAck). Runs in
262
+ // O(chunks_dropped) by tracking outboxBytes incrementally rather than
263
+ // re-summing on every loop iteration.
264
+ function applyAck(st: ResumeState, received: number): void {
265
+ if (received <= st.bytesAcked) {
227
266
  return;
228
267
  }
229
- bytesAcked = Math.min(received, bytesSent);
230
- const targetUnacked = bytesSent - bytesAcked;
231
- while (outbox.length > 0 && outboxBytes > targetUnacked) {
268
+ st.bytesAcked = Math.min(received, st.bytesSent);
269
+ const targetUnacked = st.bytesSent - st.bytesAcked;
270
+ while (st.outbox.length > 0 && st.outboxBytes > targetUnacked) {
232
271
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length checked above
233
- const head = outbox[0]!;
234
- const dropFromHead = outboxBytes - targetUnacked;
272
+ const head = st.outbox[0]!;
273
+ const dropFromHead = st.outboxBytes - targetUnacked;
235
274
  if (head.length <= dropFromHead) {
236
- outbox.shift();
237
- outboxBytes -= head.length;
275
+ st.outbox.shift();
276
+ st.outboxBytes -= head.length;
238
277
  } else {
239
- outbox[0] = head.subarray(dropFromHead);
240
- outboxBytes -= dropFromHead;
278
+ st.outbox[0] = head.subarray(dropFromHead);
279
+ st.outboxBytes -= dropFromHead;
241
280
  break;
242
281
  }
243
282
  }
244
283
  }
245
284
 
246
- // On reconnect, after sending the resume control message and getting
247
- // resumeAck, replay anything still in the outbox. The server has
248
- // adjusted bytesAcked already — only unacked bytes remain.
249
- function retransmitOutbox(): void {
250
- if (connState.status !== "connected") {
251
- return;
252
- }
253
- for (const chunk of outbox) {
254
- connState.sock.send(
285
+ // On reconnect, after sending the resume control message and getting resumeAck,
286
+ // replay anything still in the session's outbox over its (now open) socket. The
287
+ // server has adjusted bytesAcked already — only unacked bytes remain.
288
+ function retransmitOutbox(sock: WebSocket, st: ResumeState): void {
289
+ for (const chunk of st.outbox) {
290
+ sock.send(
255
291
  chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength) as ArrayBuffer,
256
292
  );
257
293
  }
@@ -330,17 +366,11 @@ function heartbeatTick(): void {
330
366
  }
331
367
  }
332
368
 
333
- export function reconnectNow(): void {
334
- // Unconditional teardown. On iOS wake (visibilitychange + pageshow), the
335
- // socket frequently reads OPEN/"connected" for a while but is actually a
336
- // zombie — the OS froze it during sleep and frames printed meanwhile never
337
- // arrive. The old early-return on "connected" trusted that stale state and
338
- // skipped the reconnect, which is exactly why content printed during sleep
339
- // stayed missing until a manual refresh (bug 2). So we never trust the
340
- // current state on a wake: abort + close whatever socket exists and
341
- // reconnect. The resume protocol (by absolute index) then backfills exactly
342
- // the missed lines, so a reconnect over a still-healthy socket is a cheap,
343
- // duplicate-free no-op rather than a risk.
369
+ // teardown aborts and closes the live socket (if any) and stops the heartbeat
370
+ // and any scheduled reconnect, leaving the module disconnected. It never touches
371
+ // per-session resume state, so a later connect() resumes cleanly. Shared by
372
+ // reconnectNow (reconnects after) and disconnect/forgetSession (do not).
373
+ function teardown(): void {
344
374
  if (connState.status === "connecting" || connState.status === "connected") {
345
375
  // Abort BEFORE close: aborting detaches all listeners on the existing
346
376
  // sock, so frames arriving between close() and the close handshake aren't
@@ -355,9 +385,64 @@ export function reconnectNow(): void {
355
385
  stopHeartbeat();
356
386
  cancelScheduledReconnect();
357
387
  connState = { status: "disconnected" };
388
+ }
389
+
390
+ export function reconnectNow(): void {
391
+ // Unconditional teardown. On iOS wake (visibilitychange + pageshow), the
392
+ // socket frequently reads OPEN/"connected" for a while but is actually a
393
+ // zombie — the OS froze it during sleep and frames printed meanwhile never
394
+ // arrive. The old early-return on "connected" trusted that stale state and
395
+ // skipped the reconnect, which is exactly why content printed during sleep
396
+ // stayed missing until a manual refresh (bug 2). So we never trust the
397
+ // current state on a wake: abort + close whatever socket exists and
398
+ // reconnect. The resume protocol (by absolute index) then backfills exactly
399
+ // the missed lines, so a reconnect over a still-healthy socket is a cheap,
400
+ // duplicate-free no-op rather than a risk.
401
+ teardown();
358
402
  connect();
359
403
  }
360
404
 
405
+ /**
406
+ * setSession switches the live socket to a different server session, keeping
407
+ * every session's resume state intact (design section 5, the switch). The
408
+ * current socket is torn down (its outbox and byte counters preserved for a
409
+ * later switch back) and a fresh socket connects to `id` with its own resume
410
+ * state, sending `?session=id`. Calling this marks the module "managed": the WS
411
+ * URL then carries the session id. A no-op when `id` is already the active,
412
+ * connected session.
413
+ */
414
+ export function setSession(id: string): void {
415
+ managed = true;
416
+ ensureState(id);
417
+ if (id === activeId && (connState.status === "connected" || connState.status === "connecting")) {
418
+ return; // already serving this session
419
+ }
420
+ activeId = id;
421
+ reconnectNow();
422
+ }
423
+
424
+ /**
425
+ * forgetSession drops a session's resume state (on tab close, design section 17).
426
+ * If it was the active session, the live socket is torn down without
427
+ * reconnecting; the shell then selects another tab via setSession.
428
+ */
429
+ export function forgetSession(id: string): void {
430
+ sessions.delete(id);
431
+ if (id === activeId) {
432
+ activeId = null;
433
+ teardown();
434
+ }
435
+ }
436
+
437
+ /**
438
+ * disconnect tears down the live socket without reconnecting. Per-session resume
439
+ * state is kept, so a later setSession/connect resumes cleanly. Used when the
440
+ * shell has no active tab to show (e.g. the last tab closed).
441
+ */
442
+ export function disconnect(): void {
443
+ teardown();
444
+ }
445
+
361
446
  export function connect(): void {
362
447
  // Guard against double-call: a stray invocation while a previous
363
448
  // socket is still CONNECTING/OPEN would orphan it (its handlers
@@ -384,7 +469,18 @@ export function connect(): void {
384
469
 
385
470
  cb?.onConnecting?.();
386
471
 
387
- const sock = new WebSocket(wsURL(location.protocol, location.host, wsPath));
472
+ // The resume state this socket serves, captured for the socket's lifetime.
473
+ // A switch aborts this socket's listeners, so even a late frame is handled
474
+ // against the session it was opened for, never whoever is active now.
475
+ const st = activeState();
476
+ let url = wsURL(location.protocol, location.host, wsPath);
477
+ if (managed) {
478
+ // Route the socket to this session (SessionManager's WebSocketHandler
479
+ // dispatches on ?session=). The unmanaged single-terminal path keeps the
480
+ // bare wsPath, matching resume purely by the resume frame's sessionId.
481
+ url += (url.includes("?") ? "&" : "?") + "session=" + encodeURIComponent(st.id);
482
+ }
483
+ const sock = new WebSocket(url);
388
484
  sock.binaryType = "arraybuffer";
389
485
 
390
486
  // One AbortController governs the lifetime of THIS sock's listeners.
@@ -427,8 +523,8 @@ export function connect(): void {
427
523
  sock.send(
428
524
  controlFrame({
429
525
  type: "resume",
430
- sessionId,
431
- sentBytes: bytesSent,
526
+ sessionId: st.id,
527
+ sentBytes: st.bytesSent,
432
528
  // Highest absolute line index the client holds (-1 if none). The
433
529
  // server replays everything after it, so lines printed while the
434
530
  // device slept are backfilled exactly on wake (bug 2), with no
@@ -499,14 +595,14 @@ export function connect(): void {
499
595
  // of the previous boot's input). Reset state and notify the UI.
500
596
  const epoch = msg.serverEpoch;
501
597
  if (epoch !== undefined && epoch !== 0) {
502
- if (lastServerEpoch !== null && lastServerEpoch !== epoch) {
503
- bytesSent = 0;
504
- bytesAcked = 0;
505
- outbox.length = 0;
506
- outboxBytes = 0;
598
+ if (st.lastServerEpoch !== null && st.lastServerEpoch !== epoch) {
599
+ st.bytesSent = 0;
600
+ st.bytesAcked = 0;
601
+ st.outbox.length = 0;
602
+ st.outboxBytes = 0;
507
603
  cb?.onServerRestart?.();
508
604
  }
509
- lastServerEpoch = epoch;
605
+ st.lastServerEpoch = epoch;
510
606
  }
511
607
  // Resync guard 8.2.2: hand the server's retained-history bounds to the
512
608
  // consumer so it can surface a trim marker when history the client was
@@ -525,16 +621,16 @@ export function connect(): void {
525
621
  // since the last successful ack is irrecoverable but at least
526
622
  // not duplicated. Skip this branch when bytesSent = 0 (genuine
527
623
  // first-connect; received=0 is correct).
528
- if (msg.received === 0 && bytesAcked > 0) {
529
- bytesSent = 0;
530
- bytesAcked = 0;
531
- outbox.length = 0;
532
- outboxBytes = 0;
624
+ if (msg.received === 0 && st.bytesAcked > 0) {
625
+ st.bytesSent = 0;
626
+ st.bytesAcked = 0;
627
+ st.outbox.length = 0;
628
+ st.outboxBytes = 0;
533
629
  cb?.onServerRestart?.();
534
630
  return;
535
631
  }
536
- applyAck(msg.received);
537
- retransmitOutbox();
632
+ applyAck(st, msg.received);
633
+ retransmitOutbox(sock, st);
538
634
  return;
539
635
  }
540
636
  if (msg.type === "modes") {
@@ -546,9 +642,10 @@ export function connect(): void {
546
642
  msg.mouseMode,
547
643
  msg.applicationKeypad,
548
644
  msg.reverseVideo,
645
+ msg.mousePixels,
549
646
  );
550
647
  if (typeof msg.inputAck === "number") {
551
- applyAck(msg.inputAck);
648
+ applyAck(st, msg.inputAck);
552
649
  }
553
650
  // Notify the UI so it can react to mode changes (e.g. clear
554
651
  // scrollback on alt-screen entry — handled by the caller).
@@ -556,7 +653,7 @@ export function connect(): void {
556
653
  return;
557
654
  }
558
655
  if (typeof msg.inputAck === "number") {
559
- applyAck(msg.inputAck);
656
+ applyAck(st, msg.inputAck);
560
657
  }
561
658
  cb?.onMessage(msg);
562
659
  }
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export * from "./types.js";
2
+ export { LineStore } from "./store.js";
3
+ export type { WindowState, StoreChanges } from "./store.js";
2
4
  export * as render from "./render.js";
3
5
  export * as keyboard from "./keyboard.js";
4
6
  export * as scroll from "./scroll.js";
@@ -8,3 +10,11 @@ export * as connection from "./connection.js";
8
10
  export { decodeWireBinary } from "./wire-binary.js";
9
11
  export { controlFrame, CONTROL_FRAME_PREFIX } from "./wire.js";
10
12
  export { wsURL } from "./wsurl.js";
13
+ export { connectStatusStream } from "./status-stream.js";
14
+ export type {
15
+ SessionStatus,
16
+ StatusStream,
17
+ StatusStreamCallbacks,
18
+ EventSourceFactory,
19
+ EventSourceLike,
20
+ } from "./status-stream.js";
package/src/keyboard.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // vt screen tracks both bracketed paste (?2004) and DECCKM (?1) and
15
15
  // emits a modes frame whenever they change.
16
16
 
17
- import { isApplicationCursor, isApplicationKeypad, isBracketedPaste } from "./modes.js";
17
+ import { isApplicationCursor, isBracketedPaste } from "./modes.js";
18
18
 
19
19
  /** Result of mapping a keyboard event. */
20
20
  export type KeyboardResult =
@@ -23,6 +23,18 @@ export type KeyboardResult =
23
23
  | { kind: "scroll-down" } // Shift+PageDown — handled locally
24
24
  | { kind: "ignore" }; // Modifier-only press, etc.
25
25
 
26
+ /**
27
+ * KeyboardModes is the mode state `mapKeyboardEvent` reads: DECCKM (application
28
+ * cursor keys) and DECKPAM (application keypad). Passed explicitly so the shared
29
+ * input maps against the active tab's modes and so the mapping is testable
30
+ * without mutating global state. The `modes` module namespace satisfies this
31
+ * structurally; a tabbed shell passes its active session's modes.
32
+ */
33
+ export interface KeyboardModes {
34
+ isApplicationCursor: () => boolean;
35
+ isApplicationKeypad: () => boolean;
36
+ }
37
+
26
38
  const ESC = "\x1b";
27
39
  const DEL = "\x7f";
28
40
 
@@ -42,11 +54,11 @@ function modifiersDigit(ev: KeyboardEvent): number {
42
54
  // When the application has set DECCKM, the modifier-less form switches
43
55
  // from CSI to SS3 (ESC O letter); modifier-bearing forms stay on CSI
44
56
  // because they have no SS3 equivalent.
45
- function csiLetter(letter: string, ev: KeyboardEvent): string {
57
+ function csiLetter(letter: string, ev: KeyboardEvent, modes: KeyboardModes): string {
46
58
  const m = modifiersDigit(ev);
47
59
  if (m === 1) {
48
60
  if (
49
- isApplicationCursor() &&
61
+ modes.isApplicationCursor() &&
50
62
  (letter === "A" ||
51
63
  letter === "B" ||
52
64
  letter === "C" ||
@@ -123,8 +135,12 @@ const KEYPAD_SS3: Record<string, string | undefined> = {
123
135
  * Caller is responsible for ev.preventDefault() when the result is
124
136
  * "send" or "scroll-*"; we don't call it here so the function stays
125
137
  * pure and testable.
138
+ *
139
+ * `modes` supplies the active session's DECCKM/DECKPAM state; pass the `modes`
140
+ * module namespace for the single-terminal case or the active tab's modes in a
141
+ * tabbed shell.
126
142
  */
127
- export function mapKeyboardEvent(ev: KeyboardEvent): KeyboardResult {
143
+ export function mapKeyboardEvent(ev: KeyboardEvent, modes: KeyboardModes): KeyboardResult {
128
144
  // Modifier-only presses (Shift, Ctrl, Alt, Meta) — no-op.
129
145
  if (ev.key === "Shift" || ev.key === "Control" || ev.key === "Alt" || ev.key === "Meta") {
130
146
  return { kind: "ignore" };
@@ -139,7 +155,7 @@ export function mapKeyboardEvent(ev: KeyboardEvent): KeyboardResult {
139
155
  // We detect numpad keys via ev.code (Numpad0-9, NumpadDecimal,
140
156
  // NumpadSubtract, NumpadAdd, NumpadMultiply, NumpadDivide, NumpadEnter).
141
157
  if (
142
- isApplicationKeypad() &&
158
+ modes.isApplicationKeypad() &&
143
159
  !ev.ctrlKey &&
144
160
  !ev.altKey &&
145
161
  !ev.metaKey &&
@@ -175,15 +191,15 @@ export function mapKeyboardEvent(ev: KeyboardEvent): KeyboardResult {
175
191
  // Cursor keys — CSI form with optional modifiers (CSI 1;{m}{letter}).
176
192
  const arrow = ARROW_LETTER[ev.key];
177
193
  if (arrow !== undefined) {
178
- return { kind: "send", bytes: csiLetter(arrow, ev) };
194
+ return { kind: "send", bytes: csiLetter(arrow, ev, modes) };
179
195
  }
180
196
 
181
197
  // Home / End — CSI {H,F} with optional modifiers.
182
198
  if (ev.key === "Home") {
183
- return { kind: "send", bytes: csiLetter("H", ev) };
199
+ return { kind: "send", bytes: csiLetter("H", ev, modes) };
184
200
  }
185
201
  if (ev.key === "End") {
186
- return { kind: "send", bytes: csiLetter("F", ev) };
202
+ return { kind: "send", bytes: csiLetter("F", ev, modes) };
187
203
  }
188
204
 
189
205
  // Insert / Delete / PageUp / PageDown (no Shift) — CSI tilde forms.
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
@@ -110,7 +110,11 @@ function measureCellWidth(): number {
110
110
  let output: HTMLElement;
111
111
  let termWrap: HTMLElement;
112
112
 
113
- const store = new LineStore();
113
+ // The store the renderer reflects. Module-private by default (a consumer that
114
+ // never calls bind() gets one implicit store, the original single-terminal
115
+ // behavior). The tabs feature keeps one LineStore per tab and calls bind() on
116
+ // switch to point the renderer at the active tab's cache (design section 6).
117
+ let store = new LineStore();
114
118
  // abs index -> its row element. The DOM children of `output` are these
115
119
  // elements, kept in ascending data-abs order.
116
120
  const rowEls = new Map<number, HTMLDivElement>();
@@ -215,6 +219,69 @@ export function resetScrollback(): void {
215
219
  scheduleFlush();
216
220
  }
217
221
 
222
+ /**
223
+ * Bind the renderer to a different store and rebuild the surface from it. The
224
+ * tabs feature calls this on every switch to point the one renderer at the
225
+ * active tab's cached LineStore (design sections 5, 6, 8). The DOM is wiped and
226
+ * repainted viewport-first from the new store; this is local, so the last-known
227
+ * screen paints without a network round-trip.
228
+ */
229
+ export function bind(next: LineStore): void {
230
+ store = next;
231
+ rebuild();
232
+ }
233
+
234
+ /**
235
+ * The store the renderer is currently bound to. Exposed so the shell can feed
236
+ * decoded frames into the active tab's store and read its resume bounds.
237
+ */
238
+ export function boundStore(): LineStore {
239
+ return store;
240
+ }
241
+
242
+ /**
243
+ * Wipe the DOM and rebuild it from the current store, viewport-first and
244
+ * budgeted. The live window (what the user sees) builds first, then scrollback
245
+ * from newest to oldest so rows adjacent to the viewport fill in before deep
246
+ * history; the existing per-frame budget spreads a large backlog across frames
247
+ * so the switch never janks. Used by bind(); also safe to call directly to
248
+ * force a full repaint of the current store.
249
+ */
250
+ export function rebuild(): void {
251
+ output.replaceChildren();
252
+ rowEls.clear();
253
+ renderQueue.clear();
254
+ trimMarkerEl = null;
255
+ cursorAbs = -1;
256
+ altRendered = false;
257
+ prevCursorCol = -1;
258
+ prevCursorHidden = false;
259
+ prevCursorStyleVal = -1;
260
+ // Alt screen paints from the ephemeral grid in the flush, so it needs no
261
+ // absolute-index queueing here.
262
+ if (!store.isAlt()) {
263
+ const oldest = store.oldestIndex();
264
+ const highest = store.highestIndex();
265
+ if (highest >= 0) {
266
+ const winStart = Math.max(store.getWindow().base, oldest);
267
+ // Live window first (viewport), ascending.
268
+ for (let abs = winStart; abs <= highest; abs++) {
269
+ if (store.getLine(abs) !== undefined) {
270
+ renderQueue.add(abs);
271
+ }
272
+ }
273
+ // Then scrollback, newest to oldest, so rows nearest the viewport fill in
274
+ // before deep history.
275
+ for (let abs = winStart - 1; abs >= oldest; abs--) {
276
+ if (store.getLine(abs) !== undefined) {
277
+ renderQueue.add(abs);
278
+ }
279
+ }
280
+ }
281
+ }
282
+ scheduleFlush();
283
+ }
284
+
218
285
  /**
219
286
  * Highest absolute line index the client holds, or -1 if empty. This is the
220
287
  * resume `haveThrough` value (it replaces the old DOM-row count). Exposed so
@@ -408,6 +475,12 @@ function buildRowSpans(runs: WireRun[], cursorAt: number): (HTMLSpanElement | HT
408
475
  prev.style.letterSpacing = `${cellWidth * 2 - w}px`;
409
476
  }
410
477
  }
478
+ // The spacer occupies the wide char's second cell, so it advances the
479
+ // column exactly like a printed cell. The engine reports cursor_col in
480
+ // true cell coordinates (a wide glyph moves curX by 2), so col must
481
+ // count this cell too — otherwise a visible cursor positioned after a
482
+ // wide char lands one cell too far right per preceding wide char.
483
+ col++;
411
484
  continue;
412
485
  }
413
486
  if (col === cursorAt) {
@@ -0,0 +1,95 @@
1
+ // Client for the server's session status stream (Server-Sent Events at
2
+ // /api/sessions/events). A thin EventSource wrapper: it parses each status event
3
+ // and fans it out to a callback. The reconnect-resync policy (re-fetching the
4
+ // session list after a gap) is the consumer's job; onOpen fires on every
5
+ // (re)open so the consumer can trigger it. Pairs with the Go
6
+ // terminal.SessionManager EventsHandler.
7
+
8
+ /** SessionStatus is one session's current status as carried on the stream. It
9
+ * mirrors the server's status event. */
10
+ export interface SessionStatus {
11
+ readonly id: string;
12
+ readonly status: "working" | "idle" | "input" | "exited";
13
+ readonly title: string;
14
+ readonly createdAt: string;
15
+ /** true when the session is gone (closed or reaped); the consumer drops it. */
16
+ readonly removed?: boolean;
17
+ }
18
+
19
+ /** StatusStreamCallbacks are the consumer's hooks. Declared as function-typed
20
+ * properties (not method shorthand) so destructuring them is safe. */
21
+ export interface StatusStreamCallbacks {
22
+ /** Called for each status event. */
23
+ onStatus: (status: SessionStatus) => void;
24
+ /** Called on every (re)open, including auto-reconnects, so the consumer can
25
+ * resync after a gap (the stream only carries future changes). */
26
+ onOpen?: () => void;
27
+ /** Called when the stream errors; the EventSource then auto-reconnects. */
28
+ onError?: () => void;
29
+ }
30
+
31
+ /** EventSourceLike is the minimal surface used here, so the stream can be tested
32
+ * without a DOM EventSource (the default factory adapts a real one). */
33
+ export interface EventSourceLike {
34
+ addEventListener: (type: string, listener: (event: MessageEvent) => void) => void;
35
+ close: () => void;
36
+ }
37
+
38
+ /** EventSourceFactory builds an EventSourceLike for a URL. */
39
+ export type EventSourceFactory = (url: string) => EventSourceLike;
40
+
41
+ export interface StatusStream {
42
+ /** Closes the stream and stops reconnection. */
43
+ close: () => void;
44
+ }
45
+
46
+ const defaultFactory: EventSourceFactory = (url) => {
47
+ const es = new EventSource(url);
48
+ return {
49
+ addEventListener: (type, listener) => {
50
+ es.addEventListener(type, listener as unknown as EventListener);
51
+ },
52
+ close: () => {
53
+ es.close();
54
+ },
55
+ };
56
+ };
57
+
58
+ /** connectStatusStream opens the status stream at path and fans events out to
59
+ * cb. make defaults to a real EventSource; tests inject a fake. */
60
+ export function connectStatusStream(
61
+ path: string,
62
+ cb: StatusStreamCallbacks,
63
+ make: EventSourceFactory = defaultFactory,
64
+ ): StatusStream {
65
+ const es = make(path);
66
+ const { onOpen, onError } = cb;
67
+ if (onOpen) {
68
+ es.addEventListener("open", () => {
69
+ onOpen();
70
+ });
71
+ }
72
+ es.addEventListener("message", (event) => {
73
+ const raw: unknown = event.data;
74
+ if (typeof raw !== "string") {
75
+ return; // non-text frame, ignore
76
+ }
77
+ let status: SessionStatus;
78
+ try {
79
+ status = JSON.parse(raw) as SessionStatus;
80
+ } catch {
81
+ return; // skip a malformed frame, keep the stream
82
+ }
83
+ cb.onStatus(status);
84
+ });
85
+ if (onError) {
86
+ es.addEventListener("error", () => {
87
+ onError();
88
+ });
89
+ }
90
+ return {
91
+ close: () => {
92
+ es.close();
93
+ },
94
+ };
95
+ }
package/src/store.ts CHANGED
@@ -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;
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;
@@ -177,6 +180,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
177
180
  const bell = (cursorFlags & 2) !== 0;
178
181
  const cursorBlink = (cursorFlags & 4) !== 0;
179
182
  const altActive = (cursorFlags & 8) !== 0;
183
+ const scrollbackCleared = (cursorFlags & 16) !== 0;
180
184
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pre-allocated array filled below
181
185
  const rows: WireRun[][] = new Array(screenHeight);
182
186
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- pre-allocated array filled below
@@ -197,6 +201,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
197
201
  cursorHidden,
198
202
  cursorBlink,
199
203
  bell,
204
+ scrollbackCleared,
200
205
  inputAck,
201
206
  };
202
207
  return msg;
@@ -223,6 +228,7 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
223
228
  mouseSGR: (flags & MODE_FLAG_MOUSE_SGR) !== 0,
224
229
  focusReporting: (flags & MODE_FLAG_FOCUS_REPORTING) !== 0,
225
230
  reverseVideo: (flags & MODE_FLAG_REVERSE_VIDEO) !== 0,
231
+ mousePixels: (flags & MODE_FLAG_MOUSE_PIXELS) !== 0,
226
232
  mouseMode,
227
233
  inputAck,
228
234
  };
@@ -234,6 +240,12 @@ function decodeWireBinaryInner(buf: ArrayBuffer): ServerMessage | null {
234
240
  const msg: TitleMessage = { type: "title", title, inputAck };
235
241
  return msg;
236
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
+ }
237
249
  if (msgType === MSG_PONG) {
238
250
  // Liveness pong: no payload to deliver. The message listener already
239
251
  // refreshed the activity clock on arrival, so there is nothing to do