perf(tui): stabilize long-session scrolling

This commit is contained in:
Brooklyn Nicholson 2026-04-26 01:47:05 -05:00
parent 59b56d445c
commit db4e4acca0
10 changed files with 195 additions and 105 deletions

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { cursorLayout, offsetFromPosition } from '../components/textInput.js'
import { offsetFromPosition } from '../components/textInput.js'
import { cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
describe('cursorLayout — char-wrap parity with wrap-ansi', () => {
it('places cursor mid-line at its column', () => {
@ -35,6 +36,18 @@ describe('cursorLayout — char-wrap parity with wrap-ansi', () => {
})
})
describe('input metrics helpers', () => {
it('computes visual height from the wrapped cursor line', () => {
expect(inputVisualHeight('abcdefgh', 8)).toBe(2)
expect(inputVisualHeight('one\ntwo', 40)).toBe(2)
})
it('reserves a stable transcript scrollbar gutter for composer width', () => {
expect(stableComposerColumns(100, 3)).toBe(93)
expect(stableComposerColumns(10, 3)).toBe(20)
})
})
describe('offsetFromPosition — char-wrap inverse of cursorLayout', () => {
it('returns 0 for empty input', () => {
expect(offsetFromPosition('', 0, 0, 10)).toBe(0)

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { getViewportSnapshot, viewportSnapshotKey } from '../lib/viewportStore.js'
describe('viewportStore', () => {
it('normalizes absent scroll handles', () => {
expect(getViewportSnapshot(null)).toEqual({
atBottom: true,
bottom: 0,
pending: 0,
scrollHeight: 0,
top: 0,
viewportHeight: 0
})
})
it('includes pending scroll delta in snapshot math and keying', () => {
const handle = {
getPendingDelta: () => 3,
getScrollHeight: () => 40,
getScrollTop: () => 10,
getViewportHeight: () => 5,
isSticky: () => false
}
const snap = getViewportSnapshot(handle as any)
expect(snap).toMatchObject({ atBottom: false, bottom: 18, pending: 3, scrollHeight: 40, top: 13, viewportHeight: 5 })
expect(viewportSnapshotKey(snap)).toBe('0:13:5:40:3')
})
})

View file

@ -19,6 +19,7 @@ import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js'
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
import { getViewportSnapshot } from '../lib/viewportStore.js'
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
import { createGatewayEventHandler } from './createGatewayEventHandler.js'
@ -689,11 +690,9 @@ export function useMainApp(gw: GatewayClient) {
return true
}
const top = Math.max(0, s.getScrollTop() + s.getPendingDelta())
const vp = Math.max(0, s.getViewportHeight())
const total = Math.max(vp, s.getScrollHeight())
const { bottom, scrollHeight } = getViewportSnapshot(s)
return top + vp >= total - 3
return bottom >= scrollHeight - 3
})()
const liveProgress = useMemo(

View file

@ -1,6 +1,6 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type ReactNode, type RefObject, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react'
import { $delegationState } from '../app/delegationStore.js'
import { $turnState } from '../app/turnStore.js'
@ -9,6 +9,7 @@ import { VERBS } from '../content/verbs.js'
import { fmtDuration } from '../domain/messages.js'
import { stickyPromptFromViewport } from '../domain/viewport.js'
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
import { useViewportSnapshot } from '../lib/viewportStore.js'
import { fmtK } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
@ -255,17 +256,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri
}
export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) {
useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => {
const { atBottom, top } = getStickyViewport(scrollRef.current)
return atBottom ? -1 - top : top
},
() => NaN
)
const { atBottom, bottom, top } = getStickyViewport(scrollRef.current)
const { atBottom, bottom, top } = useViewportSnapshot(scrollRef)
const text = stickyPromptFromViewport(messages, offsets, top, bottom, atBottom)
useEffect(() => onChange(text), [onChange, text])
@ -274,42 +265,18 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }:
}
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => {
const s = scrollRef.current
if (!s) {
return NaN
}
const vp = Math.max(0, s.getViewportHeight())
const total = Math.max(vp, s.getScrollHeight())
const top = Math.max(0, s.getScrollTop() + s.getPendingDelta())
const thumb = total > vp ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const thumbTop = total > vp ? Math.round((top / Math.max(1, total - vp)) * travel) : 0
return `${thumbTop}:${thumb}:${vp}`
},
() => ''
)
const [hover, setHover] = useState(false)
const [grab, setGrab] = useState<number | null>(null)
const s = scrollRef.current
const vp = Math.max(0, s?.getViewportHeight() ?? 0)
const { scrollHeight: total, top: pos, viewportHeight: vp } = useViewportSnapshot(scrollRef)
if (!vp) {
return <Box width={1} />
}
const total = Math.max(vp, s?.getScrollHeight() ?? vp)
const s = scrollRef.current
const scrollable = total > vp
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const thumbColor = grab !== null ? t.color.gold : hover ? t.color.amber : t.color.bronze
const trackColor = hover ? t.color.bronze : t.color.dim
@ -391,15 +358,3 @@ interface TranscriptScrollbarProps {
scrollRef: RefObject<ScrollBoxHandle | null>
t: Theme
}
function getStickyViewport(s?: ScrollBoxHandle | null) {
const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
const vp = Math.max(0, s?.getViewportHeight() ?? 0)
const total = Math.max(vp, s?.getScrollHeight() ?? vp)
return {
atBottom: (s?.isSticky() ?? true) || top + vp >= total - 2,
bottom: top + vp,
top
}
}

View file

@ -7,6 +7,7 @@ import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.j
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
import { PLACEHOLDER } from '../content/placeholders.js'
import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
import type { Theme } from '../theme.js'
import type { DetailsMode, SectionVisibility } from '../types.js'
@ -171,6 +172,8 @@ const ComposerPane = memo(function ComposerPane({
const isBlocked = useStore($isBlocked)
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
const pw = sh ? 2 : 3
const inputColumns = stableComposerColumns(composer.cols, pw)
const inputHeight = inputVisualHeight(composer.input, inputColumns)
return (
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
@ -232,10 +235,10 @@ const ComposerPane = memo(function ComposerPane({
)}
</Box>
<Box flexGrow={1} position="relative">
{/* subtract NoSelect paddingX={1} (2 cols) + pw so wrap-ansi and cursorLayout agree */}
<Box flexGrow={0} flexShrink={0} height={inputHeight} position="relative" width={inputColumns}>
{/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */}
<TextInput
columns={Math.max(20, composer.cols - pw - 2)}
columns={inputColumns}
onChange={composer.updateInput}
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { setInputSelection } from '../app/inputSelectionStore.js'
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
import { cursorLayout } from '../lib/inputMetrics.js'
import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'
type InkExt = typeof Ink & {
@ -167,50 +168,6 @@ export function lineNav(s: string, p: number, dir: -1 | 1): null | number {
return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd))
}
// mirrors wrap-ansi(..., { wordWrap: false, hard: true }) so the declared
// cursor lines up with what <Text wrap="wrap-char"> actually renders
export function cursorLayout(value: string, cursor: number, cols: number) {
const pos = Math.max(0, Math.min(cursor, value.length))
const w = Math.max(1, cols)
let col = 0,
line = 0
for (const { segment, index } of seg().segment(value)) {
if (index >= pos) {
break
}
if (segment === '\n') {
line++
col = 0
continue
}
const sw = stringWidth(segment)
if (!sw) {
continue
}
if (col + sw > w) {
line++
col = 0
}
col += sw
}
// trailing cursor-cell overflows to the next row at the wrap column
if (col >= w) {
line++
col = 0
}
return { column: col, line }
}
export function offsetFromPosition(value: string, row: number, col: number, cols: number) {
if (!value.length) {
return 0

View file

@ -167,8 +167,20 @@ export function useVirtualHistory(
}, [])
useLayoutEffect(() => {
const s = scrollRef.current
let dirty = false
// Give the renderer the mounted-row coverage for passive scroll clamping.
// Without this, burst wheel/page scroll can race past the React commit that
// updates the virtual range and paint spacer-only frames.
if (s && n > 0 && vp > 0) {
const min = offsets[start] ?? 0
const max = Math.max(min, (offsets[end] ?? total) - vp)
s.setClampBounds(min, max)
} else {
s?.setClampBounds(undefined, undefined)
}
if (skipMeasurement.current) {
skipMeasurement.current = false
} else {
@ -188,8 +200,6 @@ export function useVirtualHistory(
}
}
const s = scrollRef.current
if (s) {
const next = {
sticky: s.isSticky(),
@ -210,7 +220,7 @@ export function useVirtualHistory(
if (dirty) {
setVer(v => v + 1)
}
}, [end, hasScrollRef, items, scrollRef, start])
}, [end, hasScrollRef, items, n, offsets, scrollRef, start, total, vp])
return {
bottomSpacer: Math.max(0, total - (offsets[end] ?? total)),

View file

@ -0,0 +1,62 @@
import { stringWidth } from '@hermes/ink'
let _seg: Intl.Segmenter | null = null
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
/**
* Mirrors the char-wrap behavior used by the composer TextInput.
* Returns the zero-based visual line and column of the cursor cell.
*/
export function cursorLayout(value: string, cursor: number, cols: number) {
const pos = Math.max(0, Math.min(cursor, value.length))
const w = Math.max(1, cols)
let col = 0,
line = 0
for (const { segment, index } of seg().segment(value)) {
if (index >= pos) {
break
}
if (segment === '\n') {
line++
col = 0
continue
}
const sw = stringWidth(segment)
if (!sw) {
continue
}
if (col + sw > w) {
line++
col = 0
}
col += sw
}
// trailing cursor-cell overflows to the next row at the wrap column
if (col >= w) {
line++
col = 0
}
return { column: col, line }
}
export function inputVisualHeight(value: string, columns: number) {
return cursorLayout(value, value.length, columns).line + 1
}
export function stableComposerColumns(totalCols: number, promptWidth: number) {
// totalCols is the terminal width. Reserve:
// - outer composer paddingX={1}: 2 columns
// - transcript scrollbar gutter + marginLeft: 2 columns
// - prompt prefix width
return Math.max(20, totalCols - promptWidth - 4)
}

View file

@ -0,0 +1,59 @@
import type { RefObject } from 'react'
import { useCallback, useSyncExternalStore } from 'react'
import type { ScrollBoxHandle } from '@hermes/ink'
export interface ViewportSnapshot {
atBottom: boolean
bottom: number
pending: number
scrollHeight: number
top: number
viewportHeight: number
}
const EMPTY: ViewportSnapshot = {
atBottom: true,
bottom: 0,
pending: 0,
scrollHeight: 0,
top: 0,
viewportHeight: 0
}
export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot {
if (!s) {
return EMPTY
}
const pending = s.getPendingDelta()
const top = Math.max(0, s.getScrollTop() + pending)
const viewportHeight = Math.max(0, s.getViewportHeight())
const scrollHeight = Math.max(viewportHeight, s.getScrollHeight())
const bottom = top + viewportHeight
return {
atBottom: s.isSticky() || bottom >= scrollHeight - 2,
bottom,
pending,
scrollHeight,
top,
viewportHeight
}
}
export function viewportSnapshotKey(v: ViewportSnapshot) {
return `${v.atBottom ? 1 : 0}:${v.top}:${v.viewportHeight}:${v.scrollHeight}:${v.pending}`
}
export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ViewportSnapshot {
const key = useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => viewportSnapshotKey(getViewportSnapshot(scrollRef.current)),
() => viewportSnapshotKey(EMPTY)
)
void key
return getViewportSnapshot(scrollRef.current)
}

View file

@ -59,6 +59,7 @@ declare module '@hermes/ink' {
readonly getViewportTop: () => number
readonly isSticky: () => boolean
readonly subscribe: (listener: () => void) => () => void
readonly setClampBounds: (min: number | undefined, max: number | undefined) => void
}
export const Box: React.ComponentType<any>