feat(tui2): tune scrolling inpu based on (#8357)

## TUI2: Normalize Mouse Scroll Input Across Terminals (Wheel +
Trackpad)

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

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

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

### Why

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

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

### What Changed

#### Scroll Model (TUI2)

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

#### Trackpad Acceleration

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

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

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

#### Config Knobs (TUI2)

All keys live under `[tui]`:

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

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

#### App Integration

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

#### Docs

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

#### Other (jj-only friendliness)

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

### Terminal Defaults

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

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

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

### Testing

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

### Review Focus

- Stream finalization + frame scheduling in `codex-rs/tui2/src/app.rs`.
- Auto-mode promotion thresholds and the 1-event-per-tick fallback
behavior.
- Trackpad divisor cap (`min(events_per_tick, 3)`) and acceleration
defaults.
- Ghostty default tradeoff (3 vs ~9) and whether we should change it.
This commit is contained in:
Josh McKinney
2025-12-20 12:48:12 -08:00
committed by GitHub
parent a6974087e5
commit 63942b883c
9 changed files with 2219 additions and 13 deletions

View File

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

View File

@@ -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<u16>,
/// 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<u16>,
/// 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<u16>,
/// 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 streams computed line delta (including any carried
/// fractional remainder).
pub scroll_trackpad_accel_events: Option<u16>,
/// 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<u16>,
/// 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<u64>,
/// 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<u64>,
/// 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 {

View File

@@ -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 streams computed line delta (including any carried
fractional remainder).
- `scroll_trackpad_accel_max` (number):
- Trackpad acceleration cap (default 3). Set to 1 to effectively disable acceleration.
- `scroll_mode` (`auto` | `wheel` | `trackpad`):
- `auto` (default): infer wheel-like vs trackpad-like per stream.
- `wheel`: always wheel-like (good for wheel-only setups; trackpads will feel jumpy).
- `trackpad`: always trackpad-like (good if auto misclassifies; wheels may feel slow).
- `scroll_wheel_tick_detect_max_ms` (number):
- Auto-mode promotion threshold: how quickly the first tick-worth of events must arrive to
consider the stream wheel-like.
- If wheel feels slow in a dense-wheel terminal, increasing this is usually better than changing
`scroll_events_per_tick`.
- `scroll_wheel_like_max_duration_ms` (number):
- Auto-mode fallback for 1-event-per-tick terminals (WezTerm/iTerm/VS Code).
- If wheel feels like trackpad (too slow) in those terminals, increasing this can help.
- `scroll_invert` (bool):
- Invert direction after terminal detection; applies consistently to wheel and trackpad.
## Previous approaches tried (and why they were replaced)
1. Cadence-based inference (rolling inter-event thresholds)
- Approach: infer wheel vs trackpad using inter-event timing thresholds (burst vs frame cadence vs slow),
with terminal-specific tuning.
- Problem: terminals differ more in event density and batching than in timing; timing overlaps heavily
between wheel and trackpad. Small threshold changes had outsized, terminal-specific effects.
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<Stream>,
carry_lines: f32,
last_redraw_at: Instant,
cfg: Config,
}
struct Config {
events_per_tick: u16,
wheel_lines_per_tick: u16,
trackpad_lines_per_tick: u16,
trackpad_accel_events: u16,
trackpad_accel_max: u16,
wheel_tick_detect_max: Duration,
}
fn on_scroll_event(dir: i32, now: Instant, st: &mut State) -> i32 {
// Close stream on idle gap or direction flip.
if let Some(stream) = st.stream.as_ref() {
let gap = now.duration_since(stream.last);
if gap > STREAM_GAP || stream.dir != dir {
finalize_stream(now, st);
st.stream = None;
}
}
let stream = st.stream.get_or_insert_with(|| Stream {
start: now,
last: now,
dir,
event_count: 0,
accumulated_events: 0,
applied_lines: 0,
kind: StreamKind::Unknown,
just_promoted: false,
});
stream.last = now;
stream.dir = dir;
stream.event_count = (stream.event_count + 1).min(MAX_EVENTS_PER_STREAM);
stream.accumulated_events =
(stream.accumulated_events + dir).clamp(-(MAX_EVENTS_PER_STREAM as i32), MAX_EVENTS_PER_STREAM as i32);
// Auto-mode promotion: promote to wheel-like when the first tick-worth of events arrives quickly.
if matches!(stream.kind, StreamKind::Unknown) {
let ept = st.cfg.events_per_tick.max(1) as usize;
if ept >= 2 && stream.event_count >= ept && now.duration_since(stream.start) <= st.cfg.wheel_tick_detect_max {
stream.kind = StreamKind::Wheel;
stream.just_promoted = true;
}
}
flush_lines(now, st)
}
fn on_tick(now: Instant, st: &mut State) -> i32 {
if let Some(stream) = st.stream.as_ref() {
let gap = now.duration_since(stream.last);
if gap > STREAM_GAP {
return finalize_stream(now, st);
}
}
flush_lines(now, st)
}
fn finalize_stream(now: Instant, st: &mut State) -> i32 {
// In auto mode, any stream that isn't wheel-like by promotion stays trackpad-like.
if let Some(stream) = st.stream.as_mut() {
if matches!(stream.kind, StreamKind::Unknown) {
stream.kind = StreamKind::Trackpad;
}
}
let lines = flush_lines(now, st);
// Carry fractional remainder across streams for trackpad-like input.
if let Some(stream) = st.stream.as_ref() {
if matches!(stream.kind, StreamKind::Trackpad) {
st.carry_lines = desired_lines_f32(st, stream) - stream.applied_lines as f32;
} else {
st.carry_lines = 0.0;
}
}
lines
}
fn flush_lines(now: Instant, st: &mut State) -> i32 {
let Some(stream) = st.stream.as_mut() else { return 0; };
let wheel_like = matches!(stream.kind, StreamKind::Wheel);
let cadence_elapsed = now.duration_since(st.last_redraw_at) >= REDRAW_CADENCE;
let should_flush = wheel_like || cadence_elapsed || stream.just_promoted;
if !should_flush {
return 0;
}
let desired_total = desired_lines_f32(st, stream);
let mut desired_lines = desired_total.trunc() as i32;
// Wheel guardrail: ensure we never produce a "dead tick" for non-zero input.
if wheel_like && desired_lines == 0 && stream.accumulated_events != 0 {
desired_lines = stream.accumulated_events.signum() * MIN_LINES_PER_DISCRETE_STREAM;
}
let mut delta = desired_lines - stream.applied_lines;
if delta == 0 {
return 0;
}
delta = delta.clamp(-MAX_ACCUMULATED_LINES, MAX_ACCUMULATED_LINES);
stream.applied_lines += delta;
stream.just_promoted = false;
st.last_redraw_at = now;
delta
}
fn desired_lines_f32(st: &State, stream: &Stream) -> f32 {
let wheel_like = matches!(stream.kind, StreamKind::Wheel);
let events_per_tick = if wheel_like {
st.cfg.events_per_tick.max(1) as f32
} else {
// Trackpad divisor is capped so dense wheel terminals don't feel slow for trackpads.
st.cfg.events_per_tick.clamp(1, DEFAULT_EVENTS_PER_LINE).max(1) as f32
};
let lines_per_tick = if wheel_like {
st.cfg.wheel_lines_per_tick.max(1) as f32
} else {
st.cfg.trackpad_lines_per_tick.max(1) as f32
};
let mut total = (stream.accumulated_events as f32 * (lines_per_tick / events_per_tick))
.clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32);
if !wheel_like {
total = (total + st.carry_lines).clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32);
// Bounded acceleration for large swipes (keep small swipes precise).
let event_count = stream.accumulated_events.abs() as f32;
let accel = (1.0 + (event_count / st.cfg.trackpad_accel_events.max(1) as f32))
.clamp(1.0, st.cfg.trackpad_accel_max.max(1) as f32);
total = (total * accel).clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32);
}
total
}
```
## 8. Terminal-specific adjustments (minimal)
Use per-terminal `EVENTS_PER_LINE` overrides derived from median `wheel_single` bursts:
```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.

View File

@@ -195,6 +195,9 @@ own scrolling also means we must own mouse interactions endtoend: 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

View File

@@ -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<AtomicBool>,
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<bool> {
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,

View File

@@ -300,6 +300,17 @@ fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, 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,

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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 streams 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 builtin and limited to the TUI session. For programmatic or crossenvironment notifications—or to integrate with OSspecific notifiers—use the toplevel `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 | TUIspecific options. |
| `tui.notifications` | boolean \| array<string> | 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). |