diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 83286999f34..a22198621eb 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -204,6 +204,10 @@ class TerminalRuntimeRegistryImpl { this.serializeExistingRuntime(terminalId, instanceId), }); entry.initialBuffer = undefined; + entry.transport._lastRequestedDims = { + cols: entry.runtime.terminal.cols, + rows: entry.runtime.terminal.rows, + }; // Pair the transport's stream position with what the fresh xterm // actually contains: the persisted anchor belongs to the persisted // snapshot only; sibling-seeded content has no known position. diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts index efc0fed62bd..982ed6c306e 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.test.ts @@ -17,6 +17,7 @@ import { park, reconnect, sendColors, + sendResize, } from "./terminal-ws-transport"; // The transport builds on createRelaySocket (partysocket) — reconnection, @@ -729,6 +730,60 @@ describe("terminal-ws-transport", () => { expect(transport.connectionState).toBe("open"); }); + describe("resized while parked", () => { + function parkWithOutput() { + const { transport, terminal, socket } = connectAttached(); + socket.message( + JSON.stringify({ type: "synced", epoch: "e1", seq: 0, mode: "exact" }), + ); + const bytes = new TextEncoder().encode("abc"); + socket.message( + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ), + ); + park(transport); + return { transport, terminal }; + } + + function reattach( + transport: ReturnType, + terminal: ReturnType, + ) { + connect(transport, terminal, "ws://host/terminal/t1"); + const redial = FakeRelaySocket.instances.at(-1); + if (!redial) throw new Error("expected relay socket instance"); + redial.open(); + redial.message(JSON.stringify({ type: "attached", terminalId: "t1" })); + return redial.sent.map((payload) => JSON.parse(payload) as unknown); + } + + test("asks the host for a repaint when the size went and came back", () => { + const { transport, terminal } = parkWithOutput(); + + sendResize(transport, 101, 20); + sendResize(transport, 101, 27); + + expect(reattach(transport, terminal)).toEqual([ + { type: "visible", visible: true }, + { type: "resize", cols: 101, rows: 27 }, + { type: "repaint" }, + ]); + }); + + test("asks for nothing when the size never changed", () => { + const { transport, terminal } = parkWithOutput(); + + sendResize(transport, 101, 27); + + expect(reattach(transport, terminal)).toEqual([ + { type: "visible", visible: true }, + { type: "resize", cols: 101, rows: 27 }, + ]); + }); + }); + test("park refuses to disconnect a pre-seq host", () => { // A pre-seq host ignores `?seq=` and `replay=0` suppresses its legacy // FIFO replay, so a closed socket means the parked gap is silently diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index d408803bcc9..6cfb0904401 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -218,6 +218,16 @@ export interface TerminalTransport { * on every attach: a reconnecting socket starts out assumed visible. */ _visible: boolean; + /** Dims of the last `sendResize` call, delivered or not. */ + _lastRequestedDims: { cols: number; rows: number } | null; + /** + * The xterm changed size while no socket could tell the host. A shrink + * deletes the rows below the cursor and a grow pulls rows back from + * scrollback, so the screen no longer lines up with where the program + * thinks its cursor is. When the PTY ends up the same size, nothing sends + * SIGWINCH, and the program's next relative redraw lands on the wrong rows. + */ + _repaintOnAttach: boolean; } const MAX_LOG_ENTRIES = 200; @@ -454,6 +464,8 @@ export function createTransport( _resumeListener: null, _disposeFocusListeners: null, _visible: true, + _lastRequestedDims: null, + _repaintOnAttach: false, }; } @@ -830,6 +842,7 @@ function attachSocketListeners( sendColors(transport, terminal.options?.theme); sendVisibleState(transport); sendResize(transport, terminal.cols, terminal.rows); + requestRepaintIfResizedOffline(transport); flushPendingInput(transport); return; } @@ -1171,12 +1184,30 @@ export function sendResize( cols: number, rows: number, ) { + const previous = transport._lastRequestedDims; + transport._lastRequestedDims = { cols, rows }; const socket = transport._socket; - if (!socket || socket.readyState !== WebSocket.OPEN) return; - if (transport.connectionState !== "open") return; + if ( + !socket || + socket.readyState !== WebSocket.OPEN || + transport.connectionState !== "open" + ) { + const resized = + previous !== null && (previous.cols !== cols || previous.rows !== rows); + const hasContent = + transport._hasReceivedBytes || transport._xtermHadContent; + if (resized && hasContent) transport._repaintOnAttach = true; + return; + } socket.send(JSON.stringify({ type: "resize", cols, rows })); } +function requestRepaintIfResizedOffline(transport: TerminalTransport) { + if (!transport._repaintOnAttach) return; + transport._repaintOnAttach = false; + transport._socket?.send(JSON.stringify({ type: "repaint" })); +} + export function sendDispose(transport: TerminalTransport) { if (transport._socket?.readyState === WebSocket.OPEN) { transport._socket.send(JSON.stringify({ type: "dispose" })); diff --git a/packages/host-service/src/terminal/terminal.seq-catchup.node-test.ts b/packages/host-service/src/terminal/terminal.seq-catchup.node-test.ts index 3d5b0dceb2b..ff76d9bbc36 100644 --- a/packages/host-service/src/terminal/terminal.seq-catchup.node-test.ts +++ b/packages/host-service/src/terminal/terminal.seq-catchup.node-test.ts @@ -359,6 +359,10 @@ class SeqRenderer { this.ws?.send(JSON.stringify({ type: "resize", cols, rows })); } + sendRepaint(): void { + this.ws?.send(JSON.stringify({ type: "repaint" })); + } + sendVisible(visible: boolean): void { this.ws?.send(JSON.stringify({ type: "visible", visible })); } @@ -882,6 +886,28 @@ test( ); console.log("PHASE 6 restart-spanning reattach: preserved + healed ✓"); + // ── Phase 7: exact reattach at the same dims, then a client repaint ask + // A same-dims resize emits no SIGWINCH, so only the repaint message + // can make the TUI redraw a screen the client reshaped while detached. + await renderer.disconnect(); + await renderer.connect(terminalId); + const synced7 = await renderer.waitSynced(); + assert.equal(synced7.mode, "exact", "phase 7: reattach must be exact"); + const bytesBeforeRepaint = renderer.allBinary.length; + const redrawnSince = () => + new TextDecoder() + .decode(Buffer.concat(renderer.allBinary.slice(bytesBeforeRepaint))) + .includes("FULL-REDRAW"); + await sleep(300); + assert.ok(!redrawnSince(), "phase 7: no repaint before the client asks"); + renderer.sendRepaint(); + await waitFor( + redrawnSince, + 5_000, + "TUI to repaint on a client repaint ask", + ); + console.log("PHASE 7 client repaint ask: full redraw ✓"); + renderer.assertNoReset("whole run"); witness.assertNoReset("whole run"); } finally { diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 42a79fef6e5..06d535800e6 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -231,6 +231,9 @@ type TerminalClientMessage = // suspended in a pocket, a Wi-Fi drop the relay never sees) stops // constraining the PTY size. Clients that never answer are never dropped. | { type: "pong" } + // The client's xterm changed size while it could not tell the host, so its + // screen no longer matches the program's. Older hosts ignore it. + | { type: "repaint" } | { type: "dispose" }; // PTY output bytes travel as binary WebSocket frames — the renderer pipes @@ -3829,6 +3832,12 @@ export function registerWorkspaceTerminalRoute({ return; } + if (message.type === "repaint") { + clearPendingRepaintNudge(session); + nudgeRepaint(session); + return; + } + if (session.exited) return; if (message.type === "colors") {