mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-04 02:21:47 +00:00
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.
190 lines
6.2 KiB
TypeScript
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
|
|
}
|