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;