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
+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,