From 48984c62456b47e44a0b732430ce8728cdb1040c Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Mon, 29 Sep 2025 16:54:07 -0700 Subject: [PATCH 1/2] Custom prompts begin with `/prompts:` --- codex-rs/protocol/src/custom_prompts.rs | 6 +++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 47 ++++++++++++++----- codex-rs/tui/src/bottom_pane/command_popup.rs | 9 +++- codex-rs/tui/src/bottom_pane/prompt_args.rs | 18 +++++-- 4 files changed, 61 insertions(+), 19 deletions(-) diff --git a/codex-rs/protocol/src/custom_prompts.rs b/codex-rs/protocol/src/custom_prompts.rs index 7bceb1390f..4839cba3c2 100644 --- a/codex-rs/protocol/src/custom_prompts.rs +++ b/codex-rs/protocol/src/custom_prompts.rs @@ -3,6 +3,12 @@ use serde::Serialize; use std::path::PathBuf; use ts_rs::TS; +/// Base namespace for custom prompt slash commands (without trailing colon). +/// Example usage forms constructed in code: +/// - Command token after '/': `"{PROMPTS_CMD_PREFIX}:name"` +/// - Full slash prefix: `"/{PROMPTS_CMD_PREFIX}:"` +pub const PROMPTS_CMD_PREFIX: &str = "prompts"; + #[derive(Serialize, Deserialize, Debug, Clone, TS)] pub struct CustomPrompt { pub name: String, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 852acc2453..9e06f5a1ab 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -32,6 +32,7 @@ use crate::slash_command::SlashCommand; use crate::style::user_message_style; use crate::terminal_palette; use codex_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -409,9 +410,11 @@ impl ChatComposer { let name = prompt.name.clone(); let starts_with_cmd = first_line .trim_start() - .starts_with(format!("/{name}").as_str()); + .starts_with(format!("/{PROMPTS_CMD_PREFIX}:{name}").as_str()); if !starts_with_cmd { - self.textarea.set_text(format!("/{name} ").as_str()); + self.textarea.set_text( + format!("/{PROMPTS_CMD_PREFIX}:{name} ").as_str(), + ); } if !self.textarea.text().is_empty() { cursor_target = Some(self.textarea.text().len()); @@ -435,7 +438,8 @@ impl ChatComposer { // immediately regardless of the popup selection. let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, _rest)) = parse_slash_name(first_line) - && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == name) + && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) + && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name) && let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { @@ -469,7 +473,8 @@ impl ChatComposer { self.textarea.set_text(""); return (InputResult::Submitted(expanded), true); } else { - let text = format!("/{} ", prompt.name); + let name = prompt.name.clone(); + let text = format!("/{PROMPTS_CMD_PREFIX}:{name} "); self.textarea.set_text(&text); self.textarea.set_cursor(self.textarea.text().len()); } @@ -1868,13 +1873,17 @@ mod tests { #[test] fn extract_args_supports_quoted_paths_single_arg() { - let args = extract_positional_args_for_prompt_line("/review \"docs/My File.md\"", "review"); + let args = extract_positional_args_for_prompt_line( + "/prompts:review \"docs/My File.md\"", + "review", + ); assert_eq!(args, vec!["docs/My File.md".to_string()]); } #[test] fn extract_args_supports_mixed_quoted_and_unquoted() { - let args = extract_positional_args_for_prompt_line("/cmd \"with spaces\" simple", "cmd"); + let args = + extract_positional_args_for_prompt_line("/prompts:cmd \"with spaces\" simple", "cmd"); assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]); } @@ -2349,7 +2358,10 @@ mod tests { type_chars_humanlike( &mut composer, - &['/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't'], + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', + 'p', 't', + ], ); let (result, _needs_redraw) = @@ -2386,8 +2398,8 @@ mod tests { type_chars_humanlike( &mut composer, &[ - '/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't', ' ', 'f', 'o', 'o', ' ', 'b', - 'a', 'r', + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', + 'p', 't', ' ', 'f', 'o', 'o', ' ', 'b', 'a', 'r', ], ); let (result, _needs_redraw) = @@ -2419,14 +2431,17 @@ mod tests { argument_hint: None, }]); - type_chars_humanlike(&mut composer, &['/', 'p']); + type_chars_humanlike( + &mut composer, + &['/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p'], + ); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); // With no args typed, selecting the prompt inserts the command template // and does not submit immediately. assert_eq!(InputResult::None, result); - assert_eq!("/p ", composer.textarea.text()); + assert_eq!("/prompts:p ", composer.textarea.text()); } #[test] @@ -2452,7 +2467,12 @@ mod tests { argument_hint: None, }]); - type_chars_humanlike(&mut composer, &['/', 'p', 'r', 'i', 'c', 'e', ' ', 'x']); + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p', 'r', 'i', 'c', 'e', ' ', 'x', + ], + ); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -2487,7 +2507,8 @@ mod tests { type_chars_humanlike( &mut composer, &[ - '/', 'r', 'e', 'p', 'e', 'a', 't', ' ', 'o', 'n', 'e', ' ', 't', 'w', 'o', + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'r', 'e', 'p', 'e', 'a', 't', ' ', + 'o', 'n', 'e', ' ', 't', 'w', 'o', ], ); let (result, _needs_redraw) = diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index cda0a8464d..c493958272 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -10,6 +10,7 @@ use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; use codex_common::fuzzy_match::fuzzy_match; use codex_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; /// A selectable item in the popup: either a built-in command or a user prompt. @@ -120,8 +121,12 @@ impl CommandPopup { out.push((CommandItem::Builtin(*cmd), Some(indices), score)); } } + // Support both search styles: + // - Typing "name" should surface "/prompts:name" results. + // - Typing "prompts:name" should also work. for (idx, p) in self.prompts.iter().enumerate() { - if let Some((indices, score)) = fuzzy_match(&p.name, filter) { + let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name); + if let Some((indices, score)) = fuzzy_match(&display, filter) { out.push((CommandItem::UserPrompt(idx), Some(indices), score)); } } @@ -158,7 +163,7 @@ impl CommandPopup { (format!("/{}", cmd.command()), cmd.description().to_string()) } CommandItem::UserPrompt(i) => ( - format!("/{}", self.prompts[i].name), + format!("/{PROMPTS_CMD_PREFIX}:{}", self.prompts[i].name), "send saved prompt".to_string(), ), }; diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index a0c67b6e29..9c36a6dc5f 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -1,4 +1,5 @@ use codex_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use shlex::Shlex; /// Parse a first-line slash command of the form `/name `. @@ -26,9 +27,9 @@ pub fn parse_positional_args(rest: &str) -> Vec { Shlex::new(rest).collect() } -/// Expands a message of the form `/name key=value …` using a matching saved prompt. +/// Expands a message of the form `/prompts:name key=value …` using a matching saved prompt. /// -/// If the text does not start with `/`, or if no prompt named `name` exists, +/// If the text does not start with `/prompts:`, or if no prompt named `name` exists, /// the function returns `Ok(None)`. On success it returns /// `Ok(Some(expanded))`; otherwise it returns a descriptive error. pub fn expand_custom_prompt( @@ -39,7 +40,12 @@ pub fn expand_custom_prompt( return Ok(None); }; - let prompt = match custom_prompts.iter().find(|p| p.name == name) { + // Only handle custom prompts when using the explicit prompts prefix with a colon. + let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return Ok(None); + }; + + let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) { Some(prompt) => prompt, None => return Ok(None), }; @@ -79,7 +85,11 @@ pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> let Some(rest) = trimmed.strip_prefix('/') else { return Vec::new(); }; - let mut parts = rest.splitn(2, char::is_whitespace); + // Require the explicit prompts prefix for custom prompt invocations. + let Some(after_prefix) = rest.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return Vec::new(); + }; + let mut parts = after_prefix.splitn(2, char::is_whitespace); let cmd = parts.next().unwrap_or(""); if cmd != prompt_name { return Vec::new(); From cd97b02e148c82a239f4d3466a275565878604ad Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Mon, 29 Sep 2025 16:59:23 -0700 Subject: [PATCH 2/2] fix comment --- codex-rs/tui/src/bottom_pane/prompt_args.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index 9c36a6dc5f..51c95d9998 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -27,7 +27,7 @@ pub fn parse_positional_args(rest: &str) -> Vec { Shlex::new(rest).collect() } -/// Expands a message of the form `/prompts:name key=value …` using a matching saved prompt. +/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt. /// /// If the text does not start with `/prompts:`, or if no prompt named `name` exists, /// the function returns `Ok(None)`. On success it returns