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/);
|
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", () => {
|
it("turns tmux mouse mode on for the session it attaches to", () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
tmux.__setExecImpl((args) => {
|
tmux.__setExecImpl((args) => {
|
||||||
|
|||||||
@@ -183,9 +183,18 @@ describe("tmux wrapper", () => {
|
|||||||
calls = fake(true);
|
calls = fake(true);
|
||||||
tmux.enableMouse("agenthub-lane-1");
|
tmux.enableMouse("agenthub-lane-1");
|
||||||
assert.ok(calls.some((c) => c[0] === "set-option" && c[2] === "=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");
|
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], [
|
assert.deepEqual(sets[0], [
|
||||||
"set-option",
|
"set-option",
|
||||||
"-t",
|
"-t",
|
||||||
@@ -235,7 +244,39 @@ describe("tmux wrapper", () => {
|
|||||||
...["set-option", "-t", "=agenthub-lane-1:", "pane-scrollbars-style"],
|
...["set-option", "-t", "=agenthub-lane-1:", "pane-scrollbars-style"],
|
||||||
"fg=brightwhite,bg=black,width=1,pad=0",
|
"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)", () => {
|
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
|
* 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
|
* 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.
|
* 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>
|
* @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.
|
// the paste in that issue's measurements.
|
||||||
const PASTE_HOLD_MS = 700;
|
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 groups = new Map();
|
||||||
|
|
||||||
const tell = (v, msg) => {
|
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 */
|
/* 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) {
|
function write(text) {
|
||||||
if (me.exited) return; // the attach is gone: nothing reads this pipe
|
if (me.exited) return; // the attach is gone: nothing reads this pipe
|
||||||
const wait = holdUntil - Date.now();
|
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 binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary);
|
||||||
const text = data.toString("utf8");
|
const text = data.toString("utf8");
|
||||||
if (binary) {
|
if (binary) {
|
||||||
write(text);
|
input(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The browser's WebSocket API sends a JS string as a text frame, and
|
// 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 {
|
try {
|
||||||
msg = JSON.parse(text);
|
msg = JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
write(text);
|
input(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
write(text);
|
input(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
|
|||||||
@@ -608,6 +608,119 @@ function enableMouse(name) {
|
|||||||
} catch {
|
} catch {
|
||||||
/* tmux < 3.7 */
|
/* 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,
|
hasSession,
|
||||||
newSession,
|
newSession,
|
||||||
enableMouse,
|
enableMouse,
|
||||||
|
leaveCopyMode,
|
||||||
paneCommand,
|
paneCommand,
|
||||||
sendCommand,
|
sendCommand,
|
||||||
sendText,
|
sendText,
|
||||||
|
|||||||
Reference in New Issue
Block a user