diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 869e148469..8b6e22a414 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4321,12 +4321,13 @@ impl ChatWidget { /// /// Live callers usually ignore the return value, but queued replay uses it to decide whether /// draining can continue after this command. `Continue` means the command only changed local - /// UI/config state, while `Stop` means it opened UI, submitted work, or otherwise hit a - /// boundary that should stop queued draining. + /// UI/config state. `Stop` means it submitted or queued work, changed session/navigation + /// state, or otherwise hit a boundary where queued draining must stop. Commands that require + /// interactive UI are resolved before queueing and should not open that UI during replay. fn dispatch_command(&mut self, cmd: SlashCommand) -> QueueReplayControl { if self.bottom_pane.is_task_running() && !cmd.available_during_task() - && !matches!(cmd, SlashCommand::Model | SlashCommand::Review) + && !cmd.requires_interaction() { self.queue_user_message(format!("/{}", cmd.command()).into()); // This busy-path queueing only happens for live command dispatch. Queued replay @@ -4707,7 +4708,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 && !matches!(cmd, SlashCommand::Model | SlashCommand::Review) { + if should_queue && !cmd.requires_interaction() { self.queue_current_inline_bare_slash_command(cmd); } else { self.dispatch_command(cmd); @@ -5081,6 +5082,13 @@ impl ChatWidget { return QueueReplayControl::Stop; }; if rest.trim().is_empty() { + if cmd.requires_interaction() { + self.add_error_message(format!( + "Failed to replay queued slash command requiring interaction: {preview}" + )); + self.restore_user_message_to_composer(draft); + return QueueReplayControl::Stop; + } let replay_control = self.dispatch_command(cmd); if replay_control == QueueReplayControl::Continue && self.bottom_pane.no_modal_or_popup_active() diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4a469912e1..251b29e5eb 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -8232,6 +8232,18 @@ async fn model_slash_command_while_task_running_opens_popup_snapshot() { ); } +#[tokio::test] +async fn theme_slash_command_while_task_running_opens_popup_instead_of_queueing() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + + chat.dispatch_command(SlashCommand::Theme); + + assert!(chat.queued_user_messages.is_empty()); + assert!(chat.has_active_view(), "expected /theme popup to open"); + assert!(drain_insert_history(&mut rx).is_empty()); +} + #[tokio::test] async fn model_selection_queues_selected_action_while_task_running() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await; @@ -11570,6 +11582,25 @@ async fn queued_review_selection_replays_after_turn_complete() { } } +#[tokio::test] +async fn queued_bare_theme_command_restores_to_composer_instead_of_opening_popup() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + chat.queued_user_messages + .push_back(UserMessage::from("/theme".to_string())); + + chat.on_task_complete(None, false); + + assert_eq!(chat.bottom_pane.composer_text(), "/theme"); + assert!(!chat.has_active_view(), "expected no popup during replay"); + assert!(chat.queued_user_messages.is_empty()); + assert_no_submit_op(&mut op_rx); + assert!( + !drain_insert_history(&mut rx).is_empty(), + "expected replay failure to be surfaced to the user" + ); +} + #[tokio::test] async fn queued_custom_review_selection_preserves_branch_like_instructions_after_turn_complete() { let (mut chat, _rx, mut 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 ce2cc20ff2..0b3d3d8097 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -133,6 +133,29 @@ impl SlashCommand { ) } + /// Whether bare dispatch opens interactive UI that should be resolved before queueing. + pub fn requires_interaction(self) -> bool { + matches!( + self, + SlashCommand::Feedback + | SlashCommand::Resume + | SlashCommand::Review + | SlashCommand::Rename + | SlashCommand::Model + | SlashCommand::Settings + | SlashCommand::Personality + | SlashCommand::Collab + | SlashCommand::Agent + | SlashCommand::MultiAgents + | SlashCommand::Approvals + | SlashCommand::Permissions + | SlashCommand::Experimental + | SlashCommand::Skills + | SlashCommand::Statusline + | SlashCommand::Theme + ) + } + /// Whether this command can be run while a task is in progress. pub fn available_during_task(self) -> bool { match self {