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
This commit is contained in:
Eric Traut
2026-09-27 22:13:56 +00:00
committed by copyberry
parent d7748e1185
commit 4c8cf3964d
3 changed files with 142 additions and 4 deletions
+19 -2
View File
@@ -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;
+38 -2
View File
@@ -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<usize> {
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"));
@@ -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),
);
}