From 3a300d11174fa6a1fa355f4da5510bbe02038468 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 13 Jan 2026 01:17:39 -0800 Subject: [PATCH] Use thread rollback for Esc backtrack (#9140) - Swap Esc backtrack to roll back the current thread instead of forking --- codex-rs/tui/src/app.rs | 55 ++++++--- codex-rs/tui/src/app_backtrack.rs | 178 ++++++++++------------------- codex-rs/tui/src/app_event.rs | 4 - codex-rs/tui2/src/app.rs | 54 ++++++--- codex-rs/tui2/src/app_backtrack.rs | 178 +++++++++-------------------- codex-rs/tui2/src/app_event.rs | 4 - 6 files changed, 196 insertions(+), 277 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b5041e988..f8d91eab5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -813,9 +813,6 @@ impl App { } self.chat_widget.handle_codex_event(event); } - AppEvent::ConversationHistory(ev) => { - self.on_conversation_history_for_backtrack(tui, ev).await?; - } AppEvent::ExitRequest => { return Ok(false); } @@ -1437,8 +1434,9 @@ impl App { && self.backtrack.nth_user_message != usize::MAX && self.chat_widget.composer_is_empty() => { - // Delegate to helper for clarity; preserves behavior. - self.confirm_backtrack_from_main(); + if let Some(selection) = self.confirm_backtrack_from_main() { + self.apply_backtrack_selection(tui, selection); + } } KeyEvent { kind: KeyEventKind::Press | KeyEventKind::Repeat, @@ -1509,6 +1507,7 @@ mod tests { use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::ThreadId; use insta::assert_snapshot; + use pretty_assertions::assert_eq; use ratatui::prelude::Line; use std::path::PathBuf; use std::sync::Arc; @@ -1775,7 +1774,7 @@ mod tests { #[tokio::test] async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { - let mut app = make_test_app().await; + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let user_cell = |text: &str| -> Arc { Arc::new(UserHistoryCell { @@ -1811,9 +1810,8 @@ mod tests { )) as Arc }; - // Simulate the transcript after trimming for a fork, replaying history, and - // appending the edited turn. The session header separates the retained history - // from the forked thread's replayed turns. + // Simulate a transcript with duplicated history (e.g., from prior backtracks) + // and an edited turn appended after a session header boundary. app.transcript_cells = vec![ make_header(true), user_cell("first question"), @@ -1829,15 +1827,44 @@ mod tests { assert_eq!(user_count(&app.transcript_cells), 2); - app.backtrack.base_id = Some(ThreadId::new()); + let base_id = ThreadId::new(); + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: base_id, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: PathBuf::new(), + }), + }); + + app.backtrack.base_id = Some(base_id); app.backtrack.primed = true; app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1); - app.confirm_backtrack_from_main(); + let selection = app + .confirm_backtrack_from_main() + .expect("backtrack selection"); + assert_eq!(selection.nth_user_message, 1); + assert_eq!(selection.prefill, "follow-up (edited)"); - let (_, nth, prefill) = app.backtrack.pending.clone().expect("pending backtrack"); - assert_eq!(nth, 1); - assert_eq!(prefill, "follow-up (edited)"); + app.apply_backtrack_rollback(selection); + + let mut rollback_turns = None; + while let Ok(op) = op_rx.try_recv() { + if let Op::ThreadRollback { num_turns } = op { + rollback_turns = Some(num_turns); + } + } + + assert_eq!(rollback_turns, Some(1)); } #[tokio::test] diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index cd38131d1..43dacb573 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -12,7 +12,6 @@ //! both committed history and in-flight activity without changing flush or coalescing behavior. use std::any::TypeId; -use std::path::PathBuf; use std::sync::Arc; use crate::app::App; @@ -21,7 +20,7 @@ use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; -use codex_core::protocol::ConversationPathResponseEvent; +use codex_core::protocol::Op; use codex_protocol::ThreadId; use color_eyre::eyre::Result; use crossterm::event::KeyCode; @@ -33,14 +32,18 @@ use crossterm::event::KeyEventKind; pub(crate) struct BacktrackState { /// True when Esc has primed backtrack mode in the main view. pub(crate) primed: bool, - /// Session id of the base thread to fork from. + /// Session id of the base thread to rollback. pub(crate) base_id: Option, /// Index in the transcript of the last user message. pub(crate) nth_user_message: usize, /// True when the transcript overlay is showing a backtrack preview. pub(crate) overlay_preview_active: bool, - /// Pending fork request: (base_id, nth_user_message, prefill). - pub(crate) pending: Option<(ThreadId, usize, String)>, +} + +#[derive(Debug, Clone)] +pub(crate) struct BacktrackSelection { + pub(crate) nth_user_message: usize, + pub(crate) prefill: String, } impl App { @@ -109,22 +112,22 @@ impl App { } /// Stage a backtrack and request thread history from the agent. - pub(crate) fn request_backtrack( - &mut self, - prefill: String, - base_id: ThreadId, - nth_user_message: usize, - ) { - self.backtrack.pending = Some((base_id, nth_user_message, prefill)); - if let Some(path) = self.chat_widget.rollout_path() { - let ev = ConversationPathResponseEvent { - conversation_id: base_id, - path, - }; - self.app_event_tx - .send(crate::app_event::AppEvent::ConversationHistory(ev)); - } else { - tracing::error!("rollout path unavailable; cannot backtrack"); + pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) { + let user_total = user_count(&self.transcript_cells); + if user_total == 0 { + return; + } + + let num_turns = user_total.saturating_sub(selection.nth_user_message); + let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX); + if num_turns == 0 { + return; + } + + self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); + self.trim_transcript_for_backtrack(selection.nth_user_message); + if !selection.prefill.is_empty() { + self.chat_widget.set_composer_text(selection.prefill); } } @@ -186,7 +189,7 @@ impl App { self.backtrack.overlay_preview_active = true; let count = user_count(&self.transcript_cells); if let Some(last) = count.checked_sub(1) { - self.apply_backtrack_selection(last); + self.apply_backtrack_selection_internal(last); } tui.frame_requester().schedule_frame(); } @@ -210,12 +213,12 @@ impl App { .min(last_index) }; - self.apply_backtrack_selection(next_selection); + self.apply_backtrack_selection_internal(next_selection); tui.frame_requester().schedule_frame(); } /// Apply a computed backtrack selection to the overlay and internal counter. - fn apply_backtrack_selection(&mut self, nth_user_message: usize) { + fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) { if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { self.backtrack.nth_user_message = nth_user_message; if let Some(Overlay::Transcript(t)) = &mut self.overlay { @@ -283,16 +286,13 @@ impl App { /// Handle Enter in overlay backtrack preview: confirm selection and reset state. fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) { let nth_user_message = self.backtrack.nth_user_message; - if let Some(base_id) = self.backtrack.base_id { - let prefill = nth_user_position(&self.transcript_cells, nth_user_message) - .and_then(|idx| self.transcript_cells.get(idx)) - .and_then(|cell| cell.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); - self.close_transcript_overlay(tui); - self.request_backtrack(prefill, base_id, nth_user_message); + let selection = self.backtrack_selection(nth_user_message); + self.close_transcript_overlay(tui); + if let Some(selection) = selection { + self.apply_backtrack_rollback(selection); + self.render_transcript_once(tui); + tui.frame_requester().schedule_frame(); } - self.reset_backtrack_state(); } /// Handle Esc in overlay backtrack preview: step selection if armed, else forward. @@ -306,18 +306,11 @@ impl App { } /// Confirm a primed backtrack from the main view (no overlay visible). - /// Computes the prefill from the selected user message and requests history. - pub(crate) fn confirm_backtrack_from_main(&mut self) { - if let Some(base_id) = self.backtrack.base_id { - let prefill = - nth_user_position(&self.transcript_cells, self.backtrack.nth_user_message) - .and_then(|idx| self.transcript_cells.get(idx)) - .and_then(|cell| cell.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); - self.request_backtrack(prefill, base_id, self.backtrack.nth_user_message); - } + /// Computes the prefill from the selected user message for rollback. + pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option { + let selection = self.backtrack_selection(self.backtrack.nth_user_message); self.reset_backtrack_state(); + selection } /// Clear all backtrack-related state and composer hints. @@ -329,89 +322,34 @@ impl App { self.chat_widget.clear_esc_backtrack_hint(); } - /// Handle a ConversationHistory response while a backtrack is pending. - /// If it matches the primed base session, fork and switch to the new conversation. - pub(crate) async fn on_conversation_history_for_backtrack( + pub(crate) fn apply_backtrack_selection( &mut self, tui: &mut tui::Tui, - ev: ConversationPathResponseEvent, - ) -> Result<()> { - if let Some((base_id, _, _)) = self.backtrack.pending.as_ref() - && ev.conversation_id == *base_id - && let Some((_, nth_user_message, prefill)) = self.backtrack.pending.take() - { - self.fork_and_switch_to_new_conversation(tui, ev, nth_user_message, prefill) - .await; - } - Ok(()) - } - - /// Fork the conversation using provided history and switch UI/state accordingly. - async fn fork_and_switch_to_new_conversation( - &mut self, - tui: &mut tui::Tui, - ev: ConversationPathResponseEvent, - nth_user_message: usize, - prefill: String, + selection: BacktrackSelection, ) { - let cfg = self.chat_widget.config_ref().clone(); - // Perform the fork via a thin wrapper for clarity/testability. - let result = self - .perform_fork(ev.path.clone(), nth_user_message, cfg.clone()) - .await; - match result { - Ok(new_conv) => { - self.install_forked_conversation(tui, cfg, new_conv, nth_user_message, &prefill) - } - Err(e) => tracing::error!("error forking conversation: {e:#}"), - } - } - - /// Thin wrapper around ThreadManager::fork_thread. - async fn perform_fork( - &self, - path: PathBuf, - nth_user_message: usize, - cfg: codex_core::config::Config, - ) -> codex_core::error::Result { - self.server.fork_thread(nth_user_message, cfg, path).await - } - - /// Install a forked thread into the ChatWidget and update UI to reflect selection. - fn install_forked_conversation( - &mut self, - tui: &mut tui::Tui, - cfg: codex_core::config::Config, - new_conv: codex_core::NewThread, - nth_user_message: usize, - prefill: &str, - ) { - let thread = new_conv.thread; - let session_configured = new_conv.session_configured; - let init = crate::chatwidget::ChatWidgetInit { - config: cfg, - model: self.current_model.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - }; - self.chat_widget = - crate::chatwidget::ChatWidget::new_from_existing(init, thread, session_configured); - // Trim transcript up to the selected user message and re-render it. - self.trim_transcript_for_backtrack(nth_user_message); + self.apply_backtrack_rollback(selection); self.render_transcript_once(tui); - if !prefill.is_empty() { - self.chat_widget.set_composer_text(prefill.to_string()); - } tui.frame_requester().schedule_frame(); } + fn backtrack_selection(&self, nth_user_message: usize) -> Option { + let base_id = self.backtrack.base_id?; + if self.chat_widget.thread_id() != Some(base_id) { + return None; + } + + let prefill = nth_user_position(&self.transcript_cells, nth_user_message) + .and_then(|idx| self.transcript_cells.get(idx)) + .and_then(|cell| cell.as_any().downcast_ref::()) + .map(|c| c.message.clone()) + .unwrap_or_default(); + + Some(BacktrackSelection { + nth_user_message, + prefill, + }) + } + /// Trim transcript_cells to preserve only content up to the selected user message. fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) { trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9301be827..e63a53487 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,7 +1,6 @@ use std::path::PathBuf; use codex_common::approval_presets::ApprovalPreset; -use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; @@ -186,9 +185,6 @@ pub(crate) enum AppEvent { /// Re-open the approval presets popup. OpenApprovalsPopup, - /// Forwarded conversation history snapshot from the current conversation. - ConversationHistory(ConversationPathResponseEvent), - /// Open the branch picker option from the review popup. OpenReviewBranchPicker(PathBuf), diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index c52e2d3e4..388ec1d05 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -1594,9 +1594,6 @@ impl App { } self.chat_widget.handle_codex_event(event); } - AppEvent::ConversationHistory(ev) => { - self.on_conversation_history_for_backtrack(tui, ev).await?; - } AppEvent::ExitRequest => { return Ok(false); } @@ -2194,8 +2191,9 @@ impl App { && self.backtrack.nth_user_message != usize::MAX && self.chat_widget.composer_is_empty() => { - // Delegate to helper for clarity; preserves behavior. - self.confirm_backtrack_from_main(); + if let Some(selection) = self.confirm_backtrack_from_main() { + self.apply_backtrack_selection(tui, selection); + } } KeyEvent { kind: KeyEventKind::Press | KeyEventKind::Repeat, @@ -2564,7 +2562,7 @@ mod tests { #[tokio::test] async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { - let mut app = make_test_app().await; + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let user_cell = |text: &str| -> Arc { Arc::new(UserHistoryCell { @@ -2600,9 +2598,8 @@ mod tests { )) as Arc }; - // Simulate the transcript after trimming for a fork, replaying history, and - // appending the edited turn. The session header separates the retained history - // from the forked conversation's replayed turns. + // Simulate a transcript with duplicated history (e.g., from prior backtracks) + // and an edited turn appended after a session header boundary. app.transcript_cells = vec![ make_header(true), user_cell("first question"), @@ -2618,15 +2615,44 @@ mod tests { assert_eq!(user_count(&app.transcript_cells), 2); - app.backtrack.base_id = Some(ThreadId::new()); + let base_id = ThreadId::new(); + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: base_id, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: PathBuf::new(), + }), + }); + + app.backtrack.base_id = Some(base_id); app.backtrack.primed = true; app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1); - app.confirm_backtrack_from_main(); + let selection = app + .confirm_backtrack_from_main() + .expect("backtrack selection"); + assert_eq!(selection.nth_user_message, 1); + assert_eq!(selection.prefill, "follow-up (edited)"); - let (_, nth, prefill) = app.backtrack.pending.clone().expect("pending backtrack"); - assert_eq!(nth, 1); - assert_eq!(prefill, "follow-up (edited)"); + app.apply_backtrack_rollback(selection); + + let mut rollback_turns = None; + while let Ok(op) = op_rx.try_recv() { + if let Op::ThreadRollback { num_turns } = op { + rollback_turns = Some(num_turns); + } + } + + assert_eq!(rollback_turns, Some(1)); } #[tokio::test] diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs index d2aca3ced..1f5ec3e45 100644 --- a/codex-rs/tui2/src/app_backtrack.rs +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -12,7 +12,6 @@ //! both committed history and in-flight activity without changing flush or coalescing behavior. use std::any::TypeId; -use std::path::PathBuf; use std::sync::Arc; use crate::app::App; @@ -21,7 +20,7 @@ use crate::history_cell::UserHistoryCell; use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; -use codex_core::protocol::ConversationPathResponseEvent; +use codex_core::protocol::Op; use codex_protocol::ThreadId; use color_eyre::eyre::Result; use crossterm::event::KeyCode; @@ -33,14 +32,18 @@ use crossterm::event::KeyEventKind; pub(crate) struct BacktrackState { /// True when Esc has primed backtrack mode in the main view. pub(crate) primed: bool, - /// Session id of the base conversation to fork from. + /// Session id of the base thread to rollback. pub(crate) base_id: Option, /// Index in the transcript of the last user message. pub(crate) nth_user_message: usize, /// True when the transcript overlay is showing a backtrack preview. pub(crate) overlay_preview_active: bool, - /// Pending fork request: (base_id, nth_user_message, prefill). - pub(crate) pending: Option<(ThreadId, usize, String)>, +} + +#[derive(Debug, Clone)] +pub(crate) struct BacktrackSelection { + pub(crate) nth_user_message: usize, + pub(crate) prefill: String, } impl App { @@ -108,23 +111,22 @@ impl App { } } - /// Stage a backtrack and request conversation history from the agent. - pub(crate) fn request_backtrack( - &mut self, - prefill: String, - base_id: ThreadId, - nth_user_message: usize, - ) { - self.backtrack.pending = Some((base_id, nth_user_message, prefill)); - if let Some(path) = self.chat_widget.rollout_path() { - let ev = ConversationPathResponseEvent { - conversation_id: base_id, - path, - }; - self.app_event_tx - .send(crate::app_event::AppEvent::ConversationHistory(ev)); - } else { - tracing::error!("rollout path unavailable; cannot backtrack"); + pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) { + let user_total = user_count(&self.transcript_cells); + if user_total == 0 { + return; + } + + let num_turns = user_total.saturating_sub(selection.nth_user_message); + let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX); + if num_turns == 0 { + return; + } + + self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); + self.trim_transcript_for_backtrack(selection.nth_user_message); + if !selection.prefill.is_empty() { + self.chat_widget.set_composer_text(selection.prefill); } } @@ -216,7 +218,7 @@ impl App { self.backtrack.overlay_preview_active = true; let count = user_count(&self.transcript_cells); if let Some(last) = count.checked_sub(1) { - self.apply_backtrack_selection(last); + self.apply_backtrack_selection_internal(last); } tui.frame_requester().schedule_frame(); } @@ -240,12 +242,12 @@ impl App { .min(last_index) }; - self.apply_backtrack_selection(next_selection); + self.apply_backtrack_selection_internal(next_selection); tui.frame_requester().schedule_frame(); } /// Apply a computed backtrack selection to the overlay and internal counter. - fn apply_backtrack_selection(&mut self, nth_user_message: usize) { + fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) { if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { self.backtrack.nth_user_message = nth_user_message; if let Some(Overlay::Transcript(t)) = &mut self.overlay { @@ -305,16 +307,13 @@ impl App { /// Handle Enter in overlay backtrack preview: confirm selection and reset state. fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) { let nth_user_message = self.backtrack.nth_user_message; - if let Some(base_id) = self.backtrack.base_id { - let prefill = nth_user_position(&self.transcript_cells, nth_user_message) - .and_then(|idx| self.transcript_cells.get(idx)) - .and_then(|cell| cell.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); - self.close_transcript_overlay(tui); - self.request_backtrack(prefill, base_id, nth_user_message); + let selection = self.backtrack_selection(nth_user_message); + self.close_transcript_overlay(tui); + if let Some(selection) = selection { + self.apply_backtrack_rollback(selection); + self.render_transcript_once(tui); + tui.frame_requester().schedule_frame(); } - self.reset_backtrack_state(); } /// Handle Esc in overlay backtrack preview: step selection if armed, else forward. @@ -328,18 +327,11 @@ impl App { } /// Confirm a primed backtrack from the main view (no overlay visible). - /// Computes the prefill from the selected user message and requests history. - pub(crate) fn confirm_backtrack_from_main(&mut self) { - if let Some(base_id) = self.backtrack.base_id { - let prefill = - nth_user_position(&self.transcript_cells, self.backtrack.nth_user_message) - .and_then(|idx| self.transcript_cells.get(idx)) - .and_then(|cell| cell.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); - self.request_backtrack(prefill, base_id, self.backtrack.nth_user_message); - } + /// Computes the prefill from the selected user message for rollback. + pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option { + let selection = self.backtrack_selection(self.backtrack.nth_user_message); self.reset_backtrack_state(); + selection } /// Clear all backtrack-related state and composer hints. @@ -351,88 +343,32 @@ impl App { self.chat_widget.clear_esc_backtrack_hint(); } - /// Handle a ConversationHistory response while a backtrack is pending. - /// If it matches the primed base session, fork and switch to the new thread. - pub(crate) async fn on_conversation_history_for_backtrack( + pub(crate) fn apply_backtrack_selection( &mut self, tui: &mut tui::Tui, - ev: ConversationPathResponseEvent, - ) -> Result<()> { - if let Some((base_id, _, _)) = self.backtrack.pending.as_ref() - && ev.conversation_id == *base_id - && let Some((_, nth_user_message, prefill)) = self.backtrack.pending.take() - { - self.fork_and_switch_to_new_conversation(tui, ev, nth_user_message, prefill) - .await; - } - Ok(()) - } - - /// Fork the thread using provided history and switch UI/state accordingly. - async fn fork_and_switch_to_new_conversation( - &mut self, - tui: &mut tui::Tui, - ev: ConversationPathResponseEvent, - nth_user_message: usize, - prefill: String, + selection: BacktrackSelection, ) { - let cfg = self.chat_widget.config_ref().clone(); - // Perform the fork via a thin wrapper for clarity/testability. - let result = self - .perform_fork(ev.path.clone(), nth_user_message, cfg.clone()) - .await; - match result { - Ok(new_conv) => { - self.install_forked_conversation(tui, cfg, new_conv, nth_user_message, &prefill) - } - Err(e) => tracing::error!("error forking conversation: {e:#}"), - } - } - - /// Thin wrapper around ThreadManager::fork_thread. - async fn perform_fork( - &self, - path: PathBuf, - nth_user_message: usize, - cfg: codex_core::config::Config, - ) -> codex_core::error::Result { - self.server.fork_thread(nth_user_message, cfg, path).await - } - - /// Install a forked thread into the ChatWidget and update UI to reflect selection. - fn install_forked_conversation( - &mut self, - tui: &mut tui::Tui, - cfg: codex_core::config::Config, - new_conv: codex_core::NewThread, - nth_user_message: usize, - prefill: &str, - ) { - let thread = new_conv.thread; - let session_configured = new_conv.session_configured; - let init = crate::chatwidget::ChatWidgetInit { - config: cfg, - model: self.current_model.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - }; - self.chat_widget = - crate::chatwidget::ChatWidget::new_from_existing(init, thread, session_configured); - // Trim transcript up to the selected user message and re-render it. - self.trim_transcript_for_backtrack(nth_user_message); + self.apply_backtrack_rollback(selection); self.render_transcript_once(tui); - if !prefill.is_empty() { - self.chat_widget.set_composer_text(prefill.to_string()); - } tui.frame_requester().schedule_frame(); } + fn backtrack_selection(&self, nth_user_message: usize) -> Option { + let base_id = self.backtrack.base_id?; + if self.chat_widget.conversation_id() != Some(base_id) { + return None; + } + + let prefill = nth_user_position(&self.transcript_cells, nth_user_message) + .and_then(|idx| self.transcript_cells.get(idx)) + .and_then(|cell| cell.as_any().downcast_ref::()) + .map(|c| c.message.clone()) + .unwrap_or_default(); + + Some(BacktrackSelection { + nth_user_message, + prefill, + }) + } /// Trim transcript_cells to preserve only content up to the selected user message. fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) { diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index a7e566edf..3396d5ca0 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -1,7 +1,6 @@ use std::path::PathBuf; use codex_common::approval_presets::ApprovalPreset; -use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; @@ -180,9 +179,6 @@ pub(crate) enum AppEvent { /// Re-open the approval presets popup. OpenApprovalsPopup, - /// Forwarded conversation history snapshot from the current conversation. - ConversationHistory(ConversationPathResponseEvent), - /// Open the branch picker option from the review popup. OpenReviewBranchPicker(PathBuf),