hop2 0.11.2 → 0.11.4

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
@@ -199,7 +199,13 @@ The attached-terminal UI adapts light/dark themes to your terminal and puts the
199
199
 
200
200
  ### Use Hop on Mobile
201
201
 
202
- Hop’s mobile UI includes:
202
+ **Native iOS app**: [hop-ios](https://github.com/jzthree/hop-ios) — a SwiftUI
203
+ client for iPhone with a real terminal (SwiftTerm), optimistic-echo typing,
204
+ bell notifications with lock-screen reply, Siri/Shortcuts/Spotlight/Handoff
205
+ integration, and a fixed-height terminal keyboard. It consumes hop's public
206
+ HTTP + WebSocket API and needs no changes to hop.
207
+
208
+ Hop’s mobile **web** UI includes:
203
209
 
204
210
  - a terminal-oriented accessory row with `Esc`, `Tab`, `Ctrl`, `Alt`, and arrows
205
211
  - a native keyboard button for dictation, spellcheck, and autocomplete
@@ -20,8 +20,10 @@
20
20
  "test": "vitest run"
21
21
  },
22
22
  "dependencies": {
23
- "hay-shared": "*",
23
+ "@xterm/addon-serialize": "^0.14.0",
24
+ "@xterm/headless": "^6.0.0",
24
25
  "express": "^4.21.2",
26
+ "hay-shared": "*",
25
27
  "node-pty-prebuilt-multiarch": "^0.10.1-pre.5",
26
28
  "ws": "^8.18.0"
27
29
  },
@@ -5,6 +5,8 @@ import type { IPty } from "node-pty-prebuilt-multiarch";
5
5
  import { clientMessageSchema, pickPresenceColor, safeParseClientMessage } from "hay-shared";
6
6
  import type { ClientMessage, PresenceClient, ServerMessage } from "hay-shared";
7
7
  import type { PtyFactory } from "./pty";
8
+ import { createScreenGrid, loadScreenGridDeps, screenGridsAvailable } from "./screenGrid";
9
+ import type { ScreenGrid } from "./screenGrid";
8
10
 
9
11
  export type SocketAdapter = {
10
12
  send: (data: string) => void;
@@ -26,7 +28,11 @@ export type ClientInfo = {
26
28
  cols: number;
27
29
  rows: number;
28
30
  // Optional per-connection cap for the join snapshot (clamped to the
29
- // room-level bound). Monitors request ~64KB.
31
+ // room-level bound). 0 = no snapshot at all (live stream only — size-claim
32
+ // and kick-start sockets). Bounded requests (tiles ~64KB, web full screen
33
+ // ~384KB) are served a SERIALIZED grid — complete and a few KB — while
34
+ // larger requests are a deliberate ask for scrollback DEPTH and keep the
35
+ // raw-tail replay. Unset = raw tail at the room default (CLI reattach).
30
36
  replayBytes?: number;
31
37
  // False = skip the equal-size attach "wiggle" (the −1/+1 column repaint
32
38
  // nudge). Wall tiles decline it: they arrive already showing the current
@@ -96,6 +102,21 @@ const boundSnapshotReplay = (buffer: string): string => {
96
102
  return tail;
97
103
  };
98
104
 
105
+ // Serialized attach: connections asking for a bounded snapshot (web full
106
+ // screen, wall tiles) get the room grid serialized — complete and a few KB —
107
+ // instead of a raw tail. Larger requests are a deliberate ask for DEPTH
108
+ // (deep-history reload, CLI full-scrollback reattach): raw tail reconstructs
109
+ // far more scrollback than the grid retains, so those keep the old path.
110
+ // Kill switch: HAY_SERIALIZED_ATTACH=0 reverts every attach to raw tails.
111
+ const SERIALIZED_ATTACH_ENABLED = process.env.HAY_SERIALIZED_ATTACH !== "0";
112
+ const SERIALIZED_MAX_REQUEST = 524_288;
113
+ // Above this much retained raw history, the serialized snapshot is probably
114
+ // shallower than what a deep replay could reconstruct — tell the client, so
115
+ // its scroll-to-top full-history reload still knows to offer itself (it used
116
+ // to infer cappedness from raw snapshot SIZE, which a few-KB serialized
117
+ // snapshot never trips).
118
+ const SERIALIZED_CAPPED_HINT_BYTES = 262_144;
119
+
99
120
  // Autofit-on-attach: a resize may claim the shared size when every OTHER
100
121
  // client has been input-idle at least this long. Someone actively typing keeps
101
122
  // the size they set; a viewer opening the session after everyone went idle
@@ -235,6 +256,11 @@ export class Room extends EventEmitter {
235
256
  // False until the first client attaches: that client is the one whose
236
257
  // connect created the room, so its hello carries created=true.
237
258
  private hasHadClient = false;
259
+ // Authoritative screen state for serialized attach (see screenGrid.ts).
260
+ // Created lazily once the headless deps load; hydrated from the retained
261
+ // ring at creation so bytes that arrived earlier are never missing.
262
+ private grid: ScreenGrid | null = null;
263
+ private gridHydrated = false;
238
264
 
239
265
  constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number }, options: RoomCreateOptions) {
240
266
  super();
@@ -354,45 +380,92 @@ export class Room extends EventEmitter {
354
380
  } satisfies ServerMessage)
355
381
  );
356
382
 
357
- if (this.outputBytes > 0) {
383
+ const replayRequest = Number.isFinite(info.replayBytes) ? Math.floor(info.replayBytes as number) : null;
384
+ const snapshotFlags = () => ({
385
+ alternateScreen: this.alternateScreen,
386
+ cursorHidden: this.cursorHidden,
387
+ keyboardEnhanced: this.keyboardEnhanced,
388
+ mouseReporting: this.mouseReporting,
389
+ mouseSgr: this.mouseSgr
390
+ });
391
+ const sendRawSnapshot = () => {
358
392
  socket.send(JSON.stringify({
359
393
  type: "snapshot",
360
394
  data: boundSnapshotReplay(this.tailOutput(
361
- Number.isFinite(info.replayBytes) && (info.replayBytes as number) > 0
362
- ? Math.min(SNAPSHOT_REPLAY_BYTES, Math.floor(info.replayBytes as number))
395
+ replayRequest !== null && replayRequest > 0
396
+ ? Math.min(SNAPSHOT_REPLAY_BYTES, replayRequest)
363
397
  : SNAPSHOT_REPLAY_BYTES
364
398
  )),
365
- alternateScreen: this.alternateScreen,
366
- cursorHidden: this.cursorHidden,
367
- keyboardEnhanced: this.keyboardEnhanced,
368
- mouseReporting: this.mouseReporting,
369
- mouseSgr: this.mouseSgr
399
+ ...snapshotFlags()
370
400
  } satisfies ServerMessage));
371
- }
372
-
373
- // The snapshot is a raw byte TAIL, and incremental TUIs (Claude Code/Ink)
374
- // never re-emit rows that don't change — e.g. the input box's horizontal
375
- // border lines — so the replay can be missing them. When this client's
376
- // size differs from the PTY's, the attach resize triggers a full repaint
377
- // anyway; when sizes are EQUAL nothing would repaint, so nudge: a one-
378
- // column wiggle (tmux's redraw trick) makes full-screen apps repaint.
379
- if (
380
- info.nudge !== false &&
381
- client.cols === this.activeCols &&
382
- client.rows === this.activeRows &&
383
- this.activeCols > 4 &&
384
- !this.ended
385
- ) {
386
- const cols = this.activeCols;
387
- const rows = this.activeRows;
388
- try { this.pty.resize(cols - 1, rows); } catch { /* pty may be gone */ }
389
- setTimeout(() => {
390
- if (this.ended) return;
391
- // Only bounce back if nobody claimed a different size meanwhile.
392
- if (this.activeCols === cols && this.activeRows === rows) {
393
- try { this.pty.resize(cols, rows); } catch { /* pty may be gone */ }
401
+ };
402
+ // A raw TAIL can be missing rows incremental TUIs (Claude Code/Ink) never
403
+ // re-emitted within the window. When this client's size differs from the
404
+ // PTY's, the attach resize triggers a full repaint anyway; when sizes are
405
+ // EQUAL nothing would repaint, so nudge: a one-column wiggle (tmux's
406
+ // redraw trick) makes full-screen apps repaint. Raw-path compensation
407
+ // only — a serialized attach is already complete.
408
+ const wiggle = () => {
409
+ if (
410
+ info.nudge !== false &&
411
+ client.cols === this.activeCols &&
412
+ client.rows === this.activeRows &&
413
+ this.activeCols > 4 &&
414
+ !this.ended
415
+ ) {
416
+ const cols = this.activeCols;
417
+ const rows = this.activeRows;
418
+ try { this.pty.resize(cols - 1, rows); } catch { /* pty may be gone */ }
419
+ setTimeout(() => {
420
+ if (this.ended) return;
421
+ // Only bounce back if nobody claimed a different size meanwhile.
422
+ if (this.activeCols === cols && this.activeRows === rows) {
423
+ try { this.pty.resize(cols, rows); } catch { /* pty may be gone */ }
424
+ }
425
+ }, 60);
426
+ }
427
+ };
428
+ // replay=0 is an explicit "live stream only" (size-claim and kick-start
429
+ // sockets): honor it — these connections used to fall through to the
430
+ // DEFAULT cap and silently pull the full 1.5MB tail they never read.
431
+ const wantsSnapshot = this.outputBytes > 0 && replayRequest !== 0;
432
+ const gridForAttach = wantsSnapshot && replayRequest !== null && replayRequest > 0
433
+ && replayRequest <= SERIALIZED_MAX_REQUEST
434
+ ? this.ensureGrid()
435
+ : null;
436
+ // Scrollback rides along in proportion to the ask: a tile's ~64KB request
437
+ // wants the screen (plus enough history for a casual scroll-up), while
438
+ // the web full screen's ~384KB request wants the grid's full depth.
439
+ const serializedScrollback = replayRequest !== null && replayRequest <= 131_072 ? 200 : undefined;
440
+ if (gridForAttach) {
441
+ gridForAttach.serialize((serialized) => {
442
+ if (this.ended || !this.clients.has(client.id) || !socket.isOpen()) return;
443
+ if (serialized === null) {
444
+ sendRawSnapshot();
445
+ wiggle();
446
+ return;
394
447
  }
395
- }, 60);
448
+ // Serialization carries the SCREEN but not private modes — the raw
449
+ // tail incidentally carried the app's mode-enable sequences, and
450
+ // clients (each terminal's own mode state) depend on them to route
451
+ // wheel events to the app vs local scrollback and to gate selection.
452
+ // Re-assert them in-band so every client — including ones that never
453
+ // read the separate flags — ends up in the app's actual modes.
454
+ const modeSeq = (this.mouseReporting ? "\x1b[?1002h" + (this.mouseSgr ? "\x1b[?1006h" : "") : "")
455
+ + (this.cursorHidden ? "\x1b[?25l" : "");
456
+ socket.send(JSON.stringify({
457
+ type: "snapshot",
458
+ // In-band RIS: every client applies the serialized screen onto a
459
+ // clean slate, including ones that don't reset on their own.
460
+ data: "\x1bc" + serialized + modeSeq,
461
+ capped: this.outputBytes > SERIALIZED_CAPPED_HINT_BYTES,
462
+ ...snapshotFlags()
463
+ } satisfies ServerMessage));
464
+ // No wiggle: the serialized grid IS the current screen.
465
+ }, serializedScrollback);
466
+ } else {
467
+ if (wantsSnapshot) sendRawSnapshot();
468
+ if (replayRequest !== 0) wiggle();
396
469
  }
397
470
 
398
471
  // Send current cwd to the new client
@@ -802,6 +875,7 @@ export class Room extends EventEmitter {
802
875
  this.pty.resize(cols, rows);
803
876
  this.activeCols = cols;
804
877
  this.activeRows = rows;
878
+ this.grid?.resize(cols, rows);
805
879
  this.activeClientId = client.id;
806
880
  // Broadcast the new active size to other clients so they can adjust —
807
881
  // and CONFIRM to the winner: a claimant otherwise learns nothing on
@@ -947,6 +1021,33 @@ export class Room extends EventEmitter {
947
1021
  }
948
1022
  }
949
1023
 
1024
+ // Grids are LAZY: created on the first serialized attach, not on output,
1025
+ // so a room nobody ever opens (agent fleets) pays no grid memory at all.
1026
+ // Creation hydrates from a bounded ring tail — deep enough to contain any
1027
+ // app's last full repaint plus every update since, which is all screen
1028
+ // correctness needs; serialized scrollback is bounded anyway.
1029
+ private static readonly GRID_HYDRATE_BYTES = 2_000_000;
1030
+ private ensureGrid(): ScreenGrid | null {
1031
+ if (!SERIALIZED_ATTACH_ENABLED || this.ended) return this.grid;
1032
+ if (!this.grid && screenGridsAvailable()) {
1033
+ this.grid = createScreenGrid(this.activeCols, this.activeRows);
1034
+ if (this.grid && !this.gridHydrated) {
1035
+ this.gridHydrated = true;
1036
+ if (this.outputBytes > 0) {
1037
+ // Cut at a line boundary (same trick as boundSnapshotReplay) so a
1038
+ // slice landing mid-escape-sequence can't garble the parse head.
1039
+ let tail = this.tailOutput(Room.GRID_HYDRATE_BYTES);
1040
+ if (this.outputBytes > tail.length) {
1041
+ const nl = tail.indexOf("\n");
1042
+ if (nl !== -1 && nl < 8192) tail = tail.slice(nl + 1);
1043
+ }
1044
+ this.grid.write(tail);
1045
+ }
1046
+ }
1047
+ }
1048
+ return this.grid;
1049
+ }
1050
+
950
1051
  /** O(1) append; trims whole chunks from the head past MAX_BUFFER_SIZE. */
951
1052
  private appendOutput(data: string) {
952
1053
  if (!data) return;
@@ -964,6 +1065,9 @@ export class Room extends EventEmitter {
964
1065
  this.outputChunks[0] = only;
965
1066
  this.outputBytes = only.length;
966
1067
  }
1068
+ // Lazily-created grids hydrate from the ring at creation; here the grid
1069
+ // (when it exists) just follows along with the delta.
1070
+ this.grid?.write(data);
967
1071
  }
968
1072
 
969
1073
  /**
@@ -989,6 +1093,8 @@ export class Room extends EventEmitter {
989
1093
  if (this.ended) return;
990
1094
  this.ended = true;
991
1095
  this.stopCwdPolling();
1096
+ this.grid?.dispose();
1097
+ this.grid = null;
992
1098
  if (this.cleanupTimer) {
993
1099
  clearTimeout(this.cleanupTimer);
994
1100
  this.cleanupTimer = null;
@@ -1101,6 +1207,9 @@ export class RoomManager {
1101
1207
 
1102
1208
  constructor(ptyFactory: PtyFactory) {
1103
1209
  this.ptyFactory = ptyFactory;
1210
+ // Start loading the headless deps now so the first attach can serialize;
1211
+ // rooms hydrate their grid from the ring when the load wins the race late.
1212
+ if (SERIALIZED_ATTACH_ENABLED) void loadScreenGridDeps();
1104
1213
  }
1105
1214
 
1106
1215
  getRoom(roomId: string, initialSize: { cols: number; rows: number }, cwdOrOptions: string | RoomCreateOptions) {
@@ -0,0 +1,145 @@
1
+ // Server-side screen state — the authoritative grid behind serialized attach.
2
+ //
3
+ // The attach snapshot used to be a raw byte TAIL of the output ring. For
4
+ // incremental TUIs (Claude Code/Ink) a tail is structurally incomplete: rows
5
+ // that haven't changed recently were emitted before the tail window began, so
6
+ // a freshly attached client rendered holes until the app happened to repaint.
7
+ // That is the whole "have to scroll before it renders correctly" bug — the
8
+ // wheel event reaches the app through mouse reporting and provokes a redraw
9
+ // that resends the pixels the snapshot never carried.
10
+ //
11
+ // Feeding the room's output through a headless terminal instead means every
12
+ // attach can serialize the complete current screen — a few KB, always whole —
13
+ // and the resize "wiggle" repaint trick becomes unnecessary on this path.
14
+ //
15
+ // Loaded lazily and failure-tolerant: when @xterm/headless is unavailable a
16
+ // room silently falls back to the raw-tail snapshot (+ wiggle), so a broken
17
+ // dependency degrades to the old behavior instead of breaking attach.
18
+
19
+ type HeadlessTerminal = {
20
+ write(data: string, callback?: () => void): void;
21
+ resize(cols: number, rows: number): void;
22
+ loadAddon(addon: unknown): void;
23
+ dispose(): void;
24
+ };
25
+
26
+ type SerializeAddonLike = {
27
+ serialize(options?: { scrollback?: number }): string;
28
+ };
29
+
30
+ type GridDeps = {
31
+ Terminal: new (options: Record<string, unknown>) => HeadlessTerminal;
32
+ SerializeAddon: new () => SerializeAddonLike;
33
+ };
34
+
35
+ // Scrollback lines the grid retains (and serialized attaches can carry).
36
+ // This bounds host memory per room — deeper history stays in the raw output
37
+ // ring and reaches clients through the existing deep-history replay path.
38
+ export const GRID_SCROLLBACK = (() => {
39
+ const env = Number(process.env.HAY_GRID_SCROLLBACK);
40
+ return Number.isFinite(env) && env >= 0 ? Math.floor(env) : 1000;
41
+ })();
42
+
43
+ let deps: GridDeps | null = null;
44
+ let loading: Promise<boolean> | null = null;
45
+
46
+ export const screenGridsAvailable = (): boolean => deps !== null;
47
+
48
+ export const loadScreenGridDeps = (): Promise<boolean> => {
49
+ if (deps) return Promise.resolve(true);
50
+ if (loading) return loading;
51
+ loading = (async () => {
52
+ try {
53
+ // Some xterm builds touch browser globals at import time; shim the few
54
+ // they reach for (same trick the daemon uses for preview rendering).
55
+ const g = globalThis as Record<string, unknown>;
56
+ if (!g.window) g.window = g;
57
+ if (!g.self) g.self = g;
58
+ const [headless, serialize] = await Promise.all([
59
+ import("@xterm/headless"),
60
+ import("@xterm/addon-serialize")
61
+ ]);
62
+ // Both packages ship UMD/CJS: under Node's ESM interop the named
63
+ // exports may only exist on `default`, so check both shapes.
64
+ type HeadlessModule = { Terminal?: GridDeps["Terminal"]; default?: { Terminal?: GridDeps["Terminal"] } };
65
+ type SerializeModule = { SerializeAddon?: GridDeps["SerializeAddon"]; default?: { SerializeAddon?: GridDeps["SerializeAddon"] } };
66
+ const Terminal = (headless as HeadlessModule).Terminal ?? (headless as HeadlessModule).default?.Terminal;
67
+ const SerializeAddon = (serialize as SerializeModule).SerializeAddon
68
+ ?? (serialize as SerializeModule).default?.SerializeAddon;
69
+ if (typeof Terminal !== "function" || typeof SerializeAddon !== "function") {
70
+ console.log("[hay] serialized attach unavailable (xterm exports not found) — raw-tail snapshots");
71
+ return false;
72
+ }
73
+ deps = { Terminal, SerializeAddon };
74
+ return true;
75
+ } catch (error) {
76
+ console.log(`[hay] serialized attach unavailable (${(error as Error)?.message || "load failed"}) — raw-tail snapshots`);
77
+ return false;
78
+ }
79
+ })();
80
+ return loading;
81
+ };
82
+
83
+ export type ScreenGrid = {
84
+ write(data: string): void;
85
+ resize(cols: number, rows: number): void;
86
+ /**
87
+ * Serialize AFTER everything already written has been parsed (xterm's
88
+ * write path is asynchronous). Calls back with null on failure so the
89
+ * caller can fall back to the raw-tail snapshot. `scrollback` bounds how
90
+ * many history lines ride along (tiles need far fewer than a full screen).
91
+ */
92
+ serialize(callback: (data: string | null) => void, scrollback?: number): void;
93
+ dispose(): void;
94
+ };
95
+
96
+ export const createScreenGrid = (cols: number, rows: number): ScreenGrid | null => {
97
+ if (!deps) return null;
98
+ let term: HeadlessTerminal;
99
+ let addon: SerializeAddonLike;
100
+ try {
101
+ term = new deps.Terminal({
102
+ cols: Math.max(2, cols),
103
+ rows: Math.max(1, rows),
104
+ scrollback: GRID_SCROLLBACK,
105
+ allowProposedApi: true
106
+ });
107
+ addon = new deps.SerializeAddon();
108
+ term.loadAddon(addon);
109
+ } catch {
110
+ return null;
111
+ }
112
+ let disposed = false;
113
+ return {
114
+ write(data: string) {
115
+ if (disposed) return;
116
+ try { term.write(data); } catch { /* parser hiccup — next write continues */ }
117
+ },
118
+ resize(nextCols: number, nextRows: number) {
119
+ if (disposed) return;
120
+ try { term.resize(Math.max(2, nextCols), Math.max(1, nextRows)); } catch { /* keep old size */ }
121
+ },
122
+ serialize(callback: (data: string | null) => void, scrollback: number = GRID_SCROLLBACK) {
123
+ if (disposed) { callback(null); return; }
124
+ try {
125
+ // An empty write's callback fires once all queued data has parsed —
126
+ // the serialize then reflects every byte up to this attach.
127
+ term.write("", () => {
128
+ if (disposed) { callback(null); return; }
129
+ try {
130
+ callback(addon.serialize({ scrollback: Math.max(0, Math.min(scrollback, GRID_SCROLLBACK)) }));
131
+ } catch {
132
+ callback(null);
133
+ }
134
+ });
135
+ } catch {
136
+ callback(null);
137
+ }
138
+ },
139
+ dispose() {
140
+ if (disposed) return;
141
+ disposed = true;
142
+ try { term.dispose(); } catch { /* already gone */ }
143
+ }
144
+ };
145
+ };
@@ -707,4 +707,108 @@ describe("Room", () => {
707
707
  expect(delta.data).toBe("fresh");
708
708
  });
709
709
  });
710
+
711
+ describe("serialized attach", () => {
712
+ const setup = async (roomId: string) => {
713
+ const { loadScreenGridDeps } = await import("../src/screenGrid");
714
+ expect(await loadScreenGridDeps()).toBe(true);
715
+ let ptyInstance: FakePty | null = null;
716
+ const factory: PtyFactory = () => {
717
+ ptyInstance = new FakePty() as unknown as FakePty;
718
+ return ptyInstance as any;
719
+ };
720
+ const manager = new RoomManager(factory);
721
+ const room = manager.getRoom(roomId, { cols: 80, rows: 24 }, "/tmp");
722
+ return { room, pty: () => ptyInstance! };
723
+ };
724
+
725
+ const waitForSnapshot = async (socket: FakeSocket) => {
726
+ await vi.waitFor(() => {
727
+ expect(findMessages(socket, "snapshot").length).toBeGreaterThan(0);
728
+ });
729
+ return findMessages(socket, "snapshot")[0] as any;
730
+ };
731
+
732
+ // THE recurring bug, reproduced: an incremental TUI (Claude Code/Ink)
733
+ // paints a row once and never re-emits it; once enough updates pass, the
734
+ // row leaves every bounded raw tail. A client attaching off such a tail
735
+ // rendered holes until the app happened to repaint — "have to scroll
736
+ // before it renders correctly". The serialized grid carries the row no
737
+ // matter how long ago it was painted.
738
+ it("carries rows the bounded raw tail lost, without the wiggle", async () => {
739
+ const { room, pty } = await setup("serial");
740
+ // The app enables mouse tracking (as Claude Code does at startup) —
741
+ // long outside any bounded tail, and never emitted by serialization.
742
+ pty().emit("\x1b[?1002h\x1b[?1006h");
743
+ pty().emit("\x1b[2J\x1b[1;1HROW1-STABLE-BORDER-MARKER");
744
+ // In-place repaints of one region, Ink-style: no scrolling, so the
745
+ // marker row stays ON SCREEN while leaving every bounded byte tail.
746
+ const update = "\x1b[20;1H\x1b[K" + "x".repeat(60);
747
+ for (let i = 0; i < 1200; i++) pty().emit(update); // ~84KB > the 64KB ask
748
+ const socket = new FakeSocket();
749
+ room.attachClient(
750
+ { id: "tile", name: "Tile", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
751
+ socket
752
+ );
753
+ const snapshot = await waitForSnapshot(socket);
754
+ expect(String(snapshot.data).startsWith("\x1bc")).toBe(true);
755
+ expect(String(snapshot.data)).toContain("ROW1-STABLE-BORDER-MARKER");
756
+ // The raw tail this connection would have gotten cannot contain it.
757
+ expect(room.getPreviewSource(65536).output).not.toContain("ROW1-STABLE-BORDER-MARKER");
758
+ // Complete screen delivered — nothing needs the repaint nudge.
759
+ expect(pty().resizes.some((r: { cols: number }) => r.cols === 79)).toBe(false);
760
+ // ~82KB of raw history is well under the deep-history hint threshold.
761
+ expect(snapshot.capped).toBe(false);
762
+ // Private modes ride along in-band: the attaching terminal must know
763
+ // the app owns the wheel, or tiles scroll their local buffer instead.
764
+ expect(String(snapshot.data)).toContain("\x1b[?1002h");
765
+ expect(String(snapshot.data)).toContain("\x1b[?1006h");
766
+ expect(snapshot.mouseReporting).toBe(true);
767
+ });
768
+
769
+ it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
770
+ const { room, pty } = await setup("capped");
771
+ const big = "\x1b[20;1H" + "y".repeat(4096);
772
+ for (let i = 0; i < 80; i++) pty().emit(big); // ~330KB ring
773
+ const socket = new FakeSocket();
774
+ room.attachClient(
775
+ { id: "web", name: "Web", colorIndex: 0, cols: 80, rows: 24, replayBytes: 393216 },
776
+ socket
777
+ );
778
+ const snapshot = await waitForSnapshot(socket);
779
+ expect(String(snapshot.data).startsWith("\x1bc")).toBe(true);
780
+ expect(snapshot.capped).toBe(true);
781
+ });
782
+
783
+ it("replay=0 means no snapshot at all — live stream only", async () => {
784
+ const { room, pty } = await setup("claim");
785
+ pty().emit("some prior output\r\n");
786
+ const socket = new FakeSocket();
787
+ room.attachClient(
788
+ { id: "claim", name: "Claim", colorIndex: 0, cols: 80, rows: 24, replayBytes: 0, nudge: false },
789
+ socket
790
+ );
791
+ // Give any (buggy) async snapshot path a chance to fire before asserting.
792
+ await new Promise((resolve) => setTimeout(resolve, 50));
793
+ expect(findMessages(socket, "snapshot")).toHaveLength(0);
794
+ expect(pty().resizes.some((r: { cols: number }) => r.cols === 79)).toBe(false);
795
+ });
796
+
797
+ it("a deep-history request keeps the raw tail replay", async () => {
798
+ const { room, pty } = await setup("deep");
799
+ pty().emit("\x1b[2J\x1b[1;1Hdeep-history-anchor\r\n");
800
+ pty().emit("z".repeat(200_000));
801
+ const socket = new FakeSocket();
802
+ room.attachClient(
803
+ { id: "deep", name: "Deep", colorIndex: 0, cols: 80, rows: 24, replayBytes: 1_572_864 },
804
+ socket
805
+ );
806
+ // Raw path is synchronous.
807
+ const snapshot = findMessages(socket, "snapshot")[0] as any;
808
+ expect(snapshot).toBeTruthy();
809
+ expect(String(snapshot.data).startsWith("\x1bc")).toBe(false);
810
+ expect(String(snapshot.data).length).toBeGreaterThan(200_000);
811
+ expect(String(snapshot.data)).toContain("deep-history-anchor");
812
+ });
813
+ });
710
814
  });