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
This commit is contained in:
Felipe Coury
2026-09-23 00:13:30 +00:00
committed by copyberry
parent 24462234b2
commit 4b664e0ef0
6 changed files with 236 additions and 47 deletions
+1
View File
@@ -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;
+11 -2
View File
@@ -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)
+67 -6
View File
@@ -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<u8>,
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,
+9 -39
View File
@@ -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<String> {
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);
}
+74
View File
@@ -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<String>,
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<Vec<u8>>) -> 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;
+74
View File
@@ -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::<Vec<_>>());
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::<Vec<_>>());
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());
}