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

@ -30,14 +30,18 @@ const TranscriptPane = memo(function TranscriptPane({
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
const ui = useStore($uiState)
// Index of the latest user message — LiveTodoPanel is rendered as a child
// of that row so it visually belongs to the user's prompt and follows it
// during scroll. Falls back to -1 when no user message exists yet (empty
// session); LiveTodoPanel then doesn't render at all.
// LiveTodoPanel rides as a child of the latest user-message row so it
// visually belongs to the prompt and follows it during scroll. -1 when
// empty → row.index === -1 is always false → no render.
const lastUserIdx = useMemo(() => {
for (let i = transcript.historyItems.length - 1; i >= 0; i--) {
if (transcript.historyItems[i].role === 'user') return i
const items = transcript.historyItems
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].role === 'user') {
return i
}
}
return -1
}, [transcript.historyItems])
@ -259,18 +263,9 @@ export const AppLayout = memo(function AppLayout({
}: AppLayoutProps) {
const overlay = useStore($overlayState)
// Inline mode: skip <AlternateScreen> so the TUI renders into the
// primary buffer and the terminal's native scrollback can capture rows
// that scroll off the top. Mouse tracking is still enabled via
// AlternateScreen when the wrapper is on; in inline mode we leave it
// to the host terminal, which typically does wheel → scrollback.
//
// `Fragment` (via alias so the JSX stays legible) drops the alt-screen
// constraint while keeping the inner layout identical. Content height
// will then follow flex-column growth, which means the ScrollBox below
// grows beyond the viewport — the terminal's primary buffer scrolls
// old rows off the top into native scrollback. Composer + progress
// stay at the bottom via normal flow (they're the last siblings).
// Inline mode skips AlternateScreen so the host terminal's native
// scrollback captures rows scrolled off the top; composer + progress
// stay anchored via normal flex-column flow.
const Shell = INLINE_MODE ? Fragment : AlternateScreen
const shellProps = INLINE_MODE ? {} : { mouseTracking }
@ -305,9 +300,6 @@ export const AppLayout = memo(function AppLayout({
<ComposerPane actions={actions} composer={composer} status={status} />
</PerfPane>
{/* FPS counter overlay: pinned to the bottom row, right
aligned, gated on HERMES_TUI_FPS. Returns null + skips
this subtree when disabled (zero cost). */}
{SHOW_FPS && (
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
<FpsOverlay />

View file

@ -1,10 +1,4 @@
// FPS counter overlay — renders in the bottom-right corner when
// HERMES_TUI_FPS=1. Zero-cost when disabled (returns null at the
// top of the component; React skips the whole subtree).
//
// Subscribes to $fpsState via nanostores. The store is only updated
// when the env flag is on (trackFrame is undefined otherwise), so we
// also gate the subscription on SHOW_FPS to avoid a useless listener.
// FPS counter overlay (HERMES_TUI_FPS=1). Zero-cost when disabled.
import { Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
@ -12,17 +6,7 @@ import { useStore } from '@nanostores/react'
import { SHOW_FPS } from '../config/env.js'
import { $fpsState } from '../lib/fpsStore.js'
const fpsColor = (fps: number) => {
if (fps >= 50) {
return 'green'
}
if (fps >= 30) {
return 'yellow'
}
return 'red'
}
const fpsColor = (fps: number) => (fps >= 50 ? 'green' : fps >= 30 ? 'yellow' : 'red')
export function FpsOverlay() {
if (!SHOW_FPS) {
@ -35,14 +19,10 @@ export function FpsOverlay() {
function FpsOverlayInner() {
const { fps, lastDurationMs, totalFrames } = useStore($fpsState)
// Zero-pad to stable width so the corner doesn't jitter as digits
// come and go. Format: " 62fps 0.3ms #12345"
const fpsStr = fps.toFixed(1).padStart(5)
const durStr = lastDurationMs.toFixed(1).padStart(5)
// Zero-pad widths so digit churn doesn't jitter the corner.
return (
<Text color={fpsColor(fps)}>
{fpsStr}fps · {durStr}ms · #{totalFrames}
{fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames}
</Text>
)
}

View file

@ -1,5 +1,5 @@
import { Box, Link, Text } from '@hermes/ink'
import { memo, useMemo, type ReactNode } from 'react'
import { memo, type ReactNode, useMemo } from 'react'
import { ensureEmojiPresentation } from '../lib/emoji.js'
import { highlightLine, isHighlightable } from '../lib/syntax.js'
@ -213,26 +213,23 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
}
// Cross-instance parsed-children cache. `Md` is mounted fresh whenever a
// virtualized row enters the mount window — useMemo's per-instance cache
// doesn't survive remounts, so PageUp into cold/resumed history reparses
// every row (markdown scan + per-line syntax highlight).
//
// Outer WeakMap keyed by theme so palette swaps drop stale baked-in colors
// without code intervention. Inner Map is LRU-bounded; key folds `compact`
// in so the two layout modes don't poison each other.
// Cross-instance parsed-children cache: useMemo's per-instance cache dies
// on remount, so virtualization re-parses every row that scrolls back into
// view. Theme-keyed WeakMap drops stale palettes; inner Map is LRU-bounded.
const MD_CACHE_LIMIT = 512
const mdCache = new WeakMap<Theme, Map<string, ReactNode[]>>()
const cacheBucket = (t: Theme) => {
let b = mdCache.get(t)
const b = mdCache.get(t)
if (!b) {
b = new Map()
mdCache.set(t, b)
if (b) {
return b
}
return b
const fresh = new Map<string, ReactNode[]>()
mdCache.set(t, fresh)
return fresh
}
const cacheGet = (b: Map<string, ReactNode[]>, key: string) => {