diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index bf11ff571d..a8d8b0d063 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,3 +1,6 @@ +use crate::slash_command::SlashInputMode; +use crate::slash_command::parse_slash_invocation; +use crate::slash_command::slash_input_mode; use codex_core::protocol::TokenUsageInfo; use codex_protocol::num_format::format_si_suffix; use crossterm::event::KeyCode; @@ -263,6 +266,19 @@ impl ChatComposer { self.sync_file_search_popup(); } + /// Move the cursor to the end of the current line/content and resync popups. + pub(crate) fn move_cursor_to_end(&mut self) { + let end = self.textarea.text().len(); + self.textarea.set_cursor(end); + // Keep popup sync consistent with other cursor/text changes. + self.sync_command_popup(); + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.dismissed_file_popup_token = None; + } else { + self.sync_file_search_popup(); + } + } + /// Get the current composer text. #[cfg(test)] pub(crate) fn current_text(&self) -> String { @@ -1147,16 +1163,27 @@ impl ChatComposer { fn sync_command_popup(&mut self) { let first_line = self.textarea.text().lines().next().unwrap_or(""); let input_starts_with_slash = first_line.starts_with('/'); + + // Suppress the slash popup when the input is an already-formed compose command + // with additional text (e.g., "/review "). + let mut suppress_for_compose = false; + if let Some((cmd, remainder)) = parse_slash_invocation(first_line) { + if let SlashInputMode::Compose { .. } = slash_input_mode(cmd) { + if !remainder.is_empty() { + suppress_for_compose = true; + } + } + } match &mut self.active_popup { ActivePopup::Command(popup) => { - if input_starts_with_slash { + if input_starts_with_slash && !suppress_for_compose { popup.on_composer_text_change(first_line.to_string()); } else { self.active_popup = ActivePopup::None; } } _ => { - if input_starts_with_slash { + if input_starts_with_slash && !suppress_for_compose { let mut command_popup = CommandPopup::new(self.custom_prompts.clone()); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); @@ -1380,6 +1407,9 @@ mod tests { use crate::bottom_pane::chat_composer::AttachedImage; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; use crate::bottom_pane::textarea::TextArea; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; use tokio::sync::mpsc::unbounded_channel; #[test] @@ -1893,6 +1923,30 @@ mod tests { assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); } + #[test] + fn compose_review_suppresses_slash_popup_when_remainder_present() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Type '/rev' to show the slash popup filtered to review + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v']); + assert!(composer.popup_active(), "popup should be active for '/rev'"); + + // Add remainder after the command; popup should be suppressed + type_chars_humanlike(&mut composer, &['i', 'e', 'w', ' ', 'x']); + assert!( + !composer.popup_active(), + "popup should be suppressed for '/review '" + ); + } + #[test] fn slash_mention_dispatches_command_and_inserts_at() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f30bd418e9..b9c0b026e6 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -243,6 +243,12 @@ impl BottomPane { self.request_redraw(); } + /// Move the composer cursor to the end of the current content. + pub(crate) fn move_cursor_to_end(&mut self) { + self.composer.move_cursor_to_end(); + self.request_redraw(); + } + /// Get the current composer text (for tests and programmatic checks). #[cfg(test)] pub(crate) fn composer_text(&self) -> String { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 73cae81478..f8f6a93509 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -73,6 +73,10 @@ use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; use crate::markdown::append_markdown; use crate::slash_command::SlashCommand; +use crate::slash_command::SlashInputMode; +use crate::slash_command::parse_slash_invocation; +use crate::slash_command::slash_input_mode; +use crate::slash_command::slash_submit_op; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; // streaming internals are provided by crate::streaming and crate::markdown_stream @@ -822,7 +826,51 @@ impl ChatWidget { _ => { match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted(text) => { - // If a task is running, queue the user input to be sent after the turn completes. + // Generic compose-style submission: interpret `/ ...` if cmd is compose. + if let Some((cmd, remainder)) = parse_slash_invocation(&text) { + if let SlashInputMode::Compose { default_prompt, .. } = + slash_input_mode(cmd) + { + let prompt = if remainder.is_empty() { + default_prompt.to_string() + } else { + remainder.to_string() + }; + + // Mirror normal flow: persist and show the user's message text. + self.add_to_history(history_cell::new_user_prompt(text.clone())); + self.codex_op_tx + .send(Op::AddToHistory { text: text.clone() }) + .unwrap_or_else(|e| { + tracing::error!("failed to send AddHistory op: {e}"); + }); + + if self.bottom_pane.is_task_running() { + // Queue raw text; dequeue path will re-interpret and submit. + let user_message = UserMessage { + text, + image_paths: self + .bottom_pane + .take_recent_submission_images(), + }; + self.queued_user_messages.push_back(user_message); + self.refresh_queued_user_messages(); + } else if let Some(op) = slash_submit_op(cmd, prompt) { + self.submit_op(op); + } else { + // Fallback: treat as a normal message if no submit mapping exists. + let user_message = UserMessage { + text, + image_paths: self + .bottom_pane + .take_recent_submission_images(), + }; + self.submit_user_message(user_message); + } + return; + } + } + // Normal user input path let user_message = UserMessage { text, image_paths: self.bottom_pane.take_recent_submission_images(), @@ -881,13 +929,12 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } SlashCommand::Review => { - // Simplified flow: directly send a review op for current changes. - self.submit_op(Op::Review { - review_request: ReviewRequest { - prompt: "review current changes".to_string(), - user_facing_hint: "current changes".to_string(), - }, - }); + // Prefill the composer with a review command allowing the user to edit. + self.bottom_pane + .set_composer_text("/review Review my current changes.".to_string()); + // Move cursor to the end so typing continues after the prefill. + self.bottom_pane.move_cursor_to_end(); + self.request_redraw(); } SlashCommand::Model => { self.open_model_popup(); @@ -1254,6 +1301,21 @@ impl ChatWidget { return; } if let Some(user_message) = self.queued_user_messages.pop_front() { + // Intercept queued compose-style commands and convert to their Ops. + if let Some((cmd, remainder)) = parse_slash_invocation(&user_message.text) { + if let SlashInputMode::Compose { default_prompt, .. } = slash_input_mode(cmd) { + let prompt = if remainder.is_empty() { + default_prompt.to_string() + } else { + remainder.to_string() + }; + if let Some(op) = slash_submit_op(cmd, prompt) { + self.submit_op(op); + self.refresh_queued_user_messages(); + return; + } + } + } self.submit_user_message(user_message); } // Update the list to reflect the remaining queued messages (if any). diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ffe3f3f707..9b05973fa0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -191,6 +191,82 @@ fn resumed_initial_messages_render_history() { ); } +/// Compose-mode: selecting /review pre-fills the composer and moves cursor to end. +#[test] +fn compose_review_prefill_and_cursor() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (mut chat, _rx, _ops) = make_chatwidget_manual(); + chat.dispatch_command(SlashCommand::Review); + + // Prefill text present + assert_eq!( + chat.bottom_pane.composer_text(), + "/review Review my current changes." + ); + + // Typing continues at the end (smoke check by submitting) + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); +} + +/// Compose-mode: pressing Enter sends a mapped Review op with the default prompt. +#[test] +fn compose_review_enter_sends_review_op() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + chat.dispatch_command(SlashCommand::Review); + // Immediately submit without editing; should use default prompt. + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Collect ops until we see a Review + let mut saw_review = false; + while let Ok(op) = op_rx.try_recv() { + if let Op::Review { review_request } = op { + assert_eq!(review_request.prompt, "Review my current changes."); + saw_review = true; + break; + } + } + assert!(saw_review, "expected a Review op to be submitted"); +} + +/// Compose-mode queueing: Enter while task running queues the user text; when idle, it submits Review. +#[test] +fn compose_review_queue_then_submit_when_idle() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + // Prefill while idle + chat.dispatch_command(SlashCommand::Review); + // Now a task begins; Enter should queue the message instead of sending. + chat.bottom_pane.set_task_running(true); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // Should be queued, not sent yet + assert_eq!(chat.queued_user_messages.len(), 1); + + // Become idle and trigger dequeue + chat.bottom_pane.set_task_running(false); + chat.maybe_send_next_queued_input(); + + let mut saw_review = false; + while let Ok(op) = op_rx.try_recv() { + if let Op::Review { review_request } = op { + assert_eq!(review_request.prompt, "Review my current changes."); + saw_review = true; + break; + } + } + assert!(saw_review, "expected a Review op after becoming idle"); +} + /// Entering review mode uses the hint provided by the review request. #[test] fn entered_review_mode_uses_request_hint() { diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 433c0a6d7f..1bf78287c1 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -1,9 +1,13 @@ +use std::str::FromStr; use strum::IntoEnumIterator; use strum_macros::AsRefStr; use strum_macros::EnumIter; use strum_macros::EnumString; use strum_macros::IntoStaticStr; +use codex_core::protocol::Op; +use codex_core::protocol::ReviewRequest; + /// Commands that can be invoked by starting a message with a leading slash. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr, @@ -81,3 +85,56 @@ impl SlashCommand { pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { SlashCommand::iter().map(|c| (c.command(), c)).collect() } + +/// Input mode for a slash command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SlashInputMode { + /// Execute immediately (handled via popup selection or dispatch_command). + Immediate, + /// Prefill composer with `/ `; on Enter, submit a specific Op. + Compose { default_prompt: &'static str }, +} + +/// Describe how a built-in command is edited/submitted. +pub fn slash_input_mode(cmd: SlashCommand) -> SlashInputMode { + match cmd { + SlashCommand::Review => SlashInputMode::Compose { + default_prompt: "Review my current changes.", + }, + _ => SlashInputMode::Immediate, + } +} + +/// If `text` begins with a built-in slash command, return the command and the +/// remainder (everything after the command token, across newlines). Callers that +/// only want to consider the first line can pass just that slice. +pub fn parse_slash_invocation(text: &str) -> Option<(SlashCommand, &str)> { + // Must start with a slash. + let after_slash = text.strip_prefix('/')?; + // Allow optional whitespace after the slash before the command token. + let token_start = after_slash.trim_start(); + let mut parts = token_start.splitn(2, char::is_whitespace); + let cmd_token = parts.next()?; + if cmd_token.is_empty() { + return None; + } + let cmd = SlashCommand::from_str(cmd_token).ok()?; + // Preserve the rest of the original input (including newlines), + // trimming only leading/trailing whitespace around it. + let remainder = parts.next().map(str::trim).unwrap_or(""); + Some((cmd, remainder)) +} + +/// Map a compose-style command + remainder into an Op for Codex. +/// Returns None for commands that don't have a direct Op mapping. +pub fn slash_submit_op(cmd: SlashCommand, remainder: String) -> Option { + match cmd { + SlashCommand::Review => Some(Op::Review { + review_request: ReviewRequest { + prompt: remainder.clone(), + user_facing_hint: remainder, + }, + }), + _ => None, + } +}