diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index fa320f9b..08ebcefc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -259,7 +259,7 @@ impl ChatComposer { /// Replace the entire composer content with `text` and reset cursor. pub(crate) fn set_text_content(&mut self, text: String) { self.textarea.set_text(&text); - self.textarea.set_cursor(0); + self.textarea.set_cursor(usize::MAX); self.sync_command_popup(); self.sync_file_search_popup(); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2f8a34fe..bfc9104c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -243,20 +243,51 @@ impl ChatWidget { ); } - fn on_error(&mut self, message: String) { - // Before emitting the error message, finalize the active exec as failed - // so spinners are replaced with a red ✗ marker. + /// Finalize any active exec as failed, push an error message into history, + /// and stop/clear running UI state. + fn finalize_turn_with_error_message(&mut self, message: String) { + // Ensure any spinner is replaced by a red ✗ and flushed into history. self.finalize_active_exec_cell_as_failed(); + // Emit the provided error message/history cell. self.add_to_history(history_cell::new_error_event(message)); + // Reset running state and clear streaming buffers. self.bottom_pane.set_task_running(false); self.running_commands.clear(); self.stream.clear_all(); + } + + fn on_error(&mut self, message: String) { + self.finalize_turn_with_error_message(message); self.request_redraw(); // After an error ends the turn, try sending the next queued input. self.maybe_send_next_queued_input(); } + /// Handle a turn aborted due to user interrupt (Esc). + /// When there are queued user messages, restore them into the composer + /// separated by newlines rather than auto‑submitting the next one. + fn on_interrupted_turn(&mut self) { + // Finalize, log a gentle prompt, and clear running state. + self.finalize_turn_with_error_message("Tell the model what to do differently".to_owned()); + + // If any messages were queued during the task, restore them into the composer. + if !self.queued_user_messages.is_empty() { + let combined = self + .queued_user_messages + .iter() + .map(|m| m.text.clone()) + .collect::>() + .join("\n"); + self.bottom_pane.set_composer_text(combined); + // Clear the queue and update the status indicator list. + self.queued_user_messages.clear(); + self.refresh_queued_user_messages(); + } + + self.request_redraw(); + } + fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) { self.add_to_history(history_cell::new_plan_update(update)); } @@ -913,7 +944,7 @@ impl ChatWidget { EventMsg::Error(ErrorEvent { message }) => self.on_error(message), EventMsg::TurnAborted(ev) => match ev.reason { TurnAbortReason::Interrupted => { - self.on_error("Tell the model what to do differently".to_owned()) + self.on_interrupted_turn(); } TurnAbortReason::Replaced => { self.on_error("Turn aborted: replaced by a new task".to_owned()) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fdfd7041..6f939c62 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -763,6 +763,45 @@ fn approval_modal_patch_snapshot() { assert_snapshot!("approval_modal_patch", terminal.backend()); } +#[test] +fn interrupt_restores_queued_messages_into_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + + // Simulate a running task to enable queuing of user inputs. + chat.bottom_pane.set_task_running(true); + + // Queue two user messages while the task is running. + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_queued_user_messages(); + + // Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed). + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: codex_core::protocol::TurnAbortReason::Interrupted, + }), + }); + + // Composer should now contain the queued messages joined by newlines, in order. + assert_eq!( + chat.bottom_pane.composer_text(), + "first queued\nsecond queued" + ); + + // Queue should be cleared and no new user input should have been auto-submitted. + assert!(chat.queued_user_messages.is_empty()); + assert!( + op_rx.try_recv().is_err(), + "unexpected outbound op after interrupt" + ); + + // Drain rx to avoid unused warnings. + let _ = drain_insert_history(&mut rx); +} + // Snapshot test: ChatWidget at very small heights (idle) // Ensures overall layout behaves when terminal height is extremely constrained. #[test]