mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
perf(tui): cache stringWidth/wrapText/sliceAnsi + skip-slice when line fits clip
CPU profile (Apr 2026, real-user scroll on 11k-line session) showed three hot loops in the per-frame render path: Output.get() per-frame walk: 24% total └─ sliceAnsi(line, from, to) per write: 18% total stringWidth(line) chain (cached + JS): 14% total All three were re-doing identical work every frame: same string → same clipped slice → same width. Fixes: 1. Memoize stringWidth (8k-entry LRU) for non-ASCII strings; ASCII fast-path skips the cache (inline scan beats Map.get for short ASCII, the >90% case). String.charCodeAt scan up to 64 chars is cheaper than the regex fallback. 2. Memoize wrapText (4k-entry LRU keyed by maxWidth|wrapType|text) — wrapAnsi is pure and the same content reflows identically every frame. 3. Memoize sliceAnsi (4k-entry LRU keyed by start|end|str) for the end-defined hot path used by Output.get(). 4. Skip the slice entirely in Output.get() when the line already fits the clip box (startsBefore=false && endsAfter=false). Most transcript lines never exceed their container width, and tokenizing them just to slice (line, 0, width) was pure overhead. This single fast-path drops sliceAnsi from 18% → ~0% in the profile. Also tighten virtualization constants (MAX_MOUNTED 260→120, OVERSCAN 40→20, SLIDE_STEP 25→12) and cap historical-message render at 800 chars / 16 lines via HISTORY_RENDER_MAX_*; messages inside the FULL_RENDER_TAIL_ITEMS window still render in full so reading-zone behavior is unchanged. Validation, real-user CPU profile, page-up scroll on 11k-line session: Output.get() self-time: 24% → 0.3% sliceAnsi total: 18% → not in top 25 stringWidth family: 14% → ~3% idle: 60.7% → 77.3% Frame timings (synthetic page-up profile harness): dur p95: ~10ms → 4.87ms dur p99: 25ms+ → 12.80ms yoga p99: ~20ms → 1.87ms The remaining CPU in the profile is Yoga layoutNode + React commit, which is the irreducible work for this UI tree size.
This commit is contained in:
parent
85e9a23efb
commit
c370e2e1e5
14 changed files with 450 additions and 42 deletions
|
|
@ -1,9 +1,9 @@
|
|||
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
|
||||
import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { STARTUP_RESUME_ID } from '../config/env.js'
|
||||
import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
|
||||
import { FULL_RENDER_TAIL_ITEMS, MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js'
|
||||
import { SECTION_NAMES, sectionMode } from '../domain/details.js'
|
||||
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
|
||||
import { fmtCwdBranch, shortCwd } from '../domain/paths.js'
|
||||
|
|
@ -21,6 +21,7 @@ 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 { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js'
|
||||
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
|
||||
|
||||
import { createGatewayEventHandler } from './createGatewayEventHandler.js'
|
||||
|
|
@ -41,6 +42,7 @@ import { useSubmission } from './useSubmission.js'
|
|||
const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i
|
||||
const BRACKET_PASTE_ON = '\x1b[?2004h'
|
||||
const BRACKET_PASTE_OFF = '\x1b[?2004l'
|
||||
const MAX_HEIGHT_CACHE_BUCKETS = 12
|
||||
|
||||
const capHistory = (items: Msg[]): Msg[] => {
|
||||
if (items.length <= MAX_HISTORY) {
|
||||
|
|
@ -132,7 +134,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||
const historyItemsRef = useRef(historyItems)
|
||||
const lastUserMsgRef = useRef(lastUserMsg)
|
||||
const msgIdsRef = useRef(new WeakMap<Msg, string>())
|
||||
const nextMsgIdRef = useRef(0)
|
||||
const heightCachesRef = useRef(new Map<string, Map<string, number>>())
|
||||
|
||||
colsRef.current = cols
|
||||
historyItemsRef.current = historyItems
|
||||
|
|
@ -179,7 +181,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||
return hit
|
||||
}
|
||||
|
||||
const next = `m${++nextMsgIdRef.current}`
|
||||
const next = messageHeightKey(msg)
|
||||
|
||||
msgIdsRef.current.set(msg, next)
|
||||
|
||||
|
|
@ -187,11 +189,67 @@ export function useMainApp(gw: GatewayClient) {
|
|||
}, [])
|
||||
|
||||
const virtualRows = useMemo<TranscriptRow[]>(
|
||||
() => historyItems.map((msg, index) => ({ index, key: messageId(msg), msg })),
|
||||
() => historyItems.map((msg, index) => ({ index, key: `${index}:${messageId(msg)}`, msg })),
|
||||
[historyItems, messageId]
|
||||
)
|
||||
|
||||
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols, { liveTailActive: turnLiveTailActive })
|
||||
const detailsLayoutKey = useMemo(() => {
|
||||
const thinking = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride)
|
||||
const tools = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride)
|
||||
|
||||
return `${thinking}:${tools}`
|
||||
}, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])
|
||||
const detailsVisible = detailsLayoutKey !== 'hidden:hidden'
|
||||
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
|
||||
const heightCache = useMemo(() => {
|
||||
let cache = heightCachesRef.current.get(heightCacheKey)
|
||||
|
||||
if (!cache) {
|
||||
cache = new Map()
|
||||
heightCachesRef.current.set(heightCacheKey, cache)
|
||||
|
||||
if (heightCachesRef.current.size > MAX_HEIGHT_CACHE_BUCKETS) {
|
||||
heightCachesRef.current.delete(heightCachesRef.current.keys().next().value!)
|
||||
}
|
||||
}
|
||||
|
||||
return cache
|
||||
}, [heightCacheKey])
|
||||
const initialHeights = useMemo(() => {
|
||||
const out = new Map<string, number>()
|
||||
|
||||
for (const row of virtualRows) {
|
||||
out.set(
|
||||
row.key,
|
||||
heightCache.get(row.key) ??
|
||||
estimatedMsgHeight(row.msg, cols, {
|
||||
compact: ui.compact,
|
||||
details: detailsVisible,
|
||||
limitHistory: row.index < virtualRows.length - FULL_RENDER_TAIL_ITEMS
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
}, [cols, detailsVisible, heightCache, ui.compact, virtualRows])
|
||||
const syncHeightCache = useCallback(
|
||||
(heights: ReadonlyMap<string, number>) => {
|
||||
for (const row of virtualRows) {
|
||||
const h = heights.get(row.key)
|
||||
|
||||
if (h) {
|
||||
heightCache.set(row.key, h)
|
||||
}
|
||||
}
|
||||
},
|
||||
[heightCache, virtualRows]
|
||||
)
|
||||
|
||||
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols, {
|
||||
initialHeights,
|
||||
liveTailActive: turnLiveTailActive,
|
||||
onHeightsChange: syncHeightCache
|
||||
})
|
||||
|
||||
const scrollWithSelection = useCallback(
|
||||
(delta: number) => scrollWithSelectionBy(delta, { scrollRef, selection }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue