@cplieger/web-terminal-engine 1.4.0 → 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.4.0",
3
+ "version": "2.0.0",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-engine",
3
- "version": "1.4.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "typecheck": "tsgo -p tsconfig.json",
@@ -18,7 +18,7 @@
18
18
  "fast-check": "4.8.0",
19
19
  "happy-dom": "20.10.6",
20
20
  "pngjs": "7.0.0",
21
- "prettier": "3.9.3",
21
+ "prettier": "3.9.4",
22
22
  "typescript": "6.0.3",
23
23
  "typescript-eslint": "8.62.1",
24
24
  "vitest": "4.1.9"
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") {
@@ -549,7 +645,7 @@ export function connect(): void {
549
645
  msg.mousePixels,
550
646
  );
551
647
  if (typeof msg.inputAck === "number") {
552
- applyAck(msg.inputAck);
648
+ applyAck(st, msg.inputAck);
553
649
  }
554
650
  // Notify the UI so it can react to mode changes (e.g. clear
555
651
  // scrollback on alt-screen entry — handled by the caller).
@@ -557,7 +653,7 @@ export function connect(): void {
557
653
  return;
558
654
  }
559
655
  if (typeof msg.inputAck === "number") {
560
- applyAck(msg.inputAck);
656
+ applyAck(st, msg.inputAck);
561
657
  }
562
658
  cb?.onMessage(msg);
563
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/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
@@ -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
+ }