diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index fd5efd2d94..7d00f7f5ff 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -144,17 +144,14 @@ impl App { return; }; + if buffer.render_from_transcript_tail || self.overlay.is_some() { + // Reflow clears any pre-replay or partially emitted history and applies the reserved + // history width. It also waits for an active overlay to close before rebuilding. + self.schedule_immediate_resize_reflow(tui); + return; + } + if buffer.retained_lines.is_empty() { - if buffer.render_from_transcript_tail { - let width = tui.terminal.last_known_screen_size.width; - let reflowed_lines = self.render_transcript_lines_for_reflow(width).lines; - if !reflowed_lines.is_empty() { - tui.insert_history_hyperlink_lines_with_wrap_policy( - reflowed_lines, - self.history_line_wrap_policy(), - ); - } - } return; } @@ -274,6 +271,19 @@ impl App { /// replaced as one styled source-backed cell. If this reflow is skipped after a stream-time /// resize, the visible scrollback can keep the pre-consolidation wrapping. pub(super) fn finish_required_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> { + // Capped initial replay normally buffers per-cell display rows. A live stream tail is + // consolidated directly into `transcript_cells`, so any retained rows no longer describe + // the canonical transcript. Let the replay-end event render the capped transcript tail + // once all consolidation events have been processed. + if self.resize_reflow_max_rows().is_some() + && let Some(buffer) = self.initial_history_replay_buffer.as_mut() + { + buffer.retained_lines.clear(); + buffer.render_from_transcript_tail = true; + self.transcript_reflow.clear_stream_flags(); + return Ok(()); + } + self.schedule_immediate_resize_reflow(tui); self.maybe_run_resize_reflow(tui)?; if !self.transcript_reflow.has_pending_reflow() { diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__required_stream_reflow_during_capped_initial_replay.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__required_stream_reflow_during_capped_initial_replay.snap new file mode 100644 index 0000000000..acd26d1b97 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__required_stream_reflow_during_capped_initial_replay.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests.rs +expression: "rendered.lines.iter().map(rendered_line_text).collect::>().join(\"\\n\")" +--- +latest user question + +• Final answer: + + Pattern Outcome + ━━━━━━━━━━━━ ━━━━━━━━━━━ + Table tail Preserved diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay.snap new file mode 100644 index 0000000000..acd26d1b97 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests.rs +expression: "rendered.lines.iter().map(rendered_line_text).collect::>().join(\"\\n\")" +--- +latest user question + +• Final answer: + + Pattern Outcome + ━━━━━━━━━━━━ ━━━━━━━━━━━ + Table tail Preserved diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 04c2e8505b..51ecd0106a 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -31,6 +31,7 @@ use crate::multi_agents::SubAgentActivityDisplay; use assert_matches::assert_matches; use crate::app_command::AppCommand as Op; +use crate::app_event::ConsolidationScrollbackReflow; use crate::diff_model::FileChange; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; @@ -4561,6 +4562,119 @@ async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() { ); } +#[tokio::test] +async fn required_stream_reflow_during_capped_initial_replay_uses_transcript_tail() -> Result<()> { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(20); + app.transcript_cells = vec![ + plain_line_cell("latest user question"), + Arc::new(AgentMarkdownCell::new( + "Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |" + .to_string(), + Path::new("/tmp"), + )), + ]; + + app.begin_initial_history_replay_buffer(); + App::buffer_initial_history_replay_display_lines( + app.initial_history_replay_buffer + .as_mut() + .expect("initial replay buffer active"), + vec![Line::from("latest user question").into()], + /*max_rows*/ 20, + ); + + let mut tui = crate::tui::test_support::make_test_tui()?; + app.finish_required_stream_reflow(&mut tui)?; + + let buffer = app + .initial_history_replay_buffer + .as_ref() + .expect("initial replay buffer should remain active"); + assert_eq!( + ( + buffer.retained_lines.len(), + buffer.render_from_transcript_tail + ), + (0, true), + ); + + let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); + assert_snapshot!( + "required_stream_reflow_during_capped_initial_replay", + rendered + .lines + .iter() + .map(rendered_line_text) + .collect::>() + .join("\n") + ); + + app.finish_initial_history_replay_buffer(&mut tui); + assert!(app.initial_history_replay_buffer.is_none()); + assert!(app.transcript_reflow.has_pending_reflow()); + Ok(()) +} + +#[tokio::test] +async fn required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay() +-> Result<()> { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(7); + app.transcript_cells = vec![ + plain_line_cell("latest user question"), + Arc::new(AgentMessageCell::new( + vec![Line::from("stale streamed table tail")], + /*is_first_line*/ true, + )), + ]; + + app.begin_initial_history_replay_buffer(); + App::buffer_initial_history_replay_display_lines( + app.initial_history_replay_buffer + .as_mut() + .expect("initial replay buffer active"), + vec![Line::from("stale streamed table tail").into()], + /*max_rows*/ 7, + ); + + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_consolidate_agent_message( + &mut tui, + "Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |" + .to_string(), + PathBuf::from("/tmp"), + ConsolidationScrollbackReflow::Required, + /*deferred_history_cell*/ None, + )?; + app.open_transcript_overlay(&mut tui); + assert!(tui.is_alt_screen_active()); + + app.finish_initial_history_replay_buffer(&mut tui); + assert!(app.initial_history_replay_buffer.is_none()); + assert!(app.transcript_reflow.has_pending_reflow()); + + app.maybe_run_resize_reflow(&mut tui)?; + assert!(app.transcript_reflow.has_pending_reflow()); + + app.close_transcript_overlay(&mut tui); + assert!(!tui.is_alt_screen_active()); + assert!(app.transcript_reflow.has_pending_reflow()); + + let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); + assert_eq!(rendered.lines.len(), 7); + assert_snapshot!( + "required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay", + rendered + .lines + .iter() + .map(rendered_line_text) + .collect::>() + .join("\n") + ); + Ok(()) +} + #[tokio::test] async fn thread_switch_replay_buffer_uses_transcript_tail_mode_when_row_cap_present() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;