From df3b0a9e25b416035b7d93967af67c69e29126ac Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 15 Apr 2026 14:07:38 -0300 Subject: [PATCH 1/2] fix(tui): detect superset resize changes Add Superset terminal detection and a narrowly gated resize watchdog so missed split-pane resize notifications still trigger a visible repaint. Keep transcript reflow on the existing debounced path and do not port the immediate scrollback repaint behavior from the feature stack. --- codex-rs/terminal-detection/src/lib.rs | 4 + .../terminal-detection/src/terminal_tests.rs | 24 ++ codex-rs/tui/src/app.rs | 7 +- codex-rs/tui/src/app_backtrack.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/cwd_prompt.rs | 2 +- codex-rs/tui/src/model_migration.rs | 2 +- .../tui/src/onboarding/onboarding_screen.rs | 2 +- codex-rs/tui/src/pager_overlay.rs | 4 +- codex-rs/tui/src/resume_picker.rs | 2 +- codex-rs/tui/src/tui.rs | 28 ++- codex-rs/tui/src/tui/event_stream.rs | 208 +++++++++++++++++- codex-rs/tui/src/update_prompt.rs | 2 +- 13 files changed, 274 insertions(+), 14 deletions(-) diff --git a/codex-rs/terminal-detection/src/lib.rs b/codex-rs/terminal-detection/src/lib.rs index 69b6f474593..73aeb875278 100644 --- a/codex-rs/terminal-detection/src/lib.rs +++ b/codex-rs/terminal-detection/src/lib.rs @@ -33,6 +33,8 @@ pub enum TerminalName { WarpTerminal, /// Visual Studio Code integrated terminal. VsCode, + /// Superset xterm.js terminal host. + Superset, /// WezTerm terminal emulator. WezTerm, /// kitty terminal emulator. @@ -190,6 +192,7 @@ impl TerminalInfo { format_terminal_version("WarpTerminal", &self.version) } TerminalName::VsCode => format_terminal_version("vscode", &self.version), + TerminalName::Superset => format_terminal_version("Superset", &self.version), TerminalName::WezTerm => format_terminal_version("WezTerm", &self.version), TerminalName::Kitty => "kitty".to_string(), TerminalName::Alacritty => "Alacritty".to_string(), @@ -482,6 +485,7 @@ fn terminal_name_from_term_program(value: &str) -> Option { "iterm" | "iterm2" | "itermapp" => Some(TerminalName::Iterm2), "warp" | "warpterminal" => Some(TerminalName::WarpTerminal), "vscode" => Some(TerminalName::VsCode), + "superset" => Some(TerminalName::Superset), "wezterm" => Some(TerminalName::WezTerm), "kitty" => Some(TerminalName::Kitty), "alacritty" => Some(TerminalName::Alacritty), diff --git a/codex-rs/terminal-detection/src/terminal_tests.rs b/codex-rs/terminal-detection/src/terminal_tests.rs index 52974a0c318..0fe1c38f660 100644 --- a/codex-rs/terminal-detection/src/terminal_tests.rs +++ b/codex-rs/terminal-detection/src/terminal_tests.rs @@ -252,6 +252,30 @@ fn detects_vscode() { ); } +#[test] +fn detects_superset() { + let env = FakeEnvironment::new() + .with_var("TERM_PROGRAM", "Superset") + .with_var("TERM_PROGRAM_VERSION", "2.0.0"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Superset, + Some("Superset"), + Some("2.0.0"), + /*term*/ None, + /*multiplexer*/ None, + ), + "superset_term_program_info" + ); + assert_eq!( + terminal.user_agent_token(), + "Superset/2.0.0", + "superset_term_program_user_agent" + ); +} + #[test] fn detects_warp_terminal() { let env = FakeEnvironment::new() diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 026bd05d7c1..20a824c83ed 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4139,7 +4139,10 @@ impl App { app_server: &mut AppServerSession, event: TuiEvent, ) -> Result { - if matches!(event, TuiEvent::Draw) { + if matches!(event, TuiEvent::Resize) { + tui.force_full_repaint(); + } + if matches!(event, TuiEvent::Draw | TuiEvent::Resize) { let size = tui.terminal.size()?; if size != tui.terminal.last_known_screen_size { self.refresh_status_line(); @@ -4161,7 +4164,7 @@ impl App { let pasted = pasted.replace("\r", "\n"); self.chat_widget.handle_paste(pasted); } - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { if self.backtrack_render_pending { self.backtrack_render_pending = false; self.render_transcript_once(tui); diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 2852fbc3790..09029e16731 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -363,7 +363,7 @@ impl App { /// source of truth for the active cell and its cache invalidation key, and because `App` owns /// overlay lifecycle and frame scheduling for animations. fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { - if let TuiEvent::Draw = &event + if matches!(&event, TuiEvent::Draw | TuiEvent::Resize) && let Some(Overlay::Transcript(t)) = &mut self.overlay { let active_key = self.chat_widget.active_cell_transcript_key(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e9f56a0ef07..9b4e4f7902f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -292,6 +292,7 @@ fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyB | TerminalName::GnomeTerminal | TerminalName::Vte | TerminalName::WindowsTerminal + | TerminalName::Superset | TerminalName::Dumb | TerminalName::Unknown => key_hint::alt(KeyCode::Up), } diff --git a/codex-rs/tui/src/cwd_prompt.rs b/codex-rs/tui/src/cwd_prompt.rs index 0dace9c7b6f..264fa39c794 100644 --- a/codex-rs/tui/src/cwd_prompt.rs +++ b/codex-rs/tui/src/cwd_prompt.rs @@ -97,7 +97,7 @@ pub(crate) async fn run_cwd_selection_prompt( match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), TuiEvent::Paste(_) => {} - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); })?; diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 1b2de5ecfd6..c307abb78ff 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -153,7 +153,7 @@ pub(crate) async fn run_model_migration_prompt( match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), TuiEvent::Paste(_) => {} - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { let _ = alt.tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); }); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 22a444cdaf8..48676e955a6 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -474,7 +474,7 @@ pub(crate) async fn run_onboarding_app( TuiEvent::Paste(text) => { onboarding_screen.handle_paste(text); } - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { if !did_full_clear_after_success && onboarding_screen.steps.iter().any(|step| { if let Step::Auth(w) = step { diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index bca5f1f360a..0d6362feb55 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -700,7 +700,7 @@ impl TranscriptOverlay { } other => self.view.handle_key_event(tui, other), }, - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); })?; @@ -764,7 +764,7 @@ impl StaticOverlay { } other => self.view.handle_key_event(tui, other), }, - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); })?; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 6703a606c34..7692fb9156d 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -288,7 +288,7 @@ async fn run_session_picker_with_loader( return Ok(sel); } } - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { if let Ok(size) = alt.tui.terminal.size() { let list_height = size.height.saturating_sub(4) as usize; state.update_view_rows(list_height); diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 5cff8521ad3..b00c6e9464f 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -274,6 +274,7 @@ fn set_panic_hook() { pub enum TuiEvent { Key(KeyEvent), Paste(String), + Resize, Draw, } @@ -294,6 +295,7 @@ pub struct Tui { notification_backend: Option, notification_condition: NotificationCondition, is_zellij: bool, + force_full_repaint: bool, // When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support) alt_screen_enabled: bool, } @@ -309,8 +311,9 @@ impl Tui { // Cache this to avoid contention with the event reader. supports_color::on_cached(supports_color::Stream::Stdout); let _ = crate::terminal_palette::default_colors(); + let terminal_info = codex_terminal_detection::terminal_info(); let is_zellij = matches!( - codex_terminal_detection::terminal_info().multiplexer, + terminal_info.multiplexer, Some(codex_terminal_detection::Multiplexer::Zellij {}) ); @@ -329,6 +332,7 @@ impl Tui { notification_backend: Some(detect_backend(NotificationMethod::default())), notification_condition: NotificationCondition::default(), is_zellij, + force_full_repaint: false, alt_screen_enabled: true, } } @@ -351,6 +355,10 @@ impl Tui { self.frame_requester.clone() } + pub(crate) fn force_full_repaint(&mut self) { + self.force_full_repaint = true; + } + pub fn enhanced_keys_supported(&self) -> bool { self.enhanced_keys_supported } @@ -590,9 +598,18 @@ impl Tui { .suspend_context .prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport); + let force_full_repaint = self.force_full_repaint; + self.force_full_repaint = false; + // Precompute any viewport updates that need a cursor-position query before entering - // the synchronized update, to avoid racing with the event reader. - let mut pending_viewport_area = self.pending_viewport_area()?; + // the synchronized update, to avoid racing with the event reader. Explicit resize + // events skip this heuristic because xterm.js can report stale cursor positions while + // a blurred split pane is being resized rapidly. + let mut pending_viewport_area = if force_full_repaint { + None + } else { + self.pending_viewport_area()? + }; stdout().sync_update(|_| { #[cfg(unix)] @@ -614,6 +631,11 @@ impl Tui { self.is_zellij, )?; + if force_full_repaint { + terminal.clear()?; + needs_full_repaint = true; + } + if needs_full_repaint { terminal.invalidate_viewport(); } diff --git a/codex-rs/tui/src/tui/event_stream.rs b/codex-rs/tui/src/tui/event_stream.rs index 2ce0aa7d2cd..7688a75e75d 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -24,10 +24,16 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::task::Context; use std::task::Poll; +use std::time::Duration; +use codex_terminal_detection::TerminalInfo; +use codex_terminal_detection::TerminalName; use crossterm::event::Event; +use crossterm::terminal; use tokio::sync::broadcast; use tokio::sync::watch; +use tokio::time::Interval; +use tokio::time::MissedTickBehavior; use tokio_stream::Stream; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::WatchStream; @@ -35,6 +41,8 @@ use tokio_stream::wrappers::errors::BroadcastStreamRecvError; use super::TuiEvent; +const SUPERSET_RESIZE_WATCHDOG_INTERVAL: Duration = Duration::from_millis(100); + /// Result type produced by an event source. pub type EventResult = std::io::Result; @@ -44,6 +52,56 @@ pub trait EventSource: Send + 'static { fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>; } +type TerminalSizeReader = Arc std::io::Result<(u16, u16)> + Send + Sync>; + +struct ResizeWatchdog { + interval: Interval, + last_size: Option<(u16, u16)>, + read_size: TerminalSizeReader, +} + +impl ResizeWatchdog { + fn new(interval_duration: Duration, read_size: TerminalSizeReader) -> Self { + let mut interval = tokio::time::interval(interval_duration); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + Self { + interval, + last_size: None, + read_size, + } + } + + fn superset() -> Self { + Self::new(SUPERSET_RESIZE_WATCHDOG_INTERVAL, Arc::new(terminal::size)) + } + + fn set_observed_size(&mut self, size: (u16, u16)) { + self.last_size = Some(size); + } + + fn observe_current_size(&mut self) -> bool { + let Ok(size) = (self.read_size)() else { + return false; + }; + let previous_size = self.last_size; + let changed = previous_size.is_some_and(|last_size| last_size != size); + self.last_size = Some(size); + changed + } +} + +fn should_enable_superset_resize_watchdog( + terminal_info: &TerminalInfo, + has_superset_env_markers: bool, +) -> bool { + terminal_info.name == TerminalName::Superset || has_superset_env_markers +} + +fn has_superset_env_markers() -> bool { + std::env::var_os("SUPERSET_TERMINAL_ID").is_some() + && std::env::var_os("SUPERSET_WORKSPACE_ID").is_some() +} + /// Shared crossterm input state for all [`TuiEventStream`] instances. A single crossterm EventStream /// is reused so all streams still see the same input source. /// @@ -140,6 +198,7 @@ pub struct TuiEventStream>, draw_stream: BroadcastStream<()>, resume_stream: WatchStream<()>, + resize_watchdog: Option, terminal_focused: Arc, poll_draw_first: bool, #[cfg(unix)] @@ -157,10 +216,16 @@ impl TuiEventStream { #[cfg(unix)] alt_screen_active: Arc, ) -> Self { let resume_stream = WatchStream::from_changes(broker.resume_events_rx()); + let terminal_info = codex_terminal_detection::terminal_info(); + let has_superset_env_markers = has_superset_env_markers(); + let enable_resize_watchdog = + should_enable_superset_resize_watchdog(&terminal_info, has_superset_env_markers); + let resize_watchdog = enable_resize_watchdog.then(ResizeWatchdog::superset); Self { broker, draw_stream: BroadcastStream::new(draw_rx), resume_stream, + resize_watchdog, terminal_focused, poll_draw_first: false, #[cfg(unix)] @@ -233,6 +298,23 @@ impl TuiEventStream { } } + fn poll_resize_watchdog(&mut self, cx: &mut Context<'_>) -> Poll> { + let Some(watchdog) = self.resize_watchdog.as_mut() else { + return Poll::Pending; + }; + + loop { + match Pin::new(&mut watchdog.interval).poll_tick(cx) { + Poll::Ready(_) => { + if watchdog.observe_current_size() { + return Poll::Ready(Some(TuiEvent::Resize)); + } + } + Poll::Pending => return Poll::Pending, + } + } + } + /// Map a crossterm event to a [`TuiEvent`], skipping events we don't use (mouse events, etc.). fn map_crossterm_event(&mut self, event: Event) -> Option { match event { @@ -244,7 +326,12 @@ impl TuiEventStream { } Some(TuiEvent::Key(key_event)) } - Event::Resize(_, _) => Some(TuiEvent::Draw), + Event::Resize(cols, rows) => { + if let Some(watchdog) = self.resize_watchdog.as_mut() { + watchdog.set_observed_size((cols, rows)); + } + Some(TuiEvent::Resize) + } Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)), Event::FocusGained => { self.terminal_focused.store(true, Ordering::Relaxed); @@ -286,6 +373,10 @@ impl Stream for TuiEventStream { } } + if let Poll::Ready(event) = self.poll_resize_watchdog(cx) { + return Poll::Ready(event); + } + Poll::Pending } } @@ -369,6 +460,31 @@ mod tests { ) } + fn terminal_info(name: TerminalName) -> TerminalInfo { + TerminalInfo { + name, + term_program: None, + version: None, + term: None, + multiplexer: None, + } + } + + fn make_resize_watchdog( + size: Arc>, + interval_duration: Duration, + ) -> ResizeWatchdog { + ResizeWatchdog::new( + interval_duration, + Arc::new(move || { + let size = size + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Ok(*size) + }), + ) + } + type SetupState = ( Arc>, FakeEventSourceHandle, @@ -438,6 +554,85 @@ mod tests { assert!(saw_draw && saw_key, "expected both draw and key events"); } + #[test] + fn superset_resize_watchdog_gate_is_narrow() { + assert!(should_enable_superset_resize_watchdog( + &terminal_info(TerminalName::Superset), + /*has_superset_env_markers*/ false, + )); + assert!(should_enable_superset_resize_watchdog( + &terminal_info(TerminalName::Unknown), + /*has_superset_env_markers*/ true, + )); + assert!(!should_enable_superset_resize_watchdog( + &terminal_info(TerminalName::Unknown), + /*has_superset_env_markers*/ false, + )); + assert!(!should_enable_superset_resize_watchdog( + &terminal_info(TerminalName::VsCode), + /*has_superset_env_markers*/ false, + )); + } + + #[tokio::test(flavor = "current_thread")] + async fn resize_watchdog_reports_changes_after_seed() { + let size = Arc::new(std::sync::Mutex::new((80, 24))); + let mut watchdog = make_resize_watchdog(size.clone(), Duration::from_millis(10)); + + assert!(!watchdog.observe_current_size(), "first sample seeds size"); + assert!( + !watchdog.observe_current_size(), + "same size does not request resize" + ); + + *size + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = (100, 30); + assert!( + watchdog.observe_current_size(), + "changed size requests resize" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn resize_watchdog_emits_resize_when_size_changes() { + let (broker, _handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let size = Arc::new(std::sync::Mutex::new((80, 24))); + let mut stream = make_stream(broker, draw_rx, terminal_focused); + stream.resize_watchdog = Some(make_resize_watchdog(size.clone(), Duration::from_millis(5))); + + let no_event = timeout(Duration::from_millis(20), stream.next()).await; + assert!(no_event.is_err(), "unchanged terminal size should not emit"); + + *size + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = (100, 30); + let next = timeout(Duration::from_millis(50), stream.next()) + .await + .expect("timed out waiting for watchdog resize"); + assert!(matches!(next, Some(TuiEvent::Resize))); + } + + #[tokio::test(flavor = "current_thread")] + async fn resize_event_updates_watchdog_size() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let size = Arc::new(std::sync::Mutex::new((100, 30))); + let mut stream = make_stream(broker, draw_rx, terminal_focused); + stream.resize_watchdog = Some(make_resize_watchdog(size, Duration::from_millis(10))); + + handle.send(Ok(Event::Resize(100, 30))); + + let next = stream.next().await; + assert!(matches!(next, Some(TuiEvent::Resize))); + assert_eq!( + stream + .resize_watchdog + .as_ref() + .and_then(|watchdog| watchdog.last_size), + Some((100, 30)) + ); + } + #[tokio::test(flavor = "current_thread")] async fn lagged_draw_maps_to_draw() { let (broker, _handle, draw_tx, draw_rx, terminal_focused) = setup(); @@ -451,6 +646,17 @@ mod tests { assert!(matches!(first, Some(TuiEvent::Draw))); } + #[tokio::test(flavor = "current_thread")] + async fn resize_event_maps_to_resize() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker, draw_rx, terminal_focused); + + handle.send(Ok(Event::Resize(80, 24))); + + let next = stream.next().await; + assert!(matches!(next, Some(TuiEvent::Resize))); + } + #[tokio::test(flavor = "current_thread")] async fn error_or_eof_ends_stream() { let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index ab9c93f4243..4d5a9e1287b 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -57,7 +57,7 @@ pub(crate) async fn run_update_prompt_if_needed( match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), TuiEvent::Paste(_) => {} - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); })?; From 09fb819baa92c51979312ebbeb66dd90d9c6d120 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 15 Apr 2026 14:47:02 -0300 Subject: [PATCH 2/2] docs(tui): document resize repaint contract Clarify the event-stream resize watchdog and the TUI repaint path so reviewers can distinguish scheduled draws from geometry-driven repaints. Document the Superset-specific gate and the cached-size contract without changing runtime behavior. --- codex-rs/tui/src/tui.rs | 18 ++++++++++++--- codex-rs/tui/src/tui/event_stream.rs | 33 ++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index b00c6e9464f..97b761056a8 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -270,11 +270,20 @@ fn set_panic_hook() { })); } +/// Event consumed by the top-level TUI event loop. +/// +/// Resize is intentionally modeled separately from [`TuiEvent::Draw`] because resize handling has to reconcile terminal geometry and cached viewport state before rendering the next frame. #[derive(Clone, Debug)] pub enum TuiEvent { + /// Keyboard input delivered by crossterm. Key(KeyEvent), + /// Bracketed paste payload delivered by crossterm. Paste(String), + /// Terminal geometry changed or was inferred to have changed. + /// + /// Resize events should be rendered immediately and should not be downgraded to a scheduled draw. Some terminal hosts can report stale cursor positions during resize, so handlers use this variant to request a conservative repaint path. Resize, + /// Scheduled repaint request from application state changes. Draw, } @@ -355,6 +364,9 @@ impl Tui { self.frame_requester.clone() } + /// Requests that the next draw clear and invalidate the terminal viewport. + /// + /// This is used for resize handling when the cursor-position viewport heuristic is less trustworthy than the size event itself. It is a one-shot flag consumed by [`Tui::draw`]; callers should set it immediately before the draw that needs the conservative repaint. pub(crate) fn force_full_repaint(&mut self) { self.force_full_repaint = true; } @@ -602,9 +614,9 @@ impl Tui { self.force_full_repaint = false; // Precompute any viewport updates that need a cursor-position query before entering - // the synchronized update, to avoid racing with the event reader. Explicit resize - // events skip this heuristic because xterm.js can report stale cursor positions while - // a blurred split pane is being resized rapidly. + // the synchronized update, to avoid racing with the event reader. Forced repaint + // callers skip this heuristic because some terminal hosts can report stale cursor + // positions while a blurred split pane is being resized rapidly. let mut pending_viewport_area = if force_full_repaint { None } else { diff --git a/codex-rs/tui/src/tui/event_stream.rs b/codex-rs/tui/src/tui/event_stream.rs index 7688a75e75d..f90d8d5bc87 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -16,6 +16,10 @@ //! from stdin, so the safer approach is to drop and recreate the event stream when we need to hand off the terminal. //! //! See https://ratatui.rs/recipes/apps/spawn-vim/ and https://www.reddit.com/r/rust/comments/1f3o33u/myterious_crossterm_input_after_running_vim for more details. +//! +//! Resize events are kept distinct from scheduled draw events because resize handling carries an additional repaint contract. A draw event means some application state may need another frame, while a resize event means the terminal geometry may have changed underneath ratatui's cached viewport state and the renderer may need to invalidate more aggressively. +//! +//! Superset's xterm.js host can miss the normal crossterm resize event when a split pane is resized while the terminal is blurred. The resize watchdog below is intentionally scoped to Superset detection and Superset environment markers; it only synthesizes a [`TuiEvent::Resize`] when the reported terminal size changes. It does not attempt to reflow transcript content, repair scrollback, or make xterm.js generally repaint. Those responsibilities stay in the TUI draw path. use std::pin::Pin; use std::sync::Arc; @@ -46,14 +50,21 @@ const SUPERSET_RESIZE_WATCHDOG_INTERVAL: Duration = Duration::from_millis(100); /// Result type produced by an event source. pub type EventResult = std::io::Result; -/// Abstraction over a source of terminal events. Allows swapping in a fake for tests. -/// Value in production is [`CrosstermEventSource`]. +/// Abstraction over a source of terminal events. +/// +/// The production implementation is [`CrosstermEventSource`]. Tests use this trait to inject a deterministic event source while still exercising the same broker, mapping, pause/resume, and resize watchdog behavior as the real stream. pub trait EventSource: Send + 'static { fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>; } +/// Shared terminal size reader used by resize watchdogs. +/// +/// The indirection keeps production reads wired to `crossterm::terminal::size` while allowing tests to model missed resize notifications without relying on the process terminal. type TerminalSizeReader = Arc std::io::Result<(u16, u16)> + Send + Sync>; +/// Polling fallback for terminals that can miss resize events. +/// +/// The watchdog is deliberately small: it remembers the last observed `(cols, rows)` and reports a change only after it has a seeded size. Callers must update it from real resize events with [`ResizeWatchdog::set_observed_size`] so the fallback does not emit duplicate synthetic resize events for sizes crossterm already delivered. struct ResizeWatchdog { interval: Interval, last_size: Option<(u16, u16)>, @@ -61,6 +72,9 @@ struct ResizeWatchdog { } impl ResizeWatchdog { + /// Creates a watchdog with an injectable interval and size reader. + /// + /// Tests use this constructor to control time and terminal size. Production callers should use a terminal-specific constructor such as [`ResizeWatchdog::superset`] so the polling cadence stays tied to the terminal behavior that required the fallback. fn new(interval_duration: Duration, read_size: TerminalSizeReader) -> Self { let mut interval = tokio::time::interval(interval_duration); interval.set_missed_tick_behavior(MissedTickBehavior::Skip); @@ -71,14 +85,23 @@ impl ResizeWatchdog { } } + /// Creates the Superset resize watchdog. + /// + /// This is only appropriate after Superset detection has already passed. Enabling it for every terminal would turn every event stream poll into periodic terminal size I/O and could add unnecessary resize churn to terminals that already deliver reliable resize events. fn superset() -> Self { Self::new(SUPERSET_RESIZE_WATCHDOG_INTERVAL, Arc::new(terminal::size)) } + /// Updates the watchdog with a resize event the terminal delivered normally. + /// + /// This keeps a later watchdog tick from rediscovering the same size and emitting a redundant synthetic resize event. fn set_observed_size(&mut self, size: (u16, u16)) { self.last_size = Some(size); } + /// Samples the current terminal size and returns whether it changed from the previous sample. + /// + /// The first successful sample only seeds state. Read errors are treated as no change because callers cannot distinguish an unavailable size query from a real stable size, and emitting a resize on error would produce repaint noise without geometry evidence. fn observe_current_size(&mut self) -> bool { let Ok(size) = (self.read_size)() else { return false; @@ -90,6 +113,9 @@ impl ResizeWatchdog { } } +/// Returns whether the Superset resize watchdog should be enabled. +/// +/// The gate accepts either explicit `TERM_PROGRAM=Superset` detection or the pair of Superset workspace environment markers. A caller that widens this gate should also update the tests and document the terminal-specific failure mode, otherwise reliable terminals may inherit a workaround they do not need. fn should_enable_superset_resize_watchdog( terminal_info: &TerminalInfo, has_superset_env_markers: bool, @@ -97,6 +123,9 @@ fn should_enable_superset_resize_watchdog( terminal_info.name == TerminalName::Superset || has_superset_env_markers } +/// Returns whether the process environment contains Superset terminal markers. +/// +/// Superset can embed an xterm.js terminal without exposing a unique `TERM_PROGRAM`, so the marker pair provides a fallback signal for the same resize behavior. Requiring both values keeps unrelated processes from opting into the watchdog after exporting only one similarly named variable. fn has_superset_env_markers() -> bool { std::env::var_os("SUPERSET_TERMINAL_ID").is_some() && std::env::var_os("SUPERSET_WORKSPACE_ID").is_some()