diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 1623289cb8..bc49d91c31 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -1466,15 +1466,18 @@ async fn failed_root_switch_keeps_background_requests_on_the_active_session() -> #[tokio::test] async fn root_switch_preserves_vim_line_yank() -> Result<()> { - let mut app = make_test_app().await; + // Keep the large setup and root-switch futures off the test thread's stack. + let mut app = Box::pin(make_test_app()).await; trust_fixture_folders(&mut app); std::fs::write( app.local_settings.user_config_path.as_path(), "[tui]\nresume_cwd = \"session\"\n", )?; - let mut app_server = - crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; - let previous = app_server.start_thread(&app.config).await?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + let previous = Box::pin(app_server.start_thread(&app.config)).await?; app.enqueue_primary_thread_session(previous.session, previous.turns) .await?; let target_thread_id = ThreadId::from_string( @@ -1497,7 +1500,7 @@ async fn root_switch_preserves_vim_line_yank() -> Result<()> { assert_eq!(app.chat_widget.composer_text_with_pending(), ""); let mut tui = crate::tui::test_support::make_test_tui()?; - app.select_agents_overview_thread(&mut tui, &mut app_server, target_thread_id) + Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, target_thread_id)) .await?; assert_eq!(app.current_displayed_thread_id(), Some(target_thread_id)); diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index ec2237c97a..96c20f267c 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -1361,7 +1361,7 @@ mod tests { let default_effort = app.on_apply_advanced_reasoning("gpt-5.5", ReasoningEffortConfig::Ultra); - let new_thread_config = app.fresh_session_config(); + let new_thread_config = &app.config; assert_eq!(default_effort, Some(expected_default_effort.clone())); assert_eq!(app.chat_widget.current_model(), "gpt-5.5"); @@ -1372,7 +1372,7 @@ mod tests { assert_eq!( ( new_thread_config.model.as_deref(), - new_thread_config.model_reasoning_effort, + new_thread_config.model_reasoning_effort.clone(), ), (Some("gpt-5.5"), Some(expected_default_effort)) ); @@ -1401,7 +1401,7 @@ mod tests { let default_effort = app.on_apply_advanced_reasoning("ultra-only", ReasoningEffortConfig::Ultra); - let new_thread_config = app.fresh_session_config(); + let new_thread_config = &app.config; assert_eq!(default_effort, None); assert_eq!(app.chat_widget.current_model(), "ultra-only"); @@ -1412,7 +1412,7 @@ mod tests { assert_eq!( ( new_thread_config.model.as_deref(), - new_thread_config.model_reasoning_effort, + new_thread_config.model_reasoning_effort.clone(), ), (Some("gpt-5.5"), Some(ReasoningEffortConfig::Low)) ); @@ -1658,7 +1658,7 @@ enabled = false )?; let assert_cloud_requirements = |app: &App| { - let config = app.fresh_session_config(); + let config = &app.config; assert_eq!( config .config_layer_stack diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index c43699d375..f99cfba2b0 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -17,6 +17,7 @@ use crate::config_update::format_config_error; use crate::external_agent_config_migration::flow::ExternalAgentConfigMigrationFlowOutcome; use crate::pager_overlay::TranscriptHistoryState; use crate::session_resume::cwds_differ; +use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::ThreadGoalStatus; #[cfg(target_os = "windows")] use codex_app_server_protocol::WindowsSandboxSetupMode; @@ -36,6 +37,7 @@ impl App { AppEvent::InsertHistoryCell(_) | AppEvent::CommitRealtimeTranscriptHistory | AppEvent::ResetTranscriptForThreadSwitch + | AppEvent::FinishPromptRevert { .. } | AppEvent::ManagedWorktreeCreated(_) | AppEvent::AgentsOverviewWorktreeCreated(_) | AppEvent::AppendMessageHistoryEntry { .. } @@ -80,7 +82,7 @@ impl App { mode: crate::app_event::ManagedWorktreeMode::Fork, .. } - | AppEvent::ForkSessionForPromptEdit { .. } + | AppEvent::RevertSessionForPromptEdit { .. } | AppEvent::SetThreadGoalDraft { .. } | AppEvent::SetThreadGoalStatus { status: ThreadGoalStatus::Active, @@ -518,7 +520,7 @@ impl App { self.chat_widget.maybe_send_next_queued_input(); tui.frame_requester().schedule_frame(); } - AppEvent::ForkSessionForPromptEdit { + AppEvent::RevertSessionForPromptEdit { thread_id, nth_user_message, mut prompt, @@ -534,130 +536,160 @@ impl App { tui.frame_requester().schedule_frame(); return Ok(AppRunControl::Continue); } - self.session_telemetry.counter( - "codex.thread.fork", - /*inc*/ 1, - &[("source", "transcript")], - ); - self.refresh_in_memory_config_from_disk_best_effort("forking the thread") - .await; - let config = self.fresh_session_config(); - let selected_profile = self.confirmed_server_profile(thread_id); - let turns = match self.thread_event_channels.get(&thread_id) { - Some(channel) => { + let selection: Result<(String, Vec)> = async { + let channel = self.thread_event_channels.get(&thread_id) + .ok_or_else(|| color_eyre::eyre::eyre!("the selected thread is no longer available"))?; + let (start_item, loaded_tail, latest_turn_id) = { let store = channel.store.lock().await; - let mut turns = store.turns.clone(); - // Snapshot turns contain loaded history; newer live turns remain in - // the replay buffer and must also be visible to prompt-edit lookups. - for event in &store.buffer { - let ThreadBufferedEvent::Notification(notification) = event else { - continue; - }; - match notification.as_ref() { - ServerNotification::TurnStarted(notification) - if !turns - .iter() - .any(|turn| turn.id == notification.turn.id) => - { - turns.push(notification.turn.clone()); - } - ServerNotification::ItemCompleted(notification) => { - if matches!( - notification.item, - ThreadItem::UserMessage { .. } - | ThreadItem::EnteredReviewMode { .. } - | ThreadItem::ExitedReviewMode { .. } - ) && let Some(turn) = turns - .iter_mut() - .find(|turn| turn.id == notification.turn_id) - && !turn - .items - .iter() - .any(|item| item.id() == notification.item.id()) - { - turn.items.push(notification.item.clone()); - } - } - ServerNotification::TurnCompleted(notification) => { - if let Some(turn) = turns - .iter_mut() - .find(|turn| turn.id == notification.turn.id) - { - turn.status = notification.turn.status.clone(); - turn.error = notification.turn.error.clone(); - turn.started_at = notification.turn.started_at; - turn.completed_at = notification.turn.completed_at; - turn.duration_ms = notification.turn.duration_ms; - } - } - _ => {} - } - } - Some(turns) + ( + store.turns.iter().find_map(|turn| turn.items.first() + .map(|item| (turn.id.clone(), item.id().to_string()))), + store.turns.last().map(|turn| turn.id.clone()), + store.latest_turn_id.clone(), + ) + }; + let mut thread = app_server.thread_read(thread_id, /*include_turns*/ false).await?; + app_server.hydrate_initial_thread_history( + &mut thread, + /*turn_cursor*/ None, + /*item_cursor*/ None, + /*config*/ None, + /*local_settings*/ None, + start_item.as_ref().map(|(turn_id, _)| turn_id).or(loaded_tail.as_ref()).map_or( + crate::app_server_session::HistoryHydrationScope::Complete, + |turn_id| crate::app_server_session::HistoryHydrationScope::ThroughTurn(turn_id), + ), + ).await?; + if thread.turns.last().map(|turn| &turn.id) != latest_turn_id.as_ref() { + color_eyre::eyre::bail!("thread history changed; reload the session before editing this prompt"); } - None => None, - }; - let started = match turns { - Some(turns) => match crate::app_backtrack::backtrack_fork_before_turn_id( - &turns, + // With no retained visible items, the next prompt follows the metadata-only tail. + let start_item = start_item.or_else(|| loaded_tail.as_ref().and_then(|tail| { + thread.turns.iter().position(|turn| &turn.id == tail).and_then(|index| { + thread.turns[index + 1..].iter().find_map(|turn| turn.items.first() + .map(|item| (turn.id.clone(), item.id().to_string()))) + }) + })); + let before_turn_id = crate::app_backtrack::backtrack_revert_before_turn_id( + &thread.turns, + start_item.as_ref(), nth_user_message, &mut prompt, - ) { - Ok(before_turn_id) - if before_turn_id.is_some() - || app_server.has_older_history(thread_id) => - { - let before_turn_id = before_turn_id - .or_else(|| turns.first().map(|turn| turn.id.clone())); - app_server - .fork_thread_at(&self.local_settings, config.clone(), - thread_id, - /*last_turn_id*/ None, - before_turn_id, - ForkGoalContinuation::StartIfIdle, - selected_profile.as_ref(), - ) - .await - } - Ok(_) => { - app_server - .start_thread_with_session_start_source( -&self.local_settings, - &config, /*session_start_source*/ None, - /*remote_cwd_override*/ None, - selected_profile.as_ref(), - ) - .await - } - Err(err) => Err(err), - }, - None => Err(color_eyre::eyre::eyre!( - "the selected thread is no longer available for prompt editing" - )), - }; - match started { - Ok(forked) => { - self.shutdown_current_thread(app_server).await; - match self - .replace_chat_widget_with_app_server_thread( - tui, - forked, - ThreadAttachPresentation::PromptEdit, - /*initial_user_message*/ None, - ) - .await - { - Ok(()) => self.chat_widget.restore_user_message_to_composer(prompt), - Err(err) => { - self.restore_backtrack_prompt_after_branch_error(prompt, err); + )?; + // Keep the store aligned with the displayed prefix, including retained live turns. + let cut = thread.turns.iter().position(|turn| turn.id == before_turn_id) + .ok_or_else(|| color_eyre::eyre::eyre!("selected turn disappeared"))?; + thread.turns.truncate(cut); + if let Some((turn_id, item_id)) = &start_item { + for turn in &mut thread.turns { + if &turn.id == turn_id { + if let Some(index) = turn.items.iter().position(|item| item.id() == item_id) { + turn.items.drain(..index); + } + break; } + turn.items.clear(); } } + Ok((before_turn_id, thread.turns)) + }.await; + let (before_turn_id, retained_turns) = match selection { + Ok(selection) => selection, Err(err) => { - self.restore_backtrack_prompt_after_branch_error(prompt, err); + self.restore_backtrack_prompt_after_revert_error(prompt, err); + tui.frame_requester().schedule_frame(); + return Ok(AppRunControl::Continue); + } + }; + let reverted = match app_server.revert_thread(thread_id, before_turn_id, &retained_turns).await { + Ok(reverted) => reverted, + Err(err) => { + // Validation and unsupported-method errors leave history unchanged. Other + // failures can arrive after the server has already committed the revert. + if matches!(&err, codex_app_server_client::TypedRequestError::Server { source, .. } + if matches!(source.code, -32602..=-32600)) { + self.restore_backtrack_prompt_after_revert_error(prompt, err); + tui.frame_requester().schedule_frame(); + return Ok(AppRunControl::Continue); + } + self.chat_widget.restore_user_message_to_composer(prompt); + return Err(color_eyre::Report::new(err).wrap_err("prompt edit could not be confirmed; resume this session to reload its history")); + } + }; + self.chat_widget.restore_user_message_to_composer(prompt.clone()); + // Stop on any post-mutation failure: accepting input against the old displayed + // transcript would hide the fact that server history has already changed. + tokio::time::timeout(Duration::from_secs(/*secs*/ 10), async { + while let Some(event) = app_server.next_event().await { + let is_reverted = matches!( + &event, + AppServerEvent::ServerNotification(notification) + if matches!(notification.as_ref(), ServerNotification::ThreadReverted(notification) + if notification.thread_id == thread_id.to_string()) + ); + self.handle_app_server_event(app_server, event).await; + if is_reverted { + return Ok(()); + } + } + Err(color_eyre::eyre::eyre!("app-server disconnected")) + }).await + .wrap_err("history was reverted, but refreshing the session timed out; resume this session to reload it")??; + // Preserve the widget and unrelated threads; only replace this replay store. + self.chat_widget.restore_thread_input_state( + /*input_state*/ None, + crate::chatwidget::ThreadInputStateRestoreMode { preserve_in_flight_turn: false }, + ); + self.chat_widget.restore_user_message_to_composer(prompt); + self.chat_widget.set_queue_autosend_suppressed(/*suppressed*/ true); + self.abort_thread_event_listener(thread_id); + if let Some(mut rx) = self.active_thread_rx.take() { + while let Ok(event) = rx.try_recv() { + if let ThreadBufferedEvent::Notification(notification) = event { + self.chat_widget.handle_server_notification(*notification, Some(ReplayKind::ThreadSnapshot)); + } } } - tui.frame_requester().schedule_frame(); + let mut session = self.thread_event_channels.get(&thread_id) + .ok_or_else(|| color_eyre::eyre::eyre!("reverted thread is no longer available"))? + .store.lock().await.session.clone() + .ok_or_else(|| color_eyre::eyre::eyre!("reverted thread has no session"))?; + session.rollout_path = reverted.thread.path.clone(); + if self.primary_thread_id == Some(thread_id) { + self.primary_session_configured = Some(session.clone()); + } + self.thread_event_channels.remove(&thread_id); + self.active_thread_id = None; + self.recap.reset_for_new_thread(Instant::now()); + self.recap.seed_from_turns(&retained_turns, Instant::now()); + self.retain_realtime_replay_state_before_replace(); + self.forget_realtime_replay_thread(thread_id); + self.chat_widget.reset_after_prompt_revert(reverted.thread.path, &retained_turns); + self.ensure_thread_channel(thread_id).store.lock().await + .set_session(session, retained_turns); + self.activate_thread_channel(thread_id).await; + // Apply the trim after any transcript inserts produced by shutdown notifications. + // The existing reset barrier keeps terminal input blocked until then. + self.pending_thread_switch_resets += 1; + self.app_event_tx.send(AppEvent::FinishPromptRevert { + thread_id, nth_user_message, + }); + } + AppEvent::FinishPromptRevert { thread_id, nth_user_message } => { + self.pending_thread_switch_resets -= 1; + if self.chat_widget.thread_id() == Some(thread_id) { + if let Some(index) = crate::app_backtrack::nth_user_position(&self.transcript_cells, nth_user_message) { + self.transcript_cells.truncate(index); + } + self.deferred_history_lines.clear(); + self.last_rendered_history_tail = None; + self.last_thread_usage_status_cell = None; + self.pending_thread_usage_history_refresh = false; + self.backtrack_render_pending = true; + self.chat_widget.set_queue_autosend_suppressed(/*suppressed*/ false); + self.chat_widget.emit_prompt_edit_thread_event(); + tui.frame_requester().schedule_frame(); + } } AppEvent::BeginInitialHistoryReplayBuffer => { self.begin_initial_history_replay_buffer(); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index a743b3b2bd..26d0cebddf 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -19,7 +19,6 @@ use std::collections::HashSet; #[derive(Clone, Copy)] pub(super) enum ThreadAttachPresentation { SessionLineage, - PromptEdit, } /// Reports whether a loaded-thread backfill completed and which descendants already had their @@ -1197,11 +1196,6 @@ impl App { .adjacent_thread_id(self.current_displayed_thread_id(), direction) } - pub(super) fn fresh_session_config(&self) -> Config { - let mut config = self.config.clone(); - config.service_tier = self.chat_widget.configured_service_tier(); - config - } pub(super) async fn resume_target_session( &mut self, tui: &mut tui::Tui, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index b3344bc5a2..df66b75320 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -183,7 +183,6 @@ use codex_protocol::protocol::SessionSource as RolloutSessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; -use codex_protocol::protocol::UserMessageEvent; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::user_input::TextElement; use codex_utils_absolute_path::AbsolutePathBuf; @@ -7284,27 +7283,6 @@ fn active_turn_interrupt_race_extracts_actual_turn_id_from_mismatch() { ); } -#[tokio::test] -async fn fresh_session_config_uses_current_service_tier() { - let mut app = make_test_app().await; - app.chat_widget.set_service_tier(Some( - codex_protocol::config_types::ServiceTier::Fast - .request_value() - .to_string(), - )); - - let config = app.fresh_session_config(); - - assert_eq!( - config.service_tier, - Some( - codex_protocol::config_types::ServiceTier::Fast - .request_value() - .to_string() - ) - ); -} - #[tokio::test] async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -7452,11 +7430,11 @@ async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() { app.apply_backtrack_selection(selection); let event = std::iter::from_fn(|| app_event_rx.try_recv().ok()) - .find(|event| matches!(event, AppEvent::ForkSessionForPromptEdit { .. })) + .find(|event| matches!(event, AppEvent::RevertSessionForPromptEdit { .. })) .expect("prompt edit fork should be requested"); assert_matches!( event, - AppEvent::ForkSessionForPromptEdit { + AppEvent::RevertSessionForPromptEdit { thread_id, nth_user_message, prompt, @@ -7477,7 +7455,7 @@ async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() { async fn backtrack_branch_failure_restores_selected_prompt_snapshot() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; - app.restore_backtrack_prompt_after_branch_error( + app.restore_backtrack_prompt_after_revert_error( crate::chatwidget::UserMessage::from("edit this prompt"), "branch unavailable", ); @@ -7993,11 +7971,16 @@ async fn remembered_current_cwd_stays_at_launch_across_in_app_resumes() -> Resul } #[tokio::test] -async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Result<()> { +async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Result<()> { + use codex_protocol::items::TurnItem; + use codex_protocol::items::UserMessageItem; + use codex_protocol::models::ImageReference; + use codex_protocol::protocol::ItemCompletedEvent; + use codex_protocol::user_input::UserInput; let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let config = app.chat_widget.config_ref().clone(); let filename_ts = "2025-01-05T12-00-00"; - let source_thread_id = app_test_support::create_fake_rollout( + let source_thread_id = app_test_support::create_fake_paginated_rollout( config.codex_home.as_path(), filename_ts, "2025-01-05T12:00:00Z", @@ -8015,6 +7998,7 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu .to_string(); std::fs::write(&source_path, format!("{session_meta}\n"))?; for (turn_id, message, images, local_images) in [ + ("turn-0", "older prompt", None, Vec::new()), ("turn-1", "retained prompt", None, Vec::new()), ( "turn-2", @@ -8022,7 +8006,31 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu Some(vec!["https://example.com/backtrack.png".to_string()]), vec![PathBuf::from("/tmp/fake-image.png")], ), + ( + "turn-3", + "selected prompt [Image #1]", + Some(vec!["https://example.com/backtrack.png".to_string()]), + vec![PathBuf::from("/tmp/fake-image.png")], + ), ] { + let mut content = vec![UserInput::Text { + text: message.to_string(), + text_elements: Vec::new(), + }]; + content.extend( + images + .into_iter() + .flatten() + .map(|image_url| UserInput::Image { + image: ImageReference::Inline { image_url }, + detail: None, + }), + ); + content.extend( + local_images + .into_iter() + .map(|path| UserInput::LocalImage { path, detail: None }), + ); for item in [ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), @@ -8032,11 +8040,16 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu model_context_window: None, collaboration_mode_kind: ModeKind::default(), })), - RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { - message: message.to_string(), - images, - local_images, - ..Default::default() + RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: ThreadId::from_string(&source_thread_id)?, + turn_id: turn_id.to_string(), + item: TurnItem::UserMessage(UserMessageItem { + id: format!("user-{turn_id}"), + client_id: None, + content, + }), + started_at_ms: None, + completed_at_ms: 0, })), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), @@ -8054,15 +8067,19 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu let source_thread_id = ThreadId::from_string(&source_thread_id)?; let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; + // Leave the oldest prompt on an unloaded page. + let mut local_settings = crate::local_settings::LocalSettings::from(&config); + local_settings.tui.terminal_resize_reflow_max_rows = Some(3); let started = app_server .resume_thread( - &crate::local_settings::LocalSettings::from(&config), + &local_settings, config.clone(), source_thread_id, crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig, ) .await?; - let selected_turn = started.turns[1].clone(); + assert!(app_server.has_older_history(source_thread_id)); + let selected_turn = started.turns[3].clone(); app.enqueue_primary_thread_session(started.session, started.turns) .await?; { @@ -8073,7 +8090,8 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu .store .lock() .await; - store.turns.pop(); + // The selected prompt can outlive its bounded replay-buffer entries. + store.turns.truncate(/*len*/ 2); store.push_notification(turn_started_notification( source_thread_id, &selected_turn.id, @@ -8094,8 +8112,23 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu TurnStatus::Interrupted, )); } - while app_event_rx.try_recv().is_ok() {} - let source_before = std::fs::read_to_string(&source_path)?; + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + app.transcript_cells.push(Arc::from(cell)); + } + } + assert_eq!(crate::app_backtrack::user_count(&app.transcript_cells), 3); + let child_id = ThreadId::new(); + let child = Arc::clone(&app.ensure_thread_channel(child_id).store); + let goal = app_server + .thread_goal_set( + source_thread_id, + Some("Keep this goal".into()), + Some(codex_app_server_protocol::ThreadGoalStatus::Paused), + /*token_budget*/ None, + ) + .await? + .goal; let mut tui = crate::tui::test_support::make_test_tui()?; let prompt = crate::chatwidget::UserMessage { text: "selected prompt [Image #1]".to_string(), @@ -8108,10 +8141,16 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu mention_bindings: Vec::new(), }; + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::UpdateModel("gpt-5.4".into()), + )) + .await?; let control = Box::pin(app.handle_event( &mut tui, &mut app_server, - AppEvent::ForkSessionForPromptEdit { + AppEvent::RevertSessionForPromptEdit { thread_id: source_thread_id, nth_user_message: 1, prompt: prompt.clone(), @@ -8119,18 +8158,92 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu )) .await?; + while let Ok(event) = app_event_rx.try_recv() { + match event { + AppEvent::InsertHistoryCell(cell) => app.transcript_cells.push(Arc::from(cell)), + event @ AppEvent::FinishPromptRevert { .. } => { + Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; + } + _ => {} + } + } + assert!(Arc::ptr_eq( + &child, + &app.thread_event_channels[&child_id].store + )); + assert_eq!(app.chat_widget.current_model(), "gpt-5.4"); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 120).contains("Goal paused")); assert!(matches!(control, AppRunControl::Continue)); - let forked_thread_id = app + let reverted_thread_id = app .chat_widget .thread_id() - .expect("prompt edit should switch to a forked thread"); - assert_ne!(forked_thread_id, source_thread_id); + .expect("prompt edit should keep the current thread"); + assert_eq!(reverted_thread_id, source_thread_id); assert_eq!(app.chat_widget.composer_text_with_pending(), prompt.text); assert_eq!( app.chat_widget.remote_image_urls(), prompt.remote_image_urls ); - assert_eq!(std::fs::read_to_string(&source_path)?, source_before); + assert_eq!( + app_server + .thread_read(reverted_thread_id, /*include_turns*/ true) + .await? + .turns + .iter() + .map(|turn| turn.id.as_str()) + .collect::>(), + vec!["turn-0", "turn-1"] + ); + + let history = app + .transcript_cells + .iter() + .map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 120))) + .collect::>(); + let retained_index = history + .iter() + .position(|line| line.contains("retained prompt")) + .expect("revert should keep the earlier prompt"); + let notice_index = history + .iter() + .position(|line| { + line == "• Conversation reverted to this point. File changes are unchanged." + }) + .expect("prompt edit should emit the revert notice"); + assert!(retained_index < notice_index); + assert!( + !history + .iter() + .any(|line| line.contains("Thread forked from")) + ); + // Editing the first visible prompt leaves the unloaded prefix intact. + app.chat_widget.restore_thread_input_state( + /*input_state*/ None, + crate::chatwidget::ThreadInputStateRestoreMode { + preserve_in_flight_turn: false, + }, + ); + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::RevertSessionForPromptEdit { + thread_id: source_thread_id, + nth_user_message: 0, + prompt: "retained prompt".into(), + }, + )) + .await?; + while let Ok(event) = app_event_rx.try_recv() { + if matches!(event, AppEvent::FinishPromptRevert { .. }) { + Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; + } + } + assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id)); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "retained prompt" + ); + assert_eq!(crate::app_backtrack::user_count(&app.transcript_cells), 0); assert_eq!( app_server .thread_read(source_thread_id, /*include_turns*/ true) @@ -8139,173 +8252,41 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu .iter() .map(|turn| turn.id.as_str()) .collect::>(), - vec!["turn-1", "turn-2"] + vec!["turn-0"] ); - assert_eq!( - app_server - .thread_read(forked_thread_id, /*include_turns*/ true) - .await? - .turns - .iter() - .map(|turn| turn.id.as_str()) - .collect::>(), - vec!["turn-1"] - ); - - let history = std::iter::from_fn(|| app_event_rx.try_recv().ok()) - .filter_map(|event| match event { - AppEvent::InsertHistoryCell(cell) => { - Some(lines_to_single_string(&cell.display_lines(/*width*/ 120))) - } - _ => None, - }) - .collect::>(); - let retained_index = history - .iter() - .position(|line| line.contains("retained prompt")) - .expect("forked history should replay the retained prompt"); - let notice_index = history - .iter() - .position(|line| line == "• You’re continuing from this point in a new conversation") - .expect("prompt edit should emit the branch notice"); - assert!(retained_index < notice_index); - assert!( - !history - .iter() - .any(|line| line.contains("Thread forked from")) - ); - app_server.shutdown().await?; - - Ok(()) -} - -#[tokio::test] -async fn prompt_edit_before_first_prompt_starts_fresh_thread() -> Result<()> { - let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; - let config = app.chat_widget.config_ref().clone(); - let source_thread_id = app_test_support::create_fake_rollout( - config.codex_home.as_path(), - "2025-01-05T12-00-00", - "2025-01-05T12:00:00Z", - "first prompt", - Some("test-provider"), - /*git_info*/ None, - ) - .expect("materialized rollout should be created"); - let source_thread_id = ThreadId::from_string(&source_thread_id)?; - std::fs::write( - config.codex_home.join("config.toml"), - "default_permissions = \":workspace\"\n[permissions.server-only]\nextends = \":read-only\"\n", - )?; - let server_config = ConfigBuilder::default() - .codex_home(config.codex_home.to_path_buf()) - .build() - .await?; - let mut app_server = - Box::pin(crate::start_embedded_app_server_for_picker(&server_config)).await?; - let started = app_server - .resume_thread( - &crate::local_settings::LocalSettings::from(&config), - config.clone(), + let cursor = app_server + .begin_older_history_page(source_thread_id) + .expect("retained prefix should remain pageable after reverting the visible window"); + let page = app_server + .thread_items_page( source_thread_id, - crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig, + /*turn_id*/ None, + Some(cursor.clone()), + /*limit*/ 100, ) .await?; - app.enqueue_primary_thread_session(started.session, started.turns) - .await?; - app.select_permission_profile( - &mut app_server, - PermissionProfileSelection { - profile_id: "server-only".into(), - approval_policy: None, - approvals_reviewer: None, - display_label: "server-only".into(), - }, - ) - .await; - while app_event_rx.try_recv().is_ok() {} - let mut tui = crate::tui::test_support::make_test_tui()?; - Box::pin(app.handle_event( + app.handle_older_history_page( &mut tui, &mut app_server, - AppEvent::ForkSessionForPromptEdit { - thread_id: source_thread_id, - nth_user_message: 0, - prompt: crate::chatwidget::UserMessage::from("first prompt"), - }, - )) - .await?; - assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id)); - assert_eq!(app.chat_widget.composer_text_with_pending(), "first prompt"); - insta::assert_snapshot!( - next_history_message(&mut app_event_rx), - @"■ Wait for permissions to update before editing this prompt." - ); - let settings = next_thread_settings_updated(&mut app_server, source_thread_id).await; - app.enqueue_thread_notification( source_thread_id, - ServerNotification::ThreadSettingsUpdated(settings), + &cursor, + Ok(page), ) .await?; - while app_event_rx.try_recv().is_ok() {} - let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:8765")?; - app.app_server_target = crate::AppServerTarget::Remote { endpoint }; - let local_only_root = tempdir()?; - let local_only_root_path = local_only_root.path().abs(); - app.harness_overrides - .additional_writable_roots - .push(local_only_root.path().to_path_buf()); - app.refresh_in_memory_config_from_disk().await?; - assert!(app.config.workspace_roots.contains(&local_only_root_path)); - - let control = Box::pin(app.handle_event( - &mut tui, - &mut app_server, - AppEvent::ForkSessionForPromptEdit { - thread_id: source_thread_id, - nth_user_message: 0, - prompt: crate::chatwidget::UserMessage::from("first prompt"), - }, - )) - .await?; - - assert!(matches!(control, AppRunControl::Continue)); - let fresh_thread_id = app - .chat_widget - .thread_id() - .expect("first prompt edit should start a fresh thread"); - assert_ne!(fresh_thread_id, source_thread_id); - let active = app - .chat_widget - .config_ref() - .permissions - .active_permission_profile() - .unwrap(); - assert_eq!(active.id, "server-only"); - let session = app - .primary_session_configured - .as_ref() - .expect("new session"); - let roots = &session.runtime_workspace_roots; - assert!(!roots.contains(&local_only_root_path)); - assert_eq!(app.chat_widget.composer_text_with_pending(), "first prompt"); - let history = std::iter::from_fn(|| app_event_rx.try_recv().ok()) - .filter_map(|event| match event { - AppEvent::InsertHistoryCell(cell) => { - Some(lines_to_single_string(&cell.display_lines(/*width*/ 120))) - } - _ => None, + let prompts = app + .transcript_cells + .iter() + .filter_map(|cell| { + cell.as_any() + .downcast_ref::() }) + .map(|cell| cell.message.as_str()) .collect::>(); - assert!( - history.iter().any(|line| { - line == "• You’re continuing from this point in a new conversation" - }) - ); - assert!( - !history - .iter() - .any(|line| line.contains("Thread forked from")) + assert_eq!(prompts, vec!["older prompt"]); + assert!(!app_server.has_older_history(source_thread_id)); + assert_eq!( + app_server.thread_goal_get(source_thread_id).await?.goal, + Some(goal) ); app_server.shutdown().await?; diff --git a/codex-rs/tui/src/app/tests/transcript_composer.rs b/codex-rs/tui/src/app/tests/transcript_composer.rs index ed0174a383..49aafa269c 100644 --- a/codex-rs/tui/src/app/tests/transcript_composer.rs +++ b/codex-rs/tui/src/app/tests/transcript_composer.rs @@ -279,7 +279,7 @@ async fn transcript_flag_off_preserves_viewer_and_backtracking() -> Result<()> { assert!( std::iter::from_fn(|| app_event_rx.try_recv().ok()).any(|event| matches!( event, - AppEvent::ForkSessionForPromptEdit { + AppEvent::RevertSessionForPromptEdit { nth_user_message: 1, .. } diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 6f2a9ab99b..bebf0c04b0 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1542,9 +1542,6 @@ impl App { ThreadAttachPresentation::SessionLineage => { self.chat_widget.handle_thread_session(session); } - ThreadAttachPresentation::PromptEdit => { - self.chat_widget.handle_prompt_edit_thread_session(session); - } } let should_buffer_initial_replay = !turns.is_empty(); let replayed_final_items = realtime_delivery::completed_agent_items_from_turns(&turns); @@ -1570,9 +1567,6 @@ impl App { &replayed_final_items, retained_assistant_captions, ); - if matches!(presentation, ThreadAttachPresentation::PromptEdit) { - self.chat_widget.emit_prompt_edit_thread_event(); - } let pending = std::mem::take(&mut self.pending_primary_events); for pending_event in pending { match pending_event { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index e71d75b9e9..f3c5e64c7d 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -4,14 +4,14 @@ //! mediates a key rendering boundary for the transcript overlay. //! //! Overall goal: keep the main chat view and the transcript overlay in sync while allowing users -//! to edit an earlier prompt on a source-preserving branch. Confirming a selection forks before -//! the selected turn and restores its prompt in the new composer. +//! to edit an earlier prompt in place. Confirming a selection reverts before +//! the selected turn and restores its prompt in the composer. //! //! 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 when //! there is a prompt to reuse. -//! - `Enter` requests a fork before the selected prompt and reopens it for editing. +//! - `Enter` requests a revert before the selected prompt and reopens it for editing. //! //! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live //! tail derived from the current in-flight `ChatWidget.active_cell`. @@ -74,7 +74,7 @@ pub(crate) struct BacktrackState { pub(crate) overlay_preview_active: bool, } -/// A user-visible backtrack choice that can be reopened on a source-preserving branch. +/// A user-visible backtrack choice that can be reopened in the current thread. #[derive(Debug, Clone, PartialEq)] pub(crate) struct BacktrackSelection { pub(crate) thread_id: ThreadId, @@ -117,7 +117,7 @@ impl App { } } - /// Request a source-preserving branch before the selected prompt. + /// Revert the current thread before the selected prompt. pub(crate) fn apply_backtrack_selection(&mut self, selection: BacktrackSelection) { if self.chat_widget.side_conversation_active() { self.reset_backtrack_state(); @@ -130,22 +130,22 @@ impl App { return; } - self.app_event_tx.send(AppEvent::ForkSessionForPromptEdit { - thread_id: selection.thread_id, - nth_user_message: selection.nth_user_message, - prompt: selection.prompt, - }); + self.app_event_tx + .send(AppEvent::RevertSessionForPromptEdit { + thread_id: selection.thread_id, + nth_user_message: selection.nth_user_message, + prompt: selection.prompt, + }); } - pub(crate) fn restore_backtrack_prompt_after_branch_error( + pub(crate) fn restore_backtrack_prompt_after_revert_error( &mut self, prompt: UserMessage, err: impl std::fmt::Display, ) { self.chat_widget.restore_user_message_to_composer(prompt); - self.chat_widget.add_error_message(format!( - "Failed to branch before the selected prompt: {err}" - )); + self.chat_widget + .add_error_message(format!("Failed to edit the selected prompt: {err:#}")); } /// Open transcript overlay (enters alternate screen and shows full transcript). @@ -439,23 +439,63 @@ impl App { /// /// Replay hides review prompts and other display-empty inputs, so the selected ordinal must be /// resolved against the same visible projection before restoring its canonical mention bindings. +/// `start_item` anchors a partially loaded transcript within authoritative history; earlier +/// user inputs still establish whether the selected input was a mid-turn steer. /// /// A turn can contain multiple user messages when it was steered. Only its initial prompt can be -/// reopened independently because app-server cannot fork in the middle of a turn. -pub(crate) fn backtrack_fork_before_turn_id( +/// reopened independently because app-server cannot revert in the middle of a turn. +pub(crate) fn backtrack_revert_before_turn_id( turns: &[Turn], + start_item: Option<&(String, String)>, nth_user_message: usize, prompt: &mut UserMessage, -) -> Result> { +) -> Result { + let (start_turn, start_item) = match start_item { + Some((turn_id, item_id)) => turns + .iter() + .position(|turn| turn.id == *turn_id) + .and_then(|index| { + turns[index] + .items + .iter() + .position(|item| item.id() == item_id) + .map(|item| (index, item)) + }) + .ok_or_else(|| { + color_eyre::eyre::eyre!("the loaded history boundary no longer exists") + })?, + None => (0, 0), + }; let mut visible_user_messages_seen = 0_usize; let mut review_mode = false; - for (turn_index, turn) in turns.iter().enumerate() { - let hidden_nested_review_turn = turn_index - .checked_sub(/*rhs*/ 1) - .and_then(|index| turns.get(index)) - .is_some_and(|previous| is_hidden_nested_review_turn(previous, turn)); - let mut user_messages_in_turn = 0_usize; - for item in &turn.items { + for (turn_index, turn) in turns.iter().enumerate().skip(start_turn) { + let hidden_nested_review_turn = turn_index > start_turn + && turns.get(turn_index - 1).is_some_and(|previous| { + let previous_items = if turn_index - 1 == start_turn { + &previous.items[start_item..] + } else { + &previous.items + }; + is_hidden_nested_review_turn(previous, turn) + && previous_items + .iter() + .any(|item| matches!(item, ThreadItem::EnteredReviewMode { .. })) + && previous_items + .iter() + .any(|item| matches!(item, ThreadItem::ExitedReviewMode { .. })) + }); + let first_item = if turn_index == start_turn { + start_item + } else { + 0 + }; + // Clipped items affect whether an input is a steer, but were never displayed and + // therefore must not change the transcript's review visibility or prompt ordinals. + let mut user_messages_in_turn = turn.items[..first_item] + .iter() + .filter(|item| matches!(item, ThreadItem::UserMessage { .. })) + .count(); + for item in &turn.items[first_item..] { let content = match item { ThreadItem::EnteredReviewMode { .. } => { review_mode = true; @@ -492,7 +532,7 @@ pub(crate) fn backtrack_fork_before_turn_id( } if is_steer { - bail!("the selected prompt is a steer and cannot be branched independently"); + bail!("the selected prompt is a steer and cannot be edited independently"); } if matches!(turn.status, TurnStatus::InProgress) { bail!("the selected prompt belongs to a turn that is still in progress"); @@ -508,7 +548,7 @@ pub(crate) fn backtrack_fork_before_turn_id( } prompt.mention_bindings = mention_bindings_from_user_inputs(content, &display.message); - return Ok((turn_index > 0).then(|| turn.id.clone())); + return Ok(turn.id.clone()); } } @@ -667,7 +707,7 @@ mod tests { } #[test] - fn backtrack_fork_before_turn_id_resolves_first_and_later_prompts() { + fn backtrack_revert_before_turn_id_resolves_first_and_later_prompts() { let turns = vec![ turn("turn-1", TurnStatus::Completed, /*user_messages*/ 1), turn( @@ -679,48 +719,72 @@ mod tests { ]; assert_eq!( - backtrack_fork_before_turn_id( + backtrack_revert_before_turn_id( &turns, + /*start_item*/ None, /*nth_user_message*/ 0, &mut prompt("turn-1-prompt-0"), ) .expect("first prompt should resolve"), - None + "turn-1".to_string() ); assert_eq!( - backtrack_fork_before_turn_id( + backtrack_revert_before_turn_id( &turns, + /*start_item*/ None, /*nth_user_message*/ 1, &mut prompt("turn-2-prompt-0"), ) .expect("later prompt should resolve"), - Some("turn-2".to_string()) + "turn-2".to_string() ); } #[test] - fn backtrack_fork_before_turn_id_rejects_mid_turn_steers() { + fn backtrack_revert_before_turn_id_rejects_mid_turn_steers() { let turns = vec![turn( "turn-1", TurnStatus::Completed, /*user_messages*/ 2, )]; - let error = backtrack_fork_before_turn_id( + let error = backtrack_revert_before_turn_id( &turns, + /*start_item*/ None, /*nth_user_message*/ 1, &mut prompt("turn-1-prompt-1"), ) - .expect_err("a steer cannot be branched independently"); + .expect_err("a steer cannot be edited independently"); assert_eq!( error.to_string(), - "the selected prompt is a steer and cannot be branched independently" + "the selected prompt is a steer and cannot be edited independently" ); } #[test] - fn backtrack_fork_before_turn_id_rejects_in_progress_and_missing_prompts() { + fn backtrack_revert_partial_history_does_not_make_a_steer_editable() { + let turns = vec![turn( + "turn-1", + TurnStatus::Completed, + /*user_messages*/ 2, + )]; + let start_item = (turns[0].id.clone(), turns[0].items[1].id().to_string()); + let error = backtrack_revert_before_turn_id( + &turns, + Some(&start_item), + /*nth_user_message*/ 0, + &mut prompt("turn-1-prompt-1"), + ) + .expect_err("the loaded page starts mid-turn"); + assert_eq!( + error.to_string(), + "the selected prompt is a steer and cannot be edited independently" + ); + } + + #[test] + fn backtrack_revert_before_turn_id_rejects_in_progress_and_missing_prompts() { let turns = vec![turn( "turn-1", TurnStatus::InProgress, @@ -728,22 +792,24 @@ mod tests { )]; assert_eq!( - backtrack_fork_before_turn_id( + backtrack_revert_before_turn_id( &turns, + /*start_item*/ None, /*nth_user_message*/ 0, &mut prompt("turn-1-prompt-0"), ) - .expect_err("in-progress prompt cannot be branched") + .expect_err("in-progress prompt cannot be edited") .to_string(), "the selected prompt belongs to a turn that is still in progress" ); assert_eq!( - backtrack_fork_before_turn_id( + backtrack_revert_before_turn_id( &turns, + /*start_item*/ None, /*nth_user_message*/ 1, &mut prompt("missing prompt"), ) - .expect_err("missing prompt cannot be branched") + .expect_err("missing prompt cannot be edited") .to_string(), "the selected prompt was not found in the persisted thread" ); @@ -754,19 +820,20 @@ mod tests { /*user_messages*/ 1, )]; assert_eq!( - backtrack_fork_before_turn_id( + backtrack_revert_before_turn_id( &completed_turns, + /*start_item*/ None, /*nth_user_message*/ 0, &mut prompt("different prompt"), ) - .expect_err("a stale transcript prompt cannot be branched") + .expect_err("a stale transcript prompt cannot be edited") .to_string(), "the selected transcript prompt no longer matches the persisted thread" ); } #[test] - fn backtrack_fork_before_turn_id_skips_hidden_review_prompts() { + fn backtrack_revert_before_turn_id_skips_hidden_review_prompts() { let mut review_turn = turn( "turn-review", TurnStatus::Completed, @@ -790,18 +857,31 @@ mod tests { ]; assert_eq!( - backtrack_fork_before_turn_id( + backtrack_revert_before_turn_id( &turns, + /*start_item*/ None, /*nth_user_message*/ 1, &mut prompt("turn-2-prompt-0"), ) .expect("the visible prompt after review should resolve"), - Some("turn-2".to_string()) + "turn-2".to_string() + ); + // This loaded page starts after the review marker, so its review prompt is visible. + let start_item = (turns[1].id.clone(), turns[1].items[1].id().to_string()); + assert_eq!( + backtrack_revert_before_turn_id( + &turns, + Some(&start_item), + /*nth_user_message*/ 1, + &mut prompt("turn-2-prompt-0"), + ) + .expect("ordinal must match the clipped transcript"), + "turn-2" ); } #[test] - fn backtrack_fork_before_turn_id_skips_hidden_nested_review_prompts() { + fn backtrack_revert_before_turn_id_skips_hidden_nested_review_prompts() { let review_hint = "current changes"; let review_prompt = "Review the current code changes (staged, unstaged, and untracked files)."; @@ -859,18 +939,19 @@ mod tests { ]; assert_eq!( - backtrack_fork_before_turn_id( + backtrack_revert_before_turn_id( &turns, + /*start_item*/ None, /*nth_user_message*/ 0, &mut prompt("turn-2-prompt-0"), ) .expect("the visible prompt after a nested review should resolve"), - Some("turn-2".to_string()) + "turn-2".to_string() ); } #[test] - fn backtrack_fork_before_turn_id_restores_canonical_mention_bindings() { + fn backtrack_revert_before_turn_id_restores_canonical_mention_bindings() { let mut selected_turn = turn("turn-2", TurnStatus::Completed, /*user_messages*/ 1); selected_turn.items = vec![ThreadItem::UserMessage { id: "selected-prompt".to_string(), @@ -901,13 +982,14 @@ mod tests { let mut selected_prompt = prompt("use $skill @sample $google-calendar"); assert_eq!( - backtrack_fork_before_turn_id( + backtrack_revert_before_turn_id( &turns, + /*start_item*/ None, /*nth_user_message*/ 1, &mut selected_prompt, ) .expect("the selected prompt should resolve"), - Some("turn-2".to_string()) + "turn-2".to_string() ); assert_eq!( selected_prompt.mention_bindings, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0027716d86..fd084f36a7 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -571,11 +571,15 @@ pub(crate) enum AppEvent { }, /// Branch before a selected prompt and reopen it in the new thread's composer. - ForkSessionForPromptEdit { + RevertSessionForPromptEdit { thread_id: ThreadId, nth_user_message: usize, prompt: UserMessage, }, + FinishPromptRevert { + thread_id: ThreadId, + nth_user_message: usize, + }, /// Request to exit the application. /// diff --git a/codex-rs/tui/src/app_server_session/history.rs b/codex-rs/tui/src/app_server_session/history.rs index d545a85f50..f6fe4d2c3c 100644 --- a/codex-rs/tui/src/app_server_session/history.rs +++ b/codex-rs/tui/src/app_server_session/history.rs @@ -15,6 +15,8 @@ use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadItemsListParams; use codex_app_server_protocol::ThreadItemsListResponse; +use codex_app_server_protocol::ThreadRevertParams; +use codex_app_server_protocol::ThreadRevertResponse; use codex_app_server_protocol::ThreadTurnsListParams; use codex_app_server_protocol::ThreadTurnsListResponse; use codex_app_server_protocol::Turn; @@ -28,9 +30,10 @@ pub(crate) const HISTORY_ITEM_PAGE_LIMIT: u32 = 100; pub(crate) const HISTORY_ITEM_SCAN_LIMIT: usize = 4 * HISTORY_ITEM_PAGE_LIMIT as usize; #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum HistoryHydrationScope { +pub(crate) enum HistoryHydrationScope<'a> { Initial, Complete, + ThroughTurn(&'a str), } pub(crate) fn thread_items_page_params( @@ -70,6 +73,41 @@ pub(crate) struct ThreadHistoryPagination { } impl AppServerSession { + pub(crate) async fn revert_thread( + &mut self, + thread_id: ThreadId, + before_turn_id: String, + retained_turns: &[Turn], + ) -> std::result::Result { + let request_id = self.next_request_id(); + let response: ThreadRevertResponse = self + .client + .request_typed(ClientRequest::ThreadRevert { + request_id, + params: ThreadRevertParams { + thread_id: thread_id.to_string(), + before_turn_id, + }, + }) + .await?; + // Older cursors in the retained prefix remain valid. If the entire displayed window + // disappeared, resume paging at the replacement history's end instead. + if retained_turns.iter().all(|turn| turn.items.is_empty()) { + self.history_pagination.insert( + thread_id, + ThreadHistoryPagination { + history_mode: ThreadHistoryMode::Paginated, + next_turn_cursor: response.turns_backwards_cursor.clone(), + next_item_cursor: response.items_backwards_cursor.clone(), + ..ThreadHistoryPagination::default() + }, + ); + } else { + self.cancel_older_history_page(thread_id); + } + Ok(response) + } + pub(crate) fn has_older_history(&self, thread_id: ThreadId) -> bool { self.history_pagination .get(&thread_id) @@ -202,7 +240,7 @@ impl AppServerSession { item_cursor: Option, config: Option<&Config>, local_settings: Option<&crate::local_settings::LocalSettings>, - scope: HistoryHydrationScope, + scope: HistoryHydrationScope<'_>, ) -> Result<()> { let thread_id = ThreadId::from_string(&thread.id) .wrap_err("invalid thread id in bounded history response")?; @@ -231,6 +269,7 @@ impl AppServerSession { .and_then(|settings| resize_reflow_max_rows(settings.terminal_resize_reflow())); let item_budget = match (scope, config, row_budget) { (HistoryHydrationScope::Complete, _, _) + | (HistoryHydrationScope::ThroughTurn(_), _, _) | (HistoryHydrationScope::Initial, Some(_), None) => None, (HistoryHydrationScope::Initial, Some(_), Some(max_rows)) => { Some(max_rows.saturating_add(HISTORY_ITEM_SCAN_LIMIT)) @@ -269,6 +308,16 @@ impl AppServerSession { let items = self .merge_thread_item_page(thread_id, page, &mut state, &mut thread.turns) .await?; + // Finish the anchor turn so prompt editing can detect earlier steers, + // but do not scan history older than the displayed transcript. + if let HistoryHydrationScope::ThroughTurn(anchor) = scope + && let Some(index) = thread.turns.iter().position(|turn| turn.id == anchor) + && thread.turns[..index] + .iter() + .any(|turn| !turn.items.is_empty()) + { + break; + } if let Some((config, local_settings)) = config.zip(local_settings) { rendered_rows = rendered_history_rows( thread_id, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 66a2d964db..4dcfd3affb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -869,7 +869,6 @@ pub(crate) enum ReplayKind { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum SessionConfiguredDisplay { Normal, - PromptEdit, /// Apply session state without emitting the session info cell. Quiet, SideConversation, diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 1c4129420b..68d4b702b6 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -158,10 +158,7 @@ impl ChatWidget { self.sync_goal_command_enabled(); self.refresh_plugin_mentions(); let model_for_header = self.current_model().to_string(); - if matches!( - display, - SessionConfiguredDisplay::Normal | SessionConfiguredDisplay::PromptEdit - ) { + if display == SessionConfiguredDisplay::Normal { let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = self .should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref()); @@ -224,16 +221,6 @@ impl ChatWidget { ); } - pub(crate) fn handle_prompt_edit_thread_session(&mut self, session: ThreadSessionState) { - self.instruction_source_paths = session.instruction_source_paths.clone(); - let fork_parent_title = session.fork_parent_title.clone(); - self.on_session_configured_with_display_and_fork_parent_title( - session, - SessionConfiguredDisplay::PromptEdit, - fork_parent_title, - ); - } - pub(crate) fn handle_side_thread_session(&mut self, session: ThreadSessionState) { self.instruction_source_paths = session.instruction_source_paths.clone(); let fork_parent_title = session.fork_parent_title.clone(); @@ -275,10 +262,63 @@ impl ChatWidget { ))); } + /// Clear history-derived state while preserving this thread's settings and goal. + pub(crate) fn reset_after_prompt_revert( + &mut self, + rollout_path: Option, + retained_turns: &[Turn], + ) { + self.current_rollout_path = rollout_path; + self.input_queue.clear(); + self.reset_realtime_conversation(); + // Finish any output not yet drained from the old runtime without live completion actions. + self.on_task_complete( + /*last_agent_message*/ None, /*completion*/ None, /*from_replay*/ true, + ); + self.turn_lifecycle.reset_thread(); + self.review = Default::default(); + self.transcript.take_active_cell(); + self.transcript.reset_copy_history(); + self.transcript.reset_turn_flags(); + for turn in retained_turns { + let mut replaying_delegation = false; + for item in &turn.items { + if matches!(item, ThreadItem::UserMessage { content, .. } + if realtime::realtime_delegation_input(content).is_some()) + { + replaying_delegation = true; + } + if replaying_delegation && realtime::is_private_realtime_agent_item(item) { + continue; + } + let (markdown, source) = match item { + ThreadItem::AgentMessage { text, .. } => ( + parse_assistant_markdown(text, self.config.cwd.as_path()).visible_markdown, + text, + ), + ThreadItem::Plan { text, .. } => (text.clone(), text), + _ => continue, + }; + if !markdown.trim().is_empty() { + self.transcript + .record_agent_markdown(markdown, source.clone()); + } + } + } + self.transcript.last_plan_progress = None; + self.last_rendered_user_message_display = None; + self.last_rendered_user_message_client_id = None; + self.clear_pending_rate_limit_reset_hint(); + self.set_token_info(/*info*/ None); + self.bottom_pane.clear_pending_questions(); + self.bottom_pane.set_task_running(/*running*/ false); + self.refresh_status_surfaces(); + } + pub(crate) fn emit_prompt_edit_thread_event(&mut self) { let line: Line<'static> = vec![ "• ".dim(), - "You’re continuing from this point in a new conversation".into(), + "Conversation reverted to this point. File changes are unchanged.".into(), ] .into(); self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__prompt_edit_thread_history_line.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__prompt_edit_thread_history_line.snap index 73e660ddd3..b1e444073a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__prompt_edit_thread_history_line.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__prompt_edit_thread_history_line.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests/history_replay.rs expression: combined --- -• You’re continuing from this point in a new conversation +• Conversation reverted to this point. File changes are unchanged. diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index d43ffc7d1d..63e538e2d4 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -236,6 +236,21 @@ async fn approving_recent_denial_emits_structured_core_op_once() { assert!(rx.try_recv().is_err()); } +#[tokio::test] +async fn prompt_revert_discards_recent_denial_actions() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + chat.on_guardian_assessment(auto_review_denial_event()); + chat.reset_after_prompt_revert(/*rollout_path*/ None, &[]); + drain_insert_history(&mut rx); + + chat.approve_recent_auto_review_denial(thread_id, "auto-review-recent-1".to_string()); + + assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + assert!(rx.try_recv().is_err()); +} + #[tokio::test] async fn guardian_denied_exec_renders_warning_and_denied_request() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index b4582414e2..d8b958080e 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -936,6 +936,63 @@ async fn forked_thread_history_line_without_name_shows_id_once_snapshot() { assert_chatwidget_snapshot!("forked_thread_history_line_without_name", combined); } +#[tokio::test] +async fn prompt_edit_stops_streaming_without_submitting_queued_input() { + let (mut chat, _events, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.on_task_started(); + chat.on_agent_message_delta("removed response".into()); + chat.queue_user_message("queued follow-up".into()); + chat.enter_review_mode_with_hint("review".into(), /*from_replay*/ false); + + let retained_turn = AppServerTurn { + items: vec![ + AppServerThreadItem::AgentMessage { + id: "retained-response".to_string(), + text: "retained response".to_string(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: None, + delivery: None, + questions: None, + }, + AppServerThreadItem::UserMessage { + id: "voice-steer".to_string(), + client_id: None, + content: vec![UserInput::Text { + text: "voice input" + .to_string(), + text_elements: Vec::new(), + }], + }, + AppServerThreadItem::AgentMessage { + id: "private-commentary".to_string(), + text: "hidden voice commentary".to_string(), + phase: Some(MessagePhase::Commentary), + memory_citation: None, + delivery: None, + questions: None, + }, + ], + ..app_server_turn( + "retained-turn", + AppServerTurnStatus::Completed, + /*duration_ms*/ None, + /*error*/ None, + ) + }; + chat.reset_after_prompt_revert(/*rollout_path*/ None, &[retained_turn]); + chat.pre_draw_tick(); + + assert_eq!( + ( + chat.is_user_turn_pending_or_running(), + chat.has_active_stream_tail(), + chat.maybe_send_next_queued_input(), + chat.last_agent_markdown_text(), + ), + (false, false, false, Some("retained response")), + ); +} + #[tokio::test] async fn prompt_edit_thread_history_line_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__backtrack_branch_failure_restores_selected_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__backtrack_branch_failure_restores_selected_prompt.snap index b12fc14561..747cbe397a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__backtrack_branch_failure_restores_selected_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__backtrack_branch_failure_restores_selected_prompt.snap @@ -2,4 +2,4 @@ source: tui/src/app/tests.rs expression: rendered --- -■ Failed to branch before the selected prompt: branch unavailable +■ Failed to edit the selected prompt: branch unavailable