diff --git a/codex-rs/tui/src/app/history_pagination.rs b/codex-rs/tui/src/app/history_pagination.rs index f7210795b2..6ba490ca89 100644 --- a/codex-rs/tui/src/app/history_pagination.rs +++ b/codex-rs/tui/src/app/history_pagination.rs @@ -292,7 +292,7 @@ impl App { .complete_select_beginning(&self.transcript_cells); } else { self.transcript_view - .jump_to_entry(&self.transcript_cells, /*index*/ 0); + .jump_to_beginning(&self.transcript_cells); } } if self.scrollback_has_older_history diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index ca29929c13..b7c5f08c78 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -325,6 +325,14 @@ impl App { self.clear_ui_header_lines_with_version(width, CODEX_CLI_VERSION) } + pub(super) fn start_cleared_display(&mut self, tui: &mut tui::Tui) { + self.reset_terminal_display_state(); + self.transcript_cells + .retain(|cell| !cell.as_any().is::()); + let header = history_cell::ClearScreenHeader(self.clear_ui_header_cell(CODEX_CLI_VERSION)); + self.insert_history_cell(tui, Box::new(header)); + } + pub(super) fn queue_clear_ui_header(&mut self, tui: &mut tui::Tui) { if tui.is_owned_screen() { if !self.transcript_cells.iter().any(|cell| { @@ -389,8 +397,12 @@ impl App { } pub(super) fn reset_transcript_state_after_clear(&mut self) { - self.overlay = None; self.transcript_cells.clear(); + self.reset_terminal_display_state(); + } + + fn reset_terminal_display_state(&mut self) { + self.overlay = None; self.turn_tips.dismiss(); self.chat_widget.warning_display_state.dismissed.clear(); self.chat_widget.warning_display_state.transcript = Arc::default(); diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index e824cf9943..6a08b748c9 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -787,8 +787,7 @@ impl App { self.chat_widget .add_error_message(format!("Failed to clear terminal UI: {err}")); } else { - self.reset_app_ui_state_after_clear(); - self.queue_clear_ui_header(tui); + self.start_cleared_display(tui); tui.frame_requester().schedule_frame(); } return true; diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 632ac8b4de..adac5fd262 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -580,6 +580,10 @@ impl App { && self .resize_reflow_max_rows() .is_some_and(|max_rows| rendered_rows < max_rows) + && !self + .transcript_cells + .iter() + .any(|cell| cell.as_any().is::()) } fn request_scrollback_history_top_up(&self, rendered_rows: usize) { @@ -671,6 +675,9 @@ impl App { is_stream_continuation: cell.is_stream_continuation(), }); + if cell.as_any().is::() { + break; + } if row_cap.is_some_and(|max_rows| rendered_rows > max_rows) { history_was_truncated = true; break; diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index b459395737..24b616d56f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -6351,7 +6351,7 @@ async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() { } #[tokio::test] -async fn ctrl_l_clears_owned_history_and_preserves_the_draft() -> Result<()> { +async fn ctrl_l_preserves_the_transcript_and_draft_after_clearing_the_screen() -> Result<()> { let (mut app, _codex_home, thread_id) = pagination_completion_tests::completed_history_app(&["Oldest", "Newest"]).await?; let mut tui = crate::tui::test_support::make_test_tui()?; @@ -6381,8 +6381,11 @@ async fn ctrl_l_clears_owned_history_and_preserves_the_draft() -> Result<()> { .await?; app.chat_widget.set_model("gpt-test"); app.chat_widget.insert_str("draft survives"); - app.transcript_cells = vec![Arc::new(history_cell::PlainHistoryCell::new( - (0..50).map(|_| "old transcript row".into()).collect(), + app.transcript_cells = vec![Arc::new(history_cell::new_user_prompt( + "Earlier exchange".into(), + Vec::new(), + Vec::new(), + Vec::new(), ))]; tui.set_owned_screen(/*owned*/ true)?; let size = ratatui::layout::Size::new(/*width*/ 80, /*height*/ 12); @@ -6394,22 +6397,76 @@ async fn ctrl_l_clears_owned_history_and_preserves_the_draft() -> Result<()> { ) .await?; app.render_owned_transcript(&mut tui, size)?; - app.handle_older_history_page(&mut tui, &mut app_server, thread_id, &cursor, Ok(page)) - .await?; + app.handle_older_history_page( + &mut tui, + &mut app_server, + thread_id, + &cursor, + Ok(page.clone()), + ) + .await?; assert!(app_server.has_older_history(thread_id)); app.request_owned_history(&mut tui, &mut app_server); assert!(!app_server.is_older_history_page_pending(thread_id, &cursor)); - assert_eq!(app.transcript_cells.len(), 1); - assert!( - app.transcript_cells[0] - .as_any() - .is::() + app.insert_history_cell( + &mut tui, + Box::new(history_cell::PlainHistoryCell::new(vec![ + "New exchange".into(), + ])), ); - let header = lines_to_single_string(&app.transcript_cells[0].display_lines(/*width*/ 80)); - assert!(header.contains("OpenAI Codex")); - let raw_header = lines_to_single_string(&app.transcript_cells[0].raw_lines()); - assert!(raw_header.contains("gpt-test")); - assert!(!header.contains("old transcript row")); + let size = Size::new(/*width*/ 64, /*height*/ 16); + tui.screen_size_for_event(&TuiEvent::Resize(size))?; + app.render_owned_transcript(&mut tui, size)?; + let compact = transcript_composer::buffer_text( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal), + ); + assert!(compact.contains("OpenAI Codex")); + assert!(compact.contains("New exchange")); + assert!(!compact.contains("Earlier exchange")); + let ctrl_t = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL); + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(ctrl_t)) + .await?; + let area = Rect::new(/*x*/ 0, /*y*/ 0, size.width, size.height); + let mut buffer = ratatui::buffer::Buffer::empty(area); + app.transcript_view + .render(area, &mut buffer, &app.transcript_cells); + assert_app_snapshot!( + "ctrl_l_detailed_transcript", + transcript_composer::buffer_text(&buffer).trim() + ); + + let home = KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL); + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(home)) + .await?; + assert_eq!( + app.transcript_view.history, + crate::pager_overlay::TranscriptHistoryState::LoadingBeginning + ); + assert!(app_server.is_older_history_page_pending(thread_id, &cursor)); + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(ctrl_t)) + .await?; + app.handle_older_history_page(&mut tui, &mut app_server, thread_id, &cursor, Ok(page)) + .await?; + for event in [TuiEvent::Draw, TuiEvent::Key(home)] { + app.handle_tui_event(&mut tui, &mut app_server, event) + .await?; + app.render_owned_transcript(&mut tui, size)?; + let compact = transcript_composer::buffer_text( + crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal), + ); + assert!(!compact.contains("Earlier exchange")); + } + // Native scrollback replay uses a separate renderer when the terminal changes width. + app.local_settings.tui.terminal_resize_reflow_max_rows = None; + let replay = app + .render_transcript_lines_for_reflow(size.width) + .lines + .iter() + .map(rendered_line_text) + .collect::>() + .join("\n"); + assert!(replay.contains("New exchange")); + assert!(!replay.contains("Earlier exchange")); assert_eq!( app.chat_widget.composer_text_with_pending(), "draft survives" diff --git a/codex-rs/tui/src/app/tests/transcript_composer.rs b/codex-rs/tui/src/app/tests/transcript_composer.rs index b1e9e6635d..846034a10f 100644 --- a/codex-rs/tui/src/app/tests/transcript_composer.rs +++ b/codex-rs/tui/src/app/tests/transcript_composer.rs @@ -11,7 +11,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; -fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String { +pub(super) fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String { buffer .content() .chunks(usize::from(buffer.area.width)) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 691136baaf..03ede03823 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -192,6 +192,11 @@ impl App { /// Show detailed history in the owned viewport or the inline session's transcript overlay. pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { if tui.is_owned_screen() { + if self.scrollback_has_older_history + && !self.transcript_view.history.has_unloaded_history() + { + self.transcript_view.history = TranscriptHistoryState::Partial; + } self.transcript_view.set_presentation( /*detailed*/ true, self.chat_widget.history_render_mode(), diff --git a/codex-rs/tui/src/empty_state_animation/policy.rs b/codex-rs/tui/src/empty_state_animation/policy.rs index 2a02496079..98cd35d62f 100644 --- a/codex-rs/tui/src/empty_state_animation/policy.rs +++ b/codex-rs/tui/src/empty_state_animation/policy.rs @@ -14,6 +14,7 @@ pub(crate) enum Presentation { /// Startup metadata is the entire allowlist; new content types opt out by default. pub(crate) fn is_startup_cell(cell: &dyn HistoryCell) -> bool { cell.as_any().is::() + || cell.as_any().is::() || cell.as_any().is::() || cell.as_any().is::() || cell.as_any().is::() diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs index b0a53d3358..120454d1ea 100644 --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs @@ -290,6 +290,29 @@ pub(crate) fn has_yolo_permissions( } ) } +/// The fresh terminal header marks the start of visible scrollback after Ctrl+L. +/// It has no transcript representation because the original session header is still retained. +#[derive(Debug)] +pub(crate) struct ClearScreenHeader(pub(crate) SessionHeaderHistoryCell); + +impl HistoryCell for ClearScreenHeader { + fn display_lines(&self, width: u16) -> Vec> { + self.0.display_lines(width) + } + + fn live_raw_lines(&self) -> Vec> { + self.0.raw_lines() + } + + fn raw_lines(&self) -> Vec> { + Vec::new() + } + + fn transcript_lines(&self, _width: u16) -> Vec> { + Vec::new() + } +} + /// Session banner with a model label already resolved for presentation by its caller. #[derive(Debug)] pub(crate) struct SessionHeaderHistoryCell { diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__ctrl_l_detailed_transcript.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__ctrl_l_detailed_transcript.snap new file mode 100644 index 0000000000..4a437a2724 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__ctrl_l_detailed_transcript.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/tests.rs +expression: "transcript_composer::buffer_text(&buffer).trim()" +--- +› Earlier exchange + + +New exchange diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs index ff7b669965..0f895886a6 100644 --- a/codex-rs/tui/src/transcript_view.rs +++ b/codex-rs/tui/src/transcript_view.rs @@ -219,7 +219,9 @@ impl TranscriptView { { // Freezing follow must preserve a header that yielded to the next visible prompt. self.suppressed_prompt_header = Some(header_position); - } else if area.height >= 4 && prompt_header::line(cells, start.0, area.width).is_some() { + } else if area.height >= 4 + && prompt_header::line(cells, start.0, area.width, self.detailed).is_some() + { body = Rect::new(area.x, area.y + 1, area.width, area.height - 1); let resized = resized || previous_height != body.height; self.area = body; @@ -229,7 +231,9 @@ impl TranscriptView { } // Following may advance into the next turn after reserving the header row. start = self.start(cells); - if let Some((header, key)) = prompt_header::line(cells, start.0, area.width) { + if let Some((header, key)) = + prompt_header::line(cells, start.0, area.width, self.detailed) + { let header_area = Rect::new(area.x, area.y, area.width, /*height*/ 1); header.render(header_area, buf); self.prompt_header_target = Some((header_area, key)); @@ -433,6 +437,15 @@ impl TranscriptView { /// Hold the current reading position until every older page has arrived. pub(crate) fn jump_to_beginning(&mut self, cells: &[Arc]) { + if !self.detailed + && !self.is_select_mode() + && let Some(index) = cells + .iter() + .rposition(|cell| cell.as_any().is::()) + { + self.jump_to_entry(cells, index); + return; + } if self.history == TranscriptHistoryState::LoadingBeginning { return; } @@ -631,6 +644,13 @@ impl TranscriptView { if rows < 0 { let mut remaining = rows.unsigned_abs(); while remaining > row && index > 0 { + if !self.detailed + && cells.get(index).is_some_and(|cell| { + cell.as_any().is::() + }) + { + return (index, 0); + } remaining -= row; index -= 1; row = self diff --git a/codex-rs/tui/src/transcript_view/prompt_header.rs b/codex-rs/tui/src/transcript_view/prompt_header.rs index fe7d17fa8e..628d3577ce 100644 --- a/codex-rs/tui/src/transcript_view/prompt_header.rs +++ b/codex-rs/tui/src/transcript_view/prompt_header.rs @@ -19,6 +19,7 @@ pub(super) fn line( cells: &[Arc], first: usize, width: u16, + detailed: bool, ) -> Option<(Line<'static>, EntryKey)> { // A prompt that is still visible supplies its own context. Only pin a preceding prompt. if width < 16 @@ -29,10 +30,13 @@ pub(super) fn line( { return None; } - let (index, prompt) = cells[..first.min(cells.len())] + let (index, prompt) = cells[..first.saturating_add(1).min(cells.len())] .iter() .enumerate() .rev() + .take_while(|(_, cell)| { + detailed || !cell.as_any().is::() + }) .find_map(|(index, cell)| { cell.as_any() .downcast_ref::() diff --git a/codex-rs/tui/src/transcript_view/prompt_header_tests.rs b/codex-rs/tui/src/transcript_view/prompt_header_tests.rs index 66be2e2435..2260129c7c 100644 --- a/codex-rs/tui/src/transcript_view/prompt_header_tests.rs +++ b/codex-rs/tui/src/transcript_view/prompt_header_tests.rs @@ -91,7 +91,10 @@ fn prompt_header_tracks_reading_and_yields_to_visible_prompts_and_short_screens( #[test] fn header_sanitizes_controls_and_truncates_by_display_width() { let cells = vec![user("\x1b[31m界 café\r\nnext question"), cell("answer")]; - let (line, _) = line(&cells, /*first*/ 1, /*width*/ 18).unwrap(); + let (line, _) = line( + &cells, /*first*/ 1, /*width*/ 18, /*detailed*/ false, + ) + .unwrap(); assert!(line.width() <= 18); insta::assert_snapshot!(line.to_string()); }