From 9b67a13710b0343cea3cb09c5422c2963bbe25bb Mon Sep 17 00:00:00 2001 From: kavish-19 <63698788+kavish-19@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:06:31 +0530 Subject: [PATCH 1/2] feat(cli): scroll the transcript three lines per wheel notch Closes #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 --- cli/src/chat.tsx | 2 + .../wheel-scroll-acceleration.test.ts | 49 +++++++++++++++++++ cli/src/utils/wheel-scroll-acceleration.ts | 35 +++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 cli/src/utils/__tests__/wheel-scroll-acceleration.test.ts create mode 100644 cli/src/utils/wheel-scroll-acceleration.ts diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 50bad5c951..f531630969 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -97,6 +97,7 @@ import { import { createPasteHandler } from './utils/strings' import { setTerminalTitle } from './utils/terminal-title' import { computeInputLayoutMetrics } from './utils/text-layout' +import { wheelScrollAcceleration } from './utils/wheel-scroll-acceleration' import type { CommandResult } from './commands/command-registry' import type { MultilineInputHandle } from './components/multiline-input' @@ -1664,6 +1665,7 @@ export const Chat = ({ stickyScroll stickyStart="bottom" scrollX={false} + scrollAcceleration={wheelScrollAcceleration} scrollbarOptions={{ visible: false }} verticalScrollbarOptions={{ visible: !isStreaming && !isWaitingForResponse && hasOverflow, diff --git a/cli/src/utils/__tests__/wheel-scroll-acceleration.test.ts b/cli/src/utils/__tests__/wheel-scroll-acceleration.test.ts new file mode 100644 index 0000000000..78929eee20 --- /dev/null +++ b/cli/src/utils/__tests__/wheel-scroll-acceleration.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'fs' +import { join } from 'path' + +import { describe, expect, test } from 'bun:test' + +import { + WHEEL_SCROLL_LINES, + wheelScrollAcceleration, +} from '../wheel-scroll-acceleration' + +const repoRoot = join(import.meta.dir, '../../../..') +const read = (relative: string) => + readFileSync(join(repoRoot, relative), 'utf8') + +describe('wheel scroll acceleration', () => { + test('every notch moves the same three lines', () => { + expect(WHEEL_SCROLL_LINES).toBe(3) + expect(wheelScrollAcceleration.tick()).toBe(3) + }) + + test('the multiplier does not ramp with scroll speed', () => { + // OpenTUI calls tick() once per wheel event and multiplies the notch + // delta by the result. MacOSScrollAccel ramps here; this must not, or a + // fast flick overshoots by far more than the three lines asked for. + const now = Date.now() + const burst = [now, now + 1, now + 2, now + 3, now + 4].map((at) => + wheelScrollAcceleration.tick(at), + ) + + expect(burst).toEqual([3, 3, 3, 3, 3]) + }) + + test('reset leaves the multiplier where it was', () => { + wheelScrollAcceleration.tick() + wheelScrollAcceleration.reset() + + expect(wheelScrollAcceleration.tick()).toBe(WHEEL_SCROLL_LINES) + }) + + test('only the chat transcript opts in', () => { + // Three lines per notch suits a long transcript. The prompt editor is a + // few rows tall, so the same jump would skip most of its content -- it + // keeps OpenTUI's one-line default deliberately. + expect(read('cli/src/chat.tsx')).toContain('wheelScrollAcceleration') + expect(read('cli/src/components/multiline-input.tsx')).not.toContain( + 'wheelScrollAcceleration', + ) + }) +}) diff --git a/cli/src/utils/wheel-scroll-acceleration.ts b/cli/src/utils/wheel-scroll-acceleration.ts new file mode 100644 index 0000000000..76a076d470 --- /dev/null +++ b/cli/src/utils/wheel-scroll-acceleration.ts @@ -0,0 +1,35 @@ +import type { ScrollAcceleration } from '@opentui/core' + +/** + * Lines the transcript moves per mouse wheel notch, matching what terminals + * and desktop apps do by default. + */ +export const WHEEL_SCROLL_LINES = 3 + +/** + * 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 crawls a single line at a time (#1268). + * + * 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 we want. Hence + * this one, which is stateless -- there is nothing to accumulate or reset, + * so a single shared instance serves every scrollbox that opts in. + */ +class ConstantScrollAccel implements ScrollAcceleration { + constructor(private readonly lines: number) {} + + tick(_now?: number): number { + return this.lines + } + + reset(): void { + // Stateless: nothing accumulates between notches. + } +} + +export const wheelScrollAcceleration = new ConstantScrollAccel( + WHEEL_SCROLL_LINES, +) From b2ae8cc224c4381ce8c98488e314d540fa75f7d9 Mon Sep 17 00:00:00 2001 From: kavish-19 <63698788+kavish-19@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:04:50 +0530 Subject: [PATCH 2/2] test(cli): drive a real wheel event at the scrollbox The review on #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 --- ...l-scroll-acceleration.integration.test.tsx | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 cli/src/utils/__tests__/wheel-scroll-acceleration.integration.test.tsx diff --git a/cli/src/utils/__tests__/wheel-scroll-acceleration.integration.test.tsx b/cli/src/utils/__tests__/wheel-scroll-acceleration.integration.test.tsx new file mode 100644 index 0000000000..dedf8f21d2 --- /dev/null +++ b/cli/src/utils/__tests__/wheel-scroll-acceleration.integration.test.tsx @@ -0,0 +1,118 @@ +import { createTestRenderer } from '@opentui/core/testing' +import { createRoot, flushSync } from '@opentui/react' +import { describe, expect, test } from 'bun:test' + +import { wheelScrollAcceleration } from '../wheel-scroll-acceleration' + +import type { ScrollBoxRenderable } from '@opentui/core' + +// The unit tests pin what ConstantScrollAccel returns. They cannot show that +// the JSX prop reaches the field OpenTUI's wheel handler reads -- that path +// runs through @opentui/react's reconciler and the terminal's mouse parser, +// neither of which is ours. This drives real wheel events at a rendered +// scrollbox and watches scrollTop, so the whole chain is covered by a test +// rather than by reading the dependency's source. +// +// The line counts below are written out rather than taken from +// WHEEL_SCROLL_LINES: three is what issue #1268 asked for, and a test that +// reads the constant it is meant to pin would follow it anywhere. + +const WIDTH = 40 +const HEIGHT = 10 +const CONTENT_LINES = 200 + +// Somewhere inside the scrollbox, so the renderer routes the event to it. +const CURSOR_X = 5 +const CURSOR_Y = 5 + +// @opentui/react's own testRender helper wraps the render in React's act(), +// which the tests cannot use: they run under NODE_ENV=production, and act is +// stripped from React's production build. flushSync commits the tree just as +// synchronously, and without a dev-only import. +const renderTranscript = async ( + scrollAcceleration?: ScrollBoxRenderable['scrollAcceleration'], +) => { + let box: ScrollBoxRenderable | null = null + + const setup = await createTestRenderer({ width: WIDTH, height: HEIGHT }) + const root = createRoot(setup.renderer) + + flushSync(() => { + root.render( + { + box = instance + }} + scrollX={false} + scrollAcceleration={scrollAcceleration} + style={{ width: WIDTH, height: HEIGHT }} + > + {Array.from({ length: CONTENT_LINES }, (_, i) => ( + line {i} + ))} + , + ) + }) + await setup.flush() + + if (!box) throw new Error('scrollbox never mounted') + return { ...setup, box: box as ScrollBoxRenderable } +} + +describe('wheel scrolling the transcript', () => { + test('one notch moves three lines', async () => { + const { box, mockMouse, flush } = await renderTranscript( + wheelScrollAcceleration, + ) + + const before = box.scrollTop + await mockMouse.scroll(CURSOR_X, CURSOR_Y, 'down') + await flush() + + expect(box.scrollTop - before).toBe(3) + }) + + test('without the prop a notch still moves one line', async () => { + // Guards the assertion above against passing for some reason other than + // our accelerator -- e.g. if OpenTUI ever changed its own default. + const { box, mockMouse, flush } = await renderTranscript() + + const before = box.scrollTop + await mockMouse.scroll(CURSOR_X, CURSOR_Y, 'down') + await flush() + + expect(box.scrollTop - before).toBe(1) + }) + + test('three notches move nine lines, not more', async () => { + // MacOSScrollAccel would ramp across a burst like this. + const { box, mockMouse, flush } = await renderTranscript( + wheelScrollAcceleration, + ) + + const before = box.scrollTop + for (let i = 0; i < 3; i++) { + await mockMouse.scroll(CURSOR_X, CURSOR_Y, 'down') + } + await flush() + + expect(box.scrollTop - before).toBe(9) + }) + + test('scrolling back up moves three lines a notch too', async () => { + const { box, mockMouse, flush } = await renderTranscript( + wheelScrollAcceleration, + ) + + for (let i = 0; i < 5; i++) { + await mockMouse.scroll(CURSOR_X, CURSOR_Y, 'down') + } + await flush() + + const before = box.scrollTop + await mockMouse.scroll(CURSOR_X, CURSOR_Y, 'up') + await flush() + + expect(before - box.scrollTop).toBe(3) + }) +})