From b368aa722f7fa9a7c7df7be7a65dfb7d4f044c6a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 15 Apr 2026 09:56:22 -0300 Subject: [PATCH] fix(tui): force repaint on terminal resize Handle terminal resize as a distinct TUI event so resize-driven renders can clear and repaint the viewport instead of relying on the normal diff path. This avoids stale xterm.js rendering when blurred split panes resize quickly, where focus later repaired the duplicated composer state. --- codex-rs/tui/src/app.rs | 8 ++++-- codex-rs/tui/src/app_backtrack.rs | 2 +- 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 | 25 +++++++++++++++++-- codex-rs/tui/src/tui/event_stream.rs | 13 +++++++++- codex-rs/tui/src/update_prompt.rs | 2 +- 10 files changed, 49 insertions(+), 13 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 066a428d6d..5d591dbeea 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4219,7 +4219,11 @@ impl App { app_server: &mut AppServerSession, event: TuiEvent, ) -> Result { - if matches!(event, TuiEvent::Draw) { + let is_resize = matches!(event, TuiEvent::Resize); + if is_resize { + tui.force_full_repaint(); + } + if matches!(event, TuiEvent::Draw | TuiEvent::Resize) { self.handle_draw_pre_render(tui)?; } @@ -4238,7 +4242,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 2852fbc379..09029e1673 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/cwd_prompt.rs b/codex-rs/tui/src/cwd_prompt.rs index 0dace9c7b6..264fa39c79 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 1b2de5ecfd..c307abb78f 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 ae74691f0d..0353092ed9 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 e00e45c5c0..9fe0e3916e 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -743,7 +743,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); })?; @@ -807,7 +807,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 53f33bfb0e..4ab7c5e61a 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 5e7f1bef25..c3c1845f03 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, } @@ -329,6 +331,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 +354,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 } @@ -591,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)] @@ -615,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 2ce0aa7d2c..dcc6e17e0e 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -244,7 +244,7 @@ impl TuiEventStream { } Some(TuiEvent::Key(key_event)) } - Event::Resize(_, _) => Some(TuiEvent::Draw), + Event::Resize(_, _) => Some(TuiEvent::Resize), Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)), Event::FocusGained => { self.terminal_focused.store(true, Ordering::Relaxed); @@ -451,6 +451,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 43ee0dbd40..c8b567a8c5 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()); })?;