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:
Brooklyn Nicholson 2026-04-26 19:28:09 -05:00
parent 85e9a23efb
commit c370e2e1e5
14 changed files with 450 additions and 42 deletions

View file

@ -467,9 +467,21 @@ export default class Output {
if (clipHorizontally) { if (clipHorizontally) {
lines = lines.map(line => { lines = lines.map(line => {
const from = x < clip.x1! ? clip.x1! - x : 0 const startsBefore = x < clip.x1!
const width = stringWidth(line) 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) let sliced = sliceAnsi(line, from, to)
// Wide chars (CJK, emoji) occupy 2 cells. When `to` lands // Wide chars (CJK, emoji) occupy 2 cells. When `to` lands

View file

@ -270,6 +270,58 @@ const bunStringWidth = typeof Bun !== 'undefined' && typeof Bun.stringWidth ===
const BUN_STRING_WIDTH_OPTS = { ambiguousIsNarrow: true } as const 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) ? str => bunStringWidth(str, BUN_STRING_WIDTH_OPTS)
: stringWidthJavaScript : 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
}

View file

@ -6,6 +6,40 @@ import { wrapAnsi } from './wrapAnsi.js'
const ELLIPSIS = '…' 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 // 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. // end-1 with width 2 overshoots by 1). Retry with a tighter bound once.
function sliceFit(text: string, start: number, end: number): string { 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 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') { if (wrapType === 'wrap') {
return wrapAnsi(text, maxWidth, { return wrapAnsi(text, maxWidth, { trim: false, hard: true })
trim: false,
hard: true
})
} }
if (wrapType === 'wrap-char') { if (wrapType === 'wrap-char') {
@ -55,25 +86,24 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style
} }
if (wrapType === 'wrap-trim') { if (wrapType === 'wrap-trim') {
return wrapAnsi(text, maxWidth, { return wrapAnsi(text, maxWidth, { trim: true, hard: true })
trim: true,
hard: true
})
} }
if (wrapType!.startsWith('truncate')) { if (wrapType!.startsWith('truncate')) {
let position: 'end' | 'middle' | 'start' = 'end' const position: 'end' | 'middle' | 'start' =
wrapType === 'truncate-middle' ? 'middle' : wrapType === 'truncate-start' ? 'start' : 'end'
if (wrapType === 'truncate-middle') {
position = 'middle'
}
if (wrapType === 'truncate-start') {
position = 'start'
}
return truncate(text, maxWidth, position) return truncate(text, maxWidth, position)
} }
return text 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)
}

View file

@ -10,7 +10,42 @@ function filterStartCodes(codes: AnsiCode[]): AnsiCode[] {
return codes.filter(c => !isEndCode(c)) 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 { 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) const tokens = tokenize(str)
let activeCodes: AnsiCode[] = [] let activeCodes: AnsiCode[] = []
let position = 0 let position = 0

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
boundedHistoryRenderText,
boundedLiveRenderText, boundedLiveRenderText,
buildToolTrailLine, buildToolTrailLine,
edgePreview, 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', () => { describe('edgePreview', () => {
it('keeps both ends for long text', () => { it('keeps both ends for long text', () => {
expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe( expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe(

View 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 })
)
})
})

View file

@ -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 { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { STARTUP_RESUME_ID } from '../config/env.js' 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 { SECTION_NAMES, sectionMode } from '../domain/details.js'
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js' import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
import { fmtCwdBranch, shortCwd } from '../domain/paths.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 { terminalParityHints } from '../lib/terminalParity.js'
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
import { getViewportSnapshot } from '../lib/viewportStore.js' import { getViewportSnapshot } from '../lib/viewportStore.js'
import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js'
import type { Msg, PanelSection, SlashCatalog } from '../types.js' import type { Msg, PanelSection, SlashCatalog } from '../types.js'
import { createGatewayEventHandler } from './createGatewayEventHandler.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 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_ON = '\x1b[?2004h'
const BRACKET_PASTE_OFF = '\x1b[?2004l' const BRACKET_PASTE_OFF = '\x1b[?2004l'
const MAX_HEIGHT_CACHE_BUCKETS = 12
const capHistory = (items: Msg[]): Msg[] => { const capHistory = (items: Msg[]): Msg[] => {
if (items.length <= MAX_HISTORY) { if (items.length <= MAX_HISTORY) {
@ -132,7 +134,7 @@ export function useMainApp(gw: GatewayClient) {
const historyItemsRef = useRef(historyItems) const historyItemsRef = useRef(historyItems)
const lastUserMsgRef = useRef(lastUserMsg) const lastUserMsgRef = useRef(lastUserMsg)
const msgIdsRef = useRef(new WeakMap<Msg, string>()) const msgIdsRef = useRef(new WeakMap<Msg, string>())
const nextMsgIdRef = useRef(0) const heightCachesRef = useRef(new Map<string, Map<string, number>>())
colsRef.current = cols colsRef.current = cols
historyItemsRef.current = historyItems historyItemsRef.current = historyItems
@ -179,7 +181,7 @@ export function useMainApp(gw: GatewayClient) {
return hit return hit
} }
const next = `m${++nextMsgIdRef.current}` const next = messageHeightKey(msg)
msgIdsRef.current.set(msg, next) msgIdsRef.current.set(msg, next)
@ -187,11 +189,67 @@ export function useMainApp(gw: GatewayClient) {
}, []) }, [])
const virtualRows = useMemo<TranscriptRow[]>( 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] [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( const scrollWithSelection = useCallback(
(delta: number) => scrollWithSelectionBy(delta, { scrollRef, selection }), (delta: number) => scrollWithSelectionBy(delta, { scrollRef, selection }),

View file

@ -7,6 +7,7 @@ import type { AppLayoutProps } from '../app/interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js' import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js' import { $uiState } from '../app/uiStore.js'
import { INLINE_MODE, SHOW_FPS } from '../config/env.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 { PLACEHOLDER } from '../content/placeholders.js'
import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
import { PerfPane } from '../lib/perfPane.js' import { PerfPane } from '../lib/perfPane.js'
@ -51,6 +52,7 @@ const TranscriptPane = memo(function TranscriptPane({
compact={ui.compact} compact={ui.compact}
detailsMode={ui.detailsMode} detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride} detailsModeCommandOverride={ui.detailsModeCommandOverride}
limitHistoryRender={row.index < transcript.historyItems.length - FULL_RENDER_TAIL_ITEMS}
msg={row.msg} msg={row.msg}
sections={ui.sections} sections={ui.sections}
t={ui.theme} t={ui.theme}

View file

@ -1,5 +1,5 @@
import { Box, Link, Text } from '@hermes/ink' 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 { ensureEmojiPresentation } from '../lib/emoji.js'
import { highlightLine, isHighlightable } from '../lib/syntax.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> 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) { function MdImpl({ compact, t, text }: MdProps) {
const nodes = useMemo(() => { 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 lines = ensureEmojiPresentation(text).split('\n')
const nodes: ReactNode[] = [] const nodes: ReactNode[] = []
@ -615,6 +664,8 @@ function MdImpl({ compact, t, text }: MdProps) {
i++ i++
} }
cacheSet(bucket, cacheKey, nodes)
return nodes return nodes
}, [compact, t, text]) }, [compact, t, text])

View file

@ -5,7 +5,14 @@ import { LONG_MSG } from '../config/limits.js'
import { sectionMode } from '../domain/details.js' import { sectionMode } from '../domain/details.js'
import { userDisplay } from '../domain/messages.js' import { userDisplay } from '../domain/messages.js'
import { ROLE } from '../domain/roles.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 { Theme } from '../theme.js'
import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js' import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js'
@ -20,6 +27,7 @@ export const MessageLine = memo(function MessageLine({
detailsMode = 'collapsed', detailsMode = 'collapsed',
detailsModeCommandOverride = false, detailsModeCommandOverride = false,
isStreaming = false, isStreaming = false,
limitHistoryRender = false,
msg, msg,
sections, sections,
t, t,
@ -107,7 +115,7 @@ export const MessageLine = memo(function MessageLine({
// streamingMarkdown.tsx for the cost model. // streamingMarkdown.tsx for the cost model.
<StreamingMd compact={compact} t={t} text={boundedLiveRenderText(msg.text)} /> <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 detailsMode?: DetailsMode
detailsModeCommandOverride?: boolean detailsModeCommandOverride?: boolean
isStreaming?: boolean isStreaming?: boolean
limitHistoryRender?: boolean
msg: Msg msg: Msg
sections?: SectionVisibility sections?: SectionVisibility
t: Theme t: Theme

View file

@ -1,6 +1,16 @@
export const LARGE_PASTE = { chars: 8000, lines: 80 } export const LARGE_PASTE = { chars: 8000, lines: 80 }
export const LIVE_RENDER_MAX_CHARS = 16_000 export const LIVE_RENDER_MAX_CHARS = 16_000
export const LIVE_RENDER_MAX_LINES = 240 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 LONG_MSG = 300
export const MAX_HISTORY = 800 export const MAX_HISTORY = 800
export const THINKING_COT_MAX = 160 export const THINKING_COT_MAX = 160

View file

@ -1,19 +1,29 @@
import type { ScrollBoxHandle } from '@hermes/ink' import type { ScrollBoxHandle } from '@hermes/ink'
import { import {
type RefObject,
useCallback, useCallback,
useDeferredValue, useDeferredValue,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
useSyncExternalStore useSyncExternalStore,
type RefObject
} from 'react' } from 'react'
const ESTIMATE = 4 const ESTIMATE = 4
const OVERSCAN = 40 // Overscan was 40 (= viewport) which is way more than needed when heights
const MAX_MOUNTED = 260 // are well-estimated. Cutting in half saves ~20 mounted items per scroll
const COLD_START = 40 // 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 // Floor on unmeasured row height used when computing coverage — guarantees
// the mounted span physically reaches the viewport bottom regardless of how // the mounted span physically reaches the viewport bottom regardless of how
// small items actually are (at the cost of over-mounting when items are // 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 // a single PageUp into unmeasured territory mounts ~190 rows with
// PESSIMISTIC=1 coverage — each row running marked lexer + syntax // PESSIMISTIC=1 coverage — each row running marked lexer + syntax
// highlighting for ~3ms = ~600ms sync block. Sliding toward the target // highlighting for ~3ms = ~600ms sync block. Sliding toward the target
// over several commits keeps per-commit mount cost bounded. // over several commits keeps per-commit mount cost bounded. Tightened
const SLIDE_STEP = 25 // 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 = () => {} const NOOP = () => {}
@ -70,15 +82,19 @@ export function useVirtualHistory(
columns: number, columns: number,
{ {
estimate = ESTIMATE, estimate = ESTIMATE,
initialHeights,
liveTailActive = false, liveTailActive = false,
onHeightsChange,
overscan = OVERSCAN, overscan = OVERSCAN,
maxMounted = MAX_MOUNTED, maxMounted = MAX_MOUNTED,
coldStartCount = COLD_START coldStartCount = COLD_START
} = {} }: VirtualHistoryOptions = {}
) { ) {
const nodes = useRef(new Map<string, unknown>()) 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 refs = useRef(new Map<string, (el: unknown) => void>())
const onHeightsChangeRef = useRef(onHeightsChange)
// Bump whenever heightCache mutates so offsets rebuild on next read. // Bump whenever heightCache mutates so offsets rebuild on next read.
// Ref (not state) — checked during render phase, zero extra commits. // Ref (not state) — checked during render phase, zero extra commits.
const offsetVersion = useRef(0) const offsetVersion = useRef(0)
@ -106,6 +122,14 @@ export function useVirtualHistory(
const prevRange = useRef<null | readonly [number, number]>(null) const prevRange = useRef<null | readonly [number, number]>(null)
const freezeRenders = useRef(0) 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) { if (prevColumns.current !== columns && prevColumns.current > 0 && columns > 0) {
const ratio = prevColumns.current / columns const ratio = prevColumns.current / columns
@ -377,6 +401,7 @@ export function useVirtualHistory(
if (h > 0 && heights.current.get(key) !== h) { if (h > 0 && heights.current.get(key) !== h) {
heights.current.set(key, h) heights.current.set(key, h)
offsetVersion.current++ offsetVersion.current++
onHeightsChangeRef.current?.(heights.current)
} }
nodes.current.delete(key) nodes.current.delete(key)
@ -454,6 +479,7 @@ export function useVirtualHistory(
if (dirty) { if (dirty) {
offsetVersion.current++ offsetVersion.current++
onHeightsChangeRef.current?.(heights.current)
} }
}) })
@ -470,3 +496,13 @@ export function useVirtualHistory(
interface MeasuredNode { interface MeasuredNode {
yogaNode?: { getComputedHeight?: () => number } | null 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
}

View file

@ -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 { VERBS } from '../content/verbs.js'
import type { ThinkingMode } from '../types.js' import type { ThinkingMode } from '../types.js'
@ -98,6 +104,17 @@ export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: numb
export const boundedLiveRenderText = ( export const boundedLiveRenderText = (
text: string, text: string,
{ maxChars = LIVE_RENDER_MAX_CHARS, maxLines = LIVE_RENDER_MAX_LINES } = {} { 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) { if (text.length <= maxChars && text.split('\n', maxLines + 1).length <= maxLines) {
return text return text
@ -132,8 +149,8 @@ export const boundedLiveRenderText = (
const label = const label =
omittedLines > 0 omittedLines > 0
? `[showing live tail; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n` ? `[${labelPrefix}; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n`
: `[showing live tail; omitted ${fmtK(omittedChars)} chars]\n` : `[${labelPrefix}; omitted ${fmtK(omittedChars)} chars]\n`
return `${label}${tail}` return `${label}${tail}`
} }

View 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)
}