From 713192381b74f09f4b92f3ed410da067461d5cd0 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 5 Jun 2026 15:05:46 -0300 Subject: [PATCH] fix(tui): Windows composer background (#26181) ## Why On Windows, the TUI could not shade the composer against the terminal background because `terminal_palette::default_colors()` always fell back to `None`. That preserved safety, but it also meant terminals that do support OSC 10/11 default color replies had no path to report their real background color. This keeps the existing fallback behavior for unsupported terminals while allowing capable Windows terminals to report their default foreground/background colors during startup. | Before | After | |---|---| | win-before | win-after | ## What Changed - Moved the OSC 10/11 default color parser in `tui/src/terminal_probe.rs` out of the Unix-only implementation so it can be reused by Windows. - Added a Windows-only bounded OSC 10/11 probe using raw console handles and the existing `windows-sys` dependency. - Added Windows palette caching in `tui/src/terminal_palette.rs` so startup probe results, including `None`, are reused instead of probing again later. - Wired the Windows color probe into TUI startup after the existing non-Unix crossterm cursor and keyboard checks. - Added parser coverage for malformed, partial, and noisy OSC color replies. If the probe fails, times out, receives only one color, or receives malformed data, the cache stores `None` and the composer keeps the current behavior. ## How to Test 1. On Windows, start Codex in a terminal that supports OSC 10/11 default color replies. 2. Open the TUI composer. 3. Confirm the composer/status area is painted using the terminal's reported default background, instead of leaving the background unshaded. 4. Start Codex in a terminal that does not answer OSC 10/11, or otherwise blocks terminal color replies. 5. Confirm startup still succeeds and the composer uses the existing fallback behavior. Targeted tests: - `CARGO_TARGET_DIR=/private/tmp/codex-windows-osc-default-colors-target just test -p codex-tui terminal_probe` Additional local verification: - `CARGO_TARGET_DIR=/private/tmp/codex-windows-osc-default-colors-target just test -p codex-tui` was run; 2774 tests passed, and two unrelated Guardian feature-flag tests failed reproducibly when isolated. - `just argument-comment-lint` was attempted but blocked by the local Bazel/LLVM `include/sanitizer/*.h` empty glob issue. Touched Rust literal callsites were inspected manually. - `cargo check -p codex-tui --target x86_64-pc-windows-msvc` was attempted after installing the target, but local macOS cross-checking is blocked by missing Windows C SDK headers in native dependencies (`ring`/`aws-lc-sys`). --------- Co-authored-by: Kevin Bond --- codex-rs/tui/src/terminal_palette.rs | 187 +++++++++- codex-rs/tui/src/terminal_probe.rs | 536 +++++++++++++++++++++------ codex-rs/tui/src/tui.rs | 29 +- 3 files changed, 616 insertions(+), 136 deletions(-) diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index 53c68d96e..1b539f94c 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -1,4 +1,6 @@ use crate::color::perceptual_distance; +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; use ratatui::style::Color; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -30,19 +32,49 @@ pub fn indexed_color(index: u8) -> Color { /// Returns the closest color to the target color that the terminal can display. pub fn best_color(target: (u8, u8, u8)) -> Color { - let color_level = stdout_color_level(); - if color_level == StdoutColorLevel::TrueColor { - rgb_color(target) - } else if color_level == StdoutColorLevel::Ansi256 - && let Some((i, _)) = xterm_fixed_colors().min_by(|(_, a), (_, b)| { - perceptual_distance(*a, target) - .partial_cmp(&perceptual_distance(*b, target)) - .unwrap_or(std::cmp::Ordering::Equal) - }) + best_color_for_color_level(target, effective_stdout_color_level()) +} + +fn effective_stdout_color_level() -> StdoutColorLevel { + stdout_color_level_for_terminal( + stdout_color_level(), + terminal_info().name, + std::env::var_os("WT_SESSION").is_some(), + std::env::var_os("FORCE_COLOR").is_some(), + ) +} + +fn stdout_color_level_for_terminal( + stdout_level: StdoutColorLevel, + terminal_name: TerminalName, + has_wt_session: bool, + has_force_color_override: bool, +) -> StdoutColorLevel { + if has_wt_session && !has_force_color_override { + return StdoutColorLevel::TrueColor; + } + + if stdout_level == StdoutColorLevel::Ansi16 + && terminal_name == TerminalName::WindowsTerminal + && !has_force_color_override { - indexed_color(i as u8) + StdoutColorLevel::TrueColor } else { - Color::default() + stdout_level + } +} + +fn best_color_for_color_level(target: (u8, u8, u8), color_level: StdoutColorLevel) -> Color { + match color_level { + StdoutColorLevel::TrueColor => rgb_color(target), + StdoutColorLevel::Ansi256 => xterm_fixed_colors() + .min_by(|(_, a), (_, b)| { + perceptual_distance(*a, target) + .partial_cmp(&perceptual_distance(*b, target)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map_or_else(Color::default, |(i, _)| indexed_color(i as u8)), + StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown => Color::default(), } } @@ -68,7 +100,7 @@ pub fn default_bg() -> Option<(u8, u8, u8)> { default_colors().map(|c| c.bg) } -#[cfg(unix)] +#[cfg(any(unix, windows))] pub(crate) fn set_default_colors_from_startup_probe( colors: Option, ) { @@ -177,7 +209,73 @@ mod imp { } } -#[cfg(not(all(unix, not(test))))] +#[cfg(windows)] +mod imp { + use super::DefaultColors; + use std::sync::Mutex; + use std::sync::OnceLock; + + struct Cache { + attempted: bool, + value: Option, + } + + impl Default for Cache { + fn default() -> Self { + Self { + attempted: false, + value: None, + } + } + } + + impl Cache { + fn get_or_init_with(&mut self, mut init: impl FnMut() -> Option) -> Option { + if !self.attempted { + self.value = init(); + self.attempted = true; + } + self.value + } + } + + fn default_colors_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(Cache::default())) + } + + pub(super) fn default_colors() -> Option { + let cache = default_colors_cache(); + let mut cache = cache.lock().ok()?; + cache.get_or_init_with(query_default_colors) + } + + pub(super) fn set_default_colors_from_startup_probe( + colors: Option, + ) { + if let Ok(mut cache) = default_colors_cache().lock() { + cache.value = colors.map(|colors| DefaultColors { + fg: colors.fg, + bg: colors.bg, + }); + cache.attempted = true; + } + } + + pub(super) fn requery_default_colors() {} + + fn query_default_colors() -> Option { + crate::terminal_probe::default_colors(crate::terminal_probe::DEFAULT_TIMEOUT) + .ok() + .flatten() + .map(|colors| DefaultColors { + fg: colors.fg, + bg: colors.bg, + }) + } +} + +#[cfg(not(any(all(unix, not(test)), windows)))] mod imp { use super::DefaultColors; @@ -185,7 +283,7 @@ mod imp { None } - #[cfg(unix)] + #[cfg(any(unix, windows))] pub(super) fn set_default_colors_from_startup_probe( _colors: Option, ) { @@ -461,3 +559,64 @@ pub const XTERM_COLORS: [(u8, u8, u8); 256] = [ (228, 228, 228), // 254 Grey89 (238, 238, 238), // 255 Grey93 ]; + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn best_color_uses_truecolor_without_quantization() { + assert_eq!( + best_color_for_color_level((12, 34, 56), StdoutColorLevel::TrueColor), + rgb_color((12, 34, 56)) + ); + } + + #[test] + fn best_color_resets_for_ansi16() { + assert_eq!( + best_color_for_color_level((12, 34, 56), StdoutColorLevel::Ansi16), + Color::Reset + ); + } + + #[test] + fn windows_terminal_wt_session_promotes_to_truecolor() { + assert_eq!( + stdout_color_level_for_terminal( + StdoutColorLevel::Ansi16, + TerminalName::Unknown, + /*has_wt_session*/ true, + /*has_force_color_override*/ false, + ), + StdoutColorLevel::TrueColor + ); + } + + #[test] + fn windows_terminal_name_promotes_ansi16_to_truecolor() { + assert_eq!( + stdout_color_level_for_terminal( + StdoutColorLevel::Ansi16, + TerminalName::WindowsTerminal, + /*has_wt_session*/ false, + /*has_force_color_override*/ false, + ), + StdoutColorLevel::TrueColor + ); + } + + #[test] + fn force_color_keeps_reported_stdout_level() { + assert_eq!( + stdout_color_level_for_terminal( + StdoutColorLevel::Ansi16, + TerminalName::WindowsTerminal, + /*has_wt_session*/ true, + /*has_force_color_override*/ true, + ), + StdoutColorLevel::Ansi16 + ); + } +} diff --git a/codex-rs/tui/src/terminal_probe.rs b/codex-rs/tui/src/terminal_probe.rs index d9927ffa2..bea195083 100644 --- a/codex-rs/tui/src/terminal_probe.rs +++ b/codex-rs/tui/src/terminal_probe.rs @@ -12,9 +12,25 @@ //! startup. A future input-preservation layer would need to replay unrelated bytes through the same //! parser that normal TUI input uses. +use std::time::Duration; + +/// Default wall-clock budget for each startup probe group. +pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100); + +/// Default terminal foreground and background colors reported by OSC 10 and OSC 11. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) struct DefaultColors { + /// Default foreground color as an 8-bit RGB tuple. + pub(crate) fg: (u8, u8, u8), + /// Default background color as an 8-bit RGB tuple. + pub(crate) bg: (u8, u8, u8), +} + #[cfg(unix)] #[cfg_attr(test, allow(dead_code))] mod imp { + use super::DefaultColors; + use super::parse_default_colors; use std::fs::File; use std::fs::OpenOptions; use std::io; @@ -27,18 +43,6 @@ mod imp { use crossterm::event::KeyboardEnhancementFlags; use ratatui::layout::Position; - /// Default wall-clock budget for each startup probe group. - pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100); - - /// Default terminal foreground and background colors reported by OSC 10 and OSC 11. - #[derive(Debug, Clone, Copy, Eq, PartialEq)] - pub(crate) struct DefaultColors { - /// Default foreground color as an 8-bit RGB tuple. - pub(crate) fg: (u8, u8, u8), - /// Default background color as an 8-bit RGB tuple. - pub(crate) bg: (u8, u8, u8), - } - /// Results from the TUI's one-shot startup terminal probe. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub(crate) struct StartupProbe { @@ -389,60 +393,6 @@ mod imp { None } - fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> { - let prefix = format!("\x1B]{slot};"); - let start = find_subslice(buffer, prefix.as_bytes())?; - let payload_start = start + prefix.len(); - let rest = &buffer[payload_start..]; - let (payload_end, _terminator_len) = osc_payload_end(rest)?; - let payload = std::str::from_utf8(&rest[..payload_end]).ok()?; - parse_osc_rgb(payload) - } - - fn parse_default_colors(buffer: &[u8]) -> Option { - let fg = parse_osc_color(buffer, /*slot*/ 10)?; - let bg = parse_osc_color(buffer, /*slot*/ 11)?; - Some(DefaultColors { fg, bg }) - } - - fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> { - let mut idx = 0; - while idx < buffer.len() { - match buffer[idx] { - 0x07 => return Some((idx, 1)), - 0x1B if buffer.get(idx + 1) == Some(&b'\\') => return Some((idx, 2)), - _ => idx += 1, - } - } - None - } - - fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> { - let (prefix, values) = payload.trim().split_once(':')?; - if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") { - return None; - } - - let mut parts = values.split('/'); - let r = parse_osc_component(parts.next()?)?; - let g = parse_osc_component(parts.next()?)?; - let b = parse_osc_component(parts.next()?)?; - if prefix.eq_ignore_ascii_case("rgba") { - parse_osc_component(parts.next()?)?; - } - parts.next().is_none().then_some((r, g, b)) - } - - fn parse_osc_component(component: &str) -> Option { - match component.len() { - 2 => u8::from_str_radix(component, 16).ok(), - 4 => u16::from_str_radix(component, 16) - .ok() - .map(|value| (value / 257) as u8), - _ => None, - } - } - /// Parser state for the keyboard enhancement probe. /// /// `UnsupportedFallback` records that a primary-device-attributes response arrived without @@ -517,12 +467,6 @@ mod imp { None } - fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { - haystack - .windows(needle.len()) - .position(|window| window == needle) - } - fn find_all_subslices<'a>( haystack: &'a [u8], needle: &'a [u8], @@ -550,53 +494,6 @@ mod imp { ); } - #[test] - fn parses_osc_colors_with_bel_and_st() { - assert_eq!( - parse_osc_color(b"\x1B]10;rgb:ffff/8000/0000\x07", /*slot*/ 10), - Some((255, 127, 0)) - ); - assert_eq!( - parse_osc_color(b"\x1B]11;rgba:00/80/ff/ff\x1B\\", /*slot*/ 11), - Some((0, 128, 255)) - ); - } - - #[test] - fn parses_two_and_four_digit_color_components() { - assert_eq!(parse_osc_rgb("rgb:00/80/ff"), Some((0, 128, 255))); - assert_eq!( - parse_osc_rgb("rgba:ffff/8000/0000/ffff"), - Some((255, 127, 0)) - ); - } - - #[test] - fn parses_default_colors_from_one_buffer() { - assert_eq!( - parse_default_colors( - b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:1111/1111/1111\x07" - ), - Some(DefaultColors { - fg: (238, 238, 238), - bg: (17, 17, 17) - }) - ); - assert_eq!( - parse_default_colors( - b"\x1B]11;rgb:1111/1111/1111\x07\x1B]10;rgb:eeee/eeee/eeee\x1B\\" - ), - Some(DefaultColors { - fg: (238, 238, 238), - bg: (17, 17, 17) - }) - ); - assert_eq!( - parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\"), - None - ); - } - #[test] fn parses_keyboard_enhancement_flags_and_pda_fallback() { assert_eq!( @@ -655,5 +552,404 @@ mod imp { } } -#[cfg(unix)] +#[cfg(windows)] +mod imp { + use super::DefaultColors; + use super::parse_default_colors; + use std::io; + use std::io::ErrorKind; + use std::time::Duration; + use std::time::Instant; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::Foundation::WAIT_OBJECT_0; + use windows_sys::Win32::Foundation::WAIT_TIMEOUT; + use windows_sys::Win32::Storage::FileSystem::ReadFile; + use windows_sys::Win32::Storage::FileSystem::WriteFile; + use windows_sys::Win32::System::Console::CONSOLE_SCREEN_BUFFER_INFOEX; + use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_INPUT; + use windows_sys::Win32::System::Console::GetConsoleMode; + use windows_sys::Win32::System::Console::GetConsoleScreenBufferInfoEx; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; + use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; + use windows_sys::Win32::System::Console::SetConsoleMode; + use windows_sys::Win32::System::Threading::WaitForSingleObject; + + /// Queries OSC 10 and OSC 11 default colors under one shared deadline. + /// + /// The Windows path uses raw console handles because crossterm's public color query helper is + /// currently Unix-only. Failures and missing responses are reported as `Ok(None)` by callers so + /// terminals without OSC 10/11 support keep the existing conservative palette fallback. + pub(crate) fn default_colors(timeout: Duration) -> io::Result> { + let Ok(output) = std_handle(STD_OUTPUT_HANDLE) else { + return Ok(None); + }; + + if let Ok(input) = std_handle(STD_INPUT_HANDLE) + && let Ok(Some(colors)) = query_osc_default_colors(input, output, timeout) + { + return Ok(Some(colors)); + } + + Ok(query_console_default_colors(output).ok().flatten()) + } + + fn query_osc_default_colors( + input: HANDLE, + output: HANDLE, + timeout: Duration, + ) -> io::Result> { + let _vt_input = VirtualTerminalInputMode::enable(input)?; + write_all(output, b"\x1B]10;?\x1B\\\x1B]11;?\x1B\\")?; + read_until(input, timeout, parse_default_colors) + } + + fn query_console_default_colors(output: HANDLE) -> io::Result> { + let mut info = unsafe { std::mem::zeroed::() }; + info.cbSize = std::mem::size_of::() as u32; + if unsafe { GetConsoleScreenBufferInfoEx(output, &mut info) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Some(decode_console_default_colors( + info.wAttributes, + &info.ColorTable, + ))) + } + + fn decode_console_default_colors(attributes: u16, color_table: &[u32; 16]) -> DefaultColors { + let fg_index = (attributes & 0x0f) as usize; + let bg_index = ((attributes >> 4) & 0x0f) as usize; + // COMMON_LVB_REVERSE_VIDEO changes how cells render, but this probe is discovering the + // configured default colors for palette blending. Keep the attribute fg/bg indices as-is. + DefaultColors { + fg: decode_color_ref(color_table[fg_index]), + bg: decode_color_ref(color_table[bg_index]), + } + } + + fn decode_color_ref(color_ref: u32) -> (u8, u8, u8) { + ( + (color_ref & 0xff) as u8, + ((color_ref >> 8) & 0xff) as u8, + ((color_ref >> 16) & 0xff) as u8, + ) + } + + fn std_handle(kind: u32) -> io::Result { + let handle = unsafe { GetStdHandle(kind) }; + if handle == 0 || handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + Ok(handle) + } + + struct VirtualTerminalInputMode { + handle: HANDLE, + original_mode: u32, + } + + impl VirtualTerminalInputMode { + fn enable(handle: HANDLE) -> io::Result { + let mut original_mode = 0; + if unsafe { GetConsoleMode(handle, &mut original_mode) } == 0 { + return Err(io::Error::last_os_error()); + } + + let requested_mode = original_mode | ENABLE_VIRTUAL_TERMINAL_INPUT; + if unsafe { SetConsoleMode(handle, requested_mode) } == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(Self { + handle, + original_mode, + }) + } + } + + impl Drop for VirtualTerminalInputMode { + fn drop(&mut self) { + unsafe { + SetConsoleMode(self.handle, self.original_mode); + } + } + } + + fn write_all(handle: HANDLE, mut bytes: &[u8]) -> io::Result<()> { + while !bytes.is_empty() { + let mut written = 0; + let ok = unsafe { + WriteFile( + handle, + bytes.as_ptr().cast(), + bytes.len().min(u32::MAX as usize) as u32, + &mut written, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + if written == 0 { + return Err(io::Error::from(ErrorKind::WriteZero)); + } + bytes = &bytes[written as usize..]; + } + Ok(()) + } + + fn read_until( + handle: HANDLE, + timeout: Duration, + mut parse: impl FnMut(&[u8]) -> Option, + ) -> io::Result> { + let deadline = Instant::now() + timeout; + let mut buffer = Vec::new(); + loop { + if let Some(value) = parse(&buffer) { + return Ok(Some(value)); + } + + let now = Instant::now(); + if now >= deadline { + return Ok(None); + } + let timeout_ms = deadline + .saturating_duration_since(now) + .as_millis() + .min(u32::MAX as u128) as u32; + match unsafe { WaitForSingleObject(handle, timeout_ms) } { + WAIT_OBJECT_0 => read_once(handle, &mut buffer)?, + WAIT_TIMEOUT => return Ok(None), + _ => return Err(io::Error::last_os_error()), + } + } + } + + fn read_once(handle: HANDLE, buffer: &mut Vec) -> io::Result<()> { + let mut chunk = [0_u8; 256]; + let mut read = 0; + let ok = unsafe { + ReadFile( + handle, + chunk.as_mut_ptr().cast(), + chunk.len() as u32, + &mut read, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + buffer.extend_from_slice(&chunk[..read as usize]); + Ok(()) + } + + #[cfg(test)] + mod tests { + use super::*; + use pretty_assertions::assert_eq; + use windows_sys::Win32::System::Console::COMMON_LVB_REVERSE_VIDEO; + + fn color_table() -> [u32; 16] { + [ + 0x00000000, 0x00000080, 0x00008000, 0x00008080, 0x00800000, 0x00800080, 0x00808000, + 0x00c0c0c0, 0x00808080, 0x000000ff, 0x0000ff00, 0x0000ffff, 0x00ff0000, 0x00ff00ff, + 0x00ffff00, 0x00ffffff, + ] + } + + #[test] + fn decodes_console_color_attribute_indices() { + assert_eq!( + decode_console_default_colors(/*attributes*/ 0x21, &color_table()), + DefaultColors { + fg: (128, 0, 0), + bg: (0, 128, 0), + } + ); + } + + #[test] + fn decodes_console_color_intensity_indices() { + assert_eq!( + decode_console_default_colors(/*attributes*/ 0xe9, &color_table()), + DefaultColors { + fg: (255, 0, 0), + bg: (0, 255, 255), + } + ); + } + + #[test] + fn decodes_console_color_ref_byte_order() { + let mut colors = color_table(); + colors[3] = 0x00112233; + colors[4] = 0x00aabbcc; + + assert_eq!( + decode_console_default_colors(/*attributes*/ 0x43, &colors), + DefaultColors { + fg: (0x33, 0x22, 0x11), + bg: (0xcc, 0xbb, 0xaa), + } + ); + } + + #[test] + fn ignores_reverse_video_when_decoding_default_colors() { + assert_eq!( + decode_console_default_colors( + /*attributes*/ COMMON_LVB_REVERSE_VIDEO | 0x21, + &color_table(), + ), + DefaultColors { + fg: (128, 0, 0), + bg: (0, 128, 0), + } + ); + } + } +} + +fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> { + let prefix = format!("\x1B]{slot};"); + let start = find_subslice(buffer, prefix.as_bytes())?; + let payload_start = start + prefix.len(); + let rest = &buffer[payload_start..]; + let (payload_end, _terminator_len) = osc_payload_end(rest)?; + let payload = std::str::from_utf8(&rest[..payload_end]).ok()?; + parse_osc_rgb(payload) +} + +fn parse_default_colors(buffer: &[u8]) -> Option { + let fg = parse_osc_color(buffer, /*slot*/ 10)?; + let bg = parse_osc_color(buffer, /*slot*/ 11)?; + Some(DefaultColors { fg, bg }) +} + +fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> { + let mut idx = 0; + while idx < buffer.len() { + match buffer[idx] { + 0x07 => return Some((idx, 1)), + 0x1B if buffer.get(idx + 1) == Some(&b'\\') => return Some((idx, 2)), + _ => idx += 1, + } + } + None +} + +fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> { + let (prefix, values) = payload.trim().split_once(':')?; + if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") { + return None; + } + + let mut parts = values.split('/'); + let r = parse_osc_component(parts.next()?)?; + let g = parse_osc_component(parts.next()?)?; + let b = parse_osc_component(parts.next()?)?; + if prefix.eq_ignore_ascii_case("rgba") { + parse_osc_component(parts.next()?)?; + } + parts.next().is_none().then_some((r, g, b)) +} + +fn parse_osc_component(component: &str) -> Option { + match component.len() { + 2 => u8::from_str_radix(component, 16).ok(), + 4 => u16::from_str_radix(component, 16) + .ok() + .map(|value| (value / 257) as u8), + _ => None, + } +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +#[cfg(any(unix, windows))] pub(crate) use imp::*; + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn parses_osc_colors_with_bel_and_st() { + assert_eq!( + parse_osc_color(b"\x1B]10;rgb:ffff/8000/0000\x07", /*slot*/ 10), + Some((255, 127, 0)) + ); + assert_eq!( + parse_osc_color(b"\x1B]11;rgba:00/80/ff/ff\x1B\\", /*slot*/ 11), + Some((0, 128, 255)) + ); + } + + #[test] + fn parses_two_and_four_digit_color_components() { + assert_eq!(parse_osc_rgb("rgb:00/80/ff"), Some((0, 128, 255))); + assert_eq!( + parse_osc_rgb("rgba:ffff/8000/0000/ffff"), + Some((255, 127, 0)) + ); + } + + #[test] + fn parses_default_colors_from_one_buffer() { + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:1111/1111/1111\x07"), + Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17) + }) + ); + assert_eq!( + parse_default_colors(b"\x1B]11;rgb:1111/1111/1111\x07\x1B]10;rgb:eeee/eeee/eeee\x1B\\"), + Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17) + }) + ); + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\"), + None + ); + } + + #[test] + fn ignores_malformed_or_partial_default_color_responses() { + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:nope\x07"), + None + ); + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:11/11/11/11\x07"), + None + ); + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:1111/1111/1111"), + None + ); + } + + #[test] + fn parses_default_colors_with_unrelated_bytes() { + assert_eq!( + parse_default_colors( + b"typed\x1B]10;rgb:eeee/eeee/eeee\x1B\\noise\x1B]11;rgb:1111/1111/1111\x07" + ), + Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17), + }) + ); + } +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index b9055f5d6..681e0cb39 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -438,6 +438,9 @@ pub(crate) fn init() -> Result { let enhanced_keys_supported = !keyboard_modes::keyboard_enhancement_disabled() && detect_keyboard_enhancement_supported(); + #[cfg(windows)] + probe_windows_default_colors(); + let tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?; let stderr_guard = terminal_stderr::TerminalStderrGuard::install()?; Ok(InitializedTerminal { @@ -457,11 +460,33 @@ fn cursor_position_with_crossterm(backend: &mut CrosstermBackend) -> Pos #[cfg(not(unix))] fn detect_keyboard_enhancement_supported() -> bool { - // Non-Unix startup keeps the existing crossterm path because the bounded probe implementation - // relies on Unix file descriptors and `/dev/tty` semantics. + // Non-Unix startup keeps the existing crossterm keyboard probe path because it already knows + // how to interpret platform-specific event sources. supports_keyboard_enhancement().unwrap_or(/*default*/ false) } +#[cfg(windows)] +fn probe_windows_default_colors() { + let started_at = std::time::Instant::now(); + match crate::terminal_probe::default_colors(crate::terminal_probe::DEFAULT_TIMEOUT) { + Ok(colors) => { + tracing::info!( + duration_ms = %started_at.elapsed().as_millis(), + default_colors = colors.is_some(), + "terminal default color probe completed" + ); + crate::terminal_palette::set_default_colors_from_startup_probe(colors); + } + Err(err) => { + tracing::warn!( + duration_ms = %started_at.elapsed().as_millis(), + "terminal default color probe failed: {err}" + ); + crate::terminal_palette::set_default_colors_from_startup_probe(/*colors*/ None); + } + } +} + fn set_panic_hook() { let hook = panic::take_hook(); panic::set_hook(Box::new(move |panic_info| {