From 70a0b1eef87c4fd1398ffc77776033e5efafa645 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 15 Jul 2026 01:01:32 +0000 Subject: [PATCH] Keep interrupted prompts in conversation history (#33198) ## What changed - Leave an output-free interrupted prompt in the transcript and open a blank composer for the user's next instruction. - Use the normal interruption notice for both `Esc` and `Ctrl-C` interrupts. - Track safety-buffering prompts separately so failed retries can still restore the prompt, including after switching between threads. ## Testing - Cover output-free `Esc` and `Ctrl-C` interrupts, failed safety-buffer retries, and restoration of safety-buffer state from thread snapshots. GitOrigin-RevId: 30b368d00dfdb981d06d4c95ce3f252389b7917c --- codex-rs/tui/src/app/event_dispatch.rs | 3 - ...cancelled_turn_edit_restores_composer.snap | 5 - codex-rs/tui/src/app/tests.rs | 110 ++++++++-------- codex-rs/tui/src/app/thread_routing.rs | 2 +- codex-rs/tui/src/app_backtrack.rs | 33 ----- codex-rs/tui/src/app_command.rs | 20 +-- codex-rs/tui/src/app_event.rs | 3 - codex-rs/tui/src/app_event_sender.rs | 6 - codex-rs/tui/src/bottom_pane/mod.rs | 12 +- codex-rs/tui/src/chatwidget.rs | 19 +-- .../tui/src/chatwidget/command_lifecycle.rs | 1 - codex-rs/tui/src/chatwidget/constructor.rs | 2 +- codex-rs/tui/src/chatwidget/hook_lifecycle.rs | 1 - codex-rs/tui/src/chatwidget/input_restore.rs | 45 +------ .../tui/src/chatwidget/input_submission.rs | 2 +- codex-rs/tui/src/chatwidget/interaction.rs | 6 +- .../tui/src/chatwidget/safety_buffering.rs | 7 +- codex-rs/tui/src/chatwidget/streaming.rs | 4 - .../tui/src/chatwidget/tests/app_server.rs | 14 ++ .../chatwidget/tests/composer_submission.rs | 121 +++++++++++------- codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 +- .../tui/src/chatwidget/tests/review_mode.rs | 1 + ...rrupt_keeps_prompt_and_blank_composer.snap | 12 ++ .../src/chatwidget/tests/status_and_layout.rs | 6 +- codex-rs/tui/src/chatwidget/tool_lifecycle.rs | 7 - codex-rs/tui/src/chatwidget/tool_requests.rs | 5 - codex-rs/tui/src/chatwidget/turn_runtime.rs | 2 +- codex-rs/tui/src/chatwidget/user_messages.rs | 1 + codex-rs/tui/src/status_indicator_widget.rs | 3 +- 29 files changed, 186 insertions(+), 269 deletions(-) delete mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__tests__cancelled_turn_edit_restores_composer.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__composer_submission__output_free_ctrl_c_interrupt_keeps_prompt_and_blank_composer.snap diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 10951bc39c90..736bf9dc1961 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -414,9 +414,6 @@ impl App { .add_error_message(format!("Failed to retry with a faster model: {err}")); } } - 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/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 33d1082b1fe4..000000000000 --- 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/tests.rs b/codex-rs/tui/src/app/tests.rs index ce15b2b38e85..68b2610497bd 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -561,6 +561,61 @@ async fn replay_thread_snapshot_restores_draft_and_queued_input() { } } +#[tokio::test] +async fn replay_thread_snapshot_restores_the_matching_safety_buffer_prompt() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + session.clone(), + Vec::new(), + ), + ); + app.activate_thread_channel(thread_id).await; + app.chat_widget.handle_thread_session(session); + let default_mode = CollaborationModeMask { + name: "Default".to_string(), + mode: None, + model: None, + reasoning_effort: None, + developer_instructions: None, + }; + app.chat_widget + .submit_user_message_with_mode("buffered prompt A".to_string(), default_mode.clone()); + let expected_input_state = app + .chat_widget + .capture_thread_input_state() + .expect("expected thread input state"); + + app.store_active_thread_receiver().await; + let snapshot = { + let channel = app + .thread_event_channels + .get(&thread_id) + .expect("thread channel should exist"); + let store = channel.store.lock().await; + assert_eq!(store.input_state, Some(expected_input_state.clone())); + store.snapshot() + }; + + let (mut chat_widget, _app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; + chat_widget.handle_thread_session(test_thread_session( + ThreadId::new(), + test_path_buf("/tmp/other-project"), + )); + chat_widget.submit_user_message_with_mode("buffered prompt B".to_string(), default_mode); + app.chat_widget = chat_widget; + app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false); + + assert_eq!( + app.chat_widget.capture_thread_input_state(), + Some(expected_input_state) + ); +} + #[tokio::test] async fn active_turn_id_for_thread_uses_snapshot_turns() { let mut app = make_test_app().await; @@ -5283,61 +5338,6 @@ async fn backtrack_remote_image_only_selection_clears_existing_composer_draft() assert_eq!(rollback_turns, Some(1)); } -#[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 { - 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_resubmit_preserves_data_image_urls_in_user_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index a353711072aa..952b6affa3ef 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -521,7 +521,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] { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 0870093883d6..cd1ce9d8d212 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -31,7 +31,6 @@ use std::sync::Arc; use crate::app::App; use crate::app_command::AppCommand; use crate::app_event::AppEvent; -use crate::chatwidget::UserMessage; #[cfg(test)] use crate::history_cell::AgentMessageCell; use crate::history_cell::SessionInfoCell; @@ -239,38 +238,6 @@ impl App { } } - pub(crate) fn apply_cancelled_turn_edit(&mut self, prompt: UserMessage) { - let user_total = user_count(&self.transcript_cells); - let selection = BacktrackSelection { - nth_user_message: user_total.saturating_sub(1), - prefill: prompt.text.clone(), - text_elements: prompt.text_elements.clone(), - local_image_paths: prompt - .local_images - .iter() - .map(|image| image.path.clone()) - .collect(), - remote_image_urls: prompt.remote_image_urls.clone(), - }; - if user_total == 0 { - 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(PendingBacktrackRollback { - selection, - 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); - return; - } - self.apply_backtrack_rollback(selection); - 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(); diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 17587231a54c..aa9021cd140e 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, @@ -104,23 +102,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 { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 10854db44cd6..04a85fa328a2 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -259,9 +259,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, diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs index 1d5ac55d9561..6c1606761dd7 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/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 484016c3d6e8..73046a41d829 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" ); } @@ -2694,7 +2694,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" ); } @@ -2741,7 +2741,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`" ); } @@ -2786,7 +2786,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" ); } @@ -2816,7 +2816,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" ); } @@ -2840,7 +2840,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 7a838f436b15..be7bcdb9141d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -664,7 +664,7 @@ pub(crate) struct ChatWidget { // order. suppress_initial_user_message_submit: bool, input_queue: InputQueueState, - cancel_edit: CancelEditState, + safety_buffering_prompt: Option, /// Main chat-surface bindings resolved from `tui.keymap.chat`. chat_keymap: ChatKeymap, /// Keybinding to show for popping the most-recently queued message back @@ -787,13 +787,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, @@ -1219,9 +1212,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() @@ -1840,12 +1830,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 8e6630f8b37e..6f13d379873b 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 f0f6e44446af..3cc80ce885b6 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -199,7 +199,7 @@ impl ChatWidget { forked_from: None, interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default, input_queue: InputQueueState::default(), - cancel_edit: CancelEditState::default(), + safety_buffering_prompt: None, 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 54a3e7eee3ca..d4682d5431ab 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 bcc4e956a76c..3aaa27624789 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,11 +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(); } @@ -351,6 +311,7 @@ impl ChatWidget { }; Some(ThreadInputState { composer: composer.has_content().then_some(composer), + safety_buffering_prompt: self.safety_buffering_prompt.clone(), pending_steers: self .input_queue .pending_steers @@ -389,6 +350,7 @@ impl ChatWidget { if let Some(input_state) = input_state { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; + self.safety_buffering_prompt = input_state.safety_buffering_prompt; self.turn_lifecycle .restore_running(input_state.agent_turn_running, Instant::now()); self.input_queue.user_turn_pending_start = input_state.user_turn_pending_start; @@ -434,6 +396,7 @@ impl ChatWidget { } else { self.turn_lifecycle .restore_running(/*running*/ false, Instant::now()); + self.safety_buffering_prompt = None; self.input_queue.clear(); self.restore_composer_state(Default::default()); } diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index da2207dae0c1..f1b84f4d1b16 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -390,7 +390,7 @@ impl ChatWidget { } if render_in_history { - self.record_cancel_edit_candidate(UserMessage { + self.safety_buffering_prompt = Some(UserMessage { text: text.clone(), local_images: local_images.clone(), remote_image_urls: remote_image_urls.clone(), diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index ba0aeb0a9156..1d38fbdbe472 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -403,7 +403,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 +421,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 f46423f81d78..964c2ff796e0 100644 --- a/codex-rs/tui/src/chatwidget/safety_buffering.rs +++ b/codex-rs/tui/src/chatwidget/safety_buffering.rs @@ -64,18 +64,17 @@ impl ChatWidget { } pub(crate) fn prepare_safety_buffering_retry(&mut self) { - let cancel_edit = std::mem::take(&mut self.cancel_edit); + let safety_buffering_prompt = self.safety_buffering_prompt.take(); self.last_rendered_user_message_display = None; self.finalize_turn(); - self.cancel_edit = cancel_edit; + self.safety_buffering_prompt = safety_buffering_prompt; self.input_queue.user_turn_pending_start = true; } pub(crate) fn fail_safety_buffering_retry(&mut self) { self.input_queue.user_turn_pending_start = false; self.clear_safety_buffering(); - let prompt = self.cancel_edit.prompt.take(); - self.clear_cancel_edit(); + let prompt = self.safety_buffering_prompt.take(); if let Some(prompt) = prompt { self.restore_user_message_to_composer(prompt); } diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index 010d41cdb2e0..d90c10c1c914 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -121,9 +121,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(); @@ -394,7 +391,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 eeaedab61819..d273b16314d8 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -190,6 +190,20 @@ async fn safety_buffering_remains_visible_until_turn_completes() { assert!(!render_bottom_popup(&chat, /*width*/ 80).contains(SAFETY_BUFFERING_HEADER_TEXT)); } +#[tokio::test] +async fn failed_safety_buffering_retry_restores_submitted_prompt() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + start_safety_buffering_test_turn(&mut chat, &mut op_rx); + + chat.prepare_safety_buffering_retry(); + assert!(chat.input_queue.user_turn_pending_start); + + chat.fail_safety_buffering_retry(); + + assert_eq!(chat.composer_text_with_pending(), "Explain the request"); + assert!(!chat.input_queue.user_turn_pending_start); +} + #[tokio::test] async fn safety_buffering_without_retry_shows_short_app_message() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 238bc3609c63..920b2242655a 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] @@ -928,67 +927,89 @@ async fn empty_enter_during_task_does_not_queue() { assert!(chat.input_queue.queued_user_messages.is_empty()); } -#[tokio::test] -async fn output_free_interrupted_turn_requests_prompt_restore() { - 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()); - handle_turn_started(&mut chat, "turn-1"); - - 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, - }) +fn interrupted_history( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + prompt: &str, +) -> (bool, String) { + let mut saw_prompt = false; + let mut history = Vec::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; + } + history.push(lines_to_single_string(&cell.display_lines(/*width*/ 80))); + } + } + let history = history.join("\n"); + assert!( + history.contains("Conversation interrupted - tell the model what to do differently."), + "expected normal interruption notice, got {history:?}" ); - handle_turn_interrupted(&mut chat, "turn-1"); - - assert_matches!(rx.try_recv(), Ok(AppEvent::RestoreCancelledTurn(restored)) if restored == prompt); + (saw_prompt, history) } #[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_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 = "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.bottom_pane.ensure_status_indicator(); - handle_turn_interrupted(&mut chat, "turn-1"); + let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + assert!(chat.bottom_pane.should_interrupt_running_task(esc)); + chat.handle_key_event(esc); - while let Ok(event) = rx.try_recv() { - assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_))); + 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:?}"), + } } -} - -#[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); + let (prompt_after_interrupt, _) = interrupted_history(&mut rx, prompt); + assert!(saw_prompt || prompt_after_interrupt); + assert!(chat.bottom_pane.composer_is_empty()); } #[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")); +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_patch_apply_begin(HashMap::new()); - 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"); - while let Ok(event) = rx.try_recv() { - assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_))); - } + let (saw_prompt, interrupted_history) = interrupted_history(&mut rx, prompt); + assert!(saw_prompt); + assert!(chat.bottom_pane.composer_is_empty()); + insta::assert_snapshot!( + "output_free_ctrl_c_interrupt_keeps_prompt_and_blank_composer", + format!( + "history:\n{interrupted_history}\ncomposer:\n{}", + chat.bottom_pane.composer_text() + ) + ); } #[tokio::test] @@ -1014,7 +1035,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); @@ -1040,7 +1061,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); @@ -1053,6 +1074,7 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { chat.restore_thread_input_state(Some(ThreadInputState { composer: None, + safety_buffering_prompt: Some(UserMessage::from("buffered prompt")), pending_steers: VecDeque::new(), pending_steer_history_records: VecDeque::new(), pending_steer_compare_keys: VecDeque::new(), @@ -1070,12 +1092,19 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { assert!(chat.turn_lifecycle.agent_turn_running); assert!(chat.turn_lifecycle.sleep_inhibitor.is_turn_running()); assert!(chat.bottom_pane.is_task_running()); + assert_eq!( + chat.capture_thread_input_state() + .expect("thread input state") + .safety_buffering_prompt, + Some(UserMessage::from("buffered prompt")) + ); chat.restore_thread_input_state(/*input_state*/ None); assert!(!chat.turn_lifecycle.agent_turn_running); assert!(!chat.turn_lifecycle.sleep_inhibitor.is_turn_running()); assert!(!chat.bottom_pane.is_task_running()); + assert_eq!(chat.safety_buffering_prompt, None); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index f055777bc2f0..c5f0669f0111 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -229,7 +229,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/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index e8fe8ed30088..9ef9c46a53fe 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -375,6 +375,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ chat.restore_thread_input_state(Some(ThreadInputState { composer: None, + safety_buffering_prompt: None, pending_steers, pending_steer_history_records: VecDeque::new(), pending_steer_compare_keys, diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__composer_submission__output_free_ctrl_c_interrupt_keeps_prompt_and_blank_composer.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__composer_submission__output_free_ctrl_c_interrupt_keeps_prompt_and_blank_composer.snap new file mode 100644 index 000000000000..d0cb7e3c4813 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__composer_submission__output_free_ctrl_c_interrupt_keeps_prompt_and_blank_composer.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/composer_submission.rs +expression: "format!(\"history:\\n{interrupted_history}\\ncomposer:\\n{}\",\nchat.bottom_pane.composer_text())" +--- +history: + +› revise this prompt + + +■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue. + +composer: 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 5c72382357dd..e55364cae859 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1955,7 +1955,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()); @@ -2019,7 +2019,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); @@ -2056,7 +2056,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 2b8f5b1bc80a..9d62abaf8660 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(); if self.bottom_pane.is_task_running() { self.bottom_pane.ensure_status_indicator(); @@ -72,7 +69,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( @@ -119,7 +115,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 @@ -151,7 +146,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); } @@ -171,7 +165,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 f3ff2391f64c..babfd3fb0341 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/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 6ab68ff15f2b..ffee8ccb0461 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -332,7 +332,7 @@ 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.safety_buffering_prompt = None; 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/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs index 6cfb739da546..d5a00b47adc0 100644 --- a/codex-rs/tui/src/chatwidget/user_messages.rs +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -121,6 +121,7 @@ impl ThreadComposerState { #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadInputState { pub(super) composer: Option, + pub(super) safety_buffering_prompt: Option, pub(super) pending_steers: VecDeque, pub(super) pending_steer_history_records: VecDeque, pub(super) pending_steer_compare_keys: VecDeque, diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 622c4f989ed3..0438c206d5bb 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).