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
+38 -5
View File
@@ -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();