From 4c8cf3964dc0ae324222b77cc907940f0aedbb8a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 27 Sep 2026 22:00:12 +0000 Subject: [PATCH] Fix SGR mouse reporting for Windows terminal capture (#48799) ## Why Some Windows terminals send legacy mouse reports as key records. Requesting SGR reports lets ConPTY translate them into mouse records. ## What changed Write and flush the SGR encoding request separately from `EnablePointerCapture`, so it also reaches terminals when capture uses the Windows console API. Explicitly reset SGR encoding during Windows mouse cleanup, even if restoring the console mode fails. ## Testing Add regression tests for cleanup after SGR setup fails and an isolated Windows console test that checks mouse input activation, SGR encoding, and restoration of the original console mode and default encoding. GitOrigin-RevId: c1a48d641cda4e770dbbe9fdf805a27e036d35c3 --- codex-rs/tui/src/tui/alternate_screen.rs | 21 ++++- .../tui/src/tui/alternate_screen_tests.rs | 40 ++++++++- .../src/tui/alternate_screen_windows_tests.rs | 85 +++++++++++++++++++ 3 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 codex-rs/tui/src/tui/alternate_screen_windows_tests.rs diff --git a/codex-rs/tui/src/tui/alternate_screen.rs b/codex-rs/tui/src/tui/alternate_screen.rs index c9c6e76c50..2d5e94f083 100644 --- a/codex-rs/tui/src/tui/alternate_screen.rs +++ b/codex-rs/tui/src/tui/alternate_screen.rs @@ -52,7 +52,7 @@ struct EnablePointerCapture; impl Command for EnablePointerCapture { fn write_ansi(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { - writer.write_str("\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1003h") + writer.write_str("\x1b[?1000h\x1b[?1002h\x1b[?1003h") } #[cfg(windows)] @@ -98,7 +98,12 @@ impl AlternateScreen { 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) + execute!(writer, DisableAlternateScroll, EnablePointerCapture).and_then(|()| { + // Some Windows terminals send legacy mouse reports as key records. Request SGR + // reports so ConPTY can translate them into mouse records instead. + writer.write_all(b"\x1b[?1006h")?; + writer.flush() + }) } else { let mouse_result = self.disable_mouse(writer); let scroll_result = execute!(writer, EnableAlternateScroll); @@ -113,6 +118,14 @@ impl AlternateScreen { fn disable_mouse(&self, writer: &mut impl Write) -> Result<()> { if self.mouse_active.load(Ordering::Relaxed) { let result = execute!(writer, DisableMouseCapture); + #[cfg(windows)] + let result = { + // Attempt this even when restoring the console mode fails. + let encoding_result = writer + .write_all(b"\x1b[?1006l") + .and_then(|()| writer.flush()); + result.and(encoding_result) + }; if result.is_err() { // A partial combined write must not skip the remaining mode resets. Keep the // cleanup flag armed because delivery of these best-effort writes is uncertain. @@ -218,3 +231,7 @@ impl super::OverlayInput { #[cfg(test)] #[path = "alternate_screen_tests.rs"] mod tests; + +#[cfg(all(test, windows))] +#[path = "alternate_screen_windows_tests.rs"] +mod windows_tests; diff --git a/codex-rs/tui/src/tui/alternate_screen_tests.rs b/codex-rs/tui/src/tui/alternate_screen_tests.rs index eada79cadd..de5b5f6c98 100644 --- a/codex-rs/tui/src/tui/alternate_screen_tests.rs +++ b/codex-rs/tui/src/tui/alternate_screen_tests.rs @@ -333,6 +333,42 @@ fn owned_overlay_promotion_and_handoff_balance_both_keyboard_stacks() { ); } +#[test] +fn failed_mouse_encoding_setup_is_cleaned_up() { + let screen = AlternateScreen::default(); + let mut output = Vec::new(); + enter_with_mouse_enabled(&screen, &mut output, /*capture_mouse*/ false).unwrap(); + let mut writer = FailOnce { + output, + sequence: b"\x1b[?1006h", + failed: false, + }; + + assert!( + screen + .configure_input(&mut writer, /*capture_mouse*/ true) + .is_err() + ); + screen.leave(&mut writer).unwrap(); + + let mut terminal = vt100::Parser::new( + /*rows*/ 24, /*cols*/ 80, /*scrollback_len*/ 0, + ); + terminal.process(&writer.output); + assert_eq!( + ( + terminal.screen().alternate_screen(), + terminal.screen().mouse_protocol_mode(), + terminal.screen().mouse_protocol_encoding(), + ), + ( + false, + vt100::MouseProtocolMode::None, + vt100::MouseProtocolEncoding::Default, + ), + ); +} + #[test] fn failed_mouse_cleanup_still_leaves_owned_screen_and_remains_retryable() { let screen = AlternateScreen::default(); @@ -462,7 +498,7 @@ fn failed_pager_capture_setup_restores_requested_and_actual_picker_policy() { 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", + sequence: b"\x1b[?1000h\x1b[?1002h\x1b[?1003h", failed: false, }; let mut input = super::super::OverlayInput::Default; @@ -499,7 +535,7 @@ fn identical_default_request_retries_after_failed_fallback_cleanup() { } impl Write for PartialFailure { fn write(&mut self, bytes: &[u8]) -> Result { - if bytes == b"\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1003h" && !self.failed_setup { + if bytes == b"\x1b[?1000h\x1b[?1002h\x1b[?1003h" && !self.failed_setup { self.output.extend_from_slice(b"\x1b[?1000h\x1b[?1002h"); self.failed_setup = true; return Err(std::io::Error::other("partial setup")); diff --git a/codex-rs/tui/src/tui/alternate_screen_windows_tests.rs b/codex-rs/tui/src/tui/alternate_screen_windows_tests.rs new file mode 100644 index 0000000000..ce2ae815bc --- /dev/null +++ b/codex-rs/tui/src/tui/alternate_screen_windows_tests.rs @@ -0,0 +1,85 @@ +//! Exercise Windows console mouse capture in a separate process with its own console. + +use super::AlternateScreen; +use pretty_assertions::assert_eq; +use std::fs::File; +use std::fs::OpenOptions; +use std::os::windows::io::AsRawHandle; +use std::os::windows::process::CommandExt; +use std::process::Command; +use windows_sys::Win32::System::Console::ENABLE_MOUSE_INPUT; +use windows_sys::Win32::System::Console::GetConsoleMode; +use windows_sys::Win32::System::Console::SetConsoleMode; +use windows_sys::Win32::System::Threading::CREATE_NEW_CONSOLE; + +const CHILD_ENV: &str = "CODEX_TUI_MOUSE_CAPTURE_TEST_CHILD"; + +fn console_mode(input: &File) -> u32 { + let mut mode = 0; + // SAFETY: input is an open console handle and mode points to writable storage. + let result = unsafe { GetConsoleMode(input.as_raw_handle() as isize, &mut mode) }; + assert_ne!(result, 0, "{}", std::io::Error::last_os_error()); + mode +} + +#[test] +fn mouse_capture_restores_console_mode_and_encoding() { + if std::env::var_os(CHILD_ENV).is_none() { + let output = Command::new(std::env::current_exe().expect("test binary")) + .args([ + "--exact", + "tui::alternate_screen::windows_tests::mouse_capture_restores_console_mode_and_encoding", + "--nocapture", + ]) + .env(CHILD_ENV, "1") + .creation_flags(CREATE_NEW_CONSOLE) + .output() + .expect("run isolated console test"); + assert!( + output.status.success() && String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + return; + } + + let input = OpenOptions::new() + .read(true) + .write(true) + .open("CONIN$") + .expect("open console input"); + let initial_mode = console_mode(&input) & !ENABLE_MOUSE_INPUT; + // SAFETY: input is an open console handle and initial_mode is a valid console input mode. + let result = unsafe { SetConsoleMode(input.as_raw_handle() as isize, initial_mode) }; + assert_ne!(result, 0, "{}", std::io::Error::last_os_error()); + let original_mode = console_mode(&input); + assert_eq!(original_mode & ENABLE_MOUSE_INPUT, 0); + let screen = AlternateScreen::default(); + let mut output = Vec::new(); + let mut terminal = vt100::Parser::new( + /*rows*/ 24, /*cols*/ 80, /*scrollback_len*/ 0, + ); + + screen + .configure_input(&mut output, /*capture_mouse*/ true) + .unwrap(); + terminal.process(&std::mem::take(&mut output)); + assert_ne!(console_mode(&input) & ENABLE_MOUSE_INPUT, 0); + assert_eq!( + terminal.screen().mouse_protocol_encoding(), + vt100::MouseProtocolEncoding::Sgr, + ); + + screen + .configure_input(&mut output, /*capture_mouse*/ false) + .unwrap(); + terminal.process(&output); + assert_eq!( + ( + console_mode(&input), + terminal.screen().mouse_protocol_encoding() + ), + (original_mode, vt100::MouseProtocolEncoding::Default), + ); +}