queue slash commands in tui

Allow slash commands entered during a running turn to be queued and replayed after the turn completes, including /review and inline slash-command variants tested in codex-tui.

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 029aab5563
commit 9478b34e55
6 changed files with 518 additions and 195 deletions
+15 -42
View File
@@ -572,6 +572,7 @@ impl ChatComposer {
self.sync_popups();
}
#[cfg(test)]
pub(crate) fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
let elements = self.current_mention_elements();
let mut ordered = Vec::new();
@@ -2525,9 +2526,6 @@ impl ChatComposer {
&& let Some(cmd) =
slash_commands::find_builtin_command(name, self.builtin_command_flags())
{
if self.reject_slash_command_if_unavailable(cmd) {
return Some(InputResult::None);
}
self.textarea.set_text_clearing_elements("");
Some(InputResult::Command(cmd))
} else {
@@ -2556,9 +2554,6 @@ impl ChatComposer {
if !cmd.supports_inline_args() {
return None;
}
if self.reject_slash_command_if_unavailable(cmd) {
return Some(InputResult::None);
}
let mut args_elements =
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
@@ -2573,10 +2568,10 @@ impl ChatComposer {
/// Expand pending placeholders and extract normalized inline-command args.
///
/// Inline-arg commands are initially dispatched using the raw draft so command rejection does
/// not consume user input. Once a command is accepted, this helper performs the usual
/// submission preparation (paste expansion, element trimming) and rebases element ranges from
/// full-text offsets to command-arg offsets.
/// Inline-arg commands are initially dispatched using the raw draft so command-specific
/// handling can decide whether to consume the input. Once a command is accepted, this helper
/// performs the usual submission preparation (paste expansion, element trimming) and rebases
/// element ranges from full-text offsets to command-arg offsets.
pub(crate) fn prepare_inline_args_submission(
&mut self,
record_history: bool,
@@ -2593,20 +2588,6 @@ impl ChatComposer {
Some((trimmed_rest.to_string(), args_elements))
}
fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool {
if !self.is_task_running || cmd.available_during_task() {
return false;
}
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(message),
)));
true
}
/// Translate full-text element ranges into command-argument ranges.
///
/// `rest_offset` is the byte offset where `rest` begins in the full text.
@@ -6678,7 +6659,7 @@ mod tests {
}
#[test]
fn slash_command_disabled_while_task_running_keeps_text() {
fn slash_command_while_task_running_still_dispatches() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -6700,24 +6681,16 @@ mod tests {
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::None, result);
assert_eq!(
InputResult::CommandWithArgs(
SlashCommand::Review,
"these changes".to_string(),
Vec::new(),
),
result
);
assert_eq!("/review these changes", composer.textarea.text());
let mut found_error = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
let message = cell
.display_lines(80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(message.contains("disabled while a task is in progress"));
found_error = true;
break;
}
}
assert!(found_error, "expected error history cell to be sent");
assert!(rx.try_recv().is_err(), "no error should be emitted");
}
#[test]
+2
View File
@@ -263,6 +263,7 @@ impl BottomPane {
self.request_redraw();
}
#[cfg(test)]
pub fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
self.composer.take_mention_bindings()
}
@@ -272,6 +273,7 @@ impl BottomPane {
}
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
#[cfg(test)]
pub(crate) fn drain_pending_submission_state(&mut self) {
let _ = self.take_recent_submission_images_with_placeholders();
let _ = self.take_remote_image_urls();
+338 -77
View File
@@ -148,6 +148,7 @@ use codex_protocol::protocol::WebSearchBeginEvent;
use codex_protocol::protocol::WebSearchEndEvent;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use codex_utils_sleep_inhibitor::SleepInhibitor;
@@ -725,8 +726,8 @@ pub(crate) struct ChatWidget {
// When resuming an existing session (selected via resume picker), avoid an
// immediate redraw on SessionConfigured to prevent a gratuitous UI flicker.
suppress_session_configured_redraw: bool,
// User messages queued while a turn is in progress
queued_user_messages: VecDeque<UserMessage>,
// User messages or slash-command actions queued while a turn is in progress.
queued_user_messages: VecDeque<QueuedInput>,
// Steers already submitted to core but not yet committed into history.
//
// The bottom pane shows these above queued drafts until core records the
@@ -866,11 +867,72 @@ impl ThreadComposerState {
}
}
#[derive(Debug, Clone, PartialEq)]
enum QueuedInput {
UserMessage(UserMessage),
SlashCommand(QueuedSlashCommand),
}
impl QueuedInput {
fn preview_text(&self) -> String {
match self {
Self::UserMessage(message) => message.text.clone(),
Self::SlashCommand(command) => command.preview_text(),
}
}
fn into_restorable_user_message(self) -> UserMessage {
match self {
Self::UserMessage(message) => message,
Self::SlashCommand(command) => command.into_restorable_user_message(),
}
}
}
impl From<UserMessage> for QueuedInput {
fn from(message: UserMessage) -> Self {
Self::UserMessage(message)
}
}
#[derive(Debug, Clone, PartialEq)]
struct QueuedSlashCommand {
draft: UserMessage,
action: QueuedSlashCommandAction,
}
impl QueuedSlashCommand {
fn preview_text(&self) -> String {
self.draft.text.clone()
}
fn into_restorable_user_message(self) -> UserMessage {
self.draft
}
}
#[derive(Debug, Clone, PartialEq)]
enum QueuedSlashCommandAction {
Command(SlashCommand),
Fast(QueuedFastCommandAction),
Plan(UserMessage),
Rename(String),
Review(ReviewRequest),
SandboxReadRoot(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum QueuedFastCommandAction {
On,
Off,
Status,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ThreadInputState {
composer: Option<ThreadComposerState>,
pending_steers: VecDeque<UserMessage>,
queued_user_messages: VecDeque<UserMessage>,
queued_user_messages: VecDeque<QueuedInput>,
current_collaboration_mode: CollaborationMode,
active_collaboration_mask: Option<CollaborationModeMask>,
agent_turn_running: bool,
@@ -2233,7 +2295,11 @@ impl ChatWidget {
.drain(..)
.map(|steer| steer.user_message)
.collect();
to_merge.extend(self.queued_user_messages.drain(..));
to_merge.extend(
self.queued_user_messages
.drain(..)
.map(QueuedInput::into_restorable_user_message),
);
if !existing_message.text.is_empty()
|| !existing_message.local_images.is_empty()
|| !existing_message.remote_image_urls.is_empty()
@@ -2318,7 +2384,11 @@ impl ChatWidget {
self.bottom_pane.set_composer_pending_pastes(Vec::new());
}
self.pending_steers.clear();
self.queued_user_messages = input_state.pending_steers;
self.queued_user_messages = input_state
.pending_steers
.into_iter()
.map(QueuedInput::from)
.collect();
self.queued_user_messages
.extend(input_state.queued_user_messages);
} else {
@@ -4110,8 +4180,8 @@ impl ChatWidget {
&& self.queued_message_edit_binding.is_press(key_event)
&& !self.queued_user_messages.is_empty()
{
if let Some(user_message) = self.queued_user_messages.pop_back() {
self.restore_user_message_to_composer(user_message);
if let Some(queued_input) = self.queued_user_messages.pop_back() {
self.restore_user_message_to_composer(queued_input.into_restorable_user_message());
self.refresh_pending_input_preview();
self.request_redraw();
}
@@ -4284,14 +4354,11 @@ impl ChatWidget {
}
fn dispatch_command(&mut self, cmd: SlashCommand) {
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
self.bottom_pane.drain_pending_submission_state();
self.request_redraw();
if self.bottom_pane.is_task_running() && !cmd.available_during_task() {
self.queue_slash_command(QueuedSlashCommand {
draft: format!("/{}", cmd.command()).into(),
action: QueuedSlashCommandAction::Command(cmd),
});
return;
}
match cmd {
@@ -4622,27 +4689,49 @@ impl ChatWidget {
self.dispatch_command(cmd);
return;
}
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
return;
}
let trimmed = args.trim();
let should_queue = self.bottom_pane.is_task_running() && !cmd.available_during_task();
match cmd {
SlashCommand::Fast => {
if trimmed.is_empty() {
self.dispatch_command(cmd);
if should_queue {
self.queue_current_inline_bare_slash_command(cmd);
} else {
self.dispatch_command(cmd);
}
return;
}
match trimmed.to_ascii_lowercase().as_str() {
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
"off" => self.set_service_tier_selection(None),
"status" => {
let action = match trimmed.to_ascii_lowercase().as_str() {
"on" => QueuedFastCommandAction::On,
"off" => QueuedFastCommandAction::Off,
"status" => QueuedFastCommandAction::Status,
_ => {
self.add_error_message("Usage: /fast [on|off|status]".to_string());
return;
}
};
if should_queue {
let Some((prepared_args, prepared_elements)) =
self.bottom_pane.prepare_inline_args_submission(false)
else {
return;
};
let args_message = self
.take_prepared_submission_user_message(prepared_args, prepared_elements);
let draft = Self::queued_inline_slash_command_draft(cmd, args_message);
self.queue_slash_command(QueuedSlashCommand {
draft,
action: QueuedSlashCommandAction::Fast(action),
});
return;
}
match action {
QueuedFastCommandAction::On => {
self.set_service_tier_selection(Some(ServiceTier::Fast))
}
QueuedFastCommandAction::Off => self.set_service_tier_selection(None),
QueuedFastCommandAction::Status => {
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast))
{
"on"
@@ -4651,89 +4740,249 @@ impl ChatWidget {
};
self.add_info_message(format!("Fast mode is {status}."), None);
}
_ => {
self.add_error_message("Usage: /fast [on|off|status]".to_string());
}
}
}
SlashCommand::Rename if !trimmed.is_empty() => {
self.session_telemetry
.counter("codex.thread.rename", 1, &[]);
let Some((prepared_args, _prepared_elements)) =
let Some((prepared_args, prepared_elements)) =
self.bottom_pane.prepare_inline_args_submission(false)
else {
return;
};
let args_message = self.take_prepared_submission_user_message(
prepared_args.clone(),
prepared_elements,
);
let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else {
self.add_error_message("Thread name cannot be empty.".to_string());
return;
};
if should_queue {
let draft = Self::queued_inline_slash_command_draft(cmd, args_message);
self.queue_slash_command(QueuedSlashCommand {
draft,
action: QueuedSlashCommandAction::Rename(name),
});
return;
}
let cell = Self::rename_confirmation_cell(&name, self.thread_id);
self.add_boxed_history(Box::new(cell));
self.request_redraw();
self.app_event_tx
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::Plan if !trimmed.is_empty() => {
self.dispatch_command(cmd);
if self.active_mode_kind() != ModeKind::Plan {
return;
}
let Some((prepared_args, prepared_elements)) =
self.bottom_pane.prepare_inline_args_submission(true)
else {
return;
};
let local_images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
let user_message = UserMessage {
text: prepared_args,
local_images,
remote_image_urls,
text_elements: prepared_elements,
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
};
if self.is_session_configured() {
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
self.queue_user_message(user_message);
let user_message =
self.take_prepared_submission_user_message(prepared_args, prepared_elements);
if should_queue {
let draft = Self::queued_inline_slash_command_draft(cmd, user_message.clone());
self.queue_slash_command(QueuedSlashCommand {
draft,
action: QueuedSlashCommandAction::Plan(user_message),
});
return;
}
self.dispatch_command(cmd);
if self.active_mode_kind() != ModeKind::Plan {
return;
}
self.submit_plan_user_message(user_message);
}
SlashCommand::Review if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) =
let Some((prepared_args, prepared_elements)) =
self.bottom_pane.prepare_inline_args_submission(false)
else {
return;
};
self.submit_op(Op::Review {
review_request: ReviewRequest {
target: ReviewTarget::Custom {
instructions: prepared_args,
},
user_facing_hint: None,
let args_message = self.take_prepared_submission_user_message(
prepared_args.clone(),
prepared_elements,
);
let review_request = ReviewRequest {
target: ReviewTarget::Custom {
instructions: prepared_args,
},
});
self.bottom_pane.drain_pending_submission_state();
user_facing_hint: None,
};
if should_queue {
let draft = Self::queued_inline_slash_command_draft(cmd, args_message);
self.queue_slash_command(QueuedSlashCommand {
draft,
action: QueuedSlashCommandAction::Review(review_request),
});
return;
}
self.submit_op(Op::Review { review_request });
}
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) =
let Some((prepared_args, prepared_elements)) =
self.bottom_pane.prepare_inline_args_submission(false)
else {
return;
};
let args_message = self.take_prepared_submission_user_message(
prepared_args.clone(),
prepared_elements,
);
if should_queue {
let draft = Self::queued_inline_slash_command_draft(cmd, args_message);
self.queue_slash_command(QueuedSlashCommand {
draft,
action: QueuedSlashCommandAction::SandboxReadRoot(prepared_args),
});
return;
}
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot {
path: prepared_args,
});
self.bottom_pane.drain_pending_submission_state();
}
_ => self.dispatch_command(cmd),
_ => {
if should_queue && trimmed.is_empty() {
self.queue_current_inline_bare_slash_command(cmd);
} else {
self.dispatch_command(cmd);
}
}
}
}
fn take_prepared_submission_user_message(
&mut self,
text: String,
text_elements: Vec<TextElement>,
) -> UserMessage {
UserMessage {
text,
local_images: self
.bottom_pane
.take_recent_submission_images_with_placeholders(),
remote_image_urls: self.take_remote_image_urls(),
text_elements,
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
}
}
fn queued_inline_slash_command_draft(
cmd: SlashCommand,
args_message: UserMessage,
) -> UserMessage {
let prefix = format!("/{}", cmd.command());
let UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings,
} = args_message;
if text.is_empty() {
return UserMessage {
text: prefix,
local_images,
remote_image_urls,
text_elements,
mention_bindings,
};
}
let mut draft_text = format!("{prefix} ");
let offset = draft_text.len();
draft_text.push_str(&text);
let text_elements = text_elements
.into_iter()
.map(|element| {
let start = element.byte_range.start + offset;
let end = element.byte_range.end + offset;
element.map_range(|_| ByteRange { start, end })
})
.collect();
UserMessage {
text: draft_text,
local_images,
remote_image_urls,
text_elements,
mention_bindings,
}
}
fn queue_current_inline_bare_slash_command(&mut self, cmd: SlashCommand) {
let Some((prepared_args, prepared_elements)) =
self.bottom_pane.prepare_inline_args_submission(false)
else {
return;
};
let args_message =
self.take_prepared_submission_user_message(prepared_args, prepared_elements);
self.queue_slash_command(QueuedSlashCommand {
draft: Self::queued_inline_slash_command_draft(cmd, args_message),
action: QueuedSlashCommandAction::Command(cmd),
});
}
fn queue_slash_command(&mut self, command: QueuedSlashCommand) {
self.queued_user_messages
.push_back(QueuedInput::SlashCommand(command));
self.refresh_pending_input_preview();
}
fn submit_plan_user_message(&mut self, user_message: UserMessage) {
if self.is_session_configured() {
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
self.queue_user_message(user_message);
}
}
fn execute_queued_slash_command(&mut self, command: QueuedSlashCommand) {
match command.action {
QueuedSlashCommandAction::Command(cmd) => self.dispatch_command(cmd),
QueuedSlashCommandAction::Fast(action) => match action {
QueuedFastCommandAction::On => {
self.set_service_tier_selection(Some(ServiceTier::Fast))
}
QueuedFastCommandAction::Off => self.set_service_tier_selection(None),
QueuedFastCommandAction::Status => {
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
"on"
} else {
"off"
};
self.add_info_message(format!("Fast mode is {status}."), None);
}
},
QueuedSlashCommandAction::Plan(user_message) => {
self.dispatch_command(SlashCommand::Plan);
if self.active_mode_kind() == ModeKind::Plan {
self.submit_plan_user_message(user_message);
}
}
QueuedSlashCommandAction::Rename(name) => {
self.session_telemetry
.counter("codex.thread.rename", 1, &[]);
let cell = Self::rename_confirmation_cell(&name, self.thread_id);
self.add_boxed_history(Box::new(cell));
self.request_redraw();
self.app_event_tx
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
}
QueuedSlashCommandAction::Review(review_request) => {
self.submit_op(Op::Review { review_request });
}
QueuedSlashCommandAction::SandboxReadRoot(path) => {
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path });
}
}
}
@@ -4824,7 +5073,7 @@ impl ChatWidget {
|| self.bottom_pane.is_task_running()
|| self.is_review_mode
{
self.queued_user_messages.push_back(user_message);
self.queued_user_messages.push_back(user_message.into());
self.refresh_pending_input_preview();
} else {
self.submit_user_message(user_message);
@@ -4834,12 +5083,12 @@ impl ChatWidget {
fn submit_user_message(&mut self, user_message: UserMessage) {
if !self.is_session_configured() {
tracing::warn!("cannot submit user message before session is configured; queueing");
self.queued_user_messages.push_front(user_message);
self.queued_user_messages.push_front(user_message.into());
self.refresh_pending_input_preview();
return;
}
if self.is_review_mode {
self.queued_user_messages.push_back(user_message);
self.queued_user_messages.push_back(user_message.into());
self.refresh_pending_input_preview();
return;
}
@@ -5609,7 +5858,8 @@ impl ChatWidget {
}
}
// If idle and there are queued inputs, submit exactly one to start the next turn.
// If idle and there are queued inputs, dispatch queued work in order until a turn starts or
// a popup takes focus.
pub(crate) fn maybe_send_next_queued_input(&mut self) {
if self.suppress_queue_autosend {
return;
@@ -5617,10 +5867,21 @@ impl ChatWidget {
if self.bottom_pane.is_task_running() {
return;
}
if let Some(user_message) = self.queued_user_messages.pop_front() {
self.submit_user_message(user_message);
while !self.bottom_pane.is_task_running() {
let Some(queued_input) = self.queued_user_messages.pop_front() else {
break;
};
match queued_input {
QueuedInput::UserMessage(user_message) => {
self.submit_user_message(user_message);
break;
}
QueuedInput::SlashCommand(command) => self.execute_queued_slash_command(command),
}
if !self.bottom_pane.no_modal_or_popup_active() {
break;
}
}
// Update the list to reflect the remaining queued messages (if any).
self.refresh_pending_input_preview();
}
@@ -5629,7 +5890,7 @@ impl ChatWidget {
let queued_messages: Vec<String> = self
.queued_user_messages
.iter()
.map(|m| m.text.clone())
.map(QueuedInput::preview_text)
.collect();
let pending_steers: Vec<String> = self
.pending_steers
@@ -8771,7 +9032,7 @@ impl ChatWidget {
pub(crate) fn queued_user_message_texts(&self) -> Vec<String> {
self.queued_user_messages
.iter()
.map(|message| message.text.clone())
.map(QueuedInput::preview_text)
.collect()
}
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob
---
■ '/model' is disabled while a task is in progress.
@@ -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
+141 -71
View File
@@ -1235,26 +1235,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
@@ -1318,13 +1324,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 {
@@ -3696,9 +3705,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.
@@ -3712,7 +3721,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().text,
chat.queued_user_messages.front().unwrap().preview_text(),
"first queued"
);
}
@@ -3730,9 +3739,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()));
.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.
@@ -3746,7 +3755,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().text,
chat.queued_user_messages.front().unwrap().preview_text(),
"first queued"
);
}
@@ -3815,7 +3824,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.text, "repeat me");
assert_eq!(message.preview_text(), "repeat me");
}
}
@@ -3838,7 +3847,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().text,
chat.queued_user_messages.front().unwrap().preview_text(),
"queued submission"
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
@@ -4015,7 +4024,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().text,
chat.queued_user_messages.front().unwrap().preview_text(),
"queued submission"
);
assert!(chat.pending_steers.is_empty());
@@ -4508,7 +4517,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());
@@ -4533,7 +4542,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().text,
chat.queued_user_messages.front().unwrap().preview_text(),
"queued draft"
);
@@ -4630,7 +4639,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) {
@@ -4672,7 +4681,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) {
@@ -6054,24 +6063,19 @@ async fn slash_clear_requests_ui_clear_when_idle() {
}
#[tokio::test]
async fn slash_clear_is_disabled_while_task_running() {
async fn slash_clear_queues_while_task_running() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.set_task_running(true);
chat.on_task_started();
chat.dispatch_command(SlashCommand::Clear);
let event = rx.try_recv().expect("expected disabled command error");
match event {
AppEvent::InsertHistoryCell(cell) => {
let rendered = lines_to_single_string(&cell.display_lines(80));
assert!(
rendered.contains("'/clear' is disabled while a task is in progress."),
"expected /clear task-running error, got {rendered:?}"
);
}
other => panic!("expected InsertHistoryCell error, got {other:?}"),
}
assert!(rx.try_recv().is_err(), "expected no follow-up events");
assert_eq!(chat.queued_user_message_texts(), vec!["/clear".to_string()]);
assert!(rx.try_recv().is_err(), "expected no immediate app events");
chat.on_task_complete(None, false);
assert!(chat.queued_user_messages.is_empty());
assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi));
}
#[tokio::test]
@@ -8113,22 +8117,26 @@ async fn user_shell_command_renders_output_not_exploring() {
}
#[tokio::test]
async fn disabled_slash_command_while_task_running_snapshot() {
// Build a chat widget and simulate an active task
async fn queued_slash_command_while_task_running_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.set_task_running(true);
chat.on_task_started();
// Dispatch a command that is unavailable while a task runs (e.g., /model)
chat.dispatch_command(SlashCommand::Model);
// Drain history and snapshot the rendered error line(s)
let cells = drain_insert_history(&mut rx);
assert!(
!cells.is_empty(),
"expected an error message history cell to be emitted",
);
let blob = lines_to_single_string(cells.last().unwrap());
assert_snapshot!(blob);
assert_eq!(chat.queued_user_message_texts(), vec!["/model".to_string()]);
assert!(drain_insert_history(&mut rx).is_empty());
let width: u16 = 80;
let height: u16 = 18;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let desired_height = chat.desired_height(width).min(height);
term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height));
term.draw(|f| {
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
assert_snapshot!(term.backend().vt100().screen().contents());
}
#[tokio::test]
@@ -8999,9 +9007,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).
@@ -9039,9 +9047,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 {
@@ -11010,7 +11018,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().text,
chat.queued_user_messages.front().unwrap().preview_text(),
"Queued while /review is running."
);
assert!(chat.pending_steers.is_empty());
@@ -11018,6 +11026,68 @@ async fn enter_queues_user_messages_while_review_is_running() {
assert!(drain_insert_history(&mut rx).is_empty());
}
#[tokio::test]
async fn review_slash_command_queues_while_task_running_and_opens_popup_after_turn_complete() {
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");
}
#[tokio::test]
async fn review_slash_command_with_args_queues_while_task_running_and_submits_after_turn_complete()
{
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.on_task_started();
chat.bottom_pane.set_composer_text(
"/review audit dependency changes".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
chat.queued_user_message_texts(),
vec!["/review audit dependency changes".to_string()]
);
assert_no_submit_op(&mut op_rx);
chat.on_task_complete(None, false);
loop {
match op_rx.try_recv() {
Ok(Op::Review { review_request }) => {
assert_eq!(
review_request,
ReviewRequest {
target: ReviewTarget::Custom {
instructions: "audit dependency changes".to_string(),
},
user_facing_hint: None,
}
);
break;
}
Ok(_) => continue,
Err(TryRecvError::Empty) => panic!("expected queued /review op"),
Err(TryRecvError::Disconnected) => panic!("expected queued /review op"),
}
}
}
#[tokio::test]
async fn review_queues_user_messages_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;