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()); })?;