From 7ca16eea56a5f9f79a91ef1eeff3ce763693c745 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Apr 2026 17:01:22 -0500 Subject: [PATCH] perf(tui): scroll one row at a time per wheel event, half-viewport per pageUp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/app/useInputHandlers.ts | 9 ++++++++- ui-tui/src/config/limits.ts | 18 +++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index fff73d9cfa..b18dcbbd16 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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) } diff --git a/ui-tui/src/config/limits.ts b/ui-tui/src/config/limits.ts index a2e817d862..889ac4d686 100644 --- a/ui-tui/src/config/limits.ts +++ b/ui-tui/src/config/limits.ts @@ -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