diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c3b73659b7..0e9384f155 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -572,6 +572,7 @@ impl ChatComposer { self.sync_popups(); } + #[cfg(test)] pub(crate) fn take_mention_bindings(&mut self) -> Vec { 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::>() - .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] diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d15b03021e..782ff4de3c 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -263,6 +263,7 @@ impl BottomPane { self.request_redraw(); } + #[cfg(test)] pub fn take_mention_bindings(&mut self) -> Vec { 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(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c692f26590..b03befe593 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, + // User messages or slash-command actions queued while a turn is in progress. + queued_user_messages: VecDeque, // 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 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, pending_steers: VecDeque, - queued_user_messages: VecDeque, + queued_user_messages: VecDeque, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, 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, + ) -> 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 = self .queued_user_messages .iter() - .map(|m| m.text.clone()) + .map(QueuedInput::preview_text) .collect(); let pending_steers: Vec = self .pending_steers @@ -8771,7 +9032,7 @@ impl ChatWidget { pub(crate) fn queued_user_message_texts(&self) -> Vec { self.queued_user_messages .iter() - .map(|message| message.text.clone()) + .map(QueuedInput::preview_text) .collect() } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap deleted file mode 100644 index e8f08a437a..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob ---- -■ '/model' is disabled while a task is in progress. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__queued_slash_command_while_task_running_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__queued_slash_command_while_task_running_snapshot.snap new file mode 100644 index 0000000000..7c6587b5dd --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__queued_slash_command_while_task_running_snapshot.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ffc571288a..98b8a3840d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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;