Skip to content

Commit 5ccab51

Browse files
authored
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.
1 parent 53a0249 commit 5ccab51

6 files changed

Lines changed: 197 additions & 35 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
4+
5+
describe('precisionWheel', () => {
6+
it('passes the first modifier-held wheel event', () => {
7+
const s = initPrecisionWheel()
8+
9+
expect(computePrecisionWheelStep(s, 1, true, 1000)).toEqual({ active: true, entered: true, rows: 1 })
10+
})
11+
12+
it('coalesces same-frame events without throttling line-by-line scroll', () => {
13+
const s = initPrecisionWheel()
14+
15+
computePrecisionWheelStep(s, 1, true, 1000)
16+
17+
expect(computePrecisionWheelStep(s, 1, true, 1008).rows).toBe(0)
18+
expect(computePrecisionWheelStep(s, 1, true, 1016).rows).toBe(1)
19+
})
20+
21+
it('keeps queued momentum in precision mode briefly after modifier release', () => {
22+
const s = initPrecisionWheel()
23+
24+
computePrecisionWheelStep(s, 1, true, 1000)
25+
26+
expect(computePrecisionWheelStep(s, 1, false, 1050)).toMatchObject({ active: true, rows: 1 })
27+
})
28+
29+
it('leaves precision mode once modifier-free momentum goes idle', () => {
30+
const s = initPrecisionWheel()
31+
32+
computePrecisionWheelStep(s, 1, true, 1000)
33+
34+
expect(computePrecisionWheelStep(s, 1, false, 1100)).toEqual({ active: false, entered: false, rows: 0 })
35+
})
36+
37+
it('does not coalesce immediate reversals', () => {
38+
const s = initPrecisionWheel()
39+
40+
computePrecisionWheelStep(s, 1, true, 1000)
41+
42+
expect(computePrecisionWheelStep(s, -1, true, 1008).rows).toBe(1)
43+
})
44+
})

ui-tui/src/__tests__/viewportStore.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22

3-
import { getViewportSnapshot, viewportSnapshotKey } from '../lib/viewportStore.js'
3+
import { getScrollbarSnapshot, getViewportSnapshot, scrollbarSnapshotKey, viewportSnapshotKey } from '../lib/viewportStore.js'
44

55
describe('viewportStore', () => {
66
it('normalizes absent scroll handles', () => {
@@ -51,4 +51,35 @@ describe('viewportStore', () => {
5151
expect(snap.atBottom).toBe(true)
5252
expect(snap.scrollHeight).toBe(20)
5353
})
54+
55+
it('keeps scrollbar position tied to committed scrollTop, not pending target', () => {
56+
const handle = {
57+
getPendingDelta: () => 24,
58+
getScrollHeight: () => 100,
59+
getScrollTop: () => 10,
60+
getViewportHeight: () => 20,
61+
isSticky: () => false
62+
}
63+
64+
const viewport = getViewportSnapshot(handle as any)
65+
const scrollbar = getScrollbarSnapshot(handle as any)
66+
67+
expect(viewport.top).toBe(34)
68+
expect(scrollbar).toEqual({
69+
scrollHeight: 100,
70+
top: 10,
71+
viewportHeight: 20
72+
})
73+
expect(scrollbarSnapshotKey(scrollbar)).toBe('10:20:100')
74+
})
75+
76+
it('clamps scrollbar position to committed scroll bounds', () => {
77+
const handle = {
78+
getScrollHeight: () => 30,
79+
getScrollTop: () => 50,
80+
getViewportHeight: () => 20
81+
}
82+
83+
expect(getScrollbarSnapshot(handle as any).top).toBe(10)
84+
})
5485
})

ui-tui/src/app/useInputHandlers.ts

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
VoiceRecordResponse
1212
} from '../gatewayTypes.js'
1313
import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js'
14+
import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
1415
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
1516

1617
import { getInputSelection } from './inputSelectionStore.js'
@@ -21,8 +22,6 @@ import { patchTurnState } from './turnStore.js'
2122
import { getUiState } from './uiStore.js'
2223

2324
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
24-
const PRECISION_WHEEL_MIN_GAP_MS = 80
25-
const PRECISION_WHEEL_STICKY_MS = 80
2625

2726
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
2827
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
@@ -38,9 +37,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
3837
// rows = wheelStep × accelMult. State mutates in place across renders.
3938
const wheelAccelRef = useRef(initWheelAccelForHost())
4039

41-
const precisionWheelRef = useRef<{ active: boolean; dir: 0 | -1 | 1; lastEventAtMs: number; lastScrollAtMs: number }>(
42-
{ active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 }
43-
)
40+
const precisionWheelRef = useRef(initPrecisionWheel())
4441

4542
useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), [])
4643

@@ -291,40 +288,26 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
291288
if (key.wheelUp || key.wheelDown) {
292289
const dir: -1 | 1 = key.wheelUp ? -1 : 1
293290
const now = Date.now()
294-
// Modifier-held wheel = precision mode: at most one wheelStep per short
295-
// interval. Smooth mice / trackpads emit many raw wheel events for one
296-
// intended line step, so raw 1:1 still moves too far.
291+
// Modifier-held wheel = precision mode: one row per frame, no accel.
292+
// Smooth mice / trackpads emit tiny same-frame bursts; coalesce those
293+
// without the old 80ms throttle that made opt-scroll feel stepped.
297294
// SGR/X10 mouse encoding only carries shift/meta/ctrl bits; Cmd on
298295
// macOS is intercepted by the terminal, so we honor Option (meta) on
299296
// Mac / Alt (meta) on Win+Linux / Ctrl as a portable fallback. Shift
300297
// is reserved for selection extension.
301298
const hasModifier = key.meta || key.ctrl
302-
const precision = precisionWheelRef.current
303-
// Keep precision active through the current wheel burst after the
304-
// modifier is released. Otherwise a stream of queued/momentum wheel
305-
// events can hand off mid-burst into the accelerated path and jump.
306-
const precisionSticky = now - precision.lastEventAtMs < PRECISION_WHEEL_STICKY_MS
307-
308-
if (hasModifier || precisionSticky) {
309-
if (!precision.active) {
310-
precision.active = true
311-
wheelAccelRef.current = initWheelAccelForHost()
312-
}
313-
314-
precision.lastEventAtMs = now
299+
const precision = computePrecisionWheelStep(precisionWheelRef.current, dir, hasModifier, now)
315300

316-
if (dir === precision.dir && now - precision.lastScrollAtMs < PRECISION_WHEEL_MIN_GAP_MS) {
317-
return
301+
if (precision.active) {
302+
// Entering precision mode must discard any accelerated wheel state;
303+
// otherwise the next normal wheel event inherits stale momentum.
304+
if (precision.entered) {
305+
wheelAccelRef.current = initWheelAccelForHost()
318306
}
319307

320-
precision.lastScrollAtMs = now
321-
precision.dir = dir
322-
323-
return scrollTranscript(dir * wheelStep)
308+
return precision.rows ? scrollTranscript(dir * wheelStep) : undefined
324309
}
325310

326-
precision.active = false
327-
328311
// 0 = direction-flip bounce deferred; skip the no-op scroll.
329312
const rows = computeWheelStep(wheelAccelRef.current, dir, now)
330313

ui-tui/src/components/appChrome.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
22
import { useStore } from '@nanostores/react'
3-
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
3+
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
44
import unicodeSpinners from 'unicode-animations'
55

66
import { $delegationState } from '../app/delegationStore.js'
@@ -13,7 +13,7 @@ import { fmtDuration } from '../domain/messages.js'
1313
import { stickyPromptFromViewport } from '../domain/viewport.js'
1414
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
1515
import { fmtK } from '../lib/text.js'
16-
import { useViewportSnapshot } from '../lib/viewportStore.js'
16+
import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js'
1717
import type { Theme } from '../theme.js'
1818
import type { Msg, Usage } from '../types.js'
1919

@@ -377,7 +377,8 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }:
377377
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
378378
const [hover, setHover] = useState(false)
379379
const [grab, setGrab] = useState<number | null>(null)
380-
const { scrollHeight: total, top: pos, viewportHeight: vp } = useViewportSnapshot(scrollRef)
380+
const grabRef = useRef<number | null>(null)
381+
const { scrollHeight: total, top: pos, viewportHeight: vp } = useScrollbarSnapshot(scrollRef)
381382

382383
if (!vp) {
383384
return <Box width={1} />
@@ -405,15 +406,20 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
405406
onMouseDown={(e: { localRow?: number }) => {
406407
const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0))
407408
const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2)
409+
410+
grabRef.current = off
408411
setGrab(off)
409412
jump(row, off)
410413
}}
411414
onMouseDrag={(e: { localRow?: number }) =>
412-
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2))
415+
jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grabRef.current ?? Math.floor(thumb / 2))
413416
}
414417
onMouseEnter={() => setHover(true)}
415418
onMouseLeave={() => setHover(false)}
416-
onMouseUp={() => setGrab(null)}
419+
onMouseUp={() => {
420+
grabRef.current = null
421+
setGrab(null)
422+
}}
417423
width={1}
418424
>
419425
{!scrollable ? (

ui-tui/src/lib/precisionWheel.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
const PRECISION_WHEEL_FRAME_MS = 16
2+
const PRECISION_WHEEL_STICKY_MS = 80
3+
4+
export type PrecisionWheelState = {
5+
active: boolean
6+
dir: 0 | -1 | 1
7+
lastEventAtMs: number
8+
lastScrollAtMs: number
9+
}
10+
11+
export type PrecisionWheelStep = {
12+
active: boolean
13+
entered: boolean
14+
rows: 0 | 1
15+
}
16+
17+
export function initPrecisionWheel(): PrecisionWheelState {
18+
return { active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 }
19+
}
20+
21+
export function computePrecisionWheelStep(
22+
state: PrecisionWheelState,
23+
dir: -1 | 1,
24+
hasModifier: boolean,
25+
now: number
26+
): PrecisionWheelStep {
27+
const active = hasModifier || now - state.lastEventAtMs < PRECISION_WHEEL_STICKY_MS
28+
29+
if (!active) {
30+
state.active = false
31+
32+
return { active: false, entered: false, rows: 0 }
33+
}
34+
35+
const entered = !state.active
36+
37+
state.active = true
38+
state.lastEventAtMs = now
39+
40+
if (dir === state.dir && now - state.lastScrollAtMs < PRECISION_WHEEL_FRAME_MS) {
41+
return { active: true, entered, rows: 0 }
42+
}
43+
44+
state.dir = dir
45+
state.lastScrollAtMs = now
46+
47+
return { active: true, entered, rows: 1 }
48+
}

ui-tui/src/lib/viewportStore.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ export interface ViewportSnapshot {
1111
viewportHeight: number
1212
}
1313

14+
export interface ScrollbarSnapshot {
15+
scrollHeight: number
16+
top: number
17+
viewportHeight: number
18+
}
19+
1420
const EMPTY: ViewportSnapshot = {
1521
atBottom: true,
1622
bottom: 0,
@@ -20,6 +26,12 @@ const EMPTY: ViewportSnapshot = {
2026
viewportHeight: 0
2127
}
2228

29+
const EMPTY_SCROLLBAR: ScrollbarSnapshot = {
30+
scrollHeight: 0,
31+
top: 0,
32+
viewportHeight: 0
33+
}
34+
2335
export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot {
2436
if (!s) {
2537
return EMPTY
@@ -52,6 +64,26 @@ export function viewportSnapshotKey(v: ViewportSnapshot) {
5264
return `${v.atBottom ? 1 : 0}:${Math.ceil(v.top / 8) * 8}:${v.viewportHeight}:${Math.ceil(v.scrollHeight / 8) * 8}:${v.pending}`
5365
}
5466

67+
export function getScrollbarSnapshot(s?: ScrollBoxHandle | null): ScrollbarSnapshot {
68+
if (!s) {
69+
return EMPTY_SCROLLBAR
70+
}
71+
72+
const viewportHeight = Math.max(0, s.getViewportHeight())
73+
const scrollHeight = Math.max(viewportHeight, s.getScrollHeight())
74+
const maxTop = Math.max(0, scrollHeight - viewportHeight)
75+
76+
return {
77+
scrollHeight,
78+
top: Math.max(0, Math.min(maxTop, s.getScrollTop())),
79+
viewportHeight
80+
}
81+
}
82+
83+
export function scrollbarSnapshotKey(v: ScrollbarSnapshot) {
84+
return `${v.top}:${v.viewportHeight}:${v.scrollHeight}`
85+
}
86+
5587
export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ViewportSnapshot {
5688
const key = useSyncExternalStore(
5789
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
@@ -72,3 +104,21 @@ export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>
72104
}
73105
}, [key])
74106
}
107+
108+
export function useScrollbarSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ScrollbarSnapshot {
109+
const key = useSyncExternalStore(
110+
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
111+
() => scrollbarSnapshotKey(getScrollbarSnapshot(scrollRef.current)),
112+
() => scrollbarSnapshotKey(EMPTY_SCROLLBAR)
113+
)
114+
115+
return useMemo(() => {
116+
const [top = '0', viewportHeight = '0', scrollHeight = '0'] = key.split(':')
117+
118+
return {
119+
scrollHeight: Number(scrollHeight),
120+
top: Number(top),
121+
viewportHeight: Number(viewportHeight)
122+
}
123+
}, [key])
124+
}

0 commit comments

Comments
 (0)