Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions codex-rs/protocol/src/custom_prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 34 additions & 13 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand All @@ -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)
{
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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()]);
}

Expand Down Expand Up @@ -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) =
Expand Down Expand Up @@ -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) =
Expand Down Expand Up @@ -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]
Expand All @@ -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));

Expand Down Expand Up @@ -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) =
Expand Down
9 changes: 7 additions & 2 deletions codex-rs/tui/src/bottom_pane/command_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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));
}
}
Expand Down Expand Up @@ -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(),
),
};
Expand Down
18 changes: 14 additions & 4 deletions codex-rs/tui/src/bottom_pane/prompt_args.rs
Original file line number Diff line number Diff line change
@@ -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 <rest>`.
Expand Down Expand Up @@ -26,9 +27,9 @@ pub fn parse_positional_args(rest: &str) -> Vec<String> {
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 [value] [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(
Expand All @@ -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),
};
Expand Down Expand Up @@ -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();
Expand Down
Loading