From bb86dbd80d35e5a3fd3a3353bfe1422700fac4ad Mon Sep 17 00:00:00 2001 From: David Wiesen Date: Mon, 6 Oct 2025 21:30:52 -0700 Subject: [PATCH] implement terminal_palette for modern Windows Terminal shells. --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + codex-rs/tui/Cargo.toml | 8 + codex-rs/tui/src/terminal_palette.rs | 169 +--------- codex-rs/tui/src/terminal_palette/common.rs | 156 +++++++++ codex-rs/tui/src/terminal_palette_windows.rs | 328 +++++++++++++++++++ 6 files changed, 507 insertions(+), 156 deletions(-) create mode 100644 codex-rs/tui/src/terminal_palette/common.rs create mode 100644 codex-rs/tui/src/terminal_palette_windows.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 350773a3c5..21d705831b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1428,6 +1428,7 @@ dependencies = [ "unicode-width 0.2.1", "url", "vt100", + "windows-sys 0.59.0", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index bcbe844509..58e4be99cb 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -186,6 +186,7 @@ uuid = "1" vt100 = "0.16.2" walkdir = "2.5.0" webbrowser = "1.0" +windows-sys = "0.59.0" which = "6" wildmatch = "2.5.0" wiremock = "0.6" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index f42f555c9e..8745db2524 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -87,6 +87,14 @@ url = { workspace = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_Console", + "Win32_System_Threading", +] } + # Clipboard support via `arboard` is not available on Android/Termux. # Only include it for non-Android targets so the crate builds on Android. [target.'cfg(not(target_os = "android"))'.dependencies] diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index c35b2cfff4..b4f5721f12 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -1,3 +1,7 @@ +#[cfg(any(all(unix, not(test)), all(windows, not(test))))] +#[path = "terminal_palette/common.rs"] +mod terminal_palette_common; + pub fn terminal_palette() -> Option<[(u8, u8, u8); 256]> { imp::terminal_palette() } @@ -22,6 +26,7 @@ pub fn default_fg() -> Option<(u8, u8, u8)> { default_colors().map(|c| c.fg) } +#[allow(dead_code)] pub fn default_bg() -> Option<(u8, u8, u8)> { default_colors().map(|c| c.bg) } @@ -29,41 +34,14 @@ pub fn default_bg() -> Option<(u8, u8, u8)> { #[cfg(all(unix, not(test)))] mod imp { use super::DefaultColors; + use super::terminal_palette_common::Cache; + use super::terminal_palette_common::apply_palette_responses; + use super::terminal_palette_common::parse_osc_color; use std::mem::MaybeUninit; use std::os::fd::RawFd; 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 refresh_with(&mut self, mut init: impl FnMut() -> Option) -> Option { - 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())) @@ -302,134 +280,13 @@ mod imp { original: termios, }) } - - fn apply_palette_responses( - buffer: &mut Vec, - palette: &mut [Option<(u8, u8, u8)>; 256], - ) -> usize { - let mut newly_filled = 0; - - while let Some(start) = buffer.windows(2).position(|window| window == [0x1b, b']']) { - if start > 0 { - buffer.drain(..start); - continue; - } - - let mut index = 2; // skip ESC ] - let mut terminator_len = None; - while index < buffer.len() { - match buffer[index] { - 0x07 => { - terminator_len = Some(1); - break; - } - 0x1b if index + 1 < buffer.len() && buffer[index + 1] == b'\\' => { - terminator_len = Some(2); - break; - } - _ => index += 1, - } - } - - let Some(terminator_len) = terminator_len else { - break; - }; - - let end = index; - let parsed = std::str::from_utf8(&buffer[2..end]) - .ok() - .and_then(parse_palette_message); - let processed = end + terminator_len; - buffer.drain(..processed); - - if let Some((slot, color)) = parsed - && palette[slot].is_none() - { - palette[slot] = Some(color); - newly_filled += 1; - } - } - - newly_filled - } - - fn parse_palette_message(message: &str) -> Option<(usize, (u8, u8, u8))> { - let mut parts = message.splitn(3, ';'); - if parts.next()? != "4" { - return None; - } - let index: usize = parts.next()?.trim().parse().ok()?; - if index >= 256 { - return None; - } - let payload = parts.next()?; - let (model, values) = payload.split_once(':')?; - if model != "rgb" && model != "rgba" { - return None; - } - let mut components = values.split('/'); - let r = parse_component(components.next()?)?; - let g = parse_component(components.next()?)?; - let b = parse_component(components.next()?)?; - Some((index, (r, g, b))) - } - - fn parse_component(component: &str) -> Option { - let trimmed = component.trim(); - if trimmed.is_empty() { - return None; - } - let bits = trimmed.len().checked_mul(4)?; - if bits == 0 || bits > 64 { - return None; - } - let max = if bits == 64 { - u64::MAX - } else { - (1u64 << bits) - 1 - }; - let value = u64::from_str_radix(trimmed, 16).ok()?; - Some(((value * 255 + max / 2) / max) as u8) - } - - fn parse_osc_color(buffer: &[u8], code: u8) -> Option<(u8, u8, u8)> { - let text = std::str::from_utf8(buffer).ok()?; - let prefix = match code { - 10 => "\u{1b}]10;", - 11 => "\u{1b}]11;", - _ => return None, - }; - let start = text.rfind(prefix)?; - let after_prefix = &text[start + prefix.len()..]; - let end_bel = after_prefix.find('\u{7}'); - let end_st = after_prefix.find("\u{1b}\\"); - let end_idx = match (end_bel, end_st) { - (Some(bel), Some(st)) => bel.min(st), - (Some(bel), None) => bel, - (None, Some(st)) => st, - (None, None) => return None, - }; - let payload = after_prefix[..end_idx].trim(); - parse_color_payload(payload) - } - - fn parse_color_payload(payload: &str) -> Option<(u8, u8, u8)> { - if payload.is_empty() || payload == "?" { - return None; - } - let (model, values) = payload.split_once(':')?; - if model != "rgb" && model != "rgba" { - return None; - } - let mut parts = values.split('/'); - let r = parse_component(parts.next()?)?; - let g = parse_component(parts.next()?)?; - let b = parse_component(parts.next()?)?; - Some((r, g, b)) - } } -#[cfg(not(all(unix, not(test))))] +#[cfg(all(windows, not(test)))] +#[path = "terminal_palette_windows.rs"] +mod imp; + +#[cfg(not(any(all(unix, not(test)), all(windows, not(test)))))] mod imp { use super::DefaultColors; diff --git a/codex-rs/tui/src/terminal_palette/common.rs b/codex-rs/tui/src/terminal_palette/common.rs new file mode 100644 index 0000000000..0a6e5e83b4 --- /dev/null +++ b/codex-rs/tui/src/terminal_palette/common.rs @@ -0,0 +1,156 @@ +use std::str; + +pub(crate) struct Cache { + attempted: bool, + value: Option, +} + +impl Default for Cache { + fn default() -> Self { + Self { + attempted: false, + value: None, + } + } +} + +impl Cache { + pub(crate) fn get_or_init_with(&mut self, mut init: impl FnMut() -> Option) -> Option { + if !self.attempted { + self.value = init(); + self.attempted = true; + } + self.value + } + + pub(crate) fn refresh_with(&mut self, mut init: impl FnMut() -> Option) -> Option { + self.value = init(); + self.attempted = true; + self.value + } +} + +pub(crate) fn apply_palette_responses( + buffer: &mut Vec, + palette: &mut [Option<(u8, u8, u8)>; 256], +) -> usize { + let mut newly_filled = 0; + + while let Some(start) = buffer.windows(2).position(|window| window == [0x1b, b']']) { + if start > 0 { + buffer.drain(..start); + continue; + } + + let mut index = 2; // skip ESC ] + let mut terminator_len = None; + while index < buffer.len() { + match buffer[index] { + 0x07 => { + terminator_len = Some(1); + break; + } + 0x1b if index + 1 < buffer.len() && buffer[index + 1] == b'\\' => { + terminator_len = Some(2); + break; + } + _ => index += 1, + } + } + + let Some(terminator_len) = terminator_len else { + break; + }; + + let end = index; + let parsed = str::from_utf8(&buffer[2..end]) + .ok() + .and_then(parse_palette_message); + let processed = end + terminator_len; + buffer.drain(..processed); + + if let Some((slot, color)) = parsed + && palette[slot].is_none() + { + palette[slot] = Some(color); + newly_filled += 1; + } + } + + newly_filled +} + +pub(crate) fn parse_osc_color(buffer: &[u8], code: u8) -> Option<(u8, u8, u8)> { + let text = str::from_utf8(buffer).ok()?; + let prefix = match code { + 10 => "\u{1b}]10;", + 11 => "\u{1b}]11;", + _ => return None, + }; + let start = text.rfind(prefix)?; + let after_prefix = &text[start + prefix.len()..]; + let end_bel = after_prefix.find('\u{7}'); + let end_st = after_prefix.find("\u{1b}\\"); + let end_idx = match (end_bel, end_st) { + (Some(bel), Some(st)) => bel.min(st), + (Some(bel), None) => bel, + (None, Some(st)) => st, + (None, None) => return None, + }; + let payload = after_prefix[..end_idx].trim(); + parse_color_payload(payload) +} + +fn parse_palette_message(message: &str) -> Option<(usize, (u8, u8, u8))> { + let mut parts = message.splitn(3, ';'); + if parts.next()? != "4" { + return None; + } + let index: usize = parts.next()?.trim().parse().ok()?; + if index >= 256 { + return None; + } + let payload = parts.next()?; + let (model, values) = payload.split_once(':')?; + if model != "rgb" && model != "rgba" { + return None; + } + let mut components = values.split('/'); + let r = parse_component(components.next()?)?; + let g = parse_component(components.next()?)?; + let b = parse_component(components.next()?)?; + Some((index, (r, g, b))) +} + +fn parse_color_payload(payload: &str) -> Option<(u8, u8, u8)> { + if payload.is_empty() || payload == "?" { + return None; + } + let (model, values) = payload.split_once(':')?; + if model != "rgb" && model != "rgba" { + return None; + } + let mut parts = values.split('/'); + let r = parse_component(parts.next()?)?; + let g = parse_component(parts.next()?)?; + let b = parse_component(parts.next()?)?; + Some((r, g, b)) +} + +fn parse_component(component: &str) -> Option { + let trimmed = component.trim(); + if trimmed.is_empty() { + return None; + } + let bits = trimmed.len().checked_mul(4)?; + if bits == 0 || bits > 64 { + return None; + } + let max = if bits == 64 { + u64::MAX + } else { + (1u64 << bits) - 1 + }; + let value = u64::from_str_radix(trimmed, 16).ok()?; + Some(((value * 255 + max / 2) / max) as u8) +} diff --git a/codex-rs/tui/src/terminal_palette_windows.rs b/codex-rs/tui/src/terminal_palette_windows.rs new file mode 100644 index 0000000000..c91885a7fb --- /dev/null +++ b/codex-rs/tui/src/terminal_palette_windows.rs @@ -0,0 +1,328 @@ +use super::DefaultColors; +use super::terminal_palette_common::Cache; +use super::terminal_palette_common::apply_palette_responses; +use super::terminal_palette_common::parse_osc_color; +use std::env; +use std::ffi::c_void; +use std::fs::OpenOptions; +use std::io; +use std::io::ErrorKind; +use std::io::IsTerminal; +use std::io::Write; +use std::os::windows::fs::OpenOptionsExt; +use std::os::windows::io::AsRawHandle; +use std::sync::Mutex; +use std::sync::OnceLock; +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::Storage::FileSystem::FILE_SHARE_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; +use windows_sys::Win32::Storage::FileSystem::ReadFile; +use windows_sys::Win32::System::Console::ENABLE_ECHO_INPUT; +use windows_sys::Win32::System::Console::ENABLE_LINE_INPUT; +use windows_sys::Win32::System::Console::ENABLE_PROCESSED_INPUT; +use windows_sys::Win32::System::Console::ENABLE_PROCESSED_OUTPUT; +use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_INPUT; +use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_PROCESSING; +use windows_sys::Win32::System::Console::GetConsoleMode; +use windows_sys::Win32::System::Console::GetStdHandle; +use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; +use windows_sys::Win32::System::Console::SetConsoleMode; +use windows_sys::Win32::System::Threading::WAIT_FAILED; +use windows_sys::Win32::System::Threading::WAIT_OBJECT_0; +use windows_sys::Win32::System::Threading::WAIT_TIMEOUT; +use windows_sys::Win32::System::Threading::WaitForSingleObject; + +const RESPONSE_TIMEOUT: Duration = Duration::from_millis(1500); + +pub(super) fn terminal_palette() -> Option<[(u8, u8, u8); 256]> { + static CACHE: OnceLock> = OnceLock::new(); + *CACHE.get_or_init(|| match query_terminal_palette() { + Ok(Some(palette)) => Some(palette), + _ => None, + }) +} + +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().unwrap_or_default()) +} + +pub(super) fn requery_default_colors() { + if let Ok(mut cache) = default_colors_cache().lock() { + cache.refresh_with(|| query_default_colors().unwrap_or_default()); + } +} + +fn default_colors_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(Cache::default())) +} + +fn is_windows_terminal() -> bool { + env::var("WT_SESSION") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) +} + +fn query_terminal_palette() -> io::Result> { + if !is_windows_terminal() { + return Ok(None); + } + + let mut stdout_handle = io::stdout(); + if !stdout_handle.is_terminal() { + return Ok(None); + } + + let _output_guard = ConsoleOutputGuard::acquire(); + for index in 0..256 { + write!(stdout_handle, "\x1b]4;{index};?\x07")?; + } + stdout_handle.flush()?; + + let mut reader = match ConsoleInputReader::new() { + Ok(reader) => reader, + Err(_) => return Ok(None), + }; + + let mut palette: [Option<(u8, u8, u8)>; 256] = [None; 256]; + let mut buffer = Vec::new(); + let mut remaining = palette.len(); + let deadline = Instant::now() + RESPONSE_TIMEOUT; + + while remaining > 0 && Instant::now() < deadline { + if !reader.read_available(&mut buffer, Duration::from_millis(25))? { + continue; + } + let newly = apply_palette_responses(&mut buffer, &mut palette); + if newly == 0 { + continue; + } + remaining = remaining.saturating_sub(newly); + } + + if remaining > 0 { + return Ok(None); + } + + let mut colors = [(0, 0, 0); 256]; + for (slot, value) in colors.iter_mut().zip(palette.into_iter()) { + if let Some(rgb) = value { + *slot = rgb; + } else { + return Ok(None); + } + } + + Ok(Some(colors)) +} + +fn query_default_colors() -> io::Result> { + if !is_windows_terminal() { + return Ok(None); + } + + let mut stdout_handle = io::stdout(); + if !stdout_handle.is_terminal() { + return Ok(None); + } + + let _output_guard = ConsoleOutputGuard::acquire(); + stdout_handle.write_all(b"\x1b]10;?\x07\x1b]11;?\x07")?; + stdout_handle.flush()?; + + let mut reader = match ConsoleInputReader::new() { + Ok(reader) => reader, + Err(_) => return Ok(None), + }; + + let mut buffer = Vec::new(); + let mut fg = None; + let mut bg = None; + let deadline = Instant::now() + Duration::from_millis(250); + + while Instant::now() < deadline { + reader.read_available(&mut buffer, Duration::from_millis(20))?; + if fg.is_none() { + fg = parse_osc_color(&buffer, 10); + } + if bg.is_none() { + bg = parse_osc_color(&buffer, 11); + } + if fg.is_some() && bg.is_some() { + break; + } + } + + if fg.is_none() { + fg = parse_osc_color(&buffer, 10); + } + if bg.is_none() { + bg = parse_osc_color(&buffer, 11); + } + + Ok(fg.zip(bg).map(|(fg, bg)| DefaultColors { fg, bg })) +} + +struct ConsoleOutputGuard { + handle: HANDLE, + original_mode: Option, +} + +impl ConsoleOutputGuard { + fn acquire() -> Option { + unsafe { + let handle = GetStdHandle(STD_OUTPUT_HANDLE); + if handle == INVALID_HANDLE_VALUE || handle == 0 { + return None; + } + let mut original = 0u32; + if GetConsoleMode(handle, &mut original) == 0 { + return None; + } + let desired = original | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING; + if desired == original { + return Some(Self { + handle, + original_mode: Some(original), + }); + } + if SetConsoleMode(handle, desired) == 0 { + return None; + } + Some(Self { + handle, + original_mode: Some(original), + }) + } + } +} + +impl Drop for ConsoleOutputGuard { + fn drop(&mut self) { + if let Some(original) = self.original_mode { + unsafe { + let _ = SetConsoleMode(self.handle, original); + } + } + } +} + +struct ConsoleInputReader { + _file: std::fs::File, + handle: HANDLE, + _guard: ConsoleInputModeGuard, +} + +impl ConsoleInputReader { + fn new() -> io::Result { + let mut options = OpenOptions::new(); + options.read(true); + options.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE); + let file = options.open("CONIN$")?; + let handle = file.as_raw_handle() as HANDLE; + if handle == INVALID_HANDLE_VALUE || handle == 0 { + return Err(io::Error::other("invalid console handle")); + } + let guard = ConsoleInputModeGuard::acquire(handle)?; + Ok(Self { + _file: file, + handle, + _guard: guard, + }) + } + + fn read_available(&mut self, buffer: &mut Vec, wait: Duration) -> io::Result { + let mut any = false; + let mut current_wait = duration_to_millis(wait); + loop { + let status = unsafe { WaitForSingleObject(self.handle, current_wait) }; + match status { + WAIT_OBJECT_0 => { + if self.read_once(buffer)? { + any = true; + } + current_wait = 0; + continue; + } + WAIT_TIMEOUT => { + break; + } + WAIT_FAILED => { + return Err(io::Error::last_os_error()); + } + _ => break, + } + } + Ok(any) + } + + fn read_once(&mut self, buffer: &mut Vec) -> io::Result { + let mut chunk = [0u8; 512]; + let mut read = 0u32; + let success = unsafe { + ReadFile( + self.handle, + chunk.as_mut_ptr() as *mut c_void, + chunk.len() as u32, + &mut read, + std::ptr::null_mut(), + ) + }; + if success == 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::Interrupted || err.kind() == io::ErrorKind::WouldBlock { + return Ok(false); + } + return Err(err); + } + if read == 0 { + return Ok(false); + } + buffer.extend_from_slice(&chunk[..read as usize]); + Ok(true) + } +} + +struct ConsoleInputModeGuard { + handle: HANDLE, + original_mode: u32, +} + +impl ConsoleInputModeGuard { + fn acquire(handle: HANDLE) -> io::Result { + unsafe { + let mut original = 0u32; + if GetConsoleMode(handle, &mut original) == 0 { + return Err(io::Error::last_os_error()); + } + let mut desired = original; + desired |= ENABLE_VIRTUAL_TERMINAL_INPUT | ENABLE_PROCESSED_INPUT; + desired &= !ENABLE_LINE_INPUT; + desired &= !ENABLE_ECHO_INPUT; + if desired != original && SetConsoleMode(handle, desired) == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Self { + handle, + original_mode: original, + }) + } + } +} + +impl Drop for ConsoleInputModeGuard { + fn drop(&mut self) { + unsafe { + let _ = SetConsoleMode(self.handle, self.original_mode); + } + } +} + +fn duration_to_millis(duration: Duration) -> u32 { + duration.as_millis().try_into().unwrap_or(u32::MAX) +}