Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
park,
reconnect,
sendColors,
sendResize,
} from "./terminal-ws-transport";

// The transport builds on createRelaySocket (partysocket) — reconnection,
Expand Down Expand Up @@ -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<typeof createTransport>,
terminal: ReturnType<typeof createMockTerminal>,
) {
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
Expand Down
35 changes: 33 additions & 2 deletions apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -454,6 +464,8 @@ export function createTransport(
_resumeListener: null,
_disposeFocusListeners: null,
_visible: true,
_lastRequestedDims: null,
_repaintOnAttach: false,
};
}

Expand Down Expand Up @@ -830,6 +842,7 @@ function attachSocketListeners(
sendColors(transport, terminal.options?.theme);
sendVisibleState(transport);
sendResize(transport, terminal.cols, terminal.rows);
requestRepaintIfResizedOffline(transport);
flushPendingInput(transport);
return;
}
Expand Down Expand Up @@ -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 }));
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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" }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions packages/host-service/src/terminal/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand Down
Loading