mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
## Why On Linux, suspending Codex with `Ctrl+Z` and returning with `fg` can leave the composer misaligned or inject terminal response bytes such as focus reports into the prompt. Shell job-control output moves the cursor while Codex is suspended, and terminal input polling can race with the responses used to restore the inline viewport. Fixes #26564. ## What changed - preserve and restore keyboard reporting without disturbing the parent terminal stack - pause terminal event polling while Codex is suspended and flush buffered input before resuming it - force crossterm's cached raw-mode state back in sync after the shell completes its `fg` handoff - probe the actual post-`fg` cursor position with the tolerant terminal-response parser, then realign the inline viewport before redrawing ## How to Test 1. On Linux, start the development TUI with `just c`. 2. Type text into the composer without submitting it. 3. Press `Ctrl+Z`, run any harmless shell command, then run `fg`. 4. Confirm the composer redraws below the shell output, the draft text is preserved, and no raw escape sequences appear. 5. Repeat the suspend/resume cycle and confirm normal typing still works. Targeted tests: - `cargo test -p codex-tui --lib parses_cursor_position_as_zero_based -j 1` - `cargo test -p codex-tui --lib tui::event_stream::tests -j 1`
967 lines
34 KiB
Rust
967 lines
34 KiB
Rust
//! Short, best-effort terminal response probes for TUI startup and resume.
|
|
//!
|
|
//! Crossterm's public helpers wait up to two seconds for terminal responses. That is too long for
|
|
//! TUI startup and resume, where unsupported terminals should simply fall back to conservative
|
|
//! defaults.
|
|
//! This module sends the same kinds of optional terminal queries with a caller-provided deadline,
|
|
//! prefers duplicated stdio handles, falls back to the controlling terminal path when stdio is
|
|
//! unavailable, and reports `None` when a response is unavailable.
|
|
//!
|
|
//! Probes run only while the crossterm event stream is absent or paused, so they do not share
|
|
//! crossterm's internal skipped-event queue. Bytes read while looking for probe responses are
|
|
//! consumed from the terminal; callers must therefore own terminal input for the duration of the
|
|
//! short timeout and accept that unrelated buffered input will be discarded.
|
|
|
|
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;
|
|
use std::io::Write;
|
|
use std::os::fd::AsRawFd;
|
|
use std::os::fd::FromRawFd;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
use crossterm::event::KeyboardEnhancementFlags;
|
|
use ratatui::layout::Position;
|
|
|
|
/// Results from the TUI's one-shot startup terminal probe.
|
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
|
pub(crate) struct StartupProbe {
|
|
pub(crate) cursor_position: Option<Position>,
|
|
pub(crate) default_colors: Option<DefaultColors>,
|
|
pub(crate) keyboard_enhancement_supported: Option<bool>,
|
|
}
|
|
|
|
/// Whether the startup probe should query keyboard enhancement support.
|
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
|
pub(crate) enum StartupKeyboardEnhancementProbe {
|
|
Query,
|
|
Skip,
|
|
}
|
|
|
|
/// Temporary terminal handle used while a probe owns terminal input.
|
|
///
|
|
/// The preferred path is duplicated stdin/stdout, because terminal replies are delivered to the
|
|
/// same input stream crossterm reads from. Some embedded or redirected environments expose a
|
|
/// controlling terminal without terminal stdio; in that case the handle falls back to
|
|
/// `/dev/tty`. Only the reader is switched to nonblocking mode, and its original file status
|
|
/// flags are restored when the handle is dropped.
|
|
struct Tty {
|
|
reader: File,
|
|
writer: File,
|
|
original_flags: libc::c_int,
|
|
}
|
|
|
|
impl Tty {
|
|
/// Opens an isolated reader and writer for terminal probes.
|
|
///
|
|
/// The reader and writer must be separate file descriptions so switching the reader into
|
|
/// nonblocking mode does not also make writes fail with `WouldBlock` under terminal
|
|
/// backpressure. Falling back to `/dev/tty` keeps embedded or redirected environments
|
|
/// usable when they still expose a controlling terminal.
|
|
fn open() -> io::Result<Self> {
|
|
let stdio_reader = dup_file(libc::STDIN_FILENO);
|
|
let stdio_writer = dup_file(libc::STDOUT_FILENO);
|
|
match (stdio_reader, stdio_writer) {
|
|
(Ok(reader), Ok(writer)) => Self::new(reader, writer),
|
|
(reader, writer) => {
|
|
let stdio_err = match (reader.err(), writer.err()) {
|
|
(Some(reader_err), Some(writer_err)) => {
|
|
format!("reader: {reader_err}; writer: {writer_err}")
|
|
}
|
|
(Some(reader_err), None) => format!("reader: {reader_err}"),
|
|
(None, Some(writer_err)) => format!("writer: {writer_err}"),
|
|
(None, None) => "unknown stdio duplicate error".to_string(),
|
|
};
|
|
let reader =
|
|
OpenOptions::new()
|
|
.read(true)
|
|
.open("/dev/tty")
|
|
.map_err(|fallback_err| {
|
|
io::Error::new(
|
|
fallback_err.kind(),
|
|
format!(
|
|
"failed to duplicate stdio ({stdio_err}) or open /dev/tty reader ({fallback_err})"
|
|
),
|
|
)
|
|
})?;
|
|
let writer = OpenOptions::new().write(true).open("/dev/tty").map_err(
|
|
|fallback_err| {
|
|
io::Error::new(
|
|
fallback_err.kind(),
|
|
format!(
|
|
"failed to duplicate stdio ({stdio_err}) or open /dev/tty writer ({fallback_err})"
|
|
),
|
|
)
|
|
},
|
|
)?;
|
|
Self::new(reader, writer)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn new(reader: File, writer: File) -> io::Result<Self> {
|
|
let fd = reader.as_raw_fd();
|
|
let original_flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
|
if original_flags == -1 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
if unsafe { libc::fcntl(fd, libc::F_SETFL, original_flags | libc::O_NONBLOCK) } == -1 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
Ok(Self {
|
|
reader,
|
|
writer,
|
|
original_flags,
|
|
})
|
|
}
|
|
|
|
fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> {
|
|
self.writer.write_all(bytes)?;
|
|
self.writer.flush()
|
|
}
|
|
|
|
fn read_available(&mut self, buffer: &mut Vec<u8>) -> io::Result<()> {
|
|
let mut chunk = [0_u8; 256];
|
|
loop {
|
|
let count = unsafe {
|
|
libc::read(
|
|
self.reader.as_raw_fd(),
|
|
chunk.as_mut_ptr().cast::<libc::c_void>(),
|
|
chunk.len(),
|
|
)
|
|
};
|
|
if count > 0 {
|
|
buffer.extend_from_slice(&chunk[..count as usize]);
|
|
continue;
|
|
}
|
|
if count == 0 {
|
|
return Ok(());
|
|
}
|
|
let err = io::Error::last_os_error();
|
|
if matches!(
|
|
err.kind(),
|
|
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
|
|
) {
|
|
return Ok(());
|
|
}
|
|
return Err(err);
|
|
}
|
|
}
|
|
|
|
fn poll_readable(&self, timeout: Duration) -> io::Result<bool> {
|
|
let mut fd = libc::pollfd {
|
|
fd: self.reader.as_raw_fd(),
|
|
events: libc::POLLIN,
|
|
revents: 0,
|
|
};
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
let now = Instant::now();
|
|
if now >= deadline {
|
|
return Ok(false);
|
|
}
|
|
let timeout_ms = deadline
|
|
.saturating_duration_since(now)
|
|
.as_millis()
|
|
.min(libc::c_int::MAX as u128) as libc::c_int;
|
|
let result = unsafe {
|
|
libc::poll(&mut fd, /*nfds*/ 1, timeout_ms)
|
|
};
|
|
if result > 0 {
|
|
return Ok((fd.revents & libc::POLLIN) != 0);
|
|
}
|
|
if result == 0 {
|
|
return Ok(false);
|
|
}
|
|
let err = io::Error::last_os_error();
|
|
if err.kind() != io::ErrorKind::Interrupted {
|
|
return Err(err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for Tty {
|
|
fn drop(&mut self) {
|
|
let _ =
|
|
unsafe { libc::fcntl(self.reader.as_raw_fd(), libc::F_SETFL, self.original_flags) };
|
|
}
|
|
}
|
|
|
|
/// Duplicates a process stdio descriptor so probe cleanup owns only the duplicate.
|
|
fn dup_file(fd: libc::c_int) -> io::Result<File> {
|
|
let duplicated = unsafe { libc::dup(fd) };
|
|
if duplicated == -1 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
Ok(unsafe { File::from_raw_fd(duplicated) })
|
|
}
|
|
|
|
/// Queries OSC 10 and OSC 11 default colors under one shared deadline.
|
|
///
|
|
/// Foreground and background are only useful as a pair for palette calculations, so a missing
|
|
/// response from either slot returns `Ok(None)`. Both queries are sent before reading so a
|
|
/// terminal that supports palette replies gets the full bounded window to return both values,
|
|
/// while unsupported terminals still pay one bounded wait instead of one wait per slot.
|
|
pub(crate) fn default_colors(timeout: Duration) -> io::Result<Option<DefaultColors>> {
|
|
let mut tty = Tty::open()?;
|
|
tty.write_all(b"\x1B]10;?\x1B\\\x1B]11;?\x1B\\")?;
|
|
let Some(colors) = read_until(&mut tty, timeout, parse_default_colors)? else {
|
|
return Ok(None);
|
|
};
|
|
Ok(Some(colors))
|
|
}
|
|
|
|
/// Queries the terminal cursor position while normal input polling is paused.
|
|
///
|
|
/// Resume can emit a focus report immediately before the cursor-position response. Reusing
|
|
/// the startup parser lets the probe find the response without leaking either sequence into
|
|
/// the composer.
|
|
pub(crate) fn cursor_position(timeout: Duration) -> io::Result<Option<Position>> {
|
|
let mut tty = Tty::open()?;
|
|
tty.write_all(b"\x1B[6n")?;
|
|
read_until(&mut tty, timeout, parse_cursor_position)
|
|
}
|
|
|
|
/// Runs the optional terminal queries needed during TUI startup under one shared deadline.
|
|
///
|
|
/// Keeping these queries batched avoids paying one timeout per unsupported capability before
|
|
/// the first frame can render.
|
|
pub(crate) fn startup(
|
|
timeout: Duration,
|
|
keyboard_probe: StartupKeyboardEnhancementProbe,
|
|
) -> io::Result<StartupProbe> {
|
|
let mut tty = Tty::open()?;
|
|
match keyboard_probe {
|
|
StartupKeyboardEnhancementProbe::Query => {
|
|
tty.write_all(b"\x1B[6n\x1B]10;?\x1B\\\x1B]11;?\x1B\\\x1B[?u\x1B[c")?;
|
|
}
|
|
StartupKeyboardEnhancementProbe::Skip => {
|
|
tty.write_all(b"\x1B[6n\x1B]10;?\x1B\\\x1B]11;?\x1B\\")?;
|
|
}
|
|
}
|
|
read_startup_probe(&mut tty, timeout, keyboard_probe)
|
|
}
|
|
|
|
/// Reads available terminal bytes until `parse` recognizes a probe response or time expires.
|
|
///
|
|
/// The accumulated buffer may include unrelated terminal input. This helper intentionally does
|
|
/// not try to replay those bytes, so callers must use it only during short, exclusive probe
|
|
/// windows before normal crossterm input polling begins or while that polling is paused.
|
|
fn read_until<T>(
|
|
tty: &mut Tty,
|
|
timeout: Duration,
|
|
mut parse: impl FnMut(&[u8]) -> Option<T>,
|
|
) -> io::Result<Option<T>> {
|
|
let deadline = Instant::now() + timeout;
|
|
let mut buffer = Vec::new();
|
|
loop {
|
|
tty.read_available(&mut buffer)?;
|
|
if let Some(value) = parse(&buffer) {
|
|
return Ok(Some(value));
|
|
}
|
|
let now = Instant::now();
|
|
if now >= deadline {
|
|
return Ok(None);
|
|
}
|
|
if !tty.poll_readable(deadline.saturating_duration_since(now))? {
|
|
return Ok(None);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_startup_probe(
|
|
tty: &mut Tty,
|
|
timeout: Duration,
|
|
keyboard_probe: StartupKeyboardEnhancementProbe,
|
|
) -> io::Result<StartupProbe> {
|
|
let deadline = Instant::now() + timeout;
|
|
let mut buffer = Vec::new();
|
|
let mut probe = StartupProbe {
|
|
cursor_position: None,
|
|
default_colors: None,
|
|
keyboard_enhancement_supported: None,
|
|
};
|
|
let mut saw_supported_keyboard = false;
|
|
loop {
|
|
tty.read_available(&mut buffer)?;
|
|
update_startup_probe(
|
|
&mut probe,
|
|
&mut saw_supported_keyboard,
|
|
&buffer,
|
|
keyboard_probe,
|
|
);
|
|
if startup_probe_complete(&probe, keyboard_probe) {
|
|
return Ok(probe);
|
|
}
|
|
let now = Instant::now();
|
|
if now >= deadline {
|
|
finish_startup_probe(&mut probe, keyboard_probe, saw_supported_keyboard);
|
|
return Ok(probe);
|
|
}
|
|
if !tty.poll_readable(deadline.saturating_duration_since(now))? {
|
|
finish_startup_probe(&mut probe, keyboard_probe, saw_supported_keyboard);
|
|
return Ok(probe);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_startup_probe(
|
|
probe: &mut StartupProbe,
|
|
saw_supported_keyboard: &mut bool,
|
|
buffer: &[u8],
|
|
keyboard_probe: StartupKeyboardEnhancementProbe,
|
|
) {
|
|
if probe.cursor_position.is_none() {
|
|
probe.cursor_position = parse_cursor_position(buffer);
|
|
}
|
|
if probe.default_colors.is_none() {
|
|
probe.default_colors = parse_default_colors(buffer);
|
|
}
|
|
if keyboard_probe == StartupKeyboardEnhancementProbe::Skip
|
|
|| probe.keyboard_enhancement_supported.is_some()
|
|
{
|
|
return;
|
|
}
|
|
match parse_keyboard_enhancement_support(buffer) {
|
|
KeyboardProbeState::SupportedAndFallback => {
|
|
probe.keyboard_enhancement_supported = Some(true);
|
|
}
|
|
KeyboardProbeState::Supported => {
|
|
*saw_supported_keyboard = true;
|
|
}
|
|
KeyboardProbeState::UnsupportedFallback => {
|
|
probe.keyboard_enhancement_supported = Some(false);
|
|
}
|
|
KeyboardProbeState::Pending => {}
|
|
}
|
|
}
|
|
|
|
fn startup_probe_complete(
|
|
probe: &StartupProbe,
|
|
keyboard_probe: StartupKeyboardEnhancementProbe,
|
|
) -> bool {
|
|
probe.cursor_position.is_some()
|
|
&& probe.default_colors.is_some()
|
|
&& (keyboard_probe == StartupKeyboardEnhancementProbe::Skip
|
|
|| probe.keyboard_enhancement_supported.is_some())
|
|
}
|
|
|
|
fn finish_startup_probe(
|
|
probe: &mut StartupProbe,
|
|
keyboard_probe: StartupKeyboardEnhancementProbe,
|
|
saw_supported_keyboard: bool,
|
|
) {
|
|
if keyboard_probe == StartupKeyboardEnhancementProbe::Query
|
|
&& probe.keyboard_enhancement_supported.is_none()
|
|
{
|
|
probe.keyboard_enhancement_supported = saw_supported_keyboard.then_some(true);
|
|
}
|
|
}
|
|
|
|
fn parse_cursor_position(buffer: &[u8]) -> Option<Position> {
|
|
for start in find_all_subslices(buffer, b"\x1B[") {
|
|
let rest = &buffer[start + 2..];
|
|
let Some(end) = rest.iter().position(|b| *b == b'R') else {
|
|
continue;
|
|
};
|
|
let Ok(payload) = std::str::from_utf8(&rest[..end]) else {
|
|
continue;
|
|
};
|
|
let Some((row, col)) = payload.split_once(';') else {
|
|
continue;
|
|
};
|
|
let Ok(row) = row.parse::<u16>() else {
|
|
continue;
|
|
};
|
|
let Ok(col) = col.parse::<u16>() else {
|
|
continue;
|
|
};
|
|
let row = row.saturating_sub(1);
|
|
let col = col.saturating_sub(1);
|
|
return Some(Position { x: col, y: row });
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Parser state for the keyboard enhancement probe.
|
|
///
|
|
/// `UnsupportedFallback` records that a primary-device-attributes response arrived without
|
|
/// keyboard flags. Startup treats that as unsupported immediately, matching crossterm's
|
|
/// previous behavior and avoiding a fixed delay in terminals without the keyboard protocol.
|
|
/// `Supported` records that keyboard flags arrived, but the caller should still drain the PDA
|
|
/// fallback response if it arrives before the deadline so those bytes do not leak into the
|
|
/// normal event stream.
|
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
|
enum KeyboardProbeState {
|
|
Pending,
|
|
UnsupportedFallback,
|
|
Supported,
|
|
SupportedAndFallback,
|
|
}
|
|
|
|
fn parse_keyboard_enhancement_support(buffer: &[u8]) -> KeyboardProbeState {
|
|
match (
|
|
find_keyboard_flags(buffer).is_some(),
|
|
find_primary_device_attributes(buffer).is_some(),
|
|
) {
|
|
(true, true) => KeyboardProbeState::SupportedAndFallback,
|
|
(true, false) => KeyboardProbeState::Supported,
|
|
(false, true) => KeyboardProbeState::UnsupportedFallback,
|
|
(false, false) => KeyboardProbeState::Pending,
|
|
}
|
|
}
|
|
|
|
fn find_keyboard_flags(buffer: &[u8]) -> Option<KeyboardEnhancementFlags> {
|
|
for start in find_all_subslices(buffer, b"\x1B[?") {
|
|
let rest = &buffer[start + 3..];
|
|
let Some(end) = rest.iter().position(|b| *b == b'u') else {
|
|
continue;
|
|
};
|
|
if end == 0 {
|
|
continue;
|
|
}
|
|
let Ok(bits_text) = std::str::from_utf8(&rest[..end]) else {
|
|
continue;
|
|
};
|
|
let Ok(bits) = bits_text.parse::<u8>() else {
|
|
continue;
|
|
};
|
|
let mut flags = KeyboardEnhancementFlags::empty();
|
|
if bits & 1 != 0 {
|
|
flags |= KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
|
|
}
|
|
if bits & 2 != 0 {
|
|
flags |= KeyboardEnhancementFlags::REPORT_EVENT_TYPES;
|
|
}
|
|
if bits & 4 != 0 {
|
|
flags |= KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS;
|
|
}
|
|
if bits & 8 != 0 {
|
|
flags |= KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
|
|
}
|
|
return Some(flags);
|
|
}
|
|
None
|
|
}
|
|
|
|
fn find_primary_device_attributes(buffer: &[u8]) -> Option<()> {
|
|
for start in find_all_subslices(buffer, b"\x1B[?") {
|
|
let rest = &buffer[start + 3..];
|
|
let Some(end) = rest.iter().position(|b| *b == b'c') else {
|
|
continue;
|
|
};
|
|
if end > 0 && rest[..end].iter().all(|b| b.is_ascii_digit() || *b == b';') {
|
|
return Some(());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn find_all_subslices<'a>(
|
|
haystack: &'a [u8],
|
|
needle: &'a [u8],
|
|
) -> impl Iterator<Item = usize> + 'a {
|
|
haystack
|
|
.windows(needle.len())
|
|
.enumerate()
|
|
.filter_map(move |(idx, window)| (window == needle).then_some(idx))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[test]
|
|
fn parses_cursor_position_as_zero_based() {
|
|
assert_eq!(
|
|
parse_cursor_position(b"\x1B[20;10R"),
|
|
Some(Position { x: 9, y: 19 })
|
|
);
|
|
assert_eq!(
|
|
parse_cursor_position(b"\x1B[I\x1B[20;10R"),
|
|
Some(Position { x: 9, y: 19 })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_keyboard_enhancement_flags_and_pda_fallback() {
|
|
assert_eq!(
|
|
parse_keyboard_enhancement_support(b"\x1B[?7u"),
|
|
KeyboardProbeState::Supported
|
|
);
|
|
assert_eq!(
|
|
parse_keyboard_enhancement_support(b"\x1B[?64;1;2c"),
|
|
KeyboardProbeState::UnsupportedFallback
|
|
);
|
|
assert_eq!(
|
|
parse_keyboard_enhancement_support(b"\x1B[?64;1;2c\x1B[?7u"),
|
|
KeyboardProbeState::SupportedAndFallback
|
|
);
|
|
assert_eq!(
|
|
parse_keyboard_enhancement_support(b"\x1B[?7u\x1B[?64;1;2c"),
|
|
KeyboardProbeState::SupportedAndFallback
|
|
);
|
|
assert_eq!(
|
|
parse_keyboard_enhancement_support(b""),
|
|
KeyboardProbeState::Pending
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn startup_probe_parses_batched_terminal_responses() {
|
|
let mut probe = StartupProbe {
|
|
cursor_position: None,
|
|
default_colors: None,
|
|
keyboard_enhancement_supported: None,
|
|
};
|
|
let mut saw_supported_keyboard = false;
|
|
update_startup_probe(
|
|
&mut probe,
|
|
&mut saw_supported_keyboard,
|
|
b"\x1B[20;10R\x1B]11;rgb:1111/1111/1111\x07\x1B[?64;1;2c\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B[?7u",
|
|
StartupKeyboardEnhancementProbe::Query,
|
|
);
|
|
|
|
assert_eq!(
|
|
probe,
|
|
StartupProbe {
|
|
cursor_position: Some(Position { x: 9, y: 19 }),
|
|
default_colors: Some(DefaultColors {
|
|
fg: (238, 238, 238),
|
|
bg: (17, 17, 17),
|
|
}),
|
|
keyboard_enhancement_supported: Some(true),
|
|
}
|
|
);
|
|
assert!(startup_probe_complete(
|
|
&probe,
|
|
StartupKeyboardEnhancementProbe::Query
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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),
|
|
})
|
|
);
|
|
}
|
|
}
|