Simplify queued slash command replay

Unify queued slash commands as serialized drafts, route popup actions through the same replay path, and stop replay after commands that submit a turn.

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 9ab49d2c37
commit 35e8aa9ce0
10 changed files with 877 additions and 595 deletions
+2 -10
View File
@@ -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,
+4 -21
View File
@@ -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<ReasoningEffort>,
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<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,
@@ -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)
//!
+14 -18
View File
@@ -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::<AppEvent>();
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::<AppEvent>();
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");
}
File diff suppressed because it is too large Load Diff
@@ -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 <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.
@@ -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
+344 -114
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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;
+3 -2
View File
@@ -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
+2 -2
View File
@@ -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