feat(tui): port claude-code's wheel accel state machine

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.
This commit is contained in:
Brooklyn Nicholson 2026-04-26 17:16:11 -05:00
parent 0cd98499bb
commit 4395c2b007
5 changed files with 433 additions and 5 deletions

View file

@ -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<ReturnType<typeof setTimeout> | 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) {