From 4395c2b0073fe4bab4509fcd5967f1709a73e930 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Apr 2026 17:16:11 -0500 Subject: [PATCH] feat(tui): port claude-code's wheel accel state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the static WHEEL_SCROLL_STEP=1 multiplier on wheel events with an adaptive accel state machine that infers user intent from inter-event timing. Algorithm ported straight from claude-code's src/components/ScrollKeybindingHandler.tsx. All tuning constants, the native/xterm.js path split, the encoder-bounce detection, the trackpad-burst signature → all theirs. This file is a mechanical port into our module structure. What it does: precision click (>500ms gap) 1 row/event (deliberate scan) sustained mouse (40-200ms) 2-6 rows (decay curve) detected wheel bounce ramps to 15 (sticky wheel-mode) trackpad flick (5+ <5ms) 1 row/event (burst detect) direction reversal reset to base Two implementation paths: * native terminals (ghostty, iTerm2, Kitty, WezTerm) — linear window-ramp + optional wheel-mode curve triggered by detected encoder bounce. SGR proportional reporting handled via the burst-count guard. * xterm.js (VS Code / Cursor / browser terminals) — pure exponential-decay curve with fractional carry. Events arrive 1-per-notch with no pre-amplification, so the curve is more aggressive. Selected at construction via isXtermJs() from @hermes/ink (now exported). Per-user tune via HERMES_TUI_SCROLL_SPEED (alias CLAUDE_CODE_SCROLL_SPEED for portability). 13 unit tests covering direction flip/bounce/reversal, idle disengage, trackpad-burst disengage, frac invariants, and the native vs xterm.js branches. Profiled under --rate 30 (stress test) and --rate 10 (realistic sustained scroll): accel ramps to cap=6 at 30Hz burst, decays to 1-3 rows at sparse 10Hz clicks. Perf is comparable to baseline because accel IS multiplying step — the win is perceptual (fast flicks cover distance, slow clicks keep precision), not raw fps. Companion to the earlier WHEEL_SCROLL_STEP=1 change: that set the base; this modulates around it. --- .../packages/hermes-ink/src/entry-exports.ts | 1 + ui-tui/src/__tests__/wheelAccel.test.ts | 169 ++++++++++++ ui-tui/src/app/useInputHandlers.ts | 26 +- ui-tui/src/lib/wheelAccel.ts | 241 ++++++++++++++++++ ui-tui/src/types/hermes-ink.d.ts | 1 + 5 files changed, 433 insertions(+), 5 deletions(-) create mode 100644 ui-tui/src/__tests__/wheelAccel.test.ts create mode 100644 ui-tui/src/lib/wheelAccel.ts diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 3d5be7b543..52f81ac7b1 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -20,6 +20,7 @@ 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, diff --git a/ui-tui/src/__tests__/wheelAccel.test.ts b/ui-tui/src/__tests__/wheelAccel.test.ts new file mode 100644 index 0000000000..9d865ebfeb --- /dev/null +++ b/ui-tui/src/__tests__/wheelAccel.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' + +import { computeWheelStep, initWheelAccel } from '../lib/wheelAccel.js' + +describe('wheelAccel — native path', () => { + it('first click after init returns base', () => { + const s = initWheelAccel(false, 1) + + expect(computeWheelStep(s, 1, 1000)).toBe(1) + }) + + 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) + }) + + 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) + }) + + 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) + }) + + it('flip-back within bounce window engages wheelMode', () => { + 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) + }) + + it('flip-back outside bounce window is a real reversal (no wheelMode)', () => { + 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, 1400) + + expect(s.wheelMode).toBe(false) + }) + + it('5 consecutive sub-5ms events disengage wheelMode (trackpad signature)', () => { + const s = initWheelAccel(false, 1) + s.wheelMode = true + 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) + + expect(s.wheelMode).toBe(false) + }) + + it('1.5s idle disengages wheelMode', () => { + const s = initWheelAccel(false, 1) + s.wheelMode = true + s.dir = 1 + s.time = 1000 + + computeWheelStep(s, 1, 3000) // 2 second gap + + expect(s.wheelMode).toBe(false) + }) +}) + +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) + }) + + 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) + }) + + 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)) + } + + // Every event should produce 1-6 rows. No runaway. + for (const r of results) { + expect(r).toBeGreaterThanOrEqual(1) + expect(r).toBeLessThanOrEqual(6) + } + }) + + 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. + for (let t = 1000; t < 1200; t += 30) { + computeWheelStep(s, 1, t) + + expect(s.frac).toBeGreaterThanOrEqual(0) + expect(s.frac).toBeLessThan(1) + } + }) +}) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index b18dcbbd16..caba12bd30 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -11,6 +11,7 @@ import type { VoiceRecordResponse } from '../gatewayTypes.js' import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js' +import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js' import { getInputSelection } from './inputSelectionStore.js' import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' @@ -30,6 +31,15 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6) const scrollIdleTimer = useRef | null>(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. + const wheelAccelRef = useRef(initWheelAccelForHost()) + const scrollTranscript = (delta: number) => { if (getUiState().busy) { turnController.boostStreamingForScroll() @@ -278,12 +288,18 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } - if (key.wheelUp) { - return scrollTranscript(-wheelStep) - } + if (key.wheelUp || key.wheelDown) { + const dir: -1 | 1 = key.wheelUp ? -1 : 1 + const accelRows = computeWheelStep(wheelAccelRef.current, dir, Date.now()) - if (key.wheelDown) { - return scrollTranscript(wheelStep) + // 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) } if (key.shift && key.upArrow) { diff --git a/ui-tui/src/lib/wheelAccel.ts b/ui-tui/src/lib/wheelAccel.ts new file mode 100644 index 0000000000..d010c94131 --- /dev/null +++ b/ui-tui/src/lib/wheelAccel.ts @@ -0,0 +1,241 @@ +// Wheel-scroll acceleration state machine. +// +// Ported from claude-code's src/components/ScrollKeybindingHandler.tsx +// (commit cb7cfba6 of their research snapshot at ~/claude-code). The +// algorithm is theirs; the tuning constants below are theirs; this file +// is a straight port adapted to our module structure. +// +// 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: +// +// 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. + +import { isXtermJs } from '@hermes/ink' + +// ── Native path (ghostty, iTerm2, WezTerm, etc.) ─────────────────────── +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) ────── +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) ─────────────── +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 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. */ + frac: number + /** Native-path baseline rows/event. Reset value on idle/reversal; + * ramp builds on top. xterm.js path ignores this. */ + base: number + /** Deferred direction flip (native only). Might be encoder bounce or + * a real reversal — resolved by the NEXT event. */ + pendingFlip: boolean + /** Confirmed once a bounce fired (flip-then-flip-back within the + * bounce window). Sticky until idle disengage or trackpad burst. */ + wheelMode: boolean + /** Consecutive <5ms events. Trackpad flick ≥5 → disengage wheelMode. */ + 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 + } +} + +/** Read HERMES_TUI_SCROLL_SPEED (or CLAUDE_CODE_SCROLL_SPEED for + * portability from claude-code users). Default 1, clamped (0, 20]. */ +export function readScrollSpeedBase(): number { + const raw = 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) +} + +/** 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). + */ +export function computeWheelStep(state: WheelAccelState, dir: -1 | 1, now: number): number { + if (!state.xtermJs) { + return nativeStep(state, dir, now) + } + + return xtermJsStep(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. + 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). + state.dir = dir + state.time = now + state.mult = state.base + + 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 + + return 0 + } + + state.dir = dir + state.time = now + + 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. + if (++state.burstCount >= 5) { + state.wheelMode = false + state.burstCount = 0 + state.mult = state.base + } else { + return 1 + } + } else { + state.burstCount = 0 + } + } + + // 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) + 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, non-wheel-mode). Tight 40ms window: + // sub-40ms 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) { + // 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. + 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 +} diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 4ecd10ee9d..62b9454687 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -104,6 +104,7 @@ declare module '@hermes/ink' { export const Text: React.ComponentType export const TextInput: React.ComponentType export const stringWidth: (s: string) => number + export function isXtermJs(): boolean export type ScrollFastPathStats = { captured: number