diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 2b74f6c775..bfc25d682e 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -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' diff --git a/ui-tui/packages/hermes-ink/src/ink/cache-eviction.ts b/ui-tui/packages/hermes-ink/src/ink/cache-eviction.ts index 12e1ca0284..4e3ac4d216 100644 --- a/ui-tui/packages/hermes-ink/src/ink/cache-eviction.ts +++ b/ui-tui/packages/hermes-ink/src/ink/cache-eviction.ts @@ -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 diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 1bd47d61f1..4a26fbafba 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -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 diff --git a/ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts b/ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts index 2ca47f12ef..71b02b6226 100644 --- a/ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts +++ b/ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts @@ -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) } diff --git a/ui-tui/packages/hermes-ink/src/ink/lru.ts b/ui-tui/packages/hermes-ink/src/ink/lru.ts new file mode 100644 index 0000000000..cd119b5f00 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/lru.ts @@ -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(cache: Map, 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!) + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/output.ts b/ui-tui/packages/hermes-ink/src/ink/output.ts index a8cc147ae5..413ed8bfaa 100644 --- a/ui-tui/packages/hermes-ink/src/ink/output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/output.ts @@ -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 } diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts index ffce94f11a..37d3b2f97c 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -111,7 +111,6 @@ export function resetScrollFastPathStats(): void { scrollFastPathStats.lastPrevHeight = undefined } - export function getScrollHint(): ScrollHint | null { return scrollHint } diff --git a/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts b/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts index 41d00fd47c..69acbac1b8 100644 --- a/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts +++ b/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts @@ -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) } diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal.ts b/ui-tui/packages/hermes-ink/src/ink/terminal.ts index 0ffe6e80cb..a0aaa0beac 100644 --- a/ui-tui/packages/hermes-ink/src/ink/terminal.ts +++ b/ui-tui/packages/hermes-ink/src/ink/terminal.ts @@ -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 } } diff --git a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts index d993a1d4f7..dcc897b34f 100644 --- a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts +++ b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts @@ -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) } diff --git a/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts b/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts index c38c40b3cf..50a9237dfb 100644 --- a/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts +++ b/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts @@ -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() 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 { diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts index 466d5bc8cc..4b05aa3996 100644 --- a/ui-tui/src/__tests__/virtualHeights.test.ts +++ b/ui-tui/src/__tests__/virtualHeights.test.ts @@ -5,10 +5,9 @@ import type { Msg } from '../types.js' describe('virtual height estimates', () => { it('uses stable content keys across resumed message objects', () => { - const a: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] } - const b: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] } + const msg: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] } - expect(messageHeightKey(a)).toBe(messageHeightKey(b)) + expect(messageHeightKey(msg)).toBe(messageHeightKey({ ...msg })) }) it('accounts for wrapping and preserved blank-block rhythm', () => { diff --git a/ui-tui/src/__tests__/wheelAccel.test.ts b/ui-tui/src/__tests__/wheelAccel.test.ts index 9d865ebfeb..c8be6ab539 100644 --- a/ui-tui/src/__tests__/wheelAccel.test.ts +++ b/ui-tui/src/__tests__/wheelAccel.test.ts @@ -12,38 +12,29 @@ describe('wheelAccel — native path', () => { it('same-direction fast events ramp mult (window-mode)', () => { const s = initWheelAccel(false, 1) - // First click establishes dir. Subsequent clicks inside the 40ms - // window ramp by +0.3 each (capped at 6). computeWheelStep(s, 1, 1000) computeWheelStep(s, 1, 1020) computeWheelStep(s, 1, 1040) - const fourth = computeWheelStep(s, 1, 1060) - // After 3 window events: mult starts at 1 → stays 1 on first ramp - // (first event just sets baseline), then +0.3 × 3 = 1.9 → floor=1. - // The key property: doesn't shrink below base. - expect(fourth).toBeGreaterThanOrEqual(1) + // Key property: doesn't shrink below base. + expect(computeWheelStep(s, 1, 1060)).toBeGreaterThanOrEqual(1) }) it('gap beyond window resets mult to base', () => { const s = initWheelAccel(false, 1) - // Ramp up for (let t = 1000; t < 1100; t += 20) { computeWheelStep(s, 1, t) } - // Long pause, then click - const afterPause = computeWheelStep(s, 1, 2000) - - expect(afterPause).toBe(1) + expect(computeWheelStep(s, 1, 2000)).toBe(1) }) it('direction flip defers one event for bounce detection', () => { const s = initWheelAccel(false, 1) computeWheelStep(s, 1, 1000) - // Flip — should defer + expect(computeWheelStep(s, -1, 1050)).toBe(0) }) @@ -51,9 +42,7 @@ describe('wheelAccel — native path', () => { const s = initWheelAccel(false, 1) computeWheelStep(s, 1, 1000) - // Flip (deferred) computeWheelStep(s, -1, 1050) - // Flip BACK within 200ms → bounce confirmed → wheelMode engaged computeWheelStep(s, 1, 1100) expect(s.wheelMode).toBe(true) @@ -63,8 +52,7 @@ describe('wheelAccel — native path', () => { const s = initWheelAccel(false, 1) computeWheelStep(s, 1, 1000) - computeWheelStep(s, -1, 1050) // defer - // Flip-back arrives 300ms later → too late → real reversal + computeWheelStep(s, -1, 1050) computeWheelStep(s, 1, 1400) expect(s.wheelMode).toBe(false) @@ -76,12 +64,9 @@ describe('wheelAccel — native path', () => { s.dir = 1 s.time = 1000 - // 5 bursts <5ms apart (trackpad flick) - computeWheelStep(s, 1, 1002) - computeWheelStep(s, 1, 1004) - computeWheelStep(s, 1, 1006) - computeWheelStep(s, 1, 1008) - computeWheelStep(s, 1, 1010) + for (let t = 1002; t <= 1010; t += 2) { + computeWheelStep(s, 1, t) + } expect(s.wheelMode).toBe(false) }) @@ -92,7 +77,7 @@ describe('wheelAccel — native path', () => { s.dir = 1 s.time = 1000 - computeWheelStep(s, 1, 3000) // 2 second gap + computeWheelStep(s, 1, 3000) expect(s.wheelMode).toBe(false) }) @@ -102,34 +87,23 @@ describe('wheelAccel — xterm.js path', () => { it('first click returns 2 after long idle', () => { const s = initWheelAccel(true, 1) - // First event — "sameDir && gap > WHEEL_DECAY_IDLE_MS" triggers - // reset-to-2 branch since dir starts at 0 and 0 !== 1. - const n = computeWheelStep(s, 1, 1000) - - expect(n).toBeGreaterThanOrEqual(1) + expect(computeWheelStep(s, 1, 1000)).toBeGreaterThanOrEqual(1) }) it('sub-5ms burst returns 1 (same-direction, same-batch)', () => { const s = initWheelAccel(true, 1) computeWheelStep(s, 1, 1000) - const burst = computeWheelStep(s, 1, 1002) - expect(burst).toBe(1) + expect(computeWheelStep(s, 1, 1002)).toBe(1) }) it('slow steady scroll stays in precision range', () => { const s = initWheelAccel(true, 1) - // Simulated 30Hz sustained scroll: 33ms gap - const results: number[] = [] - for (let t = 1000; t < 2000; t += 33) { - results.push(computeWheelStep(s, 1, t)) - } + const r = computeWheelStep(s, 1, t) - // Every event should produce 1-6 rows. No runaway. - for (const r of results) { expect(r).toBeGreaterThanOrEqual(1) expect(r).toBeLessThanOrEqual(6) } @@ -138,27 +112,22 @@ describe('wheelAccel — xterm.js path', () => { it('direction reversal resets mult', () => { const s = initWheelAccel(true, 1) - // Ramp up for (let t = 1000; t < 1100; t += 20) { computeWheelStep(s, 1, t) } + const beforeFlip = s.mult - // Flip computeWheelStep(s, -1, 1200) expect(s.mult).toBeLessThanOrEqual(beforeFlip) - // Reset branch sets mult=2 expect(s.mult).toBe(2) }) it('frac stays in [0,1) across events', () => { const s = initWheelAccel(true, 1) - // frac must never go negative or reach 1.0 — that's the correctness - // invariant of the fractional carry. Whether a specific series of - // inputs produces a nonzero frac depends on tuning constants; just - // check the bound is maintained across a realistic scroll pattern. + // Correctness invariant of fractional carry: never negative, never reaches 1. for (let t = 1000; t < 1200; t += 30) { computeWheelStep(s, 1, t) diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts index e7f3366acc..643210961e 100644 --- a/ui-tui/src/app/turnStore.ts +++ b/ui-tui/src/app/turnStore.ts @@ -50,6 +50,7 @@ export const archiveTodosAtTurnEnd = () => { } const done = isTodoDone(state.todos) + const msg: Msg = { kind: 'trail', role: 'system', diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index d9f1c01810..0441c1d2c7 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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 | null>(null) + const scrollIdleTimer = useRef>(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) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 1710757761..2a8913b632 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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() @@ -232,6 +234,7 @@ export function useMainApp(gw: GatewayClient) { return out }, [cols, detailsVisible, heightCache, ui.compact, virtualRows]) + const syncHeightCache = useCallback( (heights: ReadonlyMap) => { 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) diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 3b00a19be0..473c5adb3e 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -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]) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 2e594f8cae..d8a9d0f5f2 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -30,14 +30,18 @@ const TranscriptPane = memo(function TranscriptPane({ }: Pick) { 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 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({ - {/* 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 && ( diff --git a/ui-tui/src/components/fpsOverlay.tsx b/ui-tui/src/components/fpsOverlay.tsx index d3d5aca777..f6fc748656 100644 --- a/ui-tui/src/components/fpsOverlay.tsx +++ b/ui-tui/src/components/fpsOverlay.tsx @@ -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 ( - {fpsStr}fps · {durStr}ms · #{totalFrames} + {fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames} ) } diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 281541af99..d3b6710b9e 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -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 {parts.length ? parts : {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>() 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() + mdCache.set(t, fresh) + + return fresh } const cacheGet = (b: Map, key: string) => { diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts index d20e09617b..8fb9cf69a6 100644 --- a/ui-tui/src/config/env.ts +++ b/ui-tui/src/config/env.ts @@ -1,19 +1,15 @@ +const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim()) + export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() -export const MOUSE_TRACKING = !/^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim()) -export const NO_CONFIRM_DESTRUCTIVE = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_NO_CONFIRM ?? '').trim()) -// Inline mode: skip the alt-screen wrapper. The TUI renders into the -// primary buffer so the terminal's native scrollback captures whatever -// scrolls off the top. Wheel + PageUp are then handled by the host -// terminal, not by our virtual-scroll logic. The live composer/progress -// area still pins to the bottom via Ink's normal flow. -// -// This is an experiment gate — the full "inline layout" (plain-text -// transcript with composer pinned below) is a bigger change; the env var -// here just disables AlternateScreen so we can measure whether native -// scrolling beats our virtualization on the same pipeline. -export const INLINE_MODE = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_INLINE ?? '').trim()) -// Show a small FPS counter overlay in the bottom-right corner. Fed by -// ink's onFrame callback (so it's the REAL render rate, not a synthetic -// timer). Useful during scroll-perf tuning to watch behavior in real -// time instead of running a separate profile harness. -export const SHOW_FPS = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_FPS ?? '').trim()) +export const MOUSE_TRACKING = !truthy(process.env.HERMES_TUI_DISABLE_MOUSE) +export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM) + +// Skip AlternateScreen — TUI renders into the primary buffer so the host +// terminal's native scrollback captures whatever scrolls off the top. +// Experiment gate: lets us measure native scroll vs our virtualization on +// the same pipeline. +export const INLINE_MODE = truthy(process.env.HERMES_TUI_INLINE) + +// Live FPS counter overlay, fed by ink's onFrame (real render rate, not a +// synthetic timer). +export const SHOW_FPS = truthy(process.env.HERMES_TUI_FPS) diff --git a/ui-tui/src/config/limits.ts b/ui-tui/src/config/limits.ts index 7c024220c4..4be995548a 100644 --- a/ui-tui/src/config/limits.ts +++ b/ui-tui/src/config/limits.ts @@ -1,33 +1,22 @@ export const LARGE_PASTE = { chars: 8000, lines: 80 } + export const LIVE_RENDER_MAX_CHARS = 16_000 export const LIVE_RENDER_MAX_LINES = 240 -// History-render bounds for messages outside the FULL_RENDER_TAIL window. -// Each rendered line becomes ≥1 Yoga/Text node + inline spans, so this is -// the dominant lever on cold-mount cost during PageUp catch-up. 16 lines -// × 25 mounted items ≈ 400 nodes total — small enough that the per-frame -// buffer-compose stays well inside the 16ms budget. User pages back to -// recognize where they were, not to read; stopping near a message -// re-renders it in full once it falls inside the tail window. + +// History-render bounds for messages outside FULL_RENDER_TAIL. Each rendered +// line ≈ 1 Yoga/Text node + inline spans, so this is the dominant lever on +// cold-mount cost during PageUp catch-up. 16 lines × 25 mounted ≈ 400 nodes +// — comfortably inside the 16ms per-frame budget. User pages back to +// recognize, not to read; full re-render once it falls inside the tail. export const HISTORY_RENDER_MAX_CHARS = 800 export const HISTORY_RENDER_MAX_LINES = 16 export const FULL_RENDER_TAIL_ITEMS = 8 + export const LONG_MSG = 300 export const MAX_HISTORY = 800 export const THINKING_COT_MAX = 160 -// Rows scrolled per wheel-notch event. -// -// One notch of a mechanical wheel emits multiple wheel events (3-5 per -// click in most terminals; trackpad flicks emit 100+). Each event scrolls -// WHEEL_SCROLL_STEP rows. The product = rows-per-click. -// -// 1 = pure line-by-line. Small per-event delta keeps Ink's DECSTBM fast -// path firing (each scroll < viewport-1) and produces smooth visible -// motion — the user can scan content mid-scroll. We were at 6 before -// (= ~20-30 rows per notch) which visually teleported and forced the -// virtualization to reshape the mount range on every event. -// -// If this feels sluggish on precision scrolls, porting claude-code's -// wheel accel state machine (ScrollKeybindingHandler.tsx) is the right -// next step — it ramps step up during sustained fast clicks and decays -// on pause. + +// Rows per wheel event (pre-accel). 1 keeps Ink's DECSTBM fast path live +// (each scroll < viewport-1) and produces smooth motion. wheelAccel.ts +// ramps this on sustained scrolls. export const WHEEL_SCROLL_STEP = 1 diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index da827eab26..85e4d7e112 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,4 +1,6 @@ #!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc +import type { FrameEvent } from '@hermes/ink' + import { GatewayClient } from './gatewayClient.js' import { setupGracefulExit } from './lib/gracefulExit.js' import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js' @@ -41,26 +43,21 @@ if (process.env.HERMES_HEAPDUMP_ON_START === '1') { process.on('beforeExit', () => stopMemoryMonitor()) -const [{ render }, { App }, { logFrameEvent }, { trackFrame }] = await Promise.all([ +const [ink, { App }, { logFrameEvent }, { trackFrame }] = await Promise.all([ import('@hermes/ink'), import('./app.js'), import('./lib/perfPane.js'), import('./lib/fpsStore.js') ]) -// Compose onFrame from the two opt-in consumers (HERMES_DEV_PERF and -// HERMES_TUI_FPS). Each is undefined when its env flag is off; we only -// attach onFrame at all when at least one is on, so ink skips the -// handler entirely in the default disabled case. -type InkFrameEvent = { durationMs: number } -type OnFrame = (event: InkFrameEvent) => void - -const onFrame: OnFrame | undefined = +// Both consumers are undefined when their env flags are off; only attach +// onFrame when at least one is on so ink skips timing in the default case. +const onFrame = logFrameEvent || trackFrame - ? (event: InkFrameEvent) => { - logFrameEvent?.(event as Parameters>[0]) + ? (event: FrameEvent) => { + logFrameEvent?.(event) trackFrame?.(event.durationMs) } : undefined -render(, { exitOnCtrlC: false, onFrame }) +ink.render(, { exitOnCtrlC: false, onFrame }) diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index d6372289a3..cae055f1c4 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -1,13 +1,13 @@ import type { ScrollBoxHandle } from '@hermes/ink' import { + type RefObject, useCallback, useDeferredValue, useEffect, useLayoutEffect, useRef, useState, - useSyncExternalStore, - type RefObject + useSyncExternalStore } from 'react' const ESTIMATE = 4 @@ -98,6 +98,7 @@ export function useVirtualHistory( // Bump whenever heightCache mutates so offsets rebuild on next read. // Ref (not state) — checked during render phase, zero extra commits. const offsetVersion = useRef(0) + // Cached offsets: reused Float64Array keyed on (itemCount, version) so we // only rebuild when something actually changed. Previous approach allocated // a fresh Array(n+1) every render — at n=10k that's ~80KB/render of GC @@ -107,6 +108,7 @@ export function useVirtualHistory( n: -1, version: -1 }) + const [hasScrollRef, setHasScrollRef] = useState(false) const metrics = useRef({ sticky: true, top: 0, vp: 0 }) const lastScrollTopRef = useRef(0) @@ -158,6 +160,7 @@ export function useVirtualHistory( (cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? NOOP, [hasScrollRef, scrollRef] ) + useSyncExternalStore( subscribe, () => { @@ -310,13 +313,8 @@ export function useVirtualHistory( if (velocity > vp * 2) { const [pS, pE] = prevRange.current - if (start < pS - SLIDE_STEP) { - start = pS - SLIDE_STEP - } - - if (end > pE + SLIDE_STEP) { - end = pE + SLIDE_STEP - } + start = Math.max(start, pS - SLIDE_STEP) + end = Math.min(end, pE + SLIDE_STEP) // A large jump past the capped end can invert (start > end); mount // SLIDE_STEP items from the new start so the viewport isn't blank diff --git a/ui-tui/src/lib/fpsStore.ts b/ui-tui/src/lib/fpsStore.ts index 530e6229c5..f4ae63b7a1 100644 --- a/ui-tui/src/lib/fpsStore.ts +++ b/ui-tui/src/lib/fpsStore.ts @@ -1,48 +1,32 @@ -// Tiny FPS tracker fed by ink's onFrame callback. +// Tiny FPS tracker fed by ink's onFrame callback. Each entry is an Ink +// frame (React commit + drain-only frames) — the right notion for +// user-perceived motion. // -// Keeps a ring buffer of the last N frame timestamps and derives fps -// from the rolling window. Updates a nanostore so a corner-overlay -// component can subscribe without pulling it through props. -// -// FPS here means "Ink render rate" — each entry is an ink frame, which -// includes both React commits and drain-only frames (Ink re-rendering -// with an updated scrollTop without a React commit). That's the right -// notion for user-perceived motion: it's how often the screen buffer -// actually changes, not how often React reconciles. -// -// Zero-cost when HERMES_TUI_FPS is unset: trackFrame is undefined so -// the onFrame callback short-circuits at the optional chain. +// Zero-cost when HERMES_TUI_FPS is unset: trackFrame is undefined so the +// onFrame callback short-circuits at the optional chain. import { atom } from 'nanostores' import { SHOW_FPS } from '../config/env.js' -const WINDOW_SIZE = 30 // last 30 frames +const WINDOW_SIZE = 30 export type FpsState = { - /** Frames per second averaged over the last WINDOW_SIZE frames. */ fps: number - /** Total frames counted since start (wraps at JS-safe int so you can - * diff pairs in a debug overlay without worrying about precision). */ + /** Wraps at JS-safe int — diff pairs in a debug overlay safely. */ totalFrames: number - /** Last frame's durationMs (ink render phase total). */ + /** Ink render-phase total for the last frame. */ lastDurationMs: number } -export const $fpsState = atom({ - fps: 0, - lastDurationMs: 0, - totalFrames: 0 -}) +export const $fpsState = atom({ fps: 0, lastDurationMs: 0, totalFrames: 0 }) const timestamps: number[] = [] let totalFrames = 0 export const trackFrame = SHOW_FPS ? (durationMs: number) => { - const now = performance.now() - - timestamps.push(now) + timestamps.push(performance.now()) if (timestamps.length > WINDOW_SIZE) { timestamps.shift() @@ -50,20 +34,18 @@ export const trackFrame = SHOW_FPS totalFrames++ - // FPS = frames-in-window / seconds-in-window. Needs at least 2 - // timestamps to compute a gap. - if (timestamps.length >= 2) { - const elapsed = (timestamps[timestamps.length - 1]! - timestamps[0]!) / 1000 + if (timestamps.length < 2) { + return + } - if (elapsed > 0) { - const fps = (timestamps.length - 1) / elapsed + const elapsed = (timestamps[timestamps.length - 1]! - timestamps[0]!) / 1000 - $fpsState.set({ - fps: Math.round(fps * 10) / 10, - lastDurationMs: Math.round(durationMs * 100) / 100, - totalFrames - }) - } + if (elapsed > 0) { + $fpsState.set({ + fps: Math.round(((timestamps.length - 1) / elapsed) * 10) / 10, + lastDurationMs: Math.round(durationMs * 100) / 100, + totalFrames + }) } } : undefined diff --git a/ui-tui/src/lib/liveProgress.test.ts b/ui-tui/src/lib/liveProgress.test.ts index eec209baf0..cea53d543f 100644 --- a/ui-tui/src/lib/liveProgress.test.ts +++ b/ui-tui/src/lib/liveProgress.test.ts @@ -104,7 +104,10 @@ describe('appendToolShelfMessage', () => { it('starts a new shelf across assistant text boundaries', () => { const merged = appendToolShelfMessage( - [{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }, { role: 'assistant', text: 'done' }], + [ + { kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }, + { role: 'assistant', text: 'done' } + ], { kind: 'trail', role: 'system', text: '', tools: ['two ✓'] } ) diff --git a/ui-tui/src/lib/liveProgress.ts b/ui-tui/src/lib/liveProgress.ts index 1407682fba..12c384f393 100644 --- a/ui-tui/src/lib/liveProgress.ts +++ b/ui-tui/src/lib/liveProgress.ts @@ -38,8 +38,7 @@ const isBarrierMessage = (msg: Msg | undefined) => { return false } -const isToolCarryingTrail = (msg: Msg | undefined) => - Boolean(msg?.kind === 'trail' && !msg.text && msg.tools?.length) +const isToolCarryingTrail = (msg: Msg | undefined) => Boolean(msg?.kind === 'trail' && !msg.text && msg.tools?.length) export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => { if (!isToolShelfMessage(msg)) { diff --git a/ui-tui/src/lib/memoryMonitor.ts b/ui-tui/src/lib/memoryMonitor.ts index ed185991c1..bbdb229705 100644 --- a/ui-tui/src/lib/memoryMonitor.ts +++ b/ui-tui/src/lib/memoryMonitor.ts @@ -41,10 +41,8 @@ export function startMemoryMonitor({ return } - // Defensive eviction: prune Ink content caches before dumping/exiting. - // 'high' = half-prune (still warm enough to recover quickly); - // 'critical' = full drop. Reduces post-dump RSS and gives the user a - // chance to keep running rather than auto-restart. + // Prune Ink content caches before dump/exit — half on 'high' (recoverable), + // full on 'critical' (post-dump RSS reduction, keeps user running). evictInkCaches(level === 'critical' ? 'all' : 'half') dumped.add(level) diff --git a/ui-tui/src/lib/perfPane.tsx b/ui-tui/src/lib/perfPane.tsx index fbe86d7bad..f363ea59c1 100644 --- a/ui-tui/src/lib/perfPane.tsx +++ b/ui-tui/src/lib/perfPane.tsx @@ -1,43 +1,15 @@ // Perf instrumentation for the full render pipeline. // -// Two sources of timing: -// 1. React.Profiler wrapper (PerfPane) → per-pane commit times. Shows -// which subtree is reconciling and for how long. -// 2. Ink onFrame callback (logFrameEvent) → per-frame pipeline phases: -// yoga (calculateLayout), renderer (DOM → screen buffer), diff -// (prev vs current screen → patches), optimize (patch merge/dedupe), -// write (serialize → ANSI → stdout), plus yoga counters (visited, -// measured, cacheHits, live). Shows where the time goes BELOW React. +// PerfPane (React.Profiler) → per-pane commit times +// logFrameEvent (ink.onFrame) → yoga / renderer / diff / optimize / write +// phases + yoga counters + scroll fast-path // -// Both sources gate on HERMES_DEV_PERF=1 and dump JSON-lines to the same -// log (default ~/.hermes/perf.log, override via HERMES_DEV_PERF_LOG). -// Events are tagged { src: 'react' | 'frame' } so jq can split them. +// Both gate on HERMES_DEV_PERF=1 and dump JSON-lines (default ~/.hermes/perf.log, +// override HERMES_DEV_PERF_LOG). Tagged { src: 'react' | 'frame' } for jq. +// HERMES_DEV_PERF_MS (default 2) skips sub-ms idle frames; set 0 to capture all. // -// Threshold HERMES_DEV_PERF_MS (default 2ms) skips sub-millisecond idle -// frames. For the 2fps-during-PageUp investigation, set -// HERMES_DEV_PERF_MS=0 to capture everything, then filter with jq. -// -// Zero cost when the env var is unset: PerfPane returns children -// directly (no Profiler fiber), logFrameEvent is a noop on the onFrame -// callback — the ink instance isn't given the callback at all. -// -// Usage: -// # entry.tsx wires logFrameEvent into render() -// import { logFrameEvent, PerfPane } from './lib/perfPane.js' -// render(, { onFrame: logFrameEvent }) -// -// Analysis helpers (once you've captured a session): -// tail -f ~/.hermes/perf.log | jq -c 'select(.src=="frame" and .durationMs > 16)' -// # p50/p99 per phase across frame events: -// jq -s '[.[] | select(.src=="frame")] | -// {n: length, -// dur_p50: (sort_by(.durationMs) | .[length/2|floor].durationMs), -// dur_p99: (sort_by(.durationMs) | .[length*0.99|floor].durationMs), -// yoga_p99: (sort_by(.phases.yoga) | .[length*0.99|floor].phases.yoga), -// write_p99: (sort_by(.phases.write) | .[length*0.99|floor].phases.write), -// diff_p99: (sort_by(.phases.diff) | .[length*0.99|floor].phases.diff), -// patches_p99: (sort_by(.phases.patches) | .[length*0.99|floor].phases.patches)}' \ -// ~/.hermes/perf.log +// Zero cost when unset: PerfPane returns children directly, logFrameEvent is +// undefined so ink doesn't pay the timing cost. import { appendFileSync, mkdirSync } from 'node:fs' import { homedir } from 'node:os' @@ -51,31 +23,23 @@ const ENABLED = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_DEV_PERF ?? ''). const THRESHOLD_MS = Number(process.env.HERMES_DEV_PERF_MS ?? '2') || 0 const LOG_PATH = process.env.HERMES_DEV_PERF_LOG?.trim() || join(homedir(), '.hermes', 'perf.log') -let initialized = false - -const ensureLogDir = () => { - if (initialized) { - return - } - - initialized = true - - try { - mkdirSync(dirname(LOG_PATH), { recursive: true }) - } catch { - // Best-effort — if we can't create the dir (readonly fs, /tmp, etc.) - // the appendFileSync calls below will throw silently and we drop the - // sample. Perf logging should never crash the TUI. - } -} +let logReady = false const writeRow = (row: Record) => { - ensureLogDir() + if (!logReady) { + logReady = true + + try { + mkdirSync(dirname(LOG_PATH), { recursive: true }) + } catch { + // Best-effort — never crash the TUI to log a sample. + } + } try { appendFileSync(LOG_PATH, `${JSON.stringify(row)}\n`) } catch { - // Same rationale as ensureLogDir — never crash the UI to log a sample. + /* best-effort */ } } @@ -110,59 +74,27 @@ export function PerfPane({ children, id }: { children: ReactNode; id: string }) ) } -/** - * Ink onFrame handler. Captures the FULL render pipeline: yoga calculateLayout, - * DOM → screen buffer, screen diff, patch optimize, and stdout write. - * - * Returns `undefined` when disabled so `render()` doesn't attach the callback — - * ink only pays the timing cost when the callback is truthy. - */ export const logFrameEvent = ENABLED ? (event: FrameEvent) => { if (event.durationMs < THRESHOLD_MS) { return } - // Snapshot the fast-path counters each frame. Cumulative values — - // consumers diff pairs to get per-frame deltas. Written verbatim - // so we can also see "last*" fields (which decline reason fired, - // and what the height math looked like). - const fastPath = { - captured: scrollFastPathStats.captured, - taken: scrollFastPathStats.taken, - declined: { - heightDeltaMismatch: scrollFastPathStats.declined.heightDeltaMismatch, - noPrevScreen: scrollFastPathStats.declined.noPrevScreen, - other: scrollFastPathStats.declined.other - }, - lastDeclineReason: scrollFastPathStats.lastDeclineReason, - lastHeightDelta: scrollFastPathStats.lastHeightDelta, - lastHintDelta: scrollFastPathStats.lastHintDelta, - lastPrevHeight: scrollFastPathStats.lastPrevHeight, - lastScrollHeight: scrollFastPathStats.lastScrollHeight - } - writeRow({ durationMs: round2(event.durationMs), - fastPath, + // Cumulative counters — consumers diff pairs to get per-frame deltas. + fastPath: { ...scrollFastPathStats, declined: { ...scrollFastPathStats.declined } }, flickers: event.flickers.length ? event.flickers : undefined, phases: event.phases ? { - backpressure: event.phases.backpressure, + ...event.phases, commit: round2(event.phases.commit), diff: round2(event.phases.diff), optimize: round2(event.phases.optimize), - optimizedPatches: event.phases.optimizedPatches, - patches: event.phases.patches, prevFrameDrainMs: round2(event.phases.prevFrameDrainMs), renderer: round2(event.phases.renderer), write: round2(event.phases.write), - writeBytes: event.phases.writeBytes, - yoga: round2(event.phases.yoga), - yogaCacheHits: event.phases.yogaCacheHits, - yogaLive: event.phases.yogaLive, - yogaMeasured: event.phases.yogaMeasured, - yogaVisited: event.phases.yogaVisited + yoga: round2(event.phases.yoga) } : undefined, src: 'frame', diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 5b2e8c41b6..744046f6be 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -80,21 +80,16 @@ export const pasteTokenLabel = (text: string, lineCount: number) => { const THINKING_STATUS_RE = new RegExp(`^(?:${VERBS.join('|')})\\.{0,3}$`, 'i') const THINKING_STATUS_CHUNK_RE = new RegExp(`[^A-Za-z\n]+\\s*(?:${VERBS.join('|')})\\.{0,3}\\s*`, 'giu') -const normalizeThinkingParagraphs = (text: string) => - text +export const cleanThinkingText = (reasoning: string) => + reasoning + .split('\n') + .map(line => line.replace(THINKING_STATUS_CHUNK_RE, '').trim()) + .filter(line => line && !THINKING_STATUS_RE.test(line.replace(/\.\.\.$/, '').trim())) + .join('\n') .replace(/([^\n])(?=\*\*[^*\n][^\n]*?\*\*)/g, '$1\n\n') .replace(/\n{3,}/g, '\n\n') .trim() -export const cleanThinkingText = (reasoning: string) => - normalizeThinkingParagraphs( - reasoning - .split('\n') - .map(line => line.replace(THINKING_STATUS_CHUNK_RE, '').trim()) - .filter(line => line && !THINKING_STATUS_RE.test(line.replace(/\.\.\.$/, '').trim())) - .join('\n') - ) - export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => { const raw = cleanThinkingText(reasoning) diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts index 953ed43b20..6c9e2655f1 100644 --- a/ui-tui/src/lib/virtualHeights.ts +++ b/ui-tui/src/lib/virtualHeights.ts @@ -14,18 +14,18 @@ export const hashText = (text: string) => { export const messageHeightKey = (msg: Msg) => { const todoSig = msg.todos?.map(t => `${t.status}:${t.content}`).join('\u0001') ?? '' + const panelSig = msg.panelData?.sections .map(s => `${s.title ?? ''}:${s.text?.length ?? 0}:${s.items?.length ?? 0}:${s.rows?.length ?? 0}`) .join('\u0001') ?? '' + const introSig = msg.kind === 'intro' ? (msg.info?.version ?? '') : '' return [ msg.role, msg.kind ?? '', - hashText( - [msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? '', todoSig, panelSig, introSig].join('\0') - ) + hashText([msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? '', todoSig, panelSig, introSig].join('\0')) ].join(':') } @@ -68,11 +68,9 @@ export const estimatedMsgHeight = ( h += (msg.tools?.length ?? 0) + wrappedLines(msg.thinking ?? '', bodyWidth) } - if (msg.role === 'user' || msg.kind === 'slash' || msg.kind === 'diff') { - h++ - } - if (msg.role === 'user' || msg.kind === 'diff') { + h += 2 + } else if (msg.kind === 'slash') { h++ } diff --git a/ui-tui/src/lib/wheelAccel.ts b/ui-tui/src/lib/wheelAccel.ts index 78d8f56b6a..4b9e1522c0 100644 --- a/ui-tui/src/lib/wheelAccel.ts +++ b/ui-tui/src/lib/wheelAccel.ts @@ -1,52 +1,35 @@ // Wheel-scroll acceleration state machine. // -// Algorithm and tuning constants adapted from a reference implementation -// of trackpad/wheel-event acceleration in TUI scroll handlers; this file -// is the port adapted to our module structure. +// One event = 1 row feels sluggish on trackpads (200+ ev/s) and sustained +// mouse-wheel; one event = 6 rows teleports and ruins precision. +// Heuristic on inter-event gap + direction flips: // -// Problem: one wheel event = 1 scrolled row feels sluggish on trackpads -// (which can fire 200+ events/sec) and during deliberate mouse scrolls. -// One wheel event = 6 rows (our old WHEEL_SCROLL_STEP=6) visually -// teleports and ruins precision. The right answer depends on intent: +// gap < 5ms → same-batch burst → 1 row/event +// gap < 40ms (native) → ramp +0.3, cap 6 +// gap 80-500ms (xterm.js) → mult = 1 + (mult-1)·0.5^(gap/150) + 5·decay +// cap 3 slow / 6 fast +// gap > 500ms → reset (deliberate click stays responsive) +// flip + flip-back ≤200ms → encoder bounce → engage wheel-mode (sticky cap) +// 5 consecutive <5ms events → trackpad flick → disengage wheel-mode // -// precision click → 1 row/event -// sustained mouse → ramp to ~15 rows/event, decay when slowing down -// trackpad flick → 1 row/event per burst event (they come 100+) -// -// Heuristic: watch inter-event gaps and direction flips: -// * gap < 5ms → same-batch burst (SGR proportional reporting -// or trackpad flick) → 1 row/event -// * gap < 40ms, same → ramp mult by +0.3/event, cap at 6 (native path) -// * gap < 80-500ms → exponential decay curve (xterm.js path) -// mult = 1 + (mult-1)*0.5^(gap/150ms) + 5*decay -// capped at 3 for gaps ≥ 80ms, 6 for < 80ms -// * gap > 500ms → reset to 2 (deliberate click feels responsive) -// * direction flip + bounce-back within 200ms → encoder bounce, -// engage wheel-mode -// (sticky higher cap) -// * 5 consecutive <5ms events → trackpad flick, disengage wheel-mode -// -// Two separate paths because native terminals (Ghostty, iTerm2) and -// browser-embedded terminals (VS Code, Cursor) emit wheel events with -// different cadences. Native sends 1 event per intended row, often -// pre-amplified at the emulator level; xterm.js sends exactly 1 event -// per notch, unamplified. +// Native terminals (Ghostty, iTerm2) and xterm.js embedders (VS Code, +// Cursor) emit wheel events with different cadences, hence two paths. import { isXtermJs } from '@hermes/ink' -// ── Native path (ghostty, iTerm2, WezTerm, etc.) ─────────────────────── +// ── Native (ghostty, iTerm2, WezTerm, …) ─────────────────────────────── const WHEEL_ACCEL_WINDOW_MS = 40 const WHEEL_ACCEL_STEP = 0.3 const WHEEL_ACCEL_MAX = 6 -// ── Encoder bounce / wheel-mode path (detected mechanical wheels) ────── +// ── Encoder bounce / wheel-mode (mechanical wheels) ──────────────────── const WHEEL_BOUNCE_GAP_MAX_MS = 200 const WHEEL_MODE_STEP = 15 const WHEEL_MODE_CAP = 15 const WHEEL_MODE_RAMP = 3 const WHEEL_MODE_IDLE_DISENGAGE_MS = 1500 -// ── xterm.js path (VS Code / Cursor / browser terminals) ─────────────── +// ── xterm.js (VS Code / Cursor / browser terminals) ──────────────────── const WHEEL_DECAY_HALFLIFE_MS = 150 const WHEEL_DECAY_STEP = 5 const WHEEL_BURST_MS = 5 @@ -60,88 +43,61 @@ export type WheelAccelState = { mult: number dir: 0 | 1 | -1 xtermJs: boolean - /** Carried fractional scroll (xterm.js only). scrollBy floors, so - * without this a mult of 1.5 gives 1 row every time. Carrying the - * remainder gives 1,2,1,2 on average for mult=1.5 — correct - * throughput over time. */ + /** Carried fractional scroll (xterm.js). scrollBy floors, so without + * this a mult of 1.5 always gives 1 row; carrying the remainder gives + * 1,2,1,2 — correct throughput over time. */ frac: number - /** Native-path baseline rows/event. Reset value on idle/reversal; - * ramp builds on top. xterm.js path ignores this. */ + /** Native baseline rows/event. Reset on idle/reversal; ramp builds on + * top. xterm.js path ignores. */ base: number - /** Deferred direction flip (native only). Might be encoder bounce or - * a real reversal — resolved by the NEXT event. */ + /** Deferred direction flip (native): bounce vs reversal — next event + * decides. */ pendingFlip: boolean - /** Confirmed once a bounce fired (flip-then-flip-back within the - * bounce window). Sticky until idle disengage or trackpad burst. */ + /** Sticky once a flip-then-flip-back fires within the bounce window. + * Cleared by idle disengage or trackpad burst. */ wheelMode: boolean - /** Consecutive <5ms events. Trackpad flick ≥5 → disengage wheelMode. */ + /** Consecutive <5ms events. ≥5 → trackpad flick → disengage. */ burstCount: number } export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState { - return { - burstCount: 0, - base, - dir: 0, - frac: 0, - mult: base, - pendingFlip: false, - time: 0, - wheelMode: false, - xtermJs - } + return { burstCount: 0, base, dir: 0, frac: 0, mult: base, pendingFlip: false, time: 0, wheelMode: false, xtermJs } } -/** Read HERMES_TUI_SCROLL_SPEED (or CLAUDE_CODE_SCROLL_SPEED for - * portability from claude-code users). Default 1, clamped (0, 20]. */ +/** HERMES_TUI_SCROLL_SPEED (or CLAUDE_CODE_SCROLL_SPEED for portability). + * Default 1, clamped (0, 20]. */ export function readScrollSpeedBase(): number { - const raw = process.env.HERMES_TUI_SCROLL_SPEED ?? process.env.CLAUDE_CODE_SCROLL_SPEED + const n = parseFloat(process.env.HERMES_TUI_SCROLL_SPEED ?? process.env.CLAUDE_CODE_SCROLL_SPEED ?? '') - if (!raw) { - return 1 - } - - const n = parseFloat(raw) - - return Number.isNaN(n) || n <= 0 ? 1 : Math.min(n, 20) + return Number.isFinite(n) && n > 0 ? Math.min(n, 20) : 1 } -/** Initialize the accel state with environment-derived defaults. */ export function initWheelAccelForHost(): WheelAccelState { return initWheelAccel(isXtermJs(), readScrollSpeedBase()) } -/** - * Compute rows for one wheel event, MUTATING the accel state. Returns 0 - * when a direction flip is deferred for bounce detection — call sites - * should no-op on 0 (scrollBy(0) is a no-op anyway, but explicit check - * keeps the intent obvious). - */ +/** Compute rows for one wheel event, mutating `state`. Returns 0 when a + * direction flip is deferred for bounce detection — call sites should + * no-op on 0. */ export function computeWheelStep(state: WheelAccelState, dir: -1 | 1, now: number): number { - if (!state.xtermJs) { - return nativeStep(state, dir, now) - } - - return xtermJsStep(state, dir, now) + return state.xtermJs ? xtermJsStep(state, dir, now) : nativeStep(state, dir, now) } function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number { - // Device-switch guard ①: idle disengage. A pending bounce can mask - // as a real reversal via the early return below — run this first so - // "user stopped for 1.5s then mouse-click" restarts at baseline. + // Idle disengage runs first so a pending bounce can't mask "user paused + // 1.5s then mouse-clicked" as a real reversal. if (state.wheelMode && now - state.time > WHEEL_MODE_IDLE_DISENGAGE_MS) { state.wheelMode = false state.burstCount = 0 state.mult = state.base } - // Resolve any deferred flip before touching state.time/dir. if (state.pendingFlip) { state.pendingFlip = false if (dir !== state.dir || now - state.time > WHEEL_BOUNCE_GAP_MAX_MS) { - // Real reversal (flip persisted OR flip-back arrived too late). - // Commit. The deferred event's 1 row is lost (acceptable latency). + // Real reversal (flip persisted OR flip-back too late). Commit. + // The deferred event's 1 row is lost — acceptable latency. state.dir = dir state.time = now state.mult = state.base @@ -149,15 +105,12 @@ function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number { return Math.floor(state.mult) } - // Bounce confirmed: flipped back to original dir in the window. - // Engage wheel-mode for sustained mouse-wheel pattern. state.wheelMode = true } const gap = now - state.time if (dir !== state.dir && state.dir !== 0) { - // Direction flip. Defer — next event decides bounce vs reversal. state.pendingFlip = true state.time = now @@ -169,8 +122,8 @@ function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number { if (state.wheelMode) { if (gap < WHEEL_BURST_MS) { - // Same-batch burst (SGR proportional reporting) OR trackpad flick. - // Give 1 row/event; trackpad flick hits the burst-count disengage. + // Same-batch burst (SGR proportional) OR trackpad flick. 1 row/event; + // trackpad flick trips the burst-count disengage. if (++state.burstCount >= 5) { state.wheelMode = false state.burstCount = 0 @@ -183,7 +136,6 @@ function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number { } } - // Re-check after possible disengage above. if (state.wheelMode) { const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS) const cap = Math.max(WHEEL_MODE_CAP, state.base * 2) @@ -194,8 +146,8 @@ function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number { return Math.floor(state.mult) } - // Trackpad / hi-res (native, non-wheel-mode). Tight 40ms window: - // sub-40ms ramps, anything slower resets to baseline. + // Trackpad / hi-res native: tight 40ms window — sub-window ramps, + // anything slower resets to baseline. if (gap > WHEEL_ACCEL_WINDOW_MS) { state.mult = state.base } else { @@ -215,13 +167,11 @@ function xtermJsStep(state: WheelAccelState, dir: -1 | 1, now: number): number { state.dir = dir if (sameDir && gap < WHEEL_BURST_MS) { - // Same-batch burst — 1 row/event, same philosophy as native. return 1 } if (!sameDir || gap > WHEEL_DECAY_IDLE_MS) { - // Direction reversal or long idle: start at 2 so the first click - // after a pause moves visibly. + // Reversal or long idle — start at 2 so first click after a pause moves visibly. state.mult = 2 state.frac = 0 } else {