Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions ui-tui/src/__tests__/precisionWheel.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
33 changes: 32 additions & 1 deletion ui-tui/src/__tests__/viewportStore.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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)
})
})
41 changes: 12 additions & 29 deletions ui-tui/src/app/useInputHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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), [])

Expand Down Expand Up @@ -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

if (hasModifier || precisionSticky) {
if (!precision.active) {
precision.active = true
wheelAccelRef.current = initWheelAccelForHost()
}

precision.lastEventAtMs = now
const precision = computePrecisionWheelStep(precisionWheelRef.current, dir, hasModifier, now)

if (dir === precision.dir && now - precision.lastScrollAtMs < PRECISION_WHEEL_MIN_GAP_MS) {
return
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.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)

Expand Down
16 changes: 11 additions & 5 deletions ui-tui/src/components/appChrome.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -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<number | null>(null)
const { scrollHeight: total, top: pos, viewportHeight: vp } = useViewportSnapshot(scrollRef)
const grabRef = useRef<number | null>(null)
const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef)

if (!vp) {
return <Box width={1} />
Expand Down Expand Up @@ -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 ? (
Expand Down
48 changes: 48 additions & 0 deletions ui-tui/src/lib/precisionWheel.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
50 changes: 50 additions & 0 deletions ui-tui/src/lib/viewportStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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<ScrollBoxHandle | null>): ViewportSnapshot {
const key = useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
Expand All @@ -72,3 +104,21 @@ export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>
}
}, [key])
}

export function useScrollbarSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): 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])
}
Loading