diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 898a45fa05..6b292d83f6 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2717,6 +2717,15 @@ impl App { self.chat_widget.set_model(&model); self.refresh_status_line(); } + AppEvent::ApplyOrQueueModelSelection { + model, + effort, + scope, + } => { + self.chat_widget + .apply_or_queue_model_selection(model, effort, scope); + self.refresh_status_line(); + } AppEvent::UpdateCollaborationMode(mask) => { self.chat_widget.set_collaboration_mask(mask); self.refresh_status_line(); @@ -2737,6 +2746,9 @@ impl App { AppEvent::OpenAllModelsPopup { models } => { self.chat_widget.open_all_models_popup(models); } + AppEvent::ApplyOrQueueReview { review_request } => { + self.chat_widget.apply_or_queue_review(review_request); + } AppEvent::OpenFullAccessConfirmation { preset, return_to_permissions, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index e2ed046690..da94b1cb3e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -16,6 +16,7 @@ use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::ReviewRequest; use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; @@ -53,6 +54,13 @@ impl RealtimeAudioDeviceKind { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ModelSelectionScope { + Global, + PlanOnly, + AllModes, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxEnableMode { @@ -182,6 +190,14 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Apply a model selection immediately or queue it when chosen from an interactive picker + /// while a task is still running. + ApplyOrQueueModelSelection { + model: String, + effort: Option, + scope: ModelSelectionScope, + }, + /// Update the active collaboration mask in the running app and widget. UpdateCollaborationMode(CollaborationModeMask), @@ -240,6 +256,12 @@ pub(crate) enum AppEvent { models: Vec, }, + /// Submit a review request immediately or queue it when chosen from an interactive picker + /// while a task is still running. + ApplyOrQueueReview { + review_request: ReviewRequest, + }, + /// Open the confirmation prompt before enabling full access mode. OpenFullAccessConfirmation { preset: ApprovalPreset, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7a593085cd..6b3bcdb994 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -137,11 +137,11 @@ pub(crate) enum CancellationEvent { NotHandled, } -use crate::bottom_pane::prompt_args::parse_slash_name; pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposerConfig; pub(crate) use chat_composer::InputResult; use codex_protocol::custom_prompts::CustomPrompt; +pub(crate) use prompt_args::parse_slash_name; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::status_indicator_widget::StatusIndicatorWidget; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ea7ec8dcb4..3350167dd0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -216,6 +216,7 @@ fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyB use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; +use crate::app_event::ModelSelectionScope; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; @@ -238,6 +239,7 @@ use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; +use crate::bottom_pane::parse_slash_name; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::clipboard_text; @@ -915,12 +917,20 @@ impl QueuedSlashCommand { enum QueuedSlashCommandAction { Command(SlashCommand), Fast(QueuedFastCommandAction), + ModelSelection(QueuedModelSelection), Plan(UserMessage), Rename(String), Review(ReviewRequest), SandboxReadRoot(String), } +#[derive(Debug, Clone, PartialEq)] +struct QueuedModelSelection { + model: String, + effort: Option, + scope: ModelSelectionScope, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum QueuedFastCommandAction { On, @@ -4207,11 +4217,17 @@ impl ChatWidget { if matches!(key_event.code, KeyCode::Esc) && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) - && !self.pending_steers.is_empty() && self.bottom_pane.is_task_running() - && self.bottom_pane.no_modal_or_popup_active() + && (self + .bottom_pane + .composer_text() + .lines() + .next() + .and_then(parse_slash_name) + .is_none_or(|(name, _, _)| name != "agent") + || !self.pending_steers.is_empty()) { - self.submit_pending_steers_after_interrupt = true; + self.submit_pending_steers_after_interrupt = !self.pending_steers.is_empty(); if !self.submit_op(Op::Interrupt) { self.submit_pending_steers_after_interrupt = false; } @@ -4372,6 +4388,17 @@ impl ChatWidget { fn dispatch_command(&mut self, cmd: SlashCommand) { if self.bottom_pane.is_task_running() && !cmd.available_during_task() { + match cmd { + SlashCommand::Model => { + self.open_model_popup(); + return; + } + SlashCommand::Review => { + self.open_review_popup(); + return; + } + _ => {} + } self.queue_slash_command(QueuedSlashCommand { draft: format!("/{}", cmd.command()).into(), action: QueuedSlashCommandAction::Command(cmd), @@ -4950,6 +4977,107 @@ impl ChatWidget { self.refresh_pending_input_preview(); } + fn queued_model_selection_draft( + model: &str, + effort: Option, + ) -> UserMessage { + let mut text = format!("/model {model}"); + if let Some(effort) = effort { + text.push(' '); + text.push_str(Self::status_line_reasoning_effort_label(Some(effort))); + } + text.into() + } + + fn queue_model_selection_if_task_running( + &mut self, + model: String, + effort: Option, + scope: ModelSelectionScope, + ) -> bool { + if !self.bottom_pane.is_task_running() { + return false; + } + let draft = Self::queued_model_selection_draft(&model, effort); + self.queue_slash_command(QueuedSlashCommand { + draft, + action: QueuedSlashCommandAction::ModelSelection(QueuedModelSelection { + model, + effort, + scope, + }), + }); + true + } + + pub(crate) fn apply_or_queue_model_selection( + &mut self, + model: String, + effort: Option, + scope: ModelSelectionScope, + ) { + if self.queue_model_selection_if_task_running(model.clone(), effort, scope) { + return; + } + match scope { + ModelSelectionScope::Global => { + self.set_model(&model); + self.set_reasoning_effort(effort); + self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); + self.app_event_tx + .send(AppEvent::UpdateReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::PersistModelSelection { model, effort }); + } + ModelSelectionScope::PlanOnly => { + self.set_model(&model); + self.set_plan_mode_reasoning_effort(effort); + self.app_event_tx.send(AppEvent::UpdateModel(model)); + self.app_event_tx + .send(AppEvent::UpdatePlanModeReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::PersistPlanModeReasoningEffort(effort)); + } + ModelSelectionScope::AllModes => { + self.set_model(&model); + self.set_reasoning_effort(effort); + self.set_plan_mode_reasoning_effort(effort); + self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); + self.app_event_tx + .send(AppEvent::UpdateReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::UpdatePlanModeReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::PersistPlanModeReasoningEffort(effort)); + self.app_event_tx + .send(AppEvent::PersistModelSelection { model, effort }); + } + } + } + + fn queued_review_draft(review_request: &ReviewRequest) -> UserMessage { + match &review_request.target { + ReviewTarget::UncommittedChanges => "/review current changes".into(), + ReviewTarget::BaseBranch { branch } => format!("/review {branch}").into(), + ReviewTarget::Commit { sha, title } => { + let subject = title.clone().unwrap_or_else(|| sha.clone()); + format!("/review {subject}").into() + } + ReviewTarget::Custom { instructions } => format!("/review {instructions}").into(), + } + } + + pub(crate) fn apply_or_queue_review(&mut self, review_request: ReviewRequest) { + if self.bottom_pane.is_task_running() { + self.queue_slash_command(QueuedSlashCommand { + draft: Self::queued_review_draft(&review_request), + action: QueuedSlashCommandAction::Review(review_request), + }); + return; + } + self.submit_op(Op::Review { review_request }); + } + fn submit_plan_user_message(&mut self, user_message: UserMessage) { if self.is_session_configured() { self.reasoning_buffer.clear(); @@ -4978,6 +5106,13 @@ impl ChatWidget { self.add_info_message(format!("Fast mode is {status}."), None); } }, + QueuedSlashCommandAction::ModelSelection(selection) => { + self.apply_or_queue_model_selection( + selection.model, + selection.effort, + selection.scope, + ); + } QueuedSlashCommandAction::Plan(user_message) => { self.dispatch_command(SlashCommand::Plan); if self.active_mode_kind() == ModeKind::Plan { @@ -6970,11 +7105,10 @@ impl ChatWidget { return; } - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); - tx.send(AppEvent::PersistModelSelection { + tx.send(AppEvent::ApplyOrQueueModelSelection { model: model_for_action.clone(), effort: effort_for_action, + scope: ModelSelectionScope::Global, }); })] } @@ -7042,19 +7176,18 @@ impl ChatWidget { let plan_only_actions: Vec = vec![Box::new({ let model = model.clone(); move |tx| { - tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + tx.send(AppEvent::ApplyOrQueueModelSelection { + model: model.clone(), + effort, + scope: ModelSelectionScope::PlanOnly, + }); } })]; let all_modes_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort)); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistModelSelection { + tx.send(AppEvent::ApplyOrQueueModelSelection { model: model.clone(), effort, + scope: ModelSelectionScope::AllModes, }); })]; @@ -7143,7 +7276,11 @@ impl ChatWidget { effort: selected_effort, }); } else { - self.apply_model_and_effort(selected_model, selected_effort); + self.apply_or_queue_model_selection( + selected_model, + selected_effort, + ModelSelectionScope::Global, + ); } return; } @@ -7218,11 +7355,10 @@ impl ChatWidget { effort: choice_effort, }); } else { - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(choice_effort)); - tx.send(AppEvent::PersistModelSelection { + tx.send(AppEvent::ApplyOrQueueModelSelection { model: model_for_action.clone(), effort: choice_effort, + scope: ModelSelectionScope::Global, }); } })]; @@ -7263,22 +7399,6 @@ impl ChatWidget { } } - fn apply_model_and_effort_without_persist( - &self, - model: String, - effort: Option, - ) { - self.app_event_tx.send(AppEvent::UpdateModel(model)); - self.app_event_tx - .send(AppEvent::UpdateReasoningEffort(effort)); - } - - fn apply_model_and_effort(&self, model: String, effort: Option) { - self.apply_model_and_effort_without_persist(model.clone(), effort); - self.app_event_tx - .send(AppEvent::PersistModelSelection { model, effort }); - } - /// Open the permissions popup (alias for /permissions). pub(crate) fn open_approvals_popup(&mut self) { self.open_permissions_popup(); @@ -9232,12 +9352,12 @@ impl ChatWidget { items.push(SelectionItem { name: "Review uncommitted changes".to_string(), actions: vec![Box::new(move |tx: &AppEventSender| { - tx.send(AppEvent::CodexOp(Op::Review { + tx.send(AppEvent::ApplyOrQueueReview { review_request: ReviewRequest { target: ReviewTarget::UncommittedChanges, user_facing_hint: None, }, - })); + }); })], dismiss_on_select: true, ..Default::default() @@ -9285,14 +9405,14 @@ impl ChatWidget { items.push(SelectionItem { name: format!("{current_branch} -> {branch}"), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { + tx3.send(AppEvent::ApplyOrQueueReview { review_request: ReviewRequest { target: ReviewTarget::BaseBranch { branch: branch.clone(), }, user_facing_hint: None, }, - })); + }); })], dismiss_on_select: true, search_value: Some(option), @@ -9322,7 +9442,7 @@ impl ChatWidget { items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { + tx3.send(AppEvent::ApplyOrQueueReview { review_request: ReviewRequest { target: ReviewTarget::Commit { sha: sha.clone(), @@ -9330,7 +9450,7 @@ impl ChatWidget { }, user_facing_hint: None, }, - })); + }); })], dismiss_on_select: true, search_value: Some(search_val), @@ -9359,14 +9479,14 @@ impl ChatWidget { if trimmed.is_empty() { return; } - tx.send(AppEvent::CodexOp(Op::Review { + tx.send(AppEvent::ApplyOrQueueReview { review_request: ReviewRequest { target: ReviewTarget::Custom { instructions: trimmed, }, user_facing_hint: None, }, - })); + }); }), ); self.bottom_pane.show_view(Box::new(view)); @@ -9715,7 +9835,7 @@ pub(crate) fn show_review_commit_picker_with_entries( items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { + tx3.send(AppEvent::ApplyOrQueueReview { review_request: ReviewRequest { target: ReviewTarget::Commit { sha: sha.clone(), @@ -9723,7 +9843,7 @@ pub(crate) fn show_review_commit_picker_with_entries( }, user_facing_hint: None, }, - })); + }); })], dismiss_on_select: true, search_value: Some(search_val), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_slash_command_while_task_running_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_slash_command_while_task_running_popup.snap new file mode 100644 index 0000000000..6e15285fd7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_slash_command_while_task_running_popup.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 7753 +expression: term.backend().vt100().screen().contents() +--- + + + + + Select Model and Effort + Access legacy models by running codex -m or in your config.toml + +› 1. gpt-5.3-codex (default) Latest frontier agentic coding model. + 2. gpt-5.4 Latest frontier agentic coding model. + 3. gpt-5.2-codex Frontier agentic coding model. + 4. gpt-5.1-codex-max Codex-optimized flagship for deep and fast + reasoning. + 5. gpt-5.2 Latest frontier model with improvements across + knowledge, reasoning and coding + 6. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less + capable. + + Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__queued_slash_command_while_task_running_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__queued_slash_command_while_task_running_snapshot.snap deleted file mode 100644 index 7c6587b5dd..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__queued_slash_command_while_task_running_snapshot.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() ---- - - - - - - - - - -• Working (0s • esc to interrupt) - -• Queued follow-up messages - ↳ /model - ⌥ + ↑ edit last queued message - -› Ask Codex to do anything - - ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index c3c77da82b..6998fe7d0a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2558,15 +2558,19 @@ async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_sc assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdateModel(model) if model == "gpt-5.1-codex-max" + AppEvent::ApplyOrQueueModelSelection { + model, + effort: Some(ReasoningEffortConfig::Medium), + scope: ModelSelectionScope::Global, + } if model == "gpt-5.1-codex-max" )), - "expected model update event; events: {events:?}" + "expected model selection event; events: {events:?}" ); assert!( events .iter() - .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))), - "expected reasoning update event; events: {events:?}" + .all(|event| !matches!(event, AppEvent::OpenPlanReasoningScopePrompt { .. })), + "did not expect plan reasoning scope prompt event; events: {events:?}" ); } @@ -2643,15 +2647,19 @@ async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_promp assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdateModel(model) if model == "gpt-5" + AppEvent::ApplyOrQueueModelSelection { + model, + effort: Some(ReasoningEffortConfig::Medium), + scope: ModelSelectionScope::Global, + } if model == "gpt-5" )), - "expected model update event; events: {events:?}" + "expected model selection event; events: {events:?}" ); assert!( events .iter() - .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))), - "expected reasoning update event; events: {events:?}" + .all(|event| !matches!(event, AppEvent::OpenPlanReasoningScopePrompt { .. })), + "did not expect plan reasoning scope prompt event; events: {events:?}" ); } @@ -2670,24 +2678,19 @@ async fn plan_reasoning_scope_popup_all_modes_persists_global_and_plan_override( assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) + AppEvent::ApplyOrQueueModelSelection { + model, + effort: Some(ReasoningEffortConfig::High), + scope: ModelSelectionScope::AllModes, + } if model == "gpt-5.1-codex-max" )), - "expected plan override to be updated; events: {events:?}" + "expected all-modes model selection event; events: {events:?}" ); assert!( - events.iter().any(|event| matches!( - event, - AppEvent::PersistPlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) - )), - "expected updated plan override to be persisted; events: {events:?}" - ); - assert!( - events.iter().any(|event| matches!( - event, - AppEvent::PersistModelSelection { model, effort: Some(ReasoningEffortConfig::High) } - if model == "gpt-5.1-codex-max" - )), - "expected global model reasoning selection persistence; events: {events:?}" + events + .iter() + .all(|event| !matches!(event, AppEvent::PersistPlanModeReasoningEffort(_))), + "did not expect persistence events before app handling; events: {events:?}" ); } @@ -2877,9 +2880,13 @@ async fn plan_reasoning_scope_popup_plan_only_does_not_update_all_modes_reasonin assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) + AppEvent::ApplyOrQueueModelSelection { + model, + effort: Some(ReasoningEffortConfig::High), + scope: ModelSelectionScope::PlanOnly, + } if model == "gpt-5.1-codex-max" )), - "expected plan-only reasoning update; events: {events:?}" + "expected plan-only model selection event; events: {events:?}" ); assert!( events @@ -6322,7 +6329,7 @@ async fn custom_prompt_submit_sends_review_op() { // Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt let evt = rx.try_recv().expect("expected one app event"); match evt { - AppEvent::CodexOp(Op::Review { review_request }) => { + AppEvent::ApplyOrQueueReview { review_request } => { assert_eq!( review_request, ReviewRequest { @@ -8123,13 +8130,15 @@ async fn user_shell_command_renders_output_not_exploring() { } #[tokio::test] -async fn queued_slash_command_while_task_running_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; +async fn model_slash_command_while_task_running_popup_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.thread_id = Some(ThreadId::new()); chat.on_task_started(); chat.dispatch_command(SlashCommand::Model); - assert_eq!(chat.queued_user_message_texts(), vec!["/model".to_string()]); + assert!(chat.queued_user_messages.is_empty()); + assert!(chat.has_active_view(), "expected /model popup to open"); assert!(drain_insert_history(&mut rx).is_empty()); let width: u16 = 80; @@ -8142,7 +8151,66 @@ async fn queued_slash_command_while_task_running_snapshot() { chat.render(f.area(), f.buffer_mut()); }) .unwrap(); - assert_snapshot!(term.backend().vt100().screen().contents()); + assert_snapshot!( + "model_slash_command_while_task_running_popup", + term.backend().vt100().screen().contents() + ); +} + +#[tokio::test] +async fn model_selection_queues_selected_action_while_task_running() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.on_task_started(); + + chat.apply_or_queue_model_selection( + "gpt-5.1-codex-max".to_string(), + Some(ReasoningEffortConfig::High), + ModelSelectionScope::Global, + ); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["/model gpt-5.1-codex-max high".to_string()] + ); +} + +#[tokio::test] +async fn esc_interrupts_running_task_even_with_model_popup_active() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.dispatch_command(SlashCommand::Model); + + assert!(chat.has_active_view(), "expected /model popup to open"); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + next_interrupt_op(&mut op_rx); + assert!( + chat.has_active_view(), + "expected popup to remain open during interrupt" + ); +} + +#[tokio::test] +async fn interrupt_with_popup_active_restores_queued_drafts_into_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + chat.queue_user_message("queued draft".into()); + chat.dispatch_command(SlashCommand::Model); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + next_interrupt_op(&mut op_rx); + chat.on_interrupted_turn(TurnAbortReason::Interrupted); + + assert!( + chat.has_active_view(), + "expected popup to remain open after interrupt" + ); + assert_eq!(chat.bottom_pane.composer_text(), "queued draft"); + assert!(chat.queued_user_messages.is_empty()); + let _ = drain_insert_history(&mut rx); } #[tokio::test] @@ -11106,23 +11174,15 @@ async fn enter_queues_user_messages_while_review_is_running() { } #[tokio::test] -async fn review_slash_command_queues_while_task_running_and_opens_popup_after_turn_complete() { +async fn review_slash_command_opens_popup_while_task_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.on_task_started(); chat.dispatch_command(SlashCommand::Review); - assert_eq!( - chat.queued_user_message_texts(), - vec!["/review".to_string()] - ); - assert!(!chat.has_active_view()); - assert!(drain_insert_history(&mut rx).is_empty()); - - chat.on_task_complete(None, false); - assert!(chat.queued_user_messages.is_empty()); assert!(chat.has_active_view(), "expected /review popup to open"); + assert!(drain_insert_history(&mut rx).is_empty()); } #[tokio::test]