From 5e769a1cdae3fd93efd5e7b9df71e39efb2223ab Mon Sep 17 00:00:00 2001 From: Matthew Ma Date: Fri, 30 Oct 2026 06:46:15 +0000 Subject: [PATCH] Preserve transcript history when clearing the terminal with Ctrl+L (#58266) ## Why `Ctrl+L` discarded the retained transcript along with the terminal display, making earlier exchanges unavailable in the detailed transcript. ## What changed Keep earlier exchanges accessible through `Ctrl+T` while starting a fresh terminal display with a new header. Treat that header as the boundary for compact scrolling, pinned prompts, and resize replay so cleared content stays hidden in the normal view. Allow the detailed transcript to load older history after clearing. ## Testing Extend the `Ctrl+L` regression test and add a detailed-transcript snapshot covering retained exchanges, draft preservation, older-history loading, and cleared-content visibility after resizing and replay. GitOrigin-RevId: f1eb73e903a7957cedafb52678c607858fb2d77f --- codex-rs/tui/src/app/history_pagination.rs | 2 +- codex-rs/tui/src/app/history_ui.rs | 14 ++- codex-rs/tui/src/app/input.rs | 3 +- codex-rs/tui/src/app/resize_reflow.rs | 7 ++ codex-rs/tui/src/app/tests.rs | 87 +++++++++++++++---- .../tui/src/app/tests/transcript_composer.rs | 2 +- codex-rs/tui/src/app_backtrack.rs | 5 ++ .../tui/src/empty_state_animation/policy.rs | 1 + codex-rs/tui/src/history_cell/session.rs | 23 +++++ ...pp__tests__ctrl_l_detailed_transcript.snap | 8 ++ codex-rs/tui/src/transcript_view.rs | 24 ++++- .../tui/src/transcript_view/prompt_header.rs | 6 +- .../transcript_view/prompt_header_tests.rs | 5 +- 13 files changed, 163 insertions(+), 24 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__tests__ctrl_l_detailed_transcript.snap 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()); }