fix(tmux): scrolling a pane no longer freezes typing

The wheel puts a pane into tmux's copy-mode, which took every typed key as
a scrollback command and painted a yellow [0/0] box in the corner. In
AgentHub's own panes (every other session on the server keeps tmux's
defaults, restored verbatim):

- the copy-mode position box is off (copy-mode-position-format "");
- the first keystroke after a mouse report leaves copy-mode
  (`copy-mode -q`, pty-attach) before it is written, once per scroll;
- copy-mode keys that open a tmux prompt (g, :, /, f, digits…) leave
  copy-mode and reach the program;
- the wheel scrolls 10 lines a notch instead of 5, and the first notch
  already scrolls instead of only entering copy-mode.
This commit is contained in:
2026-10-19 11:59:22 +07:00
parent e2d8d314f6
commit 0434ffc750
4 changed files with 214 additions and 7 deletions
+31
View File
@@ -62,6 +62,37 @@ describe("pty-attach", () => {
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-0"), /EBADRUNID/);
});
it("leaves copy-mode before the first keystroke after a wheel, and only then", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
const ws = makeFakeWs();
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
const leaves = () => calls.filter((c) => c[0] === "copy-mode").length;
const send = (t) => ws.__emitter.emit("message", Buffer.from(t), false);
send("a");
assert.equal(leaves(), 0, "no scroll yet: typing spawns no tmux");
send("\x1b[<64;10;5M\x1b[<64;10;5M");
send("\x1b[I");
assert.equal(leaves(), 0, "a wheel or a focus report is not typing");
send("b");
assert.deepEqual(calls.filter((c) => c[0] === "copy-mode")[0], [
"copy-mode",
"-q",
"-t",
"=agenthub-lane-1:",
]);
send("c");
assert.equal(leaves(), 1, "once per scroll");
assert.deepEqual(
fakePty.__writes.filter((w) => typeof w === "string"),
["a", "\x1b[<64;10;5M\x1b[<64;10;5M", "\x1b[I", "b", "c"]
);
});
it("turns tmux mouse mode on for the session it attaches to", () => {
const calls = [];
tmux.__setExecImpl((args) => {
+44 -3
View File
@@ -183,9 +183,18 @@ describe("tmux wrapper", () => {
calls = fake(true);
tmux.enableMouse("agenthub-lane-1");
assert.ok(calls.some((c) => c[0] === "set-option" && c[2] === "=agenthub-lane-1:"));
// tmux chains the four options into two spawns; psmux above got four.
// tmux chains the four options into two spawns, plus the copy-mode
// position box (a newer tmux only) in its own; psmux above got five.
const sets = calls.filter((c) => c[0] === "set-option");
assert.equal(sets.length, 2);
assert.equal(sets.length, 3);
assert.deepEqual(sets[2], [
"set-option",
"-w",
"-t",
"=agenthub-lane-1:",
"copy-mode-position-format",
"",
]);
assert.deepEqual(sets[0], [
"set-option",
"-t",
@@ -235,7 +244,39 @@ describe("tmux wrapper", () => {
...["set-option", "-t", "=agenthub-lane-1:", "pane-scrollbars-style"],
"fg=brightwhite,bg=black,width=1,pad=0",
]);
assert.equal(calls.length, 3);
// No yellow `[0/0]` box in the corner while the wheel's copy-mode is on.
assert.deepEqual(calls[3], [
...["set-option", "-w", "-t", "=agenthub-lane-1:", "copy-mode-position-format", ""],
]);
// Copy-mode keys that open a prompt ("(goto line)"…) leave copy-mode and
// reach the program in OUR sessions only; others keep tmux's default.
assert.deepEqual(calls[4].slice(0, 9), [
...["bind-key", "-T", "copy-mode", "g", "if-shell", "-F"],
"#{m:agenthub-lane-*,#{session_name}}",
"send-keys -X cancel ; send-keys -l -- 'g'",
'command-prompt -P -p "(goto line)" { send-keys -X goto-line -- "%%" }',
]);
assert.ok(calls[4].includes(":"), "vi's `:` goto-line is re-bound too");
// The wheel scrolls 10 lines a notch in OUR panes, 5 (tmux's) elsewhere.
assert.deepEqual(calls[5].slice(0, 9), [
...["bind-key", "-T", "root", "WheelUpPane", "if-shell", "-F"],
"#{||:#{alternate_on},#{pane_in_mode},#{mouse_any_flag}}",
"send-keys -M",
'if-shell -F "#{m:agenthub-lane-*,#{session_name}}" { copy-mode -e ; send-keys -X -N 10 scroll-up } { copy-mode -e }',
]);
assert.ok(calls[5].includes("select-pane ; send-keys -X -N 10 scroll-down"));
assert.ok(calls[5].includes("select-pane ; send-keys -X -N 5 scroll-down"));
assert.equal(calls.length, 6);
});
it("newSession still succeeds when tmux refuses the copy-mode bindings", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "bind-key") throw new Error("unknown flag -F");
return "";
});
assert.doesNotThrow(() =>
tmux.newSession({ name: "agenthub-lane-1", cwd: "/tmp/repo", argv: ["claude"] })
);
});
it("newSession still succeeds on a tmux older than 3.7 (no pane-scrollbars)", () => {
+25 -4
View File
@@ -12,6 +12,8 @@
* the driver's size and told so (`{type:"size", cols, rows}`) so its xterm
* renders the same grid, scaled to fit, instead of each browser resizing tmux
* under the others. A driver that leaves sends `{type:"lead"}` to the rest.
* A keystroke that follows a mouse report first takes the pane out of tmux's
* copy-mode (the wheel's scrollback), so typing never lands in it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -73,7 +75,16 @@ function attachBinary(platform = process.platform) {
// the paste in that issue's measurements.
const PASTE_HOLD_MS = 700;
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }> }
// Input that is not typing: a mouse report (SGR `ESC[<b;x;yM`, X10 `ESC[M` +
// three bytes) or a focus in/out. A wheel report is what puts a pane into
// tmux's copy-mode, where every typed key is taken as a scrollback command —
// the pane looked frozen. So once a mouse report has passed, the next real
// keystroke first leaves copy-mode (`tmux.leaveCopyMode`), then is written.
const NOT_TYPING_RE = /\x1b\[<\d+;\d+;\d+[Mm]|\x1b\[M[\s\S]{3}|\x1b\[[IO]/g;
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }>, scrolled }
// (`scrolled`: a mouse report reached the pane since the last keystroke — per
// pane, since one browser may scroll and another type)
const groups = new Map();
const tell = (v, msg) => {
@@ -211,6 +222,16 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
/* the attach exited while its input was held — nothing to write to */
}
};
// Keystrokes after a scroll leave copy-mode first; see NOT_TYPING_RE.
function input(text) {
if (text.replace(NOT_TYPING_RE, "") === "") {
if (text.includes("\x1b[M") || text.includes("\x1b[<")) group.scrolled = true;
} else if (group.scrolled) {
group.scrolled = false;
tmux.leaveCopyMode(runId);
}
write(text);
}
function write(text) {
if (me.exited) return; // the attach is gone: nothing reads this pipe
const wait = holdUntil - Date.now();
@@ -248,7 +269,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
const binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary);
const text = data.toString("utf8");
if (binary) {
write(text);
input(text);
return;
}
// The browser's WebSocket API sends a JS string as a text frame, and
@@ -262,7 +283,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
try {
msg = JSON.parse(text);
} catch {
write(text);
input(text);
return;
}
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
@@ -274,7 +295,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
return;
}
}
write(text);
input(text);
});
ws.on("close", () => {
+114
View File
@@ -608,6 +608,119 @@ function enableMouse(name) {
} catch {
/* tmux < 3.7 */
}
// The wheel's copy-mode paints a yellow `[0/0]` position box in the pane's
// corner; an empty format draws none. A window option of a recent tmux
// only — an older one refuses it and keeps the box.
try {
run(["set-option", "-w", "-t", pane(name), "copy-mode-position-format", ""]);
} catch {
/* tmux without copy-mode-position-format */
}
try {
if (exact()) onServerOf(name, copyModePromptBindings());
} catch {
/* an old tmux without `if-shell -F`/`#{m:}`: the prompts stay */
}
try {
if (exact()) onServerOf(name, wheelBindings());
} catch {
/* an old tmux: the wheel keeps tmux's default speed */
}
}
// tmux's wheel scrolls 5 lines a notch, and its first notch only ENTERS
// copy-mode without scrolling. In OUR panes a notch is WHEEL_LINES lines and
// the first one already scrolls; every other session keeps tmux's default
// binding, restored verbatim (same server-wide re-bind as the prompts below).
const WHEEL_LINES = 10;
const WHEEL_ROOT_GUARD = "#{||:#{alternate_on},#{pane_in_mode},#{mouse_any_flag}}";
/** One `;`-chained argv re-binding the wheel in `root` and both copy-mode tables. */
function wheelBindings() {
const scroll = (dir, n) => `select-pane ; send-keys -X -N ${n} scroll-${dir}`;
const cmds = [
[
...["bind-key", "-T", "root", "WheelUpPane", "if-shell", "-F", WHEEL_ROOT_GUARD],
"send-keys -M",
`if-shell -F "${OURS}" { copy-mode -e ; send-keys -X -N ${WHEEL_LINES} scroll-up } { copy-mode -e }`,
],
];
for (const table of ["copy-mode", "copy-mode-vi"])
for (const [key, dir] of [
["WheelUpPane", "up"],
["WheelDownPane", "down"],
])
cmds.push([
...["bind-key", "-T", table, key, "if-shell", "-F", OURS],
scroll(dir, WHEEL_LINES),
scroll(dir, 5),
]);
return cmds.flatMap((c, i) => (i ? [";", ...c] : c));
}
// The wheel puts a pane into copy-mode, and there a typed `g` (`:` with vi
// keys), `f`, `/`, a digit… opens a tmux prompt — "(goto line)", "(jump
// forward)", "(repeat)" — instead of reaching the CLI. In OUR panes those keys
// leave copy-mode and are typed into the program; every other session on the
// server (the user's own tmux shares the default socket) keeps tmux's default
// binding, restored verbatim below. Key tables are server-wide, so this is
// re-bound on every attach like the options above (idempotent).
const OURS = "#{m:agenthub-lane-*,#{session_name}}";
const COPY_MODE_PROMPTS = {
"copy-mode": {
g: 'command-prompt -P -p "(goto line)" { send-keys -X goto-line -- "%%" }',
f: 'command-prompt -1P -p "(jump forward)" { send-keys -X jump-forward -- "%%" }',
F: 'command-prompt -1P -p "(jump backward)" { send-keys -X jump-backward -- "%%" }',
t: 'command-prompt -1P -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }',
T: 'command-prompt -1P -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }',
},
"copy-mode-vi": {
":": 'command-prompt -P -p "(goto line)" { send-keys -X goto-line -- "%%" }',
"/": 'command-prompt -P -T search -p "(search down)" { send-keys -X search-forward -- "%%" }',
"?": 'command-prompt -P -T search -p "(search up)" { send-keys -X search-backward -- "%%" }',
f: 'command-prompt -1P -p "(jump forward)" { send-keys -X jump-forward -- "%%" }',
F: 'command-prompt -1P -p "(jump backward)" { send-keys -X jump-backward -- "%%" }',
t: 'command-prompt -1P -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }',
T: 'command-prompt -1P -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }',
...Object.fromEntries(
[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => [
String(n),
`command-prompt -NP -I ${n} -p (repeat) { send-keys -N "%%" }`,
])
),
},
};
/** One `;`-chained argv re-binding every key in `COPY_MODE_PROMPTS`. */
function copyModePromptBindings() {
const cmds = [];
for (const [table, keys] of Object.entries(COPY_MODE_PROMPTS))
for (const [key, original] of Object.entries(keys))
cmds.push([
"bind-key",
"-T",
table,
key,
"if-shell",
"-F",
OURS,
`send-keys -X cancel ; send-keys -l -- '${key}'`,
original,
]);
return cmds.flatMap((c, i) => (i ? [";", ...c] : c));
}
/**
* Take the pane out of copy-mode (or any mode) so what is typed next reaches
* the program, not tmux's scrollback keys. A no-op for a pane in no mode;
* best-effort — a tmux without `copy-mode -q` (< 3.2) leaves it as it is.
*/
function leaveCopyMode(name) {
try {
onServerOf(name, ["copy-mode", "-q", "-t", pane(name)]);
} catch {
/* session gone or an old tmux */
}
}
/**
@@ -898,6 +1011,7 @@ module.exports = {
hasSession,
newSession,
enableMouse,
leaveCopyMode,
paneCommand,
sendCommand,
sendText,