hop2 0.11.10 → 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
@@ -34,6 +34,7 @@
34
34
 
35
35
  #### Web terminal
36
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.
37
38
 
38
39
  #### Session restore
39
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).
@@ -575,11 +575,12 @@ export class Room extends EventEmitter {
575
575
  const combined = this.controlSequenceTail + data;
576
576
  const tailLength = this.controlSequenceTail.length;
577
577
  const risIndex = combined.lastIndexOf("\x1bc");
578
- // Only process a RIS that ends in this chunk; one entirely inside the
579
- // carry tail was already applied on the preceding call. Modes before the
580
- // last new RIS are reset, while later sequences in the same chunk win.
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.
581
582
  const hasNewRis = risIndex !== -1 && risIndex + 2 > tailLength;
582
- const stateInput = hasNewRis ? combined.slice(risIndex + 2) : combined;
583
+ const stateInput = risIndex !== -1 ? combined.slice(risIndex + 2) : combined;
583
584
 
584
585
  // Parse CSI mode sequences (cursor visibility, alternate screen)
585
586
  const regex = /\x1b\[\?([0-9;]*)([hl])/g;
@@ -1214,6 +1215,29 @@ export class Room extends EventEmitter {
1214
1215
  return { cols: this.activeCols, rows: this.activeRows, output: this.tailOutput(maxBytes) };
1215
1216
  }
1216
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
+
1217
1241
  /**
1218
1242
  * Incremental output for a persistent consumer-side screen parser (the
1219
1243
  * daemon's preview grids). `since` is an absolute cursor from a previous
@@ -1353,6 +1377,16 @@ export class RoomManager {
1353
1377
  return room ? room.getPreviewSource(maxBytes) : null;
1354
1378
  }
1355
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
+
1356
1390
  getRoomOutputSince(id: string, since?: number, maxBytes?: number) {
1357
1391
  const room = this.rooms.get(id);
1358
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
 
@@ -785,6 +785,34 @@ describe("Room", () => {
785
785
  expect(room.getPreviewSource(2_000_000).output).not.toContain("ROW1-EARLY-GRID-MARKER");
786
786
  });
787
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
+
788
816
  it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
789
817
  const { room, pty } = await setup("capped");
790
818
  const big = "\x1b[20;1H" + "y".repeat(4096);
@@ -853,6 +881,9 @@ describe("Room", () => {
853
881
  // Split ESC c across chunks to cover the control-sequence carry path.
854
882
  pty().emit("\x1b");
855
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");
856
887
  const socket = new FakeSocket();
857
888
  room.attachClient(
858
889
  { id: "after-ris", name: "After RIS", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
@@ -867,6 +898,30 @@ describe("Room", () => {
867
898
  expect(snapshot.keyboardEnhanced).toBe(false);
868
899
  });
869
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
+
870
925
  it("replay=0 means no snapshot at all — live stream only", async () => {
871
926
  const { room, pty } = await setup("claim");
872
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,10 @@ 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);
522
526
  // Set by every write into the terminal; the render watchdog only spends a
523
527
  // refresh when something actually changed since its last tick.
524
528
  const outputSinceTickRef = useRef(true);
@@ -1272,9 +1276,14 @@ const App = () => {
1272
1276
 
1273
1277
  // Filter focus reporting sequences that can leak as visible text
1274
1278
  const filtered = data.replace(/\x1b\[I/g, '').replace(/\x1b\[O/g, '');
1279
+ const repairFollowAfterResize = followAfterResizeRef.current;
1275
1280
  termRef.current.write(filtered, () => {
1281
+ if (repairFollowAfterResize) {
1282
+ followAfterResizeRef.current = false;
1283
+ userScrolledUpRef.current = false;
1284
+ }
1276
1285
  // Auto-scroll to bottom unless the user has explicitly scrolled up
1277
- if (!userScrolledUpRef.current) {
1286
+ if (repairFollowAfterResize || !userScrolledUpRef.current) {
1278
1287
  termRef.current?.scrollToBottom();
1279
1288
  }
1280
1289
  });
@@ -1319,6 +1328,12 @@ const App = () => {
1319
1328
  activeSizeRef.current = null;
1320
1329
  optimisticEchoRef.current.reset();
1321
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;
1322
1337
  termRef.current?.reset();
1323
1338
 
1324
1339
  // Fast first paint: the daemon can serialize the session's CURRENT
@@ -1944,6 +1959,30 @@ const App = () => {
1944
1959
  syncOverlayScrollbar();
1945
1960
  };
1946
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
+ });
1947
1986
  // Buffer growth while following doesn't always fire onScroll — a light
1948
1987
  // interval keeps the thumb honest.
1949
1988
  const overlayInterval = window.setInterval(syncOverlayScrollbar, 700);
@@ -11,7 +11,6 @@ import {
11
11
  } from "react";
12
12
  import { Terminal } from "@xterm/xterm";
13
13
  import { FitAddon } from "@xterm/addon-fit";
14
- import { WebglAddon } from "@xterm/addon-webgl";
15
14
  import { attachScrollFlywheel } from "../utils/scrollFlywheel";
16
15
  import { ContextMenu, type MenuRequest } from "./ContextMenu";
17
16
  import { collectTerminalMatches, selectTerminalMatch } from "../utils/terminalSearch";
@@ -829,24 +828,14 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
829
828
  // terminal has had it all along, which is most of why a live tile felt
830
829
  // less responsive than full screen. Only the live tile: browsers cap
831
830
  // concurrent WebGL contexts (~16) and a wall can hold more tiles.
832
- let webgl: WebglAddon | null = null;
833
- let webglWanted = true;
834
- const loadGpu = () => {
835
- // Deferred until the first live snapshot has painted: the renderer
836
- // swap is itself a visible flash, so it happens once the tile already
837
- // shows the same pixels it will re-render — invisible in practice.
838
- if (!webglWanted || webgl) return;
839
- try {
840
- webgl = new WebglAddon();
841
- webgl.onContextLoss(() => { webgl?.dispose(); webgl = null; });
842
- term.loadAddon(webgl);
843
- term.refresh(0, term.rows - 1);
844
- } catch {
845
- try { webgl?.dispose(); } catch { /* half-constructed */ }
846
- webgl = null;
847
- webglWanted = false;
848
- }
849
- };
831
+ // ONE renderer for the wall, in BOTH modes. WebGL used to load only when
832
+ // a tile went live, so the flip changed glyph quantization mid-swap (the
833
+ // WebGL renderer floors cell widths to device pixels; the DOM renderer
834
+ // doesn't) — a size change no metric-matching could fully erase, plus a
835
+ // browser cap of ~16 WebGL contexts a big wall can exceed, plus the
836
+ // texture-atlas blank-cell class. Tiles are small; the DOM renderer is
837
+ // plenty, and preview → live becomes the SAME renderer painting the same
838
+ // bytes at the same geometry. Full screen keeps WebGL.
850
839
  setConn("live");
851
840
  const sep = wsBase.includes("?") ? "&" : "?";
852
841
  const wsUrl = () =>
@@ -903,7 +892,6 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
903
892
  term.scrollToBottom();
904
893
  term.refresh(0, term.rows - 1);
905
894
  rescaleRef.current();
906
- requestAnimationFrame(loadGpu);
907
895
  });
908
896
  kbdEnhancedRef.current = typeof m.keyboardEnhanced === "boolean"
909
897
  ? m.keyboardEnhanced
@@ -978,8 +966,6 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
978
966
  const focusTimer = window.setTimeout(() => { if (terminalMayTakeFocus()) term.focus(); }, 50);
979
967
  return () => {
980
968
  disposed = true;
981
- try { webgl?.dispose(); } catch { /* already gone */ }
982
- webgl = null;
983
969
  window.clearTimeout(focusTimer);
984
970
  window.clearTimeout(reconnectTimer);
985
971
  document.removeEventListener("visibilitychange", onVisibility);