diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts index 6536bddb02..94d1059872 100644 --- a/ui-tui/packages/hermes-ink/index.d.ts +++ b/ui-tui/packages/hermes-ink/index.d.ts @@ -4,6 +4,8 @@ export type { StderrHandle } from './src/hooks/use-stderr.ts' export { default as useStdout } from './src/hooks/use-stdout.ts' export type { StdoutHandle } from './src/hooks/use-stdout.ts' export { Ansi } from './src/ink/Ansi.tsx' +export { evictInkCaches, inkCacheSizes } from './src/ink/cache-eviction.ts' +export type { EvictLevel, InkCacheSizes } from './src/ink/cache-eviction.ts' export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx' export { default as Box } from './src/ink/components/Box.tsx' export type { Props as BoxProps } from './src/ink/components/Box.tsx' diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 52f81ac7b1..2b74f6c775 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -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' diff --git a/ui-tui/packages/hermes-ink/src/ink/cache-eviction.ts b/ui-tui/packages/hermes-ink/src/ink/cache-eviction.ts new file mode 100644 index 0000000000..12e1ca0284 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/cache-eviction.ts @@ -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 +} diff --git a/ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts b/ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts index 0791fbb8a6..2ca47f12ef 100644 --- a/ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts +++ b/ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts @@ -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!) + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts b/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts index 7c852f5a88..840c11f7bf 100644 --- a/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts +++ b/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts @@ -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!) + } +} 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 e27a40c755..d993a1d4f7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts +++ b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts @@ -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!) + } +} diff --git a/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts b/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts index bfb17fbef7..c38c40b3cf 100644 --- a/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts +++ b/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts @@ -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[] = [] diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index b475533a26..3b00a19be0 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -1,6 +1,6 @@ import { writeFileSync } from 'node:fs' -import type { ScrollBoxHandle } from '@hermes/ink' +import { evictInkCaches, type ScrollBoxHandle } from '@hermes/ink' import { type RefObject, useCallback } from 'react' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' @@ -98,6 +98,9 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { setLastUserMsg('') setStickyPrompt('') composerActions.setPasteSnips([]) + // Half-prune Ink content caches: new session has new keys, but a partial + // warm pool helps if the user resumes back to the prior session. + evictInkCaches('half') }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording]) const resetVisibleHistory = useCallback( diff --git a/ui-tui/src/lib/memoryMonitor.ts b/ui-tui/src/lib/memoryMonitor.ts index 6655819b5a..ed185991c1 100644 --- a/ui-tui/src/lib/memoryMonitor.ts +++ b/ui-tui/src/lib/memoryMonitor.ts @@ -1,3 +1,5 @@ +import { evictInkCaches } from '@hermes/ink' + import { type HeapDumpResult, performHeapDump } from './memory.js' export type MemoryLevel = 'critical' | 'high' | 'normal' @@ -39,6 +41,12 @@ export function startMemoryMonitor({ return } + // Defensive eviction: prune Ink content caches before dumping/exiting. + // 'high' = half-prune (still warm enough to recover quickly); + // 'critical' = full drop. Reduces post-dump RSS and gives the user a + // chance to keep running rather than auto-restart. + evictInkCaches(level === 'critical' ? 'all' : 'half') + dumped.add(level) const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null) diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 62b9454687..769e7e9f19 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -124,6 +124,16 @@ declare module '@hermes/ink' { export const scrollFastPathStats: ScrollFastPathStats export function resetScrollFastPathStats(): void + export type EvictLevel = 'all' | 'half' + export type InkCacheSizes = { + readonly lineWidth: number + readonly slice: number + readonly width: number + readonly wrap: number + } + export function evictInkCaches(level?: EvictLevel): InkCacheSizes + export function inkCacheSizes(): InkCacheSizes + export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance export function useApp(): { readonly exit: (error?: Error) => void }