hermes-agent/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts
Brooklyn Nicholson b1c49d5e73 chore(tui): /clean recent perf work — KISS/DRY pass
24 files, -319 LoC. Behaviour preserved, 369/369 tests green.

- hermes-ink caches: shared lruEvict helper for the four parallel LRU
  caches (stringWidth, wrapText, sliceAnsi, lineWidth); touch-on-read
  stays inlined per cache; tightened output.ts skip-slice fast path.
- wheelAccel: trimmed provenance header, collapsed env parsing, ternary
  dispatch in computeWheelStep.
- perfPane: folded ensureLogDir into once-flag, spread-with-overrides
  for fastPath/phases instead of full rebuilds.
- env: extracted truthy() (used 4×).
- virtualHeights: collapsed user/diff/slash height bumps; trail+todos
  estimate.
- useInputHandlers: scrollIdleTimer cleanup on unmount, ?? undefined
  shorthand.
- useMainApp: dropped dead liveTailVisible IIFE and liveProgress
  indirection.
- appLayout, markdown, messageLine, entry: vertical rhythm, dropped
  narration comments, inlined one-shot vars.
- fix: empty catch blocks → /* best-effort */ for no-empty lint.
2026-04-26 20:38:47 -05:00

106 lines
2.7 KiB
TypeScript

import { type AnsiCode, ansiCodesToString, reduceAnsiCodes, tokenize, undoAnsiCodes } from '@alcalzone/ansi-tokenize'
import { lruEvict } from '../ink/lru.js'
import { stringWidth } from '../ink/stringWidth.js'
function isEndCode(code: AnsiCode): boolean {
return code.code === code.endCode
}
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<string, string>()
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)
}
export function sliceCacheSize(): number {
return sliceCache.size
}
export function evictSliceCache(keepRatio = 0): void {
lruEvict(sliceCache, keepRatio)
}
function computeSlice(str: string, start: number, end?: number): string {
const tokens = tokenize(str)
let activeCodes: AnsiCode[] = []
let position = 0
let result = ''
let include = false
for (const token of tokens) {
const width = token.type === 'ansi' ? 0 : token.fullWidth ? 2 : stringWidth(token.value)
if (end !== undefined && position >= end) {
if (token.type === 'ansi' || width > 0 || !include) {
break
}
}
if (token.type === 'ansi') {
activeCodes.push(token)
if (include) {
result += token.code
}
} else {
if (!include && position >= start) {
if (start > 0 && width === 0) {
continue
}
include = true
activeCodes = filterStartCodes(reduceAnsiCodes(activeCodes))
result = ansiCodesToString(activeCodes)
}
if (include) {
result += token.value
}
position += width
}
}
const activeStartCodes = filterStartCodes(reduceAnsiCodes(activeCodes))
result += ansiCodesToString(undoAnsiCodes(activeStartCodes))
return result
}