fix(terminal): a pane on a hidden tab never shrinks its tmux window

A terminal mounted on a hidden tab sits in a 0x0 box; its first fit shrank
xterm to 12x6 and the attach carried that size to tmux, so every background
pane was squeezed (Claude's TUI redrawn at 12 columns). It now neither fits
nor reports a size until shown, attaches with no size, and the server spawns
such an attach at the window's own size (tmux.windowSize). A shown tab
reports its size even when the fit did not change xterm's.

Settings > Agents shows the host's login profiles to staff only: a developer
got two 403s from reads that were never theirs.
This commit is contained in:
2026-10-26 14:53:29 +07:00
parent e3745d7504
commit a0cd808456
9 changed files with 151 additions and 30 deletions
+20
View File
@@ -235,6 +235,26 @@ describe("pty-attach", () => {
assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" });
});
it("attaches a terminal that names no size at the window's own size", () => {
// A terminal on a hidden tab is 0×0 and sends no size; spawning its attach
// at a default resized the pane under every other client.
const opts = [];
ptyAttach.__setSpawnImpl((_f, _a, o) => {
opts.push(o);
return makeFakePty();
});
tmux.__setExecImpl((args) => (args.includes("#{window_width} #{window_height}") ? "141 48\n" : ""));
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: undefined, rows: undefined });
assert.deepEqual([opts[0].cols, opts[0].rows], [141, 48]);
// tmux cannot answer: the old default, never a crash.
ptyAttach.__resetGroups();
tmux.__setExecImpl(() => {
throw new Error("no server");
});
ptyAttach.attach(makeFakeWs(), "agenthub-lane-2", { cols: undefined, rows: undefined });
assert.deepEqual([opts[1].cols, opts[1].rows], [80, 24]);
});
it("survives a stopped pane: pipe errors, typing and resizes after the attach exited", () => {
// Windows node-pty re-throws a read-pipe error nobody listens for, and its
// write pipe has no listener at all: Stop (kill-session) ended the attach
+9 -5
View File
@@ -19,13 +19,17 @@ describe("sizeFromQuery — the attach carries the client's real size", () => {
assert.deepEqual(sizeFromQuery(query("?cols=226&rows=29")), { cols: 226, rows: 29 });
});
it("falls back to 80x24 when the client named no size", () => {
assert.deepEqual(sizeFromQuery(query("")), { cols: 80, rows: 24 });
// No size (a terminal on a hidden tab) stays no size: the attach then takes
// the window's own, never a default that would resize the pane.
const none = { cols: undefined, rows: undefined };
it("names no size when the client named none", () => {
assert.deepEqual(sizeFromQuery(query("")), none);
});
it("refuses anything out of bounds — this number reaches an ioctl", () => {
assert.deepEqual(sizeFromQuery(query("?cols=0&rows=-5")), { cols: 80, rows: 24 });
assert.deepEqual(sizeFromQuery(query("?cols=99999&rows=99999")), { cols: 80, rows: 24 });
assert.deepEqual(sizeFromQuery(query("?cols=abc&rows=12.5")), { cols: 80, rows: 12 });
assert.deepEqual(sizeFromQuery(query("?cols=0&rows=-5")), none);
assert.deepEqual(sizeFromQuery(query("?cols=99999&rows=99999")), none);
assert.deepEqual(sizeFromQuery(query("?cols=abc&rows=12")), none);
});
});
+7 -3
View File
@@ -162,12 +162,16 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
const [file, args] = require("./drop-caps").ptyArgv(
...(tmux.memberAttach(runId) || [attachBinary(), ["attach-session", "-t", tmux.session(runId)]])
);
// An attach with no size of its own (a terminal on a hidden tab, 0×0) takes
// the window's current size, so attaching it never shrinks the pane.
const own =
cols && rows ? { cols, rows } : (!lead && tmux.windowSize(runId)) || { cols: 80, rows: 24 };
// A follower attaches at the driver's size: tmux sizes the window to the
// newest client, so its own size would resize the driver's pane first.
const pty = spawnImpl(file, args, {
name: "xterm-256color",
cols: lead ? lead.cols : cols || 80,
rows: lead ? lead.rows : rows || 24,
cols: lead ? lead.cols : own.cols,
rows: lead ? lead.rows : own.rows,
// Windows' inbox ConPTY re-renders psmux's output instead of passing it
// through, and wherever its glyph widths disagree with xterm.js's (⏺, ✻,
// Vietnamese) it leaves stray cells: a torn left edge, bars mid-screen.
@@ -176,7 +180,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
...(platform === "win32" && { useConptyDll: true }),
});
swallowPipeErrors(pty);
const me = { ws, pty, cols: cols || 80, rows: rows || 24, exited: false };
const me = { ws, pty, cols: own.cols, rows: own.rows, exited: false };
group.viewers.add(me);
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
else group.driver = me;
+20
View File
@@ -723,6 +723,25 @@ function leaveCopyMode(name) {
}
}
/**
* The session's window size as tmux has it now, `{cols, rows}`, or null when
* tmux can't answer. An attach that brings no size of its own (a terminal on
* a hidden tab) is spawned at this, so attaching it resizes nothing.
*/
function windowSize(name) {
try {
const [cols, rows] = String(
onServerOf(name, ["display-message", "-p", "-t", pane(name), "#{window_width} #{window_height}"])
)
.trim()
.split(" ")
.map(Number);
return cols >= 2 && rows >= 2 ? { cols, rows } : null;
} catch {
return null;
}
}
/**
* The command currently running in the session's active pane (`bash`, `zsh`,
* `claude`, …). Null when tmux can't answer — callers treat that as "unknown,
@@ -1013,6 +1032,7 @@ module.exports = {
enableMouse,
leaveCopyMode,
paneCommand,
windowSize,
sendCommand,
sendText,
pasteText,
+7 -3
View File
@@ -115,13 +115,17 @@ const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-9])?)$/;
* `resize` message lands, leaving 80-column wreckage in the scrollback that
* shows up as torn left-margin fragments when the user scrolls back
* (issue #33). Bounded, because this number reaches a `TIOCSWINSZ` ioctl.
* No size at all (a terminal on a hidden tab has none to give) stays
* `undefined`: the attach then takes the window's own size and resizes nothing.
*/
function sizeFromQuery(params) {
const read = (name, fallback) => {
const read = (name) => {
const parsed = Number.parseInt(params.get(name) ?? "", 10);
return Number.isInteger(parsed) && parsed >= 2 && parsed <= 1000 ? parsed : fallback;
return Number.isInteger(parsed) && parsed >= 2 && parsed <= 1000 ? parsed : undefined;
};
return { cols: read("cols", 80), rows: read("rows", 24) };
const cols = read("cols");
const rows = read("rows");
return cols && rows ? { cols, rows } : { cols: undefined, rows: undefined };
}
/**