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:
@@ -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;
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user