fix(run): restore wheel scrolling in the terminal, suppress only arrow emulation

The previous `attachCustomWheelEventHandler(() => false)` killed scrolling
outright. xterm's `bindMouse` consults that handler in BOTH wheel paths — the
one that emits a real SGR mouse report and the fallback that turns a notch on
an alt-screen buffer into ESC[A/ESC[B — so a blanket false also blocked tmux's
own wheel scrolling (tmux runs with `mouse on`, i.e. tracking is active).

Now the handler returns false only for the exact bad case: no wheel tracking
AND an alternate buffer. `x10` counts as untracked because that protocol
reports button presses only, never the wheel, so xterm takes the emulation
path for it too.
This commit is contained in:
2026-08-20 08:30:43 +07:00
parent 62ce1c5267
commit 77ca6e0188
2 changed files with 50 additions and 6 deletions
+12 -6
View File
@@ -31,14 +31,20 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
if (containerRef.current) term.open(containerRef.current); if (containerRef.current) term.open(containerRef.current);
fit.fit(); 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 // 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 — // 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 // the wheel silently walks the prompt history instead of scrolling. Kill
// handler kills only that emulation branch: real mouse reports are sent by // ONLY that branch: xterm runs this same handler before sending a real SGR
// a separate listener xterm registers when the program does ask for wheel // mouse report too (`bindMouse`'s wheel case consults it), so a blanket
// events, so wheel scrolling still works wherever tracking is on. // `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
term.attachCustomWheelEventHandler(() => false); // 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 // 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 // event cancelled, so the browser still runs its default action and moves
@@ -16,9 +16,20 @@ const openMock = vi.fn();
const disposeMock = vi.fn(); const disposeMock = vi.fn();
const wheelHandlerMock = vi.fn(); const wheelHandlerMock = vi.fn();
const keyHandlerMock = 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", () => ({ vi.mock("@xterm/xterm", () => ({
Terminal: vi.fn().mockImplementation(() => ({ Terminal: vi.fn().mockImplementation(() => ({
get modes() {
return termState.modes;
},
get buffer() {
return termState.buffer;
},
open: openMock, open: openMock,
write: writeMock, write: writeMock,
onData: (fn: (d: string) => void) => { onData: (fn: (d: string) => void) => {
@@ -58,6 +69,8 @@ global.WebSocket = MockWebSocket;
describe("TerminalView", () => { describe("TerminalView", () => {
beforeEach(() => { beforeEach(() => {
termState.modes.mouseTrackingMode = "none";
termState.buffer.active.type = "alternate";
MockWebSocket.instances = []; MockWebSocket.instances = [];
onDataHandlers.length = 0; onDataHandlers.length = 0;
writeMock.mockClear(); writeMock.mockClear();
@@ -99,6 +112,8 @@ describe("TerminalView", () => {
}); });
it("swallows wheel events xterm would otherwise turn into arrow keys", () => { it("swallows wheel events xterm would otherwise turn into arrow keys", () => {
termState.modes.mouseTrackingMode = "none";
termState.buffer.active.type = "alternate";
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />); render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(wheelHandlerMock).toHaveBeenCalledTimes(1); expect(wheelHandlerMock).toHaveBeenCalledTimes(1);
// false = xterm skips its alt-screen wheel→cursor-key emulation, which in a // 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); 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(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
});
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => { it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />); render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean; const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;