diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 6fdd04ea61..a74c9e8431 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -21,6 +21,8 @@ import { patchTurnState } from './turnStore.js' import { getUiState } from './uiStore.js' const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target +const PRECISION_WHEEL_MIN_GAP_MS = 80 +const PRECISION_WHEEL_STICKY_MS = 80 export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const { actions, composer, gateway, terminal, voice, wheelStep } = ctx @@ -36,6 +38,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { // rows = wheelStep × accelMult. State mutates in place across renders. const wheelAccelRef = useRef(initWheelAccelForHost()) + const precisionWheelRef = useRef<{ active: boolean; dir: 0 | -1 | 1; lastEventAtMs: number; lastScrollAtMs: number }>( + { active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 } + ) + useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), []) const scrollTranscript = (delta: number) => { @@ -284,8 +290,43 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (key.wheelUp || key.wheelDown) { const dir: -1 | 1 = key.wheelUp ? -1 : 1 + const now = Date.now() + // Modifier-held wheel = precision mode: at most one wheelStep per short + // interval. Smooth mice / trackpads emit many raw wheel events for one + // intended line step, so raw 1:1 still moves too far. + // SGR/X10 mouse encoding only carries shift/meta/ctrl bits; Cmd on + // macOS is intercepted by the terminal, so we honor Option (meta) on + // Mac / Alt (meta) on Win+Linux / Ctrl as a portable fallback. Shift + // is reserved for selection extension. + const hasModifier = key.meta || key.ctrl + const precision = precisionWheelRef.current + // Keep precision active through the current wheel burst after the + // modifier is released. Otherwise a stream of queued/momentum wheel + // events can hand off mid-burst into the accelerated path and jump. + const precisionSticky = now - precision.lastEventAtMs < PRECISION_WHEEL_STICKY_MS + + if (hasModifier || precisionSticky) { + if (!precision.active) { + precision.active = true + wheelAccelRef.current = initWheelAccelForHost() + } + + precision.lastEventAtMs = now + + if (dir === precision.dir && now - precision.lastScrollAtMs < PRECISION_WHEEL_MIN_GAP_MS) { + return + } + + precision.lastScrollAtMs = now + precision.dir = dir + + return scrollTranscript(dir * wheelStep) + } + + precision.active = false + // 0 = direction-flip bounce deferred; skip the no-op scroll. - const rows = computeWheelStep(wheelAccelRef.current, dir, Date.now()) + const rows = computeWheelStep(wheelAccelRef.current, dir, now) return rows ? scrollTranscript(dir * rows * wheelStep) : undefined }