diff --git a/ui-tui/src/__tests__/precisionWheel.test.ts b/ui-tui/src/__tests__/precisionWheel.test.ts new file mode 100644 index 0000000000..1356752179 --- /dev/null +++ b/ui-tui/src/__tests__/precisionWheel.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js' + +describe('precisionWheel', () => { + it('passes the first modifier-held wheel event', () => { + const s = initPrecisionWheel() + + expect(computePrecisionWheelStep(s, 1, true, 1000)).toEqual({ active: true, entered: true, rows: 1 }) + }) + + it('coalesces same-frame events without throttling line-by-line scroll', () => { + const s = initPrecisionWheel() + + computePrecisionWheelStep(s, 1, true, 1000) + + expect(computePrecisionWheelStep(s, 1, true, 1008).rows).toBe(0) + expect(computePrecisionWheelStep(s, 1, true, 1016).rows).toBe(1) + }) + + it('keeps queued momentum in precision mode briefly after modifier release', () => { + const s = initPrecisionWheel() + + computePrecisionWheelStep(s, 1, true, 1000) + + expect(computePrecisionWheelStep(s, 1, false, 1050)).toMatchObject({ active: true, rows: 1 }) + }) + + it('leaves precision mode once modifier-free momentum goes idle', () => { + const s = initPrecisionWheel() + + computePrecisionWheelStep(s, 1, true, 1000) + + expect(computePrecisionWheelStep(s, 1, false, 1100)).toEqual({ active: false, entered: false, rows: 0 }) + }) + + it('does not coalesce immediate reversals', () => { + const s = initPrecisionWheel() + + computePrecisionWheelStep(s, 1, true, 1000) + + expect(computePrecisionWheelStep(s, -1, true, 1008).rows).toBe(1) + }) +}) diff --git a/ui-tui/src/__tests__/viewportStore.test.ts b/ui-tui/src/__tests__/viewportStore.test.ts index 7889b65cde..2d37127e54 100644 --- a/ui-tui/src/__tests__/viewportStore.test.ts +++ b/ui-tui/src/__tests__/viewportStore.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { getViewportSnapshot, viewportSnapshotKey } from '../lib/viewportStore.js' +import { getScrollbarSnapshot, getViewportSnapshot, scrollbarSnapshotKey, viewportSnapshotKey } from '../lib/viewportStore.js' describe('viewportStore', () => { it('normalizes absent scroll handles', () => { @@ -51,4 +51,35 @@ describe('viewportStore', () => { expect(snap.atBottom).toBe(true) expect(snap.scrollHeight).toBe(20) }) + + it('keeps scrollbar position tied to committed scrollTop, not pending target', () => { + const handle = { + getPendingDelta: () => 24, + getScrollHeight: () => 100, + getScrollTop: () => 10, + getViewportHeight: () => 20, + isSticky: () => false + } + + const viewport = getViewportSnapshot(handle as any) + const scrollbar = getScrollbarSnapshot(handle as any) + + expect(viewport.top).toBe(34) + expect(scrollbar).toEqual({ + scrollHeight: 100, + top: 10, + viewportHeight: 20 + }) + expect(scrollbarSnapshotKey(scrollbar)).toBe('10:20:100') + }) + + it('clamps scrollbar position to committed scroll bounds', () => { + const handle = { + getScrollHeight: () => 30, + getScrollTop: () => 50, + getViewportHeight: () => 20 + } + + expect(getScrollbarSnapshot(handle as any).top).toBe(10) + }) }) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 20e9b087a4..3d85a500d8 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -11,6 +11,7 @@ import type { VoiceRecordResponse } from '../gatewayTypes.js' import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js' +import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js' import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js' import { getInputSelection } from './inputSelectionStore.js' @@ -21,8 +22,6 @@ 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 @@ -38,9 +37,7 @@ 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 } - ) + const precisionWheelRef = useRef(initPrecisionWheel()) useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), []) @@ -291,40 +288,26 @@ 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. + // Modifier-held wheel = precision mode: one row per frame, no accel. + // Smooth mice / trackpads emit tiny same-frame bursts; coalesce those + // without the old 80ms throttle that made opt-scroll feel stepped. // 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 + const precision = computePrecisionWheelStep(precisionWheelRef.current, dir, hasModifier, now) - if (hasModifier || precisionSticky) { - if (!precision.active) { - precision.active = true + if (precision.active) { + // Entering precision mode must discard any accelerated wheel state; + // otherwise the next normal wheel event inherits stale momentum. + if (precision.entered) { 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) + return precision.rows ? scrollTranscript(dir * wheelStep) : undefined } - precision.active = false - // 0 = direction-flip bounce deferred; skip the no-op scroll. const rows = computeWheelStep(wheelAccelRef.current, dir, now) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 29e663a47f..c2e08b3698 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -1,6 +1,6 @@ import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' -import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react' +import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react' import unicodeSpinners from 'unicode-animations' import { $delegationState } from '../app/delegationStore.js' @@ -13,7 +13,7 @@ import { fmtDuration } from '../domain/messages.js' import { stickyPromptFromViewport } from '../domain/viewport.js' import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js' import { fmtK } from '../lib/text.js' -import { useViewportSnapshot } from '../lib/viewportStore.js' +import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js' import type { Theme } from '../theme.js' import type { Msg, Usage } from '../types.js' @@ -377,7 +377,8 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) { const [hover, setHover] = useState(false) const [grab, setGrab] = useState(null) - const { scrollHeight: total, top: pos, viewportHeight: vp } = useViewportSnapshot(scrollRef) + const grabRef = useRef(null) + const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef) if (!vp) { return @@ -405,15 +406,20 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) onMouseDown={(e: { localRow?: number }) => { const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0)) const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2) + + grabRef.current = off setGrab(off) jump(row, off) }} onMouseDrag={(e: { localRow?: number }) => - jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2)) + jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grabRef.current ?? Math.floor(thumb / 2)) } onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} - onMouseUp={() => setGrab(null)} + onMouseUp={() => { + grabRef.current = null + setGrab(null) + }} width={1} > {!scrollable ? ( diff --git a/ui-tui/src/lib/precisionWheel.ts b/ui-tui/src/lib/precisionWheel.ts new file mode 100644 index 0000000000..4ddb447abf --- /dev/null +++ b/ui-tui/src/lib/precisionWheel.ts @@ -0,0 +1,48 @@ +const PRECISION_WHEEL_FRAME_MS = 16 +const PRECISION_WHEEL_STICKY_MS = 80 + +export type PrecisionWheelState = { + active: boolean + dir: 0 | -1 | 1 + lastEventAtMs: number + lastScrollAtMs: number +} + +export type PrecisionWheelStep = { + active: boolean + entered: boolean + rows: 0 | 1 +} + +export function initPrecisionWheel(): PrecisionWheelState { + return { active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 } +} + +export function computePrecisionWheelStep( + state: PrecisionWheelState, + dir: -1 | 1, + hasModifier: boolean, + now: number +): PrecisionWheelStep { + const active = hasModifier || now - state.lastEventAtMs < PRECISION_WHEEL_STICKY_MS + + if (!active) { + state.active = false + + return { active: false, entered: false, rows: 0 } + } + + const entered = !state.active + + state.active = true + state.lastEventAtMs = now + + if (dir === state.dir && now - state.lastScrollAtMs < PRECISION_WHEEL_FRAME_MS) { + return { active: true, entered, rows: 0 } + } + + state.dir = dir + state.lastScrollAtMs = now + + return { active: true, entered, rows: 1 } +} diff --git a/ui-tui/src/lib/viewportStore.ts b/ui-tui/src/lib/viewportStore.ts index b25ef581f4..25acbd8beb 100644 --- a/ui-tui/src/lib/viewportStore.ts +++ b/ui-tui/src/lib/viewportStore.ts @@ -11,6 +11,12 @@ export interface ViewportSnapshot { viewportHeight: number } +export interface ScrollbarSnapshot { + scrollHeight: number + top: number + viewportHeight: number +} + const EMPTY: ViewportSnapshot = { atBottom: true, bottom: 0, @@ -20,6 +26,12 @@ const EMPTY: ViewportSnapshot = { viewportHeight: 0 } +const EMPTY_SCROLLBAR: ScrollbarSnapshot = { + scrollHeight: 0, + top: 0, + viewportHeight: 0 +} + export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot { if (!s) { return EMPTY @@ -52,6 +64,26 @@ export function viewportSnapshotKey(v: ViewportSnapshot) { return `${v.atBottom ? 1 : 0}:${Math.ceil(v.top / 8) * 8}:${v.viewportHeight}:${Math.ceil(v.scrollHeight / 8) * 8}:${v.pending}` } +export function getScrollbarSnapshot(s?: ScrollBoxHandle | null): ScrollbarSnapshot { + if (!s) { + return EMPTY_SCROLLBAR + } + + const viewportHeight = Math.max(0, s.getViewportHeight()) + const scrollHeight = Math.max(viewportHeight, s.getScrollHeight()) + const maxTop = Math.max(0, scrollHeight - viewportHeight) + + return { + scrollHeight, + top: Math.max(0, Math.min(maxTop, s.getScrollTop())), + viewportHeight + } +} + +export function scrollbarSnapshotKey(v: ScrollbarSnapshot) { + return `${v.top}:${v.viewportHeight}:${v.scrollHeight}` +} + export function useViewportSnapshot(scrollRef: RefObject): ViewportSnapshot { const key = useSyncExternalStore( useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), @@ -72,3 +104,21 @@ export function useViewportSnapshot(scrollRef: RefObject } }, [key]) } + +export function useScrollbarSnapshot(scrollRef: RefObject): ScrollbarSnapshot { + const key = useSyncExternalStore( + useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), + () => scrollbarSnapshotKey(getScrollbarSnapshot(scrollRef.current)), + () => scrollbarSnapshotKey(EMPTY_SCROLLBAR) + ) + + return useMemo(() => { + const [top = '0', viewportHeight = '0', scrollHeight = '0'] = key.split(':') + + return { + scrollHeight: Number(scrollHeight), + top: Number(top), + viewportHeight: Number(viewportHeight) + } + }, [key]) +}