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:
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user