diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 404853c2..4be57411 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -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, )); } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 11acf869..7606d033 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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), } } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index b243e55a..c92886f4 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -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 {