hop2 0.11.11 → 0.11.12
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 +2 -1
- package/hay/apps/server/src/index.ts +2 -0
- package/hay/apps/server/src/lib.ts +2 -0
- package/hay/apps/server/src/rooms.ts +107 -76
- package/hay/apps/server/src/screenGrid.ts +30 -2
- package/hay/apps/server/test/rooms.test.ts +129 -79
- package/hay/apps/web/scripts/run-e2e.mjs +40 -0
- package/hay/apps/web/src/App.tsx +206 -96
- package/hay/apps/web/src/components/SessionSwitcher.tsx +131 -7
- package/hay/apps/web/src/styles.css +32 -0
- package/hay/apps/web/src/utils/switcherModel.ts +52 -13
- package/hay/apps/web/test/switcherModel.test.ts +61 -0
- package/hay-web/assets/digest-archive.json +604 -0
- package/hay-web/assets/index-DHotLq-D.css +1 -0
- package/hay-web/assets/{index-DQ3VWtnf.js → index-DzlPn2In.js} +16 -16
- package/hay-web/assets/{index-DQ3VWtnf.js.map → index-DzlPn2In.js.map} +1 -1
- package/hay-web/index.html +2 -2
- package/hop +387 -48
- package/package.json +1 -1
- package/skills/hop-session/SKILL.md +28 -1
- package/tests/terminal-api.integration.test.cjs +6 -2
package/hay/CHANGELOG.md
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
- **Status bar / hints toggles are session-only**: `Opt+B` (status bar) and `Opt+T` (hints) no longer persist to `.hop.json`, so an in-session toggle can't silently become your permanent default. The default is taken from `.hop.json` (`showStatusBar`/`showHints`, both default `true`); toggles affect only the current session and the notice now says so. (Other toggles like mouse capture and sync-size still persist.)
|
|
24
24
|
|
|
25
25
|
#### Web Client
|
|
26
|
+
- **One size rule everywhere — the last user action owns the terminal size**: the server-side size election (typing-recency plus 60s/2.5s idle windows) is replaced by a single ownership rule: whoever acted last — opened the session or pressed a key — owns the PTY size, and their refits (window drag, rotation, keyboard) flow straight through. A keystroke transfers ownership and snaps the PTY to the typist's declared fit immediately. `active_size` is now interpreted by **identity** (the owner's clientId) instead of by comparing sizes. When a peer owns the size, the web terminal in Auto-fit now **follows the owner's grid** — rendered at the PTY's true geometry, scaled and letterboxed to the viewport — instead of rewrapping the buffer at its own width; typing (or the drawer's Fit button) takes the size back. Scrolling doesn't count: wheel/mouse reports never transfer ownership, so reading a session on the phone no longer steals the size from a desk. Resize sends are debounced (150ms) and the stale pre-switch resize on reconnect is gone, so a session switch reshapes the PTY once, not twice.
|
|
26
27
|
- **Restyle to the hop identity**: the web client now shares the CLI bars' palette and language — accent session chip, semantic state dot, and keycap-style find control in a real status-bar footer; mono-led chrome (brand, presence names, session labels); refined light/dark themes; dot-grid join page. The hop session picker got the same treatment, including dark-mode support.
|
|
27
28
|
- **Drawer polish**: quick actions split into two clusters — utility icons (keyboard / share / find) then named actions (Fit / Manage) — and the drawer FAB now defaults to the top-right (clear of the status bar, mirroring the close button) instead of bottom-left. It stays draggable.
|
|
28
29
|
- **Autofit by default + fit on every session load**: the terminal fits the viewport on connect and on each session switch, on every platform. Autofit resizes the shared PTY, so other viewers follow this client; switch to Manual in the drawer to opt out.
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
|
|
35
36
|
#### Web terminal
|
|
36
37
|
- **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**:
|
|
38
|
+
- **Resizing another desktop view no longer leaves a terminal unable to reach the live bottom**: Wall/watch tiles now resize only unattended sessions instead of overriding an open desktop's Claude grid, Hop reconciles xterm's follow state after local geometry changes, and authoritative screen resizes stay ordered behind the output that preceded them.
|
|
38
39
|
|
|
39
40
|
#### Session restore
|
|
40
41
|
- **`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).
|
|
@@ -75,6 +75,7 @@ wss.on("connection", (ws, req) => {
|
|
|
75
75
|
const url = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`);
|
|
76
76
|
const roomId = sanitizeRoom(url.searchParams.get("room"));
|
|
77
77
|
const name = sanitizeName(url.searchParams.get("name"));
|
|
78
|
+
const source = url.searchParams.get("source") || "";
|
|
78
79
|
const cols = Number(url.searchParams.get("cols") ?? 80);
|
|
79
80
|
const rows = Number(url.searchParams.get("rows") ?? 24);
|
|
80
81
|
|
|
@@ -91,6 +92,7 @@ wss.on("connection", (ws, req) => {
|
|
|
91
92
|
{
|
|
92
93
|
id: clientId,
|
|
93
94
|
name,
|
|
95
|
+
source,
|
|
94
96
|
colorIndex,
|
|
95
97
|
cols,
|
|
96
98
|
rows
|
|
@@ -119,6 +119,7 @@ export function attachTermshare(options: TermshareServerOptions) {
|
|
|
119
119
|
const url = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`);
|
|
120
120
|
const roomId = sanitizeRoom(url.searchParams.get("room"));
|
|
121
121
|
const name = sanitizeName(url.searchParams.get("name"));
|
|
122
|
+
const source = url.searchParams.get("source") || "";
|
|
122
123
|
const cols = Number(url.searchParams.get("cols") ?? 80);
|
|
123
124
|
const rows = Number(url.searchParams.get("rows") ?? 24);
|
|
124
125
|
|
|
@@ -135,6 +136,7 @@ export function attachTermshare(options: TermshareServerOptions) {
|
|
|
135
136
|
{
|
|
136
137
|
id: clientId,
|
|
137
138
|
name,
|
|
139
|
+
source,
|
|
138
140
|
colorIndex,
|
|
139
141
|
cols,
|
|
140
142
|
rows
|
|
@@ -117,25 +117,20 @@ const SERIALIZED_MAX_REQUEST = 524_288;
|
|
|
117
117
|
// snapshot never trips).
|
|
118
118
|
const SERIALIZED_CAPPED_HINT_BYTES = 262_144;
|
|
119
119
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
// tunnel; ~3s with this).
|
|
135
|
-
const ATTACH_CLAIM_IDLE_MS = (() => {
|
|
136
|
-
const env = Number(process.env.HAY_ATTACH_CLAIM_IDLE_MS);
|
|
137
|
-
return Number.isFinite(env) && env >= 0 ? env : 2_500;
|
|
138
|
-
})();
|
|
120
|
+
// Size ownership: the last USER ACTION owns the PTY size — opening the
|
|
121
|
+
// session (a claim:"attach" or user:true resize) or a real keystroke,
|
|
122
|
+
// whichever happened last. No idle windows, no election. The owner's
|
|
123
|
+
// resizes apply unconditionally (their viewport IS the session's size);
|
|
124
|
+
// everyone else's are recorded as that client's declared fit and answered
|
|
125
|
+
// with a corrective active_size so followers can adopt the owner's grid.
|
|
126
|
+
// When a keystroke transfers ownership, the PTY immediately takes the new
|
|
127
|
+
// owner's declared fit. Monitors (source=monitor: wall tiles, preview
|
|
128
|
+
// sockets) never own the size; they may size an UNATTENDED session so
|
|
129
|
+
// previews render true. Wheel/mouse reports are reading, not typing, and
|
|
130
|
+
// transfer nothing. SGR mouse reports only — every hop client sends wheel
|
|
131
|
+
// as CSI < b;x;y M/m. Anchored and exclusive: a payload containing
|
|
132
|
+
// anything besides mouse reports still counts as typing.
|
|
133
|
+
const PURE_MOUSE_REPORT = /^(?:\x1b\[<\d+;\d+;\d+[Mm])+$/;
|
|
139
134
|
const CONTROL_SEQUENCE_TAIL = 32;
|
|
140
135
|
const DEBUG_STATE = process.env.HAY_DEBUG === "1";
|
|
141
136
|
|
|
@@ -185,12 +180,11 @@ class ClientState {
|
|
|
185
180
|
color: string;
|
|
186
181
|
typing = false;
|
|
187
182
|
lastActive = now();
|
|
188
|
-
// Timestamp of this client's last real keystroke input (not resize, not the
|
|
189
|
-
// typing indicator, not connect). 0 = has never typed. Drives the active PTY
|
|
190
|
-
// size election so size follows the active typer, not whoever last resized.
|
|
191
|
-
lastInputAt = 0;
|
|
192
183
|
socket: SocketAdapter;
|
|
193
184
|
source: string;
|
|
185
|
+
// Declared fit: the size this client's viewport wants, updated by every
|
|
186
|
+
// resize message whether or not it applied. Becomes the PTY size the
|
|
187
|
+
// moment a keystroke makes this client the size owner.
|
|
194
188
|
cols: number;
|
|
195
189
|
rows: number;
|
|
196
190
|
|
|
@@ -224,7 +218,9 @@ export class Room extends EventEmitter {
|
|
|
224
218
|
private clients = new Map<string, ClientState>();
|
|
225
219
|
private collabMode = true;
|
|
226
220
|
private controllerId: string | null = null;
|
|
227
|
-
|
|
221
|
+
// The client whose last user action (open or keystroke) owns the PTY size.
|
|
222
|
+
// null = up for grabs (fresh room, or the owner detached).
|
|
223
|
+
private sizeOwnerId: string | null = null;
|
|
228
224
|
private activeCols: number;
|
|
229
225
|
private activeRows: number;
|
|
230
226
|
// Retained raw output as a chunk ring: append is O(1) and trimming drops
|
|
@@ -385,7 +381,7 @@ export class Room extends EventEmitter {
|
|
|
385
381
|
socket.send(
|
|
386
382
|
JSON.stringify({
|
|
387
383
|
type: "active_size",
|
|
388
|
-
clientId: this.
|
|
384
|
+
clientId: this.sizeOwnerId ?? client.id,
|
|
389
385
|
cols: this.activeCols,
|
|
390
386
|
rows: this.activeRows
|
|
391
387
|
} satisfies ServerMessage)
|
|
@@ -880,7 +876,21 @@ export class Room extends EventEmitter {
|
|
|
880
876
|
return;
|
|
881
877
|
}
|
|
882
878
|
client.lastActive = now();
|
|
883
|
-
|
|
879
|
+
// A real keystroke takes the size: transfer ownership and bring the PTY
|
|
880
|
+
// to the new owner's declared fit immediately, so typing from a phone
|
|
881
|
+
// reshapes the session even before its next resize message arrives.
|
|
882
|
+
// Wheel/mouse reports are reading, not typing — a phone scrolling claude
|
|
883
|
+
// must not take the size from someone typing at a desk.
|
|
884
|
+
if (
|
|
885
|
+
client.source !== "monitor" &&
|
|
886
|
+
this.sizeOwnerId !== client.id &&
|
|
887
|
+
!PURE_MOUSE_REPORT.test(data)
|
|
888
|
+
) {
|
|
889
|
+
this.sizeOwnerId = client.id;
|
|
890
|
+
if (client.cols !== this.activeCols || client.rows !== this.activeRows) {
|
|
891
|
+
this.applySize(client, client.cols, client.rows);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
884
894
|
this.pty.write(data);
|
|
885
895
|
this.emit("pty_input", { roomId: this.id, clientId: client.id, data, timestamp: now() });
|
|
886
896
|
}
|
|
@@ -889,60 +899,76 @@ export class Room extends EventEmitter {
|
|
|
889
899
|
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
|
|
890
900
|
if (cols < 1 || cols > 500 || rows < 1 || rows > 200) return;
|
|
891
901
|
|
|
902
|
+
// Always record the declared fit, applied or not — a later keystroke
|
|
903
|
+
// makes it the PTY size (see handleInput).
|
|
892
904
|
client.cols = cols;
|
|
893
905
|
client.rows = rows;
|
|
894
|
-
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
// can still size the PTY before typing.
|
|
902
|
-
const maxInputAt = Math.max(...[...this.clients.values()].map((c) => c.lastInputAt));
|
|
903
|
-
const othersMaxInputAt = Math.max(
|
|
904
|
-
0,
|
|
905
|
-
...[...this.clients.values()].filter((c) => c.id !== client.id).map((c) => c.lastInputAt)
|
|
906
|
-
);
|
|
907
|
-
const claimIdleMs = claim === "attach" ? ATTACH_CLAIM_IDLE_MS : RESIZE_CLAIM_IDLE_MS;
|
|
908
|
-
// A user-flagged claim is a deliberate human act (clicking a wall tile,
|
|
909
|
-
// switching to the session): it wins outright. Anyone actively typing
|
|
910
|
-
// elsewhere reclaims with their next keystroke, so misfires self-heal.
|
|
911
|
-
const isActive = userClaim ||
|
|
912
|
-
client.lastInputAt >= maxInputAt || now() - othersMaxInputAt > claimIdleMs;
|
|
913
|
-
if (isActive) {
|
|
914
|
-
this.pty.resize(cols, rows);
|
|
915
|
-
this.activeCols = cols;
|
|
916
|
-
this.activeRows = rows;
|
|
917
|
-
this.grid?.resize(cols, rows);
|
|
918
|
-
this.activeClientId = client.id;
|
|
919
|
-
// Broadcast the new active size to other clients so they can adjust —
|
|
920
|
-
// and CONFIRM to the winner: a claimant otherwise learns nothing on
|
|
921
|
-
// success and can only distinguish accept from reject by timeout.
|
|
922
|
-
this.broadcastActiveSize(client);
|
|
923
|
-
client.socket.send(
|
|
924
|
-
JSON.stringify({
|
|
925
|
-
type: "active_size",
|
|
926
|
-
clientId: client.id,
|
|
927
|
-
cols,
|
|
928
|
-
rows
|
|
929
|
-
} satisfies ServerMessage)
|
|
930
|
-
);
|
|
931
|
-
this.emit("pty_resize", { roomId: this.id, clientId: client.id, cols, rows, timestamp: now() });
|
|
932
|
-
} else if (cols !== this.activeCols || rows !== this.activeRows) {
|
|
933
|
-
// The resize lost the election (a passive viewer auto-fitted itself).
|
|
934
|
-
// Snap that client back to the shared size — otherwise its local
|
|
935
|
-
// terminal re-wraps the buffer at a size the PTY isn't, and it has no
|
|
936
|
-
// way to learn the real size until the next winning resize.
|
|
937
|
-
client.socket.send(
|
|
938
|
-
JSON.stringify({
|
|
939
|
-
type: "active_size",
|
|
940
|
-
clientId: this.activeClientId ?? client.id,
|
|
941
|
-
cols: this.activeCols,
|
|
942
|
-
rows: this.activeRows
|
|
943
|
-
} satisfies ServerMessage)
|
|
906
|
+
|
|
907
|
+
// A wall/watch tile may size an unattended session so previews render
|
|
908
|
+
// true, but must never clip a live client by forcing Claude's alternate
|
|
909
|
+
// screen to the tile's grid — and never takes ownership.
|
|
910
|
+
if (client.source === "monitor") {
|
|
911
|
+
const hasInteractivePeer = [...this.clients.values()].some(
|
|
912
|
+
(peer) => peer.id !== client.id && peer.source !== "monitor"
|
|
944
913
|
);
|
|
914
|
+
if (!hasInteractivePeer) {
|
|
915
|
+
this.applySize(client, cols, rows);
|
|
916
|
+
} else if (cols !== this.activeCols || rows !== this.activeRows) {
|
|
917
|
+
this.sendActiveSize(client, this.sizeOwnerId ?? client.id);
|
|
918
|
+
}
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Opening the session here (claim:"attach") or a deliberate click
|
|
923
|
+
// (user:true) is a user action: strict recency, it takes ownership
|
|
924
|
+
// outright. Whoever was typing elsewhere takes it back with their next
|
|
925
|
+
// keystroke, so misfires self-heal.
|
|
926
|
+
if (claim === "attach" || userClaim) {
|
|
927
|
+
this.sizeOwnerId = client.id;
|
|
928
|
+
this.applySize(client, cols, rows);
|
|
929
|
+
return;
|
|
945
930
|
}
|
|
931
|
+
|
|
932
|
+
// The owner's viewport IS the session's size: its refits (rotation,
|
|
933
|
+
// keyboard show/hide, window drag) flow straight through. While nobody
|
|
934
|
+
// owns the size (fresh room, owner detached), any interactive client
|
|
935
|
+
// may size it without claiming it.
|
|
936
|
+
if (this.sizeOwnerId === client.id || this.sizeOwnerId === null) {
|
|
937
|
+
this.applySize(client, cols, rows);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Not the owner: the declaration is recorded; snap the client back to
|
|
942
|
+
// the shared size — otherwise its local terminal re-wraps the buffer at
|
|
943
|
+
// a size the PTY isn't, and it has no way to learn the real size until
|
|
944
|
+
// the next broadcast.
|
|
945
|
+
if (cols !== this.activeCols || rows !== this.activeRows) {
|
|
946
|
+
this.sendActiveSize(client, this.sizeOwnerId);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
private applySize(client: ClientState, cols: number, rows: number) {
|
|
951
|
+
this.pty.resize(cols, rows);
|
|
952
|
+
this.activeCols = cols;
|
|
953
|
+
this.activeRows = rows;
|
|
954
|
+
this.grid?.resize(cols, rows);
|
|
955
|
+
// Broadcast to the others so they can adopt — and CONFIRM to the
|
|
956
|
+
// applier: a claimant otherwise learns nothing on success and can only
|
|
957
|
+
// distinguish accept from reject by timeout.
|
|
958
|
+
this.broadcastActiveSize(client);
|
|
959
|
+
this.sendActiveSize(client, client.id);
|
|
960
|
+
this.emit("pty_resize", { roomId: this.id, clientId: client.id, cols, rows, timestamp: now() });
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
private sendActiveSize(client: ClientState, ownerId: string) {
|
|
964
|
+
client.socket.send(
|
|
965
|
+
JSON.stringify({
|
|
966
|
+
type: "active_size",
|
|
967
|
+
clientId: ownerId,
|
|
968
|
+
cols: this.activeCols,
|
|
969
|
+
rows: this.activeRows
|
|
970
|
+
} satisfies ServerMessage)
|
|
971
|
+
);
|
|
946
972
|
}
|
|
947
973
|
|
|
948
974
|
private broadcastActiveSize(client: ClientState) {
|
|
@@ -995,6 +1021,11 @@ export class Room extends EventEmitter {
|
|
|
995
1021
|
private removeClient(clientId: string) {
|
|
996
1022
|
const wasController = this.controllerId === clientId;
|
|
997
1023
|
this.clients.delete(clientId);
|
|
1024
|
+
if (this.sizeOwnerId === clientId) {
|
|
1025
|
+
// The owner left: the size is up for grabs, so a remaining client's
|
|
1026
|
+
// next refit (not just its next keystroke) may take it.
|
|
1027
|
+
this.sizeOwnerId = null;
|
|
1028
|
+
}
|
|
998
1029
|
if (wasController && !this.collabMode) {
|
|
999
1030
|
this.collabMode = true;
|
|
1000
1031
|
this.controllerId = null;
|
|
@@ -39,6 +39,9 @@ export const GRID_SCROLLBACK = (() => {
|
|
|
39
39
|
const env = Number(process.env.HAY_GRID_SCROLLBACK);
|
|
40
40
|
return Number.isFinite(env) && env >= 0 ? Math.floor(env) : 1000;
|
|
41
41
|
})();
|
|
42
|
+
// Preserve ordinary shrink/grow sequences exactly while bounding an abusive
|
|
43
|
+
// resize storm's retained work behind a single xterm write marker.
|
|
44
|
+
const MAX_QUEUED_RESIZE_TRANSITIONS = 256;
|
|
42
45
|
|
|
43
46
|
let deps: GridDeps | null = null;
|
|
44
47
|
let loading: Promise<boolean> | null = null;
|
|
@@ -110,27 +113,52 @@ export const createScreenGrid = (cols: number, rows: number): ScreenGrid | null
|
|
|
110
113
|
return null;
|
|
111
114
|
}
|
|
112
115
|
let disposed = false;
|
|
116
|
+
let coalescibleResizes: number[] | null = null;
|
|
113
117
|
return {
|
|
114
118
|
write(data: string) {
|
|
115
119
|
if (disposed) return;
|
|
120
|
+
// Output is an ordering boundary: a later resize must run after it,
|
|
121
|
+
// rather than coalescing into a resize marker queued before it.
|
|
122
|
+
if (data) coalescibleResizes = null;
|
|
116
123
|
try { term.write(data); } catch { /* parser hiccup — next write continues */ }
|
|
117
124
|
},
|
|
118
125
|
resize(nextCols: number, nextRows: number) {
|
|
119
126
|
if (disposed) return;
|
|
120
127
|
const cols = Math.max(2, nextCols);
|
|
121
128
|
const rows = Math.max(1, nextRows);
|
|
129
|
+
if (coalescibleResizes) {
|
|
130
|
+
if (coalescibleResizes.length < MAX_QUEUED_RESIZE_TRANSITIONS * 2) {
|
|
131
|
+
coalescibleResizes.push(cols, rows);
|
|
132
|
+
} else {
|
|
133
|
+
coalescibleResizes[coalescibleResizes.length - 2] = cols;
|
|
134
|
+
coalescibleResizes[coalescibleResizes.length - 1] = rows;
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const scheduled = [cols, rows];
|
|
139
|
+
coalescibleResizes = scheduled;
|
|
122
140
|
try {
|
|
123
141
|
// xterm parses write() asynchronously. Queue the resize behind every
|
|
124
142
|
// earlier byte so room order stays PTY output → SIGWINCH, while later
|
|
125
143
|
// writes naturally queue behind this callback's synchronous resize.
|
|
144
|
+
// Consecutive resizes share this marker. Replay ordinary geometry
|
|
145
|
+
// changes exactly: shrink/grow transitions mutate xterm's grid even
|
|
146
|
+
// without output. Only an abusive overflow degrades to the latest size.
|
|
126
147
|
term.write("", () => {
|
|
127
148
|
if (disposed) return;
|
|
128
|
-
|
|
149
|
+
if (coalescibleResizes === scheduled) coalescibleResizes = null;
|
|
150
|
+
for (let i = 0; i < scheduled.length; i += 2) {
|
|
151
|
+
try { term.resize(scheduled[i], scheduled[i + 1]); } catch { /* keep previous size */ }
|
|
152
|
+
}
|
|
129
153
|
});
|
|
130
|
-
} catch {
|
|
154
|
+
} catch {
|
|
155
|
+
if (coalescibleResizes === scheduled) coalescibleResizes = null;
|
|
156
|
+
}
|
|
131
157
|
},
|
|
132
158
|
serialize(callback: (data: string | null) => void, scrollback: number = GRID_SCROLLBACK) {
|
|
133
159
|
if (disposed) { callback(null); return; }
|
|
160
|
+
// A later resize must not move ahead of this point-in-time snapshot.
|
|
161
|
+
coalescibleResizes = null;
|
|
134
162
|
try {
|
|
135
163
|
// An empty write's callback fires once all queued data has parsed —
|
|
136
164
|
// the serialize then reflects every byte up to this attach.
|
|
@@ -243,50 +243,85 @@ describe("Room", () => {
|
|
|
243
243
|
expect(ptyInstance!.resizes.at(-1)).toEqual({ cols: 90, rows: 20 });
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
-
it(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
246
|
+
it("lets a monitor size only an unattended session", () => {
|
|
247
|
+
let ptyInstance: FakePty | null = null;
|
|
248
|
+
const factory: PtyFactory = () => {
|
|
249
|
+
ptyInstance = new FakePty() as unknown as FakePty;
|
|
250
|
+
return ptyInstance as any;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const manager = new RoomManager(factory);
|
|
254
|
+
const room = manager.getRoom("monitor-resize", { cols: 80, rows: 24 }, "/tmp");
|
|
255
|
+
const desktop = new FakeSocket();
|
|
256
|
+
const monitor = new FakeSocket();
|
|
257
|
+
room.attachClient(
|
|
258
|
+
{ id: "desktop", name: "Desktop", colorIndex: 0, cols: 80, rows: 24, nudge: false },
|
|
259
|
+
desktop
|
|
260
|
+
);
|
|
261
|
+
room.attachClient(
|
|
262
|
+
{ id: "wall", name: "Wall", source: "monitor", colorIndex: 1, cols: 80, rows: 40, nudge: false },
|
|
263
|
+
monitor
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const before = ptyInstance!.resizes.length;
|
|
267
|
+
monitor.emitMessage({ type: "resize", cols: 80, rows: 40, claim: "attach", user: true });
|
|
268
|
+
expect(ptyInstance!.resizes.length).toBe(before);
|
|
269
|
+
expect(findMessages(monitor, "active_size").at(-1)).toMatchObject({ cols: 80, rows: 24 });
|
|
270
|
+
|
|
271
|
+
desktop.close();
|
|
272
|
+
monitor.emitMessage({ type: "resize", cols: 80, rows: 40, claim: "attach", user: true });
|
|
273
|
+
expect(ptyInstance!.resizes.at(-1)).toEqual({ cols: 80, rows: 40 });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("the last user action — open or keystroke — owns the size", () => {
|
|
277
|
+
let ptyInstance: FakePty | null = null;
|
|
278
|
+
const factory: PtyFactory = () => {
|
|
279
|
+
ptyInstance = new FakePty() as unknown as FakePty;
|
|
280
|
+
return ptyInstance as any;
|
|
281
|
+
};
|
|
282
|
+
const manager = new RoomManager(factory);
|
|
283
|
+
const room = manager.getRoom("attach-claim", { cols: 80, rows: 24 }, "/tmp");
|
|
284
|
+
const cli = new FakeSocket();
|
|
285
|
+
room.attachClient({ id: "cli", name: "Local", colorIndex: 0, cols: 80, rows: 24 }, cli);
|
|
286
|
+
|
|
287
|
+
// The desktop CLI is actively typing right now.
|
|
288
|
+
cli.emitMessage({ type: "input", data: "ls" });
|
|
289
|
+
|
|
290
|
+
// A phone opens the session and attach-claims its fitted size: opening
|
|
291
|
+
// is the latest user action, so the claim wins outright — even over
|
|
292
|
+
// someone who typed a moment ago. Strict recency, no idle windows.
|
|
293
|
+
const phone = new FakeSocket();
|
|
294
|
+
room.attachClient({ id: "ph", name: "Phone", colorIndex: 1, cols: 46, rows: 28 }, phone);
|
|
295
|
+
phone.emitMessage({ type: "resize", cols: 46, rows: 28, claim: "attach" });
|
|
296
|
+
expect(ptyInstance!.resizes.at(-1)).toEqual({ cols: 46, rows: 28 });
|
|
297
|
+
|
|
298
|
+
// The CLI's next keystroke is now the latest user action: ownership
|
|
299
|
+
// transfers back and the PTY snaps to the CLI's declared fit
|
|
300
|
+
// immediately — before any resize message from it.
|
|
301
|
+
cli.emitMessage({ type: "input", data: "x" });
|
|
302
|
+
expect(ptyInstance!.resizes.at(-1)).toEqual({ cols: 80, rows: 24 });
|
|
303
|
+
|
|
304
|
+
// A plain (unclaimed) resize from a fresh viewer is a declaration, not
|
|
305
|
+
// a user action — it cannot move an owned size, and the viewer is
|
|
306
|
+
// snapped back to the shared grid.
|
|
307
|
+
const viewer = new FakeSocket();
|
|
308
|
+
room.attachClient({ id: "v", name: "Viewer", colorIndex: 2, cols: 200, rows: 50 }, viewer);
|
|
309
|
+
const beforeViewer = ptyInstance!.resizes.length;
|
|
310
|
+
viewer.emitMessage({ type: "resize", cols: 200, rows: 50 });
|
|
311
|
+
expect(ptyInstance!.resizes.length).toBe(beforeViewer);
|
|
312
|
+
expect(findMessages(viewer, "active_size").at(-1)).toMatchObject({ cols: 80, rows: 24, clientId: "cli" });
|
|
313
|
+
|
|
314
|
+
// But that declaration is remembered: the viewer's first keystroke makes
|
|
315
|
+
// it the owner and the PTY takes its declared 200×50 in the same beat.
|
|
316
|
+
viewer.emitMessage({ type: "input", data: "y" });
|
|
317
|
+
expect(ptyInstance!.resizes.at(-1)).toEqual({ cols: 200, rows: 50 });
|
|
318
|
+
|
|
319
|
+
// Wheel reports are reading, not typing: a pure SGR mouse payload from
|
|
320
|
+
// the phone transfers nothing.
|
|
321
|
+
const beforeWheel = ptyInstance!.resizes.length;
|
|
322
|
+
phone.emitMessage({ type: "input", data: "\x1b[<64;40;12M\x1b[<65;40;12M" });
|
|
323
|
+
phone.emitMessage({ type: "resize", cols: 46, rows: 28 });
|
|
324
|
+
expect(ptyInstance!.resizes.length).toBe(beforeWheel);
|
|
290
325
|
});
|
|
291
326
|
|
|
292
327
|
it("bounds the join-time snapshot replay to a tail cut at a line boundary", () => {
|
|
@@ -408,41 +443,31 @@ describe("Room", () => {
|
|
|
408
443
|
expect(last?.clientId).toBe("typer");
|
|
409
444
|
});
|
|
410
445
|
|
|
411
|
-
it("
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
viewer.emitMessage({ type: "resize", cols: 60, rows: 20 });
|
|
437
|
-
expect((ptyInstance as unknown as FakePty).resizes.at(-1)).toEqual({ cols: 60, rows: 20 });
|
|
438
|
-
|
|
439
|
-
// The previous holder is told the size moved.
|
|
440
|
-
const sizes = findMessages(typer, "active_size");
|
|
441
|
-
expect(sizes.at(-1)?.cols).toBe(60);
|
|
442
|
-
expect(sizes.at(-1)?.clientId).toBe("viewer");
|
|
443
|
-
} finally {
|
|
444
|
-
vi.useRealTimers();
|
|
445
|
-
}
|
|
446
|
+
it("frees the size when the owner detaches — no idle window needed", () => {
|
|
447
|
+
let ptyInstance: FakePty | null = null;
|
|
448
|
+
const factory: PtyFactory = () => {
|
|
449
|
+
ptyInstance = new FakePty() as unknown as FakePty;
|
|
450
|
+
return ptyInstance as any;
|
|
451
|
+
};
|
|
452
|
+
const manager = new RoomManager(factory);
|
|
453
|
+
const room = manager.getRoom("claim", { cols: 120, rows: 30 }, "/tmp");
|
|
454
|
+
const typer = new FakeSocket();
|
|
455
|
+
const viewer = new FakeSocket();
|
|
456
|
+
room.attachClient({ id: "typer", name: "Alex", colorIndex: 0, cols: 120, rows: 30 }, typer);
|
|
457
|
+
room.attachClient({ id: "viewer", name: "Blake", colorIndex: 1, cols: 60, rows: 20 }, viewer);
|
|
458
|
+
|
|
459
|
+
typer.emitMessage({ type: "input", data: "ls\r" });
|
|
460
|
+
typer.emitMessage({ type: "resize", cols: 120, rows: 30 });
|
|
461
|
+
|
|
462
|
+
// The owner is elsewhere: the viewer's autofit is refused (and snapped back).
|
|
463
|
+
viewer.emitMessage({ type: "resize", cols: 60, rows: 20 });
|
|
464
|
+
expect((ptyInstance as unknown as FakePty).resizes.at(-1)).toEqual({ cols: 120, rows: 30 });
|
|
465
|
+
|
|
466
|
+
// The owner leaves: the size is up for grabs, and the viewer's next
|
|
467
|
+
// plain refit takes it — no keystroke, no claim, no waiting.
|
|
468
|
+
typer.close();
|
|
469
|
+
viewer.emitMessage({ type: "resize", cols: 60, rows: 20 });
|
|
470
|
+
expect((ptyInstance as unknown as FakePty).resizes.at(-1)).toEqual({ cols: 60, rows: 20 });
|
|
446
471
|
});
|
|
447
472
|
|
|
448
473
|
it("counts attention bells but not OSC-terminator BELs", () => {
|
|
@@ -813,6 +838,31 @@ describe("Room", () => {
|
|
|
813
838
|
}
|
|
814
839
|
});
|
|
815
840
|
|
|
841
|
+
it("preserves consecutive shrink and grow transitions in the screen grid", async () => {
|
|
842
|
+
const { room, pty } = await setup("ordered-grid-resize-burst", { cols: 20, rows: 5 });
|
|
843
|
+
const owner = new FakeSocket();
|
|
844
|
+
room.attachClient(
|
|
845
|
+
{ id: "owner", name: "Owner", colorIndex: 0, cols: 20, rows: 5, replayBytes: 0, nudge: false },
|
|
846
|
+
owner
|
|
847
|
+
);
|
|
848
|
+
const rows = Array.from({ length: 5 }, (_, i) => `\x1b[${i + 1};1HROW-${i + 1}`).join("");
|
|
849
|
+
pty().emit("\x1b[?1049h\x1b[2J" + rows);
|
|
850
|
+
|
|
851
|
+
// Even with no output between them, shrink then grow is not equivalent
|
|
852
|
+
// to only the final size: xterm discards the rows clipped by the shrink.
|
|
853
|
+
owner.emitMessage({ type: "resize", cols: 20, rows: 2, user: true });
|
|
854
|
+
owner.emitMessage({ type: "resize", cols: 20, rows: 5, user: true });
|
|
855
|
+
const late = new FakeSocket();
|
|
856
|
+
room.attachClient(
|
|
857
|
+
{ id: "late", name: "Late", colorIndex: 1, cols: 20, rows: 5, replayBytes: 65536 },
|
|
858
|
+
late
|
|
859
|
+
);
|
|
860
|
+
const snapshot = await waitForSnapshot(late);
|
|
861
|
+
expect(String(snapshot.data)).not.toContain("ROW-1");
|
|
862
|
+
expect(String(snapshot.data)).toContain("ROW-4");
|
|
863
|
+
expect(String(snapshot.data)).toContain("ROW-5");
|
|
864
|
+
});
|
|
865
|
+
|
|
816
866
|
it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
|
|
817
867
|
const { room, pty } = await setup("capped");
|
|
818
868
|
const big = "\x1b[20;1H" + "y".repeat(4096);
|
|
@@ -113,6 +113,46 @@ 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 wall/watch tile may fit an unattended room, but it must not replace
|
|
117
|
+
// the PTY geometry underneath an attached desktop. Claude's alternate
|
|
118
|
+
// screen has no local scrollback, so foreign rows can otherwise be clipped.
|
|
119
|
+
const monitorClaim = await page1.evaluate(async (roomName) => {
|
|
120
|
+
const peer = new WebSocket(
|
|
121
|
+
`ws://${location.host}/ws?room=${encodeURIComponent(roomName)}&name=Wall&source=monitor&replay=0&cols=240&rows=70`
|
|
122
|
+
);
|
|
123
|
+
const nextActiveSize = () => new Promise((resolve) => {
|
|
124
|
+
const handler = (event) => {
|
|
125
|
+
const message = JSON.parse(String(event.data));
|
|
126
|
+
if (message.type !== "active_size") return;
|
|
127
|
+
peer.removeEventListener("message", handler);
|
|
128
|
+
resolve(message);
|
|
129
|
+
};
|
|
130
|
+
peer.addEventListener("message", handler);
|
|
131
|
+
});
|
|
132
|
+
const initialSize = nextActiveSize();
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
peer.onopen = resolve;
|
|
135
|
+
peer.onerror = reject;
|
|
136
|
+
});
|
|
137
|
+
const before = await initialSize;
|
|
138
|
+
const claimedSize = nextActiveSize();
|
|
139
|
+
peer.send(JSON.stringify({
|
|
140
|
+
type: "resize",
|
|
141
|
+
cols: 240,
|
|
142
|
+
rows: 70,
|
|
143
|
+
claim: "attach",
|
|
144
|
+
user: true
|
|
145
|
+
}));
|
|
146
|
+
const after = await claimedSize;
|
|
147
|
+
peer.close();
|
|
148
|
+
return { before, after };
|
|
149
|
+
}, freshRoom);
|
|
150
|
+
assert.deepEqual(
|
|
151
|
+
{ cols: monitorClaim.after.cols, rows: monitorClaim.after.rows },
|
|
152
|
+
{ cols: monitorClaim.before.cols, rows: monitorClaim.before.rows },
|
|
153
|
+
`monitor replaced an attached desktop's PTY size: ${JSON.stringify(monitorClaim)}`
|
|
154
|
+
);
|
|
155
|
+
|
|
116
156
|
// A resize can collapse scrollback to y=base=0 without xterm emitting an
|
|
117
157
|
// onScroll event. App follow-mode must reconcile that state before the
|
|
118
158
|
// next real room output grows below a viewport parked at 0.
|