From 80799b722c06ebf356c6be24ad36206db53e7a3f Mon Sep 17 00:00:00 2001 From: Evan David <57571858+evandavid1@users.noreply.github.com> Date: Sat, 13 Sep 2025 21:40:33 -0700 Subject: [PATCH] tui/core: local prompts arguments ($1..$9, $ARGUMENTS, $$), @ file picker; frontmatter hints in palette Protocol: CustomPrompt carries description/argument_hint. Core parses minimal frontmatter and strips it from body. TUI shows hints in slash popup; falls back to five-word excerpt. Docs: prompts.md examples. --- codex-rs/core/src/custom_prompts.rs | 101 +++++- codex-rs/protocol/src/custom_prompts.rs | 6 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 334 +++++++++++++++++- codex-rs/tui/src/bottom_pane/command_popup.rs | 171 ++++++++- docs/prompts.md | 64 ++++ 5 files changed, 660 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/custom_prompts.rs b/codex-rs/core/src/custom_prompts.rs index 357abef55b..d92c683902 100644 --- a/codex-rs/core/src/custom_prompts.rs +++ b/codex-rs/core/src/custom_prompts.rs @@ -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, Option, 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 = None; + let mut hint: Option = 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 = 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"); + } } diff --git a/codex-rs/protocol/src/custom_prompts.rs b/codex-rs/protocol/src/custom_prompts.rs index be402051b5..f7f7961077 100644 --- a/codex-rs/protocol/src/custom_prompts.rs +++ b/codex-rs/protocol/src/custom_prompts.rs @@ -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, + // Optional argument hint (e.g., "[file] [flags]") shown alongside the + // description in the popup when available. + pub argument_hint: Option, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 65203b5193..9e3a32af95 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -416,15 +416,24 @@ impl ChatComposer { .. } => { if let Some(sel) = popup.selected_item() { - // Clear textarea so no residual text remains. - self.textarea.set_text(""); + // 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_content = match sel { - CommandItem::UserPrompt(idx) => { - popup.prompt_content(idx).map(str::to_string) - } - _ => None, + 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(""); // 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 "/". + let args: Vec = 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 { + 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 { + 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 = 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 { + let mut out: Vec = 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::(); + 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::(); + 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::(); + 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::(); + 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; diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index a0933f4ae6..2064d6b5cd 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -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: +/// " <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 "" 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 { + 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 { + 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 + } } diff --git a/docs/prompts.md b/docs/prompts.md index b98240d2ad..7d54506734 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -6,6 +6,12 @@ Save frequently used prompts as Markdown files and reuse them quickly from the s - File type: Only Markdown files with the `.md` extension are recognized. - Name: The filename without the `.md` extension becomes the slash entry. For a file named `my-prompt.md`, type `/my-prompt`. - Content: The file contents are sent as your message when you select the item in the slash popup and press Enter. +- Arguments: Local prompts support placeholders in their content: + - `$1..$9` expand to the first nine positional arguments typed after the slash name + - `$ARGUMENTS` expands to all arguments joined by a single space + - `$$` is preserved literally + - Quoted args: Wrap a single argument in double quotes to include spaces, e.g. `/review "docs/My File.md"`. + - File picker: While typing a slash command, type `@` to open the file picker and fuzzy‑search files under the current working directory. Selecting a file inserts its path at the cursor; if it contains spaces it is auto‑quoted. - How to use: - Start a new session (Codex loads custom prompts on session start). - In the composer, type `/` to open the slash popup and begin typing your prompt name. @@ -13,3 +19,61 @@ Save frequently used prompts as Markdown files and reuse them quickly from the s - Notes: - Files with names that collide with built‑in commands (e.g. `/init`) are ignored and won’t appear. - New or changed files are discovered on session start. If you add a new prompt while Codex is running, start a new session to pick it up. + +### Slash popup rendering + +When you type `/`, the popup lists built‑in commands and your custom prompts. For custom prompts, the popup shows only: + +- A five‑word excerpt from the first non‑empty line of the prompt file, rendered dim + italic. + +Details: + +- The excerpt strips simple Markdown markers (backticks, `*`, `_`, leading `#`) and any `$1..$9`/`$ARGUMENTS` placeholders before counting words. If the line is longer than five words, it ends with an ellipsis `…`. +- If frontmatter provides an `argument-hint`, it appears inline after the excerpt; otherwise only the excerpt is shown. Placeholders still expand when you submit the prompt. + +Examples (illustrative): + +- Prompt file `perf-investigation.md` starts with: `Profile the slow path in module $1` → popup shows: `/perf-investigation Profile the slow path in module` +- Prompt file `release-runbook.md` starts with: `Assemble release checklist for this service` → popup shows: `/release-runbook Assemble release checklist` + +Styling follows the Codex TUI conventions (command cyan + bold; excerpt dim + italic). + +### Frontmatter (optional) + +Prompt files may start with a YAML‑style block to describe how the command should appear in the palette. The frontmatter is stripped before the prompt body is sent to the model. + +``` +--- +description: "Run a post-incident retro" +argument-hint: "[incident-id] [severity]" +--- +Draft a post-incident retrospective for incident $1 (severity $2). +List the timeline, impacted subsystems, contributing factors, and next steps. +``` + +With this file saved as `incident-retro.md`, the popup row shows: +- Name: `/incident-retro` +- Description: `Run a post-incident retro` +- Argument hint: `[incident-id] [severity]` + +### Argument examples + +All arguments with `$ARGUMENTS`: + +``` +# search-codebase.md +Search the repository for $ARGUMENTS and summarize the files that need attention. +``` + +Usage: `/search-codebase async runtime contention` → `$ARGUMENTS` becomes `"async runtime contention"`. + +Individual arguments with `$1`, `$2`, …: + +``` +# hotfix-plan.md +Prepare a hotfix plan for bug $1 targeting branch $2. +Assign engineering owners: $3. +Include smoke tests and rollback steps. +``` + +Usage: `/hotfix-plan BUG-1234 main "alice,bob"` → `$1` is `"BUG-1234"`, `$2` is `"main"`, `$3` is `"alice,bob"`.