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:
@@ -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) => {
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user