queue interactive slash command selections

Keep interactive slash flows usable while a turn is running, and queue the resulting action instead of the bare slash token.

Also let Esc interrupt through popup-active states so queued drafts restore without dropping queued slash actions.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-15 23:32:49 -07:00
co-authored by Codex
parent a8f1f43c1f
commit e5f1b8435d
7 changed files with 322 additions and 107 deletions
+12
View File
@@ -2717,6 +2717,15 @@ impl App {
self.chat_widget.set_model(&model);
self.refresh_status_line();
}
AppEvent::ApplyOrQueueModelSelection {
model,
effort,
scope,
} => {
self.chat_widget
.apply_or_queue_model_selection(model, effort, scope);
self.refresh_status_line();
}
AppEvent::UpdateCollaborationMode(mask) => {
self.chat_widget.set_collaboration_mask(mask);
self.refresh_status_line();
@@ -2737,6 +2746,9 @@ impl App {
AppEvent::OpenAllModelsPopup { models } => {
self.chat_widget.open_all_models_popup(models);
}
AppEvent::ApplyOrQueueReview { review_request } => {
self.chat_widget.apply_or_queue_review(review_request);
}
AppEvent::OpenFullAccessConfirmation {
preset,
return_to_permissions,
+22
View File
@@ -16,6 +16,7 @@ use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::ReviewRequest;
use codex_utils_approval_presets::ApprovalPreset;
use crate::bottom_pane::ApprovalRequest;
@@ -53,6 +54,13 @@ impl RealtimeAudioDeviceKind {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ModelSelectionScope {
Global,
PlanOnly,
AllModes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum WindowsSandboxEnableMode {
@@ -182,6 +190,14 @@ pub(crate) enum AppEvent {
/// Update the current model slug in the running app and widget.
UpdateModel(String),
/// Apply a model selection immediately or queue it when chosen from an interactive picker
/// while a task is still running.
ApplyOrQueueModelSelection {
model: String,
effort: Option<ReasoningEffort>,
scope: ModelSelectionScope,
},
/// Update the active collaboration mask in the running app and widget.
UpdateCollaborationMode(CollaborationModeMask),
@@ -240,6 +256,12 @@ pub(crate) enum AppEvent {
models: Vec<ModelPreset>,
},
/// 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,
+1 -1
View File
@@ -137,11 +137,11 @@ pub(crate) enum CancellationEvent {
NotHandled,
}
use crate::bottom_pane::prompt_args::parse_slash_name;
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::ChatComposerConfig;
pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
pub(crate) use prompt_args::parse_slash_name;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::status_indicator_widget::StatusIndicatorWidget;
+164 -44
View File
@@ -216,6 +216,7 @@ fn queued_message_edit_binding_for_terminal(terminal_name: TerminalName) -> KeyB
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event::ExitMode;
use crate::app_event::ModelSelectionScope;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event_sender::AppEventSender;
@@ -238,6 +239,7 @@ use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::bottom_pane::parse_slash_name;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::clipboard_text;
@@ -915,12 +917,20 @@ impl QueuedSlashCommand {
enum QueuedSlashCommandAction {
Command(SlashCommand),
Fast(QueuedFastCommandAction),
ModelSelection(QueuedModelSelection),
Plan(UserMessage),
Rename(String),
Review(ReviewRequest),
SandboxReadRoot(String),
}
#[derive(Debug, Clone, PartialEq)]
struct QueuedModelSelection {
model: String,
effort: Option<ReasoningEffortConfig>,
scope: ModelSelectionScope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum QueuedFastCommandAction {
On,
@@ -4207,11 +4217,17 @@ impl ChatWidget {
if matches!(key_event.code, KeyCode::Esc)
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& !self.pending_steers.is_empty()
&& self.bottom_pane.is_task_running()
&& self.bottom_pane.no_modal_or_popup_active()
&& (self
.bottom_pane
.composer_text()
.lines()
.next()
.and_then(parse_slash_name)
.is_none_or(|(name, _, _)| name != "agent")
|| !self.pending_steers.is_empty())
{
self.submit_pending_steers_after_interrupt = true;
self.submit_pending_steers_after_interrupt = !self.pending_steers.is_empty();
if !self.submit_op(Op::Interrupt) {
self.submit_pending_steers_after_interrupt = false;
}
@@ -4372,6 +4388,17 @@ impl ChatWidget {
fn dispatch_command(&mut self, cmd: SlashCommand) {
if self.bottom_pane.is_task_running() && !cmd.available_during_task() {
match cmd {
SlashCommand::Model => {
self.open_model_popup();
return;
}
SlashCommand::Review => {
self.open_review_popup();
return;
}
_ => {}
}
self.queue_slash_command(QueuedSlashCommand {
draft: format!("/{}", cmd.command()).into(),
action: QueuedSlashCommandAction::Command(cmd),
@@ -4950,6 +4977,107 @@ impl ChatWidget {
self.refresh_pending_input_preview();
}
fn queued_model_selection_draft(
model: &str,
effort: Option<ReasoningEffortConfig>,
) -> UserMessage {
let mut text = format!("/model {model}");
if let Some(effort) = effort {
text.push(' ');
text.push_str(Self::status_line_reasoning_effort_label(Some(effort)));
}
text.into()
}
fn queue_model_selection_if_task_running(
&mut self,
model: String,
effort: Option<ReasoningEffortConfig>,
scope: ModelSelectionScope,
) -> bool {
if !self.bottom_pane.is_task_running() {
return false;
}
let draft = Self::queued_model_selection_draft(&model, effort);
self.queue_slash_command(QueuedSlashCommand {
draft,
action: QueuedSlashCommandAction::ModelSelection(QueuedModelSelection {
model,
effort,
scope,
}),
});
true
}
pub(crate) fn apply_or_queue_model_selection(
&mut self,
model: String,
effort: Option<ReasoningEffortConfig>,
scope: ModelSelectionScope,
) {
if self.queue_model_selection_if_task_running(model.clone(), effort, scope) {
return;
}
match scope {
ModelSelectionScope::Global => {
self.set_model(&model);
self.set_reasoning_effort(effort);
self.app_event_tx.send(AppEvent::UpdateModel(model.clone()));
self.app_event_tx
.send(AppEvent::UpdateReasoningEffort(effort));
self.app_event_tx
.send(AppEvent::PersistModelSelection { model, effort });
}
ModelSelectionScope::PlanOnly => {
self.set_model(&model);
self.set_plan_mode_reasoning_effort(effort);
self.app_event_tx.send(AppEvent::UpdateModel(model));
self.app_event_tx
.send(AppEvent::UpdatePlanModeReasoningEffort(effort));
self.app_event_tx
.send(AppEvent::PersistPlanModeReasoningEffort(effort));
}
ModelSelectionScope::AllModes => {
self.set_model(&model);
self.set_reasoning_effort(effort);
self.set_plan_mode_reasoning_effort(effort);
self.app_event_tx.send(AppEvent::UpdateModel(model.clone()));
self.app_event_tx
.send(AppEvent::UpdateReasoningEffort(effort));
self.app_event_tx
.send(AppEvent::UpdatePlanModeReasoningEffort(effort));
self.app_event_tx
.send(AppEvent::PersistPlanModeReasoningEffort(effort));
self.app_event_tx
.send(AppEvent::PersistModelSelection { model, effort });
}
}
}
fn queued_review_draft(review_request: &ReviewRequest) -> UserMessage {
match &review_request.target {
ReviewTarget::UncommittedChanges => "/review current changes".into(),
ReviewTarget::BaseBranch { branch } => format!("/review {branch}").into(),
ReviewTarget::Commit { sha, title } => {
let subject = title.clone().unwrap_or_else(|| sha.clone());
format!("/review {subject}").into()
}
ReviewTarget::Custom { instructions } => format!("/review {instructions}").into(),
}
}
pub(crate) fn apply_or_queue_review(&mut self, review_request: ReviewRequest) {
if self.bottom_pane.is_task_running() {
self.queue_slash_command(QueuedSlashCommand {
draft: Self::queued_review_draft(&review_request),
action: QueuedSlashCommandAction::Review(review_request),
});
return;
}
self.submit_op(Op::Review { review_request });
}
fn submit_plan_user_message(&mut self, user_message: UserMessage) {
if self.is_session_configured() {
self.reasoning_buffer.clear();
@@ -4978,6 +5106,13 @@ impl ChatWidget {
self.add_info_message(format!("Fast mode is {status}."), None);
}
},
QueuedSlashCommandAction::ModelSelection(selection) => {
self.apply_or_queue_model_selection(
selection.model,
selection.effort,
selection.scope,
);
}
QueuedSlashCommandAction::Plan(user_message) => {
self.dispatch_command(SlashCommand::Plan);
if self.active_mode_kind() == ModeKind::Plan {
@@ -6970,11 +7105,10 @@ impl ChatWidget {
return;
}
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort_for_action));
tx.send(AppEvent::PersistModelSelection {
tx.send(AppEvent::ApplyOrQueueModelSelection {
model: model_for_action.clone(),
effort: effort_for_action,
scope: ModelSelectionScope::Global,
});
})]
}
@@ -7042,19 +7176,18 @@ impl ChatWidget {
let plan_only_actions: Vec<SelectionAction> = vec![Box::new({
let model = model.clone();
move |tx| {
tx.send(AppEvent::UpdateModel(model.clone()));
tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort));
tx.send(AppEvent::PersistPlanModeReasoningEffort(effort));
tx.send(AppEvent::ApplyOrQueueModelSelection {
model: model.clone(),
effort,
scope: ModelSelectionScope::PlanOnly,
});
}
})];
let all_modes_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::UpdateModel(model.clone()));
tx.send(AppEvent::UpdateReasoningEffort(effort));
tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort));
tx.send(AppEvent::PersistPlanModeReasoningEffort(effort));
tx.send(AppEvent::PersistModelSelection {
tx.send(AppEvent::ApplyOrQueueModelSelection {
model: model.clone(),
effort,
scope: ModelSelectionScope::AllModes,
});
})];
@@ -7143,7 +7276,11 @@ impl ChatWidget {
effort: selected_effort,
});
} else {
self.apply_model_and_effort(selected_model, selected_effort);
self.apply_or_queue_model_selection(
selected_model,
selected_effort,
ModelSelectionScope::Global,
);
}
return;
}
@@ -7218,11 +7355,10 @@ impl ChatWidget {
effort: choice_effort,
});
} else {
tx.send(AppEvent::UpdateModel(model_for_action.clone()));
tx.send(AppEvent::UpdateReasoningEffort(choice_effort));
tx.send(AppEvent::PersistModelSelection {
tx.send(AppEvent::ApplyOrQueueModelSelection {
model: model_for_action.clone(),
effort: choice_effort,
scope: ModelSelectionScope::Global,
});
}
})];
@@ -7263,22 +7399,6 @@ impl ChatWidget {
}
}
fn apply_model_and_effort_without_persist(
&self,
model: String,
effort: Option<ReasoningEffortConfig>,
) {
self.app_event_tx.send(AppEvent::UpdateModel(model));
self.app_event_tx
.send(AppEvent::UpdateReasoningEffort(effort));
}
fn apply_model_and_effort(&self, model: String, effort: Option<ReasoningEffortConfig>) {
self.apply_model_and_effort_without_persist(model.clone(), effort);
self.app_event_tx
.send(AppEvent::PersistModelSelection { model, effort });
}
/// Open the permissions popup (alias for /permissions).
pub(crate) fn open_approvals_popup(&mut self) {
self.open_permissions_popup();
@@ -9232,12 +9352,12 @@ impl ChatWidget {
items.push(SelectionItem {
name: "Review uncommitted changes".to_string(),
actions: vec![Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::CodexOp(Op::Review {
tx.send(AppEvent::ApplyOrQueueReview {
review_request: ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: None,
},
}));
});
})],
dismiss_on_select: true,
..Default::default()
@@ -9285,14 +9405,14 @@ impl ChatWidget {
items.push(SelectionItem {
name: format!("{current_branch} -> {branch}"),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.send(AppEvent::CodexOp(Op::Review {
tx3.send(AppEvent::ApplyOrQueueReview {
review_request: ReviewRequest {
target: ReviewTarget::BaseBranch {
branch: branch.clone(),
},
user_facing_hint: None,
},
}));
});
})],
dismiss_on_select: true,
search_value: Some(option),
@@ -9322,7 +9442,7 @@ impl ChatWidget {
items.push(SelectionItem {
name: subject.clone(),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.send(AppEvent::CodexOp(Op::Review {
tx3.send(AppEvent::ApplyOrQueueReview {
review_request: ReviewRequest {
target: ReviewTarget::Commit {
sha: sha.clone(),
@@ -9330,7 +9450,7 @@ impl ChatWidget {
},
user_facing_hint: None,
},
}));
});
})],
dismiss_on_select: true,
search_value: Some(search_val),
@@ -9359,14 +9479,14 @@ impl ChatWidget {
if trimmed.is_empty() {
return;
}
tx.send(AppEvent::CodexOp(Op::Review {
tx.send(AppEvent::ApplyOrQueueReview {
review_request: ReviewRequest {
target: ReviewTarget::Custom {
instructions: trimmed,
},
user_facing_hint: None,
},
}));
});
}),
);
self.bottom_pane.show_view(Box::new(view));
@@ -9715,7 +9835,7 @@ pub(crate) fn show_review_commit_picker_with_entries(
items.push(SelectionItem {
name: subject.clone(),
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.send(AppEvent::CodexOp(Op::Review {
tx3.send(AppEvent::ApplyOrQueueReview {
review_request: ReviewRequest {
target: ReviewTarget::Commit {
sha: sha.clone(),
@@ -9723,7 +9843,7 @@ pub(crate) fn show_review_commit_picker_with_entries(
},
user_facing_hint: None,
},
}));
});
})],
dismiss_on_select: true,
search_value: Some(search_val),
@@ -0,0 +1,23 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7753
expression: term.backend().vt100().screen().contents()
---
Select Model and Effort
Access legacy models by running codex -m <model_name> 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.
@@ -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
+100 -40
View File
@@ -2558,15 +2558,19 @@ async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_sc
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::UpdateModel(model) if model == "gpt-5.1-codex-max"
AppEvent::ApplyOrQueueModelSelection {
model,
effort: Some(ReasoningEffortConfig::Medium),
scope: ModelSelectionScope::Global,
} if model == "gpt-5.1-codex-max"
)),
"expected model update event; events: {events:?}"
"expected model selection event; events: {events:?}"
);
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))),
"expected reasoning update event; events: {events:?}"
.all(|event| !matches!(event, AppEvent::OpenPlanReasoningScopePrompt { .. })),
"did not expect plan reasoning scope prompt event; events: {events:?}"
);
}
@@ -2643,15 +2647,19 @@ async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_promp
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::UpdateModel(model) if model == "gpt-5"
AppEvent::ApplyOrQueueModelSelection {
model,
effort: Some(ReasoningEffortConfig::Medium),
scope: ModelSelectionScope::Global,
} if model == "gpt-5"
)),
"expected model update event; events: {events:?}"
"expected model selection event; events: {events:?}"
);
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))),
"expected reasoning update event; events: {events:?}"
.all(|event| !matches!(event, AppEvent::OpenPlanReasoningScopePrompt { .. })),
"did not expect plan reasoning scope prompt event; events: {events:?}"
);
}
@@ -2670,24 +2678,19 @@ async fn plan_reasoning_scope_popup_all_modes_persists_global_and_plan_override(
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High))
AppEvent::ApplyOrQueueModelSelection {
model,
effort: Some(ReasoningEffortConfig::High),
scope: ModelSelectionScope::AllModes,
} if model == "gpt-5.1-codex-max"
)),
"expected plan override to be updated; events: {events:?}"
"expected all-modes model selection event; events: {events:?}"
);
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::PersistPlanModeReasoningEffort(Some(ReasoningEffortConfig::High))
)),
"expected updated plan override to be persisted; events: {events:?}"
);
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::PersistModelSelection { model, effort: Some(ReasoningEffortConfig::High) }
if model == "gpt-5.1-codex-max"
)),
"expected global model reasoning selection persistence; events: {events:?}"
events
.iter()
.all(|event| !matches!(event, AppEvent::PersistPlanModeReasoningEffort(_))),
"did not expect persistence events before app handling; events: {events:?}"
);
}
@@ -2877,9 +2880,13 @@ async fn plan_reasoning_scope_popup_plan_only_does_not_update_all_modes_reasonin
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High))
AppEvent::ApplyOrQueueModelSelection {
model,
effort: Some(ReasoningEffortConfig::High),
scope: ModelSelectionScope::PlanOnly,
} if model == "gpt-5.1-codex-max"
)),
"expected plan-only reasoning update; events: {events:?}"
"expected plan-only model selection event; events: {events:?}"
);
assert!(
events
@@ -6322,7 +6329,7 @@ async fn custom_prompt_submit_sends_review_op() {
// Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt
let evt = rx.try_recv().expect("expected one app event");
match evt {
AppEvent::CodexOp(Op::Review { review_request }) => {
AppEvent::ApplyOrQueueReview { review_request } => {
assert_eq!(
review_request,
ReviewRequest {
@@ -8123,13 +8130,15 @@ async fn user_shell_command_renders_output_not_exploring() {
}
#[tokio::test]
async fn queued_slash_command_while_task_running_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
async fn model_slash_command_while_task_running_popup_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.dispatch_command(SlashCommand::Model);
assert_eq!(chat.queued_user_message_texts(), vec!["/model".to_string()]);
assert!(chat.queued_user_messages.is_empty());
assert!(chat.has_active_view(), "expected /model popup to open");
assert!(drain_insert_history(&mut rx).is_empty());
let width: u16 = 80;
@@ -8142,7 +8151,66 @@ async fn queued_slash_command_while_task_running_snapshot() {
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
assert_snapshot!(term.backend().vt100().screen().contents());
assert_snapshot!(
"model_slash_command_while_task_running_popup",
term.backend().vt100().screen().contents()
);
}
#[tokio::test]
async fn model_selection_queues_selected_action_while_task_running() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await;
chat.on_task_started();
chat.apply_or_queue_model_selection(
"gpt-5.1-codex-max".to_string(),
Some(ReasoningEffortConfig::High),
ModelSelectionScope::Global,
);
assert_eq!(
chat.queued_user_message_texts(),
vec!["/model gpt-5.1-codex-max high".to_string()]
);
}
#[tokio::test]
async fn esc_interrupts_running_task_even_with_model_popup_active() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.dispatch_command(SlashCommand::Model);
assert!(chat.has_active_view(), "expected /model popup to open");
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
next_interrupt_op(&mut op_rx);
assert!(
chat.has_active_view(),
"expected popup to remain open during interrupt"
);
}
#[tokio::test]
async fn interrupt_with_popup_active_restores_queued_drafts_into_composer() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.queue_user_message("queued draft".into());
chat.dispatch_command(SlashCommand::Model);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
next_interrupt_op(&mut op_rx);
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
assert!(
chat.has_active_view(),
"expected popup to remain open after interrupt"
);
assert_eq!(chat.bottom_pane.composer_text(), "queued draft");
assert!(chat.queued_user_messages.is_empty());
let _ = drain_insert_history(&mut rx);
}
#[tokio::test]
@@ -11106,23 +11174,15 @@ async fn enter_queues_user_messages_while_review_is_running() {
}
#[tokio::test]
async fn review_slash_command_queues_while_task_running_and_opens_popup_after_turn_complete() {
async fn review_slash_command_opens_popup_while_task_running() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.on_task_started();
chat.dispatch_command(SlashCommand::Review);
assert_eq!(
chat.queued_user_message_texts(),
vec!["/review".to_string()]
);
assert!(!chat.has_active_view());
assert!(drain_insert_history(&mut rx).is_empty());
chat.on_task_complete(None, false);
assert!(chat.queued_user_messages.is_empty());
assert!(chat.has_active_view(), "expected /review popup to open");
assert!(drain_insert_history(&mut rx).is_empty());
}
#[tokio::test]