diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index a96079ed..4b956acb 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -59,6 +59,25 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { const decoder = new TextDecoder(); let ws: WebSocket | null = null; + + /** + * Tell the server this terminal's size. tmux draws the pane at whatever + * width it last heard, and a pane WIDER than xterm is silently corrupting: + * every over-long row autowraps its tail onto the next row's left margin, + * and tmux — believing that row unchanged — never repaints over it. The + * result is stray characters glued to the left edge of blank lines, which + * only becomes visible once a scroll repaints history. Reproduced with a + * one-column difference; two columns leaves two characters per line. + * + * A zero-size fit is never reported: the console collapses with CSS + * (`display:none` leaves the container 0×0), and fitting to that would + * resize the real tmux pane down to nothing. + */ + const sendSize = () => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + if (term.cols < 2 || term.rows < 2) return; + ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); + }; let retryTimer: ReturnType | null = null; let ended = false; // server said the pane exited — nothing left to attach to let disposed = false; @@ -72,7 +91,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { sock.binaryType = "arraybuffer"; sock.onopen = () => { - sock.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); + sendSize(); }; sock.onmessage = (event) => { const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]"; @@ -113,11 +132,23 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); }); + // xterm's own size change is the event worth reporting, not the + // container's: cols also move when the webfont finishes loading or the + // page zooms, neither of which resizes the element ResizeObserver watches. + const resizeDisposable = term.onResize(sendSize); + + // Re-assert on focus. Several attach clients can share one tmux session + // (a second tab, another lane pane), and tmux's `window-size latest` sizes + // the window to whichever client was last active — resizing this pane out + // from under a terminal that is never told. Coming back to it says so. + const reassert = () => sendSize(); + term.textarea?.addEventListener("focus", reassert); + const resizeObserver = new ResizeObserver(() => { - fit.fit(); - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); - } + // Never fit a collapsed console — see sendSize. + const el = containerRef.current; + if (!el || !el.clientWidth || !el.clientHeight) return; + fit.fit(); // a real change fires onResize, which reports it }); if (containerRef.current) resizeObserver.observe(containerRef.current); @@ -125,6 +156,8 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { disposed = true; if (retryTimer) clearTimeout(retryTimer); resizeObserver.disconnect(); + term.textarea?.removeEventListener("focus", reassert); + resizeDisposable.dispose(); dataDisposable.dispose(); ws?.close(); term.dispose(); diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index d2fc7e51..94c46e17 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -20,7 +20,10 @@ const keyHandlerMock = vi.fn(); const termState = { modes: { mouseTrackingMode: "none" as "none" | "x10" | "vt200" | "drag" | "any" }, buffer: { active: { type: "alternate" as "normal" | "alternate" } }, + cols: 131, + rows: 28, }; +const onResizeHandlers: Array<() => void> = []; vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn().mockImplementation(() => ({ @@ -30,6 +33,16 @@ vi.mock("@xterm/xterm", () => ({ get buffer() { return termState.buffer; }, + get cols() { + return termState.cols; + }, + get rows() { + return termState.rows; + }, + onResize: (fn: () => void) => { + onResizeHandlers.push(fn); + return { dispose: vi.fn() }; + }, open: openMock, write: writeMock, onData: (fn: (d: string) => void) => { @@ -53,6 +66,8 @@ class MockWebSocket { onopen: (() => void) | null = null; onmessage: ((e: { data: unknown }) => void) | null = null; onclose: (() => void) | null = null; + readyState = 1; // OPEN — the component only sends on an open socket + static OPEN = 1; constructor(url: string) { this.url = url; MockWebSocket.instances.push(this); @@ -71,8 +86,11 @@ describe("TerminalView", () => { beforeEach(() => { termState.modes.mouseTrackingMode = "none"; termState.buffer.active.type = "alternate"; + termState.cols = 131; + termState.rows = 28; MockWebSocket.instances = []; onDataHandlers.length = 0; + onResizeHandlers.length = 0; writeMock.mockClear(); wheelHandlerMock.mockClear(); keyHandlerMock.mockClear(); @@ -161,6 +179,43 @@ describe("TerminalView", () => { expect(preventPlainTab).not.toHaveBeenCalled(); }); + describe("size reporting (tmux must never be wider than the terminal)", () => { + // A tmux pane wider than xterm wraps each over-long row's tail onto the + // next row's left margin, and tmux never repaints a row it believes + // unchanged — the stray characters glued to the left edge after scrolling. + it("reports its size when the socket opens", () => { + render(); + const ws = MockWebSocket.instances[0]!; + ws.onopen?.(); + expect(ws.sent).toContain(JSON.stringify({ type: "resize", cols: 131, rows: 28 })); + }); + + it("reports a size change xterm made on its own, with no container resize", () => { + // Webfont load and page zoom both move cols without resizing the element + // ResizeObserver watches, which is why onResize is the hook. + render(); + const ws = MockWebSocket.instances[0]!; + ws.onopen?.(); + ws.sent.length = 0; + termState.cols = 129; + onResizeHandlers.forEach((fn) => fn()); + expect(ws.sent).toEqual([JSON.stringify({ type: "resize", cols: 129, rows: 28 })]); + }); + + it("never reports a collapsed console's zero size", () => { + // The Workspace console collapses with CSS, so the container can be 0×0 + // while mounted; resizing the real tmux pane to that would wreck it. + render(); + const ws = MockWebSocket.instances[0]!; + ws.sent.length = 0; + termState.cols = 0; + termState.rows = 0; + onResizeHandlers.forEach((fn) => fn()); + ws.onopen?.(); + expect(ws.sent).toEqual([]); + }); + }); + it("re-attaches after the socket drops (a server restart must not freeze the pane)", () => { vi.useFakeTimers(); render(); diff --git a/docs/API.md b/docs/API.md index a4a395df..70533545 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1809,7 +1809,7 @@ Sent when a notification is created. #### /ws-pty/:runId — PTY frames -A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell`, other tools), all receiving the same frames live-synced. Resize events: the client sends a `TIOCSWINSZ` ioctl down the pane's pty when the browser terminal is resized, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again. +A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell`, other tools), all receiving the same frames live-synced. Resize events: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because `window-size latest` lets a second attach client (another tab, `ccam lanes shell`) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again. #### cc_config_changed