Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1664,6 +1665,7 @@ export const Chat = ({
stickyScroll
stickyStart="bottom"
scrollX={false}
scrollAcceleration={wheelScrollAcceleration}
scrollbarOptions={{ visible: false }}
verticalScrollbarOptions={{
visible: !isStreaming && !isWaitingForResponse && hasOverflow,
Expand Down
118 changes: 118 additions & 0 deletions cli/src/utils/__tests__/wheel-scroll-acceleration.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<scrollbox
ref={(instance: ScrollBoxRenderable | null) => {
box = instance
}}
scrollX={false}
scrollAcceleration={scrollAcceleration}
style={{ width: WIDTH, height: HEIGHT }}
>
{Array.from({ length: CONTENT_LINES }, (_, i) => (
<text key={i}>line {i}</text>
))}
</scrollbox>,
)
})
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)
})
})
49 changes: 49 additions & 0 deletions cli/src/utils/__tests__/wheel-scroll-acceleration.test.ts
Original file line number Diff line number Diff line change
@@ -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',
)
})
})
35 changes: 35 additions & 0 deletions cli/src/utils/wheel-scroll-acceleration.ts
Original file line number Diff line number Diff line change
@@ -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,
)
Loading