mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
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:
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 stream’s 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 {
|
||||
|
||||
610
codex-rs/tui2/docs/scroll_input_model.md
Normal file
610
codex-rs/tui2/docs/scroll_input_model.md
Normal 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 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<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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
1105
codex-rs/tui2/src/tui/scrolling/mouse.rs
Normal file
1105
codex-rs/tui2/src/tui/scrolling/mouse.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<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). |
|
||||
|
||||
Reference in New Issue
Block a user