hop2 0.11.9 → 0.11.10

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,9 @@
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
+
35
38
  #### Session restore
36
39
  - **`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
40
  - **`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,23 @@ 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 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.
581
+ const hasNewRis = risIndex !== -1 && risIndex + 2 > tailLength;
582
+ const stateInput = hasNewRis ? combined.slice(risIndex + 2) : combined;
567
583
 
568
584
  // Parse CSI mode sequences (cursor visibility, alternate screen)
569
585
  const regex = /\x1b\[\?([0-9;]*)([hl])/g;
570
586
  let match: RegExpExecArray | null;
571
- let nextAlternate = this.alternateScreen;
572
- let nextCursorHidden = this.cursorHidden;
587
+ let nextAlternate = hasNewRis ? false : this.alternateScreen;
588
+ let nextCursorHidden = hasNewRis ? false : this.cursorHidden;
573
589
 
574
- let nextMouseReporting = this.mouseReporting;
575
- let nextMouseSgr = this.mouseSgr;
576
- while ((match = regex.exec(combined)) !== null) {
590
+ let nextMouseReporting = hasNewRis ? false : this.mouseReporting;
591
+ let nextMouseSgr = hasNewRis ? false : this.mouseSgr;
592
+ while ((match = regex.exec(stateInput)) !== null) {
577
593
  const params = match[1].split(";").filter(Boolean);
578
594
  const mode = match[2];
579
595
  for (const param of params) {
@@ -593,10 +609,10 @@ export class Room extends EventEmitter {
593
609
 
594
610
  // Enhanced keyboard reporting: kitty keyboard protocol (CSI >flags u push /
595
611
  // CSI <n u pop / CSI =flags u set) and xterm modifyOtherKeys (CSI >4;n m).
596
- let nextKeyboardEnhanced = this.keyboardEnhanced;
612
+ let nextKeyboardEnhanced = hasNewRis ? false : this.keyboardEnhanced;
597
613
  const kbdRegex = /\x1b\[([<>=])[0-9;:]*u|\x1b\[>([0-9;]*)m/g;
598
614
  let kbdMatch: RegExpExecArray | null;
599
- while ((kbdMatch = kbdRegex.exec(combined)) !== null) {
615
+ while ((kbdMatch = kbdRegex.exec(stateInput)) !== null) {
600
616
  if (kbdMatch[1] !== undefined) {
601
617
  nextKeyboardEnhanced = kbdMatch[1] !== "<"; // push/set = on, pop = off
602
618
  } else {
@@ -954,7 +970,10 @@ export class Room extends EventEmitter {
954
970
  // serialize marker). That silent loss was 'the screen is stale until
955
971
  // the app repaints' on every attach that raced live output. The
956
972
  // withheld bytes are delivered as catch-up appended to the snapshot.
957
- if (isOutput && client.awaitingSnapshot) continue;
973
+ if (isOutput && client.awaitingSnapshot) {
974
+ client.snapshotCatchUp.push((message as { data: string }).data);
975
+ continue;
976
+ }
958
977
  if (client.socket.isOpen()) {
959
978
  client.socket.send(payload);
960
979
  }
@@ -1052,13 +1071,38 @@ export class Room extends EventEmitter {
1052
1071
  }
1053
1072
  }
1054
1073
 
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.
1074
+ // Incremental TUIs paint some rows once and then update other regions for
1075
+ // hours. Reconstructing their first grid from any bounded tail can therefore
1076
+ // omit cells until the app happens to repaint (for example, on scroll).
1077
+ // Begin tracking at TUI entry; dependency loading is shared and normally
1078
+ // finishes first, but the callback covers a TUI that starts immediately.
1079
+ private startTuiGrid() {
1080
+ if (!SERIALIZED_ATTACH_ENABLED || this.grid || this.gridStartPending || this.gridUnavailable || this.ended) return;
1081
+ if (!this.alternateScreen && !this.mouseReporting) return;
1082
+ if (screenGridsAvailable()) {
1083
+ this.gridUnavailable = this.ensureGrid() === null;
1084
+ return;
1085
+ }
1086
+ this.gridStartPending = true;
1087
+ this.gridPendingSeed = [this.tailOutput(MAX_BUFFER_SIZE)];
1088
+ this.gridPendingSeedBytes = this.gridPendingSeed[0].length;
1089
+ void loadScreenGridDeps().then((available) => {
1090
+ this.gridStartPending = false;
1091
+ this.gridUnavailable = !available;
1092
+ const seed = this.gridPendingSeed?.join("") ?? "";
1093
+ this.gridPendingSeed = null;
1094
+ this.gridPendingSeedBytes = 0;
1095
+ if (available && !this.ended) {
1096
+ this.gridUnavailable = this.ensureGrid(seed) === null;
1097
+ }
1098
+ });
1099
+ }
1100
+
1101
+ // Plain-shell grids remain lazy until the first serialized attach. Creation
1102
+ // hydrates from a bounded ring tail; append-only shell output is safely
1103
+ // reconstructible from that tail, unlike the incremental TUI path above.
1060
1104
  private static readonly GRID_HYDRATE_BYTES = 2_000_000;
1061
- private ensureGrid(): ScreenGrid | null {
1105
+ private ensureGrid(seed?: string): ScreenGrid | null {
1062
1106
  if (!SERIALIZED_ATTACH_ENABLED || this.ended) return this.grid;
1063
1107
  if (!this.grid && screenGridsAvailable()) {
1064
1108
  this.grid = createScreenGrid(this.activeCols, this.activeRows);
@@ -1067,8 +1111,8 @@ export class Room extends EventEmitter {
1067
1111
  if (this.outputBytes > 0) {
1068
1112
  // Cut at a line boundary (same trick as boundSnapshotReplay) so a
1069
1113
  // 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) {
1114
+ let tail = seed ?? this.tailOutput(Room.GRID_HYDRATE_BYTES);
1115
+ if (seed === undefined && this.outputBytes > tail.length) {
1072
1116
  const nl = tail.indexOf("\n");
1073
1117
  if (nl !== -1 && nl < 8192) tail = tail.slice(nl + 1);
1074
1118
  }
@@ -1082,6 +1126,20 @@ export class Room extends EventEmitter {
1082
1126
  /** O(1) append; trims whole chunks from the head past MAX_BUFFER_SIZE. */
1083
1127
  private appendOutput(data: string) {
1084
1128
  if (!data) return;
1129
+ if (this.gridPendingSeed) {
1130
+ this.gridPendingSeed.push(data);
1131
+ this.gridPendingSeedBytes += data.length;
1132
+ while (this.gridPendingSeed.length > 1
1133
+ && this.gridPendingSeedBytes - this.gridPendingSeed[0].length >= MAX_BUFFER_SIZE) {
1134
+ this.gridPendingSeedBytes -= this.gridPendingSeed[0].length;
1135
+ this.gridPendingSeed.shift();
1136
+ }
1137
+ if (this.gridPendingSeed.length === 1 && this.gridPendingSeedBytes > MAX_BUFFER_SIZE) {
1138
+ const tail = this.gridPendingSeed[0].slice(-MAX_BUFFER_SIZE);
1139
+ this.gridPendingSeed[0] = tail;
1140
+ this.gridPendingSeedBytes = tail.length;
1141
+ }
1142
+ }
1085
1143
  this.outputChunks.push(data);
1086
1144
  this.outputBytes += data.length;
1087
1145
  while (this.outputChunks.length > 1 && this.outputBytes - this.outputChunks[0].length >= MAX_BUFFER_SIZE) {
@@ -766,6 +766,25 @@ 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
+
769
788
  it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
770
789
  const { room, pty } = await setup("capped");
771
790
  const big = "\x1b[20;1H" + "y".repeat(4096);
@@ -810,6 +829,44 @@ describe("Room", () => {
810
829
  expect(after.some((m) => String(m.data).includes("LATE-MARKER-XYZ"))).toBe(false);
811
830
  });
812
831
 
832
+ it("retains catch-up larger than the incremental preview limit", async () => {
833
+ const { room, pty } = await setup("large-race");
834
+ pty().emit("\x1b[?1002h\x1b[2J\x1b[1;1Hbefore large catch-up");
835
+ const socket = new FakeSocket();
836
+ room.attachClient(
837
+ { id: "large-racer", name: "Large Racer", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
838
+ socket
839
+ );
840
+ // Larger than getOutputSince's 1MB default: cursor-based catch-up used
841
+ // to reset here and silently drop every withheld byte.
842
+ const late = "x".repeat(1_100_000) + "LARGE-CATCHUP-MARKER";
843
+ pty().emit(late);
844
+ const snapshot = await waitForSnapshot(socket);
845
+ expect(String(snapshot.data)).toContain("LARGE-CATCHUP-MARKER");
846
+ const live = readMessages(socket).filter((m) => m.type === "output");
847
+ expect(live.some((m) => String(m.data).includes("LARGE-CATCHUP-MARKER"))).toBe(false);
848
+ });
849
+
850
+ it("clears tracked private modes when the app sends RIS", async () => {
851
+ const { room, pty } = await setup("ris-modes");
852
+ pty().emit("\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[?25l\x1b[>1u");
853
+ // Split ESC c across chunks to cover the control-sequence carry path.
854
+ pty().emit("\x1b");
855
+ pty().emit("c\x1b[2J\x1b[1;1HAFTER-RIS");
856
+ const socket = new FakeSocket();
857
+ room.attachClient(
858
+ { id: "after-ris", name: "After RIS", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
859
+ socket
860
+ );
861
+ const snapshot = await waitForSnapshot(socket);
862
+ expect(String(snapshot.data)).toContain("AFTER-RIS");
863
+ expect(snapshot.alternateScreen).toBe(false);
864
+ expect(snapshot.mouseReporting).toBe(false);
865
+ expect(snapshot.mouseSgr).toBe(false);
866
+ expect(snapshot.cursorHidden).toBe(false);
867
+ expect(snapshot.keyboardEnhanced).toBe(false);
868
+ });
869
+
813
870
  it("replay=0 means no snapshot at all — live stream only", async () => {
814
871
  const { room, pty } = await setup("claim");
815
872
  pty().emit("some prior output\r\n");
@@ -519,6 +519,12 @@ const App = () => {
519
519
  const shouldReconnectRef = useRef(true);
520
520
  const connectNonceRef = useRef(0);
521
521
  const userScrolledUpRef = useRef(false);
522
+ // Set by every write into the terminal; the render watchdog only spends a
523
+ // refresh when something actually changed since its last tick.
524
+ const outputSinceTickRef = useRef(true);
525
+ // Timestamp of the last bytes from the session — the ack signal that
526
+ // paces synthetic wheel steps (see scrollFlywheel appGlide).
527
+ const lastOutputAtRef = useRef(0);
522
528
  // Late-bound hooks for the voice controller (created before these exist).
523
529
  const handleUserInputRef = useRef<((data: string) => void) | null>(null);
524
530
  const pushNoticeRef = useRef<((m: string) => void) | null>(null);
@@ -928,8 +934,18 @@ const App = () => {
928
934
  return presence.find((client) => client.id === controllerId)?.name ?? "Someone";
929
935
  }, [presence, controllerId]);
930
936
 
937
+ // Local echo policy. Exclusive control keeps the old rule. In COLLAB mode
938
+ // (the default for every shared session) echo used to be flatly OFF — so
939
+ // each character waited a full round trip, tunnel RTT included, even when
940
+ // you were the only person typing, which is the overwhelmingly common
941
+ // case. Echo now stays on unless another client is ACTUALLY typing right
942
+ // now (presence carries the flag), and drops the instant one starts. A
943
+ // sub-second overlap before presence propagates is absorbed by the
944
+ // reconciler's guards (complex-chunk passthrough, two-strike clear).
945
+ const othersTyping = presence.some((c) => c.id !== clientId && c.typing);
931
946
  const optimisticActive =
932
- LATENCY_COMP && status === "connected" && !collabMode && controllerId === clientId;
947
+ LATENCY_COMP && status === "connected" && !othersTyping
948
+ && (collabMode ? true : controllerId === clientId);
933
949
 
934
950
  useEffect(() => {
935
951
  if (optimisticPrevRef.current && !optimisticActive) {
@@ -1251,6 +1267,8 @@ const App = () => {
1251
1267
  if (!termRef.current) {
1252
1268
  return;
1253
1269
  }
1270
+ outputSinceTickRef.current = true;
1271
+ lastOutputAtRef.current = performance.now(); // same clock the flywheel compares
1254
1272
 
1255
1273
  // Filter focus reporting sequences that can leak as visible text
1256
1274
  const filtered = data.replace(/\x1b\[I/g, '').replace(/\x1b\[O/g, '');
@@ -1339,6 +1357,9 @@ const App = () => {
1339
1357
  // snapshot and must land where the snapshot would.
1340
1358
  t.scrollToBottom();
1341
1359
  t.refresh(0, t.rows - 1);
1360
+ // This write bypasses writeToTerminal(). Re-arm the delayed render
1361
+ // invariant after parsing, so a missed WebGL frame self-heals.
1362
+ outputSinceTickRef.current = true;
1342
1363
  });
1343
1364
  })
1344
1365
  .catch(() => { /* fast paint is best-effort */ });
@@ -1504,6 +1525,10 @@ const App = () => {
1504
1525
  userScrolledUpRef.current = false;
1505
1526
  }
1506
1527
  t.refresh(0, t.rows - 1);
1528
+ // Snapshot writes bypass writeToTerminal(). Set this only after
1529
+ // parsing so a slow snapshot cannot have its retry consumed
1530
+ // before there is anything complete to repaint.
1531
+ outputSinceTickRef.current = true;
1507
1532
  // The snapshot has fully landed: only from here on can a
1508
1533
  // viewport-top event mean a user gesture.
1509
1534
  lastViewportYRef.current = t.buffer.active.viewportY;
@@ -1699,7 +1724,8 @@ const App = () => {
1699
1724
  () => termRef.current,
1700
1725
  {
1701
1726
  linesPerNotch: 4, // mirrors scrollSensitivity above
1702
- lineHeightPx: () => Math.max(1, ((terminal as any)._core?._renderService?.dimensions?.css?.cell?.height) || fontSize * 1.3)
1727
+ lineHeightPx: () => Math.max(1, ((terminal as any)._core?._renderService?.dimensions?.css?.cell?.height) || fontSize * 1.3),
1728
+ lastOutputAt: () => lastOutputAtRef.current
1703
1729
  }
1704
1730
  );
1705
1731
  }
@@ -2565,6 +2591,12 @@ const App = () => {
2565
2591
  if (document.hidden || switcherOpenRef.current) return;
2566
2592
  const t = termRef.current;
2567
2593
  if (!t) return;
2594
+ // Idle sessions get no tick: a full refresh every 1.4s on a screen
2595
+ // nothing wrote to re-rasterizes identical rows forever (measurable
2596
+ // on battery with several sessions open). Output sets the flag; one
2597
+ // enforcement pass clears it.
2598
+ if (!outputSinceTickRef.current) return;
2599
+ outputSinceTickRef.current = false;
2568
2600
  try {
2569
2601
  const buf = t.buffer.active;
2570
2602
  if (!userScrolledUpRef.current && buf.viewportY < buf.baseY) t.scrollToBottom();
@@ -346,6 +346,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
346
346
  const kbdEnhancedRef = useRef(false);
347
347
  // null = watching; "live"/"down" = connected state for the chrome.
348
348
  const [conn, setConn] = useState<"live" | "down" | null>(null);
349
+ // Last bytes from the session — paces the app-scroll coast (flywheel).
350
+ const lastOutputAtRef = useRef(0);
349
351
  // Hold-space voice, same controller as the full screen — the tile is where
350
352
  // typing mostly happens now, and dictation not existing here was most of
351
353
  // "voice doesn't always work".
@@ -653,6 +655,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
653
655
  box.addEventListener("pointercancel", endPan, true);
654
656
 
655
657
  const detachFlywheel = attachScrollFlywheel(box, () => termRef.current as never, {
658
+ lastOutputAt: () => lastOutputAtRef.current,
656
659
  linesPerNotch: 4,
657
660
  lineHeightPx: () => Math.max(1, ((term as never as { _core?: { _renderService?: { dimensions?: { css?: { cell?: { height?: number } } } } } })._core
658
661
  ?._renderService?.dimensions?.css?.cell?.height) || PREVIEW_BASE_FS * 1.3)
@@ -906,6 +909,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
906
909
  ? m.keyboardEnhanced
907
910
  : scanKeyboardProtocol(String(m.data || ""), false);
908
911
  } else if (m.type === "output") {
912
+ lastOutputAtRef.current = performance.now();
909
913
  term.write(m.data);
910
914
  kbdEnhancedRef.current = scanKeyboardProtocol(String(m.data || ""), kbdEnhancedRef.current);
911
915
  } else if (m.type === "active_size") {
@@ -59,6 +59,14 @@ export type FlywheelOptions = {
59
59
  linesPerNotch: number;
60
60
  /** Pixel height of one terminal row (for pixel-mode delta → lines). */
61
61
  lineHeightPx: () => number;
62
+ /**
63
+ * Timestamp of the last output received from the session, when the caller
64
+ * has one. Paces app-owned coasting: a tracking app repaints per wheel
65
+ * step and each step costs a network round trip — a fixed-rate coast
66
+ * outruns a distant app and queues repaints, which reads as scroll LAG.
67
+ * With this signal each synthetic step waits for evidence the app kept up.
68
+ */
69
+ lastOutputAt?: () => number;
62
70
  };
63
71
 
64
72
  /**
@@ -140,6 +148,7 @@ export const attachScrollFlywheel = (
140
148
  }
141
149
  };
142
150
 
151
+ let lastStepAt = 0;
143
152
  const appGlide = (t: number) => {
144
153
  appRaf = 0;
145
154
  const term = getTerm();
@@ -147,11 +156,21 @@ export const attachScrollFlywheel = (
147
156
  if (!term || terminalOwnsScrolling(term) || Math.abs(appVel) < 4) return stopApp();
148
157
  const dt = lastFrameAt ? Math.min(0.05, (t - lastFrameAt) / 1000) : 0.016;
149
158
  lastFrameAt = t;
150
- appCarry += Math.abs(appVel) * dt;
151
- let steps = Math.trunc(appCarry);
152
- appCarry -= steps;
153
- while (steps-- > 0) dispatchStep();
154
- appVel *= Math.exp(-dt / 0.35);
159
+ // ACK pacing: after each synthetic step, wait for the app to answer
160
+ // (any output) before spending the next one — the coast then runs at
161
+ // the link's real rhythm instead of flooding a slow one. 300ms cap so
162
+ // an app that repaints invisibly (no bytes for us) can't stall the
163
+ // glide forever.
164
+ const ackAt = opts.lastOutputAt ? opts.lastOutputAt() : Infinity;
165
+ const waitingForAck = lastStepAt > 0 && ackAt < lastStepAt && performance.now() - lastStepAt < 300;
166
+ if (!waitingForAck) {
167
+ appCarry += Math.abs(appVel) * dt;
168
+ let steps = Math.trunc(appCarry);
169
+ appCarry -= steps;
170
+ if (steps > 0) lastStepAt = performance.now();
171
+ while (steps-- > 0) dispatchStep();
172
+ appVel *= Math.exp(-dt / 0.35);
173
+ }
155
174
  appRaf = requestAnimationFrame(appGlide);
156
175
  };
157
176