hermes-agent/ui-tui/src/lib/wheelAccel.ts
Brooklyn Nicholson b1c49d5e73 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.
2026-04-26 20:38:47 -05:00

190 lines
6.2 KiB
TypeScript

// Wheel-scroll acceleration state machine.
//
// 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:
//
// 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
//
// 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 (ghostty, iTerm2, WezTerm, …) ───────────────────────────────
const WHEEL_ACCEL_WINDOW_MS = 40
const WHEEL_ACCEL_STEP = 0.3
const WHEEL_ACCEL_MAX = 6
// ── 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 (VS Code / Cursor / browser terminals) ────────────────────
const WHEEL_DECAY_HALFLIFE_MS = 150
const WHEEL_DECAY_STEP = 5
const WHEEL_BURST_MS = 5
const WHEEL_DECAY_GAP_MS = 80
const WHEEL_DECAY_CAP_SLOW = 3
const WHEEL_DECAY_CAP_FAST = 6
const WHEEL_DECAY_IDLE_MS = 500
export type WheelAccelState = {
time: number
mult: number
dir: 0 | 1 | -1
xtermJs: boolean
/** 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 baseline rows/event. Reset on idle/reversal; ramp builds on
* top. xterm.js path ignores. */
base: number
/** Deferred direction flip (native): bounce vs reversal — next event
* decides. */
pendingFlip: boolean
/** Sticky once a flip-then-flip-back fires within the bounce window.
* Cleared by idle disengage or trackpad burst. */
wheelMode: boolean
/** 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 }
}
/** HERMES_TUI_SCROLL_SPEED (or CLAUDE_CODE_SCROLL_SPEED for portability).
* Default 1, clamped (0, 20]. */
export function readScrollSpeedBase(): number {
const n = parseFloat(process.env.HERMES_TUI_SCROLL_SPEED ?? process.env.CLAUDE_CODE_SCROLL_SPEED ?? '')
return Number.isFinite(n) && n > 0 ? Math.min(n, 20) : 1
}
export function initWheelAccelForHost(): WheelAccelState {
return initWheelAccel(isXtermJs(), readScrollSpeedBase())
}
/** 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 {
return state.xtermJs ? xtermJsStep(state, dir, now) : nativeStep(state, dir, now)
}
function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
// 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
}
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 too late). Commit.
// The deferred event's 1 row is lost — acceptable latency.
state.dir = dir
state.time = now
state.mult = state.base
return Math.floor(state.mult)
}
state.wheelMode = true
}
const gap = now - state.time
if (dir !== state.dir && state.dir !== 0) {
state.pendingFlip = true
state.time = now
return 0
}
state.dir = dir
state.time = now
if (state.wheelMode) {
if (gap < WHEEL_BURST_MS) {
// 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
state.mult = state.base
} else {
return 1
}
} else {
state.burstCount = 0
}
}
if (state.wheelMode) {
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
const cap = Math.max(WHEEL_MODE_CAP, state.base * 2)
const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m
state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP)
return Math.floor(state.mult)
}
// 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 {
const cap = Math.max(WHEEL_ACCEL_MAX, state.base * 2)
state.mult = Math.min(cap, state.mult + WHEEL_ACCEL_STEP)
}
return Math.floor(state.mult)
}
function xtermJsStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
const gap = now - state.time
const sameDir = dir === state.dir
state.time = now
state.dir = dir
if (sameDir && gap < WHEEL_BURST_MS) {
return 1
}
if (!sameDir || gap > WHEEL_DECAY_IDLE_MS) {
// Reversal or long idle — start at 2 so first click after a pause moves visibly.
state.mult = 2
state.frac = 0
} else {
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
const cap = gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST
state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m)
}
const total = state.mult + state.frac
const rows = Math.floor(total)
state.frac = total - rows
return rows
}