fix(terminal): touchpad scrolls 1:1 with the finger

xterm.js sends one wheel report per event whatever its size and damps a
touchpad's pixel deltas x0.3, so a swipe crawled. With mouse tracking on,
TerminalView now sends the SGR reports itself (lib/wheelReports.ts): one
per 5 rows of travel (what one report scrolls in a Claude pane), the
remainder carried between events, at least one per wheel notch, at the
pointer's cell with xterm's modifier bits.
This commit is contained in:
2026-11-02 16:06:43 +07:00
parent e0b8d5641e
commit 2d6c751fb2
5 changed files with 155 additions and 8 deletions
@@ -86,6 +86,7 @@ import {
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { wheelReports, type WheelState } from "../../lib/wheelReports";
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
@@ -524,9 +525,32 @@ export const TerminalView = memo(function TerminalView({
// 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;
// Tracked: the reports are this pane's own too — one per
// LINES_PER_REPORT rows of travel (lib/wheelReports.ts), where xterm
// sends one per event and damps a touchpad ×0.3. SGR, as tmux always
// asks for it; at the pointer's cell, with xterm's modifier bits.
const wheelState: WheelState = { partial: 0 };
const sendWheel = (ev: WheelEvent) => {
const screen = term.element?.querySelector(".xterm-screen");
const box = screen?.getBoundingClientRect();
if (!box || !box.height || !box.width) return;
const n = wheelReports(wheelState, ev, box.height / term.rows, term.rows);
const ws = wsRef.current;
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
const col = clamp(Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1, term.cols);
const row = clamp(Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1, term.rows);
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
};
term.attachCustomWheelEventHandler((ev) => {
const mode = term.modes.mouseTrackingMode;
const tracked = mode !== "none" && mode !== "x10";
// Shift+wheel stays xterm's (it reports nothing for it).
if (tracked && !ev.shiftKey) {
sendWheel(ev);
return false;
}
if (!tracked && ownWheel) {
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
@@ -21,6 +21,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { Terminal } from "@xterm/xterm";
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
@@ -326,13 +327,33 @@ 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.
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
const screenBox = () => {
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!.value as { element: HTMLElement };
const screen = document.createElement("div");
screen.className = "xterm-screen";
screen.getBoundingClientRect = () =>
({ left: 0, top: 0, width: termState.cols * 10, height: termState.rows * 10 }) as DOMRect;
term.element.appendChild(screen);
};
it("sends a tracked wheel itself, one report per LINES_PER_REPORT rows, at the pointer", () => {
// tmux with `mouse on` sets this. xterm would send ONE report per event and
// damp a touchpad ×0.3 — a swipe crawled — so the pane sends its own.
termState.modes.mouseTrackingMode = "any";
termState.buffer.active.type = "alternate";
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true);
screenBox();
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
ws.sent.length = 0;
const wheel = wheelHandlerMock.mock.calls[0]![0]!;
// A notch of 6 rows: one report, at the cell under the pointer.
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
// A touchpad: 4 steps of 25px = 10 rows = two reports, sent as they add up.
ws.sent.length = 0;
for (let i = 0; i < 4; i++) wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
});
it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => {
@@ -361,10 +382,11 @@ describe("TerminalView", () => {
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.
// Tracked again (a tmux that does report): sent once, by the tracked path.
termState.modes.mouseTrackingMode = "any";
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
expect(ws.sent).toHaveLength(2);
screenBox();
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
expect(ws.sent).toHaveLength(3);
});
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {