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.
This commit is contained in:
Brooklyn Nicholson 2026-04-26 20:38:47 -05:00
parent 527ac351b4
commit b1c49d5e73
32 changed files with 259 additions and 547 deletions

View file

@ -1,12 +1,7 @@
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 { 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'
@ -26,13 +21,9 @@ export { useTabStatus } from './ink/hooks/use-tab-status.js'
export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js'
export { isXtermJs } from './ink/terminal.js'
export { default as measureElement } from './ink/measure-element.js'
export {
resetScrollFastPathStats,
scrollFastPathStats,
type ScrollFastPathStats
} from './ink/render-node-to-output.js'
export { resetScrollFastPathStats, scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
export { createRoot, default as render, renderSync } from './ink/root.js'
export { stringWidth } from './ink/stringWidth.js'
export { isXtermJs } from './ink/terminal.js'
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'

View file

@ -9,12 +9,12 @@
// (not session-keyed), so cross-session sharing is normally beneficial —
// only evict when memory tightens or when the user explicitly resets.
import { evictSliceCache, sliceCacheSize } from '../utils/sliceAnsi.js'
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

View file

@ -979,15 +979,13 @@ export default class Ink {
}
const tWrite = performance.now()
// Capture any stale pending write BEFORE starting this frame's write —
// if the callback already fired, pendingWriteStart is null and lastDrainMs
// already reflects the previous frame's drain. If it hasn't fired, we
// report "still pending" via a non-zero duration based on now-then so
// backpressure shows up even if Node never flushes this session.
const staleDrain =
this.pendingWriteStart !== null
? performance.now() - this.pendingWriteStart
: this.lastDrainMs
const staleDrain = this.pendingWriteStart !== null ? performance.now() - this.pendingWriteStart : this.lastDrainMs
const prevFrameDrainMs = Math.round(staleDrain * 100) / 100
this.lastDrainMs = 0
@ -1016,6 +1014,7 @@ export default class Ink {
}
: undefined
)
const writeMs = performance.now() - tWrite
// Update blit safety for the NEXT frame. The frame just rendered

View file

@ -1,3 +1,4 @@
import { lruEvict } from './lru.js'
import { stringWidth } from './stringWidth.js'
// During streaming, text grows but completed lines are immutable.
@ -13,6 +14,7 @@ export function lineWidth(line: string): number {
if (cached !== undefined) {
cache.delete(line)
cache.set(line, cached)
return cached
}
@ -32,14 +34,5 @@ export function lineWidthCacheSize(): number {
}
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!)
}
lruEvict(cache, keepRatio)
}

View file

@ -0,0 +1,14 @@
// Shared eviction for the hot Ink LRU caches (widthCache, wrapCache,
// sliceCache, lineWidthCache). Hot-path touch-on-read stays inlined per
// cache — only the bulk eviction is factored here.
export function lruEvict<K, V>(cache: Map<K, V>, keepRatio: number): void {
if (keepRatio <= 0) {
return cache.clear()
}
const target = Math.floor(cache.size * keepRatio)
while (cache.size > target) {
cache.delete(cache.keys().next().value!)
}
}

View file

@ -467,15 +467,15 @@ export default class Output {
if (clipHorizontally) {
lines = lines.map(line => {
const startsBefore = x < clip.x1!
const width = stringWidth(line)
const startsBefore = x < clip.x1!
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).
// tokenize/slice. Common case for transcript text where
// containers are wider than rendered content. CPU profile
// (Apr 2026): sliceAnsi at 18% total during scroll, mostly
// no-op (line, 0, width) slices.
if (!startsBefore && !endsAfter) {
return line
}

View file

@ -111,7 +111,6 @@ export function resetScrollFastPathStats(): void {
scrollFastPathStats.lastPrevHeight = undefined
}
export function getScrollHint(): ScrollHint | null {
return scrollHint
}

View file

@ -4,6 +4,8 @@ import stripAnsi from 'strip-ansi'
import { getGraphemeSegmenter } from '../utils/intl.js'
import { lruEvict } from './lru.js'
const EMOJI_REGEX = emojiRegex()
/**
@ -299,6 +301,7 @@ export const stringWidth: (str: string) => number = str => {
if (code >= 127 || code === 0x1b) {
asciiOnly = false
break
}
}
@ -334,14 +337,5 @@ export function widthCacheSize(): number {
}
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!)
}
lruEvict(widthCache, keepRatio)
}

View file

@ -289,9 +289,7 @@ export function writeDiffToTerminal(
// The 2-arg form attaches a drain callback that fires once the chunk
// is actually flushed to the OS socket/pipe — giving us end-to-end
// drain timing, not just "queued in Node".
const wrote = onDrain
? terminal.stdout.write(buffer, () => onDrain())
: terminal.stdout.write(buffer)
const wrote = onDrain ? terminal.stdout.write(buffer, () => onDrain()) : terminal.stdout.write(buffer)
return { bytes: Buffer.byteLength(buffer, 'utf8'), backpressure: !wrote }
}

View file

@ -1,5 +1,6 @@
import sliceAnsi from '../utils/sliceAnsi.js'
import { lruEvict } from './lru.js'
import { stringWidth } from './stringWidth.js'
import type { Styles } from './styles.js'
import { wrapAnsi } from './wrapAnsi.js'
@ -113,14 +114,5 @@ export function wrapCacheSize(): number {
}
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!)
}
lruEvict(wrapCache, keepRatio)
}

View file

@ -1,5 +1,6 @@
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 {
@ -19,7 +20,9 @@ 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 ''
if (!str) {
return ''
}
// Hot-path: only cache when end is defined (the Output.get() use-case).
if (end !== undefined) {
@ -29,6 +32,7 @@ export default function sliceAnsi(str: string, start: number, end?: number): str
if (cached !== undefined) {
sliceCache.delete(key)
sliceCache.set(key, cached)
return cached
}
@ -39,6 +43,7 @@ export default function sliceAnsi(str: string, start: number, end?: number): str
}
sliceCache.set(key, result)
return result
}
@ -50,16 +55,7 @@ export function sliceCacheSize(): number {
}
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!)
}
lruEvict(sliceCache, keepRatio)
}
function computeSlice(str: string, start: number, end?: number): string {