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;