fix(terminal): psmux wheel scrolling, per-platform tmux upgrade advice, Windows-safe server tests

- Server tests on Windows no longer reach the user's real psmux: psmux
  ignores TMUX_TMPDIR (and 3.3.x PSMUX_DATA_DIR), so quit.test.js's Quit and
  the runner's kill-server ended every live lane pane — the pre-commit hook
  included. scripts/test-server.js now takes tmux off the suites' PATH there.
- Wheel scrolling on Windows: psmux never turns mouse tracking on for its
  client, so xterm.js never reported the wheel and a pane could not scroll.
  A Windows attach is told {type:"wheel"} and the browser sends the SGR wheel
  report itself; psmux scrolls or forwards it to the pane's app.
- tmux.upgradeAdvice(): the oldest tmux AgentHub asks for and how each
  platform upgrades (psmux 3.4.0 -> winget upgrade marlocarlo.psmux; tmux 3.7
  -> brew upgrade tmux / build the release on Linux). Used by the doctor,
  GET /api/run/tmux ({available, version, upgrade}), `agenthub doctor`, and a
  sticky toast on the Workspace page. Advice only: nothing installs tmux.

Committed with --no-verify (user's call): the pre-commit hook runs the full
backend suite, which has ~378 pre-existing failures on Windows (S3/Linux-only
tests: sudo, /proc, systemd, members). Every test touching this change passes:
pty-attach, tmux, run, plugin-doctor-tmux, TerminalView, Workspace, and the
client tests related to the changed files; tsc and the header audit are clean.
This commit is contained in:
Nguyễn Ngọc Trí Vĩ
2026-10-15 11:46:09 +07:00
parent 7a88b74016
commit cd427d218b
19 changed files with 218 additions and 45 deletions
+14 -1
View File
@@ -355,9 +355,20 @@ export const TerminalView = memo(function TerminalView({
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
// because that protocol reports button presses only, never the wheel — so
// xterm takes the emulation path for it as well.
term.attachCustomWheelEventHandler(() => {
// psmux (Windows) never turns tracking on, so xterm never reports the
// wheel there; on the server's `{type:"wheel"}` this pane sends the SGR
// wheel report itself — psmux scrolls, or hands it to the pane's app.
// ponytail: at 1;1, which is all psmux reports to the app anyway.
let ownWheel = false;
term.attachCustomWheelEventHandler((ev) => {
const mode = term.modes.mouseTrackingMode;
const tracked = mode !== "none" && mode !== "x10";
if (!tracked && ownWheel) {
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
}
return false;
}
return tracked || term.buffer.active.type !== "alternate";
});
@@ -530,6 +541,8 @@ export const TerminalView = memo(function TerminalView({
requestAnimationFrame(scaleToFit);
} else if (msg.type === "lead") {
takeLead();
} else if (msg.type === "wheel") {
ownWheel = true;
} else if (msg.type === "exit") {
ended = true;
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
@@ -231,6 +231,24 @@ describe("TerminalView", () => {
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
});
it("sends the wheel itself once the server says its tmux never tracks the mouse", () => {
// psmux (Windows) never turns mouse tracking on for its client, so xterm
// never reports the wheel and the pane could not be scrolled at all.
termState.modes.mouseTrackingMode = "none";
termState.buffer.active.type = "alternate";
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
ws.onmessage?.({ data: '{"type":"wheel"}' });
const wheel = wheelHandlerMock.mock.calls[wheelHandlerMock.mock.calls.length - 1]![0]!;
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
// Tracked again (a tmux that does report): xterm sends it, never twice.
termState.modes.mouseTrackingMode = "any";
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
expect(ws.sent).toHaveLength(2);
});
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
+2 -1
View File
@@ -617,5 +617,6 @@
"fx.readOnlyHome": "Another user's home — view and download only",
"fx.readOnly": "Read-only",
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left."
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
"tmuxUpgrade": "Upgrade tmux: {{advice}}"
}
+2 -1
View File
@@ -617,5 +617,6 @@
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
"fx.readOnly": "Chỉ đọc",
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái."
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}"
}
+6 -2
View File
@@ -2936,8 +2936,12 @@ export const api = {
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
binary: (tool: LaneTool = "claude") =>
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH. */
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH, its
* version, and this platform's upgrade advice when it is too old. */
tmuxAvailable: () =>
request<{ available: boolean; version?: string | null; upgrade?: string | null }>(
"/run/tmux"
),
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
+19
View File
@@ -665,6 +665,25 @@ export function Workspace() {
const { local } = usePermissions();
const [searchParams, setSearchParams] = useSearchParams();
// The server's tmux is older than this platform needs (psmux < 3.4.0 on
// Windows, tmux < 3.7 elsewhere): say how to upgrade it, until it is.
// Sticky (an error toast) so the command can be copied. An older server or
// no answer says nothing.
useEffect(() => {
let live = true;
(async () => {
try {
const { upgrade } = await api.run.tmuxAvailable();
if (live && upgrade) toast.error(tLanes("tmuxUpgrade", { advice: upgrade }));
} catch {
/* nothing to say */
}
})();
return () => {
live = false;
};
}, [tLanes]);
// Lane state: the list is the shared store the sidebar reads too; the
// selection is the URL's `?lane=` (the sidebar sets it, so does a
// notification click), falling back to the last one shown, then the first.
@@ -459,6 +459,23 @@ describe("Workspace page — lane integration", () => {
});
describe("Workspace layout", () => {
it("asks the user to upgrade a tmux older than their platform needs", async () => {
const { toast } = await import("../../lib/toast");
const shown = vi.spyOn(toast, "error");
const run = api.run as unknown as Record<string, unknown>;
run.tmuxAvailable = vi.fn().mockResolvedValue({
available: true,
version: "3.3.2",
upgrade: "psmux 3.3.2 is older than 3.4.0 — winget upgrade marlocarlo.psmux",
});
onTestFinished(() => {
delete run.tmuxAvailable;
shown.mockRestore();
});
await renderWorkspace();
expect(shown).toHaveBeenCalledWith(expect.stringContaining("winget upgrade marlocarlo.psmux"));
});
it("shows no empty banner when there is no workspace (the sidebar's + adds one)", async () => {
lanesToReturn = [];
await renderWorkspace();