From 4b664e0ef0397f82e68c60088a90fcd035deb796 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 23 Sep 2026 00:02:53 +0000 Subject: [PATCH] Respect tmux mouse settings in fullscreen and overlays (#47399) ## Why Codex could enable mouse capture even when the containing tmux session had mouse support turned off. ## What changed - Probe tmux's `mouse` and `extended-keys-format` settings together, targeting `TMUX_PANE` when available. - Suppress mouse capture when tmux explicitly reports `0` or `off`, and apply that policy across fullscreen and overlay input changes. Refresh the policy when entering or restoring the alternate screen. - Preserve normal mouse behavior when the probe fails or the setting is unknown, and retain the keyboard-format fallback for older tmux versions. ## Testing Add tests for pane targeting, explicit and unknown mouse settings, probe failures, keyboard fallback, and mouse-policy changes across fullscreen and overlays. GitOrigin-RevId: 7bdd2759207b30a3877e983b489462de7c96e692 --- codex-rs/tui/src/tui.rs | 1 + codex-rs/tui/src/tui/alternate_screen.rs | 13 +++- .../tui/src/tui/alternate_screen_tests.rs | 73 ++++++++++++++++-- codex-rs/tui/src/tui/keyboard_modes.rs | 48 +++--------- codex-rs/tui/src/tui/tmux.rs | 74 +++++++++++++++++++ codex-rs/tui/src/tui/tmux_tests.rs | 74 +++++++++++++++++++ 6 files changed, 236 insertions(+), 47 deletions(-) create mode 100644 codex-rs/tui/src/tui/tmux.rs create mode 100644 codex-rs/tui/src/tui/tmux_tests.rs diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 9f03439747..01b6273094 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -86,6 +86,7 @@ mod startup_tests; mod terminal_stderr; #[cfg(test)] pub(crate) mod test_support; +mod tmux; #[cfg(any(windows, test))] mod windows_console; diff --git a/codex-rs/tui/src/tui/alternate_screen.rs b/codex-rs/tui/src/tui/alternate_screen.rs index 4f45b44570..8cc8ca46e2 100644 --- a/codex-rs/tui/src/tui/alternate_screen.rs +++ b/codex-rs/tui/src/tui/alternate_screen.rs @@ -3,6 +3,7 @@ //! Push only on entry and pop before leaving; the main screen keeps its own TUI mode until //! terminal handoff. Transcript surfaces retain pointer reporting across overlays and disable it //! before yielding to the shell. Promoting an overlay must not push another keyboard frame. +//! Refresh tmux's input policy on entry and apply it to every mouse-capture request. use std::io::Result; use std::io::Write; @@ -23,11 +24,13 @@ use super::DisableAlternateScroll; use super::EnableAlternateScroll; use super::KeyboardRestore; use super::keyboard_modes; +use super::tmux::MouseCapture; // Panic and exit cleanup cannot borrow Tui. Track the actual screen independently of its owner. pub(super) static ALTERNATE_SCREEN: AlternateScreen = AlternateScreen { active: AtomicBool::new(/*v*/ false), mouse_active: AtomicBool::new(/*v*/ false), + mouse_capture_disabled: AtomicBool::new(/*v*/ false), input_configured: AtomicBool::new(/*v*/ false), }; @@ -35,6 +38,8 @@ pub(super) static ALTERNATE_SCREEN: AlternateScreen = AlternateScreen { pub(super) struct AlternateScreen { active: AtomicBool, mouse_active: AtomicBool, + // Refresh alongside keyboard modes whenever this screen is entered or restored. + mouse_capture_disabled: AtomicBool, // A cleanup/setup error must not make the next identical request look already applied. input_configured: AtomicBool, } @@ -68,7 +73,11 @@ impl AlternateScreen { // Stdout retains queued bytes on a flush error; cleanup must follow that pending entry. self.active.store(/*val*/ true, Ordering::Relaxed); writer.flush()?; - keyboard_modes::enable_keyboard_enhancement(writer); + let mouse_capture = keyboard_modes::enable_keyboard_enhancement(writer); + self.mouse_capture_disabled.store( + mouse_capture == MouseCapture::DisabledByTmux, + Ordering::Relaxed, + ); self.configure_input(writer, capture_mouse) } @@ -79,7 +88,7 @@ impl AlternateScreen { ) -> Result<()> { self.input_configured .store(/*val*/ false, Ordering::Relaxed); - let result = if capture_mouse { + let result = if capture_mouse && !self.mouse_capture_disabled.load(Ordering::Relaxed) { // A partial write can already enable reporting; cleanup must still attempt to stop it. self.mouse_active.store(/*val*/ true, Ordering::Relaxed); execute!(writer, DisableAlternateScroll, EnablePointerCapture) diff --git a/codex-rs/tui/src/tui/alternate_screen_tests.rs b/codex-rs/tui/src/tui/alternate_screen_tests.rs index 1b01e6caec..2be750edd2 100644 --- a/codex-rs/tui/src/tui/alternate_screen_tests.rs +++ b/codex-rs/tui/src/tui/alternate_screen_tests.rs @@ -14,6 +14,50 @@ struct KeyboardScreens { alternate_active: bool, } +#[test] +fn refreshed_mouse_policy_applies_to_fullscreen_and_overlays() { + use super::super::OverlayInput; + + for owned in [false, true] { + let screen = AlternateScreen::default(); + let mut output = Vec::new(); + let mut terminal = vt100::Parser::new( + /*rows*/ 24, /*cols*/ 80, /*scrollback_len*/ 0, + ); + let mut input = OverlayInput::Default; + screen + .enter(&mut output, input.captures_mouse(owned)) + .unwrap(); + for disabled in [true, false, true] { + screen + .mouse_capture_disabled + .store(disabled, Ordering::Relaxed); + for next in [ + OverlayInput::Transcript, + OverlayInput::Usage, + OverlayInput::StaticPager, + OverlayInput::Default, + ] { + input.apply(&screen, &mut output, next, owned).unwrap(); + terminal.process(&std::mem::take(&mut output)); + let expected = if !disabled && next.captures_mouse(owned) { + vt100::MouseProtocolMode::AnyMotion + } else { + vt100::MouseProtocolMode::None + }; + assert_eq!( + ( + terminal.screen().alternate_screen(), + terminal.screen().mouse_protocol_mode() + ), + (true, expected), + ); + } + } + screen.leave(&mut output).unwrap(); + } +} + impl KeyboardScreens { fn process(&mut self, output: &[u8]) { for sequence in std::str::from_utf8(output).unwrap().split('\x1b') { @@ -48,6 +92,23 @@ impl KeyboardScreens { } } +// Capture-specific tests must not inherit the tmux session running the test binary. +fn enter_with_mouse_enabled( + screen: &AlternateScreen, + writer: &mut impl Write, + capture_mouse: bool, +) -> Result<()> { + screen.enter(writer, capture_mouse)?; + if screen + .mouse_capture_disabled + .swap(/*val*/ false, Ordering::Relaxed) + && capture_mouse + { + screen.configure_input(writer, capture_mouse)?; + } + Ok(()) +} + struct FailOnce { output: Vec, sequence: &'static [u8], @@ -174,7 +235,7 @@ fn owned_overlay_promotion_and_handoff_balance_both_keyboard_stacks() { /*rows*/ 24, /*cols*/ 80, /*scrollback_len*/ 0, ); keyboard_modes::enable_keyboard_enhancement(&mut output); - screen.enter(&mut output, /*capture_mouse*/ false).unwrap(); + enter_with_mouse_enabled(&screen, &mut output, /*capture_mouse*/ false).unwrap(); keyboard.process(&output); terminal.process(&std::mem::take(&mut output)); let stacks = (keyboard.main.clone(), keyboard.alternate.clone()); @@ -212,7 +273,7 @@ fn owned_overlay_promotion_and_handoff_balance_both_keyboard_stacks() { vt100::MouseProtocolEncoding::Default, ), ); - screen.enter(&mut output, /*capture_mouse*/ true).unwrap(); + enter_with_mouse_enabled(&screen, &mut output, /*capture_mouse*/ true).unwrap(); keyboard.process(&output); terminal.process(&std::mem::take(&mut output)); } @@ -231,7 +292,7 @@ fn owned_overlay_promotion_and_handoff_balance_both_keyboard_stacks() { fn failed_mouse_cleanup_still_leaves_owned_screen_and_remains_retryable() { let screen = AlternateScreen::default(); let mut output = Vec::new(); - screen.enter(&mut output, /*capture_mouse*/ true).unwrap(); + enter_with_mouse_enabled(&screen, &mut output, /*capture_mouse*/ true).unwrap(); let mut writer = FailOnce { output, sequence: b"\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l", @@ -284,7 +345,7 @@ fn pager_capture_restores_picker_input_without_a_screen_transition() { let mut terminal = vt100::Parser::new( /*rows*/ 12, /*cols*/ 40, /*scrollback_len*/ 0, ); - screen.enter(&mut output, /*capture_mouse*/ false).unwrap(); + enter_with_mouse_enabled(&screen, &mut output, /*capture_mouse*/ false).unwrap(); terminal.process(&std::mem::take(&mut output)); assert_eq!( terminal.screen().mouse_protocol_mode(), @@ -353,7 +414,7 @@ fn failed_pager_capture_setup_restores_requested_and_actual_picker_policy() { ] { let screen = AlternateScreen::default(); let mut output = Vec::new(); - screen.enter(&mut output, /*capture_mouse*/ false).unwrap(); + enter_with_mouse_enabled(&screen, &mut output, /*capture_mouse*/ false).unwrap(); let mut writer = FailOnce { output, sequence: b"\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1003h", @@ -417,7 +478,7 @@ fn identical_default_request_retries_after_failed_fallback_cleanup() { failed_setup: false, failed_cleanup: false, }; - screen.enter(&mut writer, /*capture_mouse*/ false).unwrap(); + enter_with_mouse_enabled(&screen, &mut writer, /*capture_mouse*/ false).unwrap(); let mut input = super::super::OverlayInput::Default; let result = input.apply( &screen, diff --git a/codex-rs/tui/src/tui/keyboard_modes.rs b/codex-rs/tui/src/tui/keyboard_modes.rs index f110b2bcbe..bf467c8371 100644 --- a/codex-rs/tui/src/tui/keyboard_modes.rs +++ b/codex-rs/tui/src/tui/keyboard_modes.rs @@ -206,14 +206,16 @@ fn read_windows_vscode_detection_with_timeout( #[path = "windows_term_program_tests.rs"] mod windows_term_program_tests; -pub(super) fn enable_keyboard_enhancement(writer: &mut impl Write) { +/// Restore keyboard reporting and return the mouse policy from the same fresh tmux probe. +pub(super) fn enable_keyboard_enhancement(writer: &mut impl Write) -> super::tmux::MouseCapture { + let tmux_options = super::tmux::options(); if keyboard_enhancement_disabled() { - return; + return tmux_options.mouse_capture; } let running_in_tmux_session = running_in_tmux_session(); let tmux_extended_keys_format = if running_in_tmux_session { - read_tmux_extended_keys_format() + tmux_options.extended_keys_format.as_deref() } else { None }; @@ -224,16 +226,15 @@ pub(super) fn enable_keyboard_enhancement(writer: &mut impl Write) { PushKeyboardEnhancementFlags(keyboard_enhancement_flags( terminal_info().name, running_in_tmux_session, - tmux_extended_keys_format.as_deref() + tmux_extended_keys_format )) ); - if tmux_should_enable_modify_other_keys_for( - running_in_tmux_session, - tmux_extended_keys_format.as_deref(), - ) { + if tmux_should_enable_modify_other_keys_for(running_in_tmux_session, tmux_extended_keys_format) + { let _ = execute!(writer, EnableModifyOtherKeys); } + tmux_options.mouse_capture } fn keyboard_enhancement_flags( @@ -278,37 +279,6 @@ fn tmux_should_enable_modify_other_keys_for( running_in_tmux_session && matches!(extended_keys_format, Some("csi-u")) } -fn read_tmux_extended_keys_format() -> Option { - let executable = codex_utils_path::system_executable("tmux")?; - let path = codex_utils_path::system_path().ok()?; - for args in [ - ["display-message", "-p", "#{extended-keys-format}"], - ["show-options", "-gqv", "extended-keys-format"], - ] { - let output = std::process::Command::new(&executable) - .env("PATH", &path) - .args(args) - .stdin(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .output() - .ok()?; - - if !output.status.success() { - continue; - } - - if let Some(value) = String::from_utf8(output.stdout) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - { - return Some(value); - } - } - - None -} - pub(super) fn restore_keyboard_enhancement_stack(writer: &mut impl Write) { let _ = execute!(writer, PopKeyboardEnhancementFlags, DisableModifyOtherKeys); } diff --git a/codex-rs/tui/src/tui/tmux.rs b/codex-rs/tui/src/tui/tmux.rs new file mode 100644 index 0000000000..147c1c1fb5 --- /dev/null +++ b/codex-rs/tui/src/tui/tmux.rs @@ -0,0 +1,74 @@ +//! Read tmux's current input settings with one probe shared by keyboard and mouse setup. +//! +//! Mouse capture is suppressed only when the containing session explicitly reports mouse off. +//! Missing tmux or an inconclusive probe preserves the normal input policy. + +use std::process::Command; +use std::process::Stdio; + +/// Capture policy for the current terminal setup, shared by fullscreen and overlays. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum MouseCapture { + #[default] + Enabled, + DisabledByTmux, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct Options { + pub(super) extended_keys_format: Option, + pub(super) mouse_capture: MouseCapture, +} + +pub(super) fn options() -> Options { + let pane = std::env::var("TMUX_PANE").ok(); + if std::env::var_os("TMUX").is_none() && pane.is_none() { + return Options::default(); + } + let Some(executable) = codex_utils_path::system_executable("tmux") else { + return Options::default(); + }; + let Ok(path) = codex_utils_path::system_path() else { + return Options::default(); + }; + read_options(pane.as_deref(), |args| { + let output = Command::new(&executable) + .env("PATH", &path) + .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + output.status.success().then_some(output.stdout) + }) +} + +fn read_options(pane: Option<&str>, mut run: impl FnMut(&[&str]) -> Option>) -> Options { + let mut args = vec!["display-message", "-p"]; + if let Some(pane) = pane { + args.extend(["-t", pane]); + } + args.push("#{extended-keys-format}\t#{mouse}"); + let mut options = Options::default(); + if let Some(output) = run(&args).and_then(|output| String::from_utf8(output).ok()) + && let Some((format, mouse)) = output.trim_end_matches(['\r', '\n']).split_once('\t') + { + options.extended_keys_format = + (!format.trim().is_empty()).then(|| format.trim().to_owned()); + if matches!(mouse.trim(), "0" | "off") { + options.mouse_capture = MouseCapture::DisabledByTmux; + } + } + // Preserve keyboard compatibility with tmux versions that cannot expand the format. + if options.extended_keys_format.is_none() { + options.extended_keys_format = run(&["show-options", "-gqv", "extended-keys-format"]) + .and_then(|output| String::from_utf8(output).ok()) + .map(|output| output.trim().to_owned()) + .filter(|output| !output.is_empty()); + } + options +} + +#[cfg(test)] +#[path = "tmux_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/tui/tmux_tests.rs b/codex-rs/tui/src/tui/tmux_tests.rs new file mode 100644 index 0000000000..cc145d33a0 --- /dev/null +++ b/codex-rs/tui/src/tui/tmux_tests.rs @@ -0,0 +1,74 @@ +//! Cover shared probing, session targeting, and conservative behavior for unknown mouse settings. + +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn input_probe_targets_the_containing_pane_and_only_disables_confirmed_mouse_off() { + for (mouse, mouse_capture) in [ + ("0", MouseCapture::DisabledByTmux), + ("off", MouseCapture::DisabledByTmux), + ("1", MouseCapture::Enabled), + ("on", MouseCapture::Enabled), + ("", MouseCapture::Enabled), + ("unknown", MouseCapture::Enabled), + ] { + let mut calls = Vec::new(); + let options = read_options(Some("%42"), |args| { + calls.push(args.iter().map(|arg| (*arg).to_owned()).collect::>()); + Some(format!("csi-u\t{mouse}\n").into_bytes()) + }); + assert_eq!( + options, + Options { + extended_keys_format: Some("csi-u".to_owned()), + mouse_capture, + } + ); + assert_eq!( + calls, + vec![vec![ + "display-message", + "-p", + "-t", + "%42", + "#{extended-keys-format}\t#{mouse}", + ]] + ); + } +} + +#[test] +fn keyboard_fallback_preserves_confirmed_mouse_policy_without_guessing_on_failure() { + for (output, mouse_capture) in [ + (Some(b"\t0\n".to_vec()), MouseCapture::DisabledByTmux), + (Some(b"\t\n".to_vec()), MouseCapture::Enabled), + (Some(vec![0xff]), MouseCapture::Enabled), + (None, MouseCapture::Enabled), + ] { + let mut calls = Vec::new(); + let options = read_options(/*pane*/ None, |args| { + calls.push(args.iter().map(|arg| (*arg).to_owned()).collect::>()); + if args[0] == "display-message" { + output.clone() + } else { + Some(b"xterm\n".to_vec()) + } + }); + assert_eq!( + options, + Options { + extended_keys_format: Some("xterm".to_owned()), + mouse_capture, + } + ); + assert_eq!( + calls, + vec![ + vec!["display-message", "-p", "#{extended-keys-format}\t#{mouse}"], + vec!["show-options", "-gqv", "extended-keys-format"], + ] + ); + } + assert_eq!(read_options(Some("%42"), |_| None), Options::default()); +}