Skip to content

Commit f7da902

Browse files
authored
Custom prompts: passing arguments ($1..$9, $ARGUMENTS) + @ file picker + frontmatter hints (#3565)
Key features - Custom prompts accept arguments: $1..$9, $ARGUMENTS, and $$ (literal) - @ file picker in composer: type @ to fuzzy‑search and insert quoted paths - Frontmatter hints: optional description + argument-hint shown in palette (body stripped before send) Why - Make saved prompts reusable with runtime parameters. - Improve discoverability with concise, helpful hints in the slash popup. - Preserve privacy and approvals; no auto‑execution added. Details - Protocol: extend CustomPrompt with description, argument_hint (optional). - Core: parse minimal YAML‑style frontmatter at file top; strip it from the submitted body. - TUI: expand arguments; insert @ paths; render description/argument-hint or fallback excerpt. - Docs: prompts.md updated with frontmatter and argument examples. Tests - Frontmatter parsing (description/argument-hint extracted; body stripped). - Popup rows show description + argument-hint; excerpt fallback; builtin name collision. - Argument expansion for $1..$9, $ARGUMENTS, $$; quoted args and @ path insertion. Safety / Approvals - No changes to approvals or sandboxing; prompts do not auto‑run tools. Related - Closes #2890 - Related #3265 - Complements #3403
1 parent e857426 commit f7da902

File tree

5 files changed

+660
-16
lines changed

5 files changed

+660
-16
lines changed

codex-rs/core/src/custom_prompts.rs

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,88 @@ pub async fn discover_prompts_in_excluding(
6363
Ok(s) => s,
6464
Err(_) => continue,
6565
};
66+
let (description, argument_hint, body) = parse_frontmatter(&content);
6667
out.push(CustomPrompt {
6768
name,
6869
path,
69-
content,
70+
content: body,
71+
description,
72+
argument_hint,
7073
});
7174
}
7275
out.sort_by(|a, b| a.name.cmp(&b.name));
7376
out
7477
}
7578

79+
/// Parse optional YAML-like frontmatter at the beginning of `content`.
80+
/// Supported keys:
81+
/// - `description`: short description shown in the slash popup
82+
/// - `argument-hint` or `argument_hint`: brief hint string shown after the description
83+
/// Returns (description, argument_hint, body_without_frontmatter).
84+
fn parse_frontmatter(content: &str) -> (Option<String>, Option<String>, String) {
85+
let mut segments = content.split_inclusive('\n');
86+
let Some(first_segment) = segments.next() else {
87+
return (None, None, String::new());
88+
};
89+
let first_line = first_segment.trim_end_matches(['\r', '\n']);
90+
if first_line.trim() != "---" {
91+
return (None, None, content.to_string());
92+
}
93+
94+
let mut desc: Option<String> = None;
95+
let mut hint: Option<String> = None;
96+
let mut frontmatter_closed = false;
97+
let mut consumed = first_segment.len();
98+
99+
for segment in segments {
100+
let line = segment.trim_end_matches(['\r', '\n']);
101+
let trimmed = line.trim();
102+
103+
if trimmed == "---" {
104+
frontmatter_closed = true;
105+
consumed += segment.len();
106+
break;
107+
}
108+
109+
if trimmed.is_empty() || trimmed.starts_with('#') {
110+
consumed += segment.len();
111+
continue;
112+
}
113+
114+
if let Some((k, v)) = trimmed.split_once(':') {
115+
let key = k.trim().to_ascii_lowercase();
116+
let mut val = v.trim().to_string();
117+
if val.len() >= 2 {
118+
let bytes = val.as_bytes();
119+
let first = bytes[0];
120+
let last = bytes[bytes.len() - 1];
121+
if (first == b'\"' && last == b'\"') || (first == b'\'' && last == b'\'') {
122+
val = val[1..val.len().saturating_sub(1)].to_string();
123+
}
124+
}
125+
match key.as_str() {
126+
"description" => desc = Some(val),
127+
"argument-hint" | "argument_hint" => hint = Some(val),
128+
_ => {}
129+
}
130+
}
131+
132+
consumed += segment.len();
133+
}
134+
135+
if !frontmatter_closed {
136+
// Unterminated frontmatter: treat input as-is.
137+
return (None, None, content.to_string());
138+
}
139+
140+
let body = if consumed >= content.len() {
141+
String::new()
142+
} else {
143+
content[consumed..].to_string()
144+
};
145+
(desc, hint, body)
146+
}
147+
76148
#[cfg(test)]
77149
mod tests {
78150
use super::*;
@@ -124,4 +196,31 @@ mod tests {
124196
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
125197
assert_eq!(names, vec!["good"]);
126198
}
199+
200+
#[tokio::test]
201+
async fn parses_frontmatter_and_strips_from_body() {
202+
let tmp = tempdir().expect("create TempDir");
203+
let dir = tmp.path();
204+
let file = dir.join("withmeta.md");
205+
let text = "---\nname: ignored\ndescription: \"Quick review command\"\nargument-hint: \"[file] [priority]\"\n---\nActual body with $1 and $ARGUMENTS";
206+
fs::write(&file, text).unwrap();
207+
208+
let found = discover_prompts_in(dir).await;
209+
assert_eq!(found.len(), 1);
210+
let p = &found[0];
211+
assert_eq!(p.name, "withmeta");
212+
assert_eq!(p.description.as_deref(), Some("Quick review command"));
213+
assert_eq!(p.argument_hint.as_deref(), Some("[file] [priority]"));
214+
// Body should not include the frontmatter delimiters.
215+
assert_eq!(p.content, "Actual body with $1 and $ARGUMENTS");
216+
}
217+
218+
#[test]
219+
fn parse_frontmatter_preserves_body_newlines() {
220+
let content = "---\r\ndescription: \"Line endings\"\r\nargument_hint: \"[arg]\"\r\n---\r\nFirst line\r\nSecond line\r\n";
221+
let (desc, hint, body) = parse_frontmatter(content);
222+
assert_eq!(desc.as_deref(), Some("Line endings"));
223+
assert_eq!(hint.as_deref(), Some("[arg]"));
224+
assert_eq!(body, "First line\r\nSecond line\r\n");
225+
}
127226
}

codex-rs/protocol/src/custom_prompts.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,10 @@ pub struct CustomPrompt {
88
pub name: String,
99
pub path: PathBuf,
1010
pub content: String,
11+
// Optional short description shown in the slash popup, typically provided
12+
// via frontmatter in the prompt file.
13+
pub description: Option<String>,
14+
// Optional argument hint (e.g., "[file] [flags]") shown alongside the
15+
// description in the popup when available.
16+
pub argument_hint: Option<String>,
1117
}

0 commit comments

Comments
 (0)