From 037a7c69c00ea88830aa9c5444fd9f763a5c1043 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Thu, 21 May 2026 11:40:37 -0700 Subject: [PATCH 1/4] Prevent trailing text from being removed when activating some slash commands --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 205 +++++++++++++++++- .../bottom_pane/chat_composer/slash_input.rs | 146 +++++++++++-- codex-rs/tui/src/slash_command.rs | 6 + 3 files changed, 337 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8fcda378fc5..8ff47df4bbd 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3504,6 +3504,9 @@ impl ChatComposer { && self .slash_input() .is_editing_command_name(first_line, cursor); + let command_filter_text = caret_on_first_line + .then(|| slash_input::command_popup_filter_text(first_line, cursor)) + .flatten(); // If the cursor is currently positioned within an `@token`, prefer the // file-search popup over the slash popup so users can insert a file path @@ -3517,15 +3520,19 @@ impl ChatComposer { match &mut self.popups.active { ActivePopup::Command(popup) => { if is_editing_slash_command_name { - popup.on_composer_text_change(first_line.to_string()); + if let Some(command_filter_text) = command_filter_text.as_deref() { + popup.on_composer_text_change(command_filter_text.to_string()); + } } else { self.popups.active = ActivePopup::None; } } _ => { if is_editing_slash_command_name { - let command_popup = self.slash_input().command_popup(first_line); - self.popups.active = ActivePopup::Command(command_popup); + if let Some(command_filter_text) = command_filter_text.as_deref() { + let command_popup = self.slash_input().command_popup(command_filter_text); + self.popups.active = ActivePopup::Command(command_popup); + } } } } @@ -7995,6 +8002,198 @@ 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_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 a7205d237ae..e86f38ff05c 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 @@ -126,7 +126,11 @@ impl<'a> SlashInput<'a> { self.enabled && !text.starts_with(' ') && text.trim().starts_with('/') } - pub(super) fn command_element_range(&self, first_line: &str) -> Option> { + pub(super) fn command_element_range( + &self, + first_line: &str, + cursor: usize, + ) -> Option> { if self.is_bash_mode { return None; } @@ -135,6 +139,11 @@ impl<'a> SlashInput<'a> { return None; } let element_end = 1 + name.len(); + // A draft tail can make an in-progress prefix look complete ("/re" + "view"). + // Keep it editable until the cursor leaves the command name. + if cursor <= first_line.len() && (1..element_end).contains(&cursor) { + return None; + } let has_space_after = first_line .get(element_end..) .and_then(|tail| tail.chars().next()) @@ -159,7 +168,7 @@ impl<'a> SlashInput<'a> { has_slash_command_prefix(name, self.command_flags, self.service_tier_commands) } - pub(super) fn command_popup(&self, first_line: &str) -> CommandPopup { + pub(super) fn command_popup(&self, filter_text: &str) -> CommandPopup { let mut command_popup = CommandPopup::new( CommandPopupFlags { collaboration_modes_enabled: self.command_flags.collaboration_modes_enabled, @@ -175,7 +184,7 @@ impl<'a> SlashInput<'a> { }, self.service_tier_commands.to_vec(), ); - command_popup.on_composer_text_change(first_line.to_string()); + command_popup.on_composer_text_change(filter_text.to_string()); command_popup } @@ -255,8 +264,12 @@ impl ChatComposer { } => { // Ensure popup filtering/selection reflects the latest composer text // before applying completion. - let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); - popup.on_composer_text_change(first_line.to_string()); + let text = self.draft.textarea.text(); + let first_line = text.lines().next().unwrap_or("").to_owned(); + let cursor = self.draft.textarea.cursor(); + let filter_text = command_popup_filter_text(&first_line, cursor) + .unwrap_or_else(|| first_line.clone()); + popup.on_composer_text_change(filter_text); if let Some(selected_cmd) = popup.selected_item() { if selected_command_dispatches_immediately_on_tab(&selected_cmd) && let CommandItem::Builtin(cmd) = &selected_cmd @@ -267,8 +280,16 @@ impl ChatComposer { return (InputResult::Command(*cmd), true); } + if self + .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &selected_cmd, + ) + { + return (InputResult::None, true); + } + if let Some(completed_text) = - selected_command_completion(first_line, &selected_cmd) + selected_command_completion(&first_line, &selected_cmd) { self.draft .textarea @@ -293,11 +314,23 @@ impl ChatComposer { } => { // Treat "/" as accepting the highlighted command as text completion // while the slash-command popup is active. - let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); - popup.on_composer_text_change(first_line.to_string()); + let text = self.draft.textarea.text(); + let first_line = text.lines().next().unwrap_or("").to_owned(); + let cursor = self.draft.textarea.cursor(); + let filter_text = command_popup_filter_text(&first_line, cursor) + .unwrap_or_else(|| first_line.clone()); + popup.on_composer_text_change(filter_text); if let Some(selected_cmd) = popup.selected_item() { + if self + .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &selected_cmd, + ) + { + return (InputResult::None, true); + } + if let Some(completed_text) = - selected_command_completion(first_line, &selected_cmd) + selected_command_completion(&first_line, &selected_cmd) { self.draft .textarea @@ -318,6 +351,15 @@ impl ChatComposer { .. } => { if let Some(sel) = popup.selected_item() { + if self + .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &sel, + ) + && let Some(result) = self.try_dispatch_slash_command_with_args() + { + return (result, true); + } + self.stage_selected_slash_command_history(&sel); self.draft.textarea.set_text_clearing_elements(""); self.draft.is_bash_mode = false; @@ -338,6 +380,66 @@ impl ChatComposer { } } + fn complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &mut self, + selected_cmd: &CommandItem, + ) -> bool { + let CommandItem::Builtin(cmd) = selected_cmd else { + return false; + }; + let cmd = *cmd; + if !cmd.preserves_draft_tail_on_completion() { + return false; + } + + let text = self.draft.textarea.text(); + let first_line_end = text.find('\n').unwrap_or(text.len()); + let cursor = self.draft.textarea.cursor(); + if cursor > first_line_end || !text.starts_with('/') || !text.is_char_boundary(cursor) { + return false; + } + + let command_token_end = text[1..first_line_end] + .find(char::is_whitespace) + .map(|idx| 1 + idx) + .unwrap_or(first_line_end); + let replace_end = if cursor <= 1 { + command_token_end + } else { + cursor + }; + let tail = &text[replace_end..]; + let tail_starts_with_whitespace = tail.chars().next().is_some_and(char::is_whitespace); + let selected_command_text = format!("/{}", cmd.command()); + let replacement = if tail_starts_with_whitespace { + selected_command_text + } else { + format!("{selected_command_text} ") + }; + + let ranges_to_unmark = self + .draft + .textarea + .text_elements() + .into_iter() + .filter_map(|element| { + let range = element.byte_range.start..element.byte_range.end; + (range.start < replace_end && replace_end < range.end).then_some(range) + }) + .collect::>(); + for range in ranges_to_unmark { + self.draft.textarea.remove_element_range(range); + } + self.draft + .textarea + .replace_range(0..replace_end, &replacement); + self.draft.is_bash_mode = false; + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); + true + } + /// Keep slash command elements aligned with the current first line. pub(super) fn sync_slash_command_elements(&mut self) { if !self.slash_commands_enabled() { @@ -346,7 +448,8 @@ impl ChatComposer { let text = self.draft.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); let first_line = &text[..first_line_end]; - let desired_range = self.slash_input().command_element_range(first_line); + let cursor = self.draft.textarea.cursor(); + let desired_range = self.slash_input().command_element_range(first_line, cursor); // Slash commands are only valid at byte 0 of the first line. // Any slash-shaped element not matching the current desired prefix is stale. let mut has_desired = false; @@ -424,12 +527,20 @@ pub(super) fn args_elements( .collect() } +pub(super) fn command_popup_filter_text(first_line: &str, cursor: usize) -> Option { + let (name, _rest) = command_under_cursor(first_line, cursor)?; + Some(format!("/{name}")) +} + /// If the cursor is currently within a slash command on the first line, -/// extract the command name and the rest of the line after it. +/// extract the command fragment before the cursor and the rest of the line after it. fn command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> { if !first_line.starts_with('/') { return None; } + if cursor > first_line.len() || !first_line.is_char_boundary(cursor) { + return None; + } let name_start = 1usize; let name_end = first_line[name_start..] @@ -437,16 +548,17 @@ fn command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> .map(|idx| name_start + idx) .unwrap_or_else(|| first_line.len()); + let cursor = if cursor <= name_start { + name_end + } else { + cursor + }; if cursor > name_end { return None; } - let name = &first_line[name_start..name_end]; - let rest_start = first_line[name_end..] - .find(|c: char| !c.is_whitespace()) - .map(|idx| name_end + idx) - .unwrap_or(name_end); - let rest = &first_line[rest_start..]; + let name = &first_line[name_start..cursor]; + let rest = &first_line[cursor..]; Some((name, rest)) } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 4a43409296a..687d50bf372 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -163,6 +163,12 @@ 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!( From 31bf81545b35c47c13fa23762b8f695e4e8f8360 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 26 May 2026 11:59:18 -0700 Subject: [PATCH 2/4] Fix slash command completion tail ambiguity --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 82 +++++++++++++++++++ .../bottom_pane/chat_composer/slash_input.rs | 13 +-- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8ff47df4bbd..3d5829182a6 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -8194,6 +8194,88 @@ mod tests { ); } + #[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 e86f38ff05c..a1387e6c82b 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 @@ -403,11 +403,14 @@ impl ChatComposer { .find(char::is_whitespace) .map(|idx| 1 + idx) .unwrap_or(first_line_end); - let replace_end = if cursor <= 1 { - command_token_end - } else { - cursor - }; + let typed_command_name = &text[1..command_token_end]; + let rest_after_token_is_empty = text[command_token_end..].trim().is_empty(); + let replace_end = + if cursor <= 1 || (typed_command_name == cmd.command() && rest_after_token_is_empty) { + command_token_end + } else { + cursor + }; let tail = &text[replace_end..]; let tail_starts_with_whitespace = tail.chars().next().is_some_and(char::is_whitespace); let selected_command_text = format!("/{}", cmd.command()); From 5fd2bc09b6ba39aa4e4b37282ac6b76bfc601d63 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 26 May 2026 12:11:40 -0700 Subject: [PATCH 3/4] Collapse slash command popup condition --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3d5829182a6..2da6d759e9a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3528,11 +3528,11 @@ impl ChatComposer { } } _ => { - if is_editing_slash_command_name { - if let Some(command_filter_text) = command_filter_text.as_deref() { - let command_popup = self.slash_input().command_popup(command_filter_text); - self.popups.active = ActivePopup::Command(command_popup); - } + if is_editing_slash_command_name + && let Some(command_filter_text) = command_filter_text.as_deref() + { + let command_popup = self.slash_input().command_popup(command_filter_text); + self.popups.active = ActivePopup::Command(command_popup); } } } From 68b6b6833198996678e180c7f331fe2703afdaa8 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 27 May 2026 10:21:39 -0700 Subject: [PATCH 4/4] Simplify slash command draft-tail completion (#24683) ## Summary This stacks on #23950 to make draft-tail preservation reuse the existing inline-argument slash command opt-in instead of maintaining a separate command list. It also moves the new completion tests out of the large chat composer module and into slash_input.rs, where the behavior lives. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 274 ------------------ .../bottom_pane/chat_composer/slash_input.rs | 88 +++++- codex-rs/tui/src/slash_command.rs | 6 - 3 files changed, 86 insertions(+), 282 deletions(-) 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!(