Queue slash and shell prompts in the TUI (#18542)

## Why

Users have asked to queue follow-up slash commands while a task is
running, including in #14081, #14588, #14286, and #13779. The previous
TUI behavior validated slash commands immediately, so commands that are
only meaningful once the current turn is idle could not be queued
consistently.

The queue should preserve what the user typed and defer command parsing
until the item is actually dispatched. This also gives `/fast`, `/review
...`, `/rename ...`, `/model`, `/permissions`, and similar slash
workflows the same FIFO behavior as plain queued prompts.

## What Changed

- Added a queued-input action enum so queued items can be dispatched as
plain prompts, slash commands, or user shell commands.
- Changed `Tab` queueing to accept slash-led prompts without validating
them up front, then parse and dispatch them when dequeued.
- Added `!` shell-command queueing for `Tab` while a task is running,
while preserving existing `Enter` behavior for immediate shell
execution.
- Moved queued slash dispatch through shared slash-command parsing so
inline commands, unavailable commands, unknown commands, and local
config commands report at dequeue time.
- Continued queue draining after local-only actions and after slash menu
cancellation or selection when no task is running.
- Preserved slash-popup completion behavior so `/mo<Tab>` completes to
`/model ` instead of queueing the prefix.
- Updated pending-input preview snapshots to show queued follow-up
inputs.

## Verification

I did a bunch of manual validation (and found and fixed a few bugs along
the way).
This commit is contained in:
Eric Traut
2026-04-19 10:52:16 -07:00
committed by GitHub
parent 116317021d
commit 917a85b0d6
24 changed files with 1428 additions and 206 deletions
+257 -67
View File
@@ -6,6 +6,23 @@
//! slash-command recall follows the same submitted-input rule as ordinary text.
use super::*;
use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::bottom_pane::slash_commands;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlashCommandDispatchSource {
Live,
Queued,
}
struct PreparedSlashCommandArgs {
args: String,
text_elements: Vec<TextElement>,
local_images: Vec<LocalImageAttachment>,
remote_image_urls: Vec<String>,
mention_bindings: Vec<MentionBinding>,
source: SlashCommandDispatchSource,
}
impl ChatWidget {
/// Dispatch a bare slash command and record its staged local-history entry.
@@ -370,7 +387,7 @@ impl ChatWidget {
&mut self,
cmd: SlashCommand,
args: String,
_text_elements: Vec<TextElement>,
text_elements: Vec<TextElement>,
) {
if !cmd.supports_inline_args() {
self.dispatch_command(cmd);
@@ -386,25 +403,60 @@ impl ChatWidget {
return;
}
let trimmed = args.trim();
if trimmed.is_empty() {
self.dispatch_command(cmd);
return;
}
let Some((prepared_args, prepared_elements)) =
self.prepare_live_inline_args(args, text_elements)
else {
return;
};
self.dispatch_prepared_command_with_args(
cmd,
PreparedSlashCommandArgs {
args: prepared_args,
text_elements: prepared_elements,
local_images: Vec::new(),
remote_image_urls: Vec::new(),
mention_bindings: Vec::new(),
source: SlashCommandDispatchSource::Live,
},
);
}
fn prepare_live_inline_args(
&mut self,
args: String,
text_elements: Vec<TextElement>,
) -> Option<(String, Vec<TextElement>)> {
if self.bottom_pane.composer_text().is_empty() {
Some((args, text_elements))
} else {
self.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
}
}
fn dispatch_prepared_command_with_args(
&mut self,
cmd: SlashCommand,
prepared: PreparedSlashCommandArgs,
) {
let PreparedSlashCommandArgs {
args,
text_elements,
mut local_images,
mut remote_image_urls,
mut mention_bindings,
source,
} = prepared;
let trimmed = args.trim();
match cmd {
SlashCommand::Fast => {
if trimmed.is_empty() {
self.dispatch_command(cmd);
return;
}
let prepared_args = if self.bottom_pane.composer_text().is_empty() {
args
} else {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
prepared_args
};
match prepared_args.trim().to_ascii_lowercase().as_str() {
match trimmed.to_ascii_lowercase().as_str() {
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
"off" => self.set_service_tier_selection(/*service_tier*/ None),
"status" => {
@@ -427,40 +479,29 @@ impl ChatWidget {
SlashCommand::Rename if !trimmed.is_empty() => {
self.session_telemetry
.counter("codex.thread.rename", /*inc*/ 1, &[]);
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
let Some(name) = crate::legacy_core::util::normalize_thread_name(&prepared_args)
else {
let Some(name) = crate::legacy_core::util::normalize_thread_name(&args) else {
self.add_error_message("Thread name cannot be empty.".to_string());
return;
};
self.app_event_tx.set_thread_name(name);
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::Plan if !trimmed.is_empty() => {
if !self.apply_plan_slash_command() {
return;
}
let Some((prepared_args, prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
let local_images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
if source == SlashCommandDispatchSource::Live {
local_images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
remote_image_urls = self.take_remote_image_urls();
mention_bindings = self.bottom_pane.take_recent_submission_mention_bindings();
}
let user_message = UserMessage {
text: prepared_args,
text: args,
local_images,
remote_image_urls,
text_elements: prepared_elements,
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
text_elements,
mention_bindings,
};
if self.is_session_configured() {
self.reasoning_buffer.clear();
@@ -472,45 +513,194 @@ impl ChatWidget {
}
}
SlashCommand::Review if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.submit_op(AppCommand::review(ReviewRequest {
target: ReviewTarget::Custom {
instructions: prepared_args,
},
target: ReviewTarget::Custom { instructions: args },
user_facing_hint: None,
}));
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::Resume if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.app_event_tx
.send(AppEvent::ResumeSessionByIdOrName(prepared_args));
self.bottom_pane.drain_pending_submission_state();
.send(AppEvent::ResumeSessionByIdOrName(args));
}
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot {
path: prepared_args,
});
self.bottom_pane.drain_pending_submission_state();
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
}
_ => self.dispatch_command(cmd),
}
if source == SlashCommandDispatchSource::Live {
self.bottom_pane.drain_pending_submission_state();
}
}
pub(super) fn submit_queued_slash_prompt(&mut self, user_message: UserMessage) -> QueueDrain {
let UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings,
} = user_message;
let Some((name, rest, rest_offset)) = parse_slash_name(&text) else {
self.submit_user_message(UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings,
});
return QueueDrain::Stop;
};
if name.contains('/') {
self.submit_user_message(UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings,
});
return QueueDrain::Stop;
}
let Some(cmd) = slash_commands::find_builtin_command(name, self.builtin_command_flags())
else {
self.add_info_message(
format!(
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
),
/*hint*/ None,
);
return QueueDrain::Continue;
};
if rest.is_empty() {
self.dispatch_command(cmd);
return self.queued_command_drain_result(cmd);
}
if !cmd.supports_inline_args() {
self.submit_user_message(UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings,
});
return QueueDrain::Stop;
}
let args_elements = Self::slash_command_args_elements(rest, rest_offset, &text_elements);
self.dispatch_prepared_command_with_args(
cmd,
PreparedSlashCommandArgs {
args: rest.trim().to_string(),
text_elements: args_elements,
local_images,
remote_image_urls,
mention_bindings,
source: SlashCommandDispatchSource::Queued,
},
);
self.queued_command_drain_result(cmd)
}
fn builtin_command_flags(&self) -> slash_commands::BuiltinCommandFlags {
#[cfg(target_os = "windows")]
let allow_elevate_sandbox = {
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken)
};
#[cfg(not(target_os = "windows"))]
let allow_elevate_sandbox = false;
slash_commands::BuiltinCommandFlags {
collaboration_modes_enabled: self.collaboration_modes_enabled(),
connectors_enabled: self.connectors_enabled(),
plugins_command_enabled: self.config.features.enabled(Feature::Plugins),
fast_command_enabled: self.fast_mode_enabled(),
personality_command_enabled: self.config.features.enabled(Feature::Personality),
realtime_conversation_enabled: self.realtime_conversation_enabled(),
audio_device_selection_enabled: self.realtime_audio_device_selection_enabled(),
allow_elevate_sandbox,
}
}
fn queued_command_drain_result(&self, cmd: SlashCommand) -> QueueDrain {
if self.is_user_turn_pending_or_running() || !self.bottom_pane.no_modal_or_popup_active() {
return QueueDrain::Stop;
}
match cmd {
SlashCommand::Fast
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Rollout
| SlashCommand::Copy
| SlashCommand::Diff
| SlashCommand::Rename
| SlashCommand::TestApproval => QueueDrain::Continue,
SlashCommand::Feedback
| SlashCommand::New
| SlashCommand::Clear
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Review
| SlashCommand::Model
| SlashCommand::Realtime
| SlashCommand::Settings
| SlashCommand::Personality
| SlashCommand::Plan
| SlashCommand::Collab
| SlashCommand::Agent
| SlashCommand::MultiAgents
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::ElevateSandbox
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental
| SlashCommand::Memories
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Logout
| SlashCommand::Mention
| SlashCommand::Skills
| SlashCommand::Title
| SlashCommand::Statusline
| SlashCommand::Theme => QueueDrain::Stop,
}
}
fn slash_command_args_elements(
rest: &str,
rest_offset: usize,
text_elements: &[TextElement],
) -> Vec<TextElement> {
if rest.is_empty() || text_elements.is_empty() {
return Vec::new();
}
text_elements
.iter()
.filter_map(|elem| {
if elem.byte_range.end <= rest_offset {
return None;
}
let start = elem.byte_range.start.saturating_sub(rest_offset);
let mut end = elem.byte_range.end.saturating_sub(rest_offset);
if start >= rest.len() {
return None;
}
end = end.min(rest.len());
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
})
.collect()
}
}
@@ -1,10 +1,12 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
assertion_line: 2288
expression: normalize_snapshot_paths(term.backend().vt100().screen().contents())
---
• Working (0s • esc to interrupt)
• Queued follow-up messages
• Queued follow-up inputs
↳ Hello, world! 0
↳ Hello, world! 1
↳ Hello, world! 2
+1
View File
@@ -12,6 +12,7 @@ pub(super) use crate::app_event::RealtimeAudioDeviceKind;
pub(super) use crate::app_event_sender::AppEventSender;
pub(super) use crate::bottom_pane::LocalImageAttachment;
pub(super) use crate::bottom_pane::MentionBinding;
pub(super) use crate::bottom_pane::QueuedInputAction;
pub(super) use crate::chatwidget::realtime::RealtimeConversationPhase;
pub(super) use crate::history_cell::UserHistoryCell;
pub(super) use crate::legacy_core::config::Config;
@@ -604,13 +604,16 @@ 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(),
});
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.refresh_pending_input_preview();
chat.handle_codex_event(Event {
@@ -797,6 +800,7 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
pending_steers: VecDeque::new(),
rejected_steers_queue: VecDeque::new(),
queued_user_messages: VecDeque::new(),
user_turn_pending_start: false,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
task_running: true,
@@ -826,9 +830,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()));
.push_back(UserMessage::from("first queued".to_string()).into());
chat.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()));
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
// Press Alt+Up to edit the most recent (last) queued message.
@@ -1031,9 +1035,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()));
.push_back(UserMessage::from("first queued".to_string()).into());
chat.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()));
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
// Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed).
@@ -1073,9 +1077,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()));
.push_back(UserMessage::from("first queued".to_string()).into());
chat.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()));
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
chat.handle_codex_event(Event {
+10 -1
View File
@@ -987,7 +987,7 @@ async fn user_shell_command_renders_output_not_exploring() {
}
#[tokio::test]
async fn bang_shell_command_submits_run_user_shell_command_in_app_server_tui() {
async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
@@ -1015,6 +1015,15 @@ async fn bang_shell_command_submits_run_user_shell_command_in_app_server_tui() {
});
drain_insert_history(&mut rx);
while op_rx.try_recv().is_ok() {}
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.bottom_pane
.set_composer_text("!echo hi".to_string(), Vec::new(), Vec::new());
+3 -2
View File
@@ -255,6 +255,7 @@ pub(super) async fn make_chatwidget_manual(
show_welcome_banner: true,
startup_tooltip_override: None,
queued_user_messages: VecDeque::new(),
user_turn_pending_start: false,
rejected_steers_queue: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
@@ -726,9 +727,9 @@ pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_termi
// Seed two queued messages.
chat.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()));
.push_back(UserMessage::from("first queued".to_string()).into());
chat.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()));
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
// Press Shift+Left to edit the most recent (last) queued message.
@@ -29,26 +29,32 @@ 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(),
});
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.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.refresh_pending_input_preview();
chat.bottom_pane
@@ -164,7 +170,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
});
let _ = drain_insert_history(&mut rx);
chat.queued_user_messages
.push_back(UserMessage::from("queued later"));
.push_back(UserMessage::from("queued later").into());
chat.submit_user_message(UserMessage::from("review follow-up one"));
chat.submit_user_message(UserMessage::from("review follow-up two"));
@@ -354,13 +360,14 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
let mut rejected_steers_queue = VecDeque::new();
rejected_steers_queue.push_back(UserMessage::from("already rejected"));
let mut queued_user_messages = VecDeque::new();
queued_user_messages.push_back(UserMessage::from("queued draft"));
queued_user_messages.push_back(UserMessage::from("queued draft").into());
chat.restore_thread_input_state(Some(ThreadInputState {
composer: None,
pending_steers,
rejected_steers_queue,
queued_user_messages,
user_turn_pending_start: false,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
task_running: false,
@@ -755,7 +762,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()));
.push_back(UserMessage::from("queued draft".to_string()).into());
chat.refresh_pending_input_preview();
chat.bottom_pane
.set_composer_text("still editing".to_string(), Vec::new(), Vec::new());
@@ -877,7 +884,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()));
.push_back(UserMessage::from("queued draft".to_string()).into());
chat.refresh_pending_input_preview();
match next_submit_op(&mut op_rx) {
@@ -919,7 +926,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()));
.push_back(UserMessage::from("queued draft".to_string()).into());
chat.refresh_pending_input_preview();
match next_submit_op(&mut op_rx) {
@@ -14,6 +14,13 @@ fn submit_composer_text(chat: &mut ChatWidget, text: &str) {
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
}
fn queue_composer_text_with_tab(chat: &mut ChatWidget, text: &str) {
chat.bottom_pane
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
}
fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String {
@@ -51,6 +58,539 @@ async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn queued_slash_compact_dispatches_after_active_turn() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, "/compact");
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().action,
QueuedInputAction::ParseSlash
);
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::CodexOp(Op::Compact))),
"expected queued /compact to submit compact op; events: {events:?}"
);
}
#[tokio::test]
async fn queued_slash_review_with_args_dispatches_after_active_turn() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, "/review check regressions");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
match op_rx.try_recv() {
Ok(Op::AddToHistory { .. }) => match op_rx.try_recv() {
Ok(Op::Review { review_request }) => assert_eq!(
review_request,
ReviewRequest {
target: ReviewTarget::Custom {
instructions: "check regressions".to_string(),
},
user_facing_hint: None,
}
),
other => panic!("expected queued /review to submit review op, got {other:?}"),
},
Ok(Op::Review { review_request }) => assert_eq!(
review_request,
ReviewRequest {
target: ReviewTarget::Custom {
instructions: "check regressions".to_string(),
},
user_facing_hint: None,
}
),
other => panic!("expected queued /review to submit review op, got {other:?}"),
}
}
#[tokio::test]
async fn queued_slash_review_with_args_restores_for_edit() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, "/review check regressions");
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
assert_eq!(
chat.bottom_pane.composer_text(),
"/review check regressions"
);
}
#[tokio::test]
async fn queued_bang_shell_dispatches_after_active_turn() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, "!echo hi");
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().action,
QueuedInputAction::RunShell
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
match op_rx.try_recv() {
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
other => panic!("expected queued shell command op, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_input() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, "!");
queue_composer_text_with_tab(&mut chat, "hello after help");
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
let cells = drain_insert_history(&mut rx);
let rendered = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains(USER_SHELL_COMMAND_HELP_TITLE),
"expected delayed shell help, got {rendered:?}"
);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after help".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued message after empty shell command, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, "!echo hi");
queue_composer_text_with_tab(&mut chat, "hello after shell");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
match op_rx.try_recv() {
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
other => panic!("expected queued shell command op, got {other:?}"),
}
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
assert_eq!(chat.queued_user_messages.len(), 1);
let begin = begin_exec_with_source(
&mut chat,
"user-shell-echo",
"echo hi",
ExecCommandSource::UserShell,
);
end_exec(&mut chat, begin, "hi\n", "", /*exit_code*/ 0);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after shell".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued message after shell completion, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_popup_text: &str) {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, command);
queue_composer_text_with_tab(&mut chat, "hello after menu");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
assert_eq!(chat.queued_user_messages.len(), 1);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains(expected_popup_text),
"expected {command} menu to open; popup:\n{popup}"
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after menu".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued message after cancelling {command}, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_slash_menu_cancel_drains_next_input() {
assert_cancelled_queued_menu_drains_next_input("/model", "Select Model").await;
assert_cancelled_queued_menu_drains_next_input("/permissions", "Update Model Permissions")
.await;
}
#[tokio::test]
async fn queued_slash_menu_selection_drains_next_input() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, "/permissions");
queue_composer_text_with_tab(&mut chat, "hello after selection");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Update Model Permissions"),
"expected permissions menu to open; popup:\n{popup}"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after selection".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued message after permissions selection, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_bare_rename_drains_next_input_after_name_update() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, "/rename");
queue_composer_text_with_tab(&mut chat, "hello after rename");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
assert_eq!(chat.queued_user_messages.len(), 1);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Name thread"));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_paste("Queued rename".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::SetThreadName { name }) if name == "Queued rename"
)),
"expected rename prompt to submit thread name; events: {events:?}"
);
chat.handle_codex_event(Event {
id: "rename".into(),
msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent {
thread_id,
thread_name: Some("Queued rename".to_string()),
}),
});
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after rename".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued message after /rename, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, "/rename Queued rename");
queue_composer_text_with_tab(&mut chat, "first after rename");
queue_composer_text_with_tab(&mut chat, "second after rename");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::SetThreadName { name }) if name == "Queued rename"
)),
"expected queued /rename to submit thread name; events: {events:?}"
);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "first after rename".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected first queued message after /rename, got {other:?}"),
}
assert_matches!(
op_rx.try_recv(),
Ok(Op::AddToHistory { text }) if text == "first after rename"
);
assert_eq!(
chat.queued_user_message_texts(),
vec!["second after rename"]
);
let input_state = chat.capture_thread_input_state().unwrap();
assert!(input_state.user_turn_pending_start);
chat.restore_thread_input_state(/*input_state*/ None);
assert!(!chat.user_turn_pending_start);
chat.restore_thread_input_state(Some(input_state));
assert!(chat.user_turn_pending_start);
assert_eq!(
chat.queued_user_message_texts(),
vec!["second after rename"]
);
chat.handle_codex_event(Event {
id: "rename".into(),
msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent {
thread_id,
thread_name: Some("Queued rename".to_string()),
}),
});
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
assert_eq!(
chat.queued_user_message_texts(),
vec!["second after rename"]
);
chat.handle_codex_event(Event {
id: "turn-2-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-2".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "turn-2-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-2", Some("done"))),
});
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "second after rename".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected second queued message after turn complete, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_unknown_slash_reports_error_when_dequeued() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, "/does-not-exist");
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
let cells = drain_insert_history(&mut rx);
let rendered = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("Unrecognized command '/does-not-exist'"),
"expected delayed slash error, got {rendered:?}"
);
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn ctrl_d_quits_without_prompt() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -825,6 +1365,58 @@ async fn user_turn_carries_service_tier_after_fast_toggle() {
}
}
#[tokio::test]
async fn queued_fast_slash_applies_before_next_queued_message() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.thread_id = Some(ThreadId::new());
set_chatgpt_auth(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
queue_composer_text_with_tab(&mut chat, "/fast on");
queue_composer_text_with_tab(&mut chat, "hello after fast");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::OverrideTurnContext {
service_tier: Some(Some(ServiceTier::Fast)),
..
})
)),
"expected queued /fast to update service tier before next turn; events: {events:?}"
);
match next_submit_op(&mut op_rx) {
Op::UserTurn {
items,
service_tier: Some(Some(ServiceTier::Fast)),
..
} => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after fast".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued message to submit with fast tier, got {other:?}"),
}
}
#[tokio::test]
async fn user_turn_clears_service_tier_after_fast_is_turned_off() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;