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
This commit is contained in:
Matthew Ma
2026-10-30 06:54:13 +00:00
committed by copyberry
parent d7b77a5e74
commit 5e769a1cda
13 changed files with 163 additions and 24 deletions
+1 -1
View File
@@ -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
+13 -1
View File
@@ -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::<history_cell::ClearScreenHeader>());
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();
+1 -2
View File
@@ -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;
+7
View File
@@ -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::<history_cell::ClearScreenHeader>())
}
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::<history_cell::ClearScreenHeader>() {
break;
}
if row_cap.is_some_and(|max_rows| rendered_rows > max_rows) {
history_was_truncated = true;
break;
+72 -15
View File
@@ -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::<history_cell::SessionHeaderHistoryCell>()
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::<Vec<_>>()
.join("\n");
assert!(replay.contains("New exchange"));
assert!(!replay.contains("Earlier exchange"));
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"draft survives"
@@ -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))
+5
View File
@@ -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(),
@@ -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::<history_cell::SessionHeaderHistoryCell>()
|| cell.as_any().is::<history_cell::ClearScreenHeader>()
|| cell.as_any().is::<history_cell::SessionInfoCell>()
|| cell.as_any().is::<history_cell::StartupWarningsCell>()
|| cell.as_any().is::<history_cell::DeprecationNoticeCell>()
+23
View File
@@ -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<Line<'static>> {
self.0.display_lines(width)
}
fn live_raw_lines(&self) -> Vec<Line<'static>> {
self.0.raw_lines()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
Vec::new()
}
fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
Vec::new()
}
}
/// Session banner with a model label already resolved for presentation by its caller.
#[derive(Debug)]
pub(crate) struct SessionHeaderHistoryCell {
@@ -0,0 +1,8 @@
---
source: tui/src/app/tests.rs
expression: "transcript_composer::buffer_text(&buffer).trim()"
---
› Earlier exchange
New exchange
+22 -2
View File
@@ -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<dyn HistoryCell>]) {
if !self.detailed
&& !self.is_select_mode()
&& let Some(index) = cells
.iter()
.rposition(|cell| cell.as_any().is::<crate::history_cell::ClearScreenHeader>())
{
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::<crate::history_cell::ClearScreenHeader>()
})
{
return (index, 0);
}
remaining -= row;
index -= 1;
row = self
@@ -19,6 +19,7 @@ pub(super) fn line(
cells: &[Arc<dyn HistoryCell>],
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::<crate::history_cell::ClearScreenHeader>()
})
.find_map(|(index, cell)| {
cell.as_any()
.downcast_ref::<UserHistoryCell>()
@@ -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());
}