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 |
|---|---|
| <img width="1235" height="658" alt="win-before"
src="https://github.com/user-attachments/assets/ff756589-fcb3-43de-8f2a-ebc0369b30dd"
/> | <img width="1235" height="658" alt="win-after"
src="https://github.com/user-attachments/assets/9563ff20-4be5-4608-9414-a2afb647e745"
/> |

## 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 <kbond@openai.com>
This commit is contained in:
Felipe Coury
2026-06-05 15:05:46 -03:00
committed by GitHub
Unverified
parent a8c9530911
commit 713192381b
3 changed files with 616 additions and 136 deletions
+173 -14
View File
@@ -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<crate::terminal_probe::DefaultColors>,
) {
@@ -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<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 default_colors_cache() -> &'static Mutex<Cache<DefaultColors>> {
static CACHE: OnceLock<Mutex<Cache<DefaultColors>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(Cache::default()))
}
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)
}
pub(super) fn set_default_colors_from_startup_probe(
colors: Option<crate::terminal_probe::DefaultColors>,
) {
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<DefaultColors> {
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<crate::terminal_probe::DefaultColors>,
) {
@@ -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
);
}
}
+416 -120
View File
@@ -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<DefaultColors> {
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<u8> {
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<usize> {
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<Option<DefaultColors>> {
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<Option<DefaultColors>> {
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<Option<DefaultColors>> {
let mut info = unsafe { std::mem::zeroed::<CONSOLE_SCREEN_BUFFER_INFOEX>() };
info.cbSize = std::mem::size_of::<CONSOLE_SCREEN_BUFFER_INFOEX>() 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<HANDLE> {
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<Self> {
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<T>(
handle: HANDLE,
timeout: Duration,
mut parse: impl FnMut(&[u8]) -> Option<T>,
) -> io::Result<Option<T>> {
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<u8>) -> 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<DefaultColors> {
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<u8> {
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<usize> {
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),
})
);
}
}
+27 -2
View File
@@ -438,6 +438,9 @@ pub(crate) fn init() -> Result<InitializedTerminal> {
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<Stdout>) -> 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| {