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.
This commit is contained in:
Josh McKinney
2025-12-20 12:48:12 -08:00
committed by GitHub
parent a6974087e5
commit 63942b883c
9 changed files with 2219 additions and 13 deletions

View File

@@ -8,6 +8,7 @@ use crate::config::types::OtelConfig;
use crate::config::types::OtelConfigToml;
use crate::config::types::OtelExporterKind;
use crate::config::types::SandboxWorkspaceWrite;
use crate::config::types::ScrollInputMode;
use crate::config::types::ShellEnvironmentPolicy;
use crate::config::types::ShellEnvironmentPolicyToml;
use crate::config::types::Tui;
@@ -178,6 +179,58 @@ pub struct Config {
/// Show startup tooltips in the TUI welcome screen.
pub show_tooltips: bool,
/// Override the events-per-wheel-tick factor for TUI2 scroll normalization.
///
/// This is the same `tui.scroll_events_per_tick` value from `config.toml`, plumbed through the
/// merged [`Config`] object (see [`Tui`]) so TUI2 can normalize scroll event density per
/// terminal.
pub tui_scroll_events_per_tick: Option<u16>,
/// Override the number of lines applied per wheel tick in TUI2.
///
/// This is the same `tui.scroll_wheel_lines` value from `config.toml` (see [`Tui`]). TUI2
/// applies it to wheel-like scroll streams. Trackpad-like scrolling uses a separate
/// `tui.scroll_trackpad_lines` setting.
pub tui_scroll_wheel_lines: Option<u16>,
/// Override the number of lines per tick-equivalent used for trackpad scrolling in TUI2.
///
/// This is the same `tui.scroll_trackpad_lines` value from `config.toml` (see [`Tui`]).
pub tui_scroll_trackpad_lines: Option<u16>,
/// Trackpad acceleration: approximate number of events required to gain +1x speed in TUI2.
///
/// This is the same `tui.scroll_trackpad_accel_events` value from `config.toml` (see [`Tui`]).
pub tui_scroll_trackpad_accel_events: Option<u16>,
/// Trackpad acceleration: maximum multiplier applied to trackpad-like streams in TUI2.
///
/// This is the same `tui.scroll_trackpad_accel_max` value from `config.toml` (see [`Tui`]).
pub tui_scroll_trackpad_accel_max: Option<u16>,
/// Control how TUI2 interprets mouse scroll input (wheel vs trackpad).
///
/// This is the same `tui.scroll_mode` value from `config.toml` (see [`Tui`]).
pub tui_scroll_mode: ScrollInputMode,
/// Override the wheel tick detection threshold (ms) for TUI2 auto scroll mode.
///
/// This is the same `tui.scroll_wheel_tick_detect_max_ms` value from `config.toml` (see
/// [`Tui`]).
pub tui_scroll_wheel_tick_detect_max_ms: Option<u64>,
/// Override the wheel-like end-of-stream threshold (ms) for TUI2 auto scroll mode.
///
/// This is the same `tui.scroll_wheel_like_max_duration_ms` value from `config.toml` (see
/// [`Tui`]).
pub tui_scroll_wheel_like_max_duration_ms: Option<u64>,
/// Invert mouse scroll direction for TUI2.
///
/// This is the same `tui.scroll_invert` value from `config.toml` (see [`Tui`]) and is applied
/// consistently to both mouse wheels and trackpads.
pub tui_scroll_invert: bool,
/// The directory that should be treated as the current working directory
/// for the session. All relative paths inside the business-logic layer are
/// resolved against this path.
@@ -1346,6 +1399,27 @@ impl Config {
.unwrap_or_default(),
animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true),
show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true),
tui_scroll_events_per_tick: cfg.tui.as_ref().and_then(|t| t.scroll_events_per_tick),
tui_scroll_wheel_lines: cfg.tui.as_ref().and_then(|t| t.scroll_wheel_lines),
tui_scroll_trackpad_lines: cfg.tui.as_ref().and_then(|t| t.scroll_trackpad_lines),
tui_scroll_trackpad_accel_events: cfg
.tui
.as_ref()
.and_then(|t| t.scroll_trackpad_accel_events),
tui_scroll_trackpad_accel_max: cfg
.tui
.as_ref()
.and_then(|t| t.scroll_trackpad_accel_max),
tui_scroll_mode: cfg.tui.as_ref().map(|t| t.scroll_mode).unwrap_or_default(),
tui_scroll_wheel_tick_detect_max_ms: cfg
.tui
.as_ref()
.and_then(|t| t.scroll_wheel_tick_detect_max_ms),
tui_scroll_wheel_like_max_duration_ms: cfg
.tui
.as_ref()
.and_then(|t| t.scroll_wheel_like_max_duration_ms),
tui_scroll_invert: cfg.tui.as_ref().map(|t| t.scroll_invert).unwrap_or(false),
otel: {
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
@@ -1518,8 +1592,23 @@ persistence = "none"
.expect("TUI config without notifications should succeed");
let tui = parsed.tui.expect("config should include tui section");
assert_eq!(tui.notifications, Notifications::Enabled(true));
assert!(tui.show_tooltips);
assert_eq!(
tui,
Tui {
notifications: Notifications::Enabled(true),
animations: true,
show_tooltips: true,
scroll_events_per_tick: None,
scroll_wheel_lines: None,
scroll_trackpad_lines: None,
scroll_trackpad_accel_events: None,
scroll_trackpad_accel_max: None,
scroll_mode: ScrollInputMode::Auto,
scroll_wheel_tick_detect_max_ms: None,
scroll_wheel_like_max_duration_ms: None,
scroll_invert: false,
}
);
}
#[test]
@@ -3119,6 +3208,15 @@ model_verbosity = "high"
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_scroll_events_per_tick: None,
tui_scroll_wheel_lines: None,
tui_scroll_trackpad_lines: None,
tui_scroll_trackpad_accel_events: None,
tui_scroll_trackpad_accel_max: None,
tui_scroll_mode: ScrollInputMode::Auto,
tui_scroll_wheel_tick_detect_max_ms: None,
tui_scroll_wheel_like_max_duration_ms: None,
tui_scroll_invert: false,
otel: OtelConfig::default(),
},
o3_profile_config
@@ -3194,6 +3292,15 @@ model_verbosity = "high"
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_scroll_events_per_tick: None,
tui_scroll_wheel_lines: None,
tui_scroll_trackpad_lines: None,
tui_scroll_trackpad_accel_events: None,
tui_scroll_trackpad_accel_max: None,
tui_scroll_mode: ScrollInputMode::Auto,
tui_scroll_wheel_tick_detect_max_ms: None,
tui_scroll_wheel_like_max_duration_ms: None,
tui_scroll_invert: false,
otel: OtelConfig::default(),
};
@@ -3284,6 +3391,15 @@ model_verbosity = "high"
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_scroll_events_per_tick: None,
tui_scroll_wheel_lines: None,
tui_scroll_trackpad_lines: None,
tui_scroll_trackpad_accel_events: None,
tui_scroll_trackpad_accel_max: None,
tui_scroll_mode: ScrollInputMode::Auto,
tui_scroll_wheel_tick_detect_max_ms: None,
tui_scroll_wheel_like_max_duration_ms: None,
tui_scroll_invert: false,
otel: OtelConfig::default(),
};
@@ -3360,6 +3476,15 @@ model_verbosity = "high"
tui_notifications: Default::default(),
animations: true,
show_tooltips: true,
tui_scroll_events_per_tick: None,
tui_scroll_wheel_lines: None,
tui_scroll_trackpad_lines: None,
tui_scroll_trackpad_accel_events: None,
tui_scroll_trackpad_accel_max: None,
tui_scroll_mode: ScrollInputMode::Auto,
tui_scroll_wheel_tick_detect_max_ms: None,
tui_scroll_wheel_like_max_duration_ms: None,
tui_scroll_invert: false,
otel: OtelConfig::default(),
};