hop2 0.11.9 → 0.11.11

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/hay/CHANGELOG.md CHANGED
@@ -32,6 +32,10 @@
32
32
 
33
33
  ### Bug Fixes
34
34
 
35
+ #### Web terminal
36
+ - **Claude screens render completely on attach without a scroll**: Hop now starts its authoritative screen grid when a session enters alternate-screen or mouse-reporting TUI mode, before startup rows can fall out of a bounded replay tail. Full-screen attaches use that serialized screen, and direct snapshot paints re-arm one delayed repaint in case the browser misses the first WebGL frame.
37
+ - **Resizing another desktop view no longer leaves a terminal unable to reach the live bottom**: Hop now reconciles xterm's follow state after every geometry change and keeps authoritative screen resizes ordered behind the output that preceded them.
38
+
35
39
  #### Session restore
36
40
  - **`hop restore` actually restores now (Claude conversations included), from the SessionStart-hook records**: restore built its candidate list from the workspace-scoped session store, which is only populated when a workspace is active — and no workspace is ever activated in normal use, so `restore` found nothing and silently restored zero sessions. It now sources candidates directly from the durable per-session records the SessionStart hook writes (`~/.hop2/claude-sessions/<name>.json` = `{ sessionId, cwd }`), which survive a crash/reboot and carry the exact conversation id. So after a restart, `hop restore` recreates each session in its directory and resumes its Claude conversation with `claude --resume <id>` — no manual capture step. Sessions already live are skipped (idempotent), and `hop restore` now starts the daemon itself if it isn't running, so post-reboot recovery is a single command. `--dry-run` previews the plan. Records are pruned when a session is deleted, so restore never resurrects a session you intentionally closed (and if an unwanted one does come back, deleting it once keeps it gone).
37
41
  - **`hopx_agent_turn` no longer runs non-UI input twice**: in `readable_raw` mode (and `auto` resolving to it — i.e. a plain shell or any non-alternate-screen agent), a synchronous turn pre-sent the input and *then* sent it again via the combined send+wait, so a command driven through `hopx_agent_turn` executed twice. The pre-send is now done only for the paths that need it (the `ui` branch and async turns, which return before the combined call); non-UI synchronous turns let the single send+wait do the send. UI and async turns are unchanged.
@@ -179,6 +179,7 @@ class ClientState {
179
179
  // Output broadcasts are withheld meanwhile (see broadcast) and delivered
180
180
  // as catch-up appended to the snapshot itself.
181
181
  awaitingSnapshot = false;
182
+ snapshotCatchUp: string[] = [];
182
183
  id: string;
183
184
  name: string;
184
185
  color: string;
@@ -261,10 +262,15 @@ export class Room extends EventEmitter {
261
262
  // connect created the room, so its hello carries created=true.
262
263
  private hasHadClient = false;
263
264
  // Authoritative screen state for serialized attach (see screenGrid.ts).
264
- // Created lazily once the headless deps load; hydrated from the retained
265
- // ring at creation so bytes that arrived earlier are never missing.
265
+ // Start it as soon as a TUI advertises alternate-screen or mouse mode, so
266
+ // rows painted once at startup cannot age out of a bounded replay tail.
267
+ // Plain shells stay lazy and pay no grid cost until somebody attaches.
266
268
  private grid: ScreenGrid | null = null;
267
269
  private gridHydrated = false;
270
+ private gridStartPending = false;
271
+ private gridUnavailable = false;
272
+ private gridPendingSeed: string[] | null = null;
273
+ private gridPendingSeedBytes = 0;
268
274
 
269
275
  constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number }, options: RoomCreateOptions) {
270
276
  super();
@@ -297,6 +303,7 @@ export class Room extends EventEmitter {
297
303
  this.lastActivityAt = now();
298
304
  this.appendOutput(data);
299
305
  this.updateTerminalState(data);
306
+ this.startTuiGrid();
300
307
  this.answerTerminalQueriesIfHeadless(data);
301
308
  this.broadcast({ type: "output", data });
302
309
  this.emit("pty_output", { roomId: this.id, data, timestamp: now() });
@@ -445,19 +452,20 @@ export class Room extends EventEmitter {
445
452
  // Everything already in the ring at THIS moment will be in the
446
453
  // serialized screen; everything after it will not (grid writes queue
447
454
  // behind the serialize marker). Mute output to this client until the
448
- // snapshot goes out, then hand it the exact delta from this cursor.
455
+ // snapshot goes out, retaining the exact withheld chunks for catch-up.
449
456
  client.awaitingSnapshot = true;
450
- const catchUpFrom = this.outputStart + this.outputBytes;
457
+ client.snapshotCatchUp = [];
451
458
  gridForAttach.serialize((serialized) => {
452
459
  if (this.ended || !this.clients.has(client.id) || !socket.isOpen()) {
453
460
  client.awaitingSnapshot = false;
461
+ client.snapshotCatchUp = [];
454
462
  return;
455
463
  }
456
464
  if (serialized === null) {
457
- // Raw fallback reads the ring as it stands NOW — everything the
458
- // mute withheld is inside it. Unmute first so nothing new slips
459
- // into the erased-by-reset gap either (raw has no RIS).
465
+ // Raw fallback reads the ring as it stands NOW. Unmute first so
466
+ // nothing new slips into the handoff (raw has no in-band RIS).
460
467
  client.awaitingSnapshot = false;
468
+ client.snapshotCatchUp = [];
461
469
  sendRawSnapshot();
462
470
  wiggle();
463
471
  return;
@@ -473,16 +481,17 @@ export class Room extends EventEmitter {
473
481
  // Catch-up: output that arrived while the snapshot was being
474
482
  // serialized. Appended INSIDE the snapshot payload so snapshot and
475
483
  // delta land as one atomic parse — no frame between reset and delta.
476
- const catchUp = this.getOutputSince(catchUpFrom);
484
+ const catchUp = client.snapshotCatchUp.join("");
477
485
  socket.send(JSON.stringify({
478
486
  type: "snapshot",
479
487
  // In-band RIS: every client applies the serialized screen onto a
480
488
  // clean slate, including ones that don't reset on their own.
481
- data: "\x1bc" + serialized + modeSeq + (catchUp.reset ? "" : catchUp.data),
489
+ data: "\x1bc" + serialized + modeSeq + catchUp,
482
490
  capped: this.outputBytes > SERIALIZED_CAPPED_HINT_BYTES,
483
491
  ...snapshotFlags()
484
492
  } satisfies ServerMessage));
485
493
  client.awaitingSnapshot = false;
494
+ client.snapshotCatchUp = [];
486
495
  // No wiggle: the serialized grid IS the current screen.
487
496
  }, serializedScrollback);
488
497
  } else {
@@ -564,16 +573,24 @@ export class Room extends EventEmitter {
564
573
 
565
574
  private updateTerminalState(data: string) {
566
575
  const combined = this.controlSequenceTail + data;
576
+ const tailLength = this.controlSequenceTail.length;
577
+ const risIndex = combined.lastIndexOf("\x1bc");
578
+ // Only reset tracked state for a RIS that ends in this chunk; one entirely
579
+ // inside the carry tail was already applied on the preceding call. Always
580
+ // ignore bytes before the last RIS so carried pre-reset mode enables cannot
581
+ // be parsed again on the next chunk.
582
+ const hasNewRis = risIndex !== -1 && risIndex + 2 > tailLength;
583
+ const stateInput = risIndex !== -1 ? combined.slice(risIndex + 2) : combined;
567
584
 
568
585
  // Parse CSI mode sequences (cursor visibility, alternate screen)
569
586
  const regex = /\x1b\[\?([0-9;]*)([hl])/g;
570
587
  let match: RegExpExecArray | null;
571
- let nextAlternate = this.alternateScreen;
572
- let nextCursorHidden = this.cursorHidden;
588
+ let nextAlternate = hasNewRis ? false : this.alternateScreen;
589
+ let nextCursorHidden = hasNewRis ? false : this.cursorHidden;
573
590
 
574
- let nextMouseReporting = this.mouseReporting;
575
- let nextMouseSgr = this.mouseSgr;
576
- while ((match = regex.exec(combined)) !== null) {
591
+ let nextMouseReporting = hasNewRis ? false : this.mouseReporting;
592
+ let nextMouseSgr = hasNewRis ? false : this.mouseSgr;
593
+ while ((match = regex.exec(stateInput)) !== null) {
577
594
  const params = match[1].split(";").filter(Boolean);
578
595
  const mode = match[2];
579
596
  for (const param of params) {
@@ -593,10 +610,10 @@ export class Room extends EventEmitter {
593
610
 
594
611
  // Enhanced keyboard reporting: kitty keyboard protocol (CSI >flags u push /
595
612
  // CSI <n u pop / CSI =flags u set) and xterm modifyOtherKeys (CSI >4;n m).
596
- let nextKeyboardEnhanced = this.keyboardEnhanced;
613
+ let nextKeyboardEnhanced = hasNewRis ? false : this.keyboardEnhanced;
597
614
  const kbdRegex = /\x1b\[([<>=])[0-9;:]*u|\x1b\[>([0-9;]*)m/g;
598
615
  let kbdMatch: RegExpExecArray | null;
599
- while ((kbdMatch = kbdRegex.exec(combined)) !== null) {
616
+ while ((kbdMatch = kbdRegex.exec(stateInput)) !== null) {
600
617
  if (kbdMatch[1] !== undefined) {
601
618
  nextKeyboardEnhanced = kbdMatch[1] !== "<"; // push/set = on, pop = off
602
619
  } else {
@@ -954,7 +971,10 @@ export class Room extends EventEmitter {
954
971
  // serialize marker). That silent loss was 'the screen is stale until
955
972
  // the app repaints' on every attach that raced live output. The
956
973
  // withheld bytes are delivered as catch-up appended to the snapshot.
957
- if (isOutput && client.awaitingSnapshot) continue;
974
+ if (isOutput && client.awaitingSnapshot) {
975
+ client.snapshotCatchUp.push((message as { data: string }).data);
976
+ continue;
977
+ }
958
978
  if (client.socket.isOpen()) {
959
979
  client.socket.send(payload);
960
980
  }
@@ -1052,13 +1072,38 @@ export class Room extends EventEmitter {
1052
1072
  }
1053
1073
  }
1054
1074
 
1055
- // Grids are LAZY: created on the first serialized attach, not on output,
1056
- // so a room nobody ever opens (agent fleets) pays no grid memory at all.
1057
- // Creation hydrates from a bounded ring tail — deep enough to contain any
1058
- // app's last full repaint plus every update since, which is all screen
1059
- // correctness needs; serialized scrollback is bounded anyway.
1075
+ // Incremental TUIs paint some rows once and then update other regions for
1076
+ // hours. Reconstructing their first grid from any bounded tail can therefore
1077
+ // omit cells until the app happens to repaint (for example, on scroll).
1078
+ // Begin tracking at TUI entry; dependency loading is shared and normally
1079
+ // finishes first, but the callback covers a TUI that starts immediately.
1080
+ private startTuiGrid() {
1081
+ if (!SERIALIZED_ATTACH_ENABLED || this.grid || this.gridStartPending || this.gridUnavailable || this.ended) return;
1082
+ if (!this.alternateScreen && !this.mouseReporting) return;
1083
+ if (screenGridsAvailable()) {
1084
+ this.gridUnavailable = this.ensureGrid() === null;
1085
+ return;
1086
+ }
1087
+ this.gridStartPending = true;
1088
+ this.gridPendingSeed = [this.tailOutput(MAX_BUFFER_SIZE)];
1089
+ this.gridPendingSeedBytes = this.gridPendingSeed[0].length;
1090
+ void loadScreenGridDeps().then((available) => {
1091
+ this.gridStartPending = false;
1092
+ this.gridUnavailable = !available;
1093
+ const seed = this.gridPendingSeed?.join("") ?? "";
1094
+ this.gridPendingSeed = null;
1095
+ this.gridPendingSeedBytes = 0;
1096
+ if (available && !this.ended) {
1097
+ this.gridUnavailable = this.ensureGrid(seed) === null;
1098
+ }
1099
+ });
1100
+ }
1101
+
1102
+ // Plain-shell grids remain lazy until the first serialized attach. Creation
1103
+ // hydrates from a bounded ring tail; append-only shell output is safely
1104
+ // reconstructible from that tail, unlike the incremental TUI path above.
1060
1105
  private static readonly GRID_HYDRATE_BYTES = 2_000_000;
1061
- private ensureGrid(): ScreenGrid | null {
1106
+ private ensureGrid(seed?: string): ScreenGrid | null {
1062
1107
  if (!SERIALIZED_ATTACH_ENABLED || this.ended) return this.grid;
1063
1108
  if (!this.grid && screenGridsAvailable()) {
1064
1109
  this.grid = createScreenGrid(this.activeCols, this.activeRows);
@@ -1067,8 +1112,8 @@ export class Room extends EventEmitter {
1067
1112
  if (this.outputBytes > 0) {
1068
1113
  // Cut at a line boundary (same trick as boundSnapshotReplay) so a
1069
1114
  // slice landing mid-escape-sequence can't garble the parse head.
1070
- let tail = this.tailOutput(Room.GRID_HYDRATE_BYTES);
1071
- if (this.outputBytes > tail.length) {
1115
+ let tail = seed ?? this.tailOutput(Room.GRID_HYDRATE_BYTES);
1116
+ if (seed === undefined && this.outputBytes > tail.length) {
1072
1117
  const nl = tail.indexOf("\n");
1073
1118
  if (nl !== -1 && nl < 8192) tail = tail.slice(nl + 1);
1074
1119
  }
@@ -1082,6 +1127,20 @@ export class Room extends EventEmitter {
1082
1127
  /** O(1) append; trims whole chunks from the head past MAX_BUFFER_SIZE. */
1083
1128
  private appendOutput(data: string) {
1084
1129
  if (!data) return;
1130
+ if (this.gridPendingSeed) {
1131
+ this.gridPendingSeed.push(data);
1132
+ this.gridPendingSeedBytes += data.length;
1133
+ while (this.gridPendingSeed.length > 1
1134
+ && this.gridPendingSeedBytes - this.gridPendingSeed[0].length >= MAX_BUFFER_SIZE) {
1135
+ this.gridPendingSeedBytes -= this.gridPendingSeed[0].length;
1136
+ this.gridPendingSeed.shift();
1137
+ }
1138
+ if (this.gridPendingSeed.length === 1 && this.gridPendingSeedBytes > MAX_BUFFER_SIZE) {
1139
+ const tail = this.gridPendingSeed[0].slice(-MAX_BUFFER_SIZE);
1140
+ this.gridPendingSeed[0] = tail;
1141
+ this.gridPendingSeedBytes = tail.length;
1142
+ }
1143
+ }
1085
1144
  this.outputChunks.push(data);
1086
1145
  this.outputBytes += data.length;
1087
1146
  while (this.outputChunks.length > 1 && this.outputBytes - this.outputChunks[0].length >= MAX_BUFFER_SIZE) {
@@ -1156,6 +1215,29 @@ export class Room extends EventEmitter {
1156
1215
  return { cols: this.activeCols, rows: this.activeRows, output: this.tailOutput(maxBytes) };
1157
1216
  }
1158
1217
 
1218
+ /**
1219
+ * The room's current screen, serialized — IDENTICAL in geometry and bytes
1220
+ * to what a live attach snapshots. Previews painted from this cannot
1221
+ * disagree with the terminal a click turns them into: that disagreement
1222
+ * (daemon-side grids rendering at their own capped size while the room ran
1223
+ * at another) was the preview → terminal size change. Modes ride in-band
1224
+ * like the attach path so cursor/mouse state present identically too.
1225
+ */
1226
+ serializeScreen(
1227
+ callback: (result: { data: string; cols: number; rows: number } | null) => void,
1228
+ scrollback = 200
1229
+ ) {
1230
+ if (this.outputBytes === 0) { callback(null); return; }
1231
+ const grid = this.ensureGrid();
1232
+ if (!grid) { callback(null); return; }
1233
+ grid.serialize((serialized) => {
1234
+ if (serialized === null) { callback(null); return; }
1235
+ const modeSeq = (this.mouseReporting ? "\x1b[?1002h" + (this.mouseSgr ? "\x1b[?1006h" : "") : "")
1236
+ + (this.cursorHidden ? "\x1b[?25l" : "");
1237
+ callback({ data: "\x1bc" + serialized + modeSeq, cols: this.activeCols, rows: this.activeRows });
1238
+ }, scrollback);
1239
+ }
1240
+
1159
1241
  /**
1160
1242
  * Incremental output for a persistent consumer-side screen parser (the
1161
1243
  * daemon's preview grids). `since` is an absolute cursor from a previous
@@ -1295,6 +1377,16 @@ export class RoomManager {
1295
1377
  return room ? room.getPreviewSource(maxBytes) : null;
1296
1378
  }
1297
1379
 
1380
+ serializeRoomScreen(
1381
+ id: string,
1382
+ callback: (result: { data: string; cols: number; rows: number } | null) => void,
1383
+ scrollback?: number
1384
+ ) {
1385
+ const room = this.rooms.get(id);
1386
+ if (!room) { callback(null); return; }
1387
+ room.serializeScreen(callback, scrollback);
1388
+ }
1389
+
1298
1390
  getRoomOutputSince(id: string, since?: number, maxBytes?: number) {
1299
1391
  const room = this.rooms.get(id);
1300
1392
  return room ? room.getOutputSince(since, maxBytes) : null;
@@ -117,7 +117,17 @@ export const createScreenGrid = (cols: number, rows: number): ScreenGrid | null
117
117
  },
118
118
  resize(nextCols: number, nextRows: number) {
119
119
  if (disposed) return;
120
- try { term.resize(Math.max(2, nextCols), Math.max(1, nextRows)); } catch { /* keep old size */ }
120
+ const cols = Math.max(2, nextCols);
121
+ const rows = Math.max(1, nextRows);
122
+ try {
123
+ // xterm parses write() asynchronously. Queue the resize behind every
124
+ // earlier byte so room order stays PTY output → SIGWINCH, while later
125
+ // writes naturally queue behind this callback's synchronous resize.
126
+ term.write("", () => {
127
+ if (disposed) return;
128
+ try { term.resize(cols, rows); } catch { /* keep old size */ }
129
+ });
130
+ } catch { /* keep old size */ }
121
131
  },
122
132
  serialize(callback: (data: string | null) => void, scrollback: number = GRID_SCROLLBACK) {
123
133
  if (disposed) { callback(null); return; }
@@ -709,7 +709,7 @@ describe("Room", () => {
709
709
  });
710
710
 
711
711
  describe("serialized attach", () => {
712
- const setup = async (roomId: string) => {
712
+ const setup = async (roomId: string, initialSize = { cols: 80, rows: 24 }) => {
713
713
  const { loadScreenGridDeps } = await import("../src/screenGrid");
714
714
  expect(await loadScreenGridDeps()).toBe(true);
715
715
  let ptyInstance: FakePty | null = null;
@@ -718,7 +718,7 @@ describe("Room", () => {
718
718
  return ptyInstance as any;
719
719
  };
720
720
  const manager = new RoomManager(factory);
721
- const room = manager.getRoom(roomId, { cols: 80, rows: 24 }, "/tmp");
721
+ const room = manager.getRoom(roomId, initialSize, "/tmp");
722
722
  return { room, pty: () => ptyInstance! };
723
723
  };
724
724
 
@@ -766,6 +766,53 @@ describe("Room", () => {
766
766
  expect(snapshot.mouseReporting).toBe(true);
767
767
  });
768
768
 
769
+ it("tracks TUI rows before they age out of the grid hydration tail", async () => {
770
+ const { room, pty } = await setup("early-grid");
771
+ pty().emit("\x1b[?1002h\x1b[?1006h");
772
+ pty().emit("\x1b[2J\x1b[1;1HROW1-EARLY-GRID-MARKER");
773
+ // Repaint only row 20 for more than the lazy grid's 2MB hydration
774
+ // window. A grid first created at attach would have no row 1 to send.
775
+ const updateBatch = ("\x1b[20;1H\x1b[K" + "x".repeat(60)).repeat(64);
776
+ for (let i = 0; i < 500; i++) pty().emit(updateBatch);
777
+
778
+ const socket = new FakeSocket();
779
+ room.attachClient(
780
+ { id: "late", name: "Late", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
781
+ socket
782
+ );
783
+ const snapshot = await waitForSnapshot(socket);
784
+ expect(String(snapshot.data)).toContain("ROW1-EARLY-GRID-MARKER");
785
+ expect(room.getPreviewSource(2_000_000).output).not.toContain("ROW1-EARLY-GRID-MARKER");
786
+ });
787
+
788
+ it("parses queued screen output before applying a later resize", async () => {
789
+ const { room, pty } = await setup("ordered-grid-resize", { cols: 80, rows: 60 });
790
+ const owner = new FakeSocket();
791
+ room.attachClient(
792
+ { id: "owner", name: "Owner", colorIndex: 0, cols: 80, rows: 60, replayBytes: 0, nudge: false },
793
+ owner
794
+ );
795
+ const rows = Array.from({ length: 60 }, (_, i) => {
796
+ const row = String(i + 1).padStart(2, "0");
797
+ return `\x1b[${i + 1};1HROW-${row}`;
798
+ }).join("");
799
+ pty().emit("\x1b[?1049h\x1b[2J" + rows);
800
+
801
+ // PTY order is old-size output, then SIGWINCH. The headless grid used
802
+ // to resize synchronously while its earlier write was still queued,
803
+ // clamping rows 25-59 away before a late attach serialized the screen.
804
+ owner.emitMessage({ type: "resize", cols: 80, rows: 25, user: true });
805
+ const late = new FakeSocket();
806
+ room.attachClient(
807
+ { id: "late", name: "Late", colorIndex: 1, cols: 80, rows: 25, replayBytes: 65536 },
808
+ late
809
+ );
810
+ const snapshot = await waitForSnapshot(late);
811
+ for (let row = 36; row <= 60; row++) {
812
+ expect(String(snapshot.data)).toContain(`ROW-${String(row).padStart(2, "0")}`);
813
+ }
814
+ });
815
+
769
816
  it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
770
817
  const { room, pty } = await setup("capped");
771
818
  const big = "\x1b[20;1H" + "y".repeat(4096);
@@ -810,6 +857,71 @@ describe("Room", () => {
810
857
  expect(after.some((m) => String(m.data).includes("LATE-MARKER-XYZ"))).toBe(false);
811
858
  });
812
859
 
860
+ it("retains catch-up larger than the incremental preview limit", async () => {
861
+ const { room, pty } = await setup("large-race");
862
+ pty().emit("\x1b[?1002h\x1b[2J\x1b[1;1Hbefore large catch-up");
863
+ const socket = new FakeSocket();
864
+ room.attachClient(
865
+ { id: "large-racer", name: "Large Racer", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
866
+ socket
867
+ );
868
+ // Larger than getOutputSince's 1MB default: cursor-based catch-up used
869
+ // to reset here and silently drop every withheld byte.
870
+ const late = "x".repeat(1_100_000) + "LARGE-CATCHUP-MARKER";
871
+ pty().emit(late);
872
+ const snapshot = await waitForSnapshot(socket);
873
+ expect(String(snapshot.data)).toContain("LARGE-CATCHUP-MARKER");
874
+ const live = readMessages(socket).filter((m) => m.type === "output");
875
+ expect(live.some((m) => String(m.data).includes("LARGE-CATCHUP-MARKER"))).toBe(false);
876
+ });
877
+
878
+ it("clears tracked private modes when the app sends RIS", async () => {
879
+ const { room, pty } = await setup("ris-modes");
880
+ pty().emit("\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[?25l\x1b[>1u");
881
+ // Split ESC c across chunks to cover the control-sequence carry path.
882
+ pty().emit("\x1b");
883
+ pty().emit("c\x1b[2J\x1b[1;1HAFTER-RIS");
884
+ // A later chunk must not reparse mode enables that remain before RIS in
885
+ // the control-sequence carry buffer.
886
+ pty().emit("plain");
887
+ const socket = new FakeSocket();
888
+ room.attachClient(
889
+ { id: "after-ris", name: "After RIS", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
890
+ socket
891
+ );
892
+ const snapshot = await waitForSnapshot(socket);
893
+ expect(String(snapshot.data)).toContain("AFTER-RIS");
894
+ expect(snapshot.alternateScreen).toBe(false);
895
+ expect(snapshot.mouseReporting).toBe(false);
896
+ expect(snapshot.mouseSgr).toBe(false);
897
+ expect(snapshot.cursorHidden).toBe(false);
898
+ expect(snapshot.keyboardEnhanced).toBe(false);
899
+ });
900
+
901
+ // The preview → terminal size change, killed at the root: a preview must
902
+ // be painted from the SAME grid, at the SAME geometry, as the snapshot a
903
+ // live attach would receive.
904
+ it("serializeScreen matches the live room's geometry and carries modes", async () => {
905
+ const { room, pty } = await setup("faithful");
906
+ pty().emit("\x1b[?1002h\x1b[?1006h");
907
+ pty().emit("\x1b[2J\x1b[1;1HFAITHFUL-MARKER");
908
+ const result = await new Promise((resolve) => room.serializeScreen(resolve));
909
+ expect(result).not.toBeNull();
910
+ expect((result as any).cols).toBe(80);
911
+ expect((result as any).rows).toBe(24);
912
+ expect(String((result as any).data).startsWith("\x1bc")).toBe(true);
913
+ expect(String((result as any).data)).toContain("FAITHFUL-MARKER");
914
+ expect(String((result as any).data)).toContain("\x1b[?1002h");
915
+ // After a size claim, the serialized geometry follows the room.
916
+ const socket = new FakeSocket();
917
+ room.attachClient({ id: "typer", name: "T", colorIndex: 0, cols: 100, rows: 30 }, socket);
918
+ socket.emitMessage({ type: "input", data: "x" });
919
+ socket.emitMessage({ type: "resize", cols: 100, rows: 30 });
920
+ const after = await new Promise((resolve) => room.serializeScreen(resolve));
921
+ expect((after as any).cols).toBe(100);
922
+ expect((after as any).rows).toBe(30);
923
+ });
924
+
813
925
  it("replay=0 means no snapshot at all — live stream only", async () => {
814
926
  const { room, pty } = await setup("claim");
815
927
  pty().emit("some prior output\r\n");
@@ -113,6 +113,59 @@ const runE2E = async () => {
113
113
  const switchedBuffer = await page1.evaluate(() => window.__hay?.getBufferText() || "");
114
114
  assert.ok(!switchedBuffer.includes("shared"), `old room output survived session switch: ${JSON.stringify(switchedBuffer)}`);
115
115
 
116
+ // A resize can collapse scrollback to y=base=0 without xterm emitting an
117
+ // onScroll event. App follow-mode must reconcile that state before the
118
+ // next real room output grows below a viewport parked at 0.
119
+ const followAfterResize = await page1.evaluate(async () => {
120
+ const terminal = window.__hay.terminal;
121
+ const write = (data) => new Promise((resolve) => terminal.write(data, resolve));
122
+ terminal.reset();
123
+ terminal.resize(20, 5);
124
+ await write(Array.from({ length: 20 }, (_, i) => `old-${i}\r\n`).join(""));
125
+ terminal.scrollLines(-2);
126
+ const before = {
127
+ y: terminal.buffer.active.viewportY,
128
+ base: terminal.buffer.active.baseY
129
+ };
130
+ terminal.resize(20, 30);
131
+ const afterResize = {
132
+ y: terminal.buffer.active.viewportY,
133
+ base: terminal.buffer.active.baseY
134
+ };
135
+ return { before, afterResize };
136
+ });
137
+ assert.ok(followAfterResize.before.y < followAfterResize.before.base, "follow-latch setup did not scroll above bottom");
138
+ assert.deepEqual(followAfterResize.afterResize, { y: 0, base: 0 }, "resize did not collapse the test scrollback");
139
+
140
+ // Deliver the new rows through the room, so this covers App's actual
141
+ // output/follow path rather than xterm's behavior for a direct test write.
142
+ await page1.evaluate(async ({ roomName, data }) => {
143
+ const peer = new WebSocket(
144
+ `ws://${location.host}/ws?room=${encodeURIComponent(roomName)}&name=FollowPeer&cols=20&rows=30`
145
+ );
146
+ await new Promise((resolve, reject) => {
147
+ peer.onopen = resolve;
148
+ peer.onerror = reject;
149
+ });
150
+ window.__followPeer = peer;
151
+ peer.send(JSON.stringify({ type: "input", data }));
152
+ }, {
153
+ roomName: freshRoom,
154
+ data: Array.from({ length: 15 }, (_, i) => `new-${i}\r\n`).join("")
155
+ });
156
+ await page1.waitForFunction(() => window.__hay.terminal.buffer.active.baseY > 0);
157
+ followAfterResize.afterWrite = await page1.evaluate(() => ({
158
+ y: window.__hay.terminal.buffer.active.viewportY,
159
+ base: window.__hay.terminal.buffer.active.baseY
160
+ }));
161
+ await page1.evaluate(() => window.__followPeer?.close());
162
+ assert.ok(followAfterResize.afterWrite.base > 0, "follow-latch test did not regrow scrollback");
163
+ assert.equal(
164
+ followAfterResize.afterWrite.y,
165
+ followAfterResize.afterWrite.base,
166
+ `terminal stopped following after resize: ${JSON.stringify(followAfterResize)}`
167
+ );
168
+
116
169
  const roomLock = `e2e-${Date.now()}-lock`;
117
170
  const context2 = await browser.newContext({ viewport: { width: 1280, height: 720 } });
118
171
  const page3 = await context2.newPage();
@@ -519,6 +519,16 @@ const App = () => {
519
519
  const shouldReconnectRef = useRef(true);
520
520
  const connectNonceRef = useRef(0);
521
521
  const userScrolledUpRef = useRef(false);
522
+ // A resize can collapse a previously scrolled viewport to y=base=0 without
523
+ // clearing xterm's internal user-scroll state. The next output must repair
524
+ // follow mode after xterm has actually grown the buffer again.
525
+ const followAfterResizeRef = useRef(false);
526
+ // Set by every write into the terminal; the render watchdog only spends a
527
+ // refresh when something actually changed since its last tick.
528
+ const outputSinceTickRef = useRef(true);
529
+ // Timestamp of the last bytes from the session — the ack signal that
530
+ // paces synthetic wheel steps (see scrollFlywheel appGlide).
531
+ const lastOutputAtRef = useRef(0);
522
532
  // Late-bound hooks for the voice controller (created before these exist).
523
533
  const handleUserInputRef = useRef<((data: string) => void) | null>(null);
524
534
  const pushNoticeRef = useRef<((m: string) => void) | null>(null);
@@ -928,8 +938,18 @@ const App = () => {
928
938
  return presence.find((client) => client.id === controllerId)?.name ?? "Someone";
929
939
  }, [presence, controllerId]);
930
940
 
941
+ // Local echo policy. Exclusive control keeps the old rule. In COLLAB mode
942
+ // (the default for every shared session) echo used to be flatly OFF — so
943
+ // each character waited a full round trip, tunnel RTT included, even when
944
+ // you were the only person typing, which is the overwhelmingly common
945
+ // case. Echo now stays on unless another client is ACTUALLY typing right
946
+ // now (presence carries the flag), and drops the instant one starts. A
947
+ // sub-second overlap before presence propagates is absorbed by the
948
+ // reconciler's guards (complex-chunk passthrough, two-strike clear).
949
+ const othersTyping = presence.some((c) => c.id !== clientId && c.typing);
931
950
  const optimisticActive =
932
- LATENCY_COMP && status === "connected" && !collabMode && controllerId === clientId;
951
+ LATENCY_COMP && status === "connected" && !othersTyping
952
+ && (collabMode ? true : controllerId === clientId);
933
953
 
934
954
  useEffect(() => {
935
955
  if (optimisticPrevRef.current && !optimisticActive) {
@@ -1251,12 +1271,19 @@ const App = () => {
1251
1271
  if (!termRef.current) {
1252
1272
  return;
1253
1273
  }
1274
+ outputSinceTickRef.current = true;
1275
+ lastOutputAtRef.current = performance.now(); // same clock the flywheel compares
1254
1276
 
1255
1277
  // Filter focus reporting sequences that can leak as visible text
1256
1278
  const filtered = data.replace(/\x1b\[I/g, '').replace(/\x1b\[O/g, '');
1279
+ const repairFollowAfterResize = followAfterResizeRef.current;
1257
1280
  termRef.current.write(filtered, () => {
1281
+ if (repairFollowAfterResize) {
1282
+ followAfterResizeRef.current = false;
1283
+ userScrolledUpRef.current = false;
1284
+ }
1258
1285
  // Auto-scroll to bottom unless the user has explicitly scrolled up
1259
- if (!userScrolledUpRef.current) {
1286
+ if (repairFollowAfterResize || !userScrolledUpRef.current) {
1260
1287
  termRef.current?.scrollToBottom();
1261
1288
  }
1262
1289
  });
@@ -1301,6 +1328,12 @@ const App = () => {
1301
1328
  activeSizeRef.current = null;
1302
1329
  optimisticEchoRef.current.reset();
1303
1330
  userScrolledUpRef.current = false;
1331
+ followAfterResizeRef.current = false;
1332
+ // reset() can emit onScroll(0). Clear the previous room's history guards
1333
+ // first so that event cannot masquerade as a real scroll-to-top gesture.
1334
+ lastViewportYRef.current = 0;
1335
+ snapshotSettledAtRef.current = 0;
1336
+ snapshotWasCappedRef.current = false;
1304
1337
  termRef.current?.reset();
1305
1338
 
1306
1339
  // Fast first paint: the daemon can serialize the session's CURRENT
@@ -1339,6 +1372,9 @@ const App = () => {
1339
1372
  // snapshot and must land where the snapshot would.
1340
1373
  t.scrollToBottom();
1341
1374
  t.refresh(0, t.rows - 1);
1375
+ // This write bypasses writeToTerminal(). Re-arm the delayed render
1376
+ // invariant after parsing, so a missed WebGL frame self-heals.
1377
+ outputSinceTickRef.current = true;
1342
1378
  });
1343
1379
  })
1344
1380
  .catch(() => { /* fast paint is best-effort */ });
@@ -1504,6 +1540,10 @@ const App = () => {
1504
1540
  userScrolledUpRef.current = false;
1505
1541
  }
1506
1542
  t.refresh(0, t.rows - 1);
1543
+ // Snapshot writes bypass writeToTerminal(). Set this only after
1544
+ // parsing so a slow snapshot cannot have its retry consumed
1545
+ // before there is anything complete to repaint.
1546
+ outputSinceTickRef.current = true;
1507
1547
  // The snapshot has fully landed: only from here on can a
1508
1548
  // viewport-top event mean a user gesture.
1509
1549
  lastViewportYRef.current = t.buffer.active.viewportY;
@@ -1699,7 +1739,8 @@ const App = () => {
1699
1739
  () => termRef.current,
1700
1740
  {
1701
1741
  linesPerNotch: 4, // mirrors scrollSensitivity above
1702
- lineHeightPx: () => Math.max(1, ((terminal as any)._core?._renderService?.dimensions?.css?.cell?.height) || fontSize * 1.3)
1742
+ lineHeightPx: () => Math.max(1, ((terminal as any)._core?._renderService?.dimensions?.css?.cell?.height) || fontSize * 1.3),
1743
+ lastOutputAt: () => lastOutputAtRef.current
1703
1744
  }
1704
1745
  );
1705
1746
  }
@@ -1918,6 +1959,30 @@ const App = () => {
1918
1959
  syncOverlayScrollbar();
1919
1960
  };
1920
1961
  terminal.onScroll(onBufferScroll);
1962
+ // xterm does not always emit onScroll when a resize collapses scrollback.
1963
+ // Its public y/base can then say "bottom" while both our follow flag and
1964
+ // xterm's private user-scroll latch remain stuck above it. Reconcile every
1965
+ // resize from buffer truth and remember to repair follow mode after the
1966
+ // next write has regrown the buffer (scrollToBottom is a no-op at 0/0).
1967
+ terminal.onResize(() => {
1968
+ // onResize fires inside xterm's resize transaction, before its private
1969
+ // user-scroll latch has fully settled. Reconcile just after resize()
1970
+ // returns, still ahead of terminal writes queued later in the turn.
1971
+ queueMicrotask(() => {
1972
+ if (termRef.current !== terminal) return;
1973
+ const { y, base } = scrollState();
1974
+ lastViewportYRef.current = y;
1975
+ if (y >= base) {
1976
+ userScrolledUpRef.current = false;
1977
+ followAfterResizeRef.current = true;
1978
+ terminal.scrollToBottom();
1979
+ } else {
1980
+ userScrolledUpRef.current = true;
1981
+ followAfterResizeRef.current = false;
1982
+ }
1983
+ syncOverlayScrollbar();
1984
+ });
1985
+ });
1921
1986
  // Buffer growth while following doesn't always fire onScroll — a light
1922
1987
  // interval keeps the thumb honest.
1923
1988
  const overlayInterval = window.setInterval(syncOverlayScrollbar, 700);
@@ -2565,6 +2630,12 @@ const App = () => {
2565
2630
  if (document.hidden || switcherOpenRef.current) return;
2566
2631
  const t = termRef.current;
2567
2632
  if (!t) return;
2633
+ // Idle sessions get no tick: a full refresh every 1.4s on a screen
2634
+ // nothing wrote to re-rasterizes identical rows forever (measurable
2635
+ // on battery with several sessions open). Output sets the flag; one
2636
+ // enforcement pass clears it.
2637
+ if (!outputSinceTickRef.current) return;
2638
+ outputSinceTickRef.current = false;
2568
2639
  try {
2569
2640
  const buf = t.buffer.active;
2570
2641
  if (!userScrolledUpRef.current && buf.viewportY < buf.baseY) t.scrollToBottom();