Custom prompts begin with /prompts: (#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" />
This commit is contained in:
@@ -3,6 +3,12 @@ use serde::Serialize;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use ts_rs::TS;
|
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)]
|
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
|
||||||
pub struct CustomPrompt {
|
pub struct CustomPrompt {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ use crate::slash_command::SlashCommand;
|
|||||||
use crate::style::user_message_style;
|
use crate::style::user_message_style;
|
||||||
use crate::terminal_palette;
|
use crate::terminal_palette;
|
||||||
use codex_protocol::custom_prompts::CustomPrompt;
|
use codex_protocol::custom_prompts::CustomPrompt;
|
||||||
|
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||||
|
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
@@ -438,9 +439,11 @@ impl ChatComposer {
|
|||||||
let name = prompt.name.clone();
|
let name = prompt.name.clone();
|
||||||
let starts_with_cmd = first_line
|
let starts_with_cmd = first_line
|
||||||
.trim_start()
|
.trim_start()
|
||||||
.starts_with(format!("/{name}").as_str());
|
.starts_with(format!("/{PROMPTS_CMD_PREFIX}:{name}").as_str());
|
||||||
if !starts_with_cmd {
|
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() {
|
if !self.textarea.text().is_empty() {
|
||||||
cursor_target = Some(self.textarea.text().len());
|
cursor_target = Some(self.textarea.text().len());
|
||||||
@@ -464,7 +467,8 @@ impl ChatComposer {
|
|||||||
// immediately regardless of the popup selection.
|
// immediately regardless of the popup selection.
|
||||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||||
if let Some((name, _rest)) = parse_slash_name(first_line)
|
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) =
|
&& let Some(expanded) =
|
||||||
expand_if_numeric_with_positional_args(prompt, first_line)
|
expand_if_numeric_with_positional_args(prompt, first_line)
|
||||||
{
|
{
|
||||||
@@ -498,7 +502,8 @@ impl ChatComposer {
|
|||||||
self.textarea.set_text("");
|
self.textarea.set_text("");
|
||||||
return (InputResult::Submitted(expanded), true);
|
return (InputResult::Submitted(expanded), true);
|
||||||
} else {
|
} 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_text(&text);
|
||||||
self.textarea.set_cursor(self.textarea.text().len());
|
self.textarea.set_cursor(self.textarea.text().len());
|
||||||
}
|
}
|
||||||
@@ -2122,13 +2127,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_args_supports_quoted_paths_single_arg() {
|
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()]);
|
assert_eq!(args, vec!["docs/My File.md".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_args_supports_mixed_quoted_and_unquoted() {
|
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()]);
|
assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2603,7 +2612,10 @@ mod tests {
|
|||||||
|
|
||||||
type_chars_humanlike(
|
type_chars_humanlike(
|
||||||
&mut composer,
|
&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) =
|
let (result, _needs_redraw) =
|
||||||
@@ -2640,8 +2652,8 @@ mod tests {
|
|||||||
type_chars_humanlike(
|
type_chars_humanlike(
|
||||||
&mut composer,
|
&mut composer,
|
||||||
&[
|
&[
|
||||||
'/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't', ' ', 'f', 'o', 'o', ' ', 'b',
|
'/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm',
|
||||||
'a', 'r',
|
'p', 't', ' ', 'f', 'o', 'o', ' ', 'b', 'a', 'r',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
let (result, _needs_redraw) =
|
let (result, _needs_redraw) =
|
||||||
@@ -2673,14 +2685,17 @@ mod tests {
|
|||||||
argument_hint: None,
|
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) =
|
let (result, _needs_redraw) =
|
||||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
|
||||||
// With no args typed, selecting the prompt inserts the command template
|
// With no args typed, selecting the prompt inserts the command template
|
||||||
// and does not submit immediately.
|
// and does not submit immediately.
|
||||||
assert_eq!(InputResult::None, result);
|
assert_eq!(InputResult::None, result);
|
||||||
assert_eq!("/p ", composer.textarea.text());
|
assert_eq!("/prompts:p ", composer.textarea.text());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2706,7 +2721,12 @@ mod tests {
|
|||||||
argument_hint: None,
|
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) =
|
let (result, _needs_redraw) =
|
||||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
|
|
||||||
@@ -2741,7 +2761,8 @@ mod tests {
|
|||||||
type_chars_humanlike(
|
type_chars_humanlike(
|
||||||
&mut composer,
|
&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) =
|
let (result, _needs_redraw) =
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use crate::slash_command::SlashCommand;
|
|||||||
use crate::slash_command::built_in_slash_commands;
|
use crate::slash_command::built_in_slash_commands;
|
||||||
use codex_common::fuzzy_match::fuzzy_match;
|
use codex_common::fuzzy_match::fuzzy_match;
|
||||||
use codex_protocol::custom_prompts::CustomPrompt;
|
use codex_protocol::custom_prompts::CustomPrompt;
|
||||||
|
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
/// A selectable item in the popup: either a built-in command or a user prompt.
|
/// 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));
|
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() {
|
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));
|
out.push((CommandItem::UserPrompt(idx), Some(indices), score));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,7 +163,7 @@ impl CommandPopup {
|
|||||||
(format!("/{}", cmd.command()), cmd.description().to_string())
|
(format!("/{}", cmd.command()), cmd.description().to_string())
|
||||||
}
|
}
|
||||||
CommandItem::UserPrompt(i) => (
|
CommandItem::UserPrompt(i) => (
|
||||||
format!("/{}", self.prompts[i].name),
|
format!("/{PROMPTS_CMD_PREFIX}:{}", self.prompts[i].name),
|
||||||
"send saved prompt".to_string(),
|
"send saved prompt".to_string(),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use codex_protocol::custom_prompts::CustomPrompt;
|
use codex_protocol::custom_prompts::CustomPrompt;
|
||||||
|
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||||
use shlex::Shlex;
|
use shlex::Shlex;
|
||||||
|
|
||||||
/// Parse a first-line slash command of the form `/name <rest>`.
|
/// Parse a first-line slash command of the form `/name <rest>`.
|
||||||
@@ -26,9 +27,9 @@ pub fn parse_positional_args(rest: &str) -> Vec<String> {
|
|||||||
Shlex::new(rest).collect()
|
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
|
/// the function returns `Ok(None)`. On success it returns
|
||||||
/// `Ok(Some(expanded))`; otherwise it returns a descriptive error.
|
/// `Ok(Some(expanded))`; otherwise it returns a descriptive error.
|
||||||
pub fn expand_custom_prompt(
|
pub fn expand_custom_prompt(
|
||||||
@@ -39,7 +40,12 @@ pub fn expand_custom_prompt(
|
|||||||
return Ok(None);
|
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,
|
Some(prompt) => prompt,
|
||||||
None => return Ok(None),
|
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 {
|
let Some(rest) = trimmed.strip_prefix('/') else {
|
||||||
return Vec::new();
|
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("");
|
let cmd = parts.next().unwrap_or("");
|
||||||
if cmd != prompt_name {
|
if cmd != prompt_name {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user