diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5b39a0f01d9f..acecc36b9a67 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -239,6 +239,7 @@ pub enum InputResult { Queued { text: String, text_elements: Vec, + action: QueuedInputAction, }, /// A bare slash command parsed by the composer. /// @@ -254,6 +255,13 @@ pub enum InputResult { None, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QueuedInputAction { + Plain, + ParseSlash, + RunShell, +} + #[derive(Clone, Debug, PartialEq)] struct AttachedImage { placeholder: String, @@ -398,6 +406,12 @@ enum ActivePopup { Skill(SkillPopup), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SlashValidation { + Immediate, + Deferred, +} + const FOOTER_SPACING_HEIGHT: u16 = 0; impl ChatComposer { @@ -1389,14 +1403,48 @@ impl ChatComposer { // before applying completion. let first_line = self.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); - if let Some(sel) = popup.selected_item() { + let selected_cmd = popup.selected_item().map(|sel| { let CommandItem::Builtin(cmd) = sel; + cmd + }); + if let Some(cmd) = selected_cmd { if cmd == SlashCommand::Skills { self.stage_selected_slash_command_history(cmd); self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } + let selected_command_text = format!("/{}", cmd.command()); + let starts_with_cmd = + first_line.trim_start().starts_with(&selected_command_text); + if !starts_with_cmd { + self.textarea + .set_text_clearing_elements(&format!("/{} ", cmd.command())); + if !self.textarea.text().is_empty() { + self.textarea.set_cursor(self.textarea.text().len()); + } + return (InputResult::None, true); + } + } + if self.is_task_running { + return self.handle_submission(/*should_queue*/ true); + } + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Char('/'), + modifiers: KeyModifiers::NONE, + .. + } => { + // Treat "/" as accepting the highlighted command as text completion + // while the slash-command popup is active. + let first_line = self.textarea.text().lines().next().unwrap_or(""); + popup.on_composer_text_change(first_line.to_string()); + let selected_cmd = popup.selected_item().map(|sel| { + let CommandItem::Builtin(cmd) = sel; + cmd + }); + if let Some(cmd) = selected_cmd { let starts_with_cmd = first_line .trim_start() .starts_with(&format!("/{}", cmd.command())); @@ -2169,6 +2217,14 @@ impl ChatComposer { fn prepare_submission_text( &mut self, record_history: bool, + ) -> Option<(String, Vec)> { + self.prepare_submission_text_with_options(record_history, SlashValidation::Immediate) + } + + fn prepare_submission_text_with_options( + &mut self, + record_history: bool, + slash_validation: SlashValidation, ) -> Option<(String, Vec)> { let mut text = self.textarea.text().to_string(); let original_input = text.clone(); @@ -2199,7 +2255,8 @@ impl ChatComposer { text = text.trim().to_string(); text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); - if self.slash_commands_enabled() + if slash_validation == SlashValidation::Immediate + && self.slash_commands_enabled() && let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); @@ -2282,6 +2339,31 @@ impl ChatComposer { should_queue: bool, now: Instant, ) -> (InputResult, bool) { + if should_queue { + let raw_text = self.textarea.text(); + let defer_slash_validation = + self.should_parse_as_slash_on_dequeue_from_raw_text(raw_text); + if let Some((text, text_elements)) = self.prepare_submission_text_with_options( + /*record_history*/ true, + if defer_slash_validation { + SlashValidation::Deferred + } else { + SlashValidation::Immediate + }, + ) { + let action = self.queued_input_action(&text, defer_slash_validation); + return ( + InputResult::Queued { + text, + text_elements, + action, + }, + true, + ); + } + return (InputResult::None, true); + } + // If the first line is a bare built-in slash command (no args), // dispatch it even when the slash popup isn't visible. This preserves // the workflow: type a prefix ("/di"), press Tab to complete to @@ -2346,6 +2428,7 @@ impl ChatComposer { InputResult::Queued { text, text_elements, + action: QueuedInputAction::Plain, }, true, ) @@ -2477,6 +2560,24 @@ impl ChatComposer { true } + fn should_parse_as_slash_on_dequeue_from_raw_text(&self, text: &str) -> bool { + self.slash_commands_enabled() && !text.starts_with(' ') && text.trim().starts_with('/') + } + + fn queued_input_action( + &self, + prepared_text: &str, + defer_slash_validation: bool, + ) -> QueuedInputAction { + if defer_slash_validation && prepared_text.starts_with('/') { + QueuedInputAction::ParseSlash + } else if prepared_text.starts_with('!') { + QueuedInputAction::RunShell + } else { + QueuedInputAction::Plain + } + } + /// Stage the current slash-command text for later local recall. /// /// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget` @@ -2690,7 +2791,9 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, .. - } if !self.is_bang_shell_command() => self.handle_submission(self.is_task_running), + } if self.is_task_running || !self.is_bang_shell_command() => { + self.handle_submission(self.is_task_running) + } KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, @@ -6543,6 +6646,131 @@ mod tests { assert!(found_error, "expected error history cell to be sent"); } + #[test] + fn tab_queues_slash_led_prompts_while_task_running_without_validation() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + fn assert_queued_slash(input: &str) { + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_task_running(/*running*/ true); + composer.textarea.set_text_clearing_elements(input); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + match result { + InputResult::Queued { + text, + text_elements, + action, + } => { + assert_eq!(text, input); + assert!(text_elements.is_empty()); + assert_eq!(action, QueuedInputAction::ParseSlash); + } + other => panic!("expected slash-led input to queue, got {other:?}"), + } + assert!(composer.textarea.is_empty()); + assert!( + rx.try_recv().is_err(), + "queueing should not report slash errors" + ); + } + + assert_queued_slash("/compact"); + assert_queued_slash("/review check regressions"); + assert_queued_slash("/fast on"); + assert_queued_slash("/does-not-exist"); + } + + #[test] + fn tab_queues_leading_space_slash_as_plain_text_while_task_running() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_task_running(/*running*/ true); + composer + .textarea + .set_text_clearing_elements(" /does-not-exist"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + match result { + InputResult::Queued { text, action, .. } => { + assert_eq!(text, "/does-not-exist"); + assert_eq!(action, QueuedInputAction::Plain); + } + other => panic!("expected leading-space slash input to queue, got {other:?}"), + } + } + + #[test] + fn tab_queues_bang_shell_prompts_while_task_running_without_execution() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + fn assert_queued_shell(input: &str, expected_text: &str) { + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_task_running(/*running*/ true); + composer.textarea.set_text_clearing_elements(input); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + match result { + InputResult::Queued { + text, + text_elements, + action, + } => { + assert_eq!(text, expected_text); + assert!(text_elements.is_empty()); + assert_eq!(action, QueuedInputAction::RunShell); + } + other => panic!("expected bang shell input to queue, got {other:?}"), + } + assert!(composer.textarea.is_empty()); + assert!( + rx.try_recv().is_err(), + "queueing should not show shell help immediately" + ); + } + + assert_queued_shell("!echo hi", "!echo hi"); + assert_queued_shell("!", "!"); + assert_queued_shell(" !echo hi", "!echo hi"); + } + #[test] fn slash_tab_completion_moves_cursor_to_end() { use crossterm::event::KeyCode; @@ -6568,6 +6796,59 @@ mod tests { assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); } + #[test] + fn slash_tab_completion_wins_over_queueing_while_task_running() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_task_running(/*running*/ true); + + type_chars_humanlike(&mut composer, &['/', 'm', 'o']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert_eq!(result, InputResult::None); + assert_eq!(composer.textarea.text(), "/model "); + assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + } + + #[test] + fn slash_key_completes_selected_slash_command_as_text() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'm']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + + assert_eq!(result, InputResult::None); + assert_eq!(composer.textarea.text(), "/model "); + assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + } + #[test] fn slash_tab_then_enter_dispatches_builtin_command() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs index c9844ff9151e..3c0abec24d24 100644 --- a/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -1273,6 +1273,7 @@ impl McpServerElicitationOverlay { | InputResult::Queued { text, text_elements, + .. } => { self.apply_submission_to_draft(text, text_elements); self.validation_error = None; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 6411f0182ee9..cbc071449a29 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -84,10 +84,10 @@ mod file_search_popup; mod footer; mod list_selection_view; mod memories_settings_view; -mod prompt_args; +pub(crate) mod prompt_args; mod skill_popup; mod skills_toggle_view; -mod slash_commands; +pub(crate) mod slash_commands; pub(crate) use footer::CollaborationModeIndicator; pub(crate) use list_selection_view::ColumnWidthMode; pub(crate) use list_selection_view::SelectionRowDisplay; @@ -154,6 +154,7 @@ use crate::bottom_pane::prompt_args::parse_slash_name; pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposerConfig; pub(crate) use chat_composer::InputResult; +pub(crate) use chat_composer::QueuedInputAction; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::status_indicator_widget::StatusIndicatorWidget; diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 090892f5f6a6..a393af08de3f 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -10,14 +10,14 @@ use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; -/// Widget that displays pending steers plus follow-up messages held while a turn is in progress. +/// Widget that displays pending steers plus follow-up inputs held while a turn is in progress. /// /// The widget renders pending steers first, then rejected steers that will be /// resubmitted at end of turn, then ordinary queued user messages. Pending /// steers explain that they will be submitted after the next tool/result /// boundary unless the user presses Esc to interrupt and send them /// immediately. The edit hint at the bottom only appears when there are actual -/// queued user messages to pop back into the composer. Because some terminals +/// queued user inputs to pop back into the composer. Because some terminals /// intercept certain modifier-key combinations, the displayed binding is /// configurable via [`set_edit_binding`](Self::set_edit_binding). pub(crate) struct PendingInputPreview { @@ -128,7 +128,7 @@ impl PendingInputPreview { if !lines.is_empty() { lines.push(Line::from("")); } - Self::push_section_header(&mut lines, width, "Queued follow-up messages".into()); + Self::push_section_header(&mut lines, width, "Queued follow-up inputs".into()); for message in &self.queued_messages { let wrapped = adaptive_wrap_lines( diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index bc07c06e0ba2..4e152fbd1b3b 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -933,6 +933,7 @@ impl RequestUserInputOverlay { | InputResult::Queued { text, text_elements, + .. } => { if self.has_options() && matches!(self.focus, Focus::Notes) diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap index 4af8aa4d7647..65c011c26b12 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap @@ -1,11 +1,12 @@ --- source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 282 expression: "format!(\"{buf:?}\")" --- Buffer { area: Rect { x: 0, y: 0, width: 40, height: 6 }, content: [ - "• Queued follow-up messages ", + "• Queued follow-up inputs ", " ↳ This is ", " a message ", " with many ", diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap index 6a5312f602e1..f2506a364650 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap @@ -1,11 +1,12 @@ --- source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 253 expression: "format!(\"{buf:?}\")" --- Buffer { area: Rect { x: 0, y: 0, width: 40, height: 6 }, content: [ - "• Queued follow-up messages ", + "• Queued follow-up inputs ", " ↳ Hello, world! ", " ↳ This is another message ", " ↳ This is a third message ", diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap index 9816a4dc8516..74e39ed51471 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap @@ -1,11 +1,12 @@ --- source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 204 expression: "format!(\"{buf:?}\")" --- Buffer { area: Rect { x: 0, y: 0, width: 40, height: 3 }, content: [ - "• Queued follow-up messages ", + "• Queued follow-up inputs ", " ↳ Hello, world! ", " ⌥ + ↑ edit last queued message ", ], diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap index f3ad37e731da..c79ee32506ad 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap @@ -1,11 +1,12 @@ --- source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 216 expression: "format!(\"{buf:?}\")" --- Buffer { area: Rect { x: 0, y: 0, width: 40, height: 3 }, content: [ - "• Queued follow-up messages ", + "• Queued follow-up inputs ", " ↳ Hello, world! ", " shift + ← edit last queued message ", ], diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap index 77d57c3f4833..0321b79d6bdc 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 345 expression: "format!(\"{buf:?}\")" --- Buffer { @@ -13,7 +14,7 @@ Buffer { "• Messages to be submitted at end of turn ", " ↳ Rejected steer that will be retried. ", " ", - "• Queued follow-up messages ", + "• Queued follow-up inputs ", " ↳ Queued follow-up question ", " ⌥ + ↑ edit last queued message ", ], diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap index 51e600c7fc63..90c4fea4a009 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap @@ -1,11 +1,12 @@ --- source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 233 expression: "format!(\"{buf:?}\")" --- Buffer { area: Rect { x: 0, y: 0, width: 40, height: 4 }, content: [ - "• Queued follow-up messages ", + "• Queued follow-up inputs ", " ↳ Hello, world! ", " ↳ This is another message ", " ⌥ + ↑ edit last queued message ", diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap index 54b78f08237a..b338f880acca 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap @@ -1,11 +1,12 @@ --- source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 269 expression: "format!(\"{buf:?}\")" --- Buffer { area: Rect { x: 0, y: 0, width: 40, height: 5 }, content: [ - "• Queued follow-up messages ", + "• Queued follow-up inputs ", " ↳ This is a longer message that should", " be wrapped ", " ↳ This is another message ", diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap index de6d21ee7e6a..dbaad6344266 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -1,8 +1,9 @@ --- source: tui/src/bottom_pane/mod.rs +assertion_line: 1858 expression: "render_snapshot(&pane, area)" --- -• Queued follow-up messages +• Queued follow-up inputs ↳ Queued follow-up question ⌥ + ↑ edit last queued message diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap index 350cbfe27d8e..94f462d835b1 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -1,10 +1,11 @@ --- source: tui/src/bottom_pane/mod.rs +assertion_line: 1889 expression: "render_snapshot(&pane, area)" --- • Working (0s • esc to interrupt) -• Queued follow-up messages +• Queued follow-up inputs ↳ Queued follow-up question ⌥ + ↑ edit last queued message diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap index 65e21260dd84..4e8763c83dc1 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap @@ -1,12 +1,13 @@ --- source: tui/src/bottom_pane/mod.rs +assertion_line: 1826 expression: "render_snapshot(&pane, area)" --- • Working (0s • esc to interrupt) └ First detail line Second detail line -• Queued follow-up messages +• Queued follow-up inputs ↳ Queued follow-up question ⌥ + ↑ edit last queued message diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2c88baf85e2c..72cfa842e6f8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -34,6 +34,7 @@ use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; +use std::ops::Deref; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -218,6 +219,7 @@ use codex_protocol::protocol::WebSearchEndEvent; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::request_user_input::RequestUserInputQuestionOption; +use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use codex_terminal_detection::Multiplexer; @@ -317,6 +319,7 @@ use crate::bottom_pane::McpServerElicitationFormRequest; use crate::bottom_pane::MemoriesSettingsView; use crate::bottom_pane::MentionBinding; use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; +use crate::bottom_pane::QueuedInputAction; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; @@ -883,8 +886,10 @@ pub(crate) struct ChatWidget { // history has been rendered so resumed/forked prompts keep chronological // order. suppress_initial_user_message_submit: bool, - // User messages queued while a turn is in progress - queued_user_messages: VecDeque, + // User inputs queued while a turn is in progress. + queued_user_messages: VecDeque, + // A user turn has been submitted to core, but `TurnStarted` has not arrived yet. + user_turn_pending_start: bool, // User messages that tried to steer a non-regular turn and must be retried first. rejected_steers_queue: VecDeque, // Steers already submitted to core but not yet committed into history. @@ -1043,6 +1048,45 @@ pub(crate) struct UserMessage { mention_bindings: Vec, } +#[derive(Debug, Clone, PartialEq)] +struct QueuedUserMessage { + user_message: UserMessage, + action: QueuedInputAction, +} + +impl QueuedUserMessage { + fn new(user_message: UserMessage, action: QueuedInputAction) -> Self { + Self { + user_message, + action, + } + } + + fn into_user_message(self) -> UserMessage { + self.user_message + } +} + +impl From for QueuedUserMessage { + fn from(user_message: UserMessage) -> Self { + Self::new(user_message, QueuedInputAction::Plain) + } +} + +impl Deref for QueuedUserMessage { + type Target = UserMessage; + + fn deref(&self) -> &Self::Target { + &self.user_message + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QueueDrain { + Continue, + Stop, +} + #[derive(Debug, Clone, PartialEq, Default)] struct ThreadComposerState { text: String, @@ -1069,7 +1113,8 @@ pub(crate) struct ThreadInputState { composer: Option, pending_steers: VecDeque, rejected_steers_queue: VecDeque, - queued_user_messages: VecDeque, + queued_user_messages: VecDeque, + user_turn_pending_start: bool, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, task_running: bool, @@ -2134,6 +2179,7 @@ impl ChatWidget { self.refresh_terminal_title(); self.refresh_status_surfaces(); self.request_redraw(); + self.maybe_send_next_queued_input(); } } @@ -2326,6 +2372,7 @@ impl ChatWidget { // Raw reasoning uses the same flow as summarized reasoning fn on_task_started(&mut self) { + self.user_turn_pending_start = false; self.agent_turn_running = true; self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ true); @@ -2420,6 +2467,7 @@ impl ChatWidget { } // Mark task stopped and request redraw now that all content is in history. self.pending_status_indicator_restore = false; + self.user_turn_pending_start = false; self.agent_turn_running = false; self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ false); @@ -2495,19 +2543,20 @@ impl ChatWidget { !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() } - fn pop_next_queued_user_message(&mut self) -> Option { + fn pop_next_queued_user_message(&mut self) -> Option { if self.rejected_steers_queue.is_empty() { self.queued_user_messages.pop_front() } else { - Some(merge_user_messages( + Some(QueuedUserMessage::from(merge_user_messages( self.rejected_steers_queue.drain(..).collect(), - )) + ))) } } fn pop_latest_queued_user_message(&mut self) -> Option { self.queued_user_messages .pop_back() + .map(QueuedUserMessage::into_user_message) .or_else(|| self.rejected_steers_queue.pop_back()) } @@ -2799,6 +2848,7 @@ impl ChatWidget { // Ensure any spinner is replaced by a red ✗ and flushed into history. self.finalize_active_cell_as_failed(); // Reset running state and clear streaming buffers. + self.user_turn_pending_start = false; self.agent_turn_running = false; self.turn_sleep_inhibitor .set_turn_running(/*turn_running*/ false); @@ -3180,7 +3230,11 @@ impl ChatWidget { .drain(..) .map(|steer| steer.user_message), ); - to_merge.extend(self.queued_user_messages.drain(..)); + to_merge.extend( + self.queued_user_messages + .drain(..) + .map(QueuedUserMessage::into_user_message), + ); if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() || !existing_message.remote_image_urls.is_empty() @@ -3227,6 +3281,7 @@ impl ChatWidget { .collect(), rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), + user_turn_pending_start: self.user_turn_pending_start, current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), task_running: self.bottom_pane.is_task_running(), @@ -3240,6 +3295,7 @@ impl ChatWidget { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; self.agent_turn_running = input_state.agent_turn_running; + self.user_turn_pending_start = input_state.user_turn_pending_start; self.update_collaboration_mode_indicator(); self.refresh_model_dependent_surfaces(); if let Some(composer) = input_state.composer { @@ -3283,6 +3339,7 @@ impl ChatWidget { self.queued_user_messages = input_state.queued_user_messages; } else { self.agent_turn_running = false; + self.user_turn_pending_start = false; self.pending_steers.clear(); self.rejected_steers_queue.clear(); self.set_remote_image_urls(Vec::new()); @@ -4429,6 +4486,7 @@ impl ChatWidget { let parsed = self.annotate_skill_reads_in_parsed_cmd(parsed); let is_unified_exec_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); + let is_user_shell = source == ExecCommandSource::UserShell; let end_target = match self.active_cell.as_ref() { Some(cell) => match cell.as_any().downcast_ref::() { Some(exec_cell) @@ -4522,6 +4580,9 @@ impl ChatWidget { } // Mark that actual work was done (command executed) self.had_work_activity = true; + if is_user_shell { + self.maybe_send_next_queued_input(); + } } pub(crate) fn handle_patch_apply_end_now( @@ -4897,6 +4958,7 @@ impl ChatWidget { thread_name: None, forked_from: 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, @@ -5085,70 +5147,77 @@ impl ChatWidget { { self.cycle_collaboration_mode(); } - _ => match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted { - text, - text_elements, - } => { - 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 { + _ => { + let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); + match self.bottom_pane.handle_key_event(key_event) { + InputResult::Submitted { text, - local_images, - remote_image_urls, text_elements, - mention_bindings: self + } => { + let local_images = self .bottom_pane - .take_recent_submission_mention_bindings(), - }; - if user_message.text.is_empty() - && user_message.local_images.is_empty() - && user_message.remote_image_urls.is_empty() - { - return; - } - let should_submit_now = - self.is_session_configured() && !self.is_plan_streaming_in_tui(); - if should_submit_now { - // Submitted is emitted when user submits. - // Reset any reasoning header only when we are actually submitting a turn. - 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); + .take_recent_submission_images_with_placeholders(); + let remote_image_urls = self.take_remote_image_urls(); + let user_message = UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings: self + .bottom_pane + .take_recent_submission_mention_bindings(), + }; + if user_message.text.is_empty() + && user_message.local_images.is_empty() + && user_message.remote_image_urls.is_empty() + { + return; + } + let should_submit_now = + self.is_session_configured() && !self.is_plan_streaming_in_tui(); + if should_submit_now { + // Submitted is emitted when user submits. + // Reset any reasoning header only when we are actually submitting a turn. + 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); + } } - } - InputResult::Queued { - text, - text_elements, - } => { - 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 { + InputResult::Queued { text, - local_images, - remote_image_urls, text_elements, - mention_bindings: self + action, + } => { + let local_images = self .bottom_pane - .take_recent_submission_mention_bindings(), - }; - self.queue_user_message(user_message); - } - InputResult::Command(cmd) => { - self.handle_slash_command_dispatch(cmd); + .take_recent_submission_images_with_placeholders(); + let remote_image_urls = self.take_remote_image_urls(); + let user_message = UserMessage { + text, + local_images, + remote_image_urls, + text_elements, + mention_bindings: self + .bottom_pane + .take_recent_submission_mention_bindings(), + }; + self.queue_user_message_with_options(user_message, action); + } + InputResult::Command(cmd) => { + self.handle_slash_command_dispatch(cmd); + } + InputResult::CommandWithArgs(cmd, args, text_elements) => { + self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); + } + InputResult::None => {} } - InputResult::CommandWithArgs(cmd, args, text_elements) => { - self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); + if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() { + self.maybe_send_next_queued_input(); } - InputResult::None => {} - }, + } } } @@ -5329,18 +5398,54 @@ impl ChatWidget { } fn queue_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() || self.bottom_pane.is_task_running() { - self.queued_user_messages.push_back(user_message); + self.queue_user_message_with_options(user_message, QueuedInputAction::Plain); + } + + fn queue_user_message_with_options( + &mut self, + user_message: UserMessage, + action: QueuedInputAction, + ) { + if !self.is_session_configured() || self.is_user_turn_pending_or_running() { + self.queued_user_messages + .push_back(QueuedUserMessage::new(user_message, action)); self.refresh_pending_input_preview(); } else { self.submit_user_message(user_message); } } + fn submit_shell_command(&mut self, command: &str) -> QueueDrain { + let cmd = command.trim(); + if cmd.is_empty() { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + USER_SHELL_COMMAND_HELP_TITLE.to_string(), + Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), + ), + ))); + QueueDrain::Continue + } else { + self.submit_op(AppCommand::run_user_shell_command(cmd.to_string())); + QueueDrain::Stop + } + } + + fn submit_queued_shell_prompt(&mut self, user_message: UserMessage) -> QueueDrain { + match user_message.text.strip_prefix('!') { + Some(command) => self.submit_shell_command(command), + None => { + self.submit_user_message(user_message); + QueueDrain::Stop + } + } + } + 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(QueuedUserMessage::from(user_message)); self.refresh_pending_input_preview(); return; } @@ -5372,17 +5477,7 @@ impl ChatWidget { // Special-case: "!cmd" executes a local shell command instead of sending to the model. if let Some(stripped) = text.strip_prefix('!') { - let cmd = stripped.trim(); - if cmd.is_empty() { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event( - USER_SHELL_COMMAND_HELP_TITLE.to_string(), - Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), - ), - ))); - return; - } - self.submit_op(AppCommand::run_user_shell_command(cmd.to_string())); + self.submit_shell_command(stripped); return; } @@ -5558,6 +5653,9 @@ impl ChatWidget { if !self.submit_op(op) { return; } + if render_in_history { + self.user_turn_pending_start = true; + } // Persist the text to cross-session message history. Mentions are // encoded into placeholder syntax so recall can reconstruct the @@ -7072,16 +7170,40 @@ impl ChatWidget { if self.suppress_queue_autosend { return; } - if self.bottom_pane.is_task_running() { + if self.is_user_turn_pending_or_running() { return; } - if let Some(user_message) = self.pop_next_queued_user_message() { - self.submit_user_message(user_message); + while !self.is_user_turn_pending_or_running() { + let Some(queued_message) = self.pop_next_queued_user_message() else { + break; + }; + match queued_message.action { + QueuedInputAction::Plain => { + self.submit_user_message(queued_message.into_user_message()); + break; + } + QueuedInputAction::ParseSlash => { + let drain = self.submit_queued_slash_prompt(queued_message.into_user_message()); + if drain == QueueDrain::Stop { + break; + } + } + QueuedInputAction::RunShell => { + let drain = self.submit_queued_shell_prompt(queued_message.into_user_message()); + if drain == QueueDrain::Stop { + break; + } + } + } } // Update the list to reflect the remaining queued messages (if any). self.refresh_pending_input_preview(); } + pub(super) fn is_user_turn_pending_or_running(&self) -> bool { + self.user_turn_pending_start || self.bottom_pane.is_task_running() + } + /// Rebuild and update the bottom-pane pending-input preview. fn refresh_pending_input_preview(&mut self) { let queued_messages: Vec = self diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index c2cec8626c7e..64fb7a1f2bfb 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -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, + local_images: Vec, + remote_image_urls: Vec, + mention_bindings: Vec, + 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, + text_elements: Vec, ) { 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, + ) -> Option<(String, Vec)> { + 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 { + 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() } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap index 26984635260b..a287d9d7a290 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 6067f3a768f0..cb3ac492cf9a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 8cafb54dc26f..2f8c6a9b2b5e 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -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 { diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 53f16a39d1e8..9f181d3d2151 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -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()); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 66df972ebfcd..72499c762263 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -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. diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac36..91b9423242ab 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -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) { diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e9276582594c..63ae3d099b0c 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -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::>(); + 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::>() + .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::>(); + 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::>(); + 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::>() + .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::>(); + 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;