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)", () => {