Use thread rollback for Esc backtrack (#9140)
- Swap Esc backtrack to roll back the current thread instead of forking
This commit is contained in:
parent
17ab5f6a52
commit
3a300d1117
6 changed files with 196 additions and 277 deletions
|
|
@ -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<dyn HistoryCell> {
|
||||
Arc::new(UserHistoryCell {
|
||||
|
|
@ -1811,9 +1810,8 @@ mod tests {
|
|||
)) as Arc<dyn HistoryCell>
|
||||
};
|
||||
|
||||
// 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]
|
||||
|
|
|
|||
|
|
@ -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<ThreadId>,
|
||||
/// 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::<UserHistoryCell>())
|
||||
.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::<UserHistoryCell>())
|
||||
.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<BacktrackSelection> {
|
||||
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<codex_core::NewThread> {
|
||||
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<BacktrackSelection> {
|
||||
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::<UserHistoryCell>())
|
||||
.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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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<dyn HistoryCell> {
|
||||
Arc::new(UserHistoryCell {
|
||||
|
|
@ -2600,9 +2598,8 @@ mod tests {
|
|||
)) as Arc<dyn HistoryCell>
|
||||
};
|
||||
|
||||
// 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]
|
||||
|
|
|
|||
|
|
@ -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<ThreadId>,
|
||||
/// 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::<UserHistoryCell>())
|
||||
.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::<UserHistoryCell>())
|
||||
.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<BacktrackSelection> {
|
||||
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<codex_core::NewThread> {
|
||||
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<BacktrackSelection> {
|
||||
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::<UserHistoryCell>())
|
||||
.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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue