diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 56a13dd703..08cd02a93b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -123,7 +123,6 @@ use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadMemoryMode; -use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadStartSource; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -542,11 +541,6 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, - /// When set, the next draw rebuilds terminal scrollback from the retained transcript cells. - /// - /// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed - /// transcript cells. - pub(crate) backtrack_render_pending: bool, pub(crate) feedback: codex_feedback::CodexFeedback, feedback_audience: FeedbackAudience, environment_manager: Arc, @@ -1043,7 +1037,6 @@ See the Codex keymap documentation for supported actions and examples." terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), - backtrack_render_pending: false, feedback: feedback.clone(), feedback_audience, environment_manager, @@ -1289,10 +1282,6 @@ See the Codex keymap documentation for supported actions and examples." self.chat_widget.handle_paste(pasted); } TuiEvent::Draw | TuiEvent::Resize => { - if self.backtrack_render_pending { - self.rebuild_transcript_after_backtrack(tui)?; - self.backtrack_render_pending = false; - } self.chat_widget.maybe_post_pending_notification(tui); if self .chat_widget diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 5583d5ac85..ea42880930 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -344,11 +344,6 @@ impl App { self.chat_widget.note_stream_consolidation_completed(); self.insert_pending_usage_output_after_stream_shutdown(tui); } - AppEvent::ApplyThreadRollback { num_turns } => { - if self.apply_non_pending_thread_rollback(num_turns) { - tui.frame_requester().schedule_frame(); - } - } AppEvent::StartCommitAnimation => { if self .commit_anim_running @@ -417,9 +412,6 @@ impl App { ) .await; } - AppEvent::RestoreCancelledTurn(prompt) => { - self.apply_cancelled_turn_edit(prompt); - } AppEvent::AppendMessageHistoryEntry { thread_id, text } => { self.append_message_history_entry(thread_id, text); } diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index d527df7a90..57e63180cc 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -172,7 +172,6 @@ impl App { self.chat_widget.clear_pending_rate_limit_reset_hint(); self.initial_history_replay_buffer = None; self.backtrack = BacktrackState::default(); - self.backtrack_render_pending = false; self.skill_load_warnings.clear(); } } diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index fd5efd2d94..c68c150440 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -423,35 +423,6 @@ impl App { Ok(terminal_width) } - /// Rebuild scrollback after rollback removes transcript cells. - /// - /// Unlike resize reflow, rollback must clear the terminal even when no cells remain. Otherwise - /// the cancelled user prompt stays visible in scrollback despite being removed from the source - /// transcript. - pub(super) fn rebuild_transcript_after_backtrack(&mut self, tui: &mut tui::Tui) -> Result<()> { - let terminal_width = tui.terminal.size()?.width; - let width = self.chat_widget.history_wrap_width(terminal_width); - let reflowed_lines = if self.transcript_cells.is_empty() { - self.reset_history_emission_state(); - Vec::new() - } else { - self.render_transcript_lines_for_reflow(width).lines - }; - - tui.clear_pending_history_lines(); - self.clear_terminal_for_resize_replay(tui)?; - - self.deferred_history_lines.clear(); - if !reflowed_lines.is_empty() { - tui.insert_history_hyperlink_lines_with_wrap_policy( - reflowed_lines, - self.history_line_wrap_policy(), - ); - } - - Ok(()) - } - /// Render transcript cells for the current resize rebuild. /// /// Rendering walks backward from the transcript tail so row-capped sessions avoid formatting the diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index eac2665352..0a536d50c0 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -441,7 +441,7 @@ impl App { }; self.chat_widget.add_info_message(message, /*hint*/ None); } - self.drain_active_thread_events(tui).await?; + self.drain_active_thread_events().await?; self.refresh_pending_thread_approvals().await; Ok(()) diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__cancelled_turn_edit_restores_composer.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__cancelled_turn_edit_restores_composer.snap deleted file mode 100644 index 33d1082b1f..0000000000 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__cancelled_turn_edit_restores_composer.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/app/tests.rs -expression: app.chat_widget.composer_text_with_pending() ---- -edit me diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index e0eafaa23b..6976e699a7 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -44,7 +44,6 @@ pub(super) async fn make_test_app() -> App { terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), - backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2880375167..44cf40a9de 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -8,7 +8,6 @@ mod startup; use super::*; use crate::app::session_lifecycle::ThreadAttachPresentation; use crate::app_backtrack::BacktrackState; -use crate::app_backtrack::user_count; use crate::chatwidget::ChatWidgetInit; use crate::chatwidget::create_initial_user_message; @@ -41,7 +40,6 @@ use codex_app_server_protocol::AdditionalPermissionProfile; use codex_app_server_protocol::AgentMessageDeltaNotification; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; -use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileUpdateChange; use codex_app_server_protocol::ItemStartedNotification; @@ -60,7 +58,6 @@ use codex_app_server_protocol::PermissionsRequestApprovalParams; use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadItem; @@ -4167,7 +4164,6 @@ async fn make_test_app() -> App { terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), - backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), @@ -4232,7 +4228,6 @@ async fn make_test_app_with_channels() -> ( terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), - backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), @@ -5208,8 +5203,6 @@ async fn backtrack_selection_targets_predecessor_and_preserves_selected_prompt() agent_cell("answer edited"), ]; - assert_eq!(user_count(&app.transcript_cells), 2); - let base_id = ThreadId::new(); app.chat_widget .handle_thread_session(crate::session_state::ThreadSessionState { @@ -5237,7 +5230,7 @@ async fn backtrack_selection_targets_predecessor_and_preserves_selected_prompt() 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.backtrack.nth_user_message = 1; let selection = app .confirm_backtrack_from_main() @@ -5318,62 +5311,6 @@ async fn backtrack_branch_failure_restores_selected_prompt() { ); } -#[tokio::test] -async fn cancelled_turn_edit_restores_prompt_and_rolls_back_latest_turn() { - let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; - app.transcript_cells = vec![Arc::new(UserHistoryCell { - turn_id: None, - message: "original".to_string(), - text_elements: Vec::new(), - local_image_paths: Vec::new(), - remote_image_urls: Vec::new(), - }) as Arc]; - let prompt = crate::chatwidget::UserMessage { - text: "edit me".to_string(), - local_images: Vec::new(), - remote_image_urls: vec!["https://example.com/edit.png".to_string()], - text_elements: Vec::new(), - mention_bindings: Vec::new(), - }; - - app.apply_cancelled_turn_edit(prompt); - - assert_eq!(app.chat_widget.composer_text_with_pending(), "edit me"); - assert_snapshot!( - "cancelled_turn_edit_restores_composer", - app.chat_widget.composer_text_with_pending() - ); - assert_eq!( - app.chat_widget.remote_image_urls(), - vec!["https://example.com/edit.png".to_string()] - ); - assert_matches!(op_rx.try_recv(), Ok(Op::ThreadRollback { num_turns: 1 })); -} - -#[tokio::test] -async fn first_cancelled_turn_edit_restores_prompt_without_local_history() { - let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; - let prompt = crate::chatwidget::UserMessage { - text: "edit first prompt".to_string(), - local_images: Vec::new(), - remote_image_urls: vec!["https://example.com/edit.png".to_string()], - text_elements: Vec::new(), - mention_bindings: Vec::new(), - }; - - app.apply_cancelled_turn_edit(prompt); - - assert_eq!( - app.chat_widget.composer_text_with_pending(), - "edit first prompt" - ); - assert_eq!( - app.chat_widget.remote_image_urls(), - vec!["https://example.com/edit.png".to_string()] - ); - assert_matches!(op_rx.try_recv(), Ok(Op::ThreadRollback { num_turns: 1 })); -} - #[tokio::test] async fn backtrack_selection_preserves_data_image_urls() { let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -5652,86 +5589,6 @@ async fn refreshed_snapshot_session_persists_resumed_turns() { assert_eq!(store_snapshot.turns, snapshot.turns); } -#[tokio::test] -async fn queued_rollback_syncs_overlay_and_clears_deferred_history() { - let mut app = make_test_app().await; - app.transcript_cells = vec![ - Arc::new(UserHistoryCell { - turn_id: Some("turn-1".to_string()), - message: "first".to_string(), - text_elements: Vec::new(), - local_image_paths: Vec::new(), - remote_image_urls: Vec::new(), - }) as Arc, - Arc::new(AgentMessageCell::new( - vec![Line::from("after first")], - /*is_first_line*/ false, - )) as Arc, - Arc::new(UserHistoryCell { - turn_id: Some("turn-2".to_string()), - message: "second".to_string(), - text_elements: Vec::new(), - local_image_paths: Vec::new(), - remote_image_urls: Vec::new(), - }) as Arc, - Arc::new(AgentMessageCell::new( - vec![Line::from("after second")], - /*is_first_line*/ false, - )) as Arc, - ]; - app.overlay = Some(Overlay::new_transcript( - app.transcript_cells.clone(), - app.keymap.pager.clone(), - )); - app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; - app.backtrack.overlay_preview_active = true; - app.backtrack.nth_user_message = 1; - app.chat_widget.update_account_state( - /*status_account_display*/ None, /*plan_type*/ None, - /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, - ); - app.chat_widget - .set_composer_text("/usage daily".to_string(), Vec::new(), Vec::new()); - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let pending_usage = app - .chat_widget - .active_cell_transcript_lines(/*width*/ 80) - .expect("pending usage transcript"); - assert!(lines_to_single_string(&pending_usage).contains("Token activity\n Loading...")); - - let changed = app.apply_non_pending_thread_rollback(/*num_turns*/ 1); - - assert!(changed); - assert!(app.backtrack_render_pending); - assert!(app.deferred_history_lines.is_empty()); - assert!( - app.chat_widget - .active_cell_transcript_lines(/*width*/ 80) - .is_none_or(|lines| !lines_to_single_string(&lines).contains("Token activity")) - ); - assert_eq!(app.backtrack.nth_user_message, 0); - let user_messages: Vec = app - .transcript_cells - .iter() - .filter_map(|cell| { - cell.as_any() - .downcast_ref::() - .map(|cell| cell.message.clone()) - }) - .collect(); - assert_eq!(user_messages, vec!["first".to_string()]); - let overlay_cell_count = match app.overlay.as_ref() { - Some(Overlay::Transcript(t)) => t.committed_cell_count(), - _ => panic!("expected transcript overlay"), - }; - assert_eq!(overlay_cell_count, app.transcript_cells.len()); -} - #[tokio::test] async fn late_usage_result_can_follow_finalized_plan() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -5764,64 +5621,6 @@ async fn late_usage_result_can_follow_finalized_plan() { ); } -#[tokio::test] -async fn thread_rollback_response_discards_queued_active_thread_events() { - let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - let (tx, rx) = mpsc::channel(8); - app.active_thread_id = Some(thread_id); - app.active_thread_rx = Some(rx); - tx.send(ThreadBufferedEvent::Notification( - ServerNotification::ConfigWarning(ConfigWarningNotification { - summary: "stale warning".to_string(), - details: None, - path: None, - range: None, - }), - )) - .await - .expect("event should queue"); - - app.handle_thread_rollback_response( - thread_id, - /*num_turns*/ 1, - &ThreadRollbackResponse { - thread: Thread { - id: thread_id.to_string(), - extra: None, - session_id: thread_id.to_string(), - forked_from_id: None, - parent_thread_id: None, - preview: String::new(), - ephemeral: false, - history_mode: Default::default(), - model_provider: "openai".to_string(), - created_at: 0, - updated_at: 0, - recency_at: Some(0), - status: codex_app_server_protocol::ThreadStatus::Idle, - path: None, - cwd: test_path_buf("/tmp/project").abs(), - cli_version: "0.0.0".to_string(), - source: SessionSource::Cli, - thread_source: None, - agent_nickname: None, - agent_role: None, - git_info: None, - name: None, - turns: Vec::new(), - }, - }, - ) - .await; - - let rx = app - .active_thread_rx - .as_mut() - .expect("active receiver should remain attached"); - assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); -} - #[tokio::test] async fn new_session_requests_shutdown_for_previous_conversation() { Box::pin(async { @@ -6282,7 +6081,6 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { app.backtrack.primed = true; app.backtrack.overlay_preview_active = true; app.backtrack.nth_user_message = 0; - app.backtrack_render_pending = true; app.reset_app_ui_state_after_clear(); @@ -6292,8 +6090,6 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { assert!(!app.has_emitted_history_lines); assert!(!app.backtrack.primed); assert!(!app.backtrack.overlay_preview_active); - assert!(app.backtrack.pending_rollback.is_none()); - assert!(!app.backtrack_render_pending); assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt"); } diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 571c6e4f32..796215c5fc 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -198,13 +198,6 @@ impl ThreadEventStore { }) } - pub(super) fn apply_thread_rollback(&mut self, response: &ThreadRollbackResponse) { - self.turns = response.thread.turns.clone(); - self.buffer.clear(); - self.pending_interactive_replay = PendingInteractiveReplayState::default(); - self.active_turn_id = None; - } - pub(super) fn snapshot(&self) -> ThreadEventSnapshot { ThreadEventSnapshot { session: self.session.clone(), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 2024ffb3d8..872e06f30b 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -11,8 +11,6 @@ use crate::session_resume::read_session_model; impl App { pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) { if let Some(thread_id) = self.chat_widget.thread_id() { - // Clear any in-flight rollback guard when switching threads. - self.backtrack.pending_rollback = None; if let Err(err) = app_server.thread_unsubscribe(thread_id).await { tracing::warn!("failed to unsubscribe thread {thread_id}: {err}"); } @@ -516,7 +514,7 @@ impl App { op: &AppCommand, ) -> Result { match op { - AppCommand::Interrupt { .. } => { + AppCommand::Interrupt => { if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { let mut interrupt_turn_id = turn_id; for retried_after_turn_mismatch in [false, true] { @@ -693,18 +691,6 @@ impl App { .await?; Ok(true) } - AppCommand::ThreadRollback { num_turns } => { - let response = match app_server.thread_rollback(thread_id, *num_turns).await { - Ok(response) => response, - Err(err) => { - self.handle_backtrack_rollback_failed(); - return Err(err); - } - }; - self.handle_thread_rollback_response(thread_id, *num_turns, &response) - .await; - Ok(true) - } AppCommand::Review { target } => { let response = app_server.review_start(thread_id, target.clone()).await?; let review_thread_id = ThreadId::from_string(&response.review_thread_id) @@ -1278,7 +1264,7 @@ impl App { /// refreshes from the backend. Refresh failures are treated as "thread is only inspectable by /// historical id now" and converted into closed picker entries instead of deleting them, so /// the stable traversal order remains intact for review and keyboard navigation. - pub(super) async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> { + pub(super) async fn drain_active_thread_events(&mut self) -> Result<()> { let Some(mut rx) = self.active_thread_rx.take() else { return Ok(()); }; @@ -1301,9 +1287,6 @@ impl App { self.clear_active_thread().await; } - if self.backtrack_render_pending { - tui.frame_requester().schedule_frame(); - } Ok(()) } @@ -1424,40 +1407,6 @@ impl App { self.chat_widget.handle_skills_list_response(response); } - pub(super) async fn handle_thread_rollback_response( - &mut self, - thread_id: ThreadId, - num_turns: u32, - response: &ThreadRollbackResponse, - ) { - if let Some(channel) = self.thread_event_channels.get(&thread_id) { - let mut store = channel.store.lock().await; - store.apply_thread_rollback(response); - } - if self.active_thread_id == Some(thread_id) - && let Some(mut rx) = self.active_thread_rx.take() - { - let mut disconnected = false; - loop { - match rx.try_recv() { - Ok(_) => {} - Err(TryRecvError::Empty) => break, - Err(TryRecvError::Disconnected) => { - disconnected = true; - break; - } - } - } - - if !disconnected { - self.active_thread_rx = Some(rx); - } else { - self.clear_active_thread().await; - } - } - self.handle_backtrack_rollback_succeeded(num_turns); - } - pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) { let needs_refresh = matches!( &event, @@ -1570,9 +1519,6 @@ impl App { self.pending_shutdown_exit_thread_id = None; } self.handle_thread_event_now(event); - if self.backtrack_render_pending { - tui.frame_requester().schedule_frame(); - } Ok(()) } } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 704d6bbaa2..14b1f027d2 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -26,7 +26,6 @@ use std::any::TypeId; use std::sync::Arc; use crate::app::App; -use crate::app_command::AppCommand; use crate::app_event::AppEvent; use crate::bottom_pane::LocalImageAttachment; use crate::chatwidget::UserMessage; @@ -63,11 +62,6 @@ pub(crate) struct BacktrackState { pub(crate) nth_user_message: usize, /// True when the transcript overlay is showing a backtrack preview. pub(crate) overlay_preview_active: bool, - /// Output-free cancellation rollback awaiting confirmation from app-server. - /// - /// Transcript prompt editing no longer uses rollback, but the legacy early-interrupt path - /// remains until the final PR in the stack. - pub(crate) pending_rollback: Option, } /// A completed canonical turn selected for editing on a source-preserving branch. @@ -79,12 +73,6 @@ pub(crate) struct BacktrackSelection { pub(crate) prompt: UserMessage, } -#[derive(Debug, Clone)] -pub(crate) struct PendingCancelledTurnRollback { - nth_user_message: usize, - thread_id: Option, -} - impl App { /// Route overlay events while the transcript overlay is active. /// @@ -166,24 +154,6 @@ impl App { } } - /// Preserve the existing output-free cancellation behavior while transcript editing migrates - /// to source-preserving forks. - pub(crate) fn apply_cancelled_turn_edit(&mut self, prompt: UserMessage) { - if self.backtrack.pending_rollback.is_some() { - self.chat_widget - .add_error_message("Backtrack rollback already in progress.".to_string()); - return; - } - - self.backtrack.pending_rollback = Some(PendingCancelledTurnRollback { - nth_user_message: user_count(&self.transcript_cells).saturating_sub(1), - thread_id: self.chat_widget.thread_id(), - }); - self.chat_widget - .submit_op(AppCommand::thread_rollback(/*num_turns*/ 1)); - self.chat_widget.restore_user_message_to_composer(prompt); - } - /// Open transcript overlay (enters alternate screen and shows full transcript). pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { let _ = tui.enter_alt_screen(); @@ -495,67 +465,6 @@ impl App { )); } - pub(crate) fn handle_backtrack_rollback_succeeded(&mut self, num_turns: u32) { - if self.backtrack.pending_rollback.is_some() { - self.finish_pending_cancelled_turn_rollback(); - } else { - self.app_event_tx - .send(AppEvent::ApplyThreadRollback { num_turns }); - } - } - - pub(crate) fn handle_backtrack_rollback_failed(&mut self) { - self.backtrack.pending_rollback = None; - } - - /// Apply rollback semantics for legacy TUI consumers that have not migrated to forks yet. - pub(crate) fn apply_non_pending_thread_rollback(&mut self, num_turns: u32) -> bool { - if !trim_transcript_cells_drop_last_n_user_turns(&mut self.transcript_cells, num_turns) { - return false; - } - self.chat_widget.clear_pending_token_activity_refreshes(); - self.chat_widget.clear_pending_rate_limit_reset_hint(); - self.chat_widget - .truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells)); - self.sync_overlay_after_transcript_trim(); - self.backtrack_render_pending = true; - true - } - - fn finish_pending_cancelled_turn_rollback(&mut self) { - let Some(pending) = self.backtrack.pending_rollback.take() else { - return; - }; - if pending.thread_id != self.chat_widget.thread_id() { - return; - } - if trim_transcript_cells_to_nth_user(&mut self.transcript_cells, pending.nth_user_message) { - self.chat_widget.clear_pending_token_activity_refreshes(); - self.chat_widget.clear_pending_rate_limit_reset_hint(); - self.chat_widget - .truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells)); - self.sync_overlay_after_transcript_trim(); - self.backtrack_render_pending = true; - } - } - - fn sync_overlay_after_transcript_trim(&mut self) { - if let Some(Overlay::Transcript(overlay)) = &mut self.overlay { - overlay.replace_cells(self.transcript_cells.clone()); - } - if self.backtrack.overlay_preview_active { - let count = - forkable_turn_count(&self.transcript_cells, self.chat_widget.active_turn_id()); - let next_selection = if count == 0 { - usize::MAX - } else { - self.backtrack.nth_user_message.min(count.saturating_sub(1)) - }; - self.apply_backtrack_selection_internal(next_selection); - } - self.deferred_history_lines.clear(); - } - 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) { @@ -603,78 +512,6 @@ impl App { } } -fn trim_transcript_cells_to_nth_user( - transcript_cells: &mut Vec>, - nth_user_message: usize, -) -> bool { - if nth_user_message == usize::MAX { - return false; - } - - if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) { - let original_len = transcript_cells.len(); - transcript_cells.truncate(cut_idx); - return transcript_cells.len() != original_len; - } - false -} - -pub(crate) fn trim_transcript_cells_drop_last_n_user_turns( - transcript_cells: &mut Vec>, - num_turns: u32, -) -> bool { - if num_turns == 0 { - return false; - } - - let user_positions: Vec = user_positions_iter(transcript_cells).collect(); - let Some(&first_user_idx) = user_positions.first() else { - return false; - }; - - let turns_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX); - let cut_idx = if turns_from_end >= user_positions.len() { - first_user_idx - } else { - user_positions[user_positions.len() - turns_from_end] - }; - let original_len = transcript_cells.len(); - transcript_cells.truncate(cut_idx); - transcript_cells.len() != original_len -} - -pub(crate) fn user_count(cells: &[Arc]) -> usize { - user_positions_iter(cells).count() -} - -fn nth_user_position( - cells: &[Arc], - nth: usize, -) -> Option { - user_positions_iter(cells) - .enumerate() - .find_map(|(i, idx)| (i == nth).then_some(idx)) -} - -fn user_positions_iter( - cells: &[Arc], -) -> impl Iterator + '_ { - let session_start_type = TypeId::of::(); - let user_type = TypeId::of::(); - let type_of = |cell: &Arc| cell.as_any().type_id(); - - let start = cells - .iter() - .rposition(|cell| type_of(cell) == session_start_type) - .map_or(0, |idx| idx + 1); - - cells - .iter() - .enumerate() - .skip(start) - .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx)) -} - fn has_backtrack_target( cells: &[Arc], active_turn_id: Option<&str>, diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 17587231a5..1c7693a051 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -24,9 +24,7 @@ use serde_json::Value; #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) enum AppCommand { - Interrupt { - behavior: InterruptBehavior, - }, + Interrupt, CleanBackgroundTerminals, RunUserShellCommand { command: String, @@ -93,9 +91,6 @@ pub(crate) enum AppCommand { name: String, }, Shutdown, - ThreadRollback { - num_turns: u32, - }, Review { target: ReviewTarget, }, @@ -104,23 +99,9 @@ pub(crate) enum AppCommand { }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub(crate) enum InterruptBehavior { - Default, - RestorePromptIfNoOutput, -} - impl AppCommand { pub(crate) fn interrupt() -> Self { - Self::Interrupt { - behavior: InterruptBehavior::Default, - } - } - - pub(crate) fn interrupt_and_restore_prompt_if_no_output() -> Self { - Self::Interrupt { - behavior: InterruptBehavior::RestorePromptIfNoOutput, - } + Self::Interrupt } pub(crate) fn clean_background_terminals() -> Self { @@ -256,10 +237,6 @@ impl AppCommand { Self::Shutdown } - pub(crate) fn thread_rollback(num_turns: u32) -> Self { - Self::ThreadRollback { num_turns } - } - pub(crate) fn review(target: ReviewTarget) -> Self { Self::Review { target } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index ca75a624bf..62198b003e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -267,9 +267,6 @@ pub(crate) enum AppEvent { /// bubbling channels through layers of widgets. CodexOp(AppCommand), - /// Restore an output-free interrupted turn into the composer and roll it back. - RestoreCancelledTurn(UserMessage), - /// Approve one retry of a recent auto-review denial selected in the TUI. ApproveRecentAutoReviewDenial { thread_id: ThreadId, @@ -706,15 +703,6 @@ pub(crate) enum AppEvent { /// finalization. ConsolidateProposedPlan(String), - /// Apply rollback semantics to local transcript cells. - /// - /// This is emitted when rollback was not initiated by the current - /// backtrack flow so trimming occurs in AppEvent queue order relative to - /// inserted history cells. - ApplyThreadRollback { - num_turns: u32, - }, - StartCommitAnimation, StopCommitAnimation, CommitTick, diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs index 1d5ac55d95..6c1606761d 100644 --- a/codex-rs/tui/src/app_event_sender.rs +++ b/codex-rs/tui/src/app_event_sender.rs @@ -46,12 +46,6 @@ impl AppEventSender { self.send(AppEvent::CodexOp(AppCommand::interrupt())); } - pub(crate) fn interrupt_and_restore_prompt_if_no_output(&self) { - self.send(AppEvent::CodexOp( - AppCommand::interrupt_and_restore_prompt_if_no_output(), - )); - } - pub(crate) fn compact(&self) { self.send(AppEvent::CodexOp(AppCommand::compact())); } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index cea18177a6..fb737c6fa9 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -85,8 +85,6 @@ use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; -use codex_app_server_protocol::ThreadRollbackParams; -use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; use codex_app_server_protocol::ThreadSettingsUpdateParams; @@ -1097,24 +1095,6 @@ impl AppServerSession { Ok(()) } - pub(crate) async fn thread_rollback( - &mut self, - thread_id: ThreadId, - num_turns: u32, - ) -> Result { - let request_id = self.next_request_id(); - self.client - .request_typed(ClientRequest::ThreadRollback { - request_id, - params: ThreadRollbackParams { - thread_id: thread_id.to_string(), - num_turns, - }, - }) - .await - .wrap_err("thread/rollback failed in TUI") - } - pub(crate) async fn review_start( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ad30dedf5a..3a57f0a864 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -2655,7 +2655,7 @@ mod tests { while let Ok(ev) = rx.try_recv() { assert!( - !matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })), + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), "expected Esc to not send Op::Interrupt when dismissing skill popup" ); } @@ -2693,7 +2693,7 @@ mod tests { while let Ok(ev) = rx.try_recv() { assert!( - !matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })), + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), "expected Esc to not send Op::Interrupt while command popup is active" ); } @@ -2729,7 +2729,7 @@ mod tests { while let Ok(ev) = rx.try_recv() { assert!( - !matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })), + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), "expected Esc to not send Op::Interrupt while typing `/agent`" ); } @@ -2774,7 +2774,7 @@ mod tests { while let Ok(ev) = rx.try_recv() { assert!( - !matches!(ev, AppEvent::CodexOp(Op::Interrupt { .. })), + !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), "expected Esc release after dismissing agent picker to not interrupt" ); } @@ -2804,7 +2804,7 @@ mod tests { pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( - matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))), + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), "expected Esc to send Op::Interrupt while a task is running" ); } @@ -2828,7 +2828,7 @@ mod tests { pane.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)); assert!( - matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))), + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), "expected configured key to interrupt while `/agent` is being edited" ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3258d0e458..60089f15e7 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -663,7 +663,6 @@ pub(crate) struct ChatWidget { // order. suppress_initial_user_message_submit: bool, input_queue: InputQueueState, - cancel_edit: CancelEditState, /// Main chat-surface bindings resolved from `tui.keymap.chat`. chat_keymap: ChatKeymap, /// Keybinding to show for popping the most-recently queued message back @@ -786,13 +785,6 @@ pub(crate) enum InterruptedTurnNoticeMode { Suppress, } -#[derive(Debug, Default)] -struct CancelEditState { - prompt: Option, - eligible: bool, - armed: bool, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ReplayKind { ResumeInitialMessages, @@ -1218,9 +1210,6 @@ impl ChatWidget { } fn add_boxed_history(&mut self, cell: Box) { - if self.turn_lifecycle.agent_turn_running && !cell.display_lines(u16::MAX).is_empty() { - self.record_visible_turn_activity(); - } // Keep the placeholder session header as the active cell until real session info arrives, // so we can merge headers instead of committing a duplicate box to history. let keep_placeholder_header_active = !self.is_session_configured() @@ -1854,12 +1843,7 @@ impl ChatWidget { } pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) { - if let AppCommand::Interrupt { behavior } = op - && self.turn_lifecycle.agent_turn_running - { - if *behavior == crate::app_command::InterruptBehavior::RestorePromptIfNoOutput { - self.arm_cancel_edit(); - } + if matches!(op, AppCommand::Interrupt) && self.turn_lifecycle.agent_turn_running { if let Some(controller) = self.stream_controller.as_mut() { controller.clear_queue(); } diff --git a/codex-rs/tui/src/chatwidget/command_lifecycle.rs b/codex-rs/tui/src/chatwidget/command_lifecycle.rs index 8e6630f8b3..6f13d37987 100644 --- a/codex-rs/tui/src/chatwidget/command_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/command_lifecycle.rs @@ -240,7 +240,6 @@ impl ChatWidget { } pub(crate) fn handle_command_execution_started_now(&mut self, item: ThreadItem) { - self.record_visible_turn_activity(); let ThreadItem::CommandExecution { id, command, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 5e5286962e..96ae7b8dfc 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -198,7 +198,6 @@ impl ChatWidget { forked_from: None, interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default, input_queue: InputQueueState::default(), - cancel_edit: CancelEditState::default(), chat_keymap, queued_message_edit_hint_binding, show_welcome_banner: is_first_run, diff --git a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs index 54a3e7eee3..d4682d5431 100644 --- a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs @@ -15,7 +15,6 @@ impl ChatWidget { } pub(super) fn on_hook_started(&mut self, run: codex_app_server_protocol::HookRunSummary) { - self.record_visible_turn_activity(); self.flush_answer_stream_with_separator(); self.flush_completed_hook_output(); match self.active_hook_cell.as_mut() { diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index bcc4e956a7..e08c1939cc 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -6,38 +6,6 @@ use super::user_messages::remap_colliding_paste_placeholders; use super::*; impl ChatWidget { - pub(super) fn record_cancel_edit_candidate(&mut self, prompt: UserMessage) { - self.cancel_edit.prompt = Some(prompt); - self.cancel_edit.eligible = true; - self.cancel_edit.armed = false; - } - - pub(super) fn record_visible_turn_activity(&mut self) { - self.cancel_edit.eligible = false; - self.cancel_edit.armed = false; - } - - pub(super) fn arm_cancel_edit(&mut self) { - self.cancel_edit.armed = self.cancel_edit.eligible - && self.cancel_edit.prompt.is_some() - && self.bottom_pane.composer_is_empty() - && self.input_queue.pending_steers.is_empty() - && !self.has_queued_follow_up_messages() - && !self.active_side_conversation; - } - - fn take_armed_cancel_edit_prompt(&mut self, reason: TurnAbortReason) -> Option { - (reason == TurnAbortReason::Interrupted - && self.cancel_edit.armed - && self.cancel_edit.eligible) - .then(|| self.cancel_edit.prompt.take()) - .flatten() - } - - pub(super) fn clear_cancel_edit(&mut self) { - self.cancel_edit = CancelEditState::default(); - } - pub(crate) fn set_initial_user_message_submit_suppressed(&mut self, suppressed: bool) { self.suppress_initial_user_message_submit = suppressed; } @@ -147,15 +115,12 @@ impl ChatWidget { /// When there are queued user messages, restore them into the composer /// separated by newlines rather than auto-submitting the next one. pub(super) fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { - let cancelled_prompt = self.take_armed_cancel_edit_prompt(reason); // Finalize, log a gentle prompt, and clear running state. self.finalize_turn(); let send_pending_steers_immediately = self.input_queue.submit_pending_steers_after_interrupt; self.input_queue.submit_pending_steers_after_interrupt = false; - if cancelled_prompt.is_none() - && self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress - { + if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress { if send_pending_steers_immediately { self.add_to_history(history_cell::new_info_event( "Model interrupted to submit steer instructions.".to_owned(), @@ -189,10 +154,6 @@ impl ChatWidget { self.restore_composer_state(combined); } self.refresh_pending_input_preview(); - if let Some(prompt) = cancelled_prompt { - self.app_event_tx - .send(AppEvent::RestoreCancelledTurn(prompt)); - } self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 9f774c94bc..c925fc55cd 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -400,7 +400,6 @@ impl ChatWidget { }, &history_record, ); - self.record_cancel_edit_candidate(prompt.clone()); self.record_safety_buffering_prompt(prompt); } diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 48b0a488d7..280adabe77 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -255,14 +255,6 @@ impl ChatWidget { self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard); } - pub(crate) fn truncate_agent_copy_history_to_user_turn_count( - &mut self, - user_turn_count: usize, - ) { - self.transcript - .truncate_copy_history_to_user_turn_count(user_turn_count); - } - /// Inner implementation with an injectable clipboard backend for testing. pub(super) fn copy_last_agent_markdown_with( &mut self, @@ -281,11 +273,6 @@ impl ChatWidget { "Copy failed: {error}" ))), }, - _ if self.transcript.copy_history_evicted_by_rollback => { - self.add_to_history(history_cell::new_error_event(format!( - "Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy." - ))); - } _ => self.add_to_history(history_cell::new_error_event( "No agent response to copy".into(), )), @@ -403,7 +390,7 @@ impl ChatWidget { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.bottom_pane.clear_quit_shortcut_hint(); - if self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) { + if self.submit_op(AppCommand::interrupt()) { self.pause_active_goal_for_interrupt(); } } else { @@ -421,9 +408,7 @@ impl ChatWidget { self.arm_quit_shortcut(key); - if self.is_cancellable_work_active() - && self.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()) - { + if self.is_cancellable_work_active() && self.submit_op(AppCommand::interrupt()) { self.pause_active_goal_for_interrupt(); } } diff --git a/codex-rs/tui/src/chatwidget/safety_buffering.rs b/codex-rs/tui/src/chatwidget/safety_buffering.rs index c4cca3a8d1..3d15e15ed4 100644 --- a/codex-rs/tui/src/chatwidget/safety_buffering.rs +++ b/codex-rs/tui/src/chatwidget/safety_buffering.rs @@ -44,7 +44,6 @@ impl ChatWidget { /// leave a ghost prompt in the transcript. pub(crate) fn prepare_safety_buffered_retry_submission(&mut self, prompt: UserMessage) { self.input_queue.user_turn_pending_start = true; - self.record_cancel_edit_candidate(prompt.clone()); self.record_safety_buffering_prompt(prompt); } diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index e0d90dad9b..2c1c65d8c6 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -116,9 +116,6 @@ impl ChatWidget { if self.active_mode_kind() != ModeKind::Plan { return; } - if !delta.is_empty() { - self.record_visible_turn_activity(); - } if !self.transcript.plan_item_active { self.transcript.plan_item_active = true; self.transcript.plan_delta_buffer.clear(); @@ -389,7 +386,6 @@ impl ChatWidget { #[inline] pub(super) fn handle_streaming_delta(&mut self, delta: String) { if !delta.is_empty() { - self.record_visible_turn_activity(); self.mark_safety_buffering_agent_message_started(); } if self.stream_controller.is_none() { diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 128a36a10d..cb747105d8 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -238,9 +238,6 @@ async fn safety_buffered_retry_preparation_renders_identical_prompt_and_preserve chat.prepare_safety_buffered_retry_submission(prompt.clone()); assert!(drain_insert_history(&mut rx).is_empty()); - assert_eq!(chat.cancel_edit.prompt, Some(prompt.clone())); - assert!(chat.cancel_edit.eligible); - assert!(!chat.cancel_edit.armed); let retry_turn_id = "retry-turn"; chat.record_safety_buffering_turn(retry_turn_id.to_string(), &turn); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index f54cec30fb..78ef554f2f 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -7,7 +7,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; -use std::collections::HashMap; use std::collections::VecDeque; #[tokio::test] @@ -929,66 +928,91 @@ async fn empty_enter_during_task_does_not_queue() { } #[tokio::test] -async fn output_free_interrupted_turn_requests_prompt_restore() { +async fn output_free_esc_interrupt_keeps_prompt_and_opens_blank_composer() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let prompt = UserMessage::from("revise this prompt"); - chat.record_cancel_edit_candidate(prompt.clone()); + let prompt = "revise this prompt"; + chat.thread_id = Some(ThreadId::new()); + chat.submit_user_message(UserMessage::from(prompt)); + assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. }); handle_turn_started(&mut chat, "turn-1"); + chat.bottom_pane.ensure_status_indicator(); - chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); - assert_matches!( - op_rx.try_recv(), - Ok(Op::Interrupt { - behavior: crate::app_command::InterruptBehavior::RestorePromptIfNoOutput, - }) + let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + assert!(chat.bottom_pane.is_task_running()); + assert!(chat.bottom_pane.status_indicator_visible()); + assert!(chat.bottom_pane.should_interrupt_running_task(esc)); + chat.handle_key_event(esc); + + let mut saw_prompt = false; + loop { + match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => { + if let Some(cell) = cell.as_any().downcast_ref::() { + assert_eq!(cell.message, prompt); + saw_prompt = true; + } + } + Ok(AppEvent::CodexOp(Op::Interrupt)) => break, + Ok(_) => {} + Err(error) => panic!("expected Esc interrupt command, got {error:?}"), + } + } + + handle_turn_interrupted(&mut chat, "turn-1"); + + let mut interrupted_history = String::new(); + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + if let Some(cell) = cell.as_any().downcast_ref::() { + assert_eq!(cell.message, prompt); + saw_prompt = true; + } + interrupted_history + .push_str(&lines_to_single_string(&cell.display_lines(/*width*/ 80))); + } + } + assert!(saw_prompt, "expected the submitted prompt in history"); + assert!( + interrupted_history + .contains("Conversation interrupted - tell the model what to do differently."), + "expected normal interruption notice, got {interrupted_history:?}" ); - handle_turn_interrupted(&mut chat, "turn-1"); - - assert_matches!(rx.try_recv(), Ok(AppEvent::RestoreCancelledTurn(restored)) if restored == prompt); + assert!(chat.bottom_pane.composer_text().is_empty()); } #[tokio::test] -async fn visible_output_prevents_cancelled_turn_prompt_restore() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.record_cancel_edit_candidate(UserMessage::from("revise this prompt")); +async fn output_free_ctrl_c_interrupt_keeps_prompt_and_opens_blank_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let prompt = "revise this prompt"; + chat.thread_id = Some(ThreadId::new()); + chat.submit_user_message(UserMessage::from(prompt)); + assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. }); handle_turn_started(&mut chat, "turn-1"); - chat.on_agent_message_delta("visible output".to_string()); - chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + next_interrupt_op(&mut op_rx); handle_turn_interrupted(&mut chat, "turn-1"); + let mut saw_prompt = false; + let mut interrupted_history = String::new(); while let Ok(event) = rx.try_recv() { - assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_))); - } -} - -#[tokio::test] -async fn thinking_status_keeps_cancelled_turn_prompt_restore_eligible() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let prompt = UserMessage::from("revise this prompt"); - chat.record_cancel_edit_candidate(prompt.clone()); - handle_turn_started(&mut chat, "turn-1"); - chat.on_agent_reasoning_delta("**Thinking**".to_string()); - chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); - - handle_turn_interrupted(&mut chat, "turn-1"); - - assert_matches!(rx.try_recv(), Ok(AppEvent::RestoreCancelledTurn(restored)) if restored == prompt); -} - -#[tokio::test] -async fn patch_activity_prevents_cancelled_turn_prompt_restore() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.record_cancel_edit_candidate(UserMessage::from("revise this prompt")); - handle_turn_started(&mut chat, "turn-1"); - chat.on_patch_apply_begin(HashMap::new()); - chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); - - handle_turn_interrupted(&mut chat, "turn-1"); - - while let Ok(event) = rx.try_recv() { - assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_))); + if let AppEvent::InsertHistoryCell(cell) = event { + if let Some(cell) = cell.as_any().downcast_ref::() { + assert_eq!(cell.message, prompt); + saw_prompt = true; + } + interrupted_history + .push_str(&lines_to_single_string(&cell.display_lines(/*width*/ 80))); + } } + assert!(saw_prompt, "expected the submitted prompt in history"); + assert!( + interrupted_history + .contains("Conversation interrupted - tell the model what to do differently."), + "expected normal interruption notice, got {interrupted_history:?}" + ); + assert!(chat.bottom_pane.composer_text().is_empty()); } #[tokio::test] @@ -1031,7 +1055,7 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() { chat.handle_key_event(esc); match op_rx.try_recv() { - Ok(Op::Interrupt { .. }) => {} + Ok(Op::Interrupt) => {} other => panic!("expected Op::Interrupt, got {other:?}"), } assert!(chat.input_queue.submit_pending_steers_after_interrupt); @@ -1057,7 +1081,7 @@ async fn pending_steer_interrupt_uses_remapped_binding() { chat.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)); match op_rx.try_recv() { - Ok(Op::Interrupt { .. }) => {} + Ok(Op::Interrupt) => {} other => panic!("expected Op::Interrupt, got {other:?}"), } assert!(chat.input_queue.submit_pending_steers_after_interrupt); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 0a88b46744..7971a70749 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -228,7 +228,7 @@ pub(super) fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { loop { match op_rx.try_recv() { - Ok(Op::Interrupt { .. }) => return, + Ok(Op::Interrupt) => return, Ok(_) => continue, Err(TryRecvError::Empty) => panic!("expected interrupt op but queue was empty"), Err(TryRecvError::Disconnected) => panic!("expected interrupt op but channel closed"), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index d683aa3fbf..41a7ffc8be 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -2056,62 +2056,6 @@ async fn queued_menu_slash_keeps_agent_turn_complete_notification() { assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); } -#[tokio::test] -async fn slash_copy_uses_latest_surviving_response_after_rollback() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot); - replay_agent_message( - &mut chat, - "agent-1", - "foo response", - ReplayKind::ThreadSnapshot, - ); - replay_user_message_text(&mut chat, "user-2", "bar", ReplayKind::ThreadSnapshot); - replay_agent_message( - &mut chat, - "agent-2", - "bar response", - ReplayKind::ThreadSnapshot, - ); - let _ = drain_insert_history(&mut rx); - assert_eq!(chat.last_agent_markdown_text(), Some("bar response")); - - chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 1); - - assert_eq!(chat.last_agent_markdown_text(), Some("foo response")); - chat.copy_last_agent_markdown_with(|markdown| { - assert_eq!(markdown, "foo response"); - Ok(None) - }); -} - -#[tokio::test] -async fn slash_copy_reports_when_rewind_exceeds_retained_copy_history() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot); - replay_agent_message( - &mut chat, - "agent-1", - "foo response", - ReplayKind::ThreadSnapshot, - ); - let _ = drain_insert_history(&mut rx); - - chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 0); - chat.dispatch_command(SlashCommand::Copy); - - let cells = drain_insert_history(&mut rx); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains( - "Cannot copy that response after rewinding. Only the most recent 32 responses are available to /copy." - ), - "expected evicted-history message, got {rendered:?}" - ); -} - #[tokio::test] async fn slash_exit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 22f7c828a6..5513634265 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1519,7 +1519,7 @@ async fn streaming_final_answer_keeps_task_running_state() { chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); match op_rx.try_recv() { - Ok(Op::Interrupt { .. }) => {} + Ok(Op::Interrupt) => {} other => panic!("expected Op::Interrupt, got {other:?}"), } assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); @@ -1544,7 +1544,7 @@ async fn esc_interrupt_pauses_active_goal_turn() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); + assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))); assert_goal_paused_event(&mut rx, thread_id); update_thread_goal(&mut chat, thread_id, AppThreadGoalStatus::Paused); @@ -1581,7 +1581,7 @@ async fn request_user_input_interrupt_pauses_active_goal_turn() { chat.handle_key_event(key_event); - assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt { .. }))); + assert_matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))); assert_goal_paused_event(&mut rx, thread_id); } } diff --git a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs index 99d84d155b..cc570e6171 100644 --- a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs @@ -8,12 +8,10 @@ use codex_utils_path_uri::LegacyAppPathString; impl ChatWidget { pub(super) fn on_patch_apply_begin(&mut self, changes: HashMap) { - self.record_visible_turn_activity(); self.add_to_history(history_cell::new_patch_event(changes, &self.config.cwd)); } pub(super) fn on_view_image_tool_call(&mut self, path: LegacyAppPathString) { - self.record_visible_turn_activity(); self.flush_answer_stream_with_separator(); self.add_to_history(history_cell::new_view_image_tool_call( path, @@ -23,7 +21,6 @@ impl ChatWidget { } pub(super) fn on_image_generation_begin(&mut self) { - self.record_visible_turn_activity(); self.flush_answer_stream_with_separator(); } @@ -69,7 +66,6 @@ impl ChatWidget { } pub(super) fn on_web_search_begin(&mut self, call_id: String) { - self.record_visible_turn_activity(); self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.transcript.active_cell = Some(Box::new(history_cell::new_active_web_search_call( @@ -116,7 +112,6 @@ impl ChatWidget { } pub(super) fn on_collab_agent_tool_call(&mut self, item: ThreadItem) { - self.record_visible_turn_activity(); let ThreadItem::CollabAgentToolCall { id, tool, status, .. } = &item @@ -148,7 +143,6 @@ impl ChatWidget { } pub(super) fn on_sub_agent_activity(&mut self, item: ThreadItem) { - self.record_visible_turn_activity(); if let Some(cell) = multi_agents::sub_agent_activity_history_cell(&item) { self.on_collab_event(cell); } @@ -168,7 +162,6 @@ impl ChatWidget { } pub(crate) fn handle_mcp_tool_call_started_now(&mut self, item: ThreadItem) { - self.record_visible_turn_activity(); let ThreadItem::McpToolCall { id, server, diff --git a/codex-rs/tui/src/chatwidget/tool_requests.rs b/codex-rs/tui/src/chatwidget/tool_requests.rs index f3ff2391f6..babfd3fb03 100644 --- a/codex-rs/tui/src/chatwidget/tool_requests.rs +++ b/codex-rs/tui/src/chatwidget/tool_requests.rs @@ -7,7 +7,6 @@ use super::*; impl ChatWidget { pub(super) fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) { - self.record_visible_turn_activity(); let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_exec_approval(ev), @@ -20,7 +19,6 @@ impl ChatWidget { _id: String, ev: ApplyPatchApprovalRequestEvent, ) { - self.record_visible_turn_activity(); let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_apply_patch_approval(ev), @@ -258,7 +256,6 @@ impl ChatWidget { request_id: AppServerRequestId, params: McpServerElicitationRequestParams, ) { - self.record_visible_turn_activity(); let request_id2 = request_id.clone(); let params2 = params.clone(); self.defer_or_handle( @@ -268,7 +265,6 @@ impl ChatWidget { } pub(super) fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) { - self.record_visible_turn_activity(); let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_user_input(ev), @@ -277,7 +273,6 @@ impl ChatWidget { } pub(super) fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) { - self.record_visible_turn_activity(); let ev2 = ev.clone(); self.defer_or_handle( |q| q.push_request_permissions(ev), diff --git a/codex-rs/tui/src/chatwidget/transcript.rs b/codex-rs/tui/src/chatwidget/transcript.rs index 22b046728b..97dd6a7194 100644 --- a/codex-rs/tui/src/chatwidget/transcript.rs +++ b/codex-rs/tui/src/chatwidget/transcript.rs @@ -14,17 +14,13 @@ pub(super) struct TranscriptState { pub(super) active_cell: Option>, /// Monotonic-ish counter used to invalidate transcript overlay caching. pub(super) active_cell_revision: u64, - /// Raw markdown of the most recently completed agent response that - /// survived any local thread rollback. + /// Raw markdown of the most recently completed agent response. pub(super) last_agent_markdown: Option, /// Copyable agent responses keyed by the number of visible user turns at /// the time the response completed. pub(super) agent_turn_markdowns: Vec, /// Number of user turns currently reflected in the visible transcript. pub(super) visible_user_turn_count: usize, - /// True when rollback discarded the requested copy source because it was - /// older than the retained copy history. - pub(super) copy_history_evicted_by_rollback: bool, /// Raw markdown of the most recently completed proposed plan. pub(super) latest_proposed_plan_markdown: Option, /// Whether this turn already produced a copyable response. @@ -76,7 +72,6 @@ impl TranscriptState { } } self.last_agent_markdown = Some(markdown); - self.copy_history_evicted_by_rollback = false; self.saw_copy_source_this_turn = true; } @@ -88,21 +83,6 @@ impl TranscriptState { self.last_agent_markdown = None; self.agent_turn_markdowns.clear(); self.visible_user_turn_count = 0; - self.copy_history_evicted_by_rollback = false; - self.saw_copy_source_this_turn = false; - } - - pub(super) fn truncate_copy_history_to_user_turn_count(&mut self, user_turn_count: usize) { - self.visible_user_turn_count = user_turn_count; - let had_copy_history = !self.agent_turn_markdowns.is_empty(); - self.agent_turn_markdowns - .retain(|entry| entry.user_turn_count <= user_turn_count); - self.last_agent_markdown = self - .agent_turn_markdowns - .last() - .map(|entry| entry.markdown.clone()); - self.copy_history_evicted_by_rollback = - had_copy_history && self.last_agent_markdown.is_none(); self.saw_copy_source_this_turn = false; } @@ -134,18 +114,4 @@ mod tests { assert_eq!(state.active_cell_revision, 0); } - - #[test] - fn copy_history_tracks_latest_visible_turn() { - let mut state = TranscriptState::default(); - state.record_visible_user_turn(); - state.record_agent_markdown("first".to_string()); - state.record_visible_user_turn(); - state.record_agent_markdown("second".to_string()); - - state.truncate_copy_history_to_user_turn_count(/*user_turn_count*/ 1); - - assert_eq!(state.last_agent_markdown.as_deref(), Some("first")); - assert!(!state.copy_history_evicted_by_rollback); - } } diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 6ab68ff15f..9740fc9529 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -332,7 +332,6 @@ impl ChatWidget { self.plan_stream_controller = None; self.request_pending_usage_output_insertion_after_stream_shutdown(); self.status_state.pending_status_indicator_restore = false; - self.clear_cancel_edit(); self.request_status_line_branch_refresh(); self.request_status_line_git_summary_refresh(); self.maybe_show_pending_rate_limit_prompt(); diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 9c5935fb4a..640438557b 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -803,11 +803,6 @@ impl TranscriptOverlay { pub(crate) fn is_done(&self) -> bool { self.is_done } - - #[cfg(test)] - pub(crate) fn committed_cell_count(&self) -> usize { - self.cells.len() - } } pub(crate) struct StaticOverlay { diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 622c4f989e..0438c206d5 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -101,8 +101,7 @@ impl StatusIndicatorWidget { } pub(crate) fn interrupt(&self) { - self.app_event_tx - .interrupt_and_restore_prompt_if_no_output(); + self.app_event_tx.interrupt(); } /// Update the animated header label (left of the brackets).