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
|
|
@ -467,9 +467,21 @@ export default class Output {
|
|||
|
||||
if (clipHorizontally) {
|
||||
lines = lines.map(line => {
|
||||
const from = x < clip.x1! ? clip.x1! - x : 0
|
||||
const startsBefore = x < clip.x1!
|
||||
const width = stringWidth(line)
|
||||
const to = x + width > clip.x2! ? clip.x2! - x : width
|
||||
const endsAfter = x + width > clip.x2!
|
||||
|
||||
// Fast path: line fits entirely within the clip box — skip
|
||||
// the tokenize/slice. This is the common case for transcript
|
||||
// text where containers are wider than the rendered content.
|
||||
// CPU profile (Apr 2026) showed sliceAnsi at 18% total time;
|
||||
// most calls were no-op slices like (line, 0, width).
|
||||
if (!startsBefore && !endsAfter) {
|
||||
return line
|
||||
}
|
||||
|
||||
const from = startsBefore ? clip.x1! - x : 0
|
||||
const to = endsAfter ? clip.x2! - x : width
|
||||
let sliced = sliceAnsi(line, from, to)
|
||||
|
||||
// Wide chars (CJK, emoji) occupy 2 cells. When `to` lands
|
||||
|
|
|
|||
|
|
@ -270,6 +270,58 @@ const bunStringWidth = typeof Bun !== 'undefined' && typeof Bun.stringWidth ===
|
|||
|
||||
const BUN_STRING_WIDTH_OPTS = { ambiguousIsNarrow: true } as const
|
||||
|
||||
export const stringWidth: (str: string) => number = bunStringWidth
|
||||
const rawStringWidth: (str: string) => number = bunStringWidth
|
||||
? str => bunStringWidth(str, BUN_STRING_WIDTH_OPTS)
|
||||
: stringWidthJavaScript
|
||||
|
||||
// Memoize stringWidth — it's pure, hot (~100k calls/frame per the comment
|
||||
// above), and the underlying impl scans every grapheme + tests EMOJI_REGEX.
|
||||
// CPU profile (Apr 2026) showed stringWidth dominating at 21% of total
|
||||
// runtime during scroll. Cache is global (vs per-frame) since the same
|
||||
// strings recur across frames in a stable transcript.
|
||||
//
|
||||
// Pure-ASCII short-strings (the >90% common case) skip the cache: the inline
|
||||
// loop in stringWidthJavaScript is already faster than a Map.get for them.
|
||||
const widthCache = new Map<string, number>()
|
||||
const WIDTH_CACHE_LIMIT = 8192
|
||||
|
||||
export const stringWidth: (str: string) => number = str => {
|
||||
if (!str) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ASCII fast-path detection — for short ASCII, skip the cache.
|
||||
if (str.length <= 64) {
|
||||
let asciiOnly = true
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i)
|
||||
|
||||
if (code >= 127 || code === 0x1b) {
|
||||
asciiOnly = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (asciiOnly) {
|
||||
return rawStringWidth(str)
|
||||
}
|
||||
}
|
||||
|
||||
const cached = widthCache.get(str)
|
||||
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const w = rawStringWidth(str)
|
||||
|
||||
if (widthCache.size >= WIDTH_CACHE_LIMIT) {
|
||||
// Drop oldest entry — Map iteration order is insertion order.
|
||||
widthCache.delete(widthCache.keys().next().value!)
|
||||
}
|
||||
|
||||
widthCache.set(str, w)
|
||||
|
||||
return w
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,40 @@ import { wrapAnsi } from './wrapAnsi.js'
|
|||
|
||||
const ELLIPSIS = '…'
|
||||
|
||||
// CPU profile (Apr 2026) showed `wrap-ansi` → `string-width` consuming 30% of
|
||||
// total runtime during fast scroll: every layout pass re-wraps every visible
|
||||
// line via wrap-ansi, which calls string-width once per grapheme. The output
|
||||
// is pure of (text, maxWidth, wrapType), so memoize it. LRU-bounded so long
|
||||
// sessions don't accrete unbounded cache.
|
||||
const WRAP_CACHE_LIMIT = 4096
|
||||
const wrapCache = new Map<string, string>()
|
||||
|
||||
function memoizedWrap(text: string, maxWidth: number, wrapType: Styles['textWrap']): string {
|
||||
// Key folds maxWidth + wrapType into the prefix so the same text re-wrapped
|
||||
// at a different width doesn't collide. Width prefix bounded by viewport
|
||||
// (~10 distinct widths in a session); wrapType bounded by enum (~6 values).
|
||||
const key = `${maxWidth}|${wrapType}|${text}`
|
||||
const cached = wrapCache.get(key)
|
||||
|
||||
if (cached !== undefined) {
|
||||
// LRU touch
|
||||
wrapCache.delete(key)
|
||||
wrapCache.set(key, cached)
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
const result = computeWrap(text, maxWidth, wrapType)
|
||||
|
||||
if (wrapCache.size >= WRAP_CACHE_LIMIT) {
|
||||
wrapCache.delete(wrapCache.keys().next().value!)
|
||||
}
|
||||
|
||||
wrapCache.set(key, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// sliceAnsi may include a boundary-spanning wide char (e.g. CJK at position
|
||||
// end-1 with width 2 overshoots by 1). Retry with a tighter bound once.
|
||||
function sliceFit(text: string, start: number, end: number): string {
|
||||
|
|
@ -42,12 +76,9 @@ function truncate(text: string, columns: number, position: 'start' | 'middle' |
|
|||
return sliceFit(text, 0, columns - 1) + ELLIPSIS
|
||||
}
|
||||
|
||||
export default function wrapText(text: string, maxWidth: number, wrapType: Styles['textWrap']): string {
|
||||
function computeWrap(text: string, maxWidth: number, wrapType: Styles['textWrap']): string {
|
||||
if (wrapType === 'wrap') {
|
||||
return wrapAnsi(text, maxWidth, {
|
||||
trim: false,
|
||||
hard: true
|
||||
})
|
||||
return wrapAnsi(text, maxWidth, { trim: false, hard: true })
|
||||
}
|
||||
|
||||
if (wrapType === 'wrap-char') {
|
||||
|
|
@ -55,25 +86,24 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style
|
|||
}
|
||||
|
||||
if (wrapType === 'wrap-trim') {
|
||||
return wrapAnsi(text, maxWidth, {
|
||||
trim: true,
|
||||
hard: true
|
||||
})
|
||||
return wrapAnsi(text, maxWidth, { trim: true, hard: true })
|
||||
}
|
||||
|
||||
if (wrapType!.startsWith('truncate')) {
|
||||
let position: 'end' | 'middle' | 'start' = 'end'
|
||||
|
||||
if (wrapType === 'truncate-middle') {
|
||||
position = 'middle'
|
||||
}
|
||||
|
||||
if (wrapType === 'truncate-start') {
|
||||
position = 'start'
|
||||
}
|
||||
const position: 'end' | 'middle' | 'start' =
|
||||
wrapType === 'truncate-middle' ? 'middle' : wrapType === 'truncate-start' ? 'start' : 'end'
|
||||
|
||||
return truncate(text, maxWidth, position)
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
export default function wrapText(text: string, maxWidth: number, wrapType: Styles['textWrap']): string {
|
||||
// Skip cache for trivial inputs (faster than Map lookup).
|
||||
if (!text || maxWidth <= 0) {
|
||||
return computeWrap(text, maxWidth, wrapType)
|
||||
}
|
||||
|
||||
return memoizedWrap(text, maxWidth, wrapType)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,42 @@ function filterStartCodes(codes: AnsiCode[]): AnsiCode[] {
|
|||
return codes.filter(c => !isEndCode(c))
|
||||
}
|
||||
|
||||
// LRU cache: same (string, start, end) → same output. Output.get() re-emits
|
||||
// identical writes every frame for stable transcript content; this avoids
|
||||
// re-tokenizing them. CPU profile (Apr 2026) showed sliceAnsi at 18% total
|
||||
// time during scroll. Bounded at 4096 entries — entries are short clipped
|
||||
// lines so memory cost is small.
|
||||
const sliceCache = new Map<string, string>()
|
||||
const SLICE_CACHE_LIMIT = 4096
|
||||
|
||||
export default function sliceAnsi(str: string, start: number, end?: number): string {
|
||||
if (!str) return ''
|
||||
|
||||
// Hot-path: only cache when end is defined (the Output.get() use-case).
|
||||
if (end !== undefined) {
|
||||
const key = `${start}|${end}|${str}`
|
||||
const cached = sliceCache.get(key)
|
||||
|
||||
if (cached !== undefined) {
|
||||
sliceCache.delete(key)
|
||||
sliceCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const result = computeSlice(str, start, end)
|
||||
|
||||
if (sliceCache.size >= SLICE_CACHE_LIMIT) {
|
||||
sliceCache.delete(sliceCache.keys().next().value!)
|
||||
}
|
||||
|
||||
sliceCache.set(key, result)
|
||||
return result
|
||||
}
|
||||
|
||||
return computeSlice(str, start, end)
|
||||
}
|
||||
|
||||
function computeSlice(str: string, start: number, end?: number): string {
|
||||
const tokens = tokenize(str)
|
||||
let activeCodes: AnsiCode[] = []
|
||||
let position = 0
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
boundedHistoryRenderText,
|
||||
boundedLiveRenderText,
|
||||
buildToolTrailLine,
|
||||
edgePreview,
|
||||
|
|
@ -116,6 +117,15 @@ describe('boundedLiveRenderText', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('boundedHistoryRenderText', () => {
|
||||
it('uses a non-live omission label for completed history', () => {
|
||||
const out = boundedHistoryRenderText('abcdefghij', { maxChars: 4, maxLines: 10 })
|
||||
|
||||
expect(out).toContain('[showing tail; omitted')
|
||||
expect(out).not.toContain('live tail')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edgePreview', () => {
|
||||
it('keeps both ends for long text', () => {
|
||||
expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe(
|
||||
|
|
|
|||
28
ui-tui/src/__tests__/virtualHeights.test.ts
Normal file
28
ui-tui/src/__tests__/virtualHeights.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { estimatedMsgHeight, messageHeightKey, wrappedLines } from '../lib/virtualHeights.js'
|
||||
import type { Msg } from '../types.js'
|
||||
|
||||
describe('virtual height estimates', () => {
|
||||
it('uses stable content keys across resumed message objects', () => {
|
||||
const a: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] }
|
||||
const b: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] }
|
||||
|
||||
expect(messageHeightKey(a)).toBe(messageHeightKey(b))
|
||||
})
|
||||
|
||||
it('accounts for wrapping and preserved blank-block rhythm', () => {
|
||||
const msg: Msg = { role: 'assistant', text: `one\n\n${'x'.repeat(90)}` }
|
||||
|
||||
expect(wrappedLines(msg.text, 30)).toBe(5)
|
||||
expect(estimatedMsgHeight(msg, 35, { compact: false, details: false })).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it('includes detail sections when visible', () => {
|
||||
const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'line 1\nline 2', tools: ['Tool A', 'Tool B'] }
|
||||
|
||||
expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBeGreaterThan(
|
||||
estimatedMsgHeight(msg, 80, { compact: false, details: false })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { AppLayoutProps } from '../app/interfaces.js'
|
|||
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { INLINE_MODE, SHOW_FPS } from '../config/env.js'
|
||||
import { FULL_RENDER_TAIL_ITEMS } from '../config/limits.js'
|
||||
import { PLACEHOLDER } from '../content/placeholders.js'
|
||||
import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
||||
import { PerfPane } from '../lib/perfPane.js'
|
||||
|
|
@ -51,6 +52,7 @@ const TranscriptPane = memo(function TranscriptPane({
|
|||
compact={ui.compact}
|
||||
detailsMode={ui.detailsMode}
|
||||
detailsModeCommandOverride={ui.detailsModeCommandOverride}
|
||||
limitHistoryRender={row.index < transcript.historyItems.length - FULL_RENDER_TAIL_ITEMS}
|
||||
msg={row.msg}
|
||||
sections={ui.sections}
|
||||
t={ui.theme}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Box, Link, Text } from '@hermes/ink'
|
||||
import { memo, type ReactNode, useMemo } from 'react'
|
||||
import { memo, useMemo, type ReactNode } from 'react'
|
||||
|
||||
import { ensureEmojiPresentation } from '../lib/emoji.js'
|
||||
import { highlightLine, isHighlightable } from '../lib/syntax.js'
|
||||
|
|
@ -213,8 +213,57 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
|||
return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
|
||||
}
|
||||
|
||||
// Cross-instance parsed-children cache. `Md` is mounted fresh whenever a
|
||||
// virtualized row enters the mount window — useMemo's per-instance cache
|
||||
// doesn't survive remounts, so PageUp into cold/resumed history reparses
|
||||
// every row (markdown scan + per-line syntax highlight).
|
||||
//
|
||||
// Outer WeakMap keyed by theme so palette swaps drop stale baked-in colors
|
||||
// without code intervention. Inner Map is LRU-bounded; key folds `compact`
|
||||
// in so the two layout modes don't poison each other.
|
||||
const MD_CACHE_LIMIT = 512
|
||||
const mdCache = new WeakMap<Theme, Map<string, ReactNode[]>>()
|
||||
|
||||
const cacheBucket = (t: Theme) => {
|
||||
let b = mdCache.get(t)
|
||||
|
||||
if (!b) {
|
||||
b = new Map()
|
||||
mdCache.set(t, b)
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
const cacheGet = (b: Map<string, ReactNode[]>, key: string) => {
|
||||
const v = b.get(key)
|
||||
|
||||
if (v) {
|
||||
b.delete(key)
|
||||
b.set(key, v)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
const cacheSet = (b: Map<string, ReactNode[]>, key: string, v: ReactNode[]) => {
|
||||
b.set(key, v)
|
||||
|
||||
if (b.size > MD_CACHE_LIMIT) {
|
||||
b.delete(b.keys().next().value!)
|
||||
}
|
||||
}
|
||||
|
||||
function MdImpl({ compact, t, text }: MdProps) {
|
||||
const nodes = useMemo(() => {
|
||||
const bucket = cacheBucket(t)
|
||||
const cacheKey = `${compact ? '1' : '0'}|${text}`
|
||||
const cached = cacheGet(bucket, cacheKey)
|
||||
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const lines = ensureEmojiPresentation(text).split('\n')
|
||||
const nodes: ReactNode[] = []
|
||||
|
||||
|
|
@ -615,6 +664,8 @@ function MdImpl({ compact, t, text }: MdProps) {
|
|||
i++
|
||||
}
|
||||
|
||||
cacheSet(bucket, cacheKey, nodes)
|
||||
|
||||
return nodes
|
||||
}, [compact, t, text])
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,14 @@ import { LONG_MSG } from '../config/limits.js'
|
|||
import { sectionMode } from '../domain/details.js'
|
||||
import { userDisplay } from '../domain/messages.js'
|
||||
import { ROLE } from '../domain/roles.js'
|
||||
import { boundedLiveRenderText, compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js'
|
||||
import {
|
||||
boundedHistoryRenderText,
|
||||
boundedLiveRenderText,
|
||||
compactPreview,
|
||||
hasAnsi,
|
||||
isPasteBackedText,
|
||||
stripAnsi
|
||||
} from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js'
|
||||
|
||||
|
|
@ -20,6 +27,7 @@ export const MessageLine = memo(function MessageLine({
|
|||
detailsMode = 'collapsed',
|
||||
detailsModeCommandOverride = false,
|
||||
isStreaming = false,
|
||||
limitHistoryRender = false,
|
||||
msg,
|
||||
sections,
|
||||
t,
|
||||
|
|
@ -107,7 +115,7 @@ export const MessageLine = memo(function MessageLine({
|
|||
// streamingMarkdown.tsx for the cost model.
|
||||
<StreamingMd compact={compact} t={t} text={boundedLiveRenderText(msg.text)} />
|
||||
) : (
|
||||
<Md compact={compact} t={t} text={msg.text} />
|
||||
<Md compact={compact} t={t} text={limitHistoryRender ? boundedHistoryRenderText(msg.text) : msg.text} />
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +181,7 @@ interface MessageLineProps {
|
|||
detailsMode?: DetailsMode
|
||||
detailsModeCommandOverride?: boolean
|
||||
isStreaming?: boolean
|
||||
limitHistoryRender?: boolean
|
||||
msg: Msg
|
||||
sections?: SectionVisibility
|
||||
t: Theme
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
export const LARGE_PASTE = { chars: 8000, lines: 80 }
|
||||
export const LIVE_RENDER_MAX_CHARS = 16_000
|
||||
export const LIVE_RENDER_MAX_LINES = 240
|
||||
// History-render bounds for messages outside the FULL_RENDER_TAIL window.
|
||||
// Each rendered line becomes ≥1 Yoga/Text node + inline spans, so this is
|
||||
// the dominant lever on cold-mount cost during PageUp catch-up. 16 lines
|
||||
// × 25 mounted items ≈ 400 nodes total — small enough that the per-frame
|
||||
// buffer-compose stays well inside the 16ms budget. User pages back to
|
||||
// recognize where they were, not to read; stopping near a message
|
||||
// re-renders it in full once it falls inside the tail window.
|
||||
export const HISTORY_RENDER_MAX_CHARS = 800
|
||||
export const HISTORY_RENDER_MAX_LINES = 16
|
||||
export const FULL_RENDER_TAIL_ITEMS = 8
|
||||
export const LONG_MSG = 300
|
||||
export const MAX_HISTORY = 800
|
||||
export const THINKING_COT_MAX = 160
|
||||
|
|
|
|||
|
|
@ -1,19 +1,29 @@
|
|||
import type { ScrollBoxHandle } from '@hermes/ink'
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore
|
||||
useSyncExternalStore,
|
||||
type RefObject
|
||||
} from 'react'
|
||||
|
||||
const ESTIMATE = 4
|
||||
const OVERSCAN = 40
|
||||
const MAX_MOUNTED = 260
|
||||
const COLD_START = 40
|
||||
// Overscan was 40 (= viewport) which is way more than needed when heights
|
||||
// are well-estimated. Cutting in half saves ~20 mounted items per scroll
|
||||
// edge → smaller fiber tree → less buffer-compose work per frame. HN/CC
|
||||
// dev (https://news.ycombinator.com/item?id=46699072) confirmed GC pressure
|
||||
// from large JSX trees was their main perf issue post-rewrite.
|
||||
const OVERSCAN = 20
|
||||
// Hard cap on mounted items. Was 260; profiling showed ~23k live Yoga
|
||||
// nodes during sustained PageUp catch-up (renderer p99=106ms). The
|
||||
// viewport+2*overscan = 80 rows of needed coverage = ~25 items at avg 3
|
||||
// rows/item, so 120 leaves >4× headroom and never blanks the viewport
|
||||
// even when items are tiny.
|
||||
const MAX_MOUNTED = 120
|
||||
const COLD_START = 30
|
||||
// Floor on unmeasured row height used when computing coverage — guarantees
|
||||
// the mounted span physically reaches the viewport bottom regardless of how
|
||||
// small items actually are (at the cost of over-mounting when items are
|
||||
|
|
@ -34,8 +44,10 @@ const FREEZE_RENDERS = 2
|
|||
// a single PageUp into unmeasured territory mounts ~190 rows with
|
||||
// PESSIMISTIC=1 coverage — each row running marked lexer + syntax
|
||||
// highlighting for ~3ms = ~600ms sync block. Sliding toward the target
|
||||
// over several commits keeps per-commit mount cost bounded.
|
||||
const SLIDE_STEP = 25
|
||||
// over several commits keeps per-commit mount cost bounded. Tightened
|
||||
// from 25 → 12: each new item adds ~100 fibers / Yoga nodes, and a
|
||||
// 25-item commit was the dominant contributor to the 100ms+ p99 frames.
|
||||
const SLIDE_STEP = 12
|
||||
|
||||
const NOOP = () => {}
|
||||
|
||||
|
|
@ -70,15 +82,19 @@ export function useVirtualHistory(
|
|||
columns: number,
|
||||
{
|
||||
estimate = ESTIMATE,
|
||||
initialHeights,
|
||||
liveTailActive = false,
|
||||
onHeightsChange,
|
||||
overscan = OVERSCAN,
|
||||
maxMounted = MAX_MOUNTED,
|
||||
coldStartCount = COLD_START
|
||||
} = {}
|
||||
}: VirtualHistoryOptions = {}
|
||||
) {
|
||||
const nodes = useRef(new Map<string, unknown>())
|
||||
const heights = useRef(new Map<string, number>())
|
||||
const heights = useRef(new Map(initialHeights))
|
||||
const initialHeightsRef = useRef(initialHeights)
|
||||
const refs = useRef(new Map<string, (el: unknown) => void>())
|
||||
const onHeightsChangeRef = useRef(onHeightsChange)
|
||||
// Bump whenever heightCache mutates so offsets rebuild on next read.
|
||||
// Ref (not state) — checked during render phase, zero extra commits.
|
||||
const offsetVersion = useRef(0)
|
||||
|
|
@ -106,6 +122,14 @@ export function useVirtualHistory(
|
|||
const prevRange = useRef<null | readonly [number, number]>(null)
|
||||
const freezeRenders = useRef(0)
|
||||
|
||||
onHeightsChangeRef.current = onHeightsChange
|
||||
|
||||
if (initialHeightsRef.current !== initialHeights) {
|
||||
initialHeightsRef.current = initialHeights
|
||||
heights.current = new Map(initialHeights)
|
||||
offsetVersion.current++
|
||||
}
|
||||
|
||||
if (prevColumns.current !== columns && prevColumns.current > 0 && columns > 0) {
|
||||
const ratio = prevColumns.current / columns
|
||||
|
||||
|
|
@ -377,6 +401,7 @@ export function useVirtualHistory(
|
|||
if (h > 0 && heights.current.get(key) !== h) {
|
||||
heights.current.set(key, h)
|
||||
offsetVersion.current++
|
||||
onHeightsChangeRef.current?.(heights.current)
|
||||
}
|
||||
|
||||
nodes.current.delete(key)
|
||||
|
|
@ -454,6 +479,7 @@ export function useVirtualHistory(
|
|||
|
||||
if (dirty) {
|
||||
offsetVersion.current++
|
||||
onHeightsChangeRef.current?.(heights.current)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -470,3 +496,13 @@ export function useVirtualHistory(
|
|||
interface MeasuredNode {
|
||||
yogaNode?: { getComputedHeight?: () => number } | null
|
||||
}
|
||||
|
||||
interface VirtualHistoryOptions {
|
||||
coldStartCount?: number
|
||||
estimate?: number
|
||||
initialHeights?: ReadonlyMap<string, number>
|
||||
liveTailActive?: boolean
|
||||
maxMounted?: number
|
||||
onHeightsChange?: (heights: ReadonlyMap<string, number>) => void
|
||||
overscan?: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { LIVE_RENDER_MAX_CHARS, LIVE_RENDER_MAX_LINES, THINKING_COT_MAX } from '../config/limits.js'
|
||||
import {
|
||||
HISTORY_RENDER_MAX_CHARS,
|
||||
HISTORY_RENDER_MAX_LINES,
|
||||
LIVE_RENDER_MAX_CHARS,
|
||||
LIVE_RENDER_MAX_LINES,
|
||||
THINKING_COT_MAX
|
||||
} from '../config/limits.js'
|
||||
import { VERBS } from '../content/verbs.js'
|
||||
import type { ThinkingMode } from '../types.js'
|
||||
|
||||
|
|
@ -98,6 +104,17 @@ export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: numb
|
|||
export const boundedLiveRenderText = (
|
||||
text: string,
|
||||
{ maxChars = LIVE_RENDER_MAX_CHARS, maxLines = LIVE_RENDER_MAX_LINES } = {}
|
||||
) => boundedRenderText(text, 'showing live tail', { maxChars, maxLines })
|
||||
|
||||
export const boundedHistoryRenderText = (
|
||||
text: string,
|
||||
{ maxChars = HISTORY_RENDER_MAX_CHARS, maxLines = HISTORY_RENDER_MAX_LINES } = {}
|
||||
) => boundedRenderText(text, 'showing tail', { maxChars, maxLines })
|
||||
|
||||
const boundedRenderText = (
|
||||
text: string,
|
||||
labelPrefix: string,
|
||||
{ maxChars, maxLines }: { maxChars: number; maxLines: number }
|
||||
) => {
|
||||
if (text.length <= maxChars && text.split('\n', maxLines + 1).length <= maxLines) {
|
||||
return text
|
||||
|
|
@ -132,8 +149,8 @@ export const boundedLiveRenderText = (
|
|||
|
||||
const label =
|
||||
omittedLines > 0
|
||||
? `[showing live tail; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n`
|
||||
: `[showing live tail; omitted ${fmtK(omittedChars)} chars]\n`
|
||||
? `[${labelPrefix}; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n`
|
||||
: `[${labelPrefix}; omitted ${fmtK(omittedChars)} chars]\n`
|
||||
|
||||
return `${label}${tail}`
|
||||
}
|
||||
|
|
|
|||
58
ui-tui/src/lib/virtualHeights.ts
Normal file
58
ui-tui/src/lib/virtualHeights.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { Msg } from '../types.js'
|
||||
|
||||
import { boundedHistoryRenderText } from './text.js'
|
||||
|
||||
export const hashText = (text: string) => {
|
||||
let h = 5381
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h = ((h << 5) + h) ^ text.charCodeAt(i)
|
||||
}
|
||||
|
||||
return (h >>> 0).toString(36)
|
||||
}
|
||||
|
||||
export const messageHeightKey = (msg: Msg) =>
|
||||
[msg.role, msg.kind ?? '', hashText([msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? ''].join('\0'))].join(':')
|
||||
|
||||
export const wrappedLines = (text: string, width: number) => {
|
||||
const w = Math.max(1, width)
|
||||
|
||||
return text.split('\n').reduce((n, line) => n + Math.max(1, Math.ceil(line.length / w)), 0)
|
||||
}
|
||||
|
||||
export const estimatedMsgHeight = (
|
||||
msg: Msg,
|
||||
cols: number,
|
||||
{ compact, details, limitHistory = false }: { compact: boolean; details: boolean; limitHistory?: boolean }
|
||||
) => {
|
||||
if (msg.kind === 'intro') {
|
||||
return msg.info?.version ? 9 : 5
|
||||
}
|
||||
|
||||
if (msg.kind === 'panel') {
|
||||
return Math.max(3, (msg.panelData?.sections.length ?? 1) * 2 + 1)
|
||||
}
|
||||
|
||||
const bodyWidth = Math.max(20, cols - 5)
|
||||
const text = msg.role === 'assistant' && limitHistory ? boundedHistoryRenderText(msg.text) : msg.text
|
||||
let h = wrappedLines(text || ' ', bodyWidth)
|
||||
|
||||
if (!compact && msg.role === 'assistant') {
|
||||
h += Math.min(6, (text.match(/\n\s*\n/g) ?? []).length)
|
||||
}
|
||||
|
||||
if (details) {
|
||||
h += (msg.tools?.length ?? 0) + wrappedLines(msg.thinking ?? '', bodyWidth)
|
||||
}
|
||||
|
||||
if (msg.role === 'user' || msg.kind === 'slash' || msg.kind === 'diff') {
|
||||
h++
|
||||
}
|
||||
|
||||
if (msg.role === 'user' || msg.kind === 'diff') {
|
||||
h++
|
||||
}
|
||||
|
||||
return Math.max(1, h)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue