mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
fix(tui): steady transcript scrollbar (#20917)
* fix(tui): steady transcript scrollbar Keep the visible scrollbar tied to committed viewport position while virtual history can still prefetch against pending scroll targets, and preserve drag grab offset synchronously for native-feeling scrollbar drags. * fix(tui): smooth precision wheel scroll Replace the opt-scroll throttle with frame-sized coalescing so modifier wheel gestures stay line-precise without stepping.
This commit is contained in:
parent
53a024994a
commit
5ccab51fa8
6 changed files with 196 additions and 34 deletions
44
ui-tui/src/__tests__/precisionWheel.test.ts
Normal file
44
ui-tui/src/__tests__/precisionWheel.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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} />
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
48
ui-tui/src/lib/precisionWheel.ts
Normal file
48
ui-tui/src/lib/precisionWheel.ts
Normal file
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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<ScrollBoxHandle | null>): ViewportSnapshot {
|
||||
const key = useSyncExternalStore(
|
||||
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
||||
|
|
@ -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…
Add table
Add a link
Reference in a new issue