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,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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue