fix(tui): default composer_arrows_scroll on when mouse capture is off

On platforms where mouse capture is disabled by default (Windows CMD /
legacy conhost), the terminal sends mouse-wheel events as Up/Down arrow-
key sequences.  Without composer_arrows_scroll those sequences cycle the
input history instead of scrolling the transcript (#1443).

Set the default for composer_arrows_scroll to !use_mouse_capture so that
terminals that forward wheel events as arrows get page-scrolling out of the
box, while terminals with real mouse capture (Windows Terminal, Linux, macOS)
keep the existing history-navigation default.

The explicit [tui] composer_arrows_scroll config key still overrides the
derived default in both directions.

Also enable mouse capture by default for ConEmu/Cmder (ConEmuPID env var),
which handles VT mouse-mode reporting cleanly, giving those users in-app
scrolling without needing --mouse-capture.

Fixes #1443

Signed-off-by: CrepuscularIRIS <serenitygp@qq.com>
This commit is contained in:
CrepuscularIRIS
2026-05-11 13:51:38 -04:00
committed by Hunter Bown
parent ae45d1054b
commit dde1e5e2f1
3 changed files with 129 additions and 18 deletions
+43 -15
View File
@@ -3648,12 +3648,14 @@ fn should_use_alt_screen(_cli: &Cli, _config: &Config) -> bool {
fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
let wt_session = std::env::var("WT_SESSION").ok().filter(|s| !s.is_empty());
let conemu_pid = std::env::var("ConEmuPID").ok().filter(|s| !s.is_empty());
should_use_mouse_capture_with(
cli,
config,
use_alt_screen,
terminal_emulator.as_deref(),
wt_session.as_deref(),
conemu_pid.as_deref(),
)
}
@@ -3663,6 +3665,7 @@ fn should_use_mouse_capture_with(
use_alt_screen: bool,
terminal_emulator: Option<&str>,
wt_session: Option<&str>,
conemu_pid: Option<&str>,
) -> bool {
if !use_alt_screen || cli.no_mouse_capture {
return false;
@@ -3674,15 +3677,16 @@ fn should_use_mouse_capture_with(
.tui
.as_ref()
.and_then(|tui| tui.mouse_capture)
.unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session))
.unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session, conemu_pid))
}
/// Whether to enable terminal mouse capture by default for this platform/host.
///
/// On Windows the default depends on the host: Windows Terminal (which sets
/// `WT_SESSION`) handles mouse-mode reporting cleanly, so default-on there
/// gives users in-app text selection and keeps the application's selection
/// clamped to the transcript area (#1169). Legacy conhost stays default-off
/// `WT_SESSION`) and ConEmu/Cmder (which set `ConEmuPID`) handle mouse-mode
/// reporting cleanly, so default-on there gives users in-app text selection
/// and keeps the application's selection clamped to the transcript area
/// (#1169). Legacy conhost (CMD without either env var) stays default-off
/// because its mouse-mode reporting can leak SGR escape sequences as raw
/// text into the composer (#878 / #898).
///
@@ -3693,9 +3697,10 @@ fn should_use_mouse_capture_with(
fn default_mouse_capture_enabled(
terminal_emulator: Option<&str>,
wt_session: Option<&str>,
conemu_pid: Option<&str>,
) -> bool {
if cfg!(windows) {
return wt_session.is_some();
return wt_session.is_some() || conemu_pid.is_some();
}
if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
return false;
@@ -4661,21 +4666,21 @@ mod terminal_mode_tests {
let config = Config::default();
assert!(should_use_mouse_capture_with(
&cli, &config, true, None, None
&cli, &config, true, None, None, None
));
}
#[test]
#[cfg(windows)]
fn mouse_capture_defaults_off_on_legacy_windows_console() {
// Legacy conhost (no `WT_SESSION`) keeps the v0.8.x default-off
// behavior: mouse-mode reporting on legacy console can leak SGR
// escapes into the composer.
// Legacy conhost (no `WT_SESSION` and no `ConEmuPID`) keeps the
// v0.8.x default-off behavior: mouse-mode reporting on legacy console
// can leak SGR escapes into the composer.
let cli = parse_cli(&["deepseek"]);
let config = Config::default();
assert!(!should_use_mouse_capture_with(
&cli, &config, true, None, None
&cli, &config, true, None, None, None
));
}
@@ -4696,6 +4701,25 @@ mod terminal_mode_tests {
true,
None,
Some("{a3a3b3a8-aa00-0000-0000-000000000000}"),
None,
));
}
// ConEmu/Cmder sets `ConEmuPID` and handles VT mouse-mode reporting
// cleanly; default mouse capture on there so users get in-app scrolling.
#[test]
#[cfg(windows)]
fn mouse_capture_defaults_on_in_conemu() {
let cli = parse_cli(&["deepseek"]);
let config = Config::default();
assert!(should_use_mouse_capture_with(
&cli,
&config,
true,
None,
None,
Some("12345"),
));
}
@@ -4705,7 +4729,7 @@ mod terminal_mode_tests {
let config = Config::default();
assert!(!should_use_mouse_capture_with(
&cli, &config, true, None, None
&cli, &config, true, None, None, None
));
}
@@ -4726,7 +4750,7 @@ mod terminal_mode_tests {
};
assert!(!should_use_mouse_capture_with(
&cli, &config, true, None, None
&cli, &config, true, None, None, None
));
}
@@ -4736,7 +4760,7 @@ mod terminal_mode_tests {
let config = Config::default();
assert!(should_use_mouse_capture_with(
&cli, &config, true, None, None
&cli, &config, true, None, None, None
));
}
@@ -4757,7 +4781,7 @@ mod terminal_mode_tests {
};
assert!(should_use_mouse_capture_with(
&cli, &config, true, None, None
&cli, &config, true, None, None, None
));
}
@@ -4767,7 +4791,7 @@ mod terminal_mode_tests {
let config = Config::default();
assert!(!should_use_mouse_capture_with(
&cli, &config, false, None, None
&cli, &config, false, None, None, None
));
}
@@ -4789,6 +4813,7 @@ mod terminal_mode_tests {
true,
Some("JetBrains-JediTerm"),
None,
None,
));
}
@@ -4805,6 +4830,7 @@ mod terminal_mode_tests {
true,
Some("jetbrains-jediterm"),
None,
None,
));
}
@@ -4819,6 +4845,7 @@ mod terminal_mode_tests {
true,
Some("JetBrains-JediTerm"),
None,
None,
));
}
@@ -4844,6 +4871,7 @@ mod terminal_mode_tests {
true,
Some("JetBrains-JediTerm"),
None,
None,
));
}
}
+5 -2
View File
@@ -786,7 +786,10 @@ pub struct App {
pub use_alt_screen: bool,
pub use_mouse_capture: bool,
/// When true, plain Up/Down on an empty composer scroll the transcript
/// instead of navigating input history (#1117 opt-in).
/// instead of navigating input history. Defaults to `true` when mouse
/// capture is off: terminals that convert mouse-wheel events to arrow-key
/// sequences (e.g. Windows CMD without `WT_SESSION`) get page-scrolling
/// without any explicit config (#1443).
pub composer_arrows_scroll: bool,
pub use_bracketed_paste: bool,
pub use_paste_burst_detection: bool,
@@ -1542,7 +1545,7 @@ impl App {
.tui
.as_ref()
.and_then(|tui| tui.composer_arrows_scroll)
.unwrap_or(false),
.unwrap_or(!use_mouse_capture),
}
}
+81 -1
View File
@@ -1022,6 +1022,30 @@ fn create_test_app() -> App {
App::new(options, &Config::default())
}
fn create_test_options() -> TuiOptions {
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: false,
yolo: false,
resume_session_id: None,
initial_input: None,
}
}
fn text_message(role: &str, text: &str) -> Message {
Message {
role: role.to_string(),
@@ -4567,9 +4591,12 @@ fn checklist_write_renders_dedicated_card() {
#[test]
fn history_arrow_handles_empty_input() {
let mut app = create_test_app();
// Explicitly disable arrows-scroll so this test covers the
// history-navigation path regardless of the mouse-capture default.
app.composer_arrows_scroll = false;
app.input_history.push("previous prompt".to_string());
// Default: empty composer Up navigates input history (#1117).
// With arrows-scroll off: empty composer Up navigates input history (#1117).
assert!(handle_composer_history_arrow(
&mut app,
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
@@ -4582,6 +4609,9 @@ fn history_arrow_handles_empty_input() {
#[test]
fn history_arrow_handles_whitespace_input() {
let mut app = create_test_app();
// Explicitly disable arrows-scroll so this test covers the
// history-navigation path regardless of the mouse-capture default.
app.composer_arrows_scroll = false;
app.input = " ".to_string();
app.cursor_position = app.input.chars().count();
app.input_history.push("previous prompt".to_string());
@@ -4660,6 +4690,56 @@ fn composer_arrows_scroll_nonempty_still_navigates_history() {
assert_eq!(app.input, "previous prompt");
}
// #1443: when mouse capture is off (e.g. Windows CMD), arrow-scroll
// must default to true so mouse-wheel events (sent as arrow keys by
// the terminal) scroll the transcript rather than cycling history.
#[test]
fn composer_arrows_scroll_defaults_true_without_mouse_capture() {
let options = TuiOptions {
use_mouse_capture: false,
..create_test_options()
};
let app = App::new(options, &Config::default());
assert!(
app.composer_arrows_scroll,
"arrows-scroll must default to true when mouse capture is off"
);
}
#[test]
fn composer_arrows_scroll_defaults_false_with_mouse_capture() {
let options = TuiOptions {
use_mouse_capture: true,
..create_test_options()
};
let app = App::new(options, &Config::default());
assert!(
!app.composer_arrows_scroll,
"arrows-scroll must default to false when mouse capture is on"
);
}
#[test]
fn composer_arrows_scroll_config_overrides_default() {
let config = Config {
tui: Some(crate::config::TuiConfig {
composer_arrows_scroll: Some(false),
..Default::default()
}),
..Config::default()
};
// Even with mouse_capture off, explicit config=false wins.
let options = TuiOptions {
use_mouse_capture: false,
..create_test_options()
};
let app = App::new(options, &config);
assert!(
!app.composer_arrows_scroll,
"explicit config=false must override the mouse-capture-derived default"
);
}
#[test]
fn notification_settings_tui_always_keeps_configured_method_no_threshold() {
let config = Config {