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
This commit is contained in:
Evan David
2025-09-24 21:23:38 -07:00
committed by GitHub
parent e85742635f
commit f7da902be3
5 changed files with 660 additions and 16 deletions
+100 -1
View File
@@ -63,16 +63,88 @@ pub async fn discover_prompts_in_excluding(
Ok(s) => s,
Err(_) => continue,
};
let (description, argument_hint, body) = parse_frontmatter(&content);
out.push(CustomPrompt {
name,
path,
content,
content: body,
description,
argument_hint,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
/// Parse optional YAML-like frontmatter at the beginning of `content`.
/// Supported keys:
/// - `description`: short description shown in the slash popup
/// - `argument-hint` or `argument_hint`: brief hint string shown after the description
/// Returns (description, argument_hint, body_without_frontmatter).
fn parse_frontmatter(content: &str) -> (Option<String>, Option<String>, String) {
let mut segments = content.split_inclusive('\n');
let Some(first_segment) = segments.next() else {
return (None, None, String::new());
};
let first_line = first_segment.trim_end_matches(['\r', '\n']);
if first_line.trim() != "---" {
return (None, None, content.to_string());
}
let mut desc: Option<String> = None;
let mut hint: Option<String> = None;
let mut frontmatter_closed = false;
let mut consumed = first_segment.len();
for segment in segments {
let line = segment.trim_end_matches(['\r', '\n']);
let trimmed = line.trim();
if trimmed == "---" {
frontmatter_closed = true;
consumed += segment.len();
break;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
consumed += segment.len();
continue;
}
if let Some((k, v)) = trimmed.split_once(':') {
let key = k.trim().to_ascii_lowercase();
let mut val = v.trim().to_string();
if val.len() >= 2 {
let bytes = val.as_bytes();
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'\"' && last == b'\"') || (first == b'\'' && last == b'\'') {
val = val[1..val.len().saturating_sub(1)].to_string();
}
}
match key.as_str() {
"description" => desc = Some(val),
"argument-hint" | "argument_hint" => hint = Some(val),
_ => {}
}
}
consumed += segment.len();
}
if !frontmatter_closed {
// Unterminated frontmatter: treat input as-is.
return (None, None, content.to_string());
}
let body = if consumed >= content.len() {
String::new()
} else {
content[consumed..].to_string()
};
(desc, hint, body)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -124,4 +196,31 @@ mod tests {
let names: Vec<String> = found.into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["good"]);
}
#[tokio::test]
async fn parses_frontmatter_and_strips_from_body() {
let tmp = tempdir().expect("create TempDir");
let dir = tmp.path();
let file = dir.join("withmeta.md");
let text = "---\nname: ignored\ndescription: \"Quick review command\"\nargument-hint: \"[file] [priority]\"\n---\nActual body with $1 and $ARGUMENTS";
fs::write(&file, text).unwrap();
let found = discover_prompts_in(dir).await;
assert_eq!(found.len(), 1);
let p = &found[0];
assert_eq!(p.name, "withmeta");
assert_eq!(p.description.as_deref(), Some("Quick review command"));
assert_eq!(p.argument_hint.as_deref(), Some("[file] [priority]"));
// Body should not include the frontmatter delimiters.
assert_eq!(p.content, "Actual body with $1 and $ARGUMENTS");
}
#[test]
fn parse_frontmatter_preserves_body_newlines() {
let content = "---\r\ndescription: \"Line endings\"\r\nargument_hint: \"[arg]\"\r\n---\r\nFirst line\r\nSecond line\r\n";
let (desc, hint, body) = parse_frontmatter(content);
assert_eq!(desc.as_deref(), Some("Line endings"));
assert_eq!(hint.as_deref(), Some("[arg]"));
assert_eq!(body, "First line\r\nSecond line\r\n");
}
}
+6
View File
@@ -8,4 +8,10 @@ pub struct CustomPrompt {
pub name: String,
pub path: PathBuf,
pub content: String,
// Optional short description shown in the slash popup, typically provided
// via frontmatter in the prompt file.
pub description: Option<String>,
// Optional argument hint (e.g., "[file] [flags]") shown alongside the
// description in the popup when available.
pub argument_hint: Option<String>,
}
+323 -11
View File
@@ -416,15 +416,24 @@ impl ChatComposer {
..
} => {
if let Some(sel) = popup.selected_item() {
// Capture the first line before clearing so we can parse arguments.
let first_line = self
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.to_string();
// Capture any needed data from popup before clearing it.
let (prompt_name, prompt_content) = match sel {
CommandItem::UserPrompt(idx) => (
popup.prompt_name(idx).map(|s| s.to_string()),
popup.prompt_content(idx).map(|s| s.to_string()),
),
_ => (None, None),
};
// Clear textarea so no residual text remains.
self.textarea.set_text("");
// Capture any needed data from popup before clearing it.
let prompt_content = match sel {
CommandItem::UserPrompt(idx) => {
popup.prompt_content(idx).map(str::to_string)
}
_ => None,
};
// Hide popup since an action has been dispatched.
self.active_popup = ActivePopup::None;
@@ -434,7 +443,13 @@ impl ChatComposer {
}
CommandItem::UserPrompt(_) => {
if let Some(contents) = prompt_content {
return (InputResult::Submitted(contents), true);
// Extract arguments from the first line after "/<prompt_name>".
let args: Vec<String> = prompt_name
.as_deref()
.map(|name| extract_args_for_prompt(&first_line, name))
.unwrap_or_default();
let expanded = expand_prompt_with_args(&contents, &args);
return (InputResult::Submitted(expanded), true);
}
return (InputResult::None, true);
}
@@ -446,6 +461,13 @@ impl ChatComposer {
input => self.handle_input_basic(input),
}
}
/// Extract positional arguments from the first line of the composer text for a
/// selected prompt name. Given a line like "/name foo bar", returns ["foo", "bar"].
/// If the command prefix does not match the prompt name, returns empty.
fn _extract_args_for_prompt_test_hook(line: &str, prompt_name: &str) -> Vec<String> {
extract_args_for_prompt(line, prompt_name)
}
#[inline]
fn clamp_to_char_boundary(text: &str, pos: usize) -> usize {
let mut p = pos.min(text.len());
@@ -710,16 +732,26 @@ impl ChatComposer {
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
// If the path contains whitespace, wrap it in double quotes so the
// local prompt arg parser treats it as a single argument. Avoid adding
// quotes when the path already contains one to keep behavior simple.
let needs_quotes = path.chars().any(|c| c.is_whitespace());
let inserted = if needs_quotes && !path.contains('"') {
format!("\"{path}\"")
} else {
path.to_string()
};
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
let mut new_text =
String::with_capacity(text.len() - (end_idx - start_idx) + path.len() + 1);
String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1);
new_text.push_str(&text[..start_idx]);
new_text.push_str(path);
new_text.push_str(&inserted);
new_text.push(' ');
new_text.push_str(&text[end_idx..]);
self.textarea.set_text(&new_text);
let new_cursor = start_idx.saturating_add(path.len()).saturating_add(1);
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
self.textarea.set_cursor(new_cursor);
}
@@ -1147,6 +1179,22 @@ impl ChatComposer {
fn sync_command_popup(&mut self) {
let first_line = self.textarea.text().lines().next().unwrap_or("");
let input_starts_with_slash = first_line.starts_with('/');
// If the cursor is currently positioned within an `@token`, prefer the
// file-search popup over the slash popup so users can insert a file
// path as an argument to the command (e.g., "/review @docs/...").
//
// We accomplish this by hiding the slash popup here and returning
// early. The caller will then invoke `sync_file_search_popup()` which
// activates the file popup. This keeps the logic localized and avoids
// rendering two popups at once while still allowing quick toggling
// between contexts as the cursor moves.
if Self::current_at_token(&self.textarea).is_some() {
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.active_popup = ActivePopup::None;
}
return;
}
match &mut self.active_popup {
ActivePopup::Command(popup) => {
if input_starts_with_slash {
@@ -1231,6 +1279,117 @@ impl ChatComposer {
}
}
// --- Helper functions for local prompt argument expansion ---
/// Extract arguments from a command first line like "/name arg1 arg2".
fn extract_args_for_prompt(line: &str, prompt_name: &str) -> Vec<String> {
let trimmed = line.trim_start();
let Some(rest) = trimmed.strip_prefix('/') else {
return Vec::new();
};
let mut parts = rest.splitn(2, char::is_whitespace);
let cmd = parts.next().unwrap_or("");
if cmd != prompt_name {
return Vec::new();
}
let args_str = parts.next().unwrap_or("").trim();
if args_str.is_empty() {
return Vec::new();
}
parse_args_simple_quotes(args_str)
}
/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`.
/// - `$1..$9` map to positional arguments (missing indices => empty string).
/// - `$ARGUMENTS` is all args joined by a single space.
/// - `$$` is preserved as literal `$$`.
fn expand_prompt_with_args(content: &str, args: &[String]) -> String {
let mut out = String::with_capacity(content.len());
let bytes = content.as_bytes();
let mut cached_joined_args: Option<String> = None;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'$' {
if i + 1 < bytes.len() {
let b1 = bytes[i + 1];
// Preserve $$
if b1 == b'$' {
out.push('$');
out.push('$');
i += 2;
continue;
}
// $1..$9
if (b'1'..=b'9').contains(&b1) {
let idx = (b1 - b'1') as usize;
if let Some(val) = args.get(idx) {
out.push_str(val);
}
i += 2;
continue;
}
}
// $ARGUMENTS
if content[i + 1..].starts_with("ARGUMENTS") {
if !args.is_empty() {
let joined = cached_joined_args.get_or_insert_with(|| args.join(" "));
out.push_str(joined);
}
i += 1 + "ARGUMENTS".len();
continue;
}
// Fallback: emit '$'
out.push('$');
i += 1;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
/// Minimal, predictable argument parser used for local prompt args.
///
/// Rules:
/// - Split on ASCII whitespace when outside quotes.
/// - Double quotes ("...") group text and allow spaces inside.
/// - Supports a basic escape for \" and \\\n/// - Unterminated quotes consume until end of line.
fn parse_args_simple_quotes(s: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut it = s.chars().peekable();
let mut in_quotes = false;
while let Some(ch) = it.next() {
match ch {
'"' => in_quotes = !in_quotes,
'\\' if in_quotes => match it.peek().copied() {
Some('"') => {
cur.push('"');
it.next();
}
Some('\\') => {
cur.push('\\');
it.next();
}
_ => cur.push('\\'),
},
c if c.is_whitespace() && !in_quotes => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
impl WidgetRef for ChatComposer {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let (popup_constraint, hint_spacing) = match &self.active_popup {
@@ -1868,6 +2027,22 @@ mod tests {
assert!(composer.textarea.is_empty(), "composer should be cleared");
}
#[test]
fn extract_args_supports_quoted_paths_single_arg() {
let args = ChatComposer::_extract_args_for_prompt_test_hook(
"/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 =
ChatComposer::_extract_args_for_prompt_test_hook("/cmd \"with spaces\" simple", "cmd");
assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]);
}
#[test]
fn slash_tab_completion_moves_cursor_to_end() {
use crossterm::event::KeyCode;
@@ -2333,6 +2508,8 @@ mod tests {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: prompt_text.to_string(),
description: None,
argument_hint: None,
}]);
type_chars_humanlike(
@@ -2346,6 +2523,141 @@ mod tests {
assert_eq!(InputResult::Submitted(prompt_text.to_string()), result);
}
#[test]
fn selecting_custom_prompt_with_args_expands_placeholders() {
// Support $1..$9 and $ARGUMENTS in prompt content.
let prompt_text = "Header: $1\nArgs: $ARGUMENTS\nNinth: $9\n";
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "my-prompt".to_string(),
path: "/tmp/my-prompt.md".to_string().into(),
content: prompt_text.to_string(),
description: None,
argument_hint: None,
}]);
// Type the slash command with two args and hit Enter to submit.
type_chars_humanlike(
&mut composer,
&[
'/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't', ' ', 'f', 'o', 'o', ' ', 'b',
'a', 'r',
],
);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string();
assert_eq!(InputResult::Submitted(expected), result);
}
#[test]
fn selecting_custom_prompt_with_no_args_expands_to_empty() {
let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]";
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "p".to_string(),
path: "/tmp/p.md".to_string().into(),
content: prompt_text.to_string(),
description: None,
argument_hint: None,
}]);
type_chars_humanlike(&mut composer, &['/', 'p']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::Submitted("X: Y: All:[]".to_string()), result);
}
#[test]
fn selecting_custom_prompt_preserves_literal_dollar_dollar() {
// '$$' should remain untouched.
let prompt_text = "Cost: $$ and first: $1";
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "price".to_string(),
path: "/tmp/price.md".to_string().into(),
content: prompt_text.to_string(),
description: None,
argument_hint: None,
}]);
type_chars_humanlike(&mut composer, &['/', 'p', 'r', 'i', 'c', 'e', ' ', 'x']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
InputResult::Submitted("Cost: $$ and first: x".to_string()),
result
);
}
#[test]
fn selecting_custom_prompt_reuses_cached_arguments_join() {
let prompt_text = "First: $ARGUMENTS\nSecond: $ARGUMENTS";
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_custom_prompts(vec![CustomPrompt {
name: "repeat".to_string(),
path: "/tmp/repeat.md".to_string().into(),
content: prompt_text.to_string(),
description: None,
argument_hint: None,
}]);
type_chars_humanlike(
&mut composer,
&[
'/', 'r', 'e', 'p', 'e', 'a', 't', ' ', 'o', 'n', 'e', ' ', 't', 'w', 'o',
],
);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let expected = "First: one two\nSecond: one two".to_string();
assert_eq!(InputResult::Submitted(expected), result);
}
#[test]
fn burst_paste_fast_small_buffers_and_flushes_on_stop() {
use crossterm::event::KeyCode;
+167 -4
View File
@@ -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;
// no additional imports
use std::collections::HashSet;
/// A selectable item in the popup: either a built-in command or a user prompt.
@@ -96,6 +97,7 @@ impl CommandPopup {
/// Accounts for wrapped descriptions so that long tooltips don't overflow.
pub(crate) fn calculate_required_height(&self, width: u16) -> u16 {
use super::selection_popup_common::measure_rows_height;
let rows = self.rows_from_matches(self.filtered());
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width)
@@ -161,10 +163,13 @@ impl CommandPopup {
CommandItem::Builtin(cmd) => {
(format!("/{}", cmd.command()), cmd.description().to_string())
}
CommandItem::UserPrompt(i) => (
format!("/{}", self.prompts[i].name),
"send saved prompt".to_string(),
),
CommandItem::UserPrompt(i) => {
let prompt = &self.prompts[i];
(
format!("/{}", prompt.name),
build_prompt_row_description(prompt),
)
}
};
GenericDisplayRow {
name,
@@ -215,6 +220,77 @@ impl WidgetRef for CommandPopup {
}
}
/// Build the display description for a custom prompt row:
/// "<five-word excerpt> <1> <2> <3>"
/// - Excerpt comes from the first non-empty line in content, cleaned and
/// truncated to five words. Placeholders like $1..$9 and $ARGUMENTS are
/// stripped from the excerpt to avoid noise.
/// - Argument tokens show any referenced positional placeholders ($1..$9) in
/// ascending order as minimal "<n>" hints. `$ARGUMENTS` is intentionally
/// omitted here to keep the UI simple, per product guidance.
fn build_prompt_row_description(prompt: &CustomPrompt) -> String {
let base = if let Some(d) = &prompt.description {
description_excerpt(d)
} else {
five_word_excerpt(&prompt.content)
};
let base = base.unwrap_or_else(|| "send saved prompt".to_string());
if let Some(hint) = &prompt.argument_hint {
if hint.is_empty() {
base
} else {
format!("{base} {hint}")
}
} else {
base
}
}
fn description_excerpt(desc: &str) -> Option<String> {
let normalized = desc.replace("\\n", " ");
five_word_excerpt(&normalized)
}
/// Extract a five-word excerpt from the first non-empty line of `content`.
/// Cleans basic markdown/backticks and removes placeholder tokens.
fn five_word_excerpt(content: &str) -> Option<String> {
let line = content.lines().map(|l| l.trim()).find(|l| !l.is_empty())?;
// Strip simple markdown markers and placeholders from the excerpt source.
let mut cleaned = line.replace(['`', '*', '_'], "");
// Remove leading markdown header symbols (e.g., "# ").
if let Some(stripped) = cleaned.trim_start().strip_prefix('#') {
cleaned = stripped.trim_start_matches('#').trim_start().to_string();
}
// Remove placeholder occurrences from excerpt text.
for n in 1..=9 {
cleaned = cleaned.replace(&format!("${n}"), "");
}
cleaned = cleaned.replace("$ARGUMENTS", "");
// Remove a small set of common punctuation that can look odd mid-excerpt
// once placeholders are stripped (keep hyphens and slashes).
for ch in [',', ';', ':', '!', '?', '(', ')', '{', '}', '[', ']'] {
cleaned = cleaned.replace(ch, "");
}
// Collapse whitespace and split into words.
let words: Vec<&str> = cleaned.split_whitespace().collect();
if words.is_empty() {
return None;
}
let take = words.len().min(5);
let mut out = words[..take].join(" ");
if words.len() > 5 {
out.push('…');
}
Some(out)
}
// (no positional arg tokens in the popup)
#[cfg(test)]
mod tests {
use super::*;
@@ -276,11 +352,15 @@ mod tests {
name: "foo".to_string(),
path: "/tmp/foo.md".to_string().into(),
content: "hello from foo".to_string(),
description: None,
argument_hint: None,
},
CustomPrompt {
name: "bar".to_string(),
path: "/tmp/bar.md".to_string().into(),
content: "hello from bar".to_string(),
description: None,
argument_hint: None,
},
];
let popup = CommandPopup::new(prompts);
@@ -303,6 +383,8 @@ mod tests {
name: "init".to_string(),
path: "/tmp/init.md".to_string().into(),
content: "should be ignored".to_string(),
description: None,
argument_hint: None,
}]);
let items = popup.filtered_items();
let has_collision_prompt = items.into_iter().any(|it| match it {
@@ -314,4 +396,85 @@ mod tests {
"prompt with builtin name should be ignored"
);
}
#[test]
fn prompt_displays_excerpt_when_placeholders_present() {
let prompts = vec![CustomPrompt {
name: "with-args".to_string(),
path: "/tmp/with-args.md".into(),
content: "Header $1 and $3; rest: $ARGUMENTS".to_string(),
description: None,
argument_hint: None,
}];
let mut popup = CommandPopup::new(prompts);
// Filter so the prompt appears at the top and within visible rows.
popup.on_composer_text_change("/with-args".to_string());
// Render a buffer tall enough to show the selection row.
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
popup.render_ref(Rect::new(0, 0, 80, 10), &mut buf);
let screen = buffer_to_string(&buf);
// Expect only the excerpt (first five words without placeholders).
assert!(
screen.contains("Header and rest"),
"expected five-word excerpt; got:\n{screen}"
);
assert!(
screen.contains("/with-args"),
"expected command label; got:\n{screen}"
);
}
#[test]
fn prompt_uses_excerpt_when_no_placeholders_present() {
let prompts = vec![CustomPrompt {
name: "no-args".to_string(),
path: "/tmp/no-args.md".into(),
content: "plain content".to_string(),
description: None,
argument_hint: None,
}];
let mut popup = CommandPopup::new(prompts);
popup.on_composer_text_change("/no-args".to_string());
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
popup.render_ref(Rect::new(0, 0, 80, 10), &mut buf);
let screen = buffer_to_string(&buf);
assert!(
screen.contains("plain content"),
"expected excerpt fallback; got:\n{screen}"
);
}
#[test]
fn prompt_uses_frontmatter_description_and_argument_hint_when_present() {
let prompts = vec![CustomPrompt {
name: "review-pr".to_string(),
path: "/tmp/review-pr.md".into(),
content: "Summarize changes $1".to_string(),
description: Some("Review a PR with context".to_string()),
argument_hint: Some("[pr-number] [priority]".to_string()),
}];
let mut popup = CommandPopup::new(prompts);
popup.on_composer_text_change("/review-pr".to_string());
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
popup.render_ref(Rect::new(0, 0, 80, 10), &mut buf);
let screen = buffer_to_string(&buf);
assert!(screen.contains("/review-pr"));
assert!(screen.contains("Review a PR with context [pr-number] [priority]"));
}
fn buffer_to_string(buf: &Buffer) -> String {
let area = buf.area;
let mut s = String::new();
for y in 0..area.height {
for x in 0..area.width {
let cell = &buf[(x, y)];
s.push(cell.symbol().chars().next().unwrap_or(' '));
}
s.push('\n');
}
s
}
}