diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dfcf52f63..d776b1056 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added the `fullscreenWheelScrollLines` setting and `/settings` entry for fullscreen mouse-wheel scrolling. The default `"auto"` accelerates fast wheel spins outside local macOS terminals ([#9758](https://github.com/earendil-works/pi/issues/9758)). - Added per-input disposition to successful RPC `prompt`, `steer`, and `follow_up` responses, `AgentSession.steer()`/`followUp()`, and `RpcClient.prompt()`/`steer()`/`followUp()`; `RpcClient.prompt()` also accepts `streamingBehavior` ([#9098](https://github.com/earendil-works/pi/issues/9098), [#9803](https://github.com/earendil-works/pi/issues/9803)). - Added image generation to `ModelRuntime`: `generateImages()` with runtime-resolved auth (stored credentials, OAuth, runtime API keys, `models.json` headers), plus `getModelsOfType()`, `getModelOfType()`, `getAvailableOfType()`, `getAllModels()`, and `getAllAvailable()`. OpenRouter image models are listed under the `openrouter` provider and share its credential; an upstream ID can have separate chat and image entries. `models.json` providers and extension registrations without a model list keep built-in image generation. Extension model lists can include discriminated chat, image, and classifier entries with operation implementations; when supplied, they replace the provider catalog across every operation. Chat-facing reads (`getModels()`, `getAvailableSnapshot()`, the model picker) are unchanged. - Added classifier support to `ModelRuntime`, including `classify()`, classifier model accessors, runtime-resolved authentication, and the built-in TypeSafe `jev-latest` model. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 481885f9b..d861ba306 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -81,6 +81,7 @@ See [Compaction Reference](compaction.md) for trigger, summarization, and valida | `fullscreenExitOutput` | `"transcript" \| "resume-hint"` | `"transcript"` | Output printed when fullscreen mode exits. | | `fullscreenScrollbar` | `"auto" \| "always" \| "hidden"` | `"auto"` | Fullscreen transcript scrollbar behavior. | | `fullscreenCopyOnSelect` | boolean | `true` | Copy selected text automatically in fullscreen mode. | +| `fullscreenWheelScrollLines` | `"auto"` \| number | `"auto"` | Lines per mouse-wheel event in fullscreen mode, from 1 to 100. `"auto"` moves one line per event in local macOS terminals, which already accelerate wheel and trackpad input; elsewhere, and over SSH, it speeds up fast wheel spins to at most 6 lines per event. Alt+wheel moves five times as far. | | `editorPaddingX` | number | `0` | Horizontal editor padding from 0 to 3 cells. | | `outputPad` | `0 \| 1` | `1` | Horizontal transcript padding. | | `autocompleteMaxVisible` | number | `5` | Visible autocomplete entries, from 3 to 20. | diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index cbb6445f8..ad42061ff 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,6 +1,11 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { DEFAULT_MAX_AGENT_RETRY_DELAY_MS, type Model, type Transport } from "@earendil-works/pi-ai"; -import type { TuiMode as RendererTuiMode, ScrollViewScrollbar, TerminalCapabilities } from "@earendil-works/pi-tui"; +import type { + TuiMode as RendererTuiMode, + ScrollViewScrollbar, + TerminalCapabilities, + WheelScrollLines, +} from "@earendil-works/pi-tui"; import { randomUUID } from "crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; @@ -177,6 +182,7 @@ export interface Settings { fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode fullscreenCopyOnSelect?: boolean; // default: true; no effect in regular TUI mode + fullscreenWheelScrollLines?: WheelScrollLines; // default: "auto"; lines per wheel event, 1-100 } function isMergeableObject(value: unknown): value is Record { @@ -1325,6 +1331,20 @@ export class SettingsManager { this.save(); } + getFullscreenWheelScrollLines(): WheelScrollLines { + const lines = this.settings.fullscreenWheelScrollLines; + return typeof lines === "number" && Number.isFinite(lines) + ? Math.max(1, Math.min(100, Math.floor(lines))) + : "auto"; + } + + setFullscreenWheelScrollLines(lines: WheelScrollLines): void { + this.globalSettings.fullscreenWheelScrollLines = + lines === "auto" ? lines : Math.max(1, Math.min(100, Math.floor(lines))); + this.markModified("fullscreenWheelScrollLines"); + this.save(); + } + getImageAutoResize(): boolean { return this.settings.images?.autoResize ?? true; } diff --git a/packages/coding-agent/src/experimental/client-tui.ts b/packages/coding-agent/src/experimental/client-tui.ts index e8cf9fed0..6bc810d8d 100644 --- a/packages/coding-agent/src/experimental/client-tui.ts +++ b/packages/coding-agent/src/experimental/client-tui.ts @@ -744,6 +744,7 @@ export async function runClientTui(command: ClientCommand, options: RunClientTui tuiMode: "fullscreen", showHardwareCursor: settingsManager.getShowHardwareCursor(), logDirectory: agentDir, + fullscreenWheelScrollLines: settingsManager.getFullscreenWheelScrollLines(), }); tui.setClearOnShrink(settingsManager.getClearOnShrink()); let component: ExperimentalClientTui | undefined; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 618d24e80..dc735f94b 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -10,6 +10,7 @@ import { SettingsList, Spacer, Text, + type WheelScrollLines, } from "@earendil-works/pi-tui"; import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; import { @@ -94,6 +95,7 @@ export interface SettingsConfig { fullscreenExitOutput: FullscreenExitOutput; fullscreenScrollbar: ScrollViewScrollbar; fullscreenCopyOnSelect: boolean; + fullscreenWheelScrollLines: WheelScrollLines; warnings: WarningSettings; } @@ -132,6 +134,7 @@ export interface SettingsCallbacks { onFullscreenExitOutputChange: (output: FullscreenExitOutput) => void; onFullscreenScrollbarChange: (mode: ScrollViewScrollbar) => void; onFullscreenCopyOnSelectChange: (enabled: boolean) => void; + onFullscreenWheelScrollLinesChange: (lines: WheelScrollLines) => void; onWarningsChange: (warnings: WarningSettings) => void; onCancel: () => void; } @@ -726,6 +729,20 @@ export class SettingsSelectorComponent extends Container { currentValue: config.fullscreenCopyOnSelect ? "true" : "false", values: ["true", "false"], }, + { + id: "fullscreen-wheel-scroll-lines", + label: "Fullscreen wheel scrolling", + description: + "Lines per mouse-wheel event in fullscreen mode; 'auto' speeds up fast wheel spins where the terminal does not", + currentValue: String(config.fullscreenWheelScrollLines), + values: [ + "auto", + ...[...new Set([1, 2, 3, 5, 10, config.fullscreenWheelScrollLines])] + .filter((lines) => lines !== "auto") + .sort((a, b) => a - b) + .map(String), + ], + }, { id: "theme", label: "Theme", @@ -953,6 +970,9 @@ export class SettingsSelectorComponent extends Container { case "fullscreen-copy-on-select": callbacks.onFullscreenCopyOnSelectChange(newValue === "true"); break; + case "fullscreen-wheel-scroll-lines": + callbacks.onFullscreenWheelScrollLinesChange(newValue === "auto" ? "auto" : parseInt(newValue, 10)); + break; case "theme": callbacks.onThemeChange(newValue); break; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3056b910d..25c5be24a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -590,6 +590,7 @@ export class InteractiveMode { terminal: options.terminal, onRightClickPaste: this.onRightClickPaste, fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(), + fullscreenWheelScrollLines: this.settingsManager.getFullscreenWheelScrollLines(), }); this.ui = createInteractiveTuiReference(() => this.renderer); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); @@ -876,6 +877,7 @@ export class InteractiveMode { terminal, onRightClickPaste: this.onRightClickPaste, fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(), + fullscreenWheelScrollLines: this.settingsManager.getFullscreenWheelScrollLines(), }); nextUi.setClearOnShrink(clearOnShrink); nextUi.onDebug = onDebug; @@ -1995,6 +1997,7 @@ export class InteractiveMode { this.applyFullscreenScrollbarSetting(); if (this.renderer instanceof TuiAltScreen) { this.renderer.setCopyOnSelect(this.settingsManager.getFullscreenCopyOnSelect()); + this.renderer.setWheelScrollLines(this.settingsManager.getFullscreenWheelScrollLines()); } this.footer.setSession(this.session); this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); @@ -4818,6 +4821,7 @@ export class InteractiveMode { fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(), fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(), fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(), + fullscreenWheelScrollLines: this.settingsManager.getFullscreenWheelScrollLines(), warnings: this.settingsManager.getWarnings(), }, { @@ -4998,6 +5002,10 @@ export class InteractiveMode { this.settingsManager.setFullscreenCopyOnSelect(enabled); if (this.renderer instanceof TuiAltScreen) this.renderer.setCopyOnSelect(enabled); }, + onFullscreenWheelScrollLinesChange: (lines) => { + this.settingsManager.setFullscreenWheelScrollLines(lines); + if (this.renderer instanceof TuiAltScreen) this.renderer.setWheelScrollLines(lines); + }, onWarningsChange: (warnings) => { this.settingsManager.setWarnings(warnings); }, diff --git a/packages/coding-agent/src/modes/interactive/tui-renderer.ts b/packages/coding-agent/src/modes/interactive/tui-renderer.ts index 86c634c2b..6487c1c6f 100644 --- a/packages/coding-agent/src/modes/interactive/tui-renderer.ts +++ b/packages/coding-agent/src/modes/interactive/tui-renderer.ts @@ -1,4 +1,4 @@ -import type { Terminal } from "@earendil-works/pi-tui"; +import type { Terminal, WheelScrollLines } from "@earendil-works/pi-tui"; import { ProcessTerminal, type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui"; import { copyToClipboard } from "../../utils/clipboard.ts"; import { openBrowser } from "../../utils/open-browser.ts"; @@ -12,6 +12,7 @@ export interface InteractiveTuiOptions { readonly terminal?: Terminal; readonly onRightClickPaste?: () => void; readonly fullscreenCopyOnSelect?: boolean; + readonly fullscreenWheelScrollLines?: WheelScrollLines; } /** Composition root shared by coding-agent presentations. */ @@ -34,6 +35,7 @@ export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScr openUrl: openBrowser, onRightClickPaste: options.onRightClickPaste, copyOnSelect: options.fullscreenCopyOnSelect, + wheelScrollLines: options.fullscreenWheelScrollLines ?? "auto", copySelection: async (text) => { try { await copyToClipboard(text); diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index b9b18f8b2..559edfe36 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -1,4 +1,4 @@ -import type { Component, Terminal, TUI } from "@earendil-works/pi-tui"; +import type { Component, Terminal, TUI, WheelScrollLines } from "@earendil-works/pi-tui"; import { Container, getKeybindings, isViewportTUI, ScrollView, setKeybindings, Text } from "@earendil-works/pi-tui"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; @@ -127,7 +127,14 @@ describe("createInteractiveTui", () => { renderer.setFocus(component); type SwitchContext = { - runtimeHost: { session: { settingsManager: { getFullscreenCopyOnSelect: () => boolean } } }; + runtimeHost: { + session: { + settingsManager: { + getFullscreenCopyOnSelect: () => boolean; + getFullscreenWheelScrollLines: () => WheelScrollLines; + }; + }; + }; renderer: ReturnType; ui: TUI; fullscreenLayoutRoot: Component; @@ -136,7 +143,11 @@ describe("createInteractiveTui", () => { extensionTerminalInputSubscriptions: Set; }; const context = Object.assign(Object.create(InteractiveMode.prototype), { - runtimeHost: { session: { settingsManager: { getFullscreenCopyOnSelect: () => true } } }, + runtimeHost: { + session: { + settingsManager: { getFullscreenCopyOnSelect: () => true, getFullscreenWheelScrollLines: () => "auto" }, + }, + }, renderer, ui: undefined as unknown as TUI, fullscreenLayoutRoot: component, diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 8e1bb83ad..457fba396 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -516,6 +516,27 @@ describe("SettingsManager", () => { expect(reloadedManager.getFullscreenCopyOnSelect()).toBe(true); }); + // #9758: wheel scrolling defaults to auto, persists line counts, and ignores invalid values. + it("persists fullscreen wheel scroll lines", async () => { + const manager = SettingsManager.create(projectDir, agentDir); + expect(manager.getFullscreenWheelScrollLines()).toBe("auto"); + + manager.setFullscreenWheelScrollLines(3); + await manager.flush(); + expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")).fullscreenWheelScrollLines).toBe(3); + + for (const [value, expected] of [ + [7.9, 7], + [0, 1], + [1000, 100], + ["fast", "auto"], + [null, "auto"], + ] as const) { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ fullscreenWheelScrollLines: value })); + expect(SettingsManager.create(projectDir, agentDir).getFullscreenWheelScrollLines()).toBe(expected); + } + }); + describe("outputPad", () => { it("should default to 1 and persist binary values", async () => { const manager = SettingsManager.create(projectDir, agentDir); diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index 71619d100..669bb6e5d 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -26,10 +26,12 @@ describe("SettingsSelectorComponent", () => { const onExitOutputChange = vi.fn(); const onScrollbarChange = vi.fn(); const onCopyOnSelectChange = vi.fn(); + const onWheelScrollLinesChange = vi.fn(); const config = { fullscreenExitOutput: "transcript", fullscreenScrollbar: "auto", fullscreenCopyOnSelect: true, + fullscreenWheelScrollLines: 7, warnings: {}, defaultModel: "not set", availableDefaultModels: [], @@ -41,6 +43,7 @@ describe("SettingsSelectorComponent", () => { onFullscreenExitOutputChange: onExitOutputChange, onFullscreenScrollbarChange: onScrollbarChange, onFullscreenCopyOnSelectChange: onCopyOnSelectChange, + onFullscreenWheelScrollLinesChange: onWheelScrollLinesChange, } as unknown as SettingsCallbacks; const cycle = (label: string, count: number) => { @@ -55,6 +58,9 @@ describe("SettingsSelectorComponent", () => { expect(onScrollbarChange.mock.calls.flat()).toEqual(["always", "hidden", "auto"]); cycle("Fullscreen copy on select", 2); expect(onCopyOnSelectChange.mock.calls.flat()).toEqual([false, true]); + // #9758: custom values from settings.json stay in the cycle. + cycle("Fullscreen wheel scrolling", 3); + expect(onWheelScrollLinesChange.mock.calls.flat()).toEqual([10, "auto", 1]); }); it("keeps the configured fixed theme marked while browsing", () => { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 80b0b70b1..86b7eb823 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `"auto"` to `TuiAltScreenOptions.wheelScrollLines`, which accelerates fast wheel spins on terminals that send one event per notch, and `TuiAltScreen.setWheelScrollLines()` for runtime updates ([#9758](https://github.com/earendil-works/pi/issues/9758)). + ### Fixed - Fixed `/skill` autocomplete appearing empty when loaded skill names did not contain the letters in `skill` ([#9944](https://github.com/earendil-works/pi/issues/9944)). diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 06a8ee086..93fb8b727 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -183,3 +183,4 @@ export { visibleWidth, wrapTextWithAnsi, } from "./utils.ts"; +export type { WheelScrollLines } from "./wheel-scroll.ts"; diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index cc9a04f43..bf8affb13 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -57,6 +57,7 @@ import { truncateToWidth, visibleWidth, } from "./utils.ts"; +import { WheelScrollAccelerator, type WheelScrollLines } from "./wheel-scroll.ts"; const ENTER_ALT_SCREEN = "\x1b[?1049h"; const EXIT_ALT_SCREEN = "\x1b[?1049l"; @@ -164,8 +165,11 @@ interface SearchHighlightRange { } export interface TuiAltScreenOptions { - /** Number of logical lines moved for each mouse-wheel event. */ - wheelScrollLines?: number; + /** + * Logical lines moved for each mouse-wheel event (default: 1). `"auto"` accelerates fast wheel + * spins on terminals that send one event per notch. Alt+wheel moves five times as far. + */ + wheelScrollLines?: WheelScrollLines; /** Capture mouse events for viewport scrolling and application-owned text selection. */ mouse?: boolean; /** Style a non-current transcript search match. */ @@ -236,7 +240,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { x: number; y: number; }; - private readonly wheelScrollLines: number; + private readonly wheelScroll: WheelScrollAccelerator; private readonly mouseEnabled: boolean; private readonly searchMatchStyle: (text: string) => string; private readonly searchCurrentMatchStyle: (text: string) => string; @@ -263,7 +267,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { }; this.implicitScrollView = new ScrollView(this.implicitDocument, { follow: "end", primary: true }); this.flashes = new AltScreenFlashContainer(() => this.requestRender()); - this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1)); + this.wheelScroll = new WheelScrollAccelerator(options.wheelScrollLines ?? 1); this.mouseEnabled = options.mouse ?? true; this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`); this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`); @@ -284,6 +288,10 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return this.getPrimaryScrollView().isFollowingEnd; } + setWheelScrollLines(lines: WheelScrollLines): void { + this.wheelScroll.setLines(lines); + } + getCopyOnSelect(): boolean { return this.copyOnSelect; } @@ -682,9 +690,11 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const wheelEvent = this.parseWheelEvent(data); if (wheelEvent) { - const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, { - wheelDelta: wheelEvent.direction * this.getWheelScrollLines(wheelEvent.button), - }); + const lines = this.wheelScroll.next(wheelEvent.direction, performance.now()); + // SGR mouse button codes use bit 3 (value 8) for the Alt modifier. + const wheelDelta = + wheelEvent.direction * ((wheelEvent.button & 8) !== 0 ? lines * ALT_WHEEL_SCROLL_MULTIPLIER : lines); + const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, { wheelDelta }); const overlay = this.dispatchMouseToOverlay(event); const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event)); if (result) { @@ -692,7 +702,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return { consume: true }; } if (this.shouldDeferViewportInputToOverlay()) return undefined; - this.routeWheel(wheelEvent); + this.routeWheel(wheelEvent, wheelDelta); return { consume: true }; } const mouseEvent = this.parseSgrMouseEvent(data); @@ -967,13 +977,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return undefined; } - private getWheelScrollLines(button: number): number { - // SGR mouse button codes use bit 3 (value 8) for the Alt modifier. - return (button & 8) !== 0 ? this.wheelScrollLines * ALT_WHEEL_SCROLL_MULTIPLIER : this.wheelScrollLines; - } - - private routeWheel(event: WheelEvent): void { - let remaining = event.direction * this.getWheelScrollLines(event.button); + private routeWheel(event: WheelEvent, delta: number): void { + let remaining = delta; const seen = new Set(); for (const scrollView of this.currentLayout ? getScrollViewsAt(this.currentLayout, event.x, event.y) : []) { seen.add(scrollView); diff --git a/packages/tui/src/wheel-scroll.ts b/packages/tui/src/wheel-scroll.ts new file mode 100644 index 000000000..a6c0018bd --- /dev/null +++ b/packages/tui/src/wheel-scroll.ts @@ -0,0 +1,82 @@ +/** Lines moved per mouse-wheel event, or `"auto"` to accelerate fast wheel spins. */ +export type WheelScrollLines = number | "auto"; + +// Several events closer than this belong to one physical notch (Ghostty emits them ~4 ms apart) +// or come from a high-resolution source. They move one line each and do not accelerate. +const BURST_GAP_MS = 5; +// A pause longer than this ends a scroll gesture. +const GESTURE_GAP_MS = 200; +// Average event gap that maps to one line per event. Faster events scale up proportionally. +const REFERENCE_GAP_MS = 100; +const MAX_AUTO_LINES = 6; + +/** + * Local macOS terminals receive wheel and trackpad deltas that the OS has already accelerated, + * and they emit one event per line. Other platforms, and SSH sessions where the client platform + * is unknown, usually send one event per wheel notch. + */ +function terminalAcceleratesWheel(): boolean { + const env = process.env; + return ( + process.platform === "darwin" && + env.SSH_CONNECTION === undefined && + env.SSH_CLIENT === undefined && + env.SSH_TTY === undefined + ); +} + +/** + * Converts wheel events into line counts. + * + * In `"auto"` mode on terminals that do not accelerate wheel input, the count follows event + * velocity: an isolated notch moves one line, while a fast spin moves up to six lines per event. + * For example, notches 100 ms apart move 1 line each, 50 ms apart move 2, and 20 ms apart move 5. + */ +export class WheelScrollAccelerator { + private lines: WheelScrollLines; + private readonly accelerate: boolean; + private lastTime = Number.NEGATIVE_INFINITY; + private lastDirection = 0; + private averageGap: number | undefined; + private carry = 0; + + constructor(lines: WheelScrollLines = "auto", accelerate = !terminalAcceleratesWheel()) { + this.lines = lines; + this.accelerate = accelerate; + } + + setLines(lines: WheelScrollLines): void { + this.lines = lines; + this.reset(); + } + + /** Return the positive line count for a wheel event in `direction` at time `now` (milliseconds). */ + next(direction: -1 | 1, now: number): number { + if (this.lines !== "auto") return Number.isFinite(this.lines) ? Math.max(1, Math.floor(this.lines)) : 1; + if (!this.accelerate) return 1; + + const gap = now - this.lastTime; + const sameGesture = direction === this.lastDirection && gap <= GESTURE_GAP_MS; + this.lastTime = now; + this.lastDirection = direction; + if (!sameGesture) { + this.averageGap = undefined; + this.carry = 0; + return 1; + } + if (gap < BURST_GAP_MS) return 1; + + this.averageGap = this.averageGap === undefined ? gap : (this.averageGap + gap) / 2; + const lines = Math.min(MAX_AUTO_LINES, Math.max(1, REFERENCE_GAP_MS / this.averageGap)) + this.carry; + const whole = Math.floor(lines); + this.carry = lines - whole; + return whole; + } + + private reset(): void { + this.lastTime = Number.NEGATIVE_INFINITY; + this.lastDirection = 0; + this.averageGap = undefined; + this.carry = 0; + } +} diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index e79169127..5c927535c 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -504,6 +504,31 @@ describe("TuiAltScreen", () => { tui.stop(); }); + // #9758: wheel line counts can change at runtime; Alt keeps its multiplier. + it("applies runtime wheel line count updates", async () => { + const terminal = new VirtualTerminal(20, 4); + const tui = new TuiAltScreen(terminal, undefined, undefined, { wheelScrollLines: 3 }); + const deltas: Array = []; + tui.addChild( + new MouseRegion(new Text("wheel target", 0, 0), (event) => { + if (event.type !== "wheel") return undefined; + deltas.push(event.wheelDelta); + return { handled: true }; + }), + ); + tui.start(); + try { + await terminal.waitForRender(); + terminal.sendInput("\x1b[<64;1;1M"); + tui.setWheelScrollLines(2); + terminal.sendInput("\x1b[<65;1;1M"); + terminal.sendInput("\x1b[<72;1;1M"); + assert.deepStrictEqual(deltas, [-3, 2, -10]); + } finally { + tui.stop(); + } + }); + it("chains unused wheel delta to an outer scroll view", async () => { const terminal = new VirtualTerminal(20, 4); const tui = new TuiAltScreen(terminal, undefined, undefined, { wheelScrollLines: 3 }); diff --git a/packages/tui/test/wheel-scroll.test.ts b/packages/tui/test/wheel-scroll.test.ts new file mode 100644 index 000000000..281893245 --- /dev/null +++ b/packages/tui/test/wheel-scroll.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { WheelScrollAccelerator } from "../src/wheel-scroll.ts"; + +function scroll(accelerator: WheelScrollAccelerator, times: number[], direction: -1 | 1 = 1): number[] { + return times.map((time) => accelerator.next(direction, time)); +} + +// #9758: fullscreen wheel scrolling was one line per notch on terminals that do not accelerate wheels. +describe("WheelScrollAccelerator", () => { + it("uses fixed line counts regardless of timing", () => { + const accelerator = new WheelScrollAccelerator(3, true); + assert.deepStrictEqual(scroll(accelerator, [0, 10, 20, 1000]), [3, 3, 3, 3]); + accelerator.setLines(0.5); + assert.strictEqual(accelerator.next(1, 2000), 1); + }); + + it("keeps one line per event in auto mode when the terminal already accelerates", () => { + const accelerator = new WheelScrollAccelerator("auto", false); + assert.deepStrictEqual(scroll(accelerator, [0, 10, 20, 30]), [1, 1, 1, 1]); + }); + + it("scales auto mode with wheel velocity", () => { + const accelerator = new WheelScrollAccelerator("auto", true); + assert.deepStrictEqual(scroll(accelerator, [0, 150, 300, 450]), [1, 1, 1, 1]); + assert.deepStrictEqual(scroll(accelerator, [1000, 1050, 1100, 1150]), [1, 2, 2, 2]); + assert.deepStrictEqual(scroll(accelerator, [2000, 2020, 2040, 2060]), [1, 5, 5, 5]); + assert.deepStrictEqual(scroll(accelerator, [3000, 3010, 3020, 3030]), [1, 6, 6, 6]); + }); + + it("does not accelerate bursts of events for a single notch", () => { + const accelerator = new WheelScrollAccelerator("auto", true); + assert.deepStrictEqual(scroll(accelerator, [0, 3, 6, 9]), [1, 1, 1, 1]); + }); + + it("resets acceleration on direction changes and pauses", () => { + const accelerator = new WheelScrollAccelerator("auto", true); + assert.deepStrictEqual(scroll(accelerator, [0, 20, 40]), [1, 5, 5]); + assert.strictEqual(accelerator.next(-1, 60), 1); + assert.deepStrictEqual(scroll(accelerator, [500, 520]), [1, 5]); + }); + + it("carries fractional lines between events", () => { + const accelerator = new WheelScrollAccelerator("auto", true); + assert.deepStrictEqual(scroll(accelerator, [0, 40, 80, 120, 160]), [1, 2, 3, 2, 3]); + }); +});