mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
perf(tui): debounce resize RPC + column-aware useVirtualHistory
VSCode panel-drag fires 20+ SIGWINCHes/sec, each previously triggering an unthrottled `terminal.resize` gateway RPC and a full transcript re-virtualization with stale per-row height cache. ## Changes ### gateway RPC debounce (ui-tui/src/app/useMainApp.ts) - `terminal.resize` RPC now trailing-debounced at 100 ms. React `cols` state stays synchronous (needed for Yoga / in-process rendering), only the round-trip to Python coalesces. Prevents gateway flood during panel-drag / tmux-pane-resize. ### column-aware useVirtualHistory (ui-tui/src/hooks/useVirtualHistory.ts) - New required `columns` param, plumbed through from useMainApp. - On column change: scale every cached row height by `oldCols/newCols` (Math.max 1, Math.round) instead of clearing. Clearing forces a pessimistic back-walk that mounts ~190 rows at once (viewport + 2x overscan at 1-row estimate), each a fresh marked.lexer + syntax highlight ≈ 3 ms — ~600 ms React commit block. Scaled heights keep the back-walk tight. - `freezeRenders=2`: reuse pre-resize mount range for 2 renders so already-mounted MessageRows keep their warm useMemo results. Without this the first post-resize render would unmount + remount most rows (pessimistic coverage) = visible flash + 150 ms+ freeze. - `skipMeasurement` flag: first post-resize useLayoutEffect would read PRE-resize Yoga heights (Yoga's stored values are still from the frame before this render's calculateLayout with new width) and poison the scaled cache. Skip the measurement loop for that one render; next render's Yoga is correct. ## Validation - tsc `--noEmit` clean - eslint clean on touched files - `vitest run`: 15 files / 102 tests passing The renderer-level resize patterns (sync-dim-capture + microtask- coalesced React commit, atomic BSU/ESU erase-before-paint, mouse- tracking reassert) already live in hermes-ink's own `handleResize`; this patch adds the matching app-layer hygiene.
This commit is contained in:
parent
0785aec444
commit
0078f743e6
2 changed files with 79 additions and 13 deletions
|
|
@ -161,7 +161,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||
[historyItems, messageId]
|
||||
)
|
||||
|
||||
const virtualHistory = useVirtualHistory(scrollRef, virtualRows)
|
||||
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols)
|
||||
|
||||
const scrollWithSelection = useCallback(
|
||||
(delta: number) => {
|
||||
|
|
@ -306,12 +306,26 @@ export function useMainApp(gw: GatewayClient) {
|
|||
return
|
||||
}
|
||||
|
||||
const onResize = () =>
|
||||
rpc<TerminalResizeResponse>('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid })
|
||||
let timer: null | ReturnType<typeof setTimeout> = null
|
||||
|
||||
const onResize = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
void rpc<TerminalResizeResponse>('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid })
|
||||
}, 100)
|
||||
}
|
||||
|
||||
stdout.on('resize', onResize)
|
||||
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
stdout.off('resize', onResize)
|
||||
}
|
||||
}, [rpc, stdout, ui.sid])
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const OVERSCAN = 40
|
|||
const MAX_MOUNTED = 260
|
||||
const COLD_START = 40
|
||||
const QUANTUM = OVERSCAN >> 1
|
||||
const FREEZE_RENDERS = 2
|
||||
|
||||
const upperBound = (arr: number[], target: number) => {
|
||||
let lo = 0,
|
||||
|
|
@ -31,6 +32,7 @@ const upperBound = (arr: number[], target: number) => {
|
|||
export function useVirtualHistory(
|
||||
scrollRef: RefObject<ScrollBoxHandle | null>,
|
||||
items: readonly { key: string }[],
|
||||
columns: number,
|
||||
{ estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {}
|
||||
) {
|
||||
const nodes = useRef(new Map<string, unknown>())
|
||||
|
|
@ -40,6 +42,34 @@ export function useVirtualHistory(
|
|||
const [hasScrollRef, setHasScrollRef] = useState(false)
|
||||
const metrics = useRef({ sticky: true, top: 0, vp: 0 })
|
||||
|
||||
// Resize handling — scale cached heights by oldCols/newCols so post-resize
|
||||
// offsets stay roughly aligned with (still-unknown) real Yoga heights.
|
||||
// Clearing the cache instead would force a pessimistic back-walk that mounts
|
||||
// ~190 rows at once (viewport+overscan at 1-row estimate), each a fresh
|
||||
// marked.lexer + syntax highlight = ~3ms; ~600ms React commit block. Freeze
|
||||
// the mount range for FREEZE_RENDERS so warm useMemo results survive while
|
||||
// the layout effect writes post-resize real heights back into cache.
|
||||
// skipMeasurement prevents that first post-resize useLayoutEffect from
|
||||
// poisoning the cache with pre-resize Yoga values (Yoga's stored heights
|
||||
// are from the frame BEFORE this render's calculateLayout with new width).
|
||||
const prevColumns = useRef(columns)
|
||||
const skipMeasurement = useRef(false)
|
||||
const prevRange = useRef<null | readonly [number, number]>(null)
|
||||
const freezeRenders = useRef(0)
|
||||
|
||||
if (prevColumns.current !== columns && prevColumns.current > 0 && columns > 0) {
|
||||
const ratio = prevColumns.current / columns
|
||||
|
||||
prevColumns.current = columns
|
||||
|
||||
for (const [k, h] of heights.current) {
|
||||
heights.current.set(k, Math.max(1, Math.round(h * ratio)))
|
||||
}
|
||||
|
||||
skipMeasurement.current = true
|
||||
freezeRenders.current = FREEZE_RENDERS
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setHasScrollRef(Boolean(scrollRef.current))
|
||||
}, [scrollRef])
|
||||
|
|
@ -97,10 +127,19 @@ export function useVirtualHistory(
|
|||
const vp = Math.max(0, scrollRef.current?.getViewportHeight() ?? 0)
|
||||
const sticky = scrollRef.current?.isSticky() ?? true
|
||||
|
||||
const frozenRange = freezeRenders.current > 0 ? prevRange.current : null
|
||||
|
||||
let start = 0,
|
||||
end = items.length
|
||||
|
||||
if (items.length > 0) {
|
||||
if (frozenRange) {
|
||||
// Columns just changed. Reuse the pre-resize mount range so already-mounted
|
||||
// MessageRows keep their warm memos (marked.lexer, syntax highlight). Clamp
|
||||
// to n in case messages were removed (/clear, compaction) mid-freeze.
|
||||
;[start, end] = frozenRange
|
||||
start = Math.min(start, items.length)
|
||||
end = Math.min(end, items.length)
|
||||
} else if (items.length > 0) {
|
||||
if (vp <= 0) {
|
||||
start = Math.max(0, items.length - coldStartCount)
|
||||
} else {
|
||||
|
|
@ -113,6 +152,12 @@ export function useVirtualHistory(
|
|||
sticky ? (start = Math.max(0, end - maxMounted)) : (end = Math.min(items.length, start + maxMounted))
|
||||
}
|
||||
|
||||
if (freezeRenders.current > 0) {
|
||||
freezeRenders.current--
|
||||
} else {
|
||||
prevRange.current = [start, end]
|
||||
}
|
||||
|
||||
const measureRef = useCallback((key: string) => {
|
||||
let fn = refs.current.get(key)
|
||||
|
||||
|
|
@ -127,18 +172,25 @@ export function useVirtualHistory(
|
|||
useLayoutEffect(() => {
|
||||
let dirty = false
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const k = items[i]?.key
|
||||
if (skipMeasurement.current) {
|
||||
// First render after a column change — Yoga heights still reflect the
|
||||
// pre-resize layout. Writing them into cache would overwrite the scaled
|
||||
// estimates with stale pre-resize values. Next render's Yoga is correct.
|
||||
skipMeasurement.current = false
|
||||
} else {
|
||||
for (let i = start; i < end; i++) {
|
||||
const k = items[i]?.key
|
||||
|
||||
if (!k) {
|
||||
continue
|
||||
}
|
||||
if (!k) {
|
||||
continue
|
||||
}
|
||||
|
||||
const h = Math.ceil((nodes.current.get(k) as MeasuredNode | undefined)?.yogaNode?.getComputedHeight?.() ?? 0)
|
||||
const h = Math.ceil((nodes.current.get(k) as MeasuredNode | undefined)?.yogaNode?.getComputedHeight?.() ?? 0)
|
||||
|
||||
if (h > 0 && heights.current.get(k) !== h) {
|
||||
heights.current.set(k, h)
|
||||
dirty = true
|
||||
if (h > 0 && heights.current.get(k) !== h) {
|
||||
heights.current.set(k, h)
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue