From 0434ffc750e0293742f266a33710d0138c8f7223 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 19 Oct 2026 11:59:22 +0700 Subject: [PATCH] fix(tmux): scrolling a pane no longer freezes typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- server/__tests__/pty-attach.test.js | 31 ++++++++ server/__tests__/tmux.test.js | 47 +++++++++++- server/lib/pty-attach.js | 29 ++++++- server/lib/tmux.js | 114 ++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 7 deletions(-) diff --git a/server/__tests__/pty-attach.test.js b/server/__tests__/pty-attach.test.js index ed013bae..731b716e 100644 --- a/server/__tests__/pty-attach.test.js +++ b/server/__tests__/pty-attach.test.js @@ -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) => { diff --git a/server/__tests__/tmux.test.js b/server/__tests__/tmux.test.js index 593492f4..934ef0ba 100644 --- a/server/__tests__/tmux.test.js +++ b/server/__tests__/tmux.test.js @@ -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)", () => { diff --git a/server/lib/pty-attach.js b/server/lib/pty-attach.js index 1b307591..3c30ebf5 100644 --- a/server/lib/pty-attach.js +++ b/server/lib/pty-attach.js @@ -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ĩ */ @@ -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[ { 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", () => { diff --git a/server/lib/tmux.js b/server/lib/tmux.js index ce976636..c5584994 100644 --- a/server/lib/tmux.js +++ b/server/lib/tmux.js @@ -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,