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.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) + }) +}) 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, +)