mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user