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 +4 -0
- package/hay/apps/server/src/rooms.ts +117 -25
- package/hay/apps/server/src/screenGrid.ts +11 -1
- package/hay/apps/server/test/rooms.test.ts +114 -2
- package/hay/apps/web/scripts/run-e2e.mjs +53 -0
- package/hay/apps/web/src/App.tsx +74 -3
- package/hay/apps/web/src/components/SessionSwitcher.tsx +12 -22
- package/hay/apps/web/src/utils/scrollFlywheel.ts +24 -5
- package/hay-web/assets/{index-BJnLaQd5.css → index-D4EXRUpb.css} +1 -1
- package/hay-web/assets/{index-ClpDI5hp.js → index-DQ3VWtnf.js} +19 -19
- package/hay-web/assets/index-DQ3VWtnf.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +59 -3
- package/package.json +1 -1
- package/scripts/hay-host.js +32 -0
- package/skills/hop-session/SKILL.md +16 -5
- package/hay-web/assets/index-ClpDI5hp.js.map +0 -1
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
|
-
//
|
|
265
|
-
//
|
|
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,
|
|
455
|
+
// snapshot goes out, retaining the exact withheld chunks for catch-up.
|
|
449
456
|
client.awaitingSnapshot = true;
|
|
450
|
-
|
|
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
|
|
458
|
-
//
|
|
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 =
|
|
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 +
|
|
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(
|
|
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(
|
|
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)
|
|
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
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
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
|
-
|
|
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,
|
|
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();
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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" && !
|
|
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();
|