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

@ -50,6 +50,7 @@ export const archiveTodosAtTurnEnd = () => {
}
const done = isTodoDone(state.todos)
const msg: Msg = {
kind: 'trail',
role: 'system',

View file

@ -29,35 +29,19 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const overlay = useStore($overlayState)
const isBlocked = useStore($isBlocked)
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
const scrollIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const scrollIdleTimer = useRef<null | ReturnType<typeof setTimeout>>(null)
// Wheel acceleration state machine (ported from claude-code). Adapts
// step size per wheel event based on inter-event timing: fast flicks
// ramp up, slow clicks stay at 1 row, direction flips reset. See
// lib/wheelAccel.ts for the full tuning rationale. The accel state
// mutates in place and is kept across renders via a ref. wheelStep
// (passed from useMainApp / the WHEEL_SCROLL_STEP constant) is used
// as the BASE — final rows = wheelStep × accelMult.
// Wheel accel ported from claude-code: inter-event timing drives step size,
// direction flips reset. wheelStep (WHEEL_SCROLL_STEP) is the base; final
// rows = wheelStep × accelMult. State mutates in place across renders.
const wheelAccelRef = useRef(initWheelAccelForHost())
useEffect(
() => () => {
if (scrollIdleTimer.current) {
clearTimeout(scrollIdleTimer.current)
scrollIdleTimer.current = null
}
},
[]
)
useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), [])
const scrollTranscript = (delta: number) => {
if (getUiState().busy) {
turnController.boostStreamingForScroll()
if (scrollIdleTimer.current) {
clearTimeout(scrollIdleTimer.current)
}
clearTimeout(scrollIdleTimer.current ?? undefined)
scrollIdleTimer.current = setTimeout(() => {
scrollIdleTimer.current = null
turnController.relaxStreaming()
@ -300,16 +284,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (key.wheelUp || key.wheelDown) {
const dir: -1 | 1 = key.wheelUp ? -1 : 1
const accelRows = computeWheelStep(wheelAccelRef.current, dir, Date.now())
// 0 = direction-flip bounce deferred; skip the no-op scroll.
const rows = computeWheelStep(wheelAccelRef.current, dir, Date.now())
// computeWheelStep returns 0 when a direction flip is deferred for
// bounce detection — scrollBy(0) is a no-op; skip the call to avoid
// needless render scheduling.
if (accelRows === 0) {
return
}
return scrollTranscript(dir * accelRows * wheelStep)
return rows ? scrollTranscript(dir * rows * wheelStep) : undefined
}
if (key.shift && key.upArrow) {
@ -321,14 +299,9 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
}
if (key.pageUp || key.pageDown) {
// Half-viewport keeps 50% continuity and stays under Ink's
// `delta < innerHeight` DECSTBM fast-path threshold.
const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
// Half-viewport per keystroke. A whole-viewport jump (our old
// `viewport - 2`) fully replaces what's on screen — no visual
// continuity, the user can't scan — AND it lands right at Ink's
// `delta < innerHeight` fast-path threshold, disqualifying the
// DECSTBM blit on every press. Half-viewport keeps 50% continuity,
// well under the threshold, and two presses still scroll the same
// total distance.
const step = Math.max(4, Math.floor(viewport / 2))
return scrollTranscript(key.pageUp ? -step : step)

View file

@ -1,4 +1,4 @@
import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink'
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -20,7 +20,6 @@ import { appendTranscriptMessage } from '../lib/messages.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js'
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
import { getViewportSnapshot } from '../lib/viewportStore.js'
import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js'
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
@ -199,8 +198,10 @@ export function useMainApp(gw: GatewayClient) {
return `${thinking}:${tools}`
}, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])
const detailsVisible = detailsLayoutKey !== 'hidden:hidden'
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
const heightCache = useMemo(() => {
let cache = heightCachesRef.current.get(heightCacheKey)
@ -215,6 +216,7 @@ export function useMainApp(gw: GatewayClient) {
return cache
}, [heightCacheKey])
const initialHeights = useMemo(() => {
const out = new Map<string, number>()
@ -232,6 +234,7 @@ export function useMainApp(gw: GatewayClient) {
return out
}, [cols, detailsVisible, heightCache, ui.compact, virtualRows])
const syncHeightCache = useCallback(
(heights: ReadonlyMap<string, number>) => {
for (const row of virtualRows) {
@ -719,26 +722,10 @@ export function useMainApp(gw: GatewayClient) {
[cols, composerActions, composerState, empty, pagerPageSize, submit]
)
const liveTailVisible = (() => {
const s = scrollRef.current
if (!s) {
return true
}
const { bottom, scrollHeight } = getViewportSnapshot(s)
return bottom >= scrollHeight - 3
})()
const liveProgress = useMemo(() => ({ showProgressArea }), [showProgressArea])
// Always pass current progress through. Freezing this while offscreen looked
// like a nice scroll optimization, but it also froze the live tail's
// thinking/tool state at arbitrary intermediate snapshots. Streaming update
// throttling now handles interaction load; progress state should remain
// truthful so panels don't randomly disappear.
const appProgress = liveProgress
// Pass current progress through unfrozen — streaming update throttling
// handles interaction load; progress must stay truthful so panels don't
// randomly disappear when the live tail scrolls offscreen.
const appProgress = useMemo(() => ({ showProgressArea }), [showProgressArea])
const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd()
const gitBranch = useGitBranch(cwd)

View file

@ -29,10 +29,11 @@ export const writeActiveSessionFile = (sessionId: null | string, file = process.
return
}
// Best-effort shell-epilogue hint; never break live session changes.
try {
writeFileSync(file, JSON.stringify({ session_id: sessionId }), { mode: 0o600 })
} catch {
// Best-effort shell epilogue hint only; never break live session changes.
/* best-effort */
}
}
@ -98,8 +99,8 @@ 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.
// Half-prune: new session has new keys, but keep a warm pool in case
// the user resumes back to the prior session.
evictInkCaches('half')
}, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording])