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", () => {
@@ -0,0 +1,42 @@
/**
* @file wheelReports.test.ts
* @description A touchpad scrolls 1:1 with the finger (one report per
* LINES_PER_REPORT rows, remainder carried), a wheel notch always sends one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { LINES_PER_REPORT, wheelReports, type WheelState } from "../wheelReports";
const ROW = 20;
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
describe("wheelReports", () => {
it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => {
const s: WheelState = { partial: 0 };
let sent = 0;
// 30 steps of 10px = 300px = 15 rows.
for (let i = 0; i < 30; i++) sent += wheelReports(s, px(10), ROW, 40);
expect(sent).toBe(15 / LINES_PER_REPORT);
});
it("drops the carried remainder when the swipe turns around", () => {
const s: WheelState = { partial: 0 };
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
let sent = 0;
for (let i = 0; i < 5; i++) sent += wheelReports(s, px(-20), ROW, 40); // 5 rows up
expect(sent).toBe(-1);
});
it("sends at least one report for a wheel notch, in either delta mode", () => {
expect(wheelReports({ partial: 0 }, px(100), ROW, 40)).toBe(1);
expect(wheelReports({ partial: 0 }, px(-100), ROW, 40)).toBe(-1);
expect(wheelReports({ partial: 0 }, { deltaY: 3, deltaMode: 1 }, ROW, 40)).toBe(1);
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(8);
});
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
expect(wheelReports({ partial: 0 }, px(0), ROW, 40)).toBe(0);
expect(wheelReports({ partial: 0 }, px(10), 0, 40)).toBe(0);
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* @file wheelReports.ts — turns a browser wheel event into the number of SGR
* wheel reports a terminal pane gets, in proportion to how far the finger or
* the wheel moved, the way a native terminal does.
*
* xterm.js sends ONE report per wheel event whatever its size, and damps a
* touchpad's pixel deltas ×0.3 before it counts even that one — a touchpad
* swipe crawled. In our panes every report moves a fixed stretch (Claude's
* `CLAUDE_CODE_SCROLL_SPEED`, tmux's copy-mode `WHEEL_LINES`), so a report is
* sent per `LINES_PER_REPORT` rows of travel: a touchpad scrolls 1:1 with the
* finger, and a wheel notch always sends at least one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */
export const LINES_PER_REPORT = 5;
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
const NOTCH_PX = 50;
const DOM_DELTA_PIXEL = 0;
const DOM_DELTA_LINE = 1;
const DOM_DELTA_PAGE = 2;
export interface WheelState {
/** Touchpad travel not yet sent, in rows (signed). */
partial: number;
}
/**
* Signed report count for `ev` — negative scrolls up. `rowPx` is one row's
* height on screen (CSS scale included), `rows` the terminal's height.
*/
export function wheelReports(
state: WheelState,
ev: Pick<WheelEvent, "deltaY" | "deltaMode">,
rowPx: number,
rows: number
): number {
if (!ev.deltaY || !(rowPx > 0)) return 0;
const sign = ev.deltaY < 0 ? -1 : 1;
if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) {
// Touchpad: carry the remainder, so slow swipes still add up.
if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around
state.partial += ev.deltaY / rowPx;
const n = Math.trunc(state.partial / LINES_PER_REPORT);
state.partial -= n * LINES_PER_REPORT;
return n;
}
// A notch (or a fast flick): at least one report, more for a long one.
state.partial = 0;
const lines =
ev.deltaMode === DOM_DELTA_LINE
? ev.deltaY
: ev.deltaMode === DOM_DELTA_PAGE
? ev.deltaY * rows
: ev.deltaY / rowPx;
return sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT));
}