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 useStderr } from './hooks/use-stderr.js'
export { default as useStdout } from './hooks/use-stdout.js' export { default as useStdout } from './hooks/use-stdout.js'
export { Ansi } from './ink/Ansi.js' export { Ansi } from './ink/Ansi.js'
export { export { evictInkCaches, type EvictLevel, type InkCacheSizes, inkCacheSizes } from './ink/cache-eviction.js'
evictInkCaches,
type EvictLevel,
type InkCacheSizes,
inkCacheSizes
} from './ink/cache-eviction.js'
export { AlternateScreen } from './ink/components/AlternateScreen.js' export { AlternateScreen } from './ink/components/AlternateScreen.js'
export { default as Box } from './ink/components/Box.js' export { default as Box } from './ink/components/Box.js'
export { default as Link } from './ink/components/Link.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 { useTerminalFocus } from './ink/hooks/use-terminal-focus.js'
export { useTerminalTitle } from './ink/hooks/use-terminal-title.js' export { useTerminalTitle } from './ink/hooks/use-terminal-title.js'
export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.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 { default as measureElement } from './ink/measure-element.js'
export { export { resetScrollFastPathStats, scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js'
resetScrollFastPathStats,
scrollFastPathStats,
type ScrollFastPathStats
} from './ink/render-node-to-output.js'
export { createRoot, default as render, renderSync } from './ink/root.js' export { createRoot, default as render, renderSync } from './ink/root.js'
export { stringWidth } from './ink/stringWidth.js' export { stringWidth } from './ink/stringWidth.js'
export { isXtermJs } from './ink/terminal.js'
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' 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 — // (not session-keyed), so cross-session sharing is normally beneficial —
// only evict when memory tightens or when the user explicitly resets. // 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 { evictLineWidthCache, lineWidthCacheSize } from './line-width-cache.js'
import { evictWidthCache, widthCacheSize } from './stringWidth.js' import { evictWidthCache, widthCacheSize } from './stringWidth.js'
import { evictWrapCache, wrapCacheSize } from './wrap-text.js' import { evictWrapCache, wrapCacheSize } from './wrap-text.js'
import { evictSliceCache, sliceCacheSize } from '../utils/sliceAnsi.js'
export interface InkCacheSizes { export interface InkCacheSizes {
lineWidth: number lineWidth: number
slice: number slice: number

View file

@ -979,15 +979,13 @@ export default class Ink {
} }
const tWrite = performance.now() const tWrite = performance.now()
// Capture any stale pending write BEFORE starting this frame's write — // Capture any stale pending write BEFORE starting this frame's write —
// if the callback already fired, pendingWriteStart is null and lastDrainMs // if the callback already fired, pendingWriteStart is null and lastDrainMs
// already reflects the previous frame's drain. If it hasn't fired, we // 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 // report "still pending" via a non-zero duration based on now-then so
// backpressure shows up even if Node never flushes this session. // backpressure shows up even if Node never flushes this session.
const staleDrain = const staleDrain = this.pendingWriteStart !== null ? performance.now() - this.pendingWriteStart : this.lastDrainMs
this.pendingWriteStart !== null
? performance.now() - this.pendingWriteStart
: this.lastDrainMs
const prevFrameDrainMs = Math.round(staleDrain * 100) / 100 const prevFrameDrainMs = Math.round(staleDrain * 100) / 100
this.lastDrainMs = 0 this.lastDrainMs = 0
@ -1016,6 +1014,7 @@ export default class Ink {
} }
: undefined : undefined
) )
const writeMs = performance.now() - tWrite const writeMs = performance.now() - tWrite
// Update blit safety for the NEXT frame. The frame just rendered // 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' import { stringWidth } from './stringWidth.js'
// During streaming, text grows but completed lines are immutable. // During streaming, text grows but completed lines are immutable.
@ -13,6 +14,7 @@ export function lineWidth(line: string): number {
if (cached !== undefined) { if (cached !== undefined) {
cache.delete(line) cache.delete(line)
cache.set(line, cached) cache.set(line, cached)
return cached return cached
} }
@ -32,14 +34,5 @@ export function lineWidthCacheSize(): number {
} }
export function evictLineWidthCache(keepRatio = 0): void { export function evictLineWidthCache(keepRatio = 0): void {
if (keepRatio <= 0) { lruEvict(cache, keepRatio)
cache.clear()
return
}
const target = Math.floor(cache.size * keepRatio)
while (cache.size > target) {
cache.delete(cache.keys().next().value!)
}
} }

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) { if (clipHorizontally) {
lines = lines.map(line => { lines = lines.map(line => {
const startsBefore = x < clip.x1!
const width = stringWidth(line) const width = stringWidth(line)
const startsBefore = x < clip.x1!
const endsAfter = x + width > clip.x2! const endsAfter = x + width > clip.x2!
// Fast path: line fits entirely within the clip box — skip // Fast path: line fits entirely within the clip box — skip
// the tokenize/slice. This is the common case for transcript // tokenize/slice. Common case for transcript text where
// text where containers are wider than the rendered content. // containers are wider than rendered content. CPU profile
// CPU profile (Apr 2026) showed sliceAnsi at 18% total time; // (Apr 2026): sliceAnsi at 18% total during scroll, mostly
// most calls were no-op slices like (line, 0, width). // no-op (line, 0, width) slices.
if (!startsBefore && !endsAfter) { if (!startsBefore && !endsAfter) {
return line return line
} }

View file

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

View file

@ -4,6 +4,8 @@ import stripAnsi from 'strip-ansi'
import { getGraphemeSegmenter } from '../utils/intl.js' import { getGraphemeSegmenter } from '../utils/intl.js'
import { lruEvict } from './lru.js'
const EMOJI_REGEX = emojiRegex() const EMOJI_REGEX = emojiRegex()
/** /**
@ -299,6 +301,7 @@ export const stringWidth: (str: string) => number = str => {
if (code >= 127 || code === 0x1b) { if (code >= 127 || code === 0x1b) {
asciiOnly = false asciiOnly = false
break break
} }
} }
@ -334,14 +337,5 @@ export function widthCacheSize(): number {
} }
export function evictWidthCache(keepRatio = 0): void { export function evictWidthCache(keepRatio = 0): void {
if (keepRatio <= 0) { lruEvict(widthCache, keepRatio)
widthCache.clear()
return
}
const target = Math.floor(widthCache.size * keepRatio)
while (widthCache.size > target) {
widthCache.delete(widthCache.keys().next().value!)
}
} }

View file

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

View file

@ -1,5 +1,6 @@
import sliceAnsi from '../utils/sliceAnsi.js' import sliceAnsi from '../utils/sliceAnsi.js'
import { lruEvict } from './lru.js'
import { stringWidth } from './stringWidth.js' import { stringWidth } from './stringWidth.js'
import type { Styles } from './styles.js' import type { Styles } from './styles.js'
import { wrapAnsi } from './wrapAnsi.js' import { wrapAnsi } from './wrapAnsi.js'
@ -113,14 +114,5 @@ export function wrapCacheSize(): number {
} }
export function evictWrapCache(keepRatio = 0): void { export function evictWrapCache(keepRatio = 0): void {
if (keepRatio <= 0) { lruEvict(wrapCache, keepRatio)
wrapCache.clear()
return
}
const target = Math.floor(wrapCache.size * keepRatio)
while (wrapCache.size > target) {
wrapCache.delete(wrapCache.keys().next().value!)
}
} }

View file

@ -1,5 +1,6 @@
import { type AnsiCode, ansiCodesToString, reduceAnsiCodes, tokenize, undoAnsiCodes } from '@alcalzone/ansi-tokenize' import { type AnsiCode, ansiCodesToString, reduceAnsiCodes, tokenize, undoAnsiCodes } from '@alcalzone/ansi-tokenize'
import { lruEvict } from '../ink/lru.js'
import { stringWidth } from '../ink/stringWidth.js' import { stringWidth } from '../ink/stringWidth.js'
function isEndCode(code: AnsiCode): boolean { function isEndCode(code: AnsiCode): boolean {
@ -19,7 +20,9 @@ const sliceCache = new Map<string, string>()
const SLICE_CACHE_LIMIT = 4096 const SLICE_CACHE_LIMIT = 4096
export default function sliceAnsi(str: string, start: number, end?: number): string { 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). // Hot-path: only cache when end is defined (the Output.get() use-case).
if (end !== undefined) { if (end !== undefined) {
@ -29,6 +32,7 @@ export default function sliceAnsi(str: string, start: number, end?: number): str
if (cached !== undefined) { if (cached !== undefined) {
sliceCache.delete(key) sliceCache.delete(key)
sliceCache.set(key, cached) sliceCache.set(key, cached)
return cached return cached
} }
@ -39,6 +43,7 @@ export default function sliceAnsi(str: string, start: number, end?: number): str
} }
sliceCache.set(key, result) sliceCache.set(key, result)
return result return result
} }
@ -50,16 +55,7 @@ export function sliceCacheSize(): number {
} }
export function evictSliceCache(keepRatio = 0): void { export function evictSliceCache(keepRatio = 0): void {
if (keepRatio <= 0) { lruEvict(sliceCache, keepRatio)
sliceCache.clear()
return
}
const target = Math.floor(sliceCache.size * keepRatio)
while (sliceCache.size > target) {
sliceCache.delete(sliceCache.keys().next().value!)
}
} }
function computeSlice(str: string, start: number, end?: number): string { function computeSlice(str: string, start: number, end?: number): string {

View file

@ -5,10 +5,9 @@ import type { Msg } from '../types.js'
describe('virtual height estimates', () => { describe('virtual height estimates', () => {
it('uses stable content keys across resumed message objects', () => { it('uses stable content keys across resumed message objects', () => {
const a: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] } const msg: Msg = { role: 'assistant', text: 'same text', tools: ['Search Files [long message]'] }
const b: 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', () => { it('accounts for wrapping and preserved blank-block rhythm', () => {

View file

@ -12,38 +12,29 @@ describe('wheelAccel — native path', () => {
it('same-direction fast events ramp mult (window-mode)', () => { it('same-direction fast events ramp mult (window-mode)', () => {
const s = initWheelAccel(false, 1) 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, 1000)
computeWheelStep(s, 1, 1020) computeWheelStep(s, 1, 1020)
computeWheelStep(s, 1, 1040) computeWheelStep(s, 1, 1040)
const fourth = computeWheelStep(s, 1, 1060)
// After 3 window events: mult starts at 1 → stays 1 on first ramp // Key property: doesn't shrink below base.
// (first event just sets baseline), then +0.3 × 3 = 1.9 → floor=1. expect(computeWheelStep(s, 1, 1060)).toBeGreaterThanOrEqual(1)
// The key property: doesn't shrink below base.
expect(fourth).toBeGreaterThanOrEqual(1)
}) })
it('gap beyond window resets mult to base', () => { it('gap beyond window resets mult to base', () => {
const s = initWheelAccel(false, 1) const s = initWheelAccel(false, 1)
// Ramp up
for (let t = 1000; t < 1100; t += 20) { for (let t = 1000; t < 1100; t += 20) {
computeWheelStep(s, 1, t) computeWheelStep(s, 1, t)
} }
// Long pause, then click expect(computeWheelStep(s, 1, 2000)).toBe(1)
const afterPause = computeWheelStep(s, 1, 2000)
expect(afterPause).toBe(1)
}) })
it('direction flip defers one event for bounce detection', () => { it('direction flip defers one event for bounce detection', () => {
const s = initWheelAccel(false, 1) const s = initWheelAccel(false, 1)
computeWheelStep(s, 1, 1000) computeWheelStep(s, 1, 1000)
// Flip — should defer
expect(computeWheelStep(s, -1, 1050)).toBe(0) expect(computeWheelStep(s, -1, 1050)).toBe(0)
}) })
@ -51,9 +42,7 @@ describe('wheelAccel — native path', () => {
const s = initWheelAccel(false, 1) const s = initWheelAccel(false, 1)
computeWheelStep(s, 1, 1000) computeWheelStep(s, 1, 1000)
// Flip (deferred)
computeWheelStep(s, -1, 1050) computeWheelStep(s, -1, 1050)
// Flip BACK within 200ms → bounce confirmed → wheelMode engaged
computeWheelStep(s, 1, 1100) computeWheelStep(s, 1, 1100)
expect(s.wheelMode).toBe(true) expect(s.wheelMode).toBe(true)
@ -63,8 +52,7 @@ describe('wheelAccel — native path', () => {
const s = initWheelAccel(false, 1) const s = initWheelAccel(false, 1)
computeWheelStep(s, 1, 1000) computeWheelStep(s, 1, 1000)
computeWheelStep(s, -1, 1050) // defer computeWheelStep(s, -1, 1050)
// Flip-back arrives 300ms later → too late → real reversal
computeWheelStep(s, 1, 1400) computeWheelStep(s, 1, 1400)
expect(s.wheelMode).toBe(false) expect(s.wheelMode).toBe(false)
@ -76,12 +64,9 @@ describe('wheelAccel — native path', () => {
s.dir = 1 s.dir = 1
s.time = 1000 s.time = 1000
// 5 bursts <5ms apart (trackpad flick) for (let t = 1002; t <= 1010; t += 2) {
computeWheelStep(s, 1, 1002) computeWheelStep(s, 1, t)
computeWheelStep(s, 1, 1004) }
computeWheelStep(s, 1, 1006)
computeWheelStep(s, 1, 1008)
computeWheelStep(s, 1, 1010)
expect(s.wheelMode).toBe(false) expect(s.wheelMode).toBe(false)
}) })
@ -92,7 +77,7 @@ describe('wheelAccel — native path', () => {
s.dir = 1 s.dir = 1
s.time = 1000 s.time = 1000
computeWheelStep(s, 1, 3000) // 2 second gap computeWheelStep(s, 1, 3000)
expect(s.wheelMode).toBe(false) expect(s.wheelMode).toBe(false)
}) })
@ -102,34 +87,23 @@ describe('wheelAccel — xterm.js path', () => {
it('first click returns 2 after long idle', () => { it('first click returns 2 after long idle', () => {
const s = initWheelAccel(true, 1) const s = initWheelAccel(true, 1)
// First event — "sameDir && gap > WHEEL_DECAY_IDLE_MS" triggers expect(computeWheelStep(s, 1, 1000)).toBeGreaterThanOrEqual(1)
// reset-to-2 branch since dir starts at 0 and 0 !== 1.
const n = computeWheelStep(s, 1, 1000)
expect(n).toBeGreaterThanOrEqual(1)
}) })
it('sub-5ms burst returns 1 (same-direction, same-batch)', () => { it('sub-5ms burst returns 1 (same-direction, same-batch)', () => {
const s = initWheelAccel(true, 1) const s = initWheelAccel(true, 1)
computeWheelStep(s, 1, 1000) 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', () => { it('slow steady scroll stays in precision range', () => {
const s = initWheelAccel(true, 1) const s = initWheelAccel(true, 1)
// Simulated 30Hz sustained scroll: 33ms gap
const results: number[] = []
for (let t = 1000; t < 2000; t += 33) { 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).toBeGreaterThanOrEqual(1)
expect(r).toBeLessThanOrEqual(6) expect(r).toBeLessThanOrEqual(6)
} }
@ -138,27 +112,22 @@ describe('wheelAccel — xterm.js path', () => {
it('direction reversal resets mult', () => { it('direction reversal resets mult', () => {
const s = initWheelAccel(true, 1) const s = initWheelAccel(true, 1)
// Ramp up
for (let t = 1000; t < 1100; t += 20) { for (let t = 1000; t < 1100; t += 20) {
computeWheelStep(s, 1, t) computeWheelStep(s, 1, t)
} }
const beforeFlip = s.mult const beforeFlip = s.mult
// Flip
computeWheelStep(s, -1, 1200) computeWheelStep(s, -1, 1200)
expect(s.mult).toBeLessThanOrEqual(beforeFlip) expect(s.mult).toBeLessThanOrEqual(beforeFlip)
// Reset branch sets mult=2
expect(s.mult).toBe(2) expect(s.mult).toBe(2)
}) })
it('frac stays in [0,1) across events', () => { it('frac stays in [0,1) across events', () => {
const s = initWheelAccel(true, 1) const s = initWheelAccel(true, 1)
// frac must never go negative or reach 1.0 — that's the correctness // Correctness invariant of fractional carry: never negative, never reaches 1.
// 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.
for (let t = 1000; t < 1200; t += 30) { for (let t = 1000; t < 1200; t += 30) {
computeWheelStep(s, 1, t) computeWheelStep(s, 1, t)

View file

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

View file

@ -29,35 +29,19 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const overlay = useStore($overlayState) const overlay = useStore($overlayState)
const isBlocked = useStore($isBlocked) const isBlocked = useStore($isBlocked)
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6) 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 // Wheel accel ported from claude-code: inter-event timing drives step size,
// step size per wheel event based on inter-event timing: fast flicks // direction flips reset. wheelStep (WHEEL_SCROLL_STEP) is the base; final
// ramp up, slow clicks stay at 1 row, direction flips reset. See // rows = wheelStep × accelMult. State mutates in place across renders.
// 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.
const wheelAccelRef = useRef(initWheelAccelForHost()) const wheelAccelRef = useRef(initWheelAccelForHost())
useEffect( useEffect(() => () => clearTimeout(scrollIdleTimer.current ?? undefined), [])
() => () => {
if (scrollIdleTimer.current) {
clearTimeout(scrollIdleTimer.current)
scrollIdleTimer.current = null
}
},
[]
)
const scrollTranscript = (delta: number) => { const scrollTranscript = (delta: number) => {
if (getUiState().busy) { if (getUiState().busy) {
turnController.boostStreamingForScroll() turnController.boostStreamingForScroll()
clearTimeout(scrollIdleTimer.current ?? undefined)
if (scrollIdleTimer.current) {
clearTimeout(scrollIdleTimer.current)
}
scrollIdleTimer.current = setTimeout(() => { scrollIdleTimer.current = setTimeout(() => {
scrollIdleTimer.current = null scrollIdleTimer.current = null
turnController.relaxStreaming() turnController.relaxStreaming()
@ -300,16 +284,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (key.wheelUp || key.wheelDown) { if (key.wheelUp || key.wheelDown) {
const dir: -1 | 1 = key.wheelUp ? -1 : 1 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 return rows ? scrollTranscript(dir * rows * wheelStep) : undefined
// 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)
} }
if (key.shift && key.upArrow) { if (key.shift && key.upArrow) {
@ -321,14 +299,9 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
} }
if (key.pageUp || key.pageDown) { 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) 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)) const step = Math.max(4, Math.floor(viewport / 2))
return scrollTranscript(key.pageUp ? -step : step) 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 { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from '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 { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { terminalParityHints } from '../lib/terminalParity.js' import { terminalParityHints } from '../lib/terminalParity.js'
import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
import { getViewportSnapshot } from '../lib/viewportStore.js'
import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js' import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js'
import type { Msg, PanelSection, SlashCatalog } from '../types.js' import type { Msg, PanelSection, SlashCatalog } from '../types.js'
@ -199,8 +198,10 @@ export function useMainApp(gw: GatewayClient) {
return `${thinking}:${tools}` return `${thinking}:${tools}`
}, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections]) }, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])
const detailsVisible = detailsLayoutKey !== 'hidden:hidden' const detailsVisible = detailsLayoutKey !== 'hidden:hidden'
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}` const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
const heightCache = useMemo(() => { const heightCache = useMemo(() => {
let cache = heightCachesRef.current.get(heightCacheKey) let cache = heightCachesRef.current.get(heightCacheKey)
@ -215,6 +216,7 @@ export function useMainApp(gw: GatewayClient) {
return cache return cache
}, [heightCacheKey]) }, [heightCacheKey])
const initialHeights = useMemo(() => { const initialHeights = useMemo(() => {
const out = new Map<string, number>() const out = new Map<string, number>()
@ -232,6 +234,7 @@ export function useMainApp(gw: GatewayClient) {
return out return out
}, [cols, detailsVisible, heightCache, ui.compact, virtualRows]) }, [cols, detailsVisible, heightCache, ui.compact, virtualRows])
const syncHeightCache = useCallback( const syncHeightCache = useCallback(
(heights: ReadonlyMap<string, number>) => { (heights: ReadonlyMap<string, number>) => {
for (const row of virtualRows) { for (const row of virtualRows) {
@ -719,26 +722,10 @@ export function useMainApp(gw: GatewayClient) {
[cols, composerActions, composerState, empty, pagerPageSize, submit] [cols, composerActions, composerState, empty, pagerPageSize, submit]
) )
const liveTailVisible = (() => { // Pass current progress through unfrozen — streaming update throttling
const s = scrollRef.current // handles interaction load; progress must stay truthful so panels don't
// randomly disappear when the live tail scrolls offscreen.
if (!s) { const appProgress = useMemo(() => ({ showProgressArea }), [showProgressArea])
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
const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd() const cwd = ui.info?.cwd || process.env.HERMES_CWD || process.cwd()
const gitBranch = useGitBranch(cwd) const gitBranch = useGitBranch(cwd)

View file

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

View file

@ -30,14 +30,18 @@ const TranscriptPane = memo(function TranscriptPane({
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) { }: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
const ui = useStore($uiState) const ui = useStore($uiState)
// Index of the latest user message — LiveTodoPanel is rendered as a child // LiveTodoPanel rides as a child of the latest user-message row so it
// of that row so it visually belongs to the user's prompt and follows it // visually belongs to the prompt and follows it during scroll. -1 when
// during scroll. Falls back to -1 when no user message exists yet (empty // empty → row.index === -1 is always false → no render.
// session); LiveTodoPanel then doesn't render at all.
const lastUserIdx = useMemo(() => { const lastUserIdx = useMemo(() => {
for (let i = transcript.historyItems.length - 1; i >= 0; i--) { const items = transcript.historyItems
if (transcript.historyItems[i].role === 'user') return i
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].role === 'user') {
return i
} }
}
return -1 return -1
}, [transcript.historyItems]) }, [transcript.historyItems])
@ -259,18 +263,9 @@ export const AppLayout = memo(function AppLayout({
}: AppLayoutProps) { }: AppLayoutProps) {
const overlay = useStore($overlayState) const overlay = useStore($overlayState)
// Inline mode: skip <AlternateScreen> so the TUI renders into the // Inline mode skips AlternateScreen so the host terminal's native
// primary buffer and the terminal's native scrollback can capture rows // scrollback captures rows scrolled off the top; composer + progress
// that scroll off the top. Mouse tracking is still enabled via // stay anchored via normal flex-column flow.
// 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).
const Shell = INLINE_MODE ? Fragment : AlternateScreen const Shell = INLINE_MODE ? Fragment : AlternateScreen
const shellProps = INLINE_MODE ? {} : { mouseTracking } const shellProps = INLINE_MODE ? {} : { mouseTracking }
@ -305,9 +300,6 @@ export const AppLayout = memo(function AppLayout({
<ComposerPane actions={actions} composer={composer} status={status} /> <ComposerPane actions={actions} composer={composer} status={status} />
</PerfPane> </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 && ( {SHOW_FPS && (
<Box flexShrink={0} justifyContent="flex-end" paddingRight={1}> <Box flexShrink={0} justifyContent="flex-end" paddingRight={1}>
<FpsOverlay /> <FpsOverlay />

View file

@ -1,10 +1,4 @@
// FPS counter overlay — renders in the bottom-right corner when // FPS counter overlay (HERMES_TUI_FPS=1). Zero-cost when disabled.
// 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.
import { Text } from '@hermes/ink' import { Text } from '@hermes/ink'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
@ -12,17 +6,7 @@ import { useStore } from '@nanostores/react'
import { SHOW_FPS } from '../config/env.js' import { SHOW_FPS } from '../config/env.js'
import { $fpsState } from '../lib/fpsStore.js' import { $fpsState } from '../lib/fpsStore.js'
const fpsColor = (fps: number) => { const fpsColor = (fps: number) => (fps >= 50 ? 'green' : fps >= 30 ? 'yellow' : 'red')
if (fps >= 50) {
return 'green'
}
if (fps >= 30) {
return 'yellow'
}
return 'red'
}
export function FpsOverlay() { export function FpsOverlay() {
if (!SHOW_FPS) { if (!SHOW_FPS) {
@ -35,14 +19,10 @@ export function FpsOverlay() {
function FpsOverlayInner() { function FpsOverlayInner() {
const { fps, lastDurationMs, totalFrames } = useStore($fpsState) const { fps, lastDurationMs, totalFrames } = useStore($fpsState)
// Zero-pad to stable width so the corner doesn't jitter as digits // Zero-pad widths so digit churn doesn't jitter the corner.
// come and go. Format: " 62fps 0.3ms #12345"
const fpsStr = fps.toFixed(1).padStart(5)
const durStr = lastDurationMs.toFixed(1).padStart(5)
return ( return (
<Text color={fpsColor(fps)}> <Text color={fpsColor(fps)}>
{fpsStr}fps · {durStr}ms · #{totalFrames} {fps.toFixed(1).padStart(5)}fps · {lastDurationMs.toFixed(1).padStart(5)}ms · #{totalFrames}
</Text> </Text>
) )
} }

View file

@ -1,5 +1,5 @@
import { Box, Link, Text } from '@hermes/ink' 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 { ensureEmojiPresentation } from '../lib/emoji.js'
import { highlightLine, isHighlightable } from '../lib/syntax.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> return <Text>{parts.length ? parts : <Text>{text}</Text>}</Text>
} }
// Cross-instance parsed-children cache. `Md` is mounted fresh whenever a // Cross-instance parsed-children cache: useMemo's per-instance cache dies
// virtualized row enters the mount window — useMemo's per-instance cache // on remount, so virtualization re-parses every row that scrolls back into
// doesn't survive remounts, so PageUp into cold/resumed history reparses // view. Theme-keyed WeakMap drops stale palettes; inner Map is LRU-bounded.
// 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.
const MD_CACHE_LIMIT = 512 const MD_CACHE_LIMIT = 512
const mdCache = new WeakMap<Theme, Map<string, ReactNode[]>>() const mdCache = new WeakMap<Theme, Map<string, ReactNode[]>>()
const cacheBucket = (t: Theme) => { const cacheBucket = (t: Theme) => {
let b = mdCache.get(t) const b = mdCache.get(t)
if (!b) { if (b) {
b = new Map() return b
mdCache.set(t, b)
} }
return b const fresh = new Map<string, ReactNode[]>()
mdCache.set(t, fresh)
return fresh
} }
const cacheGet = (b: Map<string, ReactNode[]>, key: string) => { const cacheGet = (b: Map<string, ReactNode[]>, key: string) => {

View file

@ -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 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 MOUSE_TRACKING = !truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
export const NO_CONFIRM_DESTRUCTIVE = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_NO_CONFIRM ?? '').trim()) export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM)
// Inline mode: skip the alt-screen wrapper. The TUI renders into the
// primary buffer so the terminal's native scrollback captures whatever // Skip AlternateScreen — TUI renders into the primary buffer so the host
// scrolls off the top. Wheel + PageUp are then handled by the host // terminal's native scrollback captures whatever scrolls off the top.
// terminal, not by our virtual-scroll logic. The live composer/progress // Experiment gate: lets us measure native scroll vs our virtualization on
// area still pins to the bottom via Ink's normal flow. // the same pipeline.
// export const INLINE_MODE = truthy(process.env.HERMES_TUI_INLINE)
// This is an experiment gate — the full "inline layout" (plain-text
// transcript with composer pinned below) is a bigger change; the env var // Live FPS counter overlay, fed by ink's onFrame (real render rate, not a
// here just disables AlternateScreen so we can measure whether native // synthetic timer).
// scrolling beats our virtualization on the same pipeline. export const SHOW_FPS = truthy(process.env.HERMES_TUI_FPS)
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())

View file

@ -1,33 +1,22 @@
export const LARGE_PASTE = { chars: 8000, lines: 80 } export const LARGE_PASTE = { chars: 8000, lines: 80 }
export const LIVE_RENDER_MAX_CHARS = 16_000 export const LIVE_RENDER_MAX_CHARS = 16_000
export const LIVE_RENDER_MAX_LINES = 240 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 // History-render bounds for messages outside FULL_RENDER_TAIL. Each rendered
// the dominant lever on cold-mount cost during PageUp catch-up. 16 lines // line ≈ 1 Yoga/Text node + inline spans, so this is the dominant lever on
// × 25 mounted items ≈ 400 nodes total — small enough that the per-frame // cold-mount cost during PageUp catch-up. 16 lines × 25 mounted ≈ 400 nodes
// buffer-compose stays well inside the 16ms budget. User pages back to // — comfortably inside the 16ms per-frame budget. User pages back to
// recognize where they were, not to read; stopping near a message // recognize, not to read; full re-render once it falls inside the tail.
// re-renders it in full once it falls inside the tail window.
export const HISTORY_RENDER_MAX_CHARS = 800 export const HISTORY_RENDER_MAX_CHARS = 800
export const HISTORY_RENDER_MAX_LINES = 16 export const HISTORY_RENDER_MAX_LINES = 16
export const FULL_RENDER_TAIL_ITEMS = 8 export const FULL_RENDER_TAIL_ITEMS = 8
export const LONG_MSG = 300 export const LONG_MSG = 300
export const MAX_HISTORY = 800 export const MAX_HISTORY = 800
export const THINKING_COT_MAX = 160 export const THINKING_COT_MAX = 160
// Rows scrolled per wheel-notch event.
// // Rows per wheel event (pre-accel). 1 keeps Ink's DECSTBM fast path live
// One notch of a mechanical wheel emits multiple wheel events (3-5 per // (each scroll < viewport-1) and produces smooth motion. wheelAccel.ts
// click in most terminals; trackpad flicks emit 100+). Each event scrolls // ramps this on sustained 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.
export const WHEEL_SCROLL_STEP = 1 export const WHEEL_SCROLL_STEP = 1

View file

@ -1,4 +1,6 @@
#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc #!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc
import type { FrameEvent } from '@hermes/ink'
import { GatewayClient } from './gatewayClient.js' import { GatewayClient } from './gatewayClient.js'
import { setupGracefulExit } from './lib/gracefulExit.js' import { setupGracefulExit } from './lib/gracefulExit.js'
import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.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()) process.on('beforeExit', () => stopMemoryMonitor())
const [{ render }, { App }, { logFrameEvent }, { trackFrame }] = await Promise.all([ const [ink, { App }, { logFrameEvent }, { trackFrame }] = await Promise.all([
import('@hermes/ink'), import('@hermes/ink'),
import('./app.js'), import('./app.js'),
import('./lib/perfPane.js'), import('./lib/perfPane.js'),
import('./lib/fpsStore.js') import('./lib/fpsStore.js')
]) ])
// Compose onFrame from the two opt-in consumers (HERMES_DEV_PERF and // Both consumers are undefined when their env flags are off; only attach
// HERMES_TUI_FPS). Each is undefined when its env flag is off; we only // onFrame when at least one is on so ink skips timing in the default case.
// attach onFrame at all when at least one is on, so ink skips the const onFrame =
// handler entirely in the default disabled case.
type InkFrameEvent = { durationMs: number }
type OnFrame = (event: InkFrameEvent) => void
const onFrame: OnFrame | undefined =
logFrameEvent || trackFrame logFrameEvent || trackFrame
? (event: InkFrameEvent) => { ? (event: FrameEvent) => {
logFrameEvent?.(event as Parameters<NonNullable<typeof logFrameEvent>>[0]) logFrameEvent?.(event)
trackFrame?.(event.durationMs) trackFrame?.(event.durationMs)
} }
: undefined : undefined
render(<App gw={gw} />, { exitOnCtrlC: false, onFrame }) ink.render(<App gw={gw} />, { exitOnCtrlC: false, onFrame })

View file

@ -1,13 +1,13 @@
import type { ScrollBoxHandle } from '@hermes/ink' import type { ScrollBoxHandle } from '@hermes/ink'
import { import {
type RefObject,
useCallback, useCallback,
useDeferredValue, useDeferredValue,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
useSyncExternalStore, useSyncExternalStore
type RefObject
} from 'react' } from 'react'
const ESTIMATE = 4 const ESTIMATE = 4
@ -98,6 +98,7 @@ export function useVirtualHistory(
// Bump whenever heightCache mutates so offsets rebuild on next read. // Bump whenever heightCache mutates so offsets rebuild on next read.
// Ref (not state) — checked during render phase, zero extra commits. // Ref (not state) — checked during render phase, zero extra commits.
const offsetVersion = useRef(0) const offsetVersion = useRef(0)
// Cached offsets: reused Float64Array keyed on (itemCount, version) so we // Cached offsets: reused Float64Array keyed on (itemCount, version) so we
// only rebuild when something actually changed. Previous approach allocated // 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 // 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, n: -1,
version: -1 version: -1
}) })
const [hasScrollRef, setHasScrollRef] = useState(false) const [hasScrollRef, setHasScrollRef] = useState(false)
const metrics = useRef({ sticky: true, top: 0, vp: 0 }) const metrics = useRef({ sticky: true, top: 0, vp: 0 })
const lastScrollTopRef = useRef(0) const lastScrollTopRef = useRef(0)
@ -158,6 +160,7 @@ export function useVirtualHistory(
(cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? NOOP, (cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? NOOP,
[hasScrollRef, scrollRef] [hasScrollRef, scrollRef]
) )
useSyncExternalStore( useSyncExternalStore(
subscribe, subscribe,
() => { () => {
@ -310,13 +313,8 @@ export function useVirtualHistory(
if (velocity > vp * 2) { if (velocity > vp * 2) {
const [pS, pE] = prevRange.current const [pS, pE] = prevRange.current
if (start < pS - SLIDE_STEP) { start = Math.max(start, pS - SLIDE_STEP)
start = pS - SLIDE_STEP end = Math.min(end, pE + SLIDE_STEP)
}
if (end > pE + SLIDE_STEP) {
end = pE + SLIDE_STEP
}
// A large jump past the capped end can invert (start > end); mount // 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 // SLIDE_STEP items from the new start so the viewport isn't blank

View file

@ -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 // Zero-cost when HERMES_TUI_FPS is unset: trackFrame is undefined so the
// from the rolling window. Updates a nanostore so a corner-overlay // onFrame callback short-circuits at the optional chain.
// 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.
import { atom } from 'nanostores' import { atom } from 'nanostores'
import { SHOW_FPS } from '../config/env.js' import { SHOW_FPS } from '../config/env.js'
const WINDOW_SIZE = 30 // last 30 frames const WINDOW_SIZE = 30
export type FpsState = { export type FpsState = {
/** Frames per second averaged over the last WINDOW_SIZE frames. */
fps: number fps: number
/** Total frames counted since start (wraps at JS-safe int so you can /** Wraps at JS-safe int — diff pairs in a debug overlay safely. */
* diff pairs in a debug overlay without worrying about precision). */
totalFrames: number totalFrames: number
/** Last frame's durationMs (ink render phase total). */ /** Ink render-phase total for the last frame. */
lastDurationMs: number lastDurationMs: number
} }
export const $fpsState = atom<FpsState>({ export const $fpsState = atom<FpsState>({ fps: 0, lastDurationMs: 0, totalFrames: 0 })
fps: 0,
lastDurationMs: 0,
totalFrames: 0
})
const timestamps: number[] = [] const timestamps: number[] = []
let totalFrames = 0 let totalFrames = 0
export const trackFrame = SHOW_FPS export const trackFrame = SHOW_FPS
? (durationMs: number) => { ? (durationMs: number) => {
const now = performance.now() timestamps.push(performance.now())
timestamps.push(now)
if (timestamps.length > WINDOW_SIZE) { if (timestamps.length > WINDOW_SIZE) {
timestamps.shift() timestamps.shift()
@ -50,20 +34,18 @@ export const trackFrame = SHOW_FPS
totalFrames++ totalFrames++
// FPS = frames-in-window / seconds-in-window. Needs at least 2 if (timestamps.length < 2) {
// timestamps to compute a gap. return
if (timestamps.length >= 2) { }
const elapsed = (timestamps[timestamps.length - 1]! - timestamps[0]!) / 1000 const elapsed = (timestamps[timestamps.length - 1]! - timestamps[0]!) / 1000
if (elapsed > 0) { if (elapsed > 0) {
const fps = (timestamps.length - 1) / elapsed
$fpsState.set({ $fpsState.set({
fps: Math.round(fps * 10) / 10, fps: Math.round(((timestamps.length - 1) / elapsed) * 10) / 10,
lastDurationMs: Math.round(durationMs * 100) / 100, lastDurationMs: Math.round(durationMs * 100) / 100,
totalFrames totalFrames
}) })
} }
} }
}
: undefined : undefined

View file

@ -104,7 +104,10 @@ describe('appendToolShelfMessage', () => {
it('starts a new shelf across assistant text boundaries', () => { it('starts a new shelf across assistant text boundaries', () => {
const merged = appendToolShelfMessage( 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 ✓'] } { kind: 'trail', role: 'system', text: '', tools: ['two ✓'] }
) )

View file

@ -38,8 +38,7 @@ const isBarrierMessage = (msg: Msg | undefined) => {
return false return false
} }
const isToolCarryingTrail = (msg: Msg | undefined) => const isToolCarryingTrail = (msg: Msg | undefined) => Boolean(msg?.kind === 'trail' && !msg.text && msg.tools?.length)
Boolean(msg?.kind === 'trail' && !msg.text && msg.tools?.length)
export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => { export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => {
if (!isToolShelfMessage(msg)) { if (!isToolShelfMessage(msg)) {

View file

@ -41,10 +41,8 @@ export function startMemoryMonitor({
return return
} }
// Defensive eviction: prune Ink content caches before dumping/exiting. // Prune Ink content caches before dump/exit — half on 'high' (recoverable),
// 'high' = half-prune (still warm enough to recover quickly); // full on 'critical' (post-dump RSS reduction, keeps user running).
// 'critical' = full drop. Reduces post-dump RSS and gives the user a
// chance to keep running rather than auto-restart.
evictInkCaches(level === 'critical' ? 'all' : 'half') evictInkCaches(level === 'critical' ? 'all' : 'half')
dumped.add(level) dumped.add(level)

View file

@ -1,43 +1,15 @@
// Perf instrumentation for the full render pipeline. // Perf instrumentation for the full render pipeline.
// //
// Two sources of timing: // PerfPane (React.Profiler) → per-pane commit times
// 1. React.Profiler wrapper (PerfPane) → per-pane commit times. Shows // logFrameEvent (ink.onFrame) → yoga / renderer / diff / optimize / write
// which subtree is reconciling and for how long. // phases + yoga counters + scroll fast-path
// 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.
// //
// Both sources gate on HERMES_DEV_PERF=1 and dump JSON-lines to the same // Both gate on HERMES_DEV_PERF=1 and dump JSON-lines (default ~/.hermes/perf.log,
// log (default ~/.hermes/perf.log, override via HERMES_DEV_PERF_LOG). // override HERMES_DEV_PERF_LOG). Tagged { src: 'react' | 'frame' } for jq.
// Events are tagged { src: 'react' | 'frame' } so jq can split them. // 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 // Zero cost when unset: PerfPane returns children directly, logFrameEvent is
// frames. For the 2fps-during-PageUp investigation, set // undefined so ink doesn't pay the timing cost.
// 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(<App/>, { 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
import { appendFileSync, mkdirSync } from 'node:fs' import { appendFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os' 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 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') const LOG_PATH = process.env.HERMES_DEV_PERF_LOG?.trim() || join(homedir(), '.hermes', 'perf.log')
let initialized = false let logReady = false
const ensureLogDir = () => { const writeRow = (row: Record<string, unknown>) => {
if (initialized) { if (!logReady) {
return logReady = true
}
initialized = true
try { try {
mkdirSync(dirname(LOG_PATH), { recursive: true }) mkdirSync(dirname(LOG_PATH), { recursive: true })
} catch { } catch {
// Best-effort — if we can't create the dir (readonly fs, /tmp, etc.) // Best-effort — never crash the TUI to log a sample.
// the appendFileSync calls below will throw silently and we drop the }
// sample. Perf logging should never crash the TUI.
} }
}
const writeRow = (row: Record<string, unknown>) => {
ensureLogDir()
try { try {
appendFileSync(LOG_PATH, `${JSON.stringify(row)}\n`) appendFileSync(LOG_PATH, `${JSON.stringify(row)}\n`)
} catch { } 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 export const logFrameEvent = ENABLED
? (event: FrameEvent) => { ? (event: FrameEvent) => {
if (event.durationMs < THRESHOLD_MS) { if (event.durationMs < THRESHOLD_MS) {
return 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({ writeRow({
durationMs: round2(event.durationMs), 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, flickers: event.flickers.length ? event.flickers : undefined,
phases: event.phases phases: event.phases
? { ? {
backpressure: event.phases.backpressure, ...event.phases,
commit: round2(event.phases.commit), commit: round2(event.phases.commit),
diff: round2(event.phases.diff), diff: round2(event.phases.diff),
optimize: round2(event.phases.optimize), optimize: round2(event.phases.optimize),
optimizedPatches: event.phases.optimizedPatches,
patches: event.phases.patches,
prevFrameDrainMs: round2(event.phases.prevFrameDrainMs), prevFrameDrainMs: round2(event.phases.prevFrameDrainMs),
renderer: round2(event.phases.renderer), renderer: round2(event.phases.renderer),
write: round2(event.phases.write), write: round2(event.phases.write),
writeBytes: event.phases.writeBytes, yoga: round2(event.phases.yoga)
yoga: round2(event.phases.yoga),
yogaCacheHits: event.phases.yogaCacheHits,
yogaLive: event.phases.yogaLive,
yogaMeasured: event.phases.yogaMeasured,
yogaVisited: event.phases.yogaVisited
} }
: undefined, : undefined,
src: 'frame', src: 'frame',

View file

@ -80,20 +80,15 @@ export const pasteTokenLabel = (text: string, lineCount: number) => {
const THINKING_STATUS_RE = new RegExp(`^(?:${VERBS.join('|')})\\.{0,3}$`, 'i') 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 THINKING_STATUS_CHUNK_RE = new RegExp(`[^A-Za-z\n]+\\s*(?:${VERBS.join('|')})\\.{0,3}\\s*`, 'giu')
const normalizeThinkingParagraphs = (text: string) =>
text
.replace(/([^\n])(?=\*\*[^*\n][^\n]*?\*\*)/g, '$1\n\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
export const cleanThinkingText = (reasoning: string) => export const cleanThinkingText = (reasoning: string) =>
normalizeThinkingParagraphs(
reasoning reasoning
.split('\n') .split('\n')
.map(line => line.replace(THINKING_STATUS_CHUNK_RE, '').trim()) .map(line => line.replace(THINKING_STATUS_CHUNK_RE, '').trim())
.filter(line => line && !THINKING_STATUS_RE.test(line.replace(/\.\.\.$/, '').trim())) .filter(line => line && !THINKING_STATUS_RE.test(line.replace(/\.\.\.$/, '').trim()))
.join('\n') .join('\n')
) .replace(/([^\n])(?=\*\*[^*\n][^\n]*?\*\*)/g, '$1\n\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => { export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => {
const raw = cleanThinkingText(reasoning) const raw = cleanThinkingText(reasoning)

View file

@ -14,18 +14,18 @@ export const hashText = (text: string) => {
export const messageHeightKey = (msg: Msg) => { export const messageHeightKey = (msg: Msg) => {
const todoSig = msg.todos?.map(t => `${t.status}:${t.content}`).join('\u0001') ?? '' const todoSig = msg.todos?.map(t => `${t.status}:${t.content}`).join('\u0001') ?? ''
const panelSig = const panelSig =
msg.panelData?.sections msg.panelData?.sections
.map(s => `${s.title ?? ''}:${s.text?.length ?? 0}:${s.items?.length ?? 0}:${s.rows?.length ?? 0}`) .map(s => `${s.title ?? ''}:${s.text?.length ?? 0}:${s.items?.length ?? 0}:${s.rows?.length ?? 0}`)
.join('\u0001') ?? '' .join('\u0001') ?? ''
const introSig = msg.kind === 'intro' ? (msg.info?.version ?? '') : '' const introSig = msg.kind === 'intro' ? (msg.info?.version ?? '') : ''
return [ return [
msg.role, msg.role,
msg.kind ?? '', msg.kind ?? '',
hashText( hashText([msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? '', todoSig, panelSig, introSig].join('\0'))
[msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? '', todoSig, panelSig, introSig].join('\0')
)
].join(':') ].join(':')
} }
@ -68,11 +68,9 @@ export const estimatedMsgHeight = (
h += (msg.tools?.length ?? 0) + wrappedLines(msg.thinking ?? '', bodyWidth) 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') { if (msg.role === 'user' || msg.kind === 'diff') {
h += 2
} else if (msg.kind === 'slash') {
h++ h++
} }

View file

@ -1,52 +1,35 @@
// Wheel-scroll acceleration state machine. // Wheel-scroll acceleration state machine.
// //
// Algorithm and tuning constants adapted from a reference implementation // One event = 1 row feels sluggish on trackpads (200+ ev/s) and sustained
// of trackpad/wheel-event acceleration in TUI scroll handlers; this file // mouse-wheel; one event = 6 rows teleports and ruins precision.
// is the port adapted to our module structure. // Heuristic on inter-event gap + direction flips:
// //
// Problem: one wheel event = 1 scrolled row feels sluggish on trackpads // gap < 5ms → same-batch burst → 1 row/event
// (which can fire 200+ events/sec) and during deliberate mouse scrolls. // gap < 40ms (native) → ramp +0.3, cap 6
// One wheel event = 6 rows (our old WHEEL_SCROLL_STEP=6) visually // gap 80-500ms (xterm.js) → mult = 1 + (mult-1)·0.5^(gap/150) + 5·decay
// teleports and ruins precision. The right answer depends on intent: // 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 // Native terminals (Ghostty, iTerm2) and xterm.js embedders (VS Code,
// sustained mouse → ramp to ~15 rows/event, decay when slowing down // Cursor) emit wheel events with different cadences, hence two paths.
// 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.
import { isXtermJs } from '@hermes/ink' import { isXtermJs } from '@hermes/ink'
// ── Native path (ghostty, iTerm2, WezTerm, etc.) ─────────────────────── // ── Native (ghostty, iTerm2, WezTerm, …) ───────────────────────────────
const WHEEL_ACCEL_WINDOW_MS = 40 const WHEEL_ACCEL_WINDOW_MS = 40
const WHEEL_ACCEL_STEP = 0.3 const WHEEL_ACCEL_STEP = 0.3
const WHEEL_ACCEL_MAX = 6 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_BOUNCE_GAP_MAX_MS = 200
const WHEEL_MODE_STEP = 15 const WHEEL_MODE_STEP = 15
const WHEEL_MODE_CAP = 15 const WHEEL_MODE_CAP = 15
const WHEEL_MODE_RAMP = 3 const WHEEL_MODE_RAMP = 3
const WHEEL_MODE_IDLE_DISENGAGE_MS = 1500 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_HALFLIFE_MS = 150
const WHEEL_DECAY_STEP = 5 const WHEEL_DECAY_STEP = 5
const WHEEL_BURST_MS = 5 const WHEEL_BURST_MS = 5
@ -60,88 +43,61 @@ export type WheelAccelState = {
mult: number mult: number
dir: 0 | 1 | -1 dir: 0 | 1 | -1
xtermJs: boolean xtermJs: boolean
/** Carried fractional scroll (xterm.js only). scrollBy floors, so /** Carried fractional scroll (xterm.js). scrollBy floors, so without
* without this a mult of 1.5 gives 1 row every time. Carrying the * this a mult of 1.5 always gives 1 row; carrying the remainder gives
* remainder gives 1,2,1,2 on average for mult=1.5 correct * 1,2,1,2 correct throughput over time. */
* throughput over time. */
frac: number frac: number
/** Native-path baseline rows/event. Reset value on idle/reversal; /** Native baseline rows/event. Reset on idle/reversal; ramp builds on
* ramp builds on top. xterm.js path ignores this. */ * top. xterm.js path ignores. */
base: number base: number
/** Deferred direction flip (native only). Might be encoder bounce or /** Deferred direction flip (native): bounce vs reversal next event
* a real reversal resolved by the NEXT event. */ * decides. */
pendingFlip: boolean pendingFlip: boolean
/** Confirmed once a bounce fired (flip-then-flip-back within the /** Sticky once a flip-then-flip-back fires within the bounce window.
* bounce window). Sticky until idle disengage or trackpad burst. */ * Cleared by idle disengage or trackpad burst. */
wheelMode: boolean wheelMode: boolean
/** Consecutive <5ms events. Trackpad flick ≥5 → disengage wheelMode. */ /** Consecutive <5ms events. ≥5 → trackpad flick → disengage. */
burstCount: number burstCount: number
} }
export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState { export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState {
return { return { burstCount: 0, base, dir: 0, frac: 0, mult: base, pendingFlip: false, time: 0, wheelMode: false, xtermJs }
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 /** HERMES_TUI_SCROLL_SPEED (or CLAUDE_CODE_SCROLL_SPEED for portability).
* portability from claude-code users). Default 1, clamped (0, 20]. */ * Default 1, clamped (0, 20]. */
export function readScrollSpeedBase(): number { 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 Number.isFinite(n) && n > 0 ? Math.min(n, 20) : 1
return 1
}
const n = parseFloat(raw)
return Number.isNaN(n) || n <= 0 ? 1 : Math.min(n, 20)
} }
/** Initialize the accel state with environment-derived defaults. */
export function initWheelAccelForHost(): WheelAccelState { export function initWheelAccelForHost(): WheelAccelState {
return initWheelAccel(isXtermJs(), readScrollSpeedBase()) return initWheelAccel(isXtermJs(), readScrollSpeedBase())
} }
/** /** Compute rows for one wheel event, mutating `state`. Returns 0 when a
* Compute rows for one wheel event, MUTATING the accel state. Returns 0 * direction flip is deferred for bounce detection call sites should
* when a direction flip is deferred for bounce detection call sites * no-op on 0. */
* should no-op on 0 (scrollBy(0) is a no-op anyway, but explicit check
* keeps the intent obvious).
*/
export function computeWheelStep(state: WheelAccelState, dir: -1 | 1, now: number): number { export function computeWheelStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
if (!state.xtermJs) { return state.xtermJs ? xtermJsStep(state, dir, now) : nativeStep(state, dir, now)
return nativeStep(state, dir, now)
}
return xtermJsStep(state, dir, now)
} }
function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number { function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
// Device-switch guard ①: idle disengage. A pending bounce can mask // Idle disengage runs first so a pending bounce can't mask "user paused
// as a real reversal via the early return below — run this first so // 1.5s then mouse-clicked" as a real reversal.
// "user stopped for 1.5s then mouse-click" restarts at baseline.
if (state.wheelMode && now - state.time > WHEEL_MODE_IDLE_DISENGAGE_MS) { if (state.wheelMode && now - state.time > WHEEL_MODE_IDLE_DISENGAGE_MS) {
state.wheelMode = false state.wheelMode = false
state.burstCount = 0 state.burstCount = 0
state.mult = state.base state.mult = state.base
} }
// Resolve any deferred flip before touching state.time/dir.
if (state.pendingFlip) { if (state.pendingFlip) {
state.pendingFlip = false state.pendingFlip = false
if (dir !== state.dir || now - state.time > WHEEL_BOUNCE_GAP_MAX_MS) { if (dir !== state.dir || now - state.time > WHEEL_BOUNCE_GAP_MAX_MS) {
// Real reversal (flip persisted OR flip-back arrived too late). // Real reversal (flip persisted OR flip-back too late). Commit.
// Commit. The deferred event's 1 row is lost (acceptable latency). // The deferred event's 1 row is lost — acceptable latency.
state.dir = dir state.dir = dir
state.time = now state.time = now
state.mult = state.base state.mult = state.base
@ -149,15 +105,12 @@ function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
return Math.floor(state.mult) 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 state.wheelMode = true
} }
const gap = now - state.time const gap = now - state.time
if (dir !== state.dir && state.dir !== 0) { if (dir !== state.dir && state.dir !== 0) {
// Direction flip. Defer — next event decides bounce vs reversal.
state.pendingFlip = true state.pendingFlip = true
state.time = now state.time = now
@ -169,8 +122,8 @@ function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
if (state.wheelMode) { if (state.wheelMode) {
if (gap < WHEEL_BURST_MS) { if (gap < WHEEL_BURST_MS) {
// Same-batch burst (SGR proportional reporting) OR trackpad flick. // Same-batch burst (SGR proportional) OR trackpad flick. 1 row/event;
// Give 1 row/event; trackpad flick hits the burst-count disengage. // trackpad flick trips the burst-count disengage.
if (++state.burstCount >= 5) { if (++state.burstCount >= 5) {
state.wheelMode = false state.wheelMode = false
state.burstCount = 0 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) { if (state.wheelMode) {
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS) const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
const cap = Math.max(WHEEL_MODE_CAP, state.base * 2) 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) return Math.floor(state.mult)
} }
// Trackpad / hi-res (native, non-wheel-mode). Tight 40ms window: // Trackpad / hi-res native: tight 40ms window — sub-window ramps,
// sub-40ms ramps, anything slower resets to baseline. // anything slower resets to baseline.
if (gap > WHEEL_ACCEL_WINDOW_MS) { if (gap > WHEEL_ACCEL_WINDOW_MS) {
state.mult = state.base state.mult = state.base
} else { } else {
@ -215,13 +167,11 @@ function xtermJsStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
state.dir = dir state.dir = dir
if (sameDir && gap < WHEEL_BURST_MS) { if (sameDir && gap < WHEEL_BURST_MS) {
// Same-batch burst — 1 row/event, same philosophy as native.
return 1 return 1
} }
if (!sameDir || gap > WHEEL_DECAY_IDLE_MS) { if (!sameDir || gap > WHEEL_DECAY_IDLE_MS) {
// Direction reversal or long idle: start at 2 so the first click // Reversal or long idle — start at 2 so first click after a pause moves visibly.
// after a pause moves visibly.
state.mult = 2 state.mult = 2
state.frac = 0 state.frac = 0
} else { } else {