diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2da6d759e9a..9fc03764da9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -8002,280 +8002,6 @@ mod tests { ); } - #[test] - fn slash_completion_preserves_existing_draft_tail_for_goal() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let draft = "preserve this draft as the goal objective"; - let draft_chars = draft.chars().collect::>(); - - 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_goal_command_enabled(/*enabled*/ true); - - type_chars_humanlike(&mut composer, &draft_chars); - let (_result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); - type_chars_humanlike(&mut composer, &['/', 'g', 'o']); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - - assert_eq!(result, InputResult::None); - assert_eq!( - composer.draft.textarea.text(), - "/goal preserve this draft as the goal objective" - ); - assert_eq!( - composer.draft.textarea.cursor(), - composer.draft.textarea.text().len() - ); - - 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_goal_command_enabled(/*enabled*/ true); - - type_chars_humanlike(&mut composer, &draft_chars); - let (_result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); - type_chars_humanlike(&mut composer, &['/', 'g', 'o']); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!( - result, - InputResult::CommandWithArgs(SlashCommand::Goal, draft.to_string(), Vec::new()) - ); - assert_eq!( - composer.draft.textarea.text(), - "/goal preserve this draft as the goal objective" - ); - - let command = "/re use the text after the slash command as review instructions"; - let review_args = "use the text after the slash command as review instructions"; - let cursor_after_re = "/re".len(); - 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.draft.textarea.set_text_clearing_elements(command); - composer.draft.textarea.set_cursor(cursor_after_re); - composer.sync_popups(); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!( - result, - InputResult::CommandWithArgs(SlashCommand::Review, review_args.to_string(), Vec::new()) - ); - assert_eq!( - composer.draft.textarea.text(), - "/review use the text after the slash command as review instructions" - ); - - 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.draft.textarea.set_text_clearing_elements(command); - composer.draft.textarea.set_cursor(cursor_after_re); - composer.sync_popups(); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - - assert_eq!(result, InputResult::None); - assert_eq!( - composer.draft.textarea.text(), - "/review use the text after the slash command as review instructions" - ); - - let make_goal_composer = |cursor| { - 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_goal_command_enabled(/*enabled*/ true); - composer - .draft - .textarea - .set_text_clearing_elements("/goal ship it"); - composer.draft.textarea.set_cursor(cursor); - composer.sync_popups(); - composer - }; - - for cursor in [0, 1] { - let mut composer = make_goal_composer(cursor); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - - assert_eq!(result, InputResult::None); - assert_eq!(composer.draft.textarea.text(), "/goal ship it"); - } - - for cursor in [0, 1] { - let mut composer = make_goal_composer(cursor); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!( - result, - InputResult::CommandWithArgs(SlashCommand::Goal, "ship it".to_string(), Vec::new()) - ); - assert_eq!(composer.draft.textarea.text(), "/goal ship it"); - } - } - - #[test] - fn slash_completion_does_not_preserve_existing_draft_tail_for_other_commands() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let draft = "preserve this draft only for opted-in slash commands"; - let draft_chars = draft.chars().collect::>(); - 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, &draft_chars); - let (_result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); - 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.draft.textarea.text(), "/model "); - assert_eq!( - composer.draft.textarea.cursor(), - composer.draft.textarea.text().len() - ); - } - - #[test] - fn slash_completion_does_not_turn_command_suffix_into_args() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let make_review_composer = || { - 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 - .draft - .textarea - .set_text_clearing_elements("/review"); - composer.draft.textarea.set_cursor("/re".len()); - composer.sync_popups(); - composer - }; - - let mut composer = make_review_composer(); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - - assert_eq!(result, InputResult::None); - assert_eq!(composer.draft.textarea.text(), "/review "); - - let mut composer = make_review_composer(); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(result, InputResult::Command(SlashCommand::Review)); - assert!(composer.draft.textarea.is_empty()); - } - - #[test] - fn slash_completion_preserves_draft_tail_that_completes_command_name() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let draft = "view the diff"; - let make_review_composer = || { - 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, &draft.chars().collect::>()); - let (_result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); - type_chars_humanlike(&mut composer, &['/', 'r', 'e']); - composer - }; - - let mut composer = make_review_composer(); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - - assert_eq!(result, InputResult::None); - assert_eq!(composer.draft.textarea.text(), "/review view the diff"); - - let mut composer = make_review_composer(); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!( - result, - InputResult::CommandWithArgs(SlashCommand::Review, draft.to_string(), Vec::new()) - ); - assert_eq!(composer.draft.textarea.text(), "/review view the diff"); - } - #[test] fn slash_tab_completion_wins_over_queueing_while_task_running() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs index a1387e6c82b..bb9d2c6904d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs @@ -387,8 +387,7 @@ impl ChatComposer { let CommandItem::Builtin(cmd) = selected_cmd else { return false; }; - let cmd = *cmd; - if !cmd.preserves_draft_tail_on_completion() { + if !cmd.supports_inline_args() { return false; } @@ -405,6 +404,9 @@ impl ChatComposer { .unwrap_or(first_line_end); let typed_command_name = &text[1..command_token_end]; let rest_after_token_is_empty = text[command_token_end..].trim().is_empty(); + if rest_after_token_is_empty && (cursor <= 1 || cursor >= command_token_end) { + return false; + } let replace_end = if cursor <= 1 || (typed_command_name == cmd.command() && rest_after_token_is_empty) { command_token_end @@ -565,3 +567,85 @@ fn command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> Some((name, rest)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::bottom_pane::AppEventSender; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn test_composer() -> ChatComposer { + let (tx, _rx) = unbounded_channel::(); + ChatComposer::new( + /*has_input_focus*/ true, + AppEventSender::new(tx), + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ) + } + + fn press(composer: &mut ChatComposer, code: KeyCode) -> InputResult { + composer + .handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)) + .0 + } + + fn composer_with_text_at_cursor(text: &str, cursor: usize) -> ChatComposer { + let mut composer = test_composer(); + composer.draft.textarea.set_text_clearing_elements(text); + composer.draft.textarea.set_cursor(cursor); + composer.sync_popups(); + composer + } + + fn composer_with_draft_tail(prefix: &str, draft: &str) -> ChatComposer { + composer_with_text_at_cursor(&format!("{prefix}{draft}"), prefix.len()) + } + + #[test] + fn slash_completion_preserves_existing_draft_tail_for_inline_arg_commands() { + let draft = "view the diff"; + let expected_text = "/review view the diff"; + + let mut composer = composer_with_draft_tail("/re", draft); + assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); + assert_eq!(composer.draft.textarea.text(), expected_text); + assert_eq!(composer.draft.textarea.cursor(), expected_text.len()); + + let mut composer = composer_with_draft_tail("/re", draft); + assert_eq!( + press(&mut composer, KeyCode::Enter), + InputResult::CommandWithArgs(SlashCommand::Review, draft.to_string(), Vec::new()) + ); + assert_eq!(composer.draft.textarea.text(), expected_text); + } + + #[test] + fn slash_completion_does_not_preserve_existing_draft_tail_for_other_commands() { + let mut composer = composer_with_draft_tail( + "/mo", + "preserve this draft only for opted-in slash commands", + ); + + assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); + assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!(composer.draft.textarea.cursor(), "/model ".len()); + } + + #[test] + fn slash_completion_does_not_turn_command_suffix_into_args() { + let mut composer = composer_with_text_at_cursor("/review", "/re".len()); + assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); + assert_eq!(composer.draft.textarea.text(), "/review "); + + let mut composer = composer_with_text_at_cursor("/review", "/re".len()); + assert_eq!( + press(&mut composer, KeyCode::Enter), + InputResult::Command(SlashCommand::Review) + ); + assert!(composer.draft.textarea.is_empty()); + } +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 687d50bf372..4a43409296a 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -163,12 +163,6 @@ impl SlashCommand { ) } - /// Whether slash-command completion should preserve draft text after the typed command prefix - /// and use it as inline arguments for this command. - pub fn preserves_draft_tail_on_completion(self) -> bool { - matches!(self, SlashCommand::Review | SlashCommand::Goal) - } - /// Whether this command remains available inside an active side conversation. pub fn available_in_side_conversation(self) -> bool { matches!(