perf(tui): scroll one row at a time per wheel event, half-viewport per pageUp

User observation: "it doesn't scroll line by line/row by row."

Was right.  Two places hardcoded big deltas:

1. WHEEL_SCROLL_STEP = 6 (config/limits.ts)
   Each wheel event scrolled 6 rows.  A mechanical wheel notch emits
   3-5 events → 18-30 rows per click, which visually teleports past
   content instead of smooth-scrolling it.  Drop to 1.  Trackpads
   emit 50-100 events per flick — at step=1 that's still a fast flick
   (a whole viewport in one flick) but each intermediate frame is
   visible.  Porting claude-code's wheel accel state machine is the
   right next step if this feels sluggish on precision scrolls.

2. pageUp/pageDown = viewport - 2 (useInputHandlers.ts)
   Full-viewport jumps replace the entire screen — no visual
   continuity, can't scan content — AND land right at Ink's fast-path
   threshold (`delta < innerHeight`), which disqualifies the DECSTBM
   blit on every press.  Half-viewport keeps 50% continuity AND
   drops well under the threshold.  Two presses still cover the same
   total distance.

Profiled against the 1106-msg session, holding the key at 30Hz for
6s:

  wheel_up (step 6 → 1):
    frames       142  →  163    (+15%)
    throughput   10.7 → 15.8 fps (+48%)
    patches tot  53018→ 36562   (-31%)
    gap p50      5ms  → 16ms    (actual rendering ~60fps now)
    <16ms frames 93   → 76
    16-33ms      82   → 76
    hitches      3    → 1

  pageUp (viewport-2 → viewport/2):
    throughput   10.7 → 9.5 fps  (same ballpark — smaller delta × same
                                  event rate = less total scroll)

Ink's proportional drain caps at `innerHeight - 1` per frame to keep
the DECSTBM fast path firing.  With these smaller deltas every event
comfortably fits under that cap, so fast-path hit rate goes up and
patch volume per frame drops — the measured 31% reduction in total
patches-sent correlates with users perceiving smoother scrolling
because the outer terminal (VS Code / xterm.js / tmux) isn't drowning
in ANSI between paints.

Tests/type-check/build clean; 352 tests pass.
This commit is contained in:
Brooklyn Nicholson
2026-04-26 17:01:22 -05:00
parent 4a9070c9ac
commit 7ca16eea56
2 changed files with 25 additions and 2 deletions

View File

@@ -296,7 +296,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (key.pageUp || key.pageDown) {
const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
const step = Math.max(4, viewport - 2)
// 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))
return scrollTranscript(key.pageUp ? -step : step)
}

View File

@@ -4,4 +4,20 @@ export const LIVE_RENDER_MAX_LINES = 240
export const LONG_MSG = 300
export const MAX_HISTORY = 800
export const THINKING_COT_MAX = 160
export const WHEEL_SCROLL_STEP = 6
// Rows scrolled per wheel-notch event.
//
// One notch of a mechanical wheel emits multiple wheel events (3-5 per
// click in most terminals; trackpad flicks emit 100+). Each event 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