mirror of
https://github.com/openai/codex.git
synced 2026-10-14 16:38:43 +00:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user