From fe68d8a5d273d46ab68ba47b17b613ae0873d92a Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 23 Apr 2026 20:19:18 -0300 Subject: [PATCH] wip(tui): preserve resize reflow replay tail --- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 15 ++++++ .../src/chatwidget/tests/slash_commands.rs | 23 +++++++- codex-rs/tui/src/insert_history.rs | 53 +++++++++++++++++++ codex-rs/tui/src/tui.rs | 51 +++++++++++++++++- 4 files changed, 140 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 6e041b833e..d315c9de8b 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -285,6 +285,21 @@ impl ChatWidget { self.open_memories_popup(); } SlashCommand::Quit | SlashCommand::Exit => { + let command = if matches!(cmd, SlashCommand::Quit) { + "/quit" + } else { + "/exit" + }; + self.add_to_history(history_cell::new_user_prompt( + command.to_string(), + /*text_elements*/ Vec::new(), + /*local_image_paths*/ Vec::new(), + /*remote_image_urls*/ Vec::new(), + )); + self.add_to_history(history_cell::new_info_event( + "Exiting.".to_string(), + /*hint*/ None, + )); self.request_quit_without_confirmation(); } SlashCommand::Logout => { diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index d255f3e00f..38e04c77f2 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -784,7 +784,28 @@ async fn slash_quit_requests_exit() { chat.dispatch_command(SlashCommand::Quit); - assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); + let mut rendered = String::new(); + let mut saw_exit = false; + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::InsertHistoryCell(cell) => { + rendered.push_str(&lines_to_single_string(&cell.display_lines(/*width*/ 80))); + } + AppEvent::Exit(ExitMode::ShutdownFirst) => { + saw_exit = true; + } + event => panic!("unexpected event: {event:?}"), + } + } + assert!( + rendered.contains("/quit"), + "expected quit command in history, got: {rendered:?}" + ); + assert!( + rendered.contains("Exiting."), + "expected exiting notice in history, got: {rendered:?}" + ); + assert!(saw_exit, "expected shutdown-first exit event"); } #[tokio::test] diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 6ee6b15ba0..0fdfbd52b1 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -165,6 +165,21 @@ where } write_history_line(writer, line, wrap_width)?; } + + if matches!(mode, InsertHistoryMode::FullScreenReplayPrefill) { + let reserve_rows = wrapped_lines + .min(screen_size.height) + .saturating_sub(area.top()); + if reserve_rows > 0 { + queue!( + writer, + MoveTo(/*x*/ 0, screen_size.height.saturating_sub(1)) + )?; + for _ in 0..reserve_rows { + queue!(writer, Print("\n"))?; + } + } + } } InsertHistoryMode::FullScreenReplayDirect => { // Rebuild scrollback with transcript content itself. Pre-scrolling with blank @@ -897,4 +912,42 @@ mod tests { assert_eq!(term.viewport_area, viewport); assert_eq!(term.visible_history_rows(), viewport.top()); } + + #[test] + fn vt100_full_screen_replay_reserves_viewport_rows_for_tui() { + let width: u16 = 32; + let height: u16 = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(/*x*/ 0, /*y*/ 4, width, /*height*/ 3); + term.set_viewport_area(viewport); + + let lines: Vec> = (0..6) + .map(|idx| Line::from(format!("reflow line {idx:02}"))) + .collect(); + insert_history_lines_with_mode( + &mut term, + lines, + InsertHistoryMode::FullScreenReplayPrefill, + ) + .expect("insert reflow history"); + + let rows: Vec = term + .backend() + .vt100() + .screen() + .rows(/*start*/ 0, width) + .collect(); + assert!( + rows[viewport.top() as usize - 1].contains("reflow line 05"), + "expected replayed tail immediately above viewport, rows: {rows:?}" + ); + assert!( + rows[viewport.top() as usize..] + .iter() + .all(|row| !row.contains("reflow line")), + "expected replay not to occupy reserved viewport rows, rows: {rows:?}" + ); + assert_eq!(term.viewport_area, viewport); + } } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 4447ebd1e7..664c088e5b 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -175,8 +175,10 @@ fn should_emit_notification(condition: NotificationCondition, terminal_focused: #[cfg(test)] mod tests { + use super::ResizeReflowDrawRefresh; use super::keyboard_enhancement_disabled_for; use super::parse_bool_env; + use super::resize_reflow_draw_refresh; use super::should_emit_notification; use super::vscode_terminal_detected; use codex_config::types::NotificationCondition; @@ -266,6 +268,32 @@ mod tests { /*linux_term_program*/ None, /*windows_term_program*/ None )); } + + #[test] + fn resize_reflow_replay_invalidates_without_clearing_after_insert() { + assert_eq!( + resize_reflow_draw_refresh( + /*needs_full_repaint*/ true, /*flushed_reflow_history*/ true + ), + ResizeReflowDrawRefresh { + clear_after_history_flush: false, + invalidate_viewport: true, + } + ); + } + + #[test] + fn resize_reflow_draw_refresh_preserves_legacy_clear_without_replay() { + assert_eq!( + resize_reflow_draw_refresh( + /*needs_full_repaint*/ true, /*flushed_reflow_history*/ false + ), + ResizeReflowDrawRefresh { + clear_after_history_flush: true, + invalidate_viewport: true, + } + ); + } } pub fn set_modes() -> Result<()> { @@ -481,6 +509,24 @@ pub struct Tui { alt_screen_enabled: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ResizeReflowDrawRefresh { + clear_after_history_flush: bool, + invalidate_viewport: bool, +} + +fn resize_reflow_draw_refresh( + needs_full_repaint: bool, + flushed_reflow_history: bool, +) -> ResizeReflowDrawRefresh { + ResizeReflowDrawRefresh { + // Resize reflow clears the terminal before queueing rebuilt rows. Clearing again after + // replay can erase the source-backed tail rows that were just inserted. + clear_after_history_flush: needs_full_repaint && !flushed_reflow_history, + invalidate_viewport: needs_full_repaint, + } +} + impl Tui { pub fn new(terminal: Terminal) -> Self { let (draw_tx, _) = broadcast::channel(1); @@ -937,8 +983,11 @@ impl Tui { )?; needs_full_repaint |= flushed_reflow_history; - if needs_full_repaint { + let refresh = resize_reflow_draw_refresh(needs_full_repaint, flushed_reflow_history); + if refresh.clear_after_history_flush { terminal.clear()?; + } + if refresh.invalidate_viewport { terminal.invalidate_viewport(); }