diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6b292d83f6..af889939e8 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2717,13 +2717,8 @@ 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); + AppEvent::HandleSlashCommandDraft(draft) => { + self.chat_widget.handle_serialized_slash_command(draft); self.refresh_status_line(); } AppEvent::UpdateCollaborationMode(mask) => { @@ -2746,9 +2741,6 @@ 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 da94b1cb3e..21e6154075 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -16,11 +16,11 @@ 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; use crate::bottom_pane::StatusLineItem; +use crate::chatwidget::UserMessage; use crate::history_cell::HistoryCell; use codex_core::config::types::ApprovalsReviewer; @@ -54,13 +54,6 @@ 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 { @@ -190,13 +183,9 @@ 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, - }, + /// Evaluate a serialized built-in slash-command draft. If a task is currently running, the + /// draft is queued and replayed later through the same path as queued composer input. + HandleSlashCommandDraft(UserMessage), /// Update the active collaboration mask in the running app and widget. UpdateCollaborationMode(CollaborationModeMask), @@ -256,12 +245,6 @@ 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/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 655ef1d825..b2193678fa 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -50,8 +50,8 @@ //! //! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion //! and attachment pruning, and clears pending paste state on success. -//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so -//! pasted content and text elements are preserved when extracting args. +//! Slash commands with arguments (like `/model`, `/plan`, and `/review`) reuse the same +//! preparation path so pasted content and text elements are preserved when extracting args. //! //! # Remote Image Rows (Up/Down/Delete) //! diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 6634eb31bc..df58c24434 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -147,6 +147,7 @@ pub(crate) use experimental_features_view::ExperimentalFeatureItem; 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; /// Pane displayed in the lower half of the chat UI. /// @@ -408,11 +409,10 @@ impl BottomPane { InputResult::None } else { // If a task is running and a status line is visible, allow Esc to - // send an interrupt only from an empty composer with no popup active. + // send an interrupt when no popup is active. if key_event.code == KeyCode::Esc && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) && self.is_task_running - && self.composer_text().is_empty() && !self.composer.popup_active() && let Some(status) = &self.status { @@ -1695,7 +1695,7 @@ mod tests { } #[test] - fn esc_with_agent_command_without_popup_does_not_interrupt_task() { + fn esc_with_agent_command_without_popup_interrupts_task() { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { @@ -1711,8 +1711,8 @@ mod tests { pane.set_task_running(true); - // Repro: `/agent ` hides the popup (cursor past command name). Esc should - // keep editing command text instead of interrupting the running task. + // `/agent ` hides the popup once the cursor moves past the command name. + // Without an active popup, Esc should interrupt even though the composer has text. pane.insert_str("/agent "); assert!( !pane.composer.popup_active(), @@ -1721,12 +1721,10 @@ mod tests { pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - while let Ok(ev) = rx.try_recv() { - assert!( - !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), - "expected Esc to not send Op::Interrupt while typing `/agent`" - ); - } + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected Esc to send Op::Interrupt while typing `/agent` with no popup" + ); assert_eq!(pane.composer_text(), "/agent "); } @@ -1804,7 +1802,7 @@ mod tests { } #[test] - fn esc_with_nonempty_composer_does_not_interrupt_task() { + fn esc_with_nonempty_composer_interrupts_task_when_no_popup() { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { @@ -1823,12 +1821,10 @@ mod tests { pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - while let Ok(ev) = rx.try_recv() { - assert!( - !matches!(ev, AppEvent::CodexOp(Op::Interrupt)), - "expected Esc to not send Op::Interrupt while composer has text" - ); - } + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected Esc to send Op::Interrupt while composer has text and no popup is active" + ); assert_eq!(pane.composer_text(), "still editing"); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 025aeda2c5..59977c21c0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -31,6 +31,7 @@ use std::collections::HashSet; use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; +use std::str::FromStr; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -216,7 +217,6 @@ 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; @@ -239,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; @@ -727,8 +728,10 @@ pub(crate) struct ChatWidget { // When resuming an existing session (selected via resume picker), avoid an // immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. suppress_session_configured_redraw: bool, - // User messages or slash-command actions queued while a turn is in progress. - queued_user_messages: VecDeque, + // User messages queued while a turn is in progress. Some entries are serialized slash-command + // drafts and are replayed through the slash-command evaluator instead of being submitted + // directly as user turns. + queued_user_messages: VecDeque, // Steers already submitted to core but not yet committed into history. // // The bottom pane shows these above queued drafts until core records the @@ -868,80 +871,24 @@ impl ThreadComposerState { } } -#[derive(Debug, Clone, PartialEq)] -enum QueuedInput { - UserMessage(UserMessage), - SlashCommand(QueuedSlashCommand), -} - -impl QueuedInput { - fn preview_text(&self) -> String { - match self { - Self::UserMessage(message) => message.text.clone(), - Self::SlashCommand(command) => command.preview_text(), - } - } - - fn into_restorable_user_message(self) -> UserMessage { - match self { - Self::UserMessage(message) => message, - Self::SlashCommand(command) => command.into_restorable_user_message(), - } - } -} - -impl From for QueuedInput { - fn from(message: UserMessage) -> Self { - Self::UserMessage(message) - } -} - -#[derive(Debug, Clone, PartialEq)] -struct QueuedSlashCommand { - draft: UserMessage, - action: QueuedSlashCommandAction, -} - -impl QueuedSlashCommand { - fn preview_text(&self) -> String { - self.draft.text.clone() - } - - fn into_restorable_user_message(self) -> UserMessage { - self.draft - } -} - -#[derive(Debug, Clone, PartialEq)] -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 QueueReplayControl { + Continue, + Stop, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum QueuedFastCommandAction { - On, - Off, - Status, +enum ModelSelectionScope { + Global, + PlanOnly, + AllModes, } #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, - queued_user_messages: VecDeque, + queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, agent_turn_running: bool, @@ -2309,7 +2256,7 @@ impl ChatWidget { || self .queued_user_messages .iter() - .any(|queued| matches!(queued, QueuedInput::UserMessage(_))); + .any(|queued| !Self::is_builtin_slash_draft(queued)); if !has_existing_message && !has_pending_user_messages { return None; } @@ -2320,12 +2267,11 @@ impl ChatWidget { .map(|steer| steer.user_message) .collect(); let mut retained_queue = VecDeque::new(); - for queued_input in self.queued_user_messages.drain(..) { - match queued_input { - QueuedInput::UserMessage(message) => to_merge.push(message), - QueuedInput::SlashCommand(command) => { - retained_queue.push_back(QueuedInput::SlashCommand(command)); - } + 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; @@ -2410,11 +2356,7 @@ impl ChatWidget { self.bottom_pane.set_composer_pending_pastes(Vec::new()); } self.pending_steers.clear(); - self.queued_user_messages = input_state - .pending_steers - .into_iter() - .map(QueuedInput::from) - .collect(); + self.queued_user_messages = input_state.pending_steers; self.queued_user_messages .extend(input_state.queued_user_messages); } else { @@ -4206,8 +4148,8 @@ impl ChatWidget { && self.queued_message_edit_binding.is_press(key_event) && !self.queued_user_messages.is_empty() { - if let Some(queued_input) = self.queued_user_messages.pop_back() { - self.restore_user_message_to_composer(queued_input.into_restorable_user_message()); + if let Some(queued_message) = self.queued_user_messages.pop_back() { + self.restore_user_message_to_composer(queued_message); self.refresh_pending_input_preview(); self.request_redraw(); } @@ -4218,7 +4160,6 @@ impl ChatWidget { && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) && self.bottom_pane.is_task_running() && self.bottom_pane.no_modal_or_popup_active() - && (!self.pending_steers.is_empty() || self.bottom_pane.composer_text().is_empty()) { self.submit_pending_steers_after_interrupt = !self.pending_steers.is_empty(); if !self.submit_op(Op::Interrupt) { @@ -4379,24 +4320,10 @@ impl ChatWidget { false } - fn dispatch_command(&mut self, cmd: SlashCommand) { + fn dispatch_command(&mut self, cmd: SlashCommand) -> QueueReplayControl { 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), - }); - return; + self.queue_user_message(format!("/{}", cmd.command()).into()); + return QueueReplayControl::Stop; } match cmd { SlashCommand::Feedback => { @@ -4404,25 +4331,30 @@ impl ChatWidget { let params = crate::bottom_pane::feedback_disabled_params(); self.bottom_pane.show_selection_view(params); self.request_redraw(); - return; + return QueueReplayControl::Stop; } // Step 1: pick a category (UI built in feedback_view) let params = crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); self.bottom_pane.show_selection_view(params); self.request_redraw(); + QueueReplayControl::Stop } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); + QueueReplayControl::Stop } SlashCommand::Clear => { self.app_event_tx.send(AppEvent::ClearUi); + QueueReplayControl::Stop } SlashCommand::Resume => { self.app_event_tx.send(AppEvent::OpenResumePicker); + QueueReplayControl::Stop } SlashCommand::Fork => { self.app_event_tx.send(AppEvent::ForkCurrentSession); + QueueReplayControl::Stop } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); @@ -4431,25 +4363,30 @@ impl ChatWidget { "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." ); self.add_info_message(message, None); - return; + return QueueReplayControl::Stop; } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); self.submit_user_message(INIT_PROMPT.to_string().into()); + QueueReplayControl::Stop } SlashCommand::Compact => { self.clear_token_usage(); self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); + QueueReplayControl::Stop } SlashCommand::Review => { self.open_review_popup(); + QueueReplayControl::Stop } SlashCommand::Rename => { self.session_telemetry .counter("codex.thread.rename", 1, &[]); self.show_rename_prompt(); + QueueReplayControl::Stop } SlashCommand::Model => { self.open_model_popup(); + QueueReplayControl::Stop } SlashCommand::Fast => { let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { @@ -4458,25 +4395,29 @@ impl ChatWidget { Some(ServiceTier::Fast) }; self.set_service_tier_selection(next_tier); + QueueReplayControl::Continue } SlashCommand::Realtime => { if !self.realtime_conversation_enabled() { - return; + return QueueReplayControl::Stop; } if self.realtime_conversation.is_live() { self.request_realtime_conversation_close(None); } else { self.start_realtime_conversation(); } + QueueReplayControl::Stop } SlashCommand::Settings => { if !self.realtime_audio_device_selection_enabled() { - return; + return QueueReplayControl::Stop; } self.open_realtime_audio_popup(); + QueueReplayControl::Stop } SlashCommand::Personality => { self.open_personality_popup(); + QueueReplayControl::Stop } SlashCommand::Plan => { if !self.collaboration_modes_enabled() { @@ -4484,12 +4425,14 @@ impl ChatWidget { "Collaboration modes are disabled.".to_string(), Some("Enable collaboration modes to use /plan.".to_string()), ); - return; + return QueueReplayControl::Stop; } if let Some(mask) = collaboration_modes::plan_mask(self.models_manager.as_ref()) { self.set_collaboration_mask(mask); + QueueReplayControl::Continue } else { self.add_info_message("Plan mode unavailable right now.".to_string(), None); + QueueReplayControl::Stop } } SlashCommand::Collab => { @@ -4498,18 +4441,22 @@ impl ChatWidget { "Collaboration modes are disabled.".to_string(), Some("Enable collaboration modes to use /collab.".to_string()), ); - return; + return QueueReplayControl::Stop; } self.open_collaboration_modes_popup(); + QueueReplayControl::Stop } SlashCommand::Agent | SlashCommand::MultiAgents => { self.app_event_tx.send(AppEvent::OpenAgentPicker); + QueueReplayControl::Stop } SlashCommand::Approvals => { self.open_permissions_popup(); + QueueReplayControl::Stop } SlashCommand::Permissions => { self.open_permissions_popup(); + QueueReplayControl::Stop } SlashCommand::ElevateSandbox => { #[cfg(target_os = "windows")] @@ -4522,7 +4469,7 @@ impl ChatWidget { { // This command should not be visible/recognized outside degraded mode, // but guard anyway in case something dispatches it directly. - return; + return QueueReplayControl::Stop; } let Some(preset) = builtin_approval_presets() @@ -4534,7 +4481,7 @@ impl ChatWidget { self.add_error_message( "Internal error: missing the 'auto' approval preset.".to_string(), ); - return; + return QueueReplayControl::Stop; }; if let Err(err) = self @@ -4544,7 +4491,7 @@ impl ChatWidget { .can_set(&preset.approval) { self.add_error_message(err.to_string()); - return; + return QueueReplayControl::Stop; } self.session_telemetry.counter( @@ -4560,17 +4507,21 @@ impl ChatWidget { let _ = &self.session_telemetry; // Not supported; on non-Windows this command should never be reachable. }; + QueueReplayControl::Stop } SlashCommand::SandboxReadRoot => { self.add_error_message( "Usage: /sandbox-add-read-dir ".to_string(), ); + QueueReplayControl::Stop } SlashCommand::Experimental => { self.open_experimental_popup(); + QueueReplayControl::Stop } SlashCommand::Quit | SlashCommand::Exit => { self.request_quit_without_confirmation(); + QueueReplayControl::Stop } SlashCommand::Logout => { if let Err(e) = codex_core::auth::logout( @@ -4580,6 +4531,7 @@ impl ChatWidget { tracing::error!("failed to logout: {e}"); } self.request_quit_without_confirmation(); + QueueReplayControl::Stop } // SlashCommand::Undo => { // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); @@ -4600,6 +4552,7 @@ impl ChatWidget { }; tx.send(AppEvent::DiffResult(text)); }); + QueueReplayControl::Continue } SlashCommand::Copy => { let Some(text) = self.last_copyable_output.as_deref() else { @@ -4608,7 +4561,7 @@ impl ChatWidget { .to_string(), None, ); - return; + return QueueReplayControl::Continue; }; let copy_result = clipboard_text::copy_text_to_clipboard(text); @@ -4628,42 +4581,55 @@ impl ChatWidget { self.add_error_message(format!("Failed to copy to clipboard: {err}")) } } + QueueReplayControl::Continue } SlashCommand::Mention => { self.insert_str("@"); + QueueReplayControl::Stop } SlashCommand::Skills => { self.open_skills_menu(); + QueueReplayControl::Stop } SlashCommand::Status => { self.add_status_output(); + QueueReplayControl::Continue } SlashCommand::DebugConfig => { self.add_debug_config_output(); + QueueReplayControl::Continue } SlashCommand::Statusline => { self.open_status_line_setup(); + QueueReplayControl::Stop } SlashCommand::Theme => { self.open_theme_picker(); + QueueReplayControl::Stop } SlashCommand::Ps => { self.add_ps_output(); + QueueReplayControl::Continue } SlashCommand::Stop => { self.clean_background_terminals(); + QueueReplayControl::Continue } SlashCommand::MemoryDrop => { self.submit_op(Op::DropMemories); + QueueReplayControl::Stop } SlashCommand::MemoryUpdate => { self.submit_op(Op::UpdateMemories); + QueueReplayControl::Stop } SlashCommand::Mcp => { self.add_mcp_output(); + QueueReplayControl::Continue } SlashCommand::Apps => { self.add_connectors_output(); + QueueReplayControl::Continue } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { @@ -4674,6 +4640,7 @@ impl ChatWidget { } else { self.add_info_message("Rollout path is not available yet.".to_string(), None); } + QueueReplayControl::Continue } SlashCommand::TestApproval => { use codex_protocol::protocol::EventMsg; @@ -4712,6 +4679,7 @@ impl ChatWidget { grant_root: Some(PathBuf::from("/tmp")), }), })); + QueueReplayControl::Stop } } } @@ -4720,7 +4688,7 @@ impl ChatWidget { &mut self, cmd: SlashCommand, args: String, - _text_elements: Vec, + text_elements: Vec, ) { if !cmd.supports_inline_args() { self.dispatch_command(cmd); @@ -4729,167 +4697,160 @@ impl ChatWidget { let trimmed = args.trim(); let should_queue = self.bottom_pane.is_task_running() && !cmd.available_during_task(); - match cmd { - SlashCommand::Fast => { - if trimmed.is_empty() { - if should_queue { - self.queue_current_inline_bare_slash_command(cmd); - } else { - self.dispatch_command(cmd); - } - return; - } - let action = match trimmed.to_ascii_lowercase().as_str() { - "on" => QueuedFastCommandAction::On, - "off" => QueuedFastCommandAction::Off, - "status" => QueuedFastCommandAction::Status, - _ => { - self.add_error_message("Usage: /fast [on|off|status]".to_string()); - return; - } - }; - if should_queue { - let Some((prepared_args, prepared_elements)) = - self.bottom_pane.prepare_inline_args_submission(false) - else { - return; - }; - let args_message = self - .take_prepared_submission_user_message(prepared_args, prepared_elements); - let draft = Self::queued_inline_slash_command_draft(cmd, args_message); - self.queue_slash_command(QueuedSlashCommand { - draft, - action: QueuedSlashCommandAction::Fast(action), - }); - return; - } - match action { - QueuedFastCommandAction::On => { - self.set_service_tier_selection(Some(ServiceTier::Fast)) - } - QueuedFastCommandAction::Off => self.set_service_tier_selection(None), - QueuedFastCommandAction::Status => { - let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) - { - "on" - } else { - "off" - }; - self.add_info_message(format!("Fast mode is {status}."), None); - } - } + if trimmed.is_empty() { + if should_queue { + self.queue_current_inline_bare_slash_command(cmd); + } else { + self.dispatch_command(cmd); } - SlashCommand::Rename if !trimmed.is_empty() => { + return; + } + + if matches!(cmd, SlashCommand::Plan) && !should_queue { + let draft = Self::inline_slash_command_draft( + cmd, + UserMessage { + text: args, + local_images: self.bottom_pane.composer_local_images(), + remote_image_urls: self.bottom_pane.remote_image_urls(), + text_elements, + mention_bindings: self.bottom_pane.composer_mention_bindings(), + }, + ); + let pending_pastes = self.bottom_pane.composer_pending_pastes(); + self.dispatch_command(cmd); + if self.active_mode_kind() != ModeKind::Plan { + self.restore_user_message_to_composer(draft); + self.bottom_pane.set_composer_pending_pastes(pending_pastes); + return; + } + + let Some((prepared_args, prepared_elements)) = + self.bottom_pane.prepare_inline_args_submission(true) + else { + return; + }; + let args_message = + self.take_prepared_submission_user_message(prepared_args, prepared_elements); + self.submit_plan_user_message(args_message); + return; + } + + let record_history = matches!(cmd, SlashCommand::Plan); + let Some((prepared_args, prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(record_history) + else { + return; + }; + let args_message = + self.take_prepared_submission_user_message(prepared_args, prepared_elements); + if should_queue { + self.queue_user_message(Self::inline_slash_command_draft(cmd, args_message)); + return; + } + let _ = self.execute_inline_slash_command(cmd, args_message); + } + + fn execute_inline_slash_command( + &mut self, + cmd: SlashCommand, + args_message: UserMessage, + ) -> QueueReplayControl { + match cmd { + SlashCommand::Fast => match args_message.text.to_ascii_lowercase().as_str() { + "on" => { + self.set_service_tier_selection(Some(ServiceTier::Fast)); + QueueReplayControl::Continue + } + "off" => { + self.set_service_tier_selection(None); + QueueReplayControl::Continue + } + "status" => { + let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { + "on" + } else { + "off" + }; + self.add_info_message(format!("Fast mode is {status}."), None); + QueueReplayControl::Continue + } + _ => self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Usage: /fast [on|off|status]".to_string(), + ), + }, + SlashCommand::Model => match Self::parse_model_selection_args(&args_message.text) { + Ok((model, effort, scope)) => { + self.apply_model_selection(model, effort, scope); + QueueReplayControl::Continue + } + Err(message) => { + self.restore_invalid_inline_slash_command(cmd, args_message, message) + } + }, + SlashCommand::Rename => { self.session_telemetry .counter("codex.thread.rename", 1, &[]); - let Some((prepared_args, prepared_elements)) = - self.bottom_pane.prepare_inline_args_submission(false) - else { - return; + let Some(name) = codex_core::util::normalize_thread_name(&args_message.text) else { + return self.restore_invalid_inline_slash_command( + cmd, + args_message, + "Thread name cannot be empty.".to_string(), + ); }; - let args_message = self.take_prepared_submission_user_message( - prepared_args.clone(), - prepared_elements, - ); - let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else { - self.add_error_message("Thread name cannot be empty.".to_string()); - return; - }; - if should_queue { - let draft = Self::queued_inline_slash_command_draft(cmd, args_message); - self.queue_slash_command(QueuedSlashCommand { - draft, - action: QueuedSlashCommandAction::Rename(name), - }); - return; - } let cell = Self::rename_confirmation_cell(&name, self.thread_id); self.add_boxed_history(Box::new(cell)); self.request_redraw(); self.app_event_tx .send(AppEvent::CodexOp(Op::SetThreadName { name })); + QueueReplayControl::Continue } - SlashCommand::Plan if !trimmed.is_empty() => { - let Some((prepared_args, prepared_elements)) = - self.bottom_pane.prepare_inline_args_submission(true) - else { - return; - }; - let user_message = - self.take_prepared_submission_user_message(prepared_args, prepared_elements); - if should_queue { - let draft = Self::queued_inline_slash_command_draft(cmd, user_message.clone()); - self.queue_slash_command(QueuedSlashCommand { - draft, - action: QueuedSlashCommandAction::Plan(user_message), - }); - return; - } + SlashCommand::Plan => { self.dispatch_command(cmd); - if self.active_mode_kind() != ModeKind::Plan { - return; + if self.active_mode_kind() == ModeKind::Plan { + self.submit_plan_user_message(args_message); + } else { + self.restore_user_message_to_composer(Self::inline_slash_command_draft( + cmd, + args_message, + )); } - self.submit_plan_user_message(user_message); + QueueReplayControl::Stop } - SlashCommand::Review if !trimmed.is_empty() => { - let Some((prepared_args, prepared_elements)) = - self.bottom_pane.prepare_inline_args_submission(false) - else { - return; - }; - let args_message = self.take_prepared_submission_user_message( - prepared_args.clone(), - prepared_elements, - ); - let review_request = ReviewRequest { - target: ReviewTarget::Custom { - instructions: prepared_args, - }, - user_facing_hint: None, - }; - if should_queue { - let draft = Self::queued_inline_slash_command_draft(cmd, args_message); - self.queue_slash_command(QueuedSlashCommand { - draft, - action: QueuedSlashCommandAction::Review(review_request), - }); - return; + SlashCommand::Review => match Self::parse_review_request(&args_message.text) { + Ok(review_request) => { + self.submit_op(Op::Review { review_request }); + QueueReplayControl::Stop } - self.submit_op(Op::Review { review_request }); - } - SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { - let Some((prepared_args, prepared_elements)) = - self.bottom_pane.prepare_inline_args_submission(false) - else { - return; - }; - let args_message = self.take_prepared_submission_user_message( - prepared_args.clone(), - prepared_elements, - ); - if should_queue { - let draft = Self::queued_inline_slash_command_draft(cmd, args_message); - self.queue_slash_command(QueuedSlashCommand { - draft, - action: QueuedSlashCommandAction::SandboxReadRoot(prepared_args), - }); - return; + Err(message) => { + self.restore_invalid_inline_slash_command(cmd, args_message, message) } + }, + SlashCommand::SandboxReadRoot => { self.app_event_tx .send(AppEvent::BeginWindowsSandboxGrantReadRoot { - path: prepared_args, + path: args_message.text, }); + QueueReplayControl::Stop } - _ => { - if should_queue && trimmed.is_empty() { - self.queue_current_inline_bare_slash_command(cmd); - } else { - self.dispatch_command(cmd); - } - } + _ => self.dispatch_command(cmd), } } + fn restore_invalid_inline_slash_command( + &mut self, + cmd: SlashCommand, + args_message: UserMessage, + message: String, + ) -> QueueReplayControl { + self.add_error_message(message); + self.restore_user_message_to_composer(Self::inline_slash_command_draft(cmd, args_message)); + QueueReplayControl::Stop + } + fn take_prepared_submission_user_message( &mut self, text: String, @@ -4906,10 +4867,7 @@ impl ChatWidget { } } - fn queued_inline_slash_command_draft( - cmd: SlashCommand, - args_message: UserMessage, - ) -> UserMessage { + fn inline_slash_command_draft(cmd: SlashCommand, args_message: UserMessage) -> UserMessage { let prefix = format!("/{}", cmd.command()); let UserMessage { text, @@ -4958,60 +4916,32 @@ impl ChatWidget { }; let args_message = self.take_prepared_submission_user_message(prepared_args, prepared_elements); - self.queue_slash_command(QueuedSlashCommand { - draft: Self::queued_inline_slash_command_draft(cmd, args_message), - action: QueuedSlashCommandAction::Command(cmd), - }); + self.queue_user_message(Self::inline_slash_command_draft(cmd, args_message)); } - fn queue_slash_command(&mut self, command: QueuedSlashCommand) { - self.queued_user_messages - .push_back(QueuedInput::SlashCommand(command)); - self.refresh_pending_input_preview(); - } - - fn queued_model_selection_draft( + fn model_selection_draft( model: &str, effort: Option, + scope: ModelSelectionScope, ) -> UserMessage { let mut text = format!("/model {model}"); - if let Some(effort) = effort { + if let Some(token) = Self::model_reasoning_effort_token(effort) { text.push(' '); - text.push_str(Self::status_line_reasoning_effort_label(Some(effort))); + text.push_str(token); + } + if let Some(token) = Self::model_selection_scope_token(scope) { + text.push(' '); + text.push_str(token); } 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( + fn apply_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); @@ -5048,27 +4978,21 @@ impl ChatWidget { } } - fn queued_review_draft(review_request: &ReviewRequest) -> UserMessage { + fn review_request_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::UncommittedChanges => "/review uncommitted".into(), + ReviewTarget::BaseBranch { branch } => format!("/review branch {branch}").into(), + ReviewTarget::Commit { sha, .. } => format!("/review commit {sha}").into(), ReviewTarget::Custom { instructions } => format!("/review {instructions}").into(), } } - pub(crate) fn apply_or_queue_review(&mut self, review_request: ReviewRequest) { + pub(crate) fn handle_serialized_slash_command(&mut self, draft: UserMessage) { if self.bottom_pane.is_task_running() { - self.queue_slash_command(QueuedSlashCommand { - draft: Self::queued_review_draft(&review_request), - action: QueuedSlashCommandAction::Review(review_request), - }); + self.queue_user_message(draft); return; } - self.submit_op(Op::Review { review_request }); + let _ = self.execute_serialized_slash_command(draft); } fn submit_plan_user_message(&mut self, user_message: UserMessage) { @@ -5082,52 +5006,206 @@ impl ChatWidget { } } - fn execute_queued_slash_command(&mut self, command: QueuedSlashCommand) { - match command.action { - QueuedSlashCommandAction::Command(cmd) => self.dispatch_command(cmd), - QueuedSlashCommandAction::Fast(action) => match action { - QueuedFastCommandAction::On => { - self.set_service_tier_selection(Some(ServiceTier::Fast)) + fn parse_builtin_slash_command(text: &str) -> Option<(SlashCommand, &str, usize)> { + let (name, rest, rest_offset) = parse_slash_name(text)?; + let cmd = SlashCommand::from_str(name).ok()?; + Some((cmd, rest, rest_offset)) + } + + fn is_builtin_slash_draft(draft: &UserMessage) -> bool { + Self::parse_builtin_slash_command(&draft.text).is_some() + } + + 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 { + self.add_error_message(format!("Failed to replay queued slash command: {preview}")); + self.restore_user_message_to_composer(draft); + return QueueReplayControl::Stop; + }; + if rest.trim().is_empty() { + let replay_control = self.dispatch_command(cmd); + if replay_control == QueueReplayControl::Continue + && self.bottom_pane.no_modal_or_popup_active() + { + return QueueReplayControl::Continue; + } + return QueueReplayControl::Stop; + } + if !cmd.supports_inline_args() { + self.add_error_message(format!("Failed to replay queued slash command: {preview}")); + self.restore_user_message_to_composer(draft); + return QueueReplayControl::Stop; + } + let args_message = Self::slash_command_args_message_from_draft(draft, rest_offset); + self.execute_inline_slash_command(cmd, args_message) + } + + fn slash_command_args_message_from_draft( + draft: UserMessage, + rest_offset: usize, + ) -> UserMessage { + let UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } = draft; + let rest = &text[rest_offset..]; + let trimmed_start = rest.len() - rest.trim_start().len(); + let trimmed_rest = rest.trim(); + let args_start = rest_offset + trimmed_start; + let args_end = args_start + trimmed_rest.len(); + let text_elements = text_elements + .into_iter() + .filter_map(|element| { + if element.byte_range.end <= args_start || element.byte_range.start >= args_end { + return None; } - QueuedFastCommandAction::Off => self.set_service_tier_selection(None), - QueuedFastCommandAction::Status => { - let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) { - "on" - } else { - "off" - }; - self.add_info_message(format!("Fast mode is {status}."), None); + let start = element.byte_range.start.saturating_sub(args_start); + let end = element.byte_range.end.min(args_end) - args_start; + (start < end).then_some(element.map_range(|_| ByteRange { start, end })) + }) + .collect(); + + UserMessage { + text: trimmed_rest.to_string(), + local_images, + remote_image_urls, + text_elements, + mention_bindings, + } + } + + fn parse_review_request(args: &str) -> Result { + let trimmed = args.trim(); + let lowered = trimmed.to_ascii_lowercase(); + let target = if matches!( + lowered.as_str(), + "uncommitted" | "current" | "current changes" | "current-changes" + ) { + ReviewTarget::UncommittedChanges + } else if let Some((keyword, value)) = trimmed.split_once(char::is_whitespace) { + let value = value.trim(); + match keyword.to_ascii_lowercase().as_str() { + "branch" if !value.is_empty() => ReviewTarget::BaseBranch { + branch: value.to_string(), + }, + "branch" => { + return Err( + "Usage: /review [uncommitted|branch |commit |]" + .to_string(), + ); } - }, - 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 { - self.submit_plan_user_message(user_message); + "commit" if !value.is_empty() => ReviewTarget::Commit { + sha: value.to_string(), + title: None, + }, + "commit" => { + return Err( + "Usage: /review [uncommitted|branch |commit |]" + .to_string(), + ); } + _ => ReviewTarget::Custom { + instructions: trimmed.to_string(), + }, } - QueuedSlashCommandAction::Rename(name) => { - self.session_telemetry - .counter("codex.thread.rename", 1, &[]); - let cell = Self::rename_confirmation_cell(&name, self.thread_id); - self.add_boxed_history(Box::new(cell)); - self.request_redraw(); - self.app_event_tx - .send(AppEvent::CodexOp(Op::SetThreadName { name })); + } else { + ReviewTarget::Custom { + instructions: trimmed.to_string(), } - QueuedSlashCommandAction::Review(review_request) => { - self.submit_op(Op::Review { review_request }); + }; + + Ok(ReviewRequest { + target, + user_facing_hint: None, + }) + } + + fn parse_model_selection_args( + args: &str, + ) -> Result<(String, Option, ModelSelectionScope), String> { + let mut tokens = args.split_whitespace(); + let Some(model) = tokens.next() else { + return Err( + "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" + .to_string(), + ); + }; + + let mut effort = None; + let mut scope = ModelSelectionScope::Global; + for token in tokens { + if let Some(parsed_effort) = Self::parse_model_reasoning_effort_token(token) { + if effort.is_some() { + return Err( + "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" + .to_string(), + ); + } + effort = parsed_effort; + continue; } - QueuedSlashCommandAction::SandboxReadRoot(path) => { - self.app_event_tx - .send(AppEvent::BeginWindowsSandboxGrantReadRoot { path }); + if let Some(parsed_scope) = Self::parse_model_scope_token(token) { + if scope != ModelSelectionScope::Global { + return Err( + "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" + .to_string(), + ); + } + scope = parsed_scope; + continue; } + return Err( + "Usage: /model [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]" + .to_string(), + ); + } + + Ok((model.to_string(), effort, scope)) + } + + fn parse_model_reasoning_effort_token(token: &str) -> Option> { + match token.to_ascii_lowercase().as_str() { + "default" => Some(None), + "none" => Some(Some(ReasoningEffortConfig::None)), + "minimal" => Some(Some(ReasoningEffortConfig::Minimal)), + "low" => Some(Some(ReasoningEffortConfig::Low)), + "medium" => Some(Some(ReasoningEffortConfig::Medium)), + "high" => Some(Some(ReasoningEffortConfig::High)), + "xhigh" => Some(Some(ReasoningEffortConfig::XHigh)), + _ => None, + } + } + + fn parse_model_scope_token(token: &str) -> Option { + match token.to_ascii_lowercase().as_str() { + "plan-only" => Some(ModelSelectionScope::PlanOnly), + "all-modes" => Some(ModelSelectionScope::AllModes), + "global" => Some(ModelSelectionScope::Global), + _ => None, + } + } + + fn model_reasoning_effort_token(effort: Option) -> Option<&'static str> { + match effort { + Some(ReasoningEffortConfig::None) => Some("none"), + Some(ReasoningEffortConfig::Minimal) => Some("minimal"), + Some(ReasoningEffortConfig::Low) => Some("low"), + Some(ReasoningEffortConfig::Medium) => Some("medium"), + Some(ReasoningEffortConfig::High) => Some("high"), + Some(ReasoningEffortConfig::XHigh) => Some("xhigh"), + None => None, + } + } + + fn model_selection_scope_token(scope: ModelSelectionScope) -> Option<&'static str> { + match scope { + ModelSelectionScope::Global => None, + ModelSelectionScope::PlanOnly => Some("plan-only"), + ModelSelectionScope::AllModes => Some("all-modes"), } } @@ -5218,7 +5296,7 @@ impl ChatWidget { || self.bottom_pane.is_task_running() || self.is_review_mode { - self.queued_user_messages.push_back(user_message.into()); + self.queued_user_messages.push_back(user_message); self.refresh_pending_input_preview(); } else { self.submit_user_message(user_message); @@ -5228,12 +5306,12 @@ impl ChatWidget { fn submit_user_message(&mut self, user_message: UserMessage) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); - self.queued_user_messages.push_front(user_message.into()); + self.queued_user_messages.push_front(user_message); self.refresh_pending_input_preview(); return; } if self.is_review_mode { - self.queued_user_messages.push_back(user_message.into()); + self.queued_user_messages.push_back(user_message); self.refresh_pending_input_preview(); return; } @@ -6013,17 +6091,18 @@ impl ChatWidget { return; } while !self.bottom_pane.is_task_running() { - let Some(queued_input) = self.queued_user_messages.pop_front() else { + let Some(queued_message) = self.queued_user_messages.pop_front() else { break; }; - match queued_input { - QueuedInput::UserMessage(user_message) => { - self.submit_user_message(user_message); - break; - } - QueuedInput::SlashCommand(command) => self.execute_queued_slash_command(command), - } - if !self.bottom_pane.no_modal_or_popup_active() { + 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); + QueueReplayControl::Stop + }; + if replay_control == QueueReplayControl::Stop + || !self.bottom_pane.no_modal_or_popup_active() + { break; } } @@ -6035,7 +6114,7 @@ impl ChatWidget { let queued_messages: Vec = self .queued_user_messages .iter() - .map(QueuedInput::preview_text) + .map(|message| message.text.clone()) .collect(); let pending_steers: Vec = self .pending_steers @@ -7098,11 +7177,13 @@ impl ChatWidget { return; } - tx.send(AppEvent::ApplyOrQueueModelSelection { - model: model_for_action.clone(), - effort: effort_for_action, - scope: ModelSelectionScope::Global, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft( + &model_for_action, + effort_for_action, + ModelSelectionScope::Global, + ), + )); })] } @@ -7169,19 +7250,15 @@ impl ChatWidget { let plan_only_actions: Vec = vec![Box::new({ let model = model.clone(); move |tx| { - tx.send(AppEvent::ApplyOrQueueModelSelection { - model: model.clone(), - effort, - scope: ModelSelectionScope::PlanOnly, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft(&model, effort, ModelSelectionScope::PlanOnly), + )); } })]; let all_modes_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::ApplyOrQueueModelSelection { - model: model.clone(), - effort, - scope: ModelSelectionScope::AllModes, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft(&model, effort, ModelSelectionScope::AllModes), + )); })]; self.bottom_pane.show_selection_view(SelectionViewParams { @@ -7269,11 +7346,13 @@ impl ChatWidget { effort: selected_effort, }); } else { - self.apply_or_queue_model_selection( - selected_model, - selected_effort, - ModelSelectionScope::Global, - ); + self.app_event_tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft( + selected_model.as_str(), + selected_effort, + ModelSelectionScope::Global, + ), + )); } return; } @@ -7348,11 +7427,13 @@ impl ChatWidget { effort: choice_effort, }); } else { - tx.send(AppEvent::ApplyOrQueueModelSelection { - model: model_for_action.clone(), - effort: choice_effort, - scope: ModelSelectionScope::Global, - }); + tx.send(AppEvent::HandleSlashCommandDraft( + Self::model_selection_draft( + &model_for_action, + choice_effort, + ModelSelectionScope::Global, + ), + )); } })]; @@ -9162,7 +9243,7 @@ impl ChatWidget { pub(crate) fn queued_user_message_texts(&self) -> Vec { self.queued_user_messages .iter() - .map(QueuedInput::preview_text) + .map(|message| message.text.clone()) .collect() } @@ -9345,12 +9426,12 @@ impl ChatWidget { items.push(SelectionItem { name: "Review uncommitted changes".to_string(), actions: vec![Box::new(move |tx: &AppEventSender| { - tx.send(AppEvent::ApplyOrQueueReview { - review_request: ReviewRequest { + tx.send(AppEvent::HandleSlashCommandDraft( + Self::review_request_draft(&ReviewRequest { target: ReviewTarget::UncommittedChanges, user_facing_hint: None, - }, - }); + }), + )); })], dismiss_on_select: true, ..Default::default() @@ -9398,14 +9479,14 @@ impl ChatWidget { items.push(SelectionItem { name: format!("{current_branch} -> {branch}"), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::ApplyOrQueueReview { - review_request: ReviewRequest { + tx3.send(AppEvent::HandleSlashCommandDraft( + Self::review_request_draft(&ReviewRequest { target: ReviewTarget::BaseBranch { branch: branch.clone(), }, user_facing_hint: None, - }, - }); + }), + )); })], dismiss_on_select: true, search_value: Some(option), @@ -9435,15 +9516,15 @@ impl ChatWidget { items.push(SelectionItem { name: subject.clone(), actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::ApplyOrQueueReview { - review_request: ReviewRequest { + tx3.send(AppEvent::HandleSlashCommandDraft( + Self::review_request_draft(&ReviewRequest { target: ReviewTarget::Commit { sha: sha.clone(), title: Some(subject.clone()), }, user_facing_hint: None, - }, - }); + }), + )); })], dismiss_on_select: true, search_value: Some(search_val), @@ -9472,14 +9553,14 @@ impl ChatWidget { if trimmed.is_empty() { return; } - tx.send(AppEvent::ApplyOrQueueReview { - review_request: ReviewRequest { + tx.send(AppEvent::HandleSlashCommandDraft( + Self::review_request_draft(&ReviewRequest { target: ReviewTarget::Custom { instructions: trimmed, }, user_facing_hint: None, - }, - }); + }), + )); }), ); self.bottom_pane.show_view(Box::new(view)); @@ -9828,15 +9909,15 @@ 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::ApplyOrQueueReview { - review_request: ReviewRequest { + tx3.send(AppEvent::HandleSlashCommandDraft( + ChatWidget::review_request_draft(&ReviewRequest { target: ReviewTarget::Commit { sha: sha.clone(), title: Some(subject.clone()), }, 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 deleted file mode 100644 index 6e15285fd7..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_slash_command_while_task_running_popup.snap +++ /dev/null @@ -1,23 +0,0 @@ ---- -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__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 new file mode 100644 index 0000000000..7c6587b5dd --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_slash_command_while_task_running_queues.snap @@ -0,0 +1,22 @@ +--- +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 e1811673b7..ce8467e60d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1238,32 +1238,26 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { )]; let existing_images = vec![PathBuf::from("/tmp/existing.png")]; - chat.queued_user_messages.push_back( - UserMessage { - text: first_text, - local_images: vec![LocalImageAttachment { - placeholder: first_placeholder.to_string(), - path: first_images[0].clone(), - }], - remote_image_urls: Vec::new(), - text_elements: first_elements, - mention_bindings: Vec::new(), - } - .into(), - ); - chat.queued_user_messages.push_back( - UserMessage { - text: second_text, - local_images: vec![LocalImageAttachment { - placeholder: second_placeholder.to_string(), - path: second_images[0].clone(), - }], - remote_image_urls: Vec::new(), - text_elements: second_elements, - mention_bindings: Vec::new(), - } - .into(), - ); + chat.queued_user_messages.push_back(UserMessage { + text: first_text, + local_images: vec![LocalImageAttachment { + placeholder: first_placeholder.to_string(), + path: first_images[0].clone(), + }], + remote_image_urls: Vec::new(), + text_elements: first_elements, + mention_bindings: Vec::new(), + }); + chat.queued_user_messages.push_back(UserMessage { + text: second_text, + local_images: vec![LocalImageAttachment { + placeholder: second_placeholder.to_string(), + path: second_images[0].clone(), + }], + remote_image_urls: Vec::new(), + text_elements: second_elements, + mention_bindings: Vec::new(), + }); chat.refresh_pending_input_preview(); chat.bottom_pane @@ -1327,16 +1321,13 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { chat.set_collaboration_mask(plan_mask); chat.on_task_started(); - chat.queued_user_messages.push_back( - UserMessage { - text: "Implement the plan.".to_string(), - local_images: Vec::new(), - remote_image_urls: Vec::new(), - text_elements: Vec::new(), - mention_bindings: Vec::new(), - } - .into(), - ); + chat.queued_user_messages.push_back(UserMessage { + text: "Implement the plan.".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }); chat.refresh_pending_input_preview(); chat.handle_codex_event(Event { @@ -2558,11 +2549,8 @@ async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_sc assert!( events.iter().any(|event| matches!( event, - AppEvent::ApplyOrQueueModelSelection { - model, - effort: Some(ReasoningEffortConfig::Medium), - scope: ModelSelectionScope::Global, - } if model == "gpt-5.1-codex-max" + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model gpt-5.1-codex-max medium" )), "expected model selection event; events: {events:?}" ); @@ -2647,11 +2635,8 @@ async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_promp assert!( events.iter().any(|event| matches!( event, - AppEvent::ApplyOrQueueModelSelection { - model, - effort: Some(ReasoningEffortConfig::Medium), - scope: ModelSelectionScope::Global, - } if model == "gpt-5" + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model gpt-5 medium" )), "expected model selection event; events: {events:?}" ); @@ -2678,11 +2663,8 @@ async fn plan_reasoning_scope_popup_all_modes_persists_global_and_plan_override( assert!( events.iter().any(|event| matches!( event, - AppEvent::ApplyOrQueueModelSelection { - model, - effort: Some(ReasoningEffortConfig::High), - scope: ModelSelectionScope::AllModes, - } if model == "gpt-5.1-codex-max" + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model gpt-5.1-codex-max high all-modes" )), "expected all-modes model selection event; events: {events:?}" ); @@ -2880,11 +2862,8 @@ async fn plan_reasoning_scope_popup_plan_only_does_not_update_all_modes_reasonin assert!( events.iter().any(|event| matches!( event, - AppEvent::ApplyOrQueueModelSelection { - model, - effort: Some(ReasoningEffortConfig::High), - scope: ModelSelectionScope::PlanOnly, - } if model == "gpt-5.1-codex-max" + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model gpt-5.1-codex-max high plan-only" )), "expected plan-only model selection event; events: {events:?}" ); @@ -3715,9 +3694,9 @@ async fn alt_up_edits_most_recent_queued_message() { // Seed two queued messages. chat.queued_user_messages - .push_back(UserMessage::from("first queued".to_string()).into()); + .push_back(UserMessage::from("first queued".to_string())); chat.queued_user_messages - .push_back(UserMessage::from("second queued".to_string()).into()); + .push_back(UserMessage::from("second queued".to_string())); chat.refresh_pending_input_preview(); // Press Alt+Up to edit the most recent (last) queued message. @@ -3731,7 +3710,7 @@ async fn alt_up_edits_most_recent_queued_message() { // And the queue should now contain only the remaining (older) item. assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().preview_text(), + chat.queued_user_messages.front().unwrap().text, "first queued" ); } @@ -3749,9 +3728,9 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( // Seed two queued messages. chat.queued_user_messages - .push_back(UserMessage::from("first queued".to_string()).into()); + .push_back(UserMessage::from("first queued".to_string())); chat.queued_user_messages - .push_back(UserMessage::from("second queued".to_string()).into()); + .push_back(UserMessage::from("second queued".to_string())); chat.refresh_pending_input_preview(); // Press Shift+Left to edit the most recent (last) queued message. @@ -3765,7 +3744,7 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( // And the queue should now contain only the remaining (older) item. assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().preview_text(), + chat.queued_user_messages.front().unwrap().text, "first queued" ); } @@ -3834,7 +3813,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { assert_eq!(chat.queued_user_messages.len(), 3); for message in chat.queued_user_messages.iter() { - assert_eq!(message.preview_text(), "repeat me"); + assert_eq!(message.text, "repeat me"); } } @@ -3857,7 +3836,7 @@ async fn streaming_final_answer_ctrl_c_interrupt_preserves_background_shells() { assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().preview_text(), + chat.queued_user_messages.front().unwrap().text, "queued submission" ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); @@ -4034,7 +4013,7 @@ async fn steer_enter_queues_while_plan_stream_is_active() { assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().preview_text(), + chat.queued_user_messages.front().unwrap().text, "queued submission" ); assert!(chat.pending_steers.is_empty()); @@ -4527,7 +4506,7 @@ async fn esc_interrupt_sends_all_pending_steers_immediately_and_keeps_existing_d } chat.queued_user_messages - .push_back(UserMessage::from("queued draft".to_string()).into()); + .push_back(UserMessage::from("queued draft".to_string())); chat.refresh_pending_input_preview(); chat.bottom_pane .set_composer_text("still editing".to_string(), Vec::new(), Vec::new()); @@ -4552,7 +4531,7 @@ async fn esc_interrupt_sends_all_pending_steers_immediately_and_keeps_existing_d assert_eq!(chat.bottom_pane.composer_text(), "still editing"); assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().preview_text(), + chat.queued_user_messages.front().unwrap().text, "queued draft" ); @@ -4652,7 +4631,7 @@ async fn manual_interrupt_restores_pending_steers_before_queued_messages() { .set_composer_text("pending steer".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); chat.queued_user_messages - .push_back(UserMessage::from("queued draft".to_string()).into()); + .push_back(UserMessage::from("queued draft".to_string())); chat.refresh_pending_input_preview(); match next_submit_op(&mut op_rx) { @@ -4694,7 +4673,7 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() { .set_composer_text("pending steer".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); chat.queued_user_messages - .push_back(UserMessage::from("queued draft".to_string()).into()); + .push_back(UserMessage::from("queued draft".to_string())); chat.refresh_pending_input_preview(); match next_submit_op(&mut op_rx) { @@ -5417,6 +5396,55 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +#[tokio::test] +async fn queued_init_replay_stops_after_submitting_user_turn() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let tempdir = tempdir().expect("tempdir"); + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: tempdir.path().to_path_buf(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }; + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + chat.queued_user_messages.push_back("/init".into()); + chat.queued_user_messages.push_back("after init".into()); + + chat.maybe_send_next_queued_input(); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!( + items, + vec![UserInput::Text { + text: include_str!("../../prompt_for_init_command.md").to_string(), + text_elements: Vec::new(), + }] + ); + assert_eq!( + chat.queued_user_message_texts(), + vec!["after init".to_string()] + ); + assert_no_submit_op(&mut op_rx); +} + #[tokio::test] async fn collab_mode_shift_tab_cycles_only_when_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; @@ -5618,6 +5646,59 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); } +#[tokio::test] +async fn queued_plan_replay_stops_after_submitting_user_turn() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }; + chat.handle_codex_event(Event { + id: "configured".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + chat.queued_user_messages + .push_back("/plan build the plan".into()); + chat.queued_user_messages + .push_back("after plan replay".into()); + + chat.maybe_send_next_queued_input(); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!( + items, + vec![UserInput::Text { + text: "build the plan".to_string(), + text_elements: Vec::new(), + }] + ); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!( + chat.queued_user_message_texts(), + vec!["after plan replay".to_string()] + ); + assert_no_submit_op(&mut op_rx); +} + #[tokio::test] async fn collaboration_modes_defaults_to_code_on_startup() { let codex_home = tempdir().expect("tempdir"); @@ -6318,7 +6399,7 @@ async fn review_commit_picker_shows_subjects_without_timestamps() { /// Submitting the custom prompt view sends Op::Review with the typed prompt /// and uses the same text for the user-facing hint. #[tokio::test] -async fn custom_prompt_submit_sends_review_op() { +async fn custom_prompt_submit_serializes_review_draft() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.show_review_custom_prompt(); @@ -6326,19 +6407,11 @@ async fn custom_prompt_submit_sends_review_op() { chat.handle_paste(" please audit dependencies ".to_string()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - // Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt + // Expect a serialized /review draft with trimmed prompt. let evt = rx.try_recv().expect("expected one app event"); match evt { - AppEvent::ApplyOrQueueReview { review_request } => { - assert_eq!( - review_request, - ReviewRequest { - target: ReviewTarget::Custom { - instructions: "please audit dependencies".to_string(), - }, - user_facing_hint: None, - } - ); + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) => { + assert_eq!(text, "/review please audit dependencies"); } other => panic!("unexpected app event: {other:?}"), } @@ -7999,9 +8072,11 @@ async fn single_reasoning_option_skips_selection() { } assert!( - events - .iter() - .any(|ev| matches!(ev, AppEvent::UpdateReasoningEffort(Some(effort)) if *effort == ReasoningEffortConfig::High)), + events.iter().any(|event| matches!( + event, + AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }) + if text == "/model model-with-single-reasoning high" + )), "expected reasoning effort to be applied automatically; events: {events:?}" ); } @@ -8130,15 +8205,18 @@ async fn user_shell_command_renders_output_not_exploring() { } #[tokio::test] -async fn model_slash_command_while_task_running_popup_snapshot() { +async fn model_slash_command_while_task_running_queues_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!(chat.queued_user_messages.is_empty()); - assert!(chat.has_active_view(), "expected /model popup to open"); + 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!(drain_insert_history(&mut rx).is_empty()); let width: u16 = 80; @@ -8152,7 +8230,7 @@ async fn model_slash_command_while_task_running_popup_snapshot() { }) .unwrap(); assert_snapshot!( - "model_slash_command_while_task_running_popup", + "model_slash_command_while_task_running_queues", term.backend().vt100().screen().contents() ); } @@ -8162,11 +8240,11 @@ 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(), + chat.handle_serialized_slash_command(ChatWidget::model_selection_draft( + "gpt-5.1-codex-max", Some(ReasoningEffortConfig::High), ModelSelectionScope::Global, - ); + )); assert_eq!( chat.queued_user_message_texts(), @@ -8174,6 +8252,103 @@ async fn model_selection_queues_selected_action_while_task_running() { ); } +#[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; + chat.on_task_started(); + chat.bottom_pane.set_composer_text( + "/model gpt-5.1-codex-max high all-modes".to_string(), + Vec::new(), + Vec::new(), + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["/model gpt-5.1-codex-max high all-modes".to_string()] + ); + + chat.on_task_complete(None, false); + + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.current_model(), "gpt-5.1-codex-max"); + assert_eq!( + chat.effective_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + chat.config.plan_mode_reasoning_effort, + Some(ReasoningEffortConfig::High) + ); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::PersistModelSelection { model, effort } + if model == "gpt-5.1-codex-max" + && *effort == Some(ReasoningEffortConfig::High) + )), + "expected queued typed /model replay to persist the global model selection; events: {events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::PersistPlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) + )), + "expected queued typed /model replay to persist plan-mode reasoning; events: {events:?}" + ); +} + +#[tokio::test] +async fn queued_model_selection_replays_after_turn_complete() { + let (mut chat, mut rx, _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::AllModes, + )); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["/model gpt-5.1-codex-max high all-modes".to_string()] + ); + + chat.on_task_complete(None, false); + + assert!(chat.queued_user_messages.is_empty()); + assert_eq!(chat.current_model(), "gpt-5.1-codex-max"); + assert_eq!( + chat.effective_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + chat.config.plan_mode_reasoning_effort, + Some(ReasoningEffortConfig::High) + ); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::PersistModelSelection { model, effort } + if model == "gpt-5.1-codex-max" + && *effort == Some(ReasoningEffortConfig::High) + )), + "expected queued /model replay to persist the global model selection; events: {events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::PersistPlanModeReasoningEffort(Some(ReasoningEffortConfig::High)) + )), + "expected queued /model replay to persist plan-mode reasoning; events: {events:?}" + ); +} + #[tokio::test] async fn esc_interrupts_running_task_with_empty_composer() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; @@ -8186,12 +8361,30 @@ async fn esc_interrupts_running_task_with_empty_composer() { assert!(!chat.has_active_view(), "expected no popup to remain open"); } +#[tokio::test] +async fn esc_interrupts_running_task_with_nonempty_composer_and_restores_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()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + next_interrupt_op(&mut op_rx); + chat.on_interrupted_turn(TurnAbortReason::Interrupted); + + assert_eq!(chat.bottom_pane.composer_text(), "still editing"); + assert!(chat.queued_user_messages.is_empty()); + let _ = drain_insert_history(&mut rx); +} + #[tokio::test] async fn esc_with_model_popup_active_dismisses_popup_without_interrupting() { 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); + chat.open_model_popup(); assert!(chat.has_active_view(), "expected /model popup to open"); @@ -8220,7 +8413,7 @@ async fn esc_with_popup_active_does_not_interrupt_pending_steers() { other => panic!("expected Op::UserTurn, got {other:?}"), } - chat.dispatch_command(SlashCommand::Model); + chat.open_model_popup(); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); @@ -9102,9 +9295,9 @@ async fn interrupt_restores_queued_messages_into_composer() { // Queue two user messages while the task is running. chat.queued_user_messages - .push_back(UserMessage::from("first queued".to_string()).into()); + .push_back(UserMessage::from("first queued".to_string())); chat.queued_user_messages - .push_back(UserMessage::from("second queued".to_string()).into()); + .push_back(UserMessage::from("second queued".to_string())); chat.refresh_pending_input_preview(); // Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed). @@ -9139,12 +9332,9 @@ async fn interrupt_replays_queued_slash_commands_and_restores_drafts() { chat.bottom_pane.set_task_running(true); chat.queued_user_messages - .push_back(UserMessage::from("queued draft".to_string()).into()); + .push_back(UserMessage::from("queued draft".to_string())); chat.queued_user_messages - .push_back(QueuedInput::SlashCommand(QueuedSlashCommand { - draft: UserMessage::from("/review".to_string()), - action: QueuedSlashCommandAction::Command(SlashCommand::Review), - })); + .push_back(UserMessage::from("/review".to_string())); chat.refresh_pending_input_preview(); chat.handle_codex_event(Event { @@ -9172,15 +9362,9 @@ async fn interrupt_replays_multiple_queued_slash_commands_in_order() { chat.bottom_pane.set_task_running(true); chat.queued_user_messages - .push_back(QueuedInput::SlashCommand(QueuedSlashCommand { - draft: UserMessage::from("/fast status".to_string()), - action: QueuedSlashCommandAction::Fast(QueuedFastCommandAction::Status), - })); + .push_back(UserMessage::from("/fast status".to_string())); chat.queued_user_messages - .push_back(QueuedInput::SlashCommand(QueuedSlashCommand { - draft: UserMessage::from("/review".to_string()), - action: QueuedSlashCommandAction::Command(SlashCommand::Review), - })); + .push_back(UserMessage::from("/review".to_string())); chat.refresh_pending_input_preview(); chat.handle_codex_event(Event { @@ -9215,9 +9399,9 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { .set_composer_text("current draft".to_string(), Vec::new(), Vec::new()); chat.queued_user_messages - .push_back(UserMessage::from("first queued".to_string()).into()); + .push_back(UserMessage::from("first queued".to_string())); chat.queued_user_messages - .push_back(UserMessage::from("second queued".to_string()).into()); + .push_back(UserMessage::from("second queued".to_string())); chat.refresh_pending_input_preview(); chat.handle_codex_event(Event { @@ -11186,7 +11370,7 @@ async fn enter_queues_user_messages_while_review_is_running() { assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().preview_text(), + chat.queued_user_messages.front().unwrap().text, "Queued while /review is running." ); assert!(chat.pending_steers.is_empty()); @@ -11195,14 +11379,20 @@ async fn enter_queues_user_messages_while_review_is_running() { } #[tokio::test] -async fn review_slash_command_opens_popup_while_task_running() { +async fn review_slash_command_queues_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!(chat.queued_user_messages.is_empty()); - assert!(chat.has_active_view(), "expected /review popup to open"); + 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!(drain_insert_history(&mut rx).is_empty()); } @@ -11248,6 +11438,46 @@ async fn review_slash_command_with_args_queues_while_task_running_and_submits_af } } +#[tokio::test] +async fn queued_review_selection_replays_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::BaseBranch { + branch: "main".to_string(), + }, + user_facing_hint: None, + })); + + assert_eq!( + chat.queued_user_message_texts(), + vec!["/review branch main".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::BaseBranch { + branch: "main".to_string(), + }, + user_facing_hint: None, + } + ); + break; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected queued /review branch op"), + Err(TryRecvError::Disconnected) => panic!("expected queued /review branch op"), + } + } +} + #[tokio::test] async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index d83135c2ff..ce2cc20ff2 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -120,11 +120,12 @@ impl SlashCommand { self.into() } - /// Whether this command supports inline args (for example `/review ...`). + /// Whether this command supports inline args (for example `/model ...` or `/review ...`). pub fn supports_inline_args(self) -> bool { matches!( self, - SlashCommand::Review + SlashCommand::Model + | SlashCommand::Review | SlashCommand::Rename | SlashCommand::Plan | SlashCommand::Fast diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 5630b84eca..bade061dea 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -115,8 +115,8 @@ the input starts with `!` (shell command). 5. Clears pending pastes on success and suppresses submission if the final text is empty and there are no attachments. -The same preparation path is reused for slash commands with arguments (for example `/plan` and -`/review`) so pasted content and text elements are preserved when extracting args. +The same preparation path is reused for slash commands with arguments (for example `/model`, +`/plan`, and `/review`) so pasted content and text elements are preserved when extracting args. The composer also treats the textarea kill buffer as separate editing state from the visible draft. After submit or slash-command dispatch clears the textarea, the most recent `Ctrl+K` payload is