diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 658fc7e..78e9dfb 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -31,14 +31,20 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { if (containerRef.current) term.open(containerRef.current); fit.fit(); - // When the pane's program has NOT enabled mouse tracking, xterm falls back + // When the pane's program has NOT enabled wheel reporting, xterm falls back // to converting each wheel notch on an alt-screen buffer into a cursor-key // press (ESC[A / ESC[B). In a `claude` pane that reads as arrow up/down — - // the wheel silently walks the prompt history instead of scrolling. This - // handler kills only that emulation branch: real mouse reports are sent by - // a separate listener xterm registers when the program does ask for wheel - // events, so wheel scrolling still works wherever tracking is on. - term.attachCustomWheelEventHandler(() => false); + // the wheel silently walks the prompt history instead of scrolling. Kill + // ONLY that branch: xterm runs this same handler before sending a real SGR + // mouse report too (`bindMouse`'s wheel case consults it), so a blanket + // `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(() => { + const mode = term.modes.mouseTrackingMode; + const tracked = mode !== "none" && mode !== "x10"; + return tracked || term.buffer.active.type !== "alternate"; + }); // xterm sends Shift+Tab as ESC[Z but — unlike plain Tab — never marks the // event cancelled, so the browser still runs its default action and moves diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 5496ac3..d2fc7e5 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -16,9 +16,20 @@ const openMock = vi.fn(); const disposeMock = vi.fn(); const wheelHandlerMock = vi.fn(); const keyHandlerMock = vi.fn(); +// Mutable stand-ins for the two live terminal facts the wheel handler reads. +const termState = { + modes: { mouseTrackingMode: "none" as "none" | "x10" | "vt200" | "drag" | "any" }, + buffer: { active: { type: "alternate" as "normal" | "alternate" } }, +}; vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn().mockImplementation(() => ({ + get modes() { + return termState.modes; + }, + get buffer() { + return termState.buffer; + }, open: openMock, write: writeMock, onData: (fn: (d: string) => void) => { @@ -58,6 +69,8 @@ global.WebSocket = MockWebSocket; describe("TerminalView", () => { beforeEach(() => { + termState.modes.mouseTrackingMode = "none"; + termState.buffer.active.type = "alternate"; MockWebSocket.instances = []; onDataHandlers.length = 0; writeMock.mockClear(); @@ -99,6 +112,8 @@ describe("TerminalView", () => { }); it("swallows wheel events xterm would otherwise turn into arrow keys", () => { + termState.modes.mouseTrackingMode = "none"; + termState.buffer.active.type = "alternate"; render(); expect(wheelHandlerMock).toHaveBeenCalledTimes(1); // false = xterm skips its alt-screen wheel→cursor-key emulation, which in a @@ -106,6 +121,29 @@ describe("TerminalView", () => { expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false); }); + it("lets the wheel through once the pane's program tracks the mouse", () => { + // tmux with `mouse on` sets this; the wheel must reach it as a real SGR + // report, so returning false here would kill scrolling entirely. + termState.modes.mouseTrackingMode = "any"; + termState.buffer.active.type = "alternate"; + render(); + expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true); + }); + + it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => { + termState.modes.mouseTrackingMode = "none"; + termState.buffer.active.type = "normal"; + render(); + expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true); + }); + + it("treats x10 tracking as no wheel tracking (x10 never reports the wheel)", () => { + termState.modes.mouseTrackingMode = "x10"; + termState.buffer.active.type = "alternate"; + render(); + expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false); + }); + it("keeps Shift+Tab in the terminal instead of letting focus escape", () => { render(); const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;