Compare commits

...

1 Commits

Author SHA1 Message Date
David Wiesen
bb86dbd80d implement terminal_palette for modern Windows Terminal shells. 2025-10-06 21:30:52 -07:00
6 changed files with 507 additions and 156 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -1428,6 +1428,7 @@ dependencies = [
"unicode-width 0.2.1",
"url",
"vt100",
"windows-sys 0.59.0",
]
[[package]]

View File

@@ -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"

View File

@@ -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]

View File

@@ -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<T> {
attempted: bool,
value: Option<T>,
}
impl<T> Default for Cache<T> {
fn default() -> Self {
Self {
attempted: false,
value: None,
}
}
}
impl<T: Copy> Cache<T> {
fn get_or_init_with(&mut self, mut init: impl FnMut() -> Option<T>) -> Option<T> {
if !self.attempted {
self.value = init();
self.attempted = true;
}
self.value
}
fn refresh_with(&mut self, mut init: impl FnMut() -> Option<T>) -> Option<T> {
self.value = init();
self.attempted = true;
self.value
}
}
fn default_colors_cache() -> &'static Mutex<Cache<DefaultColors>> {
static CACHE: OnceLock<Mutex<Cache<DefaultColors>>> = 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<u8>,
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<u8> {
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;

View File

@@ -0,0 +1,156 @@
use std::str;
pub(crate) struct Cache<T> {
attempted: bool,
value: Option<T>,
}
impl<T> Default for Cache<T> {
fn default() -> Self {
Self {
attempted: false,
value: None,
}
}
}
impl<T: Copy> Cache<T> {
pub(crate) fn get_or_init_with(&mut self, mut init: impl FnMut() -> Option<T>) -> Option<T> {
if !self.attempted {
self.value = init();
self.attempted = true;
}
self.value
}
pub(crate) fn refresh_with(&mut self, mut init: impl FnMut() -> Option<T>) -> Option<T> {
self.value = init();
self.attempted = true;
self.value
}
}
pub(crate) fn apply_palette_responses(
buffer: &mut Vec<u8>,
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<u8> {
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)
}

View File

@@ -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<Option<[(u8, u8, u8); 256]>> = OnceLock::new();
*CACHE.get_or_init(|| match query_terminal_palette() {
Ok(Some(palette)) => Some(palette),
_ => None,
})
}
pub(super) fn default_colors() -> Option<DefaultColors> {
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<Cache<DefaultColors>> {
static CACHE: OnceLock<Mutex<Cache<DefaultColors>>> = 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<Option<[(u8, u8, u8); 256]>> {
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<Option<DefaultColors>> {
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<u32>,
}
impl ConsoleOutputGuard {
fn acquire() -> Option<Self> {
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<Self> {
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<u8>, wait: Duration) -> io::Result<bool> {
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<u8>) -> io::Result<bool> {
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<Self> {
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)
}