diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index da1f82e62..2afcaa3b5 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -10,7 +10,8 @@ //! //! Backtrack operates as a small state machine: //! - The first `Esc` in the main view "primes" the feature and captures a base thread id. -//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message. +//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message when +//! there is a rewind target. //! - `Enter` requests a rollback from core and records a `pending_rollback` guard. //! - On `EventMsg::ThreadRolledBack`, we either finish an in-flight backtrack request or queue a //! rollback trim so it runs in event order with transcript inserts. @@ -44,6 +45,8 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +const NO_PREVIOUS_MESSAGE_TO_EDIT: &str = "No previous message to edit."; + /// Aggregates all backtrack-related state used by the App. #[derive(Default)] pub(crate) struct BacktrackState { @@ -266,11 +269,21 @@ impl App { self.backtrack.primed = true; self.backtrack.nth_user_message = usize::MAX; self.backtrack.base_id = self.chat_widget.thread_id(); - self.chat_widget.show_esc_backtrack_hint(); + if has_backtrack_target(&self.transcript_cells) { + self.chat_widget.show_esc_backtrack_hint(); + } } /// Open overlay and begin backtrack preview flow (first step + highlight). fn open_backtrack_preview(&mut self, tui: &mut tui::Tui) { + if !has_backtrack_target(&self.transcript_cells) { + self.reset_backtrack_state(); + self.chat_widget + .add_info_message(NO_PREVIOUS_MESSAGE_TO_EDIT.to_string(), /*hint*/ None); + tui.frame_requester().schedule_frame(); + return; + } + self.open_transcript_overlay(tui); self.backtrack.overlay_preview_active = true; // Composer is hidden by overlay; clear its hint. @@ -280,6 +293,14 @@ impl App { /// When overlay is already open, begin preview mode and select latest user message. fn begin_overlay_backtrack_preview(&mut self, tui: &mut tui::Tui) { + if !has_backtrack_target(&self.transcript_cells) { + self.close_transcript_overlay(tui); + self.chat_widget + .add_info_message(NO_PREVIOUS_MESSAGE_TO_EDIT.to_string(), /*hint*/ None); + tui.frame_requester().schedule_frame(); + return; + } + self.backtrack.primed = true; self.backtrack.base_id = self.chat_widget.thread_id(); self.backtrack.overlay_preview_active = true; @@ -613,6 +634,10 @@ pub(crate) fn user_count(cells: &[Arc]) -> user_positions_iter(cells).count() } +fn has_backtrack_target(cells: &[Arc]) -> bool { + user_count(cells) > 0 +} + fn nth_user_position( cells: &[Arc], nth: usize, @@ -674,9 +699,22 @@ mod tests { use super::*; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; + use pretty_assertions::assert_eq; use ratatui::prelude::Line; use std::sync::Arc; + fn render_lines(lines: &[Line<'static>]) -> Vec { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect() + } + #[test] fn trim_transcript_for_first_user_drops_user_and_newer_cells() { let mut cells: Vec> = vec![ @@ -885,4 +923,40 @@ mod tests { assert_eq!(agent_group_count(&cells), 2); } + + #[test] + fn backtrack_target_requires_user_message() { + let mut cells: Vec> = vec![ + Arc::new(AgentMessageCell::new( + vec![Line::from("assistant")], + /*is_first_line*/ true, + )) as Arc, + Arc::new(crate::history_cell::new_info_event( + "Context compacted".to_string(), + /*hint*/ None, + )) as Arc, + ]; + + assert!(!has_backtrack_target(&cells)); + + cells.push(Arc::new(UserHistoryCell { + message: "hello".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc); + + assert!(has_backtrack_target(&cells)); + } + + #[test] + fn backtrack_unavailable_info_message_snapshot() { + let cell = crate::history_cell::new_info_event( + NO_PREVIOUS_MESSAGE_TO_EDIT.to_string(), + /*hint*/ None, + ); + let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n"); + + insta::assert_snapshot!(rendered); + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app_backtrack__tests__backtrack_unavailable_info_message_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__app_backtrack__tests__backtrack_unavailable_info_message_snapshot.snap new file mode 100644 index 000000000..8e896e7d7 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app_backtrack__tests__backtrack_unavailable_info_message_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app_backtrack.rs +expression: rendered +--- +• No previous message to edit.