From 7ffad7057b334c8f96577023be9dab311ebd09a6 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 30 Apr 2026 21:16:03 -0300 Subject: [PATCH] Limit TUI terminal capability probe waits --- codex-rs/tui/src/custom_terminal.rs | 33 ++- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/terminal_palette.rs | 26 +- codex-rs/tui/src/terminal_probe.rs | 345 +++++++++++++++++++++++++++ codex-rs/tui/src/tui.rs | 39 ++- codex-rs/tui/src/tui/event_stream.rs | 2 + 6 files changed, 426 insertions(+), 20 deletions(-) create mode 100644 codex-rs/tui/src/terminal_probe.rs diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index cadf1fa13f..3e96f53ec3 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -189,16 +189,43 @@ where tracing::warn!("failed to read initial cursor position; defaulting to origin: {err}"); Position { x: 0, y: 0 } }); - Ok(Self { + Ok(Self::with_screen_size_and_cursor_position( + backend, + screen_size, + cursor_pos, + )) + } + + /// Creates a new [`Terminal`] when the caller has already determined the initial cursor. + pub fn with_options_and_cursor_position(backend: B, cursor_pos: Position) -> io::Result { + let screen_size = backend.size()?; + Ok(Self::with_screen_size_and_cursor_position( + backend, + screen_size, + cursor_pos, + )) + } + + fn with_screen_size_and_cursor_position( + backend: B, + screen_size: Size, + cursor_pos: Position, + ) -> Self { + Self { backend, buffers: [Buffer::empty(Rect::ZERO), Buffer::empty(Rect::ZERO)], current: 0, hidden_cursor: false, - viewport_area: Rect::new(0, cursor_pos.y, 0, 0), + viewport_area: Rect::new( + /*x*/ 0, + cursor_pos.y, + /*width*/ 0, + /*height*/ 0, + ), last_known_screen_size: screen_size, last_known_cursor_pos: cursor_pos, visible_history_rows: 0, - }) + } } /// Get a Frame object which provides a consistent view into the terminal state for rendering. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 334e412c0a..2a179e07ee 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -164,6 +164,7 @@ mod status_indicator_widget; mod streaming; mod style; mod terminal_palette; +mod terminal_probe; mod terminal_title; mod text_formatting; mod theme_picker; diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index 83f9f8283d..f89bead178 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -71,9 +71,6 @@ pub fn default_bg() -> Option<(u8, u8, u8)> { #[cfg(all(unix, not(test)))] mod imp { use super::DefaultColors; - use crossterm::style::Color as CrosstermColor; - use crossterm::style::query_background_color; - use crossterm::style::query_foreground_color; use std::sync::Mutex; use std::sync::OnceLock; @@ -115,7 +112,7 @@ mod imp { 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()) + cache.get_or_init_with(query_default_colors) } pub(super) fn requery_default_colors() { @@ -124,21 +121,18 @@ mod imp { if cache.attempted && cache.value.is_none() { return; } - cache.refresh_with(|| query_default_colors().unwrap_or_default()); + cache.refresh_with(query_default_colors); } } - fn query_default_colors() -> std::io::Result> { - let fg = query_foreground_color()?.and_then(color_to_tuple); - let bg = query_background_color()?.and_then(color_to_tuple); - Ok(fg.zip(bg).map(|(fg, bg)| DefaultColors { fg, bg })) - } - - fn color_to_tuple(color: CrosstermColor) -> Option<(u8, u8, u8)> { - match color { - CrosstermColor::Rgb { r, g, b } => Some((r, g, b)), - _ => None, - } + fn query_default_colors() -> Option { + crate::terminal_probe::default_colors(crate::terminal_probe::DEFAULT_TIMEOUT) + .ok() + .flatten() + .map(|colors| DefaultColors { + fg: colors.fg, + bg: colors.bg, + }) } } diff --git a/codex-rs/tui/src/terminal_probe.rs b/codex-rs/tui/src/terminal_probe.rs new file mode 100644 index 0000000000..6d90f63ad1 --- /dev/null +++ b/codex-rs/tui/src/terminal_probe.rs @@ -0,0 +1,345 @@ +//! Short, best-effort terminal response probes. +//! +//! Crossterm's public helpers wait up to two seconds for terminal responses. That is too long for +//! TUI startup, where unsupported terminals should simply fall back to conservative defaults. + +#[cfg(unix)] +#[cfg_attr(test, allow(dead_code))] +mod imp { + use std::fs::File; + use std::fs::OpenOptions; + use std::io; + use std::io::Write; + use std::os::fd::AsRawFd; + use std::time::Duration; + use std::time::Instant; + + use crossterm::event::KeyboardEnhancementFlags; + use ratatui::layout::Position; + + pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100); + + #[derive(Debug, Clone, Copy, Eq, PartialEq)] + pub(crate) struct DefaultColors { + pub(crate) fg: (u8, u8, u8), + pub(crate) bg: (u8, u8, u8), + } + + struct Tty { + file: File, + original_flags: libc::c_int, + } + + impl Tty { + fn open() -> io::Result { + let file = OpenOptions::new().read(true).write(true).open("/dev/tty")?; + let fd = file.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 { + file, + original_flags, + }) + } + + fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> { + self.file.write_all(bytes)?; + self.file.flush() + } + + fn read_available(&mut self, buffer: &mut Vec) -> io::Result<()> { + let mut chunk = [0_u8; 256]; + loop { + let count = unsafe { + libc::read( + self.file.as_raw_fd(), + chunk.as_mut_ptr().cast::(), + 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 { + let mut fd = libc::pollfd { + fd: self.file.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + let timeout_ms = timeout.as_millis().min(libc::c_int::MAX as u128) as libc::c_int; + loop { + 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.file.as_raw_fd(), libc::F_SETFL, self.original_flags) }; + } + } + + pub(crate) fn cursor_position(timeout: Duration) -> io::Result> { + let mut tty = Tty::open()?; + tty.write_all(b"\x1B[6n")?; + let Some(response) = read_until(&mut tty, timeout, parse_cursor_position)? else { + return Ok(None); + }; + Ok(Some(response)) + } + + pub(crate) fn default_colors(timeout: Duration) -> io::Result> { + let mut tty = Tty::open()?; + let deadline = Instant::now() + timeout; + let Some(fg) = query_color_slot(&mut tty, /*slot*/ 10, remaining(deadline))? else { + return Ok(None); + }; + let Some(bg) = query_color_slot(&mut tty, /*slot*/ 11, remaining(deadline))? else { + return Ok(None); + }; + Ok(Some(DefaultColors { fg, bg })) + } + + pub(crate) fn keyboard_enhancement_supported(timeout: Duration) -> io::Result> { + let mut tty = Tty::open()?; + tty.write_all(b"\x1B[?u\x1B[c")?; + read_until(&mut tty, timeout, parse_keyboard_enhancement_support) + } + + fn query_color_slot( + tty: &mut Tty, + slot: u8, + timeout: Duration, + ) -> io::Result> { + write!(tty.file, "\x1B]{slot};?\x1B\\")?; + tty.file.flush()?; + read_until(tty, timeout, |buffer| parse_osc_color(buffer, slot)) + } + + fn read_until( + tty: &mut Tty, + timeout: Duration, + mut parse: impl FnMut(&[u8]) -> Option, + ) -> io::Result> { + 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 remaining(deadline: Instant) -> Duration { + deadline.saturating_duration_since(Instant::now()) + } + + fn parse_cursor_position(buffer: &[u8]) -> Option { + 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 payload = std::str::from_utf8(&rest[..end]).ok()?; + let (row, col) = payload.split_once(';')?; + let row = row.parse::().ok()?.saturating_sub(1); + let col = col.parse::().ok()?.saturating_sub(1); + return Some(Position { x: col, y: row }); + } + 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 osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> { + let mut idx = 0; + while idx < buffer.len() { + match buffer[idx] { + 0x07 => return Some((idx, 1)), + 0x1B if buffer.get(idx + 1) == Some(&b'\\') => return Some((idx, 2)), + _ => idx += 1, + } + } + None + } + + fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> { + let (prefix, values) = payload.trim().split_once(':')?; + if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") { + return None; + } + + let mut parts = values.split('/'); + let r = parse_osc_component(parts.next()?)?; + let g = parse_osc_component(parts.next()?)?; + let b = parse_osc_component(parts.next()?)?; + if prefix.eq_ignore_ascii_case("rgba") { + parse_osc_component(parts.next()?)?; + } + parts.next().is_none().then_some((r, g, b)) + } + + fn parse_osc_component(component: &str) -> Option { + match component.len() { + 2 => u8::from_str_radix(component, 16).ok(), + 4 => u16::from_str_radix(component, 16) + .ok() + .map(|value| (value / 257) as u8), + _ => None, + } + } + + fn parse_keyboard_enhancement_support(buffer: &[u8]) -> Option { + if find_keyboard_flags(buffer).is_some() { + return Some(true); + } + find_primary_device_attributes(buffer).map(|_| false) + } + + fn find_keyboard_flags(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'u') else { + continue; + }; + if end == 0 { + continue; + } + let bits = std::str::from_utf8(&rest[..end]).ok()?.parse::().ok()?; + 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_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) + } + + fn find_all_subslices<'a>( + haystack: &'a [u8], + needle: &'a [u8], + ) -> impl Iterator + 'a { + haystack + .windows(needle.len()) + .enumerate() + .filter_map(move |(idx, window)| (window == needle).then_some(idx)) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn parses_cursor_position_as_zero_based() { + assert_eq!( + parse_cursor_position(b"\x1B[20;10R"), + Some(Position { x: 9, y: 19 }) + ); + } + + #[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_keyboard_enhancement_flags_and_pda_fallback() { + assert_eq!(parse_keyboard_enhancement_support(b"\x1B[?7u"), Some(true)); + assert_eq!( + parse_keyboard_enhancement_support(b"\x1B[?64;1;2c"), + Some(false) + ); + assert_eq!(parse_keyboard_enhancement_support(b""), None); + } + } +} + +#[cfg(unix)] +pub(crate) use imp::*; + +#[cfg(not(unix))] +pub(crate) const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(100); diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 30af7804a8..4d0a97340a 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -21,6 +21,7 @@ use crossterm::event::EnableFocusChange; use crossterm::event::KeyEvent; use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::LeaveAlternateScreen; +#[cfg(not(unix))] use crossterm::terminal::supports_keyboard_enhancement; use ratatui::backend::Backend; use ratatui::backend::CrosstermBackend; @@ -282,8 +283,35 @@ pub fn init() -> Result { set_panic_hook(); + #[cfg(unix)] let backend = CrosstermBackend::new(stdout()); - let tui = CustomTerminal::with_options(backend)?; + + #[cfg(not(unix))] + let mut backend = CrosstermBackend::new(stdout()); + + #[cfg(unix)] + let cursor_pos = + match crate::terminal_probe::cursor_position(crate::terminal_probe::DEFAULT_TIMEOUT) { + Ok(Some(pos)) => pos, + Ok(None) => { + tracing::warn!("initial cursor position probe timed out; defaulting to origin"); + Position { x: 0, y: 0 } + } + Err(err) => { + tracing::warn!( + "failed to read initial cursor position; defaulting to origin: {err}" + ); + Position { x: 0, y: 0 } + } + }; + + #[cfg(not(unix))] + let cursor_pos = backend.get_cursor_position().unwrap_or_else(|err| { + tracing::warn!("failed to read initial cursor position; defaulting to origin: {err}"); + Position { x: 0, y: 0 } + }); + + let tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?; Ok(tui) } @@ -338,6 +366,15 @@ impl Tui { // Detect keyboard enhancement support before any EventStream is created so the // crossterm poller can acquire its lock without contention. + #[cfg(unix)] + let enhanced_keys_supported = !keyboard_modes::keyboard_enhancement_disabled() + && crate::terminal_probe::keyboard_enhancement_supported( + crate::terminal_probe::DEFAULT_TIMEOUT, + ) + .unwrap_or(/*default*/ None) + .unwrap_or(/*default*/ false); + + #[cfg(not(unix))] let enhanced_keys_supported = !keyboard_modes::keyboard_enhancement_disabled() && supports_keyboard_enhancement().unwrap_or(false); // Cache this to avoid contention with the event reader. diff --git a/codex-rs/tui/src/tui/event_stream.rs b/codex-rs/tui/src/tui/event_stream.rs index dcc6e17e0e..de09caedde 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -248,7 +248,9 @@ impl TuiEventStream { Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)), Event::FocusGained => { self.terminal_focused.store(true, Ordering::Relaxed); + self.broker.pause_events(); crate::terminal_palette::requery_default_colors(); + self.broker.resume_events(); Some(TuiEvent::Draw) } Event::FocusLost => {