mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
perf(tui): stabilize long-session scrolling
This commit is contained in:
parent
59b56d445c
commit
db4e4acca0
10 changed files with 195 additions and 105 deletions
|
|
@ -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)
|
||||
|
|
|
|||
31
ui-tui/src/__tests__/viewportStore.test.ts
Normal file
31
ui-tui/src/__tests__/viewportStore.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
62
ui-tui/src/lib/inputMetrics.ts
Normal file
62
ui-tui/src/lib/inputMetrics.ts
Normal 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)
|
||||
}
|
||||
59
ui-tui/src/lib/viewportStore.ts
Normal file
59
ui-tui/src/lib/viewportStore.ts
Normal 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)
|
||||
}
|
||||
1
ui-tui/src/types/hermes-ink.d.ts
vendored
1
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue