mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-13 03:52:00 +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 { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { getViewportSnapshot, viewportSnapshotKey } from '../lib/viewportStore.js'
|
import { getScrollbarSnapshot, getViewportSnapshot, scrollbarSnapshotKey, viewportSnapshotKey } from '../lib/viewportStore.js'
|
||||||
|
|
||||||
describe('viewportStore', () => {
|
describe('viewportStore', () => {
|
||||||
it('normalizes absent scroll handles', () => {
|
it('normalizes absent scroll handles', () => {
|
||||||
|
|
@ -51,4 +51,35 @@ describe('viewportStore', () => {
|
||||||
expect(snap.atBottom).toBe(true)
|
expect(snap.atBottom).toBe(true)
|
||||||
expect(snap.scrollHeight).toBe(20)
|
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
|
VoiceRecordResponse
|
||||||
} from '../gatewayTypes.js'
|
} from '../gatewayTypes.js'
|
||||||
import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.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 { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
|
||||||
|
|
||||||
import { getInputSelection } from './inputSelectionStore.js'
|
import { getInputSelection } from './inputSelectionStore.js'
|
||||||
|
|
@ -21,8 +22,6 @@ import { patchTurnState } from './turnStore.js'
|
||||||
import { getUiState } from './uiStore.js'
|
import { getUiState } from './uiStore.js'
|
||||||
|
|
||||||
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
|
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 {
|
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||||
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
|
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.
|
// rows = wheelStep × accelMult. State mutates in place across renders.
|
||||||
const wheelAccelRef = useRef(initWheelAccelForHost())
|
const wheelAccelRef = useRef(initWheelAccelForHost())
|
||||||
|
|
||||||
const precisionWheelRef = useRef<{ active: boolean; dir: 0 | -1 | 1; lastEventAtMs: number; lastScrollAtMs: number }>(
|
const precisionWheelRef = useRef(initPrecisionWheel())
|
||||||
{ active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 }
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), [])
|
useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), [])
|
||||||
|
|
||||||
|
|
@ -291,40 +288,26 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||||
if (key.wheelUp || key.wheelDown) {
|
if (key.wheelUp || key.wheelDown) {
|
||||||
const dir: -1 | 1 = key.wheelUp ? -1 : 1
|
const dir: -1 | 1 = key.wheelUp ? -1 : 1
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
// Modifier-held wheel = precision mode: at most one wheelStep per short
|
// Modifier-held wheel = precision mode: one row per frame, no accel.
|
||||||
// interval. Smooth mice / trackpads emit many raw wheel events for one
|
// Smooth mice / trackpads emit tiny same-frame bursts; coalesce those
|
||||||
// intended line step, so raw 1:1 still moves too far.
|
// without the old 80ms throttle that made opt-scroll feel stepped.
|
||||||
// SGR/X10 mouse encoding only carries shift/meta/ctrl bits; Cmd on
|
// SGR/X10 mouse encoding only carries shift/meta/ctrl bits; Cmd on
|
||||||
// macOS is intercepted by the terminal, so we honor Option (meta) 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
|
// Mac / Alt (meta) on Win+Linux / Ctrl as a portable fallback. Shift
|
||||||
// is reserved for selection extension.
|
// is reserved for selection extension.
|
||||||
const hasModifier = key.meta || key.ctrl
|
const hasModifier = key.meta || key.ctrl
|
||||||
const precision = precisionWheelRef.current
|
const precision = computePrecisionWheelStep(precisionWheelRef.current, dir, hasModifier, now)
|
||||||
// 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) {
|
||||||
if (!precision.active) {
|
// Entering precision mode must discard any accelerated wheel state;
|
||||||
precision.active = true
|
// otherwise the next normal wheel event inherits stale momentum.
|
||||||
|
if (precision.entered) {
|
||||||
wheelAccelRef.current = initWheelAccelForHost()
|
wheelAccelRef.current = initWheelAccelForHost()
|
||||||
}
|
}
|
||||||
|
|
||||||
precision.lastEventAtMs = now
|
return precision.rows ? scrollTranscript(dir * wheelStep) : undefined
|
||||||
|
|
||||||
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.
|
// 0 = direction-flip bounce deferred; skip the no-op scroll.
|
||||||
const rows = computeWheelStep(wheelAccelRef.current, dir, now)
|
const rows = computeWheelStep(wheelAccelRef.current, dir, now)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||||
import { useStore } from '@nanostores/react'
|
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 unicodeSpinners from 'unicode-animations'
|
||||||
|
|
||||||
import { $delegationState } from '../app/delegationStore.js'
|
import { $delegationState } from '../app/delegationStore.js'
|
||||||
|
|
@ -13,7 +13,7 @@ import { fmtDuration } from '../domain/messages.js'
|
||||||
import { stickyPromptFromViewport } from '../domain/viewport.js'
|
import { stickyPromptFromViewport } from '../domain/viewport.js'
|
||||||
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
|
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
|
||||||
import { fmtK } from '../lib/text.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 { Theme } from '../theme.js'
|
||||||
import type { Msg, Usage } from '../types.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) {
|
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
|
||||||
const [hover, setHover] = useState(false)
|
const [hover, setHover] = useState(false)
|
||||||
const [grab, setGrab] = useState<number | null>(null)
|
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) {
|
if (!vp) {
|
||||||
return <Box width={1} />
|
return <Box width={1} />
|
||||||
|
|
@ -405,15 +406,20 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
|
||||||
onMouseDown={(e: { localRow?: number }) => {
|
onMouseDown={(e: { localRow?: number }) => {
|
||||||
const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
|
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)
|
const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
|
||||||
|
|
||||||
|
grabRef.current = off
|
||||||
setGrab(off)
|
setGrab(off)
|
||||||
jump(row, off)
|
jump(row, off)
|
||||||
}}
|
}}
|
||||||
onMouseDrag={(e: { localRow?: number }) =>
|
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)}
|
onMouseEnter={() => setHover(true)}
|
||||||
onMouseLeave={() => setHover(false)}
|
onMouseLeave={() => setHover(false)}
|
||||||
onMouseUp={() => setGrab(null)}
|
onMouseUp={() => {
|
||||||
|
grabRef.current = null
|
||||||
|
setGrab(null)
|
||||||
|
}}
|
||||||
width={1}
|
width={1}
|
||||||
>
|
>
|
||||||
{!scrollable ? (
|
{!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
|
viewportHeight: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScrollbarSnapshot {
|
||||||
|
scrollHeight: number
|
||||||
|
top: number
|
||||||
|
viewportHeight: number
|
||||||
|
}
|
||||||
|
|
||||||
const EMPTY: ViewportSnapshot = {
|
const EMPTY: ViewportSnapshot = {
|
||||||
atBottom: true,
|
atBottom: true,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|
@ -20,6 +26,12 @@ const EMPTY: ViewportSnapshot = {
|
||||||
viewportHeight: 0
|
viewportHeight: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_SCROLLBAR: ScrollbarSnapshot = {
|
||||||
|
scrollHeight: 0,
|
||||||
|
top: 0,
|
||||||
|
viewportHeight: 0
|
||||||
|
}
|
||||||
|
|
||||||
export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot {
|
export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot {
|
||||||
if (!s) {
|
if (!s) {
|
||||||
return EMPTY
|
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}`
|
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 {
|
export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ViewportSnapshot {
|
||||||
const key = useSyncExternalStore(
|
const key = useSyncExternalStore(
|
||||||
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
||||||
|
|
@ -72,3 +104,21 @@ export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>
|
||||||
}
|
}
|
||||||
}, [key])
|
}, [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