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:
2026-08-28 10:07:12 +07:00
parent 82263a0ab5
commit ce5eadba89
3 changed files with 94 additions and 6 deletions
@@ -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" />);