diff --git a/packages/cli/src/ui/contexts/ScrollProvider.test.tsx b/packages/cli/src/ui/contexts/ScrollProvider.test.tsx
index c06eada4f0..4455b66919 100644
--- a/packages/cli/src/ui/contexts/ScrollProvider.test.tsx
+++ b/packages/cli/src/ui/contexts/ScrollProvider.test.tsx
@@ -14,6 +14,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useRef, useImperativeHandle, forwardRef, type RefObject } from 'react';
import { Box, type DOMElement } from 'ink';
import type { MouseEvent } from '../hooks/useMouse.js';
+import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
+
+vi.mock('../utils/terminalCapabilityManager.js', () => ({
+ terminalCapabilityManager: {
+ isGhosttyTerminal: vi.fn(() => false),
+ },
+}));
// Mock useMouse hook
const mockUseMouseCallbacks = new Set<(event: MouseEvent) => void | boolean>();
@@ -78,6 +85,7 @@ describe('ScrollProvider', () => {
});
afterEach(() => {
+ vi.restoreAllMocks();
vi.useRealTimers();
});
@@ -520,4 +528,157 @@ describe('ScrollProvider', () => {
expect(scrollBy).toHaveBeenCalled();
});
+
+ describe('Scroll Acceleration', () => {
+ it('accelerates scroll for non-Ghostty terminals during rapid scrolling', async () => {
+ const scrollBy = vi.fn();
+ const getScrollState = vi.fn(() => ({
+ scrollTop: 50,
+ scrollHeight: 1000,
+ innerHeight: 10,
+ }));
+
+ vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue(
+ false,
+ );
+
+ await render(
+
+
+ ,
+ );
+
+ const mouseEvent: MouseEvent = {
+ name: 'scroll-down',
+ col: 5,
+ row: 5,
+ shift: false,
+ ctrl: false,
+ meta: false,
+ button: 'none',
+ };
+
+ // Perform 60 rapid scrolls (within 50ms of each other)
+ for (let i = 0; i < 60; i++) {
+ for (const callback of mockUseMouseCallbacks) {
+ callback(mouseEvent);
+ }
+ // Advance time by 10ms for each scroll
+ vi.advanceTimersByTime(10);
+ }
+
+ await vi.runAllTimersAsync();
+
+ // We sum all calls to scrollBy as they might have been flushed individually due to advanceTimersByTime
+ const totalDelta = scrollBy.mock.calls.reduce(
+ (sum, call) => sum + call[0],
+ 0,
+ );
+ expect(totalDelta).toBeGreaterThan(60);
+ expect(totalDelta).toBe(150);
+ });
+
+ it('does not accelerate for Ghostty terminals even during rapid scrolling', async () => {
+ const scrollBy = vi.fn();
+ const getScrollState = vi.fn(() => ({
+ scrollTop: 50,
+ scrollHeight: 1000,
+ innerHeight: 10,
+ }));
+
+ vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue(
+ true,
+ );
+
+ await render(
+
+
+ ,
+ );
+
+ const mouseEvent: MouseEvent = {
+ name: 'scroll-down',
+ col: 5,
+ row: 5,
+ shift: false,
+ ctrl: false,
+ meta: false,
+ button: 'none',
+ };
+
+ for (let i = 0; i < 60; i++) {
+ for (const callback of mockUseMouseCallbacks) {
+ callback(mouseEvent);
+ }
+ vi.advanceTimersByTime(10);
+ }
+
+ await vi.runAllTimersAsync();
+
+ // No acceleration means 60 scrolls = delta 60
+ const totalDelta = scrollBy.mock.calls.reduce(
+ (sum, call) => sum + call[0],
+ 0,
+ );
+ expect(totalDelta).toBe(60);
+ });
+
+ it('resets acceleration count if scrolling is slow', async () => {
+ const scrollBy = vi.fn();
+ const getScrollState = vi.fn(() => ({
+ scrollTop: 50,
+ scrollHeight: 1000,
+ innerHeight: 10,
+ }));
+
+ vi.mocked(terminalCapabilityManager.isGhosttyTerminal).mockReturnValue(
+ false,
+ );
+
+ await render(
+
+
+ ,
+ );
+
+ const mouseEvent: MouseEvent = {
+ name: 'scroll-down',
+ col: 5,
+ row: 5,
+ shift: false,
+ ctrl: false,
+ meta: false,
+ button: 'none',
+ };
+
+ // Perform scrolls with 100ms gap (greater than 50ms threshold)
+ for (let i = 0; i < 60; i++) {
+ for (const callback of mockUseMouseCallbacks) {
+ callback(mouseEvent);
+ }
+ vi.advanceTimersByTime(100);
+ }
+
+ await vi.runAllTimersAsync();
+
+ // No acceleration because gaps were too large
+ const totalDelta = scrollBy.mock.calls.reduce(
+ (sum, call) => sum + call[0],
+ 0,
+ );
+ expect(totalDelta).toBe(60);
+ });
+ });
});
diff --git a/packages/cli/src/ui/contexts/ScrollProvider.tsx b/packages/cli/src/ui/contexts/ScrollProvider.tsx
index a76768de21..16b63416b6 100644
--- a/packages/cli/src/ui/contexts/ScrollProvider.tsx
+++ b/packages/cli/src/ui/contexts/ScrollProvider.tsx
@@ -16,6 +16,7 @@ import {
} from 'react';
import { getBoundingBox, type DOMElement } from 'ink';
import { useMouse, type MouseEvent } from '../hooks/useMouse.js';
+import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
export interface ScrollState {
scrollTop: number;
@@ -125,8 +126,33 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
}
}, []);
+ const scrollMomentumRef = useRef({
+ count: 0,
+ lastTime: 0,
+ });
+
const handleScroll = (direction: 'up' | 'down', mouseEvent: MouseEvent) => {
- const delta = direction === 'up' ? -1 : 1;
+ let multiplier = 1;
+ const now = Date.now();
+
+ if (!terminalCapabilityManager.isGhosttyTerminal()) {
+ const timeSinceLastScroll = now - scrollMomentumRef.current.lastTime;
+ // 50ms threshold to consider scrolls consecutive
+ if (timeSinceLastScroll < 50) {
+ scrollMomentumRef.current.count += 1;
+ // Accelerate up to 3x, starting after 5 consecutive scrolls.
+ // Each consecutive scroll increases the multiplier by 0.1.
+ multiplier = Math.min(
+ 3,
+ 1 + Math.max(0, scrollMomentumRef.current.count - 5) * 0.1,
+ );
+ } else {
+ scrollMomentumRef.current.count = 0;
+ }
+ }
+ scrollMomentumRef.current.lastTime = now;
+
+ const delta = (direction === 'up' ? -1 : 1) * multiplier;
const candidates = findScrollableCandidates(
mouseEvent,
scrollablesRef.current,
@@ -142,15 +168,16 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
const canScrollUp = effectiveScrollTop > 0.001;
const canScrollDown =
effectiveScrollTop < scrollHeight - innerHeight - 0.001;
+ const totalDelta = Math.round(pendingDelta + delta);
if (direction === 'up' && canScrollUp) {
- pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
+ pendingScrollsRef.current.set(candidate.id, totalDelta);
scheduleFlush();
return true;
}
if (direction === 'down' && canScrollDown) {
- pendingScrollsRef.current.set(candidate.id, pendingDelta + delta);
+ pendingScrollsRef.current.set(candidate.id, totalDelta);
scheduleFlush();
return true;
}
diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts
index 732945ffe8..4cc25586b5 100644
--- a/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts
+++ b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts
@@ -322,6 +322,49 @@ describe('TerminalCapabilityManager', () => {
});
});
+ describe('isGhosttyTerminal', () => {
+ const manager = TerminalCapabilityManager.getInstance();
+
+ it.each([
+ {
+ name: 'Ghostty (terminal name)',
+ terminalName: 'Ghostty',
+ env: {},
+ expected: true,
+ },
+ {
+ name: 'ghostty (TERM_PROGRAM)',
+ terminalName: undefined,
+ env: { TERM_PROGRAM: 'ghostty' },
+ expected: true,
+ },
+ {
+ name: 'xterm-ghostty (TERM)',
+ terminalName: undefined,
+ env: { TERM: 'xterm-ghostty' },
+ expected: true,
+ },
+ {
+ name: 'iTerm.app (TERM_PROGRAM)',
+ terminalName: undefined,
+ env: { TERM_PROGRAM: 'iTerm.app' },
+ expected: false,
+ },
+ {
+ name: 'undefined env',
+ terminalName: undefined,
+ env: {},
+ expected: false,
+ },
+ ])(
+ 'should return $expected for $name',
+ ({ terminalName, env, expected }) => {
+ vi.spyOn(manager, 'getTerminalName').mockReturnValue(terminalName);
+ expect(manager.isGhosttyTerminal(env)).toBe(expected);
+ },
+ );
+ });
+
describe('supportsOsc9Notifications', () => {
const manager = TerminalCapabilityManager.getInstance();
diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.ts
index 6aeda005dc..ddbbad4ce8 100644
--- a/packages/cli/src/ui/utils/terminalCapabilityManager.ts
+++ b/packages/cli/src/ui/utils/terminalCapabilityManager.ts
@@ -272,6 +272,18 @@ export class TerminalCapabilityManager {
return this.kittyEnabled;
}
+ isGhosttyTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
+ const termProgram = env['TERM_PROGRAM']?.toLowerCase();
+ const term = env['TERM']?.toLowerCase();
+ const name = this.getTerminalName()?.toLowerCase();
+
+ return !!(
+ name?.includes('ghostty') ||
+ termProgram?.includes('ghostty') ||
+ term?.includes('ghostty')
+ );
+ }
+
supportsOsc9Notifications(env: NodeJS.ProcessEnv = process.env): boolean {
if (env['WT_SESSION']) {
return false;