Skip to content

Commit 9b67a13

Browse files
committed
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
1 parent 4511764 commit 9b67a13

3 files changed

Lines changed: 86 additions & 0 deletions

File tree

cli/src/chat.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import {
9797
import { createPasteHandler } from './utils/strings'
9898
import { setTerminalTitle } from './utils/terminal-title'
9999
import { computeInputLayoutMetrics } from './utils/text-layout'
100+
import { wheelScrollAcceleration } from './utils/wheel-scroll-acceleration'
100101

101102
import type { CommandResult } from './commands/command-registry'
102103
import type { MultilineInputHandle } from './components/multiline-input'
@@ -1664,6 +1665,7 @@ export const Chat = ({
16641665
stickyScroll
16651666
stickyStart="bottom"
16661667
scrollX={false}
1668+
scrollAcceleration={wheelScrollAcceleration}
16671669
scrollbarOptions={{ visible: false }}
16681670
verticalScrollbarOptions={{
16691671
visible: !isStreaming && !isWaitingForResponse && hasOverflow,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { readFileSync } from 'fs'
2+
import { join } from 'path'
3+
4+
import { describe, expect, test } from 'bun:test'
5+
6+
import {
7+
WHEEL_SCROLL_LINES,
8+
wheelScrollAcceleration,
9+
} from '../wheel-scroll-acceleration'
10+
11+
const repoRoot = join(import.meta.dir, '../../../..')
12+
const read = (relative: string) =>
13+
readFileSync(join(repoRoot, relative), 'utf8')
14+
15+
describe('wheel scroll acceleration', () => {
16+
test('every notch moves the same three lines', () => {
17+
expect(WHEEL_SCROLL_LINES).toBe(3)
18+
expect(wheelScrollAcceleration.tick()).toBe(3)
19+
})
20+
21+
test('the multiplier does not ramp with scroll speed', () => {
22+
// OpenTUI calls tick() once per wheel event and multiplies the notch
23+
// delta by the result. MacOSScrollAccel ramps here; this must not, or a
24+
// fast flick overshoots by far more than the three lines asked for.
25+
const now = Date.now()
26+
const burst = [now, now + 1, now + 2, now + 3, now + 4].map((at) =>
27+
wheelScrollAcceleration.tick(at),
28+
)
29+
30+
expect(burst).toEqual([3, 3, 3, 3, 3])
31+
})
32+
33+
test('reset leaves the multiplier where it was', () => {
34+
wheelScrollAcceleration.tick()
35+
wheelScrollAcceleration.reset()
36+
37+
expect(wheelScrollAcceleration.tick()).toBe(WHEEL_SCROLL_LINES)
38+
})
39+
40+
test('only the chat transcript opts in', () => {
41+
// Three lines per notch suits a long transcript. The prompt editor is a
42+
// few rows tall, so the same jump would skip most of its content -- it
43+
// keeps OpenTUI's one-line default deliberately.
44+
expect(read('cli/src/chat.tsx')).toContain('wheelScrollAcceleration')
45+
expect(read('cli/src/components/multiline-input.tsx')).not.toContain(
46+
'wheelScrollAcceleration',
47+
)
48+
})
49+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { ScrollAcceleration } from '@opentui/core'
2+
3+
/**
4+
* Lines the transcript moves per mouse wheel notch, matching what terminals
5+
* and desktop apps do by default.
6+
*/
7+
export const WHEEL_SCROLL_LINES = 3
8+
9+
/**
10+
* OpenTUI's ScrollBox multiplies each wheel event's notch delta by whatever
11+
* its ScrollAcceleration returns, and defaults to LinearScrollAccel, whose
12+
* tick() returns 1. A terminal reports one notch as a delta of 1, so the
13+
* transcript crawls a single line at a time (#1268).
14+
*
15+
* Neither shipped accelerator gives a flat multiplier: LinearScrollAccel is
16+
* fixed at 1, and MacOSScrollAccel ramps with scroll velocity, which would
17+
* make a fast flick jump much further than the three lines we want. Hence
18+
* this one, which is stateless -- there is nothing to accumulate or reset,
19+
* so a single shared instance serves every scrollbox that opts in.
20+
*/
21+
class ConstantScrollAccel implements ScrollAcceleration {
22+
constructor(private readonly lines: number) {}
23+
24+
tick(_now?: number): number {
25+
return this.lines
26+
}
27+
28+
reset(): void {
29+
// Stateless: nothing accumulates between notches.
30+
}
31+
}
32+
33+
export const wheelScrollAcceleration = new ConstantScrollAccel(
34+
WHEEL_SCROLL_LINES,
35+
)

0 commit comments

Comments
 (0)