fix(run): keep the tmux pane exactly as wide as xterm
Scrolling a lane console repainted history with stray characters glued to the left edge of blank lines. Reproduced against a real tmux attach: a pane WIDER than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux — believing that row unchanged — never repaints over it. One column of difference leaves one stray character per wrapped line, two columns leave two; matched widths render clean. TerminalView only reported its size on WS open and on the container's ResizeObserver, so nothing corrected: - cols moving with no container resize (webfont load, page zoom, scrollbar) - tmux's `window-size latest` letting a second attach client (another tab, `ccam lanes shell`) resize the window out from under this one Size is now reported from xterm's own onResize, re-asserted when the terminal regains focus, and never reported as zero — the console collapses with CSS, and fitting a 0x0 container would resize the real pane to nothing. client 447/447, tsc clean.
This commit is contained in:
@@ -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<typeof setTimeout> | 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();
|
||||
|
||||
@@ -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(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
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(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
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(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
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(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user