diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 9e81f1a0..f4950fa8 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -86,7 +86,7 @@ import { sameFilesHere, } from "../../lib/promptAttach"; import { attachImeInput } from "../../lib/imeInput"; -import { wheelReports, type WheelState } from "../../lib/wheelReports"; +import { SCROLL_SPEED, wheelReports, type WheelState } from "../../lib/wheelReports"; import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste"; import { usePermissions } from "../AuthGate"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; @@ -455,6 +455,8 @@ export const TerminalView = memo(function TerminalView({ fontWeightBold: 700, theme: paletteRef.current, screenReaderMode: readFlag(SCREEN_READER_KEY), + // xterm's own scrollback scrolls at the same share as the reports below. + scrollSensitivity: SCROLL_SPEED, }); termRef.current = term; const fit = new FitAddon(); @@ -538,8 +540,14 @@ export const TerminalView = memo(function TerminalView({ 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 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))); }; diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 4518e4f4..de9ad21c 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -329,7 +329,8 @@ describe("TerminalView", () => { /** 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 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 = () => @@ -350,9 +351,11 @@ describe("TerminalView", () => { // 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. + // A touchpad: 6 steps of 25px = 15 rows, ×0.7 scroll speed = 10.5 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 })); + for (let i = 0; i < 6; i++) + wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 })); expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2)); }); @@ -523,9 +526,7 @@ describe("TerminalView", () => { // wrong. The WebGL renderer draws each cell at a fixed position instead // (xterm 6 removed the canvas one). render(); - expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe( - true - ); + expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(true); }); it("falls back to the DOM renderer when the browser takes the WebGL context back", () => { diff --git a/client/src/lib/__tests__/wheelReports.test.ts b/client/src/lib/__tests__/wheelReports.test.ts index 82d1d56b..4a9b277b 100644 --- a/client/src/lib/__tests__/wheelReports.test.ts +++ b/client/src/lib/__tests__/wheelReports.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect } from "vitest"; -import { LINES_PER_REPORT, wheelReports, type WheelState } from "../wheelReports"; +import { LINES_PER_REPORT, SCROLL_SPEED, wheelReports, type WheelState } from "../wheelReports"; const ROW = 20; const px = (deltaY: number) => ({ deltaY, deltaMode: 0 }); @@ -15,16 +15,17 @@ 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); + // 110 steps of 10px = 1100px = 55 rows, scrolled at SCROLL_SPEED. + for (let i = 0; i < 110; i++) sent += wheelReports(s, px(10), ROW, 40); + expect(sent).toBe(Math.trunc((55 * SCROLL_SPEED) / 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 + // 8 rows up = 5.6 scrolled: one report, the 2 rows down forgotten. + for (let i = 0; i < 8; i++) sent += wheelReports(s, px(-20), ROW, 40); expect(sent).toBe(-1); }); @@ -32,7 +33,16 @@ describe("wheelReports", () => { 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); + expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(6); // 8 × 0.7 + }); + + it("slows a run of wheel notches to SCROLL_SPEED of a report each", () => { + const s: WheelState = { partial: 0 }; + let sent = 0; + for (let i = 0; i < 10; i++) sent += wheelReports(s, px(100), ROW, 40); + expect(sent).toBe(10 * SCROLL_SPEED); + // Turning around starts from zero: the first notch up moves at once. + expect(wheelReports(s, px(-100), ROW, 40)).toBe(-1); }); it("sends nothing for a horizontal-only event or an unmeasured terminal", () => { diff --git a/client/src/lib/wheelReports.ts b/client/src/lib/wheelReports.ts index 27d50621..e606b8d6 100644 --- a/client/src/lib/wheelReports.ts +++ b/client/src/lib/wheelReports.ts @@ -8,13 +8,19 @@ * 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. + * finger, and a wheel notch always sends at least one. `SCROLL_SPEED` then + * slows both down (the user asked for 30 % less): a touchpad's travel is + * scaled, and a notch's reports are carried as a fraction, rounded, so the + * first notch still moves and the average comes out at that share. * @author Nguyễn Ngọc Trí Vĩ */ /** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */ export const LINES_PER_REPORT = 5; +/** Share of the travel a pane scrolls (0.7 = 30 % slower than 1:1). */ +export const SCROLL_SPEED = 0.7; + /** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */ const NOTCH_PX = 50; @@ -25,6 +31,10 @@ const DOM_DELTA_PAGE = 2; export interface WheelState { /** Touchpad travel not yet sent, in rows (signed). */ partial: number; + /** Wheel-notch reports owed but not sent yet (signed, a fraction). */ + notch?: number; + /** Direction of the last notch (the carry above may round to either sign). */ + notchDir?: number; } /** @@ -42,18 +52,25 @@ export function wheelReports( 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; + state.partial += (ev.deltaY / rowPx) * SCROLL_SPEED; 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; + if (state.notchDir !== sign) state.notch = 0; // turned around + state.notchDir = sign; 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)); + const owed = + (state.notch ?? 0) + + sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT)) * SCROLL_SPEED; + const n = sign * Math.round(Math.abs(owed)); + state.notch = owed - n; + return n; }