perf(tui): unified Ink cache eviction on memory pressure + session reset

Adds an `evictInkCaches(level)` API that prunes the four hot module-level
caches (`widthCache`, `wrapCache`, `sliceCache`, `lineWidthCache`) with
either a half-keep LRU pass or a full clear. Wired into:

- memoryMonitor: half-prune on 'high', full drop on 'critical', before
  the heap dump / auto-restart path. Gives long sessions a shot at
  recovering RSS instead of hard-exiting.
- useSessionLifecycle.resetSession: half-prune so a /new session starts
  with a half-warm pool and the prior session can resume cheaply.

Also: lineWidthCache now uses LRU half-eviction on overflow instead of a
full `cache.clear()`, matching the other three caches.

Comparison vs claude-code: both forks now share the same `prevScreen`
blit + dirty-cascade machinery in render-node-to-output. Their smoothness
came from sibling-memo discipline (every chrome pane memo'd so dirty
cascade doesn't disable transcript blit) — already in place in our
appLayout.tsx (TranscriptPane / ComposerPane / StatusRulePane all memo'd).
Alt-screen is not the cause; both use it. The remaining gap was per-row
CPU on width/wrap/slice, which the previous commit closed.
This commit is contained in:
Brooklyn Nicholson 2026-04-26 19:41:53 -05:00
parent c370e2e1e5
commit 25767513f2
10 changed files with 147 additions and 4 deletions

View file

@ -1,6 +1,12 @@
export { default as useStderr } from './hooks/use-stderr.js'
export { default as useStdout } from './hooks/use-stdout.js'
export { Ansi } from './ink/Ansi.js'
export {
evictInkCaches,
type EvictLevel,
type InkCacheSizes,
inkCacheSizes
} from './ink/cache-eviction.js'
export { AlternateScreen } from './ink/components/AlternateScreen.js'
export { default as Box } from './ink/components/Box.js'
export { default as Link } from './ink/components/Link.js'

View file

@ -0,0 +1,46 @@
// Unified cache eviction for the four hot Ink module-level caches:
// - widthCache (stringWidth.ts)
// - wrapCache (wrap-text.ts)
// - sliceCache (sliceAnsi.ts)
// - lineWidthCache (line-width-cache.ts)
//
// Used by the host (TUI) under memory pressure or on session swap to drop
// content-keyed entries that won't recur. All caches are content-keyed
// (not session-keyed), so cross-session sharing is normally beneficial —
// only evict when memory tightens or when the user explicitly resets.
import { evictLineWidthCache, lineWidthCacheSize } from './line-width-cache.js'
import { evictWidthCache, widthCacheSize } from './stringWidth.js'
import { evictWrapCache, wrapCacheSize } from './wrap-text.js'
import { evictSliceCache, sliceCacheSize } from '../utils/sliceAnsi.js'
export interface InkCacheSizes {
lineWidth: number
slice: number
width: number
wrap: number
}
export function inkCacheSizes(): InkCacheSizes {
return {
lineWidth: lineWidthCacheSize(),
slice: sliceCacheSize(),
width: widthCacheSize(),
wrap: wrapCacheSize()
}
}
export type EvictLevel = 'all' | 'half'
export function evictInkCaches(level: EvictLevel = 'half'): InkCacheSizes {
const before = inkCacheSizes()
const keep = level === 'half' ? 0.5 : 0
evictWidthCache(keep)
evictWrapCache(keep)
evictSliceCache(keep)
evictLineWidthCache(keep)
return before
}

View file

@ -11,18 +11,35 @@ export function lineWidth(line: string): number {
const cached = cache.get(line)
if (cached !== undefined) {
cache.delete(line)
cache.set(line, cached)
return cached
}
const width = stringWidth(line)
// Evict when cache grows too large (e.g. after many different responses).
// Simple full-clear is fine — the cache repopulates in one frame.
if (cache.size >= MAX_CACHE_SIZE) {
cache.clear()
cache.delete(cache.keys().next().value!)
}
cache.set(line, width)
return width
}
export function lineWidthCacheSize(): number {
return cache.size
}
export function evictLineWidthCache(keepRatio = 0): void {
if (keepRatio <= 0) {
cache.clear()
return
}
const target = Math.floor(cache.size * keepRatio)
while (cache.size > target) {
cache.delete(cache.keys().next().value!)
}
}

View file

@ -325,3 +325,20 @@ export const stringWidth: (str: string) => number = str => {
return w
}
export function widthCacheSize(): number {
return widthCache.size
}
export function evictWidthCache(keepRatio = 0): void {
if (keepRatio <= 0) {
widthCache.clear()
return
}
const target = Math.floor(widthCache.size * keepRatio)
while (widthCache.size > target) {
widthCache.delete(widthCache.keys().next().value!)
}
}

View file

@ -107,3 +107,20 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style
return memoizedWrap(text, maxWidth, wrapType)
}
export function wrapCacheSize(): number {
return wrapCache.size
}
export function evictWrapCache(keepRatio = 0): void {
if (keepRatio <= 0) {
wrapCache.clear()
return
}
const target = Math.floor(wrapCache.size * keepRatio)
while (wrapCache.size > target) {
wrapCache.delete(wrapCache.keys().next().value!)
}
}

View file

@ -45,6 +45,23 @@ export default function sliceAnsi(str: string, start: number, end?: number): str
return computeSlice(str, start, end)
}
export function sliceCacheSize(): number {
return sliceCache.size
}
export function evictSliceCache(keepRatio = 0): void {
if (keepRatio <= 0) {
sliceCache.clear()
return
}
const target = Math.floor(sliceCache.size * keepRatio)
while (sliceCache.size > target) {
sliceCache.delete(sliceCache.keys().next().value!)
}
}
function computeSlice(str: string, start: number, end?: number): string {
const tokens = tokenize(str)
let activeCodes: AnsiCode[] = []