Skip to content

feat(cli): scroll the transcript three lines per wheel notch - #1274

Open
kavish-19 wants to merge 2 commits into
CodebuffAI:mainfrom
kavish-19:feat/wheel-scroll-three-lines
Open

feat(cli): scroll the transcript three lines per wheel notch#1274
kavish-19 wants to merge 2 commits into
CodebuffAI:mainfrom
kavish-19:feat/wheel-scroll-three-lines

Conversation

@kavish-19

@kavish-19 kavish-19 commented Sep 4, 2026

Copy link
Copy Markdown

Closes #1268.

The cause

The CLI never handles wheel events itself — OpenTUI's ScrollBoxRenderable does:

const multiplier = this.scrollAccel.tick(now)
const scrollAmount = baseDelta * multiplier

and the constructor defaults to LinearScrollAccel:

class LinearScrollAccel {
  tick(_now) { return 1 }
  reset() {}
}

A terminal reports one wheel notch as a delta of 1, so 1 × 1 moves the transcript a single line. That is the reported behaviour, and it's a default rather than a bug.

The change

ScrollBox already accepts a scrollAcceleration option (and exposes a setter), so nothing upstream needs changing — it's a prop on the existing <scrollbox> in chat.tsx. The React reconciler spreads JSX props straight into the renderable's constructor (createInstance in @opentui/react), which assigns the field the wheel handler reads.

Neither shipped accelerator gives a flat multiplier: LinearScrollAccel is fixed at 1, and MacOSScrollAccel ramps with scroll velocity, which would make a fast flick jump much further than the three lines the issue asks for. So this adds a small stateless ConstantScrollAccel. Because it's stateless there is nothing to accumulate or reset, so one shared instance serves it — which also keeps the prop's identity stable across renders instead of allocating per render.

Two decisions worth flagging

Scoped to the chat transcript. There are other scrollboxes — the prompt editor (multiline-input.tsx) and the landing screen. The editor is a few rows tall, where three lines a notch would skip most of its content, so it keeps the one-line default. A test pins that split so it isn't "fixed" later by accident.

Hardcoded, not configurable. The issue asks for 3 to match standard terminal and desktop behaviour, so that's what this does. Happy to put it behind a setting if you'd rather, but that seemed like unrequested surface area.

Verification

  • 4 new tests, confirmed red before the change (module didn't exist), green after.
  • Full suite: 39 failures, identical to the main baseline — no new ones.
  • tsc --noEmit clean on both touched files.
  • chat.tsx still fails prettier --check, but only at lines 279 and 931, both of which fail on unmodified main too. My lines (100 and 1668) are clean, so I've left it rather than bury the diff in an unrelated reformat.

One thing I could not verify locally: I checked the delta→multiplier→scrollTop path by reading OpenTUI's source rather than by driving a real wheel event, since onMouseEvent lives in the dependency. Worth a quick manual scroll on your side before porting.

Closes CodebuffAI#1268.

OpenTUI's ScrollBox multiplies each wheel event's notch delta by whatever
its ScrollAcceleration returns, and defaults to LinearScrollAccel, whose
tick() returns 1. A terminal reports one notch as a delta of 1, so the
transcript moves a single line per notch -- far slower than the three
lines terminals and desktop apps use.

ScrollBox already accepts a scrollAcceleration option, so this needs no
upstream change: the React reconciler spreads JSX props straight into the
renderable's constructor, which assigns the field the wheel handler reads.

Neither shipped accelerator gives a flat multiplier -- LinearScrollAccel
is fixed at 1, MacOSScrollAccel ramps with scroll velocity -- so this adds
a small stateless one. Being stateless, a single shared instance is enough,
which also keeps the prop's identity stable across renders.

Scoped to the chat transcript on purpose. The prompt editor's scrollbox is
a few rows tall, where three lines a notch would skip most of its content,
so it keeps the one-line default; a test pins that split.

Claude-Session: https://claude.ai/code/session_018vPhyqaaoKa8cgs7GEnyq5
@codebuff-team

Copy link
Copy Markdown
Contributor

Nice work — this is a well-scoped, well-documented fix. Root-causing to OpenTUI's LinearScrollAccel default and using the existing scrollAcceleration prop rather than patching the dependency is the right layer to fix this at. The stateless ConstantScrollAccel with a shared instance is a reasonable, low-risk implementation, and the test in wheel-scroll-acceleration.test.ts that pins the chat/multiline-input split is a nice touch to prevent scope creep later.

A couple of things worth double-checking before porting:

  1. The PR body is honest that the wheel-delta → multiplier → scrollTop path was verified by reading OpenTUI source, not by an actual wheel event. That's the one real gap here — no integration/manual verification that scrollAcceleration as a JSX prop actually reaches the constructor field the wheel handler reads (this depends on @opentui/react's reconciler behavior, which isn't itself tested). Worth a quick manual smoke test on a real terminal before merging, as the author flags.
  2. WHEEL_SCROLL_LINES = 3 is hardcoded per the issue request — reasonable, but if terminal notch deltas vary by platform/terminal emulator (some send larger deltas per notch), this could feel different across environments. Not a blocker, just worth a maintainer's sanity check.
  3. Tests are appropriately scoped and use bun:test, consistent with repo conventions from what's visible.

Overall: correct instinct, right layer, small diff, decent test coverage, and unusually transparent about its own verification limits. Recommend porting after a manual scroll-wheel smoke test.

@codebuff-team codebuff-team added bot:triaged Classified by the community triage bot pr:port-candidate Worth porting into the private source tree labels Sep 5, 2026
The review on CodebuffAI#1274 flagged the one gap in that PR: the delta -> multiplier
-> scrollTop path was checked by reading OpenTUI's source, not by an actual
wheel event, so nothing proved the scrollAcceleration JSX prop reaches the
constructor field the wheel handler reads.

This renders a scrollbox through @opentui/react's reconciler and scrolls it
with the mock mouse from @opentui/core/testing, which emits the same SGR
sequence a terminal does. One notch moves three lines; the same scrollbox
without the prop still moves one, so the assertion cannot pass for any
reason other than the accelerator.

The line counts are written out rather than read from WHEEL_SCROLL_LINES --
a test that reads the constant it pins follows it anywhere. Verified red
with the constant set to 1 (3 of 4 fail, the no-prop control correctly
unaffected) and green at 3.

Uses flushSync rather than @opentui/react's testRender helper: that helper
wraps the render in React's act(), which is stripped from React's
production build, and the suite runs under NODE_ENV=production.

Claude-Session: https://claude.ai/code/session_01QNL5SiuLLyRHZcFgtUN5Yp
@kavish-19

Copy link
Copy Markdown
Author

Thanks — both flagged points are now closed in b2ae8cc.

1. The verification gap. You're right that this was the one real hole: nothing proved the scrollAcceleration JSX prop actually reaches the constructor field the wheel handler reads. That's now a test rather than a manual smoke check — cli/src/utils/__tests__/wheel-scroll-acceleration.integration.test.tsx renders a <scrollbox> through @opentui/react's reconciler and scrolls it with createMockMouse from @opentui/core/testing, which emits the same SGR sequence a terminal does. One notch moves three lines; the identical scrollbox without the prop still moves one, so the assertion can't pass for any reason other than the accelerator.

Two things I got wrong first time and corrected, since they'd otherwise be worth your review time:

  • The assertions originally read WHEEL_SCROLL_LINES, so setting the constant to 1 kept them green — tautological. They now assert the literal 3. Verified red at 1 (3 of 4 fail; the no-prop control correctly stays green) and green at 3.
  • @opentui/react's own testRender helper wraps the render in React's act(), which is stripped from the production build the suite runs under. Uses flushSync instead.

A manual scroll is still worth doing before you port — this covers the parser and reconciler, not a physical wheel — but it's no longer the only thing standing behind the change.

2. Platform variance in notch deltas. I don't think this one bites. Both of OpenTUI's mouse decoders hardcode the delta:

// index-54s7pk0d.js:6592 (SGR) and :6631 (basic mode)
scrollInfo = { direction: scrollDirection, delta: 1 }

A terminal that scrolls "faster" sends more escape sequences, not a larger delta, so baseDelta × 3 is exactly three lines on every platform and emulator. Happy to be wrong if you know of a terminal whose sequences OpenTUI decodes differently.

One thing for you to decide. I named the new file *.integration.test.tsx to match grid-layout.integration.test.tsx, but that pattern is excluded from the default bun test, so as it stands it won't gate CI. It runs in ~360ms and needs no TTY or network, so it could just as well be a plain .test.tsx — say the word and I'll rename it.

tsc --noEmit clean, prettier --check clean, 8/8 pass across both wheel-scroll test files.

https://claude.ai/code/session_01QNL5SiuLLyRHZcFgtUN5Yp

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:triaged Classified by the community triage bot pr:port-candidate Worth porting into the private source tree

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Increase mouse wheel scroll speed from 1 line to 3 lines

2 participants