diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index da94f76cb0..397b3c0a2b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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, + + /// 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, + + /// 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, + + /// 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, + + /// 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, + + /// 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, + + /// 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, + + /// 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(), }; diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 8fa43a6772..8439b99160 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -363,6 +363,28 @@ impl Default for Notifications { } } +/// How TUI2 should interpret mouse scroll events. +/// +/// Terminals generally encode both mouse wheels and trackpads as the same "scroll up/down" mouse +/// button events, without a magnitude. This setting controls whether Codex uses a heuristic to +/// infer wheel vs trackpad per stream, or forces a specific behavior. +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ScrollInputMode { + /// Infer wheel vs trackpad behavior per scroll stream. + Auto, + /// Always treat scroll events as mouse-wheel input (fixed lines per tick). + Wheel, + /// Always treat scroll events as trackpad input (fractional accumulation). + Trackpad, +} + +impl Default for ScrollInputMode { + fn default() -> Self { + Self::Auto + } +} + /// Collection of settings that are specific to the TUI. #[derive(Deserialize, Debug, Clone, PartialEq, Default)] pub struct Tui { @@ -380,6 +402,109 @@ pub struct Tui { /// Defaults to `true`. #[serde(default = "default_true")] pub show_tooltips: bool, + + /// Override the *wheel* event density used to normalize TUI2 scrolling. + /// + /// Terminals generally deliver both mouse wheels and trackpads as discrete `scroll up/down` + /// mouse events with direction but no magnitude. Unfortunately, the *number* of raw events + /// per physical wheel notch varies by terminal (commonly 1, 3, or 9+). TUI2 uses this value + /// to normalize that raw event density into consistent "wheel tick" behavior. + /// + /// Wheel math (conceptually): + /// + /// - A single event contributes `1 / scroll_events_per_tick` tick-equivalents. + /// - Wheel-like streams then scale that by `scroll_wheel_lines` so one physical notch scrolls + /// a fixed number of lines. + /// + /// Trackpad math is intentionally *not* fully tied to this value: in trackpad-like mode, TUI2 + /// uses `min(scroll_events_per_tick, 3)` as the divisor so terminals with dense wheel ticks + /// (e.g. 9 events per notch) do not make trackpads feel artificially slow. + /// + /// Defaults are derived per terminal from [`crate::terminal::TerminalInfo`] when TUI2 starts. + /// See `codex-rs/tui2/docs/scroll_input_model.md` for the probe data and rationale. + pub scroll_events_per_tick: Option, + + /// Override how many transcript lines one physical *wheel notch* should scroll in TUI2. + /// + /// This is the "classic feel" knob. Defaults to 3. + /// + /// Wheel-like per-event contribution is `scroll_wheel_lines / scroll_events_per_tick`. For + /// example, in a terminal that emits 9 events per notch, the default `3 / 9` yields 1/3 of a + /// line per event and totals 3 lines once the full notch burst arrives. + /// + /// See `codex-rs/tui2/docs/scroll_input_model.md` for details on the stream model and the + /// wheel/trackpad heuristic. + pub scroll_wheel_lines: Option, + + /// Override baseline trackpad scroll sensitivity in TUI2. + /// + /// Trackpads do not have discrete notches, but terminals still emit discrete `scroll up/down` + /// events. In trackpad-like mode, TUI2 accumulates fractional scroll and only applies whole + /// lines to the viewport. + /// + /// Trackpad per-event contribution is: + /// + /// - `scroll_trackpad_lines / min(scroll_events_per_tick, 3)` + /// + /// (plus optional bounded acceleration; see `scroll_trackpad_accel_*`). The `min(..., 3)` + /// divisor is deliberate: `scroll_events_per_tick` is calibrated from *wheel* behavior and + /// can be much larger than trackpad event density, which would otherwise make trackpads feel + /// too slow in dense-wheel terminals. + /// + /// Defaults to 1, meaning one tick-equivalent maps to one transcript line. + pub scroll_trackpad_lines: Option, + + /// Trackpad acceleration: approximate number of events required to gain +1x speed in TUI2. + /// + /// This keeps small swipes precise while allowing large/faster swipes to cover more content. + /// Defaults are chosen to address terminals where trackpad event density is comparatively low. + /// + /// Concretely, TUI2 computes an acceleration multiplier for trackpad-like streams: + /// + /// - `multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events, 1..scroll_trackpad_accel_max)` + /// + /// The multiplier is applied to the stream’s computed line delta (including any carried + /// fractional remainder). + pub scroll_trackpad_accel_events: Option, + + /// Trackpad acceleration: maximum multiplier applied to trackpad-like streams. + /// + /// Set to 1 to effectively disable trackpad acceleration. + /// + /// See [`Tui::scroll_trackpad_accel_events`] for the exact multiplier formula. + pub scroll_trackpad_accel_max: Option, + + /// Select how TUI2 interprets mouse scroll input. + /// + /// - `auto` (default): infer wheel vs trackpad per scroll stream. + /// - `wheel`: always use wheel behavior (fixed lines per wheel notch). + /// - `trackpad`: always use trackpad behavior (fractional accumulation; wheel may feel slow). + #[serde(default)] + pub scroll_mode: ScrollInputMode, + + /// Auto-mode threshold: maximum time (ms) for the first tick-worth of events to arrive. + /// + /// In `scroll_mode = "auto"`, TUI2 starts a stream as trackpad-like (to avoid overshoot) and + /// promotes it to wheel-like if `scroll_events_per_tick` events arrive "quickly enough". This + /// threshold controls what "quickly enough" means. + /// + /// Most users should leave this unset; it is primarily for terminals that emit wheel ticks + /// batched over longer time spans. + pub scroll_wheel_tick_detect_max_ms: Option, + + /// Auto-mode fallback: maximum duration (ms) that a very small stream is still treated as wheel-like. + /// + /// This is only used when `scroll_events_per_tick` is effectively 1 (one event per wheel + /// notch). In that case, we cannot observe a "tick completion time", so TUI2 treats a + /// short-lived, small stream (<= 2 events) as wheel-like to preserve classic wheel behavior. + pub scroll_wheel_like_max_duration_ms: Option, + + /// Invert mouse scroll direction in TUI2. + /// + /// This flips the scroll sign after terminal detection. It is applied consistently to both + /// wheel and trackpad input. + #[serde(default)] + pub scroll_invert: bool, } const fn default_true() -> bool { diff --git a/codex-rs/tui2/docs/scroll_input_model.md b/codex-rs/tui2/docs/scroll_input_model.md new file mode 100644 index 0000000000..f6eef6a6cb --- /dev/null +++ b/codex-rs/tui2/docs/scroll_input_model.md @@ -0,0 +1,610 @@ +# 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 stream’s 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. + +2. 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. + +3. 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) + +```text +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) + +```rust +// 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, + 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: + +```text +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. diff --git a/codex-rs/tui2/docs/tui_viewport_and_history.md b/codex-rs/tui2/docs/tui_viewport_and_history.md index 3f7733f103..cac56877db 100644 --- a/codex-rs/tui2/docs/tui_viewport_and_history.md +++ b/codex-rs/tui2/docs/tui_viewport_and_history.md @@ -195,6 +195,9 @@ own scrolling also means we must own mouse interactions end‑to‑end: if we le to the terminal, we could not reliably line up selections with transcript content or avoid accidentally copying gutter/margin characters instead of just the conversation text. +Scroll normalization details and the data behind it live in +`codex-rs/tui2/docs/scroll_input_model.md`. + --- ## 5. Printing History to Scrollback @@ -428,7 +431,8 @@ feedback are already implemented: - Bottom pane positioning is pegged high with an empty transcript and moves down as the transcript fills (including on resume). -- Wheel-based transcript scrolling is enabled on top of the new scroll model. +- Wheel-based transcript scrolling uses the stream-based normalization model derived from scroll + probe data (see `codex-rs/tui2/docs/scroll_input_model.md`). - While a selection is active, streaming stops “follow latest output” so the selection remains stable, and follow mode resumes after the selection is cleared. @@ -440,7 +444,7 @@ Vim) behavior as we can while still owning the viewport. **P0 — must-have (usability/correctness):** -- **Scrolling behavior.** Default to small scroll increments (ideally 1 line per wheel tick) with +- **Scrolling behavior.** Default to a classic multi-line wheel tick (3 lines, configurable) with acceleration/velocity for faster navigation, and ensure we stop scrolling when the user stops input (avoid redraw/event-loop backlog that makes scrolling feel “janky”). - **Mouse event bounds.** Ignore mouse events outside the transcript region so clicks in the diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index f0655ad71b..826df7480e 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -19,6 +19,11 @@ use crate::render::renderable::Renderable; use crate::resume_picker::ResumeSelection; use crate::tui; use crate::tui::TuiEvent; +use crate::tui::scrolling::MouseScrollState; +use crate::tui::scrolling::ScrollConfig; +use crate::tui::scrolling::ScrollConfigOverrides; +use crate::tui::scrolling::ScrollDirection; +use crate::tui::scrolling::ScrollUpdate; use crate::tui::scrolling::TranscriptLineMeta; use crate::tui::scrolling::TranscriptScroll; use crate::update_action::UpdateAction; @@ -42,6 +47,7 @@ use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; +use codex_core::terminal::terminal_info; use codex_protocol::ConversationId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; @@ -336,6 +342,9 @@ pub(crate) struct App { /// Controls the animation thread that sends CommitTick events. pub(crate) commit_anim_running: Arc, + scroll_config: ScrollConfig, + scroll_state: MouseScrollState, + // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, pub(crate) feedback: codex_feedback::CodexFeedback, @@ -371,6 +380,7 @@ struct TranscriptSelectionPoint { line_index: usize, column: u16, } + impl App { async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { @@ -478,6 +488,20 @@ impl App { let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); #[cfg(not(debug_assertions))] let upgrade_version = crate::updates::get_upgrade_version(&config); + let scroll_config = ScrollConfig::from_terminal( + &terminal_info(), + ScrollConfigOverrides { + events_per_tick: config.tui_scroll_events_per_tick, + wheel_lines_per_tick: config.tui_scroll_wheel_lines, + trackpad_lines_per_tick: config.tui_scroll_trackpad_lines, + trackpad_accel_events: config.tui_scroll_trackpad_accel_events, + trackpad_accel_max: config.tui_scroll_trackpad_accel_max, + mode: Some(config.tui_scroll_mode), + wheel_tick_detect_max_ms: config.tui_scroll_wheel_tick_detect_max_ms, + wheel_like_max_duration_ms: config.tui_scroll_wheel_like_max_duration_ms, + invert_direction: config.tui_scroll_invert, + }, + ); let mut app = Self { server: conversation_manager.clone(), @@ -498,6 +522,8 @@ impl App { deferred_history_lines: Vec::new(), has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), + scroll_config, + scroll_state: MouseScrollState::default(), backtrack: BacktrackState::default(), feedback: feedback.clone(), pending_update_action: None, @@ -581,6 +607,10 @@ impl App { tui: &mut tui::Tui, event: TuiEvent, ) -> Result { + if matches!(&event, TuiEvent::Draw) { + self.handle_scroll_tick(tui); + } + if self.overlay.is_some() { let _ = self.handle_backtrack_overlay_event(tui, event).await?; } else { @@ -810,7 +840,8 @@ impl App { /// Handle mouse interaction in the main transcript view. /// - /// - Mouse wheel movement scrolls the conversation history by small, fixed increments, + /// - Mouse wheel movement scrolls the conversation history using stream-based + /// normalization (events-per-line factor, discrete vs. continuous streams), /// independent of the terminal's own scrollback. /// - Mouse clicks and drags adjust a text selection defined in terms of /// flattened transcript lines and columns, so the selection is anchored @@ -875,21 +906,26 @@ impl App { match mouse_event.kind { MouseEventKind::ScrollUp => { - self.scroll_transcript( + let scroll_update = self.mouse_scroll_update(ScrollDirection::Up); + self.apply_scroll_update( tui, - -3, + scroll_update, transcript_area.height as usize, transcript_area.width, + true, ); } MouseEventKind::ScrollDown => { - self.scroll_transcript( + let scroll_update = self.mouse_scroll_update(ScrollDirection::Down); + self.apply_scroll_update( tui, - 3, + scroll_update, transcript_area.height as usize, transcript_area.width, + true, ); } + MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {} MouseEventKind::Down(MouseButton::Left) => { if let Some(point) = self.transcript_point_from_coordinates( transcript_area, @@ -931,18 +967,119 @@ impl App { } } - /// Scroll the transcript by a fixed number of visual lines. + /// Convert a single mouse scroll event (direction-only) into a normalized scroll update. + /// + /// This delegates to [`MouseScrollState::on_scroll_event`] using the current [`ScrollConfig`]. + /// The returned [`ScrollUpdate`] is intentionally split into: + /// + /// - `lines`: a *delta* in visual lines to apply immediately to the transcript viewport. + /// - Sign convention matches [`ScrollDirection`] (`Up` is negative; `Down` is positive). + /// - May be 0 in trackpad-like mode while sub-line fractions are still accumulating. + /// - `next_tick_in`: an optional delay after which we should trigger a follow-up tick. + /// This is required because stream closure is defined by a *time gap* rather than an + /// explicit "gesture end" event. See [`App::apply_scroll_update`] and + /// [`App::handle_scroll_tick`]. + /// + /// In TUI2, that follow-up tick is driven via `TuiEvent::Draw`: we schedule a frame, and on + /// the next draw we call [`MouseScrollState::on_tick`] to close idle streams and flush any + /// newly-reached whole lines. This prevents perceived "stop lag" where accumulated scroll only + /// applies once the next user input arrives. + fn mouse_scroll_update(&mut self, direction: ScrollDirection) -> ScrollUpdate { + self.scroll_state + .on_scroll_event(direction, self.scroll_config) + } + + /// Apply a [`ScrollUpdate`] to the transcript viewport and schedule any needed follow-up tick. + /// + /// `update.lines` is applied immediately via [`App::scroll_transcript`]. + /// + /// If `update.next_tick_in` is `Some`, we schedule a future frame so `TuiEvent::Draw` can call + /// [`App::handle_scroll_tick`] and close the stream after it goes idle and/or cadence-flush + /// pending whole lines. + /// + /// `schedule_frame` is forwarded to [`App::scroll_transcript`] and controls whether scrolling + /// should request an additional draw. Pass `false` when applying scroll during a + /// `TuiEvent::Draw` tick to avoid redundant frames. + fn apply_scroll_update( + &mut self, + tui: &mut tui::Tui, + update: ScrollUpdate, + visible_lines: usize, + width: u16, + schedule_frame: bool, + ) { + if update.lines != 0 { + self.scroll_transcript(tui, update.lines, visible_lines, width, schedule_frame); + } + if let Some(delay) = update.next_tick_in { + tui.frame_requester().schedule_frame_in(delay); + } + } + + /// Drive stream closure and cadence-based flushing for mouse scrolling. + /// + /// This is called on every `TuiEvent::Draw` before rendering. If a scroll stream is active, it + /// may: + /// + /// - Close the stream once it has been idle for longer than the stream-gap threshold. + /// - Flush whole-line deltas on the redraw cadence for trackpad-like streams, even if no new + /// events arrive. + /// + /// The resulting update is applied with `schedule_frame = false` because we are already in a + /// draw tick. + fn handle_scroll_tick(&mut self, tui: &mut tui::Tui) { + let Some((visible_lines, width)) = self.transcript_scroll_dimensions(tui) else { + return; + }; + let update = self.scroll_state.on_tick(); + self.apply_scroll_update(tui, update, visible_lines, width, false); + } + + /// Compute the transcript viewport dimensions used for scrolling. + /// + /// Mouse scrolling is applied in terms of "visible transcript lines": the terminal height + /// minus the chat composer height. We compute this from the last known terminal size to avoid + /// querying the terminal during non-draw events. + /// + /// Returns `(visible_lines, width)` or `None` when the terminal is not yet sized or the chat + /// area consumes the full height. + fn transcript_scroll_dimensions(&self, tui: &tui::Tui) -> Option<(usize, u16)> { + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width == 0 || height == 0 { + return None; + } + + let chat_height = self.chat_widget.desired_height(width); + if chat_height >= height { + return None; + } + + let transcript_height = height.saturating_sub(chat_height); + if transcript_height == 0 { + return None; + } + + Some((transcript_height as usize, width)) + } + + /// Scroll the transcript by a number of visual lines. /// /// This is the shared implementation behind mouse wheel movement and PgUp/PgDn keys in /// the main view. Scroll state is expressed in terms of transcript cells and their /// internal line indices, so scrolling refers to logical conversation content and /// remains stable even as wrapping or streaming causes visual reflows. + /// + /// `schedule_frame` controls whether to request an extra draw; pass `false` when applying + /// scroll during a `TuiEvent::Draw` tick to avoid redundant frames. fn scroll_transcript( &mut self, tui: &mut tui::Tui, delta_lines: i32, visible_lines: usize, width: u16, + schedule_frame: bool, ) { if visible_lines == 0 { return; @@ -953,9 +1090,11 @@ impl App { self.transcript_scroll .scrolled_by(delta_lines, &line_meta, visible_lines); - // Delay redraws slightly so scroll bursts coalesce into a single frame. - tui.frame_requester() - .schedule_frame_in(Duration::from_millis(16)); + if schedule_frame { + // Delay redraws slightly so scroll bursts coalesce into a single frame. + tui.frame_requester() + .schedule_frame_in(Duration::from_millis(16)); + } } /// Convert a `ToBottom` (auto-follow) scroll state into a fixed anchor at the current view. @@ -2011,6 +2150,7 @@ impl App { delta, usize::from(transcript_height), width, + true, ); } } @@ -2035,6 +2175,7 @@ impl App { delta, usize::from(transcript_height), width, + true, ); } } @@ -2177,6 +2318,8 @@ mod tests { has_emitted_history_lines: false, enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), + scroll_config: ScrollConfig::default(), + scroll_state: MouseScrollState::default(), backtrack: BacktrackState::default(), feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, @@ -2221,6 +2364,8 @@ mod tests { has_emitted_history_lines: false, enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), + scroll_config: ScrollConfig::default(), + scroll_state: MouseScrollState::default(), backtrack: BacktrackState::default(), feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, diff --git a/codex-rs/tui2/src/diff_render.rs b/codex-rs/tui2/src/diff_render.rs index 24c5be597b..e78a5fd51c 100644 --- a/codex-rs/tui2/src/diff_render.rs +++ b/codex-rs/tui2/src/diff_render.rs @@ -300,6 +300,17 @@ fn render_change(change: &FileChange, out: &mut Vec>, width: usi } pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { + // Prefer a stable, user-local relative path when the file is under the current working + // directory. This keeps output deterministic in jj-only repos (no `.git`) and matches user + // expectations for "files in this project". + if let Some(rel) = pathdiff::diff_paths(path, cwd) + && !rel + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return rel.display().to_string(); + } + let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) { (Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo, _ => false, diff --git a/codex-rs/tui2/src/tui/scrolling.rs b/codex-rs/tui2/src/tui/scrolling.rs index 95346793dc..c3ca6e94de 100644 --- a/codex-rs/tui2/src/tui/scrolling.rs +++ b/codex-rs/tui2/src/tui/scrolling.rs @@ -19,6 +19,13 @@ //! Spacer rows between non-continuation cells are represented as `TranscriptLineMeta::Spacer`. //! They are not valid anchors; `anchor_for` will pick the nearest non-spacer line when needed. +pub(crate) mod mouse; +pub(crate) use mouse::MouseScrollState; +pub(crate) use mouse::ScrollConfig; +pub(crate) use mouse::ScrollConfigOverrides; +pub(crate) use mouse::ScrollDirection; +pub(crate) use mouse::ScrollUpdate; + /// Per-flattened-line metadata for the transcript view. /// /// Each rendered line in the flattened transcript has a corresponding `TranscriptLineMeta` entry diff --git a/codex-rs/tui2/src/tui/scrolling/mouse.rs b/codex-rs/tui2/src/tui/scrolling/mouse.rs new file mode 100644 index 0000000000..975baf41ad --- /dev/null +++ b/codex-rs/tui2/src/tui/scrolling/mouse.rs @@ -0,0 +1,1105 @@ +//! Scroll normalization for mouse wheel/trackpad input. +//! +//! Terminal scroll events vary widely in event counts per wheel tick, and inter-event timing +//! overlaps heavily between wheel and trackpad input. We normalize scroll input by treating +//! events as short streams separated by gaps, converting events into line deltas with a +//! per-terminal events-per-tick factor, and coalescing redraw to a fixed cadence. +//! +//! A mouse wheel "tick" (one notch) is expected to scroll by a fixed number of lines (default: 3) +//! regardless of the terminal's raw event density. Trackpad scrolling should remain higher +//! fidelity (small movements can result in sub-line accumulation that only scrolls once whole +//! lines are reached). +//! +//! Because terminal mouse scroll events do not encode magnitude (only direction), wheel-vs-trackpad +//! detection is heuristic. We bias toward treating input as trackpad-like (to avoid overshoot) and +//! "promote" to wheel-like when the first tick-worth of events arrives quickly. A user can always +//! force wheel/trackpad behavior via config if the heuristic is wrong for their setup. +//! +//! See `codex-rs/tui2/docs/scroll_input_model.md` for the data-derived constants and analysis. + +use codex_core::config::types::ScrollInputMode; +use codex_core::terminal::TerminalInfo; +use codex_core::terminal::TerminalName; +use std::time::Duration; +use std::time::Instant; + +const STREAM_GAP_MS: u64 = 80; +const STREAM_GAP: Duration = Duration::from_millis(STREAM_GAP_MS); +const REDRAW_CADENCE_MS: u64 = 16; +const REDRAW_CADENCE: Duration = Duration::from_millis(REDRAW_CADENCE_MS); +const DEFAULT_EVENTS_PER_TICK: u16 = 3; +const DEFAULT_WHEEL_LINES_PER_TICK: u16 = 3; +const DEFAULT_TRACKPAD_LINES_PER_TICK: u16 = 1; +const DEFAULT_SCROLL_MODE: ScrollInputMode = ScrollInputMode::Auto; +const DEFAULT_WHEEL_TICK_DETECT_MAX_MS: u64 = 12; +const DEFAULT_WHEEL_LIKE_MAX_DURATION_MS: u64 = 200; +const DEFAULT_TRACKPAD_ACCEL_EVENTS: u16 = 30; +const DEFAULT_TRACKPAD_ACCEL_MAX: u16 = 3; +const MAX_EVENTS_PER_STREAM: usize = 256; +const MAX_ACCUMULATED_LINES: i32 = 256; +const MIN_LINES_PER_WHEEL_STREAM: i32 = 1; + +fn default_wheel_tick_detect_max_ms_for_terminal(name: TerminalName) -> u64 { + // This threshold is only used for the "promote to wheel-like" fast path in auto mode. + // We keep it per-terminal because some terminals emit wheel ticks spread over tens of + // milliseconds; a tight global threshold causes those wheel ticks to be misclassified as + // trackpad-like and feel too slow. + match name { + TerminalName::WarpTerminal => 20, + _ => DEFAULT_WHEEL_TICK_DETECT_MAX_MS, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ScrollStreamKind { + Unknown, + Wheel, + Trackpad, +} + +/// High-level scroll direction used to sign line deltas. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ScrollDirection { + Up, + Down, +} + +impl ScrollDirection { + fn sign(self) -> i32 { + match self { + ScrollDirection::Up => -1, + ScrollDirection::Down => 1, + } + } + + fn inverted(self) -> Self { + match self { + ScrollDirection::Up => ScrollDirection::Down, + ScrollDirection::Down => ScrollDirection::Up, + } + } +} + +/// Scroll normalization settings derived from terminal metadata and user overrides. +/// +/// These are the knobs used by [`MouseScrollState`] to translate raw `ScrollUp`/`ScrollDown` +/// events into deltas in *visual lines* for the transcript viewport. +/// +/// - `events_per_line` normalizes per-terminal "event density" (how many raw events correspond to +/// one unit of scroll movement). +/// - `wheel_lines_per_tick` scales short, discrete streams so a single mouse wheel notch retains +/// the classic multi-line feel. +/// +/// See `codex-rs/tui2/docs/scroll_input_model.md` for the probe data and rationale. +/// User-facing overrides are exposed via `config.toml` as: +/// - `tui.scroll_events_per_tick` +/// - `tui.scroll_wheel_lines` +/// - `tui.scroll_invert` +#[derive(Clone, Copy, Debug)] +pub(crate) struct ScrollConfig { + /// Per-terminal normalization factor ("events per wheel tick"). + /// + /// Terminals can emit anywhere from ~1 to ~9+ raw events for the same physical wheel notch. + /// We use this factor to convert raw event counts into a "ticks" estimate. + /// + /// Each raw scroll event contributes `1 / events_per_tick` ticks. That tick value is then + /// scaled to lines depending on the active scroll mode (wheel vs trackpad). + /// + /// User-facing name: `tui.scroll_events_per_tick`. + events_per_tick: u16, + + /// Lines applied per mouse wheel tick. + /// + /// When the input is interpreted as wheel-like, one physical wheel notch maps to this many + /// transcript lines. Default is 3 to match typical "classic terminal" scrolling. + wheel_lines_per_tick: u16, + + /// Lines applied per tick-equivalent for trackpad scrolling. + /// + /// Trackpads do not have discrete "ticks", but terminals still emit discrete up/down events. + /// We interpret trackpad-like streams as `trackpad_lines_per_tick / events_per_tick` lines per + /// event and accumulate fractions until they cross a whole line. + trackpad_lines_per_tick: u16, + + /// Trackpad acceleration: the approximate number of events required to gain +1x speed. + /// + /// This is a pragmatic UX knob: in some terminals the vertical event density for trackpad + /// input can be relatively low, which makes large/faster swipes feel sluggish even when small + /// swipes feel correct. + trackpad_accel_events: u16, + + /// Trackpad acceleration: maximum multiplier applied to trackpad-like streams. + /// + /// Set to 1 to effectively disable acceleration. + trackpad_accel_max: u16, + + /// Force wheel/trackpad behavior, or infer it per stream. + mode: ScrollInputMode, + + /// Auto-mode threshold: how quickly the first wheel tick must complete to be considered wheel. + /// + /// This uses the time between the first event of a stream and the moment we have seen + /// `events_per_tick` events. If the first tick completes faster than this, we promote the + /// stream to wheel-like. If not, we keep treating it as trackpad-like. + wheel_tick_detect_max: Duration, + + /// Auto-mode fallback: maximum duration that is still considered "wheel-like". + /// + /// If a stream ends before this duration and we couldn't confidently classify it, we treat it + /// as wheel-like so wheel notches in 1-event-per-tick terminals (WezTerm/iTerm/VS Code) still + /// get classic multi-line behavior. + wheel_like_max_duration: Duration, + + /// Invert the sign of vertical scroll direction. + /// + /// We do not attempt to infer terminal-level inversion settings; this is an explicit + /// application-level toggle. + invert_direction: bool, +} + +/// Optional user overrides for scroll configuration. +/// +/// Most callers should construct this from the merged [`codex_core::config::Config`] fields so +/// TUI2 inherits terminal defaults and only overrides what the user configured. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct ScrollConfigOverrides { + pub(crate) events_per_tick: Option, + pub(crate) wheel_lines_per_tick: Option, + pub(crate) trackpad_lines_per_tick: Option, + pub(crate) trackpad_accel_events: Option, + pub(crate) trackpad_accel_max: Option, + pub(crate) mode: Option, + pub(crate) wheel_tick_detect_max_ms: Option, + pub(crate) wheel_like_max_duration_ms: Option, + pub(crate) invert_direction: bool, +} + +impl ScrollConfig { + /// Derive scroll normalization defaults from detected terminal metadata. + /// + /// This uses [`TerminalInfo`] (in particular [`TerminalName`]) to pick an empirically derived + /// `events_per_line` default. Users can override both `events_per_line` and the per-wheel-tick + /// multiplier via `config.toml` (see [`ScrollConfig`] docs). + pub(crate) fn from_terminal(terminal: &TerminalInfo, overrides: ScrollConfigOverrides) -> Self { + let mut events_per_tick = match terminal.name { + TerminalName::AppleTerminal => 3, + TerminalName::WarpTerminal => 9, + TerminalName::WezTerm => 1, + TerminalName::Alacritty => 3, + TerminalName::Ghostty => 3, + TerminalName::Iterm2 => 1, + TerminalName::VsCode => 1, + TerminalName::Kitty => 3, + _ => DEFAULT_EVENTS_PER_TICK, + }; + + if let Some(override_value) = overrides.events_per_tick { + events_per_tick = override_value.max(1); + } + + let mut wheel_lines_per_tick = DEFAULT_WHEEL_LINES_PER_TICK; + if let Some(override_value) = overrides.wheel_lines_per_tick { + wheel_lines_per_tick = override_value.max(1); + } + + let mut trackpad_lines_per_tick = DEFAULT_TRACKPAD_LINES_PER_TICK; + if let Some(override_value) = overrides.trackpad_lines_per_tick { + trackpad_lines_per_tick = override_value.max(1); + } + + let mut trackpad_accel_events = DEFAULT_TRACKPAD_ACCEL_EVENTS; + if let Some(override_value) = overrides.trackpad_accel_events { + trackpad_accel_events = override_value.max(1); + } + + let mut trackpad_accel_max = DEFAULT_TRACKPAD_ACCEL_MAX; + if let Some(override_value) = overrides.trackpad_accel_max { + trackpad_accel_max = override_value.max(1); + } + + let wheel_tick_detect_max_ms = overrides + .wheel_tick_detect_max_ms + .unwrap_or_else(|| default_wheel_tick_detect_max_ms_for_terminal(terminal.name)); + let wheel_tick_detect_max = Duration::from_millis(wheel_tick_detect_max_ms); + let wheel_like_max_duration = Duration::from_millis( + overrides + .wheel_like_max_duration_ms + .unwrap_or(DEFAULT_WHEEL_LIKE_MAX_DURATION_MS), + ); + + Self { + events_per_tick, + wheel_lines_per_tick, + trackpad_lines_per_tick, + trackpad_accel_events, + trackpad_accel_max, + mode: overrides.mode.unwrap_or(DEFAULT_SCROLL_MODE), + wheel_tick_detect_max, + wheel_like_max_duration, + invert_direction: overrides.invert_direction, + } + } + + fn events_per_tick_f32(self) -> f32 { + self.events_per_tick.max(1) as f32 + } + + fn wheel_lines_per_tick_f32(self) -> f32 { + self.wheel_lines_per_tick.max(1) as f32 + } + + fn trackpad_lines_per_tick_f32(self) -> f32 { + self.trackpad_lines_per_tick.max(1) as f32 + } + + fn trackpad_events_per_tick_f32(self) -> f32 { + // `events_per_tick` is derived from wheel behavior and can be much larger than the actual + // trackpad event density for the same physical movement. If we use it directly for + // trackpads, terminals like Ghostty/Warp can feel artificially slow. + // + // We cap at the global "typical" wheel tick size (3) which produces more consistent + // trackpad feel across terminals while keeping wheel normalization intact. + self.events_per_tick.clamp(1, DEFAULT_EVENTS_PER_TICK) as f32 + } + + fn trackpad_accel_events_f32(self) -> f32 { + self.trackpad_accel_events.max(1) as f32 + } + + fn trackpad_accel_max_f32(self) -> f32 { + self.trackpad_accel_max.max(1) as f32 + } + + fn apply_direction(self, direction: ScrollDirection) -> ScrollDirection { + if self.invert_direction { + direction.inverted() + } else { + direction + } + } +} + +impl Default for ScrollConfig { + fn default() -> Self { + Self { + events_per_tick: DEFAULT_EVENTS_PER_TICK, + wheel_lines_per_tick: DEFAULT_WHEEL_LINES_PER_TICK, + trackpad_lines_per_tick: DEFAULT_TRACKPAD_LINES_PER_TICK, + trackpad_accel_events: DEFAULT_TRACKPAD_ACCEL_EVENTS, + trackpad_accel_max: DEFAULT_TRACKPAD_ACCEL_MAX, + mode: DEFAULT_SCROLL_MODE, + wheel_tick_detect_max: Duration::from_millis(DEFAULT_WHEEL_TICK_DETECT_MAX_MS), + wheel_like_max_duration: Duration::from_millis(DEFAULT_WHEEL_LIKE_MAX_DURATION_MS), + invert_direction: false, + } + } +} + +/// Output from scroll handling: lines to apply plus when to check for stream end. +/// +/// The caller should apply `lines` immediately. If `next_tick_in` is `Some`, schedule a follow-up +/// tick (typically by requesting a frame) so [`MouseScrollState::on_tick`] can close the stream +/// after a period of silence. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct ScrollUpdate { + pub(crate) lines: i32, + pub(crate) next_tick_in: Option, +} + +/// Tracks mouse scroll input streams and coalesces redraws. +/// +/// This is the state machine that turns discrete terminal scroll events (`ScrollUp`/`ScrollDown`) +/// into viewport line deltas. It implements the stream-based model described in +/// `codex-rs/tui2/docs/scroll_input_model.md`: +/// +/// - **Streams**: a sequence of events is treated as one user gesture until a gap larger than +/// [`STREAM_GAP`] or a direction flip closes the stream. +/// - **Normalization**: streams are converted to line deltas using [`ScrollConfig`] (per-terminal +/// `events_per_tick`, per-mode lines-per-tick, and optional invert). +/// - **Coalescing**: trackpad-like streams are flushed at most every [`REDRAW_CADENCE`] to avoid +/// floods in very dense terminals; wheel-like streams flush immediately to feel responsive. +/// - **Follow-up ticks**: because stream closure is defined by a *time gap*, callers must schedule +/// periodic ticks while a stream is active. The returned [`ScrollUpdate::next_tick_in`] provides +/// the next suggested wake-up. +/// +/// Typical usage: +/// - Call [`MouseScrollState::on_scroll_event`] for each vertical scroll event. +/// - Apply the returned [`ScrollUpdate::lines`] to the transcript scroll state. +/// - If [`ScrollUpdate::next_tick_in`] is present, schedule a delayed tick and call +/// [`MouseScrollState::on_tick`] to close the stream after it goes idle. +#[derive(Clone, Debug)] +pub(crate) struct MouseScrollState { + stream: Option, + last_redraw_at: Instant, + carry_lines: f32, + carry_direction: Option, +} + +impl MouseScrollState { + /// Create a new scroll state with a deterministic time origin. + /// + /// This is primarily used by unit tests so they can control the coalescing and stream-gap + /// behavior by choosing `now` values. Production code generally uses [`Default`] and the + /// `Instant::now()`-based entrypoints. + fn new_at(now: Instant) -> Self { + Self { + stream: None, + last_redraw_at: now, + carry_lines: 0.0, + carry_direction: None, + } + } + + /// Handle a scroll event using the current time. + /// + /// This is the normal production entrypoint used by the TUI event loop. It forwards to + /// [`MouseScrollState::on_scroll_event_at`] using `Instant::now()`. + /// + /// If the returned [`ScrollUpdate::next_tick_in`] is `Some`, callers should schedule a future + /// tick (typically by requesting a frame) and call [`MouseScrollState::on_tick`] (or + /// [`MouseScrollState::on_tick_at`] in tests) so we can close the stream after it goes idle. + /// Without those ticks, streams would only close when a *new* scroll event arrives, which can + /// leave fractional trackpad scroll unflushed and make stop behavior feel laggy. + pub(crate) fn on_scroll_event( + &mut self, + direction: ScrollDirection, + config: ScrollConfig, + ) -> ScrollUpdate { + self.on_scroll_event_at(Instant::now(), direction, config) + } + + /// Handle a scroll event at a specific time. + /// + /// This is the deterministic entrypoint for the scroll stream state machine. It exists so we + /// can write unit tests that exercise stream splitting, coalesced redraw, and end-of-stream + /// flushing without depending on wall-clock time. + /// + /// Behavior is identical to [`MouseScrollState::on_scroll_event`], except the caller provides + /// the timestamp (`now`). In the real app, the timestamp comes from `Instant::now()`. + /// + /// Key details (see `codex-rs/tui2/docs/scroll_input_model.md` for the full model): + /// + /// - **Stream boundaries**: a gap larger than [`STREAM_GAP`] or a direction flip closes the + /// previous stream and starts a new one. + /// - **Wheel vs trackpad**: the stream kind may be promoted to wheel-like in auto mode when a + /// tick-worth of events arrives quickly; otherwise it remains trackpad-like. + /// - **Redraw coalescing**: wheel-like streams flush immediately; trackpad-like streams flush + /// at most every [`REDRAW_CADENCE`]. + /// - **Follow-up ticks**: the returned [`ScrollUpdate::next_tick_in`] tells the caller when it + /// should call [`MouseScrollState::on_tick_at`] to close idle streams and flush any remaining + /// whole lines. In TUI2 this is wired through the app’s frame scheduler. + pub(crate) fn on_scroll_event_at( + &mut self, + now: Instant, + direction: ScrollDirection, + config: ScrollConfig, + ) -> ScrollUpdate { + let direction = config.apply_direction(direction); + let mut lines = 0; + + if let Some(mut stream) = self.stream.take() { + let gap = now.duration_since(stream.last); + if gap > STREAM_GAP || stream.direction != direction { + lines += self.finalize_stream_at(now, &mut stream); + } else { + self.stream = Some(stream); + } + } + + if self.stream.is_none() { + if self.carry_direction != Some(direction) { + self.carry_lines = 0.0; + self.carry_direction = Some(direction); + } + self.stream = Some(ScrollStream::new(now, direction, config)); + } + let carry_lines = self.carry_lines; + let Some(stream) = self.stream.as_mut() else { + unreachable!("stream inserted above"); + }; + stream.push_event(now, direction); + stream.maybe_promote_kind(now); + + // Wheel-like scrolling should feel immediate; trackpad-like streams are coalesced to a + // fixed redraw cadence to avoid floods in very dense terminals. + if stream.is_wheel_like() + || now.duration_since(self.last_redraw_at) >= REDRAW_CADENCE + || stream.just_promoted + { + lines += Self::flush_lines_at(&mut self.last_redraw_at, carry_lines, now, stream); + stream.just_promoted = false; + } + + ScrollUpdate { + lines, + next_tick_in: self.next_tick_in(now), + } + } + + /// Check whether an active stream has ended based on the current time. + pub(crate) fn on_tick(&mut self) -> ScrollUpdate { + self.on_tick_at(Instant::now()) + } + + /// Check whether an active stream has ended at a specific time (for tests). + /// + /// This should be called even when no new scroll events are arriving, while a stream is still + /// considered active. It has two roles: + /// + /// - **Stream closure**: if the stream has been idle for longer than [`STREAM_GAP`], we close + /// it and flush any remaining whole-line scroll. + /// - **Coalesced flush**: for trackpad-like streams, we also flush on [`REDRAW_CADENCE`] even + /// without new events. This avoids a perceived "late jump" when the stream finally closes + /// (users interpret that as overshoot). + pub(crate) fn on_tick_at(&mut self, now: Instant) -> ScrollUpdate { + let mut lines = 0; + if let Some(mut stream) = self.stream.take() { + let gap = now.duration_since(stream.last); + if gap > STREAM_GAP { + lines = self.finalize_stream_at(now, &mut stream); + } else { + // No new events, but we may still have accumulated enough fractional scroll to + // apply additional whole lines. Flushing on a fixed cadence prevents a "late jump" + // when the stream finally closes (which users perceive as overshoot). + if now.duration_since(self.last_redraw_at) >= REDRAW_CADENCE { + lines = Self::flush_lines_at( + &mut self.last_redraw_at, + self.carry_lines, + now, + &mut stream, + ); + } + self.stream = Some(stream); + } + } + + ScrollUpdate { + lines, + next_tick_in: self.next_tick_in(now), + } + } + + /// Finalize a stream and update the trackpad carry state. + /// + /// Callers invoke this when a stream is known to have ended (gap/direction flip). It forces + /// a final wheel/trackpad classification for auto mode, flushes any whole-line deltas, and + /// persists any remaining fractional scroll for trackpad-like streams so the next stream + /// continues smoothly. + fn finalize_stream_at(&mut self, now: Instant, stream: &mut ScrollStream) -> i32 { + stream.finalize_kind(); + let lines = Self::flush_lines_at(&mut self.last_redraw_at, self.carry_lines, now, stream); + + // Preserve sub-line fractional scroll for trackpad-like streams across stream boundaries. + if stream.kind != ScrollStreamKind::Wheel && stream.config.mode != ScrollInputMode::Wheel { + self.carry_lines = + stream.desired_lines_f32(self.carry_lines) - stream.applied_lines as f32; + } else { + self.carry_lines = 0.0; + } + + lines + } + + /// Compute and apply any newly-reached whole-line deltas for the active stream. + /// + /// This converts the stream’s accumulated events to a *desired total line position*, + /// truncates to whole lines, and returns the delta relative to what has already been applied + /// for this stream. + /// + /// For wheel-like streams we also apply a minimum of ±1 line for any non-zero input so wheel + /// notches never become "dead" due to rounding or mis-detection. + fn flush_lines_at( + last_redraw_at: &mut Instant, + carry_lines: f32, + now: Instant, + stream: &mut ScrollStream, + ) -> i32 { + let desired_total = stream.desired_lines_f32(carry_lines); + let mut desired_lines = desired_total.trunc() as i32; + + // For wheel-mode (or wheel-like streams), ensure at least one line for any non-zero input. + // This avoids "dead" wheel ticks when `events_per_tick` is mis-detected or overridden. + if stream.is_wheel_like() && desired_lines == 0 && stream.accumulated_events != 0 { + desired_lines = stream.accumulated_events.signum() * MIN_LINES_PER_WHEEL_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 = stream.applied_lines.saturating_add(delta); + *last_redraw_at = now; + delta + } + + /// Determine when the caller should next call [`MouseScrollState::on_tick_at`]. + /// + /// While a stream is active, we need follow-up ticks for two reasons: + /// + /// - **Stream closure**: once idle for [`STREAM_GAP`], we finalize the stream. + /// - **Trackpad coalescing**: if whole lines are pending but we haven't hit + /// [`REDRAW_CADENCE`] yet, we schedule an earlier tick so the viewport updates promptly. + /// + /// Returning `None` means no stream is active (or it is already past the gap threshold). + fn next_tick_in(&self, now: Instant) -> Option { + let stream = self.stream.as_ref()?; + let gap = now.duration_since(stream.last); + if gap > STREAM_GAP { + return None; + } + + let mut next = STREAM_GAP.saturating_sub(gap); + + // If we've accumulated at least one whole line but haven't flushed yet (because the last + // event arrived before the redraw cadence elapsed), schedule an earlier tick so we can + // flush promptly. + let desired_lines = stream.desired_lines_f32(self.carry_lines).trunc() as i32; + if desired_lines != stream.applied_lines { + let since_redraw = now.duration_since(self.last_redraw_at); + let until_redraw = if since_redraw >= REDRAW_CADENCE { + Duration::from_millis(0) + } else { + REDRAW_CADENCE.saturating_sub(since_redraw) + }; + next = next.min(until_redraw); + } + + Some(next) + } +} + +impl Default for MouseScrollState { + fn default() -> Self { + Self::new_at(Instant::now()) + } +} + +#[derive(Clone, Debug)] +/// Per-stream state accumulated while the user performs one scroll gesture. +/// +/// A "stream" corresponds to one contiguous gesture as defined by [`STREAM_GAP`] (silence) and +/// direction changes. The stream accumulates raw event counts and converts them into a desired +/// total line position via [`ScrollConfig`]. The outer [`MouseScrollState`] then applies only the +/// delta between `desired_total` and `applied_lines` so callers can treat scroll updates as +/// incremental line deltas. +/// +/// This type is intentionally not exposed outside this module. The public API is the pair of +/// entrypoints: +/// +/// - [`MouseScrollState::on_scroll_event_at`] for new events. +/// - [`MouseScrollState::on_tick_at`] for idle-gap closure and coalesced flush. +/// +/// See `codex-rs/tui2/docs/scroll_input_model.md` for the full rationale and probe-derived +/// constants. +struct ScrollStream { + start: Instant, + last: Instant, + direction: ScrollDirection, + event_count: usize, + accumulated_events: i32, + applied_lines: i32, + config: ScrollConfig, + kind: ScrollStreamKind, + first_tick_completed_at: Option, + just_promoted: bool, +} + +impl ScrollStream { + /// Start a new stream at `now`. + /// + /// The initial `kind` is [`ScrollStreamKind::Unknown`]. In auto mode, streams begin behaving + /// like trackpads (to avoid overshoot) until [`ScrollStream::maybe_promote_kind`] promotes the + /// stream to wheel-like. + fn new(now: Instant, direction: ScrollDirection, config: ScrollConfig) -> Self { + Self { + start: now, + last: now, + direction, + event_count: 0, + accumulated_events: 0, + applied_lines: 0, + config, + kind: ScrollStreamKind::Unknown, + first_tick_completed_at: None, + just_promoted: false, + } + } + + /// Record one raw event in the stream. + /// + /// This updates the stream's last-seen timestamp, direction, and counters. Counters are + /// clamped to avoid floods and numeric blowups when terminals emit extremely dense streams. + fn push_event(&mut self, now: Instant, direction: ScrollDirection) { + self.last = now; + self.direction = direction; + self.event_count = self + .event_count + .saturating_add(1) + .min(MAX_EVENTS_PER_STREAM); + self.accumulated_events = (self.accumulated_events + direction.sign()).clamp( + -(MAX_EVENTS_PER_STREAM as i32), + MAX_EVENTS_PER_STREAM as i32, + ); + } + + /// Promote an auto-mode stream to wheel-like if the first tick completes quickly. + /// + /// Terminals often batch a wheel notch into a short burst of `events_per_tick` raw events. + /// When we observe at least that many events and they arrived within + /// [`ScrollConfig::wheel_tick_detect_max`], we treat the stream as wheel-like so a notch + /// scrolls a fixed multi-line amount (classic feel). + /// + /// We only attempt this when `events_per_tick >= 2`. In 1-event-per-tick terminals there is + /// no "tick completion time" signal; auto-mode handles those via + /// [`ScrollStream::finalize_kind`]'s end-of-stream fallback. + fn maybe_promote_kind(&mut self, now: Instant) { + if self.config.mode != ScrollInputMode::Auto { + return; + } + if self.kind != ScrollStreamKind::Unknown { + return; + } + + let events_per_tick = self.config.events_per_tick.max(1) as usize; + if events_per_tick >= 2 && self.event_count >= events_per_tick { + self.first_tick_completed_at.get_or_insert(now); + let elapsed = now.duration_since(self.start); + if elapsed <= self.config.wheel_tick_detect_max { + self.kind = ScrollStreamKind::Wheel; + self.just_promoted = true; + } + } + } + + /// Finalize wheel/trackpad classification for the stream. + /// + /// In forced modes (`wheel`/`trackpad`), this simply sets the stream kind. + /// + /// In auto mode, streams that were not promoted to wheel-like remain trackpad-like, except + /// for a small end-of-stream fallback for 1-event-per-tick terminals. That fallback treats a + /// very small, short-lived stream as wheel-like so wheels in WezTerm/iTerm/VS Code still get + /// the expected multi-line notch behavior. + fn finalize_kind(&mut self) { + match self.config.mode { + ScrollInputMode::Wheel => self.kind = ScrollStreamKind::Wheel, + ScrollInputMode::Trackpad => self.kind = ScrollStreamKind::Trackpad, + ScrollInputMode::Auto => { + if self.kind != ScrollStreamKind::Unknown { + return; + } + // If we didn't see a fast-completing first tick, we keep treating the stream as + // trackpad-like. The only exception is terminals that emit 1 event per wheel tick: + // we can't observe a "tick completion time" there, so we use a conservative + // end-of-stream fallback for *very small* bursts. + let duration = self.last.duration_since(self.start); + if self.config.events_per_tick <= 1 + && self.event_count <= 2 + && duration <= self.config.wheel_like_max_duration + { + self.kind = ScrollStreamKind::Wheel; + } else { + self.kind = ScrollStreamKind::Trackpad; + } + } + } + } + + /// Whether this stream should currently behave like a wheel. + /// + /// In auto mode, streams are wheel-like only after we promote them (or after the 1-event + /// fallback triggers on finalization). While `kind` is still unknown, we treat the stream as + /// trackpad-like to avoid overshooting. + fn is_wheel_like(&self) -> bool { + match self.config.mode { + ScrollInputMode::Wheel => true, + ScrollInputMode::Trackpad => false, + ScrollInputMode::Auto => matches!(self.kind, ScrollStreamKind::Wheel), + } + } + + /// The per-mode lines-per-tick scaling factor. + /// + /// In auto mode, unknown streams use the trackpad factor until promoted. + fn effective_lines_per_tick_f32(&self) -> f32 { + match self.config.mode { + ScrollInputMode::Wheel => self.config.wheel_lines_per_tick_f32(), + ScrollInputMode::Trackpad => self.config.trackpad_lines_per_tick_f32(), + ScrollInputMode::Auto => match self.kind { + ScrollStreamKind::Wheel => self.config.wheel_lines_per_tick_f32(), + ScrollStreamKind::Trackpad | ScrollStreamKind::Unknown => { + self.config.trackpad_lines_per_tick_f32() + } + }, + } + } + + /// Compute the desired total line position for this stream (including trackpad carry). + /// + /// This converts raw event counts into line units using the appropriate divisor and scaling: + /// + /// - Wheel-like: `lines = events * (wheel_lines_per_tick / events_per_tick)` + /// - Trackpad-like: `lines = events * (trackpad_lines_per_tick / min(events_per_tick, 3))` + /// + /// For trackpad-like streams we also add `carry_lines` (fractional remainder from previous + /// streams) and then apply bounded acceleration. The returned value is clamped as a guardrail. + fn desired_lines_f32(&self, carry_lines: f32) -> f32 { + let events_per_tick = if self.is_wheel_like() { + self.config.events_per_tick_f32() + } else { + self.config.trackpad_events_per_tick_f32() + }; + let lines_per_tick = self.effective_lines_per_tick_f32(); + + // Note: clamping here is a guardrail; the primary protection is limiting event_count. + let mut total = (self.accumulated_events as f32 * (lines_per_tick / events_per_tick)) + .clamp( + -(MAX_ACCUMULATED_LINES as f32), + MAX_ACCUMULATED_LINES as f32, + ); + if !self.is_wheel_like() { + total = (total + carry_lines).clamp( + -(MAX_ACCUMULATED_LINES as f32), + MAX_ACCUMULATED_LINES as f32, + ); + + // Trackpad acceleration: keep small swipes precise, but speed up large/fast swipes so + // they can cover more content. This is intentionally simple and bounded. + let event_count = self.accumulated_events.abs() as f32; + let accel = (1.0 + (event_count / self.config.trackpad_accel_events_f32())) + .clamp(1.0, self.config.trackpad_accel_max_f32()); + total = (total * accel).clamp( + -(MAX_ACCUMULATED_LINES as f32), + MAX_ACCUMULATED_LINES as f32, + ); + } + total + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn terminal_info_named(name: TerminalName) -> TerminalInfo { + TerminalInfo { + name, + term_program: None, + version: None, + term: None, + multiplexer: None, + } + } + + #[test] + fn terminal_overrides_match_current_defaults() { + let wezterm = ScrollConfig::from_terminal( + &terminal_info_named(TerminalName::WezTerm), + ScrollConfigOverrides::default(), + ); + let warp = ScrollConfig::from_terminal( + &terminal_info_named(TerminalName::WarpTerminal), + ScrollConfigOverrides::default(), + ); + let ghostty = ScrollConfig::from_terminal( + &terminal_info_named(TerminalName::Ghostty), + ScrollConfigOverrides::default(), + ); + let unknown = ScrollConfig::from_terminal( + &terminal_info_named(TerminalName::Unknown), + ScrollConfigOverrides::default(), + ); + + assert_eq!(wezterm.events_per_tick, 1); + assert_eq!(wezterm.wheel_lines_per_tick, DEFAULT_WHEEL_LINES_PER_TICK); + assert_eq!(warp.events_per_tick, 9); + assert_eq!(ghostty.events_per_tick, 3); + assert_eq!(unknown.events_per_tick, DEFAULT_EVENTS_PER_TICK); + } + + #[test] + fn wheel_tick_scrolls_three_lines_even_when_terminal_emits_three_events() { + let config = ScrollConfig::from_terminal( + &terminal_info_named(TerminalName::AppleTerminal), + ScrollConfigOverrides { + events_per_tick: Some(3), + mode: Some(ScrollInputMode::Auto), + ..ScrollConfigOverrides::default() + }, + ); + let base = Instant::now(); + let mut state = MouseScrollState::new_at(base); + + // Simulate a single wheel notch in terminals that emit 3 raw events per tick. + let _ = state.on_scroll_event_at( + base + Duration::from_millis(1), + ScrollDirection::Down, + config, + ); + let _ = state.on_scroll_event_at( + base + Duration::from_millis(2), + ScrollDirection::Down, + config, + ); + let update = state.on_scroll_event_at( + base + Duration::from_millis(3), + ScrollDirection::Down, + config, + ); + + assert_eq!( + update, + ScrollUpdate { + lines: 3, + next_tick_in: Some(Duration::from_millis(STREAM_GAP_MS)), + } + ); + } + + #[test] + fn wheel_tick_scrolls_three_lines_when_terminal_emits_nine_events() { + let config = ScrollConfig::from_terminal( + &terminal_info_named(TerminalName::WarpTerminal), + ScrollConfigOverrides { + events_per_tick: Some(9), + mode: Some(ScrollInputMode::Auto), + ..ScrollConfigOverrides::default() + }, + ); + let base = Instant::now(); + let mut state = MouseScrollState::new_at(base); + + let mut update = ScrollUpdate::default(); + for idx in 0..9u64 { + update = state.on_scroll_event_at( + base + Duration::from_millis(idx + 1), + ScrollDirection::Down, + config, + ); + } + assert_eq!(update.lines, 3); + } + + #[test] + fn wheel_lines_override_scales_wheel_ticks() { + let config = ScrollConfig::from_terminal( + &terminal_info_named(TerminalName::AppleTerminal), + ScrollConfigOverrides { + events_per_tick: Some(3), + wheel_lines_per_tick: Some(2), + mode: Some(ScrollInputMode::Wheel), + ..ScrollConfigOverrides::default() + }, + ); + let base = Instant::now(); + let mut state = MouseScrollState::new_at(base); + + let first = state.on_scroll_event_at( + base + Duration::from_millis(1), + ScrollDirection::Down, + config, + ); + let second = state.on_scroll_event_at( + base + Duration::from_millis(2), + ScrollDirection::Down, + config, + ); + let third = state.on_scroll_event_at( + base + Duration::from_millis(3), + ScrollDirection::Down, + config, + ); + + assert_eq!(first.lines + second.lines + third.lines, 2); + } + + #[test] + fn ghostty_trackpad_is_not_penalized_by_wheel_event_density() { + let config = ScrollConfig::from_terminal( + &terminal_info_named(TerminalName::Ghostty), + ScrollConfigOverrides { + events_per_tick: Some(9), + mode: Some(ScrollInputMode::Trackpad), + ..ScrollConfigOverrides::default() + }, + ); + let base = Instant::now(); + let mut state = MouseScrollState::new_at(base); + + let _ = state.on_scroll_event_at( + base + Duration::from_millis(1), + ScrollDirection::Down, + config, + ); + let _ = state.on_scroll_event_at( + base + Duration::from_millis(2), + ScrollDirection::Down, + config, + ); + let update = state.on_scroll_event_at( + base + Duration::from_millis(REDRAW_CADENCE_MS + 1), + ScrollDirection::Down, + config, + ); + + // Trackpad mode uses a capped events-per-tick for normalization, so 3 events should + // produce at least one line even when the wheel tick size is 9. + assert_eq!(update.lines, 1); + } + + #[test] + fn trackpad_acceleration_speeds_up_large_swipes_without_affecting_small_swipes_too_much() { + let config = ScrollConfig::from_terminal( + &terminal_info_named(TerminalName::Ghostty), + ScrollConfigOverrides { + events_per_tick: Some(9), + trackpad_accel_events: Some(30), + trackpad_accel_max: Some(3), + mode: Some(ScrollInputMode::Trackpad), + ..ScrollConfigOverrides::default() + }, + ); + let base = Instant::now(); + let mut state = MouseScrollState::new_at(base); + + let mut total_lines = 0; + for idx in 0..60u64 { + let update = state.on_scroll_event_at( + base + Duration::from_millis((idx + 1) * (REDRAW_CADENCE_MS + 1)), + ScrollDirection::Down, + config, + ); + total_lines += update.lines; + } + total_lines += state + .on_tick_at(base + Duration::from_millis(60 * (REDRAW_CADENCE_MS + 1)) + STREAM_GAP) + .lines; + + // Without acceleration, 60 events at 1/3 line each would be ~20 lines. With acceleration, + // we should be meaningfully faster. + assert!(total_lines >= 30, "total_lines={total_lines}"); + } + + #[test] + fn direction_flip_closes_previous_stream() { + let config = ScrollConfig::from_terminal( + &terminal_info_named(TerminalName::AppleTerminal), + ScrollConfigOverrides { + events_per_tick: Some(3), + mode: Some(ScrollInputMode::Auto), + ..ScrollConfigOverrides::default() + }, + ); + let base = Instant::now(); + let mut state = MouseScrollState::new_at(base); + + let _ = + state.on_scroll_event_at(base + Duration::from_millis(1), ScrollDirection::Up, config); + let _ = + state.on_scroll_event_at(base + Duration::from_millis(2), ScrollDirection::Up, config); + let up = + state.on_scroll_event_at(base + Duration::from_millis(3), ScrollDirection::Up, config); + let down = state.on_scroll_event_at( + base + Duration::from_millis(4), + ScrollDirection::Down, + config, + ); + + assert_eq!( + up, + ScrollUpdate { + lines: -3, + next_tick_in: Some(Duration::from_millis(STREAM_GAP_MS)), + } + ); + assert_eq!( + down, + ScrollUpdate { + lines: 0, + next_tick_in: Some(Duration::from_millis(STREAM_GAP_MS)), + } + ); + } + + #[test] + fn continuous_stream_coalesces_redraws() { + let config = ScrollConfig::from_terminal( + &terminal_info_named(TerminalName::AppleTerminal), + ScrollConfigOverrides { + events_per_tick: Some(1), + mode: Some(ScrollInputMode::Trackpad), + ..ScrollConfigOverrides::default() + }, + ); + let base = Instant::now(); + let mut state = MouseScrollState::new_at(base); + + let first = state.on_scroll_event_at( + base + Duration::from_millis(1), + ScrollDirection::Down, + config, + ); + let second = state.on_scroll_event_at( + base + Duration::from_millis(10), + ScrollDirection::Down, + config, + ); + let third = state.on_scroll_event_at( + base + Duration::from_millis(20), + ScrollDirection::Down, + config, + ); + + assert_eq!( + first, + ScrollUpdate { + lines: 0, + next_tick_in: Some(Duration::from_millis(REDRAW_CADENCE_MS - 1)), + } + ); + assert_eq!( + second, + ScrollUpdate { + lines: 0, + next_tick_in: Some(Duration::from_millis(REDRAW_CADENCE_MS - 10)), + } + ); + assert_eq!( + third, + ScrollUpdate { + lines: 3, + next_tick_in: Some(Duration::from_millis(STREAM_GAP_MS)), + } + ); + } + + #[test] + fn invert_direction_flips_sign() { + let config = ScrollConfig::from_terminal( + &terminal_info_named(TerminalName::AppleTerminal), + ScrollConfigOverrides { + events_per_tick: Some(1), + invert_direction: true, + ..ScrollConfigOverrides::default() + }, + ); + let base = Instant::now(); + let mut state = MouseScrollState::new_at(base); + + let update = state.on_scroll_event_at( + base + Duration::from_millis(REDRAW_CADENCE_MS + 1), + ScrollDirection::Up, + config, + ); + + assert_eq!( + update, + ScrollUpdate { + lines: 1, + next_tick_in: Some(Duration::from_millis(STREAM_GAP_MS)), + } + ); + } +} diff --git a/docs/config.md b/docs/config.md index f9bbb2ed00..691f436068 100644 --- a/docs/config.md +++ b/docs/config.md @@ -885,6 +885,54 @@ notifications = [ "agent-turn-complete", "approval-requested" ] # Disable terminal animations (welcome screen, status shimmer, spinner). # Defaults to true. animations = false + +# TUI2 mouse scrolling (wheel + trackpad) +# +# Terminals emit different numbers of raw scroll events per physical wheel notch (commonly 1, 3, +# or 9+). TUI2 normalizes raw event density into consistent wheel behavior (default: ~3 lines per +# wheel notch) while keeping trackpad input higher fidelity via fractional accumulation. +# +# See `codex-rs/tui2/docs/scroll_input_model.md` for the model and probe data. + +# Override *wheel* event density (raw events per physical wheel notch). TUI2 only. +# +# Wheel-like per-event contribution is: +# - `scroll_wheel_lines / scroll_events_per_tick` +# +# Trackpad-like streams use `min(scroll_events_per_tick, 3)` as the divisor so dense wheel ticks +# (e.g. 9 events per notch) do not make trackpads feel artificially slow. +scroll_events_per_tick = 3 + +# Override wheel scroll lines per physical wheel notch (classic feel). TUI2 only. +scroll_wheel_lines = 3 + +# Override baseline trackpad sensitivity (lines per tick-equivalent). TUI2 only. +# +# Trackpad-like per-event contribution is: +# - `scroll_trackpad_lines / min(scroll_events_per_tick, 3)` +scroll_trackpad_lines = 1 + +# Trackpad acceleration (optional). TUI2 only. +# These keep small swipes precise while letting large/faster swipes cover more content. +# +# Concretely, TUI2 computes: +# - `multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events, 1..scroll_trackpad_accel_max)` +# +# The multiplier is applied to the trackpad-like stream’s computed line delta (including any +# carried fractional remainder). +scroll_trackpad_accel_events = 30 +scroll_trackpad_accel_max = 3 + +# Force scroll interpretation. TUI2 only. +# Valid values: "auto" (default), "wheel", "trackpad" +scroll_mode = "auto" + +# Auto-mode heuristic tuning. TUI2 only. +scroll_wheel_tick_detect_max_ms = 12 +scroll_wheel_like_max_duration_ms = 200 + +# Invert scroll direction for mouse wheel/trackpad. TUI2 only. +scroll_invert = false ``` > [!NOTE] @@ -892,6 +940,23 @@ animations = false > [!NOTE] > `tui.notifications` is built‑in and limited to the TUI session. For programmatic or cross‑environment notifications—or to integrate with OS‑specific notifiers—use the top‑level `notify` option to run an external program that receives event JSON. The two settings are independent and can be used together. +Scroll settings (`tui.scroll_events_per_tick`, `tui.scroll_wheel_lines`, `tui.scroll_trackpad_lines`, `tui.scroll_trackpad_accel_*`, `tui.scroll_mode`, `tui.scroll_wheel_*`, `tui.scroll_invert`) currently apply to the TUI2 viewport scroll implementation. + +> [!NOTE] > `tui.scroll_events_per_tick` has terminal-specific defaults derived from mouse scroll probe logs +> collected on macOS for a small set of terminals: +> +> - Terminal.app: 3 +> - Warp: 9 +> - WezTerm: 1 +> - Alacritty: 3 +> - Ghostty: 3 (stopgap; one probe measured ~9) +> - iTerm2: 1 +> - VS Code terminal: 1 +> - Kitty: 3 +> +> We should augment these defaults with data from more terminals and other platforms over time. +> Unknown terminals fall back to 3 and can be overridden via `tui.scroll_events_per_tick`. + ## Authentication and authorization ### Forcing a login method @@ -975,6 +1040,15 @@ Valid values: | `file_opener` | `vscode` \| `vscode-insiders` \| `windsurf` \| `cursor` \| `none` | URI scheme for clickable citations (default: `vscode`). | | `tui` | table | TUI‑specific options. | | `tui.notifications` | boolean \| array | Enable desktop notifications in the tui (default: true). | +| `tui.scroll_events_per_tick` | number | Raw events per wheel notch (normalization input; default: terminal-specific; fallback: 3). | +| `tui.scroll_wheel_lines` | number | Lines per physical wheel notch in wheel-like mode (default: 3). | +| `tui.scroll_trackpad_lines` | number | Baseline trackpad sensitivity in trackpad-like mode (default: 1). | +| `tui.scroll_trackpad_accel_events` | number | Trackpad acceleration: events per +1x speed in TUI2 (default: 30). | +| `tui.scroll_trackpad_accel_max` | number | Trackpad acceleration: max multiplier in TUI2 (default: 3). | +| `tui.scroll_mode` | `auto` \| `wheel` \| `trackpad` | How to interpret scroll input in TUI2 (default: `auto`). | +| `tui.scroll_wheel_tick_detect_max_ms` | number | Auto-mode threshold (ms) for promoting a stream to wheel-like behavior (default: 12). | +| `tui.scroll_wheel_like_max_duration_ms` | number | Auto-mode fallback duration (ms) used for 1-event-per-tick terminals (default: 200). | +| `tui.scroll_invert` | boolean | Invert mouse scroll direction in TUI2 (default: false). | | `hide_agent_reasoning` | boolean | Hide model reasoning events. | | `check_for_update_on_startup` | boolean | Check for Codex updates on startup (default: true). Set to `false` only if updates are centrally managed. | | `show_raw_agent_reasoning` | boolean | Show raw reasoning (when available). |