diff --git a/ui-tui/packages/hermes-ink/src/ink/output.ts b/ui-tui/packages/hermes-ink/src/ink/output.ts index f52bf06363..a8cc147ae5 100644 --- a/ui-tui/packages/hermes-ink/src/ink/output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/output.ts @@ -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 diff --git a/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts b/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts index 0b97ac1519..7c852f5a88 100644 --- a/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts +++ b/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts @@ -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() +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 +} diff --git a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts index e8290feac7..e27a40c755 100644 --- a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts +++ b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts @@ -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() + +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) +} diff --git a/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts b/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts index 7be1950b12..bfb17fbef7 100644 --- a/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts +++ b/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts @@ -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() +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 diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index a81baa0fba..92afd1513d 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -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( diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts new file mode 100644 index 0000000000..466d5bc8cc --- /dev/null +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -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 }) + ) + }) +}) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index f3967c96fa..1710757761 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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()) - const nextMsgIdRef = useRef(0) + const heightCachesRef = useRef(new Map>()) 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( - () => 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() + + 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) => { + 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 }), diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 7fe8e156b9..d0fe73de35 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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} diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 3fd1b494ac..281541af99 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -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 {parts.length ? parts : {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>() + +const cacheBucket = (t: Theme) => { + let b = mdCache.get(t) + + if (!b) { + b = new Map() + mdCache.set(t, b) + } + + return b +} + +const cacheGet = (b: Map, key: string) => { + const v = b.get(key) + + if (v) { + b.delete(key) + b.set(key, v) + } + + return v +} + +const cacheSet = (b: Map, 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]) diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index a3d3f5844a..99312b0618 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -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. ) : ( - + ) } @@ -173,6 +181,7 @@ interface MessageLineProps { detailsMode?: DetailsMode detailsModeCommandOverride?: boolean isStreaming?: boolean + limitHistoryRender?: boolean msg: Msg sections?: SectionVisibility t: Theme diff --git a/ui-tui/src/config/limits.ts b/ui-tui/src/config/limits.ts index 889ac4d686..7c024220c4 100644 --- a/ui-tui/src/config/limits.ts +++ b/ui-tui/src/config/limits.ts @@ -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 diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index 656df542ed..d6372289a3 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -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()) - const heights = useRef(new Map()) + const heights = useRef(new Map(initialHeights)) + const initialHeightsRef = useRef(initialHeights) const refs = useRef(new Map 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) 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 + liveTailActive?: boolean + maxMounted?: number + onHeightsChange?: (heights: ReadonlyMap) => void + overscan?: number +} diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 9c9758c3f1..5b2e8c41b6 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -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}` } diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts new file mode 100644 index 0000000000..74583e9dd7 --- /dev/null +++ b/ui-tui/src/lib/virtualHeights.ts @@ -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) +}