From fc0f358f370c4d2a92b8ba87de7c61f7be7cb4f5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Apr 2026 20:39:39 -0500 Subject: [PATCH] fix(tui): add modifier-held precision wheel scrolling Route Option/Alt or Ctrl wheel input through a gated precision path that scrolls at most one row per short interval, while preserving the existing accelerated behavior for plain wheel input. Keep precision active briefly after modifier release so queued wheel events from the same gesture do not jump into acceleration mid-stream. --- ui-tui/src/app/useInputHandlers.ts | 43 +++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) 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 }