Skip to content

Commit bf76258

Browse files
Custom prompts begin with /prompts: (openai#4476)
<img width="608" height="354" alt="Screenshot 2025-09-29 at 4 41 08 PM" src="https://github.com/user-attachments/assets/162508eb-c1ac-4bc0-95f2-5e23cb4ae428" />
1 parent c64da4f commit bf76258

File tree

4 files changed

+61
-19
lines changed

4 files changed

+61
-19
lines changed

codex-rs/protocol/src/custom_prompts.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ use serde::Serialize;
33
use std::path::PathBuf;
44
use ts_rs::TS;
55

6+
/// Base namespace for custom prompt slash commands (without trailing colon).
7+
/// Example usage forms constructed in code:
8+
/// - Command token after '/': `"{PROMPTS_CMD_PREFIX}:name"`
9+
/// - Full slash prefix: `"/{PROMPTS_CMD_PREFIX}:"`
10+
pub const PROMPTS_CMD_PREFIX: &str = "prompts";
11+
612
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
713
pub struct CustomPrompt {
814
pub name: String,

codex-rs/tui/src/bottom_pane/chat_composer.rs

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ use crate::slash_command::SlashCommand;
3636
use crate::style::user_message_style;
3737
use crate::terminal_palette;
3838
use codex_protocol::custom_prompts::CustomPrompt;
39+
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
3940

4041
use crate::app_event::AppEvent;
4142
use crate::app_event_sender::AppEventSender;
@@ -438,9 +439,11 @@ impl ChatComposer {
438439
let name = prompt.name.clone();
439440
let starts_with_cmd = first_line
440441
.trim_start()
441-
.starts_with(format!("/{name}").as_str());
442+
.starts_with(format!("/{PROMPTS_CMD_PREFIX}:{name}").as_str());
442443
if !starts_with_cmd {
443-
self.textarea.set_text(format!("/{name} ").as_str());
444+
self.textarea.set_text(
445+
format!("/{PROMPTS_CMD_PREFIX}:{name} ").as_str(),
446+
);
444447
}
445448
if !self.textarea.text().is_empty() {
446449
cursor_target = Some(self.textarea.text().len());
@@ -464,7 +467,8 @@ impl ChatComposer {
464467
// immediately regardless of the popup selection.
465468
let first_line = self.textarea.text().lines().next().unwrap_or("");
466469
if let Some((name, _rest)) = parse_slash_name(first_line)
467-
&& let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == name)
470+
&& let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:"))
471+
&& let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name)
468472
&& let Some(expanded) =
469473
expand_if_numeric_with_positional_args(prompt, first_line)
470474
{
@@ -498,7 +502,8 @@ impl ChatComposer {
498502
self.textarea.set_text("");
499503
return (InputResult::Submitted(expanded), true);
500504
} else {
501-
let text = format!("/{} ", prompt.name);
505+
let name = prompt.name.clone();
506+
let text = format!("/{PROMPTS_CMD_PREFIX}:{name} ");
502507
self.textarea.set_text(&text);
503508
self.textarea.set_cursor(self.textarea.text().len());
504509
}
@@ -2122,13 +2127,17 @@ mod tests {
21222127

21232128
#[test]
21242129
fn extract_args_supports_quoted_paths_single_arg() {
2125-
let args = extract_positional_args_for_prompt_line("/review \"docs/My File.md\"", "review");
2130+
let args = extract_positional_args_for_prompt_line(
2131+
"/prompts:review \"docs/My File.md\"",
2132+
"review",
2133+
);
21262134
assert_eq!(args, vec!["docs/My File.md".to_string()]);
21272135
}
21282136

21292137
#[test]
21302138
fn extract_args_supports_mixed_quoted_and_unquoted() {
2131-
let args = extract_positional_args_for_prompt_line("/cmd \"with spaces\" simple", "cmd");
2139+
let args =
2140+
extract_positional_args_for_prompt_line("/prompts:cmd \"with spaces\" simple", "cmd");
21322141
assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]);
21332142
}
21342143

@@ -2603,7 +2612,10 @@ mod tests {
26032612

26042613
type_chars_humanlike(
26052614
&mut composer,
2606-
&['/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't'],
2615+
&[
2616+
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm',
2617+
'p', 't',
2618+
],
26072619
);
26082620

26092621
let (result, _needs_redraw) =
@@ -2640,8 +2652,8 @@ mod tests {
26402652
type_chars_humanlike(
26412653
&mut composer,
26422654
&[
2643-
'/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't', ' ', 'f', 'o', 'o', ' ', 'b',
2644-
'a', 'r',
2655+
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm',
2656+
'p', 't', ' ', 'f', 'o', 'o', ' ', 'b', 'a', 'r',
26452657
],
26462658
);
26472659
let (result, _needs_redraw) =
@@ -2673,14 +2685,17 @@ mod tests {
26732685
argument_hint: None,
26742686
}]);
26752687

2676-
type_chars_humanlike(&mut composer, &['/', 'p']);
2688+
type_chars_humanlike(
2689+
&mut composer,
2690+
&['/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p'],
2691+
);
26772692
let (result, _needs_redraw) =
26782693
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
26792694

26802695
// With no args typed, selecting the prompt inserts the command template
26812696
// and does not submit immediately.
26822697
assert_eq!(InputResult::None, result);
2683-
assert_eq!("/p ", composer.textarea.text());
2698+
assert_eq!("/prompts:p ", composer.textarea.text());
26842699
}
26852700

26862701
#[test]
@@ -2706,7 +2721,12 @@ mod tests {
27062721
argument_hint: None,
27072722
}]);
27082723

2709-
type_chars_humanlike(&mut composer, &['/', 'p', 'r', 'i', 'c', 'e', ' ', 'x']);
2724+
type_chars_humanlike(
2725+
&mut composer,
2726+
&[
2727+
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p', 'r', 'i', 'c', 'e', ' ', 'x',
2728+
],
2729+
);
27102730
let (result, _needs_redraw) =
27112731
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
27122732

@@ -2741,7 +2761,8 @@ mod tests {
27412761
type_chars_humanlike(
27422762
&mut composer,
27432763
&[
2744-
'/', 'r', 'e', 'p', 'e', 'a', 't', ' ', 'o', 'n', 'e', ' ', 't', 'w', 'o',
2764+
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'r', 'e', 'p', 'e', 'a', 't', ' ',
2765+
'o', 'n', 'e', ' ', 't', 'w', 'o',
27452766
],
27462767
);
27472768
let (result, _needs_redraw) =

codex-rs/tui/src/bottom_pane/command_popup.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::slash_command::SlashCommand;
1010
use crate::slash_command::built_in_slash_commands;
1111
use codex_common::fuzzy_match::fuzzy_match;
1212
use codex_protocol::custom_prompts::CustomPrompt;
13+
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
1314
use std::collections::HashSet;
1415

1516
/// A selectable item in the popup: either a built-in command or a user prompt.
@@ -120,8 +121,12 @@ impl CommandPopup {
120121
out.push((CommandItem::Builtin(*cmd), Some(indices), score));
121122
}
122123
}
124+
// Support both search styles:
125+
// - Typing "name" should surface "/prompts:name" results.
126+
// - Typing "prompts:name" should also work.
123127
for (idx, p) in self.prompts.iter().enumerate() {
124-
if let Some((indices, score)) = fuzzy_match(&p.name, filter) {
128+
let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name);
129+
if let Some((indices, score)) = fuzzy_match(&display, filter) {
125130
out.push((CommandItem::UserPrompt(idx), Some(indices), score));
126131
}
127132
}
@@ -158,7 +163,7 @@ impl CommandPopup {
158163
(format!("/{}", cmd.command()), cmd.description().to_string())
159164
}
160165
CommandItem::UserPrompt(i) => (
161-
format!("/{}", self.prompts[i].name),
166+
format!("/{PROMPTS_CMD_PREFIX}:{}", self.prompts[i].name),
162167
"send saved prompt".to_string(),
163168
),
164169
};

codex-rs/tui/src/bottom_pane/prompt_args.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use codex_protocol::custom_prompts::CustomPrompt;
2+
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
23
use shlex::Shlex;
34

45
/// Parse a first-line slash command of the form `/name <rest>`.
@@ -26,9 +27,9 @@ pub fn parse_positional_args(rest: &str) -> Vec<String> {
2627
Shlex::new(rest).collect()
2728
}
2829

29-
/// Expands a message of the form `/name key=value …` using a matching saved prompt.
30+
/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt.
3031
///
31-
/// If the text does not start with `/`, or if no prompt named `name` exists,
32+
/// If the text does not start with `/prompts:`, or if no prompt named `name` exists,
3233
/// the function returns `Ok(None)`. On success it returns
3334
/// `Ok(Some(expanded))`; otherwise it returns a descriptive error.
3435
pub fn expand_custom_prompt(
@@ -39,7 +40,12 @@ pub fn expand_custom_prompt(
3940
return Ok(None);
4041
};
4142

42-
let prompt = match custom_prompts.iter().find(|p| p.name == name) {
43+
// Only handle custom prompts when using the explicit prompts prefix with a colon.
44+
let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
45+
return Ok(None);
46+
};
47+
48+
let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) {
4349
Some(prompt) => prompt,
4450
None => return Ok(None),
4551
};
@@ -79,7 +85,11 @@ pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) ->
7985
let Some(rest) = trimmed.strip_prefix('/') else {
8086
return Vec::new();
8187
};
82-
let mut parts = rest.splitn(2, char::is_whitespace);
88+
// Require the explicit prompts prefix for custom prompt invocations.
89+
let Some(after_prefix) = rest.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
90+
return Vec::new();
91+
};
92+
let mut parts = after_prefix.splitn(2, char::is_whitespace);
8393
let cmd = parts.next().unwrap_or("");
8494
if cmd != prompt_name {
8595
return Vec::new();

0 commit comments

Comments
 (0)