diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index df58c24434..d85e136d6a 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -15,6 +15,7 @@ //! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. use std::path::PathBuf; +use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::pending_input_preview::PendingInputPreview; @@ -31,6 +32,7 @@ use codex_core::features::Features; use codex_core::plugins::PluginCapabilitySummary; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; +use codex_protocol::protocol::Op; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; @@ -148,6 +150,8 @@ pub(crate) use experimental_features_view::ExperimentalFeaturesView; pub(crate) use list_selection_view::SelectionAction; pub(crate) use list_selection_view::SelectionItem; pub(crate) use prompt_args::parse_slash_name; +pub(crate) use slash_commands::BuiltinCommandFlags; +pub(crate) use slash_commands::find_builtin_command; /// Pane displayed in the lower half of the chat UI. /// @@ -408,16 +412,19 @@ impl BottomPane { self.request_redraw(); InputResult::None } else { - // If a task is running and a status line is visible, allow Esc to - // send an interrupt when no popup is active. + // If a task is running, allow Esc to send an interrupt when no popup is active. + // Final-message streaming can temporarily hide the status widget, but that should not + // disable interrupt. if key_event.code == KeyCode::Esc && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) && self.is_task_running && !self.composer.popup_active() - && let Some(status) = &self.status { - // Send Op::Interrupt - status.interrupt(); + if let Some(status) = &self.status { + status.interrupt(); + } else { + self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); + } self.request_redraw(); return InputResult::None; } @@ -1828,6 +1835,32 @@ mod tests { assert_eq!(pane.composer_text(), "still editing"); } + #[test] + fn esc_interrupts_running_task_when_status_indicator_hidden() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.hide_status_indicator(); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected Esc to send Op::Interrupt even when the status indicator is hidden" + ); + } + #[test] fn esc_routes_to_handle_key_event_when_requested() { #[derive(Default)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 59977c21c0..bf4bb21593 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -42,9 +42,11 @@ use self::realtime::PendingSteerCompareKey; use crate::app_event::RealtimeAudioDeviceKind; #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] use crate::audio_device::list_realtime_audio_device_names; +use crate::bottom_pane::BuiltinCommandFlags; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLinePreviewData; use crate::bottom_pane::StatusLineSetupView; +use crate::bottom_pane::find_builtin_command; use crate::status::RateLimitWindowDisplay; use crate::status::format_directory_display; use crate::status::format_tokens_compact; @@ -2237,9 +2239,9 @@ impl ChatWidget { /// Each pending message numbers attachments from `[Image #1]` relative to its own remote /// images. When we concatenate multiple messages after interrupt, we must renumber local-image /// placeholders in a stable order and rebase text element byte ranges so the restored composer - /// state stays aligned with the merged attachment list. Queued slash-command actions stay in - /// the queue so they can still replay after the interrupt instead of being collapsed into plain - /// draft text. + /// state stays aligned with the merged attachment list. Slash commands are fully serializable + /// again, so queued slash drafts are restored alongside ordinary queued follow-ups instead of + /// being replayed separately after the interrupt. fn drain_restorable_messages_for_restore(&mut self) -> Option { let existing_message = UserMessage { text: self.bottom_pane.composer_text(), @@ -2252,12 +2254,9 @@ impl ChatWidget { let has_existing_message = !existing_message.text.is_empty() || !existing_message.local_images.is_empty() || !existing_message.remote_image_urls.is_empty(); - let has_pending_user_messages = !self.pending_steers.is_empty() - || self - .queued_user_messages - .iter() - .any(|queued| !Self::is_builtin_slash_draft(queued)); - if !has_existing_message && !has_pending_user_messages { + let has_pending_user_messages = + !self.pending_steers.is_empty() || !self.queued_user_messages.is_empty(); + if !has_pending_user_messages { return None; } @@ -2266,15 +2265,7 @@ impl ChatWidget { .drain(..) .map(|steer| steer.user_message) .collect(); - let mut retained_queue = VecDeque::new(); - for queued_message in self.queued_user_messages.drain(..) { - if Self::is_builtin_slash_draft(&queued_message) { - retained_queue.push_back(queued_message); - } else { - to_merge.push(queued_message); - } - } - self.queued_user_messages = retained_queue; + to_merge.extend(self.queued_user_messages.drain(..)); if has_existing_message { to_merge.push(existing_message); } @@ -4208,6 +4199,9 @@ impl ChatWidget { else { return; }; + if self.reject_unavailable_builtin_slash_command(&user_message) { + return; + } let should_submit_now = self.is_session_configured() && !self.is_plan_streaming_in_tui(); if should_submit_now { @@ -4243,6 +4237,9 @@ impl ChatWidget { else { return; }; + if self.reject_unavailable_builtin_slash_command(&user_message) { + return; + } self.queue_user_message(user_message); } InputResult::Command(cmd) => { @@ -4321,7 +4318,10 @@ impl ChatWidget { } fn dispatch_command(&mut self, cmd: SlashCommand) -> QueueReplayControl { - if self.bottom_pane.is_task_running() && !cmd.available_during_task() { + if self.bottom_pane.is_task_running() + && !cmd.available_during_task() + && !matches!(cmd, SlashCommand::Model | SlashCommand::Review) + { self.queue_user_message(format!("/{}", cmd.command()).into()); return QueueReplayControl::Stop; } @@ -4698,7 +4698,7 @@ impl ChatWidget { let trimmed = args.trim(); let should_queue = self.bottom_pane.is_task_running() && !cmd.available_during_task(); if trimmed.is_empty() { - if should_queue { + if should_queue && !matches!(cmd, SlashCommand::Model | SlashCommand::Review) { self.queue_current_inline_bare_slash_command(cmd); } else { self.dispatch_command(cmd); @@ -4982,7 +4982,10 @@ impl ChatWidget { match &review_request.target { ReviewTarget::UncommittedChanges => "/review uncommitted".into(), ReviewTarget::BaseBranch { branch } => format!("/review branch {branch}").into(), - ReviewTarget::Commit { sha, .. } => format!("/review commit {sha}").into(), + ReviewTarget::Commit { sha, title } => match title.as_deref().map(str::trim) { + Some("") | None => format!("/review commit {sha}").into(), + Some(title) => format!("/review commit {sha} {title}").into(), + }, ReviewTarget::Custom { instructions } => format!("/review {instructions}").into(), } } @@ -5006,19 +5009,64 @@ impl ChatWidget { } } - fn parse_builtin_slash_command(text: &str) -> Option<(SlashCommand, &str, usize)> { + fn parse_builtin_slash_command<'a>( + &self, + text: &'a str, + ) -> Option<(SlashCommand, &'a str, usize)> { let (name, rest, rest_offset) = parse_slash_name(text)?; - let cmd = SlashCommand::from_str(name).ok()?; + let cmd = find_builtin_command( + name, + BuiltinCommandFlags { + collaboration_modes_enabled: self.collaboration_modes_enabled(), + connectors_enabled: self.connectors_enabled(), + fast_command_enabled: self.fast_mode_enabled(), + personality_command_enabled: self.config.features.enabled(Feature::Personality), + realtime_conversation_enabled: self.realtime_conversation_enabled(), + audio_device_selection_enabled: self.realtime_audio_device_selection_enabled(), + allow_elevate_sandbox: { + #[cfg(target_os = "windows")] + { + codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && matches!( + WindowsSandboxLevel::from_config(&self.config), + WindowsSandboxLevel::RestrictedToken + ) + } + #[cfg(not(target_os = "windows"))] + { + false + } + }, + }, + )?; Some((cmd, rest, rest_offset)) } - fn is_builtin_slash_draft(draft: &UserMessage) -> bool { - Self::parse_builtin_slash_command(&draft.text).is_some() + fn is_builtin_slash_draft(&self, draft: &UserMessage) -> bool { + self.parse_builtin_slash_command(&draft.text).is_some() + } + + fn reject_unavailable_builtin_slash_command(&mut self, user_message: &UserMessage) -> bool { + let Some((name, _, _)) = parse_slash_name(&user_message.text) else { + return false; + }; + if name.contains('/') + || self + .parse_builtin_slash_command(&user_message.text) + .is_some() + || SlashCommand::from_str(name).is_err() + { + return false; + } + + self.add_error_message(format!("/{name} is not available in this session.")); + self.restore_user_message_to_composer(user_message.clone()); + true } fn execute_serialized_slash_command(&mut self, draft: UserMessage) -> QueueReplayControl { let preview = draft.text.clone(); - let Some((cmd, rest, rest_offset)) = Self::parse_builtin_slash_command(&preview) else { + let Some((cmd, rest, rest_offset)) = self.parse_builtin_slash_command(&preview) else { self.add_error_message(format!("Failed to replay queued slash command: {preview}")); self.restore_user_message_to_composer(draft); return QueueReplayControl::Stop; @@ -5079,6 +5127,8 @@ impl ChatWidget { } fn parse_review_request(args: &str) -> Result { + const REVIEW_USAGE: &str = + "Usage: /review [uncommitted|branch |commit [title]|]"; let trimmed = args.trim(); let lowered = trimmed.to_ascii_lowercase(); let target = if matches!( @@ -5093,20 +5143,23 @@ impl ChatWidget { branch: value.to_string(), }, "branch" => { - return Err( - "Usage: /review [uncommitted|branch |commit |]" - .to_string(), - ); + return Err(REVIEW_USAGE.to_string()); } - "commit" if !value.is_empty() => ReviewTarget::Commit { - sha: value.to_string(), - title: None, - }, - "commit" => { - return Err( - "Usage: /review [uncommitted|branch |commit |]" - .to_string(), + "commit" if !value.is_empty() => { + let (sha, title) = value.split_once(char::is_whitespace).map_or( + (value, None), + |(sha, title)| { + let title = title.trim(); + (sha, (!title.is_empty()).then_some(title.to_string())) + }, ); + ReviewTarget::Commit { + sha: sha.to_string(), + title, + } + } + "commit" => { + return Err(REVIEW_USAGE.to_string()); } _ => ReviewTarget::Custom { instructions: trimmed.to_string(), @@ -6094,7 +6147,7 @@ impl ChatWidget { let Some(queued_message) = self.queued_user_messages.pop_front() else { break; }; - let replay_control = if Self::is_builtin_slash_draft(&queued_message) { + let replay_control = if self.is_builtin_slash_draft(&queued_message) { self.execute_serialized_slash_command(queued_message) } else { self.submit_user_message(queued_message); 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..737b1e5f4d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_slash_command_while_task_running_popup.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/chatwidget/tests.rs +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__model_slash_command_while_task_running_queues.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_slash_command_while_task_running_queues.snap deleted file mode 100644 index 7c6587b5dd..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_slash_command_while_task_running_queues.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 ce8467e60d..552960cbe3 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -8205,18 +8205,15 @@ async fn user_shell_command_renders_output_not_exploring() { } #[tokio::test] -async fn model_slash_command_while_task_running_queues_snapshot() { +async fn model_slash_command_while_task_running_opens_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.has_active_view(), - "expected /model to queue instead of opening a popup" - ); + 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; @@ -8230,7 +8227,7 @@ async fn model_slash_command_while_task_running_queues_snapshot() { }) .unwrap(); assert_snapshot!( - "model_slash_command_while_task_running_queues", + "model_slash_command_while_task_running_popup", term.backend().vt100().screen().contents() ); } @@ -8252,6 +8249,37 @@ async fn model_selection_queues_selected_action_while_task_running() { ); } +#[tokio::test] +async fn interrupt_restores_queued_model_selection_into_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; + chat.on_task_started(); + + chat.handle_serialized_slash_command(ChatWidget::model_selection_draft( + "gpt-5.1-codex-max", + Some(ReasoningEffortConfig::High), + ModelSelectionScope::Global, + )); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + }), + }); + + assert_eq!( + chat.bottom_pane.composer_text(), + "/model gpt-5.1-codex-max high" + ); + assert!(chat.queued_user_messages.is_empty()); + assert!( + op_rx.try_recv().is_err(), + "unexpected outbound op after interrupt restoring queued /model selection" + ); + let _ = drain_insert_history(&mut rx); +} + #[tokio::test] async fn model_slash_command_with_args_queues_while_task_running_and_replays_after_turn_complete() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; @@ -8362,13 +8390,19 @@ async fn esc_interrupts_running_task_with_empty_composer() { } #[tokio::test] -async fn esc_interrupts_running_task_with_nonempty_composer_and_restores_draft() { +async fn esc_interrupts_running_task_with_nonempty_composer_without_restoring_draft() { 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.bottom_pane .set_composer_text("still editing".to_string(), Vec::new(), Vec::new()); + assert!( + chat.drain_restorable_messages_for_restore().is_none(), + "existing composer text alone should not trigger draft restoration" + ); + assert_eq!(chat.bottom_pane.composer_text(), "still editing"); + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); next_interrupt_op(&mut op_rx); @@ -8399,6 +8433,25 @@ async fn esc_with_model_popup_active_dismisses_popup_without_interrupting() { assert!(!chat.has_active_view(), "expected /model popup to close"); } +#[tokio::test] +async fn esc_interrupts_running_task_while_final_message_streaming() { + 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.on_agent_message_delta("Final answer line\n".to_string()); + chat.on_commit_tick(); + let _ = drain_insert_history(&mut rx); + + assert!( + !chat.bottom_pane.status_indicator_visible(), + "expected final-message streaming to hide the status indicator" + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + next_interrupt_op(&mut op_rx); +} + #[tokio::test] async fn esc_with_popup_active_does_not_interrupt_pending_steers() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; @@ -8458,6 +8511,50 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); } +#[tokio::test] +async fn disabled_fast_slash_command_with_args_restores_draft_instead_of_queueing() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_feature_enabled(Feature::FastMode, false); + chat.on_task_started(); + chat.bottom_pane + .set_composer_text("/fast on".to_string(), Vec::new(), Vec::new()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(chat.bottom_pane.composer_text(), "/fast on"); + assert!(chat.queued_user_messages.is_empty()); + assert_no_submit_op(&mut op_rx); + let _ = drain_insert_history(&mut rx); +} + +#[tokio::test] +async fn queued_disabled_fast_slash_draft_replays_as_user_text() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::FastMode, false); + chat.on_task_started(); + + chat.handle_serialized_slash_command(UserMessage::from("/fast on".to_string())); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["/fast on".to_string()] + ); + + chat.on_task_complete(None, false); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "/fast on".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued disabled slash draft Op::UserTurn, got {other:?}"), + } +} + #[tokio::test] async fn user_turn_carries_service_tier_after_fast_toggle() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; @@ -9327,7 +9424,7 @@ async fn interrupt_restores_queued_messages_into_composer() { } #[tokio::test] -async fn interrupt_replays_queued_slash_commands_and_restores_drafts() { +async fn interrupt_restores_queued_slash_commands_into_composer() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.bottom_pane.set_task_running(true); @@ -9345,19 +9442,22 @@ async fn interrupt_replays_queued_slash_commands_and_restores_drafts() { }), }); - assert_eq!(chat.bottom_pane.composer_text(), "queued draft"); - assert!(chat.has_active_view(), "expected /review popup to open"); + assert_eq!(chat.bottom_pane.composer_text(), "queued draft\n/review"); + assert!( + !chat.has_active_view(), + "expected interrupt restore to keep queued slash drafts in the composer" + ); assert!(chat.queued_user_messages.is_empty()); assert!( op_rx.try_recv().is_err(), - "unexpected outbound op after bare /review interrupt replay" + "unexpected outbound op after interrupt restore" ); let _ = drain_insert_history(&mut rx); } #[tokio::test] -async fn interrupt_replays_multiple_queued_slash_commands_in_order() { +async fn interrupt_restores_multiple_queued_slash_commands_into_composer() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.bottom_pane.set_task_running(true); @@ -9375,19 +9475,17 @@ async fn interrupt_replays_multiple_queued_slash_commands_in_order() { }), }); - let inserted = drain_insert_history(&mut rx); + assert_eq!(chat.bottom_pane.composer_text(), "/fast status\n/review"); assert!( - inserted - .iter() - .any(|cell| lines_to_single_string(cell).contains("Fast mode is off.")), - "expected /fast status to run before /review popup" + !chat.has_active_view(), + "expected interrupt restore to keep slash drafts editable" ); - assert!(chat.has_active_view(), "expected /review popup to open"); assert!(chat.queued_user_messages.is_empty()); assert!( op_rx.try_recv().is_err(), - "unexpected outbound op after slash-only interrupt replay" + "unexpected outbound op after slash-only interrupt restore" ); + let _ = drain_insert_history(&mut rx); } #[tokio::test] @@ -11379,20 +11477,14 @@ async fn enter_queues_user_messages_while_review_is_running() { } #[tokio::test] -async fn review_slash_command_queues_while_task_running() { +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(), - "expected /review to queue instead of opening a popup" - ); + 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()); } @@ -11478,6 +11570,48 @@ async fn queued_review_selection_replays_after_turn_complete() { } } +#[tokio::test] +async fn queued_commit_review_selection_preserves_title_after_turn_complete() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + + chat.handle_serialized_slash_command(ChatWidget::review_request_draft(&ReviewRequest { + target: ReviewTarget::Commit { + sha: "abc123".to_string(), + title: Some("Preserve commit subject".to_string()), + }, + user_facing_hint: None, + })); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["/review commit abc123 Preserve commit subject".to_string()] + ); + + chat.on_task_complete(None, false); + + loop { + match op_rx.try_recv() { + Ok(Op::Review { review_request }) => { + assert_eq!( + review_request, + ReviewRequest { + target: ReviewTarget::Commit { + sha: "abc123".to_string(), + title: Some("Preserve commit subject".to_string()), + }, + user_facing_hint: None, + } + ); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected queued /review commit op"), + Err(TryRecvError::Disconnected) => panic!("expected queued /review commit op"), + } + } +} + #[tokio::test] async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;