mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user