From bbad09a83b1fd9d7e507d688fcd266a7889d7df6 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 21 Jul 2026 22:01:54 +0000 Subject: [PATCH] Fix Windows TUI navigation key handling (#34625) ## Why Crossterm's Windows event backend expects Win32 input records. When the console inherits virtual terminal input mode, or another console client restores it, navigation keys instead arrive as literal escape bytes. ## What changed - Keep the Windows console in input-record mode while the event stream is polled, and disable focus-change reporting on Windows. - Preserve and restore the console's original virtual terminal input setting when the TUI shuts down. ## Testing - Add unit coverage for clearing and restoring the virtual terminal input bit. GitOrigin-RevId: 792b99e354fce357d51810a6c3b437e972ad11ce --- codex-rs/tui/src/tui.rs | 32 ++++++++ codex-rs/tui/src/tui/event_stream.rs | 16 +++- codex-rs/tui/src/tui/windows_console.rs | 104 ++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 codex-rs/tui/src/tui/windows_console.rs diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 76fb9b9c00..33509874fa 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -19,6 +19,7 @@ use crossterm::cursor::SetCursorStyle; use crossterm::event::DisableBracketedPaste; use crossterm::event::DisableFocusChange; use crossterm::event::EnableBracketedPaste; +#[cfg(not(windows))] use crossterm::event::EnableFocusChange; use crossterm::event::KeyEvent; use crossterm::terminal::EnterAlternateScreen; @@ -62,6 +63,8 @@ mod keyboard_modes; mod terminal_stderr; #[cfg(test)] pub(crate) mod test_support; +#[cfg(any(windows, test))] +mod windows_console; /// Target frame interval for UI redraw scheduling. pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME_INTERVAL; @@ -123,6 +126,26 @@ mod tests { )); } + #[test] + fn windows_console_input_modes_preserve_original_vt_input_state() { + let input_record_mode = super::windows_console::input_record_mode(/*mode*/ 0x398); + assert_eq!(input_record_mode, 0x198); + assert_eq!( + super::windows_console::restored_input_mode( + input_record_mode, + super::windows_console::VirtualTerminalInput::Enabled, + ), + 0x398 + ); + assert_eq!( + super::windows_console::restored_input_mode( + /*mode*/ 0x198, + super::windows_console::VirtualTerminalInput::Disabled, + ), + 0x198 + ); + } + #[test] fn unfocused_notification_condition_emits_when_unfocused() { assert!(should_emit_notification( @@ -193,6 +216,8 @@ pub fn set_modes() -> Result<()> { execute!(stdout(), EnableBracketedPaste)?; enable_raw_mode()?; + #[cfg(windows)] + windows_console::set_input_record_mode()?; // Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated. // chat_composer.rs is using a keyboard event listener to enter for any modified keys // to create a new line that require this. @@ -201,7 +226,10 @@ pub fn set_modes() -> Result<()> { // gracefully if unsupported. keyboard_modes::enable_keyboard_enhancement(); + #[cfg(not(windows))] let _ = execute!(stdout(), EnableFocusChange); + #[cfg(windows)] + let _ = execute!(stdout(), DisableFocusChange); Ok(()) } @@ -279,6 +307,10 @@ fn restore_common( { first_error.get_or_insert(err); } + #[cfg(windows)] + if let Err(err) = windows_console::restore_input_mode() { + first_error.get_or_insert(err); + } if let Err(err) = execute!( stdout(), SetCursorStyle::DefaultUserShape, diff --git a/codex-rs/tui/src/tui/event_stream.rs b/codex-rs/tui/src/tui/event_stream.rs index 9a5e416fd6..b805176756 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -125,7 +125,21 @@ impl Default for CrosstermEventSource { impl EventSource for CrosstermEventSource { fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.get_mut().0).poll_next(cx) + // Crossterm's Windows backend expects Win32 input records. If VT input is inherited or + // restored by another console client, navigation keys arrive as literal escape bytes. + #[cfg(windows)] + let _ = super::windows_console::ensure_input_record_mode(); + + let result = Pin::new(&mut self.get_mut().0).poll_next(cx); + + // EventStream starts its blocking reader before returning Pending, so reassert the mode + // after that transition as well. + #[cfg(windows)] + if result.is_pending() { + let _ = super::windows_console::ensure_input_record_mode(); + } + + result } } diff --git a/codex-rs/tui/src/tui/windows_console.rs b/codex-rs/tui/src/tui/windows_console.rs new file mode 100644 index 0000000000..e328f4ef50 --- /dev/null +++ b/codex-rs/tui/src/tui/windows_console.rs @@ -0,0 +1,104 @@ +const ENABLE_VIRTUAL_TERMINAL_INPUT: u32 = 0x0200; + +#[derive(Clone, Copy)] +pub(super) enum VirtualTerminalInput { + Enabled, + Disabled, +} + +pub(super) fn input_record_mode(mode: u32) -> u32 { + mode & !ENABLE_VIRTUAL_TERMINAL_INPUT +} + +pub(super) fn restored_input_mode(mode: u32, original: VirtualTerminalInput) -> u32 { + match original { + VirtualTerminalInput::Enabled => mode | ENABLE_VIRTUAL_TERMINAL_INPUT, + VirtualTerminalInput::Disabled => input_record_mode(mode), + } +} + +#[cfg(windows)] +static ORIGINAL_VT_INPUT: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +#[cfg(windows)] +fn current_input_mode() -> Option<(windows_sys::Win32::Foundation::HANDLE, u32)> { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::GetConsoleMode; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; + + let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + if handle == INVALID_HANDLE_VALUE || handle == 0 { + return None; + } + + let mut mode = 0; + if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { + return None; + } + + Some((handle, mode)) +} + +#[cfg(windows)] +pub(super) fn set_input_record_mode() -> std::io::Result<()> { + use windows_sys::Win32::System::Console::SetConsoleMode; + + let Some((handle, mode)) = current_input_mode() else { + return Ok(()); + }; + let requested_mode = input_record_mode(mode); + if requested_mode != mode && unsafe { SetConsoleMode(handle, requested_mode) } == 0 { + return Err(std::io::Error::last_os_error()); + } + + let original = if mode & ENABLE_VIRTUAL_TERMINAL_INPUT != 0 { + VirtualTerminalInput::Enabled + } else { + VirtualTerminalInput::Disabled + }; + ORIGINAL_VT_INPUT + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(original); + Ok(()) +} + +#[cfg(windows)] +pub(super) fn ensure_input_record_mode() -> std::io::Result<()> { + use windows_sys::Win32::System::Console::SetConsoleMode; + + let Some((handle, mode)) = current_input_mode() else { + return Ok(()); + }; + let requested_mode = input_record_mode(mode); + if requested_mode != mode && unsafe { SetConsoleMode(handle, requested_mode) } == 0 { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) +} + +#[cfg(windows)] +pub(super) fn restore_input_mode() -> std::io::Result<()> { + use windows_sys::Win32::System::Console::SetConsoleMode; + + let mut original_modes = ORIGINAL_VT_INPUT + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(original) = original_modes.last().copied() else { + return Ok(()); + }; + let Some((handle, mode)) = current_input_mode() else { + original_modes.pop(); + return Ok(()); + }; + let requested_mode = restored_input_mode(mode, original); + if requested_mode != mode && unsafe { SetConsoleMode(handle, requested_mode) } == 0 { + return Err(std::io::Error::last_os_error()); + } + + original_modes.pop(); + Ok(()) +}