Files
codex/codex-rs/tui2/docs/scroll_input_model.md
Josh McKinney 63942b883c feat(tui2): tune scrolling inpu based on (#8357)
## TUI2: Normalize Mouse Scroll Input Across Terminals (Wheel +
Trackpad)

This changes TUI2 scrolling to a stream-based model that normalizes
terminal scroll event density into consistent wheel behavior (default:
~3 transcript lines per physical wheel notch) while keeping trackpad
input higher fidelity via fractional accumulation.

Primary code: `codex-rs/tui2/src/tui/scrolling/mouse.rs`

Doc of record (model + probe-derived data):
`codex-rs/tui2/docs/scroll_input_model.md`

### Why

Terminals encode both mouse wheels and trackpads as discrete scroll
up/down events with direction but no magnitude, and they vary widely in
how many raw events they emit per physical wheel notch (commonly 1, 3,
or 9+). Timing alone doesn’t reliably distinguish wheel vs trackpad, so
cadence-based heuristics are unstable across terminals/hardware.

This PR treats scroll input as short *streams* separated by silence or
direction flips, normalizes raw event density into tick-equivalents,
coalesces redraws for dense streams, and exposes explicit config
overrides.

### What Changed

#### Scroll Model (TUI2)

- Stream detection
  - Start a stream on the first scroll event.
  - End a stream on an idle gap (`STREAM_GAP_MS`) or a direction flip.
- Normalization
- Convert raw events into tick-equivalents using per-terminal
`tui.scroll_events_per_tick`.
- Wheel-like vs trackpad-like behavior
- Wheel-like: fixed “classic” lines per wheel notch; flush immediately
for responsiveness.
- Trackpad-like: fractional accumulation + carry across stream
boundaries; coalesce flushes to ~60Hz to avoid floods and reduce “stop
lag / overshoot”.
- Trackpad divisor is intentionally capped: `min(scroll_events_per_tick,
3)` so terminals with dense wheel ticks (e.g. 9 events per notch) don’t
make trackpads feel artificially slow.
- Auto mode (default)
  - Start conservatively as trackpad-like (avoid overshoot).
- Promote to wheel-like if the first tick-worth of events arrives
quickly.
- Fallback for 1-event-per-tick terminals (no tick-completion timing
signal).

#### Trackpad Acceleration

Some terminals produce relatively low vertical event density for
trackpad gestures, which makes large/faster swipes feel sluggish even
when small motions feel correct. To address that, trackpad-like streams
apply a bounded multiplier based on event count:

- `multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events,
1..scroll_trackpad_accel_max)`

The multiplier is applied to the trackpad stream’s computed line delta
(including carried fractional remainder). Defaults are conservative and
bounded.

#### Config Knobs (TUI2)

All keys live under `[tui]`:

- `scroll_wheel_lines`: lines per physical wheel notch (default: 3).
- `scroll_events_per_tick`: raw vertical scroll events per physical
wheel notch (terminal-specific default; fallback: 3).
- Wheel-like per-event contribution: `scroll_wheel_lines /
scroll_events_per_tick`.
- `scroll_trackpad_lines`: baseline trackpad sensitivity (default: 1).
- Trackpad-like per-event contribution: `scroll_trackpad_lines /
min(scroll_events_per_tick, 3)`.
- `scroll_trackpad_accel_events` / `scroll_trackpad_accel_max`: bounded
trackpad acceleration (defaults: 30 / 3).
- `scroll_mode = auto|wheel|trackpad`: force behavior or use the
heuristic (default: `auto`).
- `scroll_wheel_tick_detect_max_ms`: auto-mode promotion threshold (ms).
- `scroll_wheel_like_max_duration_ms`: auto-mode fallback for
1-event-per-tick terminals (ms).
- `scroll_invert`: invert scroll direction (applies to wheel +
trackpad).

Config docs: `docs/config.md` and field docs in
`codex-rs/core/src/config/types.rs`.

#### App Integration

- The app schedules follow-up ticks to close idle streams (via
`ScrollUpdate::next_tick_in` and `schedule_frame_in`) and finalizes
streams on draw ticks.
  - `codex-rs/tui2/src/app.rs`

#### Docs

- Single doc of record describing the model + preserved probe
findings/spec:
  - `codex-rs/tui2/docs/scroll_input_model.md`

#### Other (jj-only friendliness)

- `codex-rs/tui2/src/diff_render.rs`: prefer stable cwd-relative paths
when the file is under the cwd even if there’s no `.git`.

### Terminal Defaults

Per-terminal defaults are derived from scroll-probe logs (see doc).
Notable:

- Ghostty currently defaults to `scroll_events_per_tick = 3` even though
logs measured ~9 in one setup. This is a deliberate stopgap; if your
Ghostty build emits ~9 events per wheel notch, set:

  ```toml
  [tui]
  scroll_events_per_tick = 9
  ```

### Testing

- `just fmt`
- `just fix -p codex-core --allow-no-vcs`
- `cargo test -p codex-core --lib` (pass)
- `cargo test -p codex-tui2` (scroll tests pass; remaining failures are
known flaky VT100 color tests in `insert_history`)

### Review Focus

- Stream finalization + frame scheduling in `codex-rs/tui2/src/app.rs`.
- Auto-mode promotion thresholds and the 1-event-per-tick fallback
behavior.
- Trackpad divisor cap (`min(events_per_tick, 3)`) and acceleration
defaults.
- Ghostty default tradeoff (3 vs ~9) and whether we should change it.
2025-12-20 12:48:12 -08:00

29 KiB
Raw Blame History

TUI2 Scroll Input: Model and Implementation

This is the single "scrolling doc of record" for TUI2.

It describes what we implemented, why it works, and what we tried before this approach. It also preserves the scroll-probe findings (see Appendix) that motivated the model.

Code reference: codex-rs/tui2/src/tui/scrolling/mouse.rs.

Goals and constraints

Goals:

  • Mouse wheel: scroll about 3 transcript lines per physical wheel tick regardless of terminal event density (classic feel).
  • Trackpad: remain higher fidelity, meaning small movements can accumulate fractionally and should not be forced into wheel behavior.
  • Work across terminals where a single wheel tick may produce 1, 3, 9, or more raw events.

Constraints:

  • Terminals typically encode both wheels and trackpads as the same "scroll up/down" mouse button events without a magnitude. We cannot reliably observe device type directly.
  • Timing alone is not a reliable discriminator (wheel and trackpad bursts overlap).

Current implementation (stream-based; data-driven)

TUI2 uses a stream model: scroll events are grouped into short streams separated by silence. Within a stream, we normalize by a per-terminal "events per tick" factor and then apply either wheel-like (fixed lines per tick) or trackpad-like (fractional) semantics.

1. Stream detection

  • A stream begins on the first scroll event.
  • A stream ends when the gap since the last event exceeds STREAM_GAP_MS or when direction flips.
  • Direction flips always close the current stream and start a new one, so we never blend "up" and "down" into a single accumulator.

This makes behavior stable across:

  • Dense bursts (Warp/Ghostty-style sub-ms intervals).
  • Sparse bursts (single events separated by tens or hundreds of ms).
  • Mixed wheel + trackpad input where direction changes quickly.

2. Normalization: events-per-tick

Different terminals emit different numbers of raw events per physical wheel notch. We normalize by converting raw events into tick-equivalents:

tick_equivalents = raw_events / events_per_tick

Per-terminal defaults come from the probe logs (Appendix), and users can override them.

Config key: tui.scroll_events_per_tick.

3. Wheel vs trackpad behavior (and why it is heuristic)

Because device type is not directly observable, the implementation provides a mode setting:

  • tui.scroll_mode = "auto" (default): infer wheel-like vs trackpad-like behavior per stream.
  • tui.scroll_mode = "wheel": always treat streams as wheel-like.
  • tui.scroll_mode = "trackpad": always treat streams as trackpad-like.

In auto mode:

  • Streams start trackpad-like (safer: avoids overshoot when we guess wrong).
  • Streams promote to wheel-like when the first tick-worth of events arrives quickly.
  • For 1-event-per-tick terminals, "first tick completion time" is not observable, so there is a conservative end-of-stream fallback for very small bursts.

This design assumes that auto classification is a best-effort heuristic and must be overridable.

4. Applying scroll: wheel-like streams

Wheel-like streams target the "classic feel" requirement.

  • Each raw event contributes tui.scroll_wheel_lines / events_per_tick lines.
  • Deltas flush immediately (not cadence-gated) so wheels feel snappy even on dense streams.
  • Wheel-like streams apply a minimum +/- 1 line when events were received but rounding would yield 0.

Defaults:

  • tui.scroll_wheel_lines = 3

5. Applying scroll: trackpad-like streams

Trackpad-like streams are designed for fidelity first.

  • Each raw event contributes tui.scroll_trackpad_lines / trackpad_events_per_tick lines.
  • Fractional remainder is carried across streams, so tiny gestures accumulate instead of being lost.
  • Trackpad deltas are cadence-gated to ~60 Hz (REDRAW_CADENCE_MS) to avoid redraw floods and to reduce "stop lag" / overshoot.
  • Trackpad streams intentionally do not apply a minimum +/- 1 line at stream end; if a gesture is small enough to round to 0, it should feel like "no movement", not a forced jump.

Dense wheel terminals (e.g. Ghostty/Warp) can emit trackpad streams with high event density. Using a wheel-derived events_per_tick = 9 for trackpad would make trackpads feel slow, so we use a capped divisor for trackpad normalization:

  • trackpad_events_per_tick = min(events_per_tick, 3)

Additionally, to keep small gestures precise while making large/fast swipes cover more content, trackpad-like streams apply bounded acceleration based on event count:

  • tui.scroll_trackpad_accel_events: how many events correspond to +1x multiplier.
  • tui.scroll_trackpad_accel_max: maximum multiplier.

6. Guard rails and axis handling

  • Horizontal scroll events are ignored for vertical scrolling.
  • Streams clamp event counts and accumulated line deltas to avoid floods.

Terminal defaults and per-terminal tuning

Defaults are keyed by TerminalName (terminal family), not exact version. Probe data is version-specific, so defaults should be revalidated as more logs arrive.

Events-per-tick defaults derived from wheel_single medians:

  • AppleTerminal: 3
  • WarpTerminal: 9
  • WezTerm: 1
  • Alacritty: 3
  • Ghostty: 3
  • Iterm2: 1
  • VsCode: 1
  • Kitty: 3
  • Unknown: 3

Note: probe logs measured Ghostty at ~9 events per tick, but we default to 3 because an upstream Ghostty change is expected to reduce wheel event density. Users can override with tui.scroll_events_per_tick.

Auto-mode wheel promotion thresholds can also be tuned per terminal if needed (see config below).

Configuration knobs (TUI2)

These are user-facing knobs in config.toml under [tui]:

In this repo, "tick" always refers to a physical mouse wheel notch. Trackpads do not have ticks, so trackpad settings are expressed in terms of "tick-equivalents" (raw events normalized to a common scale).

The core normalization formulas are:

  • Wheel-like streams:
    • lines_per_event = scroll_wheel_lines / scroll_events_per_tick
  • Trackpad-like streams:
    • lines_per_event = scroll_trackpad_lines / min(scroll_events_per_tick, 3)
    • (plus bounded acceleration from scroll_trackpad_accel_* and fractional carry across streams)

Keys:

  • scroll_events_per_tick (number):
    • Raw vertical scroll events per physical wheel notch in your terminal (normalization input).
    • Affects wheel-like scroll speed and auto-mode wheel promotion timing.
    • Trackpad-like mode uses min(..., 3) as the divisor so dense wheel ticks (e.g. 9 events per notch) do not make trackpads feel artificially slow.
  • scroll_wheel_lines (number):
    • Lines per physical wheel notch (default 3).
    • Change this if you want "classic" wheel scrolling to be more/less aggressive globally.
  • scroll_trackpad_lines (number):
    • Baseline trackpad sensitivity in trackpad-like mode (default 1).
    • Change this if your trackpad feels consistently too slow/fast for small motions.
  • scroll_trackpad_accel_events (number):
    • Trackpad acceleration tuning (default 30). Smaller values accelerate earlier.
    • Trackpad-like streams compute a multiplier:
      • multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events, 1..scroll_trackpad_accel_max)
    • The multiplier is applied to the trackpad streams computed line delta (including any carried fractional remainder).
  • scroll_trackpad_accel_max (number):
    • Trackpad acceleration cap (default 3). Set to 1 to effectively disable acceleration.
  • scroll_mode (auto | wheel | trackpad):
    • auto (default): infer wheel-like vs trackpad-like per stream.
    • wheel: always wheel-like (good for wheel-only setups; trackpads will feel jumpy).
    • trackpad: always trackpad-like (good if auto misclassifies; wheels may feel slow).
  • scroll_wheel_tick_detect_max_ms (number):
    • Auto-mode promotion threshold: how quickly the first tick-worth of events must arrive to consider the stream wheel-like.
    • If wheel feels slow in a dense-wheel terminal, increasing this is usually better than changing scroll_events_per_tick.
  • scroll_wheel_like_max_duration_ms (number):
    • Auto-mode fallback for 1-event-per-tick terminals (WezTerm/iTerm/VS Code).
    • If wheel feels like trackpad (too slow) in those terminals, increasing this can help.
  • scroll_invert (bool):
    • Invert direction after terminal detection; applies consistently to wheel and trackpad.

Previous approaches tried (and why they were replaced)

  1. Cadence-based inference (rolling inter-event thresholds)
  • Approach: infer wheel vs trackpad using inter-event timing thresholds (burst vs frame cadence vs slow), with terminal-specific tuning.
  • Problem: terminals differ more in event density and batching than in timing; timing overlaps heavily between wheel and trackpad. Small threshold changes had outsized, terminal-specific effects.
  1. Pure event-count or pure duration classification
  • Approach: classify wheel-like vs trackpad-like by event count <= N or duration <= M.
  • Problem: burst length overlaps heavily across devices/terminals; duration is more separable but still not strong enough to be authoritative.
  1. Why streams + normalization won
  • Streams give a stable unit ("what did the user do in one gesture?") that we can bound and reason about.
  • Normalization directly addresses the main cross-terminal source of variation: raw event density.
  • Classification remains heuristic, but is isolated and configurable.

Appendix A: Follow-up analysis (latest log per terminal; 2025-12-20)

This section is derived from a "latest log per terminal" subset analysis. The exact event count is not significant; it is included only as a note about which subset was used.

Key takeaways:

  • Burst length overlaps heavily between wheel and trackpad. Simple "event count <= N" classifiers perform poorly.
  • Burst span (duration) is more separable: wheel bursts typically complete in < ~180-200 ms, while trackpad bursts are often hundreds of milliseconds.
  • Conclusion: explicit wheel vs trackpad classification is inherently weak from these events; prefer a stream model, plus a small heuristic and a config override (tui.scroll_mode) for edge cases.

Data notes (latest per terminal label):

  • Logs used (one per terminal, by filename timestamp):
    • mouse_scroll_log_Apple_Terminal_2025-12-19T19-53-54Z.jsonl
    • mouse_scroll_log_WarpTerminal_2025-12-19T19-59-38Z.jsonl
    • mouse_scroll_log_WezTerm_2025-12-19T20-00-36Z.jsonl
    • mouse_scroll_log_alacritty_2025-12-19T19-56-45Z.jsonl
    • mouse_scroll_log_ghostty_2025-12-19T19-52-44Z.jsonl
    • mouse_scroll_log_iTerm_app_2025-12-19T19-55-08Z.jsonl
    • mouse_scroll_log_vscode_2025-12-19T19-51-20Z.jsonl
    • mouse_scroll_log_xterm-kitty_2025-12-19T19-58-19Z.jsonl

Per-terminal burst separability (wheel vs trackpad), summarized as median and p90:

  • Apple Terminal:
    • Wheel: length median 9.5 (p90 49), span median 94 ms (p90 136)
    • Trackpad: length median 13.5 (p90 104), span median 238 ms (p90 616)
  • Warp:
    • Wheel: length median 43 (p90 169), span median 88 ms (p90 178)
    • Trackpad: length median 60 (p90 82), span median 358 ms (p90 721)
  • WezTerm:
    • Wheel: length median 4 (p90 10), span median 91 ms (p90 156)
    • Trackpad: length median 10.5 (p90 36), span median 270 ms (p90 348)
  • alacritty:
    • Wheel: length median 14 (p90 63), span median 109 ms (p90 158)
    • Trackpad: length median 12.5 (p90 63), span median 372 ms (p90 883)
  • ghostty:
    • Wheel: length median 32.5 (p90 163), span median 99 ms (p90 157)
    • Trackpad: length median 14.5 (p90 60), span median 366 ms (p90 719)
  • iTerm:
    • Wheel: length median 4 (p90 9), span median 91 ms (p90 230)
    • Trackpad: length median 9 (p90 36), span median 223 ms (p90 540)
  • VS Code:
    • Wheel: length median 3 (p90 9), span median 94 ms (p90 120)
    • Trackpad: length median 3 (p90 12), span median 192 ms (p90 468)
  • Kitty:
    • Wheel: length median 15.5 (p90 59), span median 87 ms (p90 233)
    • Trackpad: length median 15.5 (p90 68), span median 292 ms (p90 563)

Wheel_single medians (events per tick) in the latest logs:

  • Apple: 3
  • Warp: 9
  • WezTerm: 1
  • alacritty: 3
  • ghostty: 9 (measured); TUI2 defaults use 3 because an upstream Ghostty change is expected to reduce wheel event density. If your Ghostty build still emits ~9 events per wheel tick, set tui.scroll_events_per_tick = 9.
  • iTerm: 1
  • VS Code: 1
  • Kitty: 3

Appendix B: Scroll probe findings (authoritative; preserved verbatim)

The remainder of this document is preserved from the original scroll-probe spec. It is intentionally not rewritten so the data and rationale remain auditable.

Note: the original text uses "events per line" terminology; the implementation treats this as an events-per-wheel-tick normalization factor (see "Normalization: events-per-tick").

Note: the pseudocode in the preserved spec is not the exact current implementation; it is kept as historical context for how the probe data originally mapped into an algorithm. The current implementation is described in the sections above.

1. TL;DR

Analysis of 16 scroll-probe logs (13,734 events) across 8 terminals shows large per-terminal variation in how many raw events are emitted per physical wheel tick (1-9+ events). Timing alone does not distinguish wheel vs trackpad; event counts and burst duration are more reliable. The algorithm below treats scroll input as short streams separated by gaps, normalizes events into line deltas using a per-terminal events-per-line factor, coalesces redraws at 60 Hz, and applies a minimum 1-line delta for discrete bursts. This yields stable behavior across dense streams, sparse bursts, and terminals that emit horizontal events.

2. Data overview

  • Logs analyzed: 16
  • Total events: 13,734
  • Terminals covered:
    • Apple_Terminal 455.1
    • WarpTerminal v0.2025.12.17.17.stable_02
    • WezTerm 20240203-110809-5046fc22
    • alacritty
    • ghostty 1.2.3
    • iTerm.app 3.6.6
    • vscode 1.107.1
    • xterm-kitty
  • Scenarios captured: wheel_single, wheel_small, wheel_long, trackpad_single, trackpad_slow, trackpad_fast (directional up/down variants treated as distinct bursts).
  • Legacy wheel_scroll_* logs are mapped to wheel_small in analysis.

3. Cross-terminal comparison table

Terminal Scenario Median Dt (ms) P95 Dt (ms) Typical burst Notes
Apple_Terminal 455.1 wheel_single 0.14 97.68 3
Apple_Terminal 455.1 wheel_small 0.12 23.81 19
Apple_Terminal 455.1 wheel_long 0.03 15.93 48
Apple_Terminal 455.1 trackpad_single 92.35 213.15 2
Apple_Terminal 455.1 trackpad_slow 11.30 75.46 14
Apple_Terminal 455.1 trackpad_fast 0.13 8.92 96
WarpTerminal v0.2025.12.17.17.stable_02 wheel_single 0.07 0.34 9
WarpTerminal v0.2025.12.17.17.stable_02 wheel_small 0.05 5.04 65
WarpTerminal v0.2025.12.17.17.stable_02 wheel_long 0.01 0.42 166
WarpTerminal v0.2025.12.17.17.stable_02 trackpad_single 9.77 32.64 10
WarpTerminal v0.2025.12.17.17.stable_02 trackpad_slow 7.93 16.44 74
WarpTerminal v0.2025.12.17.17.stable_02 trackpad_fast 5.40 10.04 74
WezTerm 20240203-110809-5046fc22 wheel_single 416.07 719.64 1
WezTerm 20240203-110809-5046fc22 wheel_small 19.41 50.19 6
WezTerm 20240203-110809-5046fc22 wheel_long 13.19 29.96 10
WezTerm 20240203-110809-5046fc22 trackpad_single 237.56 237.56 1
WezTerm 20240203-110809-5046fc22 trackpad_slow 23.54 76.10 10 12.5% horiz
WezTerm 20240203-110809-5046fc22 trackpad_fast 7.10 24.86 32 12.6% horiz
alacritty wheel_single 0.09 0.33 3
alacritty wheel_small 0.11 37.24 24
alacritty wheel_long 0.01 15.96 56
alacritty trackpad_single n/a n/a 1
alacritty trackpad_slow 41.90 97.36 11
alacritty trackpad_fast 3.07 25.13 62
ghostty 1.2.3 wheel_single 0.05 0.20 9
ghostty 1.2.3 wheel_small 0.05 7.18 52
ghostty 1.2.3 wheel_long 0.02 1.16 146
ghostty 1.2.3 trackpad_single 61.28 124.28 3 23.5% horiz
ghostty 1.2.3 trackpad_slow 23.10 76.30 14 34.7% horiz
ghostty 1.2.3 trackpad_fast 3.84 37.72 47 23.4% horiz
iTerm.app 3.6.6 wheel_single 74.96 80.61 1
iTerm.app 3.6.6 wheel_small 20.79 84.83 6
iTerm.app 3.6.6 wheel_long 16.70 50.91 9
iTerm.app 3.6.6 trackpad_single n/a n/a 1
iTerm.app 3.6.6 trackpad_slow 17.25 94.05 9
iTerm.app 3.6.6 trackpad_fast 7.12 24.54 33
vscode 1.107.1 wheel_single 58.01 58.01 1
vscode 1.107.1 wheel_small 16.76 66.79 5
vscode 1.107.1 wheel_long 9.86 32.12 8
vscode 1.107.1 trackpad_single n/a n/a 1
vscode 1.107.1 trackpad_slow 164.19 266.90 3
vscode 1.107.1 trackpad_fast 16.78 61.05 11
xterm-kitty wheel_single 0.16 51.74 3
xterm-kitty wheel_small 0.10 24.12 26
xterm-kitty wheel_long 0.01 16.10 56
xterm-kitty trackpad_single 155.65 289.87 1 12.5% horiz
xterm-kitty trackpad_slow 16.89 67.04 16 30.4% horiz
xterm-kitty trackpad_fast 0.23 16.37 78 20.6% horiz

4. Key findings

  • Raw wheel ticks vary by terminal: median events per tick are 1 (WezTerm/iTerm/vscode), 3 (Apple/alacritty/kitty), and 9 (Warp/ghostty).
  • Trackpad bursts are longer than wheel ticks but overlap in timing; inter-event timing alone does not distinguish device type.
  • Continuous streams have short gaps: overall inter-event p99 is 70.67 ms; trackpad_slow p95 is 66.98 ms.
  • Horizontal events appear only in trackpad scenarios and only in WezTerm/ghostty/kitty; ignore horizontal events for vertical scrolling.
  • Burst duration is a reliable discrete/continuous signal:
    • wheel_single median 0.15 ms (p95 80.61 ms)
    • trackpad_single median 0 ms (p95 237.56 ms)
    • wheel_small median 96.88 ms (p95 182.90 ms)
    • trackpad_slow median 320.69 ms (p95 812.10 ms)

5. Scrolling model (authoritative)

Stream detection. Treat scroll input as short streams separated by silence. A stream begins on the first scroll event and ends when the gap since the last event exceeds STREAM_GAP_MS or the direction flips. Direction flip immediately closes the current stream and starts a new one.

Normalization. Convert raw events into line deltas using a per-terminal EVENTS_PER_LINE factor derived from the terminal's median wheel_single burst length. If no terminal override matches, use the global default (3).

Discrete vs continuous. Classify the stream after it ends:

  • If event_count <= DISCRETE_MAX_EVENTS and duration_ms <= DISCRETE_MAX_DURATION_MS, treat as discrete.
  • Otherwise treat as continuous.

Discrete streams. Apply the accumulated line delta immediately. If the stream's accumulated lines rounds to 0 but events were received, apply a minimum +/-1 line (respecting direction).

Continuous streams. Accumulate fractional lines and coalesce redraws to REDRAW_CADENCE_MS. Flush any remaining fractional lines on stream end (with the same +/-1 minimum if the stream had events but rounded to 0).

Direction. Always use the raw event direction. Provide a separate user-level invert option if needed; do not infer inversion from timing.

Horizontal events. Ignore horizontal events in vertical scroll logic.

6. Concrete constants (data-derived)

STREAM_GAP_MS                 = 80
DISCRETE_MAX_EVENTS           = 10
DISCRETE_MAX_DURATION_MS      = 250
REDRAW_CADENCE_MS             = 16
DEFAULT_EVENTS_PER_LINE       = 3
MAX_EVENTS_PER_STREAM         = 256
MAX_ACCUMULATED_LINES         = 256
MIN_LINES_PER_DISCRETE_STREAM = 1
DEFAULT_WHEEL_LINES_PER_TICK  = 3

Why these values:

  • STREAM_GAP_MS=80: overall p99 inter-event gap is 70.67 ms; trackpad_slow p95 is 66.98 ms. 80 ms ends streams without splitting most continuous input.
  • DISCRETE_MAX_EVENTS=10: wheel_single p95 burst = 9; trackpad_single p95 burst = 10.
  • DISCRETE_MAX_DURATION_MS=250: trackpad_single p95 duration = 237.56 ms.
  • REDRAW_CADENCE_MS=16: coalesces dense streams to ~60 Hz; trackpad_fast p95 Dt = 19.83 ms.
  • DEFAULT_EVENTS_PER_LINE=3: global median wheel_single burst length.
  • MAX_EVENTS_PER_STREAM=256 and MAX_ACCUMULATED_LINES=256: highest observed burst is 206; cap to avoid floods.
  • DEFAULT_WHEEL_LINES_PER_TICK=3: restores classic wheel speed; this is a UX choice rather than a data-derived constant.

7. Pseudocode (Rust-oriented)

// This is intentionally a simplified sketch of the current implementation.
// For the authoritative behavior, see `codex-rs/tui2/src/tui/scrolling/mouse.rs`.

enum StreamKind {
    Unknown,
    Wheel,
    Trackpad,
}

struct Stream {
    start: Instant,
    last: Instant,
    dir: i32,
    event_count: usize,
    accumulated_events: i32,
    applied_lines: i32,
    kind: StreamKind,
    just_promoted: bool,
}

struct State {
    stream: Option<Stream>,
    carry_lines: f32,
    last_redraw_at: Instant,
    cfg: Config,
}

struct Config {
    events_per_tick: u16,
    wheel_lines_per_tick: u16,
    trackpad_lines_per_tick: u16,
    trackpad_accel_events: u16,
    trackpad_accel_max: u16,
    wheel_tick_detect_max: Duration,
}

fn on_scroll_event(dir: i32, now: Instant, st: &mut State) -> i32 {
    // Close stream on idle gap or direction flip.
    if let Some(stream) = st.stream.as_ref() {
        let gap = now.duration_since(stream.last);
        if gap > STREAM_GAP || stream.dir != dir {
            finalize_stream(now, st);
            st.stream = None;
        }
    }

    let stream = st.stream.get_or_insert_with(|| Stream {
        start: now,
        last: now,
        dir,
        event_count: 0,
        accumulated_events: 0,
        applied_lines: 0,
        kind: StreamKind::Unknown,
        just_promoted: false,
    });

    stream.last = now;
    stream.dir = dir;
    stream.event_count = (stream.event_count + 1).min(MAX_EVENTS_PER_STREAM);
    stream.accumulated_events =
        (stream.accumulated_events + dir).clamp(-(MAX_EVENTS_PER_STREAM as i32), MAX_EVENTS_PER_STREAM as i32);

    // Auto-mode promotion: promote to wheel-like when the first tick-worth of events arrives quickly.
    if matches!(stream.kind, StreamKind::Unknown) {
        let ept = st.cfg.events_per_tick.max(1) as usize;
        if ept >= 2 && stream.event_count >= ept && now.duration_since(stream.start) <= st.cfg.wheel_tick_detect_max {
            stream.kind = StreamKind::Wheel;
            stream.just_promoted = true;
        }
    }

    flush_lines(now, st)
}

fn on_tick(now: Instant, st: &mut State) -> i32 {
    if let Some(stream) = st.stream.as_ref() {
        let gap = now.duration_since(stream.last);
        if gap > STREAM_GAP {
            return finalize_stream(now, st);
        }
    }
    flush_lines(now, st)
}

fn finalize_stream(now: Instant, st: &mut State) -> i32 {
    // In auto mode, any stream that isn't wheel-like by promotion stays trackpad-like.
    if let Some(stream) = st.stream.as_mut() {
        if matches!(stream.kind, StreamKind::Unknown) {
            stream.kind = StreamKind::Trackpad;
        }
    }

    let lines = flush_lines(now, st);

    // Carry fractional remainder across streams for trackpad-like input.
    if let Some(stream) = st.stream.as_ref() {
        if matches!(stream.kind, StreamKind::Trackpad) {
            st.carry_lines = desired_lines_f32(st, stream) - stream.applied_lines as f32;
        } else {
            st.carry_lines = 0.0;
        }
    }

    lines
}

fn flush_lines(now: Instant, st: &mut State) -> i32 {
    let Some(stream) = st.stream.as_mut() else { return 0; };

    let wheel_like = matches!(stream.kind, StreamKind::Wheel);
    let cadence_elapsed = now.duration_since(st.last_redraw_at) >= REDRAW_CADENCE;
    let should_flush = wheel_like || cadence_elapsed || stream.just_promoted;
    if !should_flush {
        return 0;
    }

    let desired_total = desired_lines_f32(st, stream);
    let mut desired_lines = desired_total.trunc() as i32;

    // Wheel guardrail: ensure we never produce a "dead tick" for non-zero input.
    if wheel_like && desired_lines == 0 && stream.accumulated_events != 0 {
        desired_lines = stream.accumulated_events.signum() * MIN_LINES_PER_DISCRETE_STREAM;
    }

    let mut delta = desired_lines - stream.applied_lines;
    if delta == 0 {
        return 0;
    }

    delta = delta.clamp(-MAX_ACCUMULATED_LINES, MAX_ACCUMULATED_LINES);
    stream.applied_lines += delta;
    stream.just_promoted = false;
    st.last_redraw_at = now;
    delta
}

fn desired_lines_f32(st: &State, stream: &Stream) -> f32 {
    let wheel_like = matches!(stream.kind, StreamKind::Wheel);

    let events_per_tick = if wheel_like {
        st.cfg.events_per_tick.max(1) as f32
    } else {
        // Trackpad divisor is capped so dense wheel terminals don't feel slow for trackpads.
        st.cfg.events_per_tick.clamp(1, DEFAULT_EVENTS_PER_LINE).max(1) as f32
    };

    let lines_per_tick = if wheel_like {
        st.cfg.wheel_lines_per_tick.max(1) as f32
    } else {
        st.cfg.trackpad_lines_per_tick.max(1) as f32
    };

    let mut total = (stream.accumulated_events as f32 * (lines_per_tick / events_per_tick))
        .clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32);

    if !wheel_like {
        total = (total + st.carry_lines).clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32);

        // Bounded acceleration for large swipes (keep small swipes precise).
        let event_count = stream.accumulated_events.abs() as f32;
        let accel = (1.0 + (event_count / st.cfg.trackpad_accel_events.max(1) as f32))
            .clamp(1.0, st.cfg.trackpad_accel_max.max(1) as f32);
        total = (total * accel).clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32);
    }

    total
}

8. Terminal-specific adjustments (minimal)

Use per-terminal EVENTS_PER_LINE overrides derived from median wheel_single bursts:

Apple_Terminal 455.1                     = 3
WarpTerminal v0.2025.12.17.17.stable_02  = 9
WezTerm 20240203-110809-5046fc22         = 1
alacritty                                 = 3
ghostty 1.2.3                             = 9
iTerm.app 3.6.6                           = 1
vscode 1.107.1                            = 1
xterm-kitty                               = 3

If terminal is not matched, use DEFAULT_EVENTS_PER_LINE = 3.

9. Known weird cases and guardrails

  • Extremely dense streams (sub-ms Dt) occur in Warp/ghostty/kitty; redraw coalescing is mandatory.
  • Sparse bursts (hundreds of ms between events) occur in trackpad_single; do not merge them into long streams.
  • Horizontal scroll events (12-35% of trackpad events in some terminals) must be ignored for vertical scrolling.
  • Direction inversion is user-configurable in terminals; always use event direction and expose an application-level invert setting.
  • Guard against floods: cap event counts and accumulated line deltas per stream.