mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
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:
parent
527ac351b4
commit
b1c49d5e73
32 changed files with 259 additions and 547 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
14
ui-tui/packages/hermes-ink/src/ink/lru.ts
Normal file
14
ui-tui/packages/hermes-ink/src/ink/lru.ts
Normal 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!)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,6 @@ export function resetScrollFastPathStats(): void {
|
|||
scrollFastPathStats.lastPrevHeight = undefined
|
||||
}
|
||||
|
||||
|
||||
export function getScrollHint(): ScrollHint | null {
|
||||
return scrollHint
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue