mirror of
https://github.com/openai/codex.git
synced 2026-03-05 21:45:28 +03:00
Compare commits
10 Commits
a5420779c4
...
feat/custo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a01cc66f3 | ||
|
|
4535378303 | ||
|
|
8e9e71d353 | ||
|
|
3fccdbd90b | ||
|
|
2943991990 | ||
|
|
f7da902be3 | ||
|
|
24ca19fb5f | ||
|
|
6ab0c1c266 | ||
|
|
dbec6aa17b | ||
|
|
885bf8e098 |
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -31,12 +31,14 @@ use crate::bottom_pane::paste_burst::FlushResult;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
|
||||
use super::prompt_args;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::textarea::TextArea;
|
||||
use crate::bottom_pane::textarea::TextAreaState;
|
||||
use crate::clipboard_paste::normalize_pasted_path;
|
||||
use crate::clipboard_paste::pasted_image_format;
|
||||
use crate::history_cell;
|
||||
use crate::key_hint;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
use codex_file_search::FileMatch;
|
||||
@@ -383,6 +385,7 @@ impl ChatComposer {
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
popup.on_composer_text_change(first_line.to_string());
|
||||
if let Some(sel) = popup.selected_item() {
|
||||
let mut cursor_target: Option<usize> = None;
|
||||
match sel {
|
||||
CommandItem::Builtin(cmd) => {
|
||||
let starts_with_cmd = first_line
|
||||
@@ -391,21 +394,24 @@ impl ChatComposer {
|
||||
if !starts_with_cmd {
|
||||
self.textarea.set_text(&format!("/{} ", cmd.command()));
|
||||
}
|
||||
if !self.textarea.text().is_empty() {
|
||||
cursor_target = Some(self.textarea.text().len());
|
||||
}
|
||||
}
|
||||
CommandItem::UserPrompt(idx) => {
|
||||
if let Some(name) = popup.prompt_name(idx) {
|
||||
let starts_with_cmd =
|
||||
first_line.trim_start().starts_with(&format!("/{name}"));
|
||||
if !starts_with_cmd {
|
||||
self.textarea.set_text(&format!("/{name} "));
|
||||
if let Some(prompt) = popup.prompt(idx) {
|
||||
let args = prompt_args::prompt_argument_names(&prompt.content);
|
||||
let (text, cursor) = Self::prompt_command_text(&prompt.name, &args);
|
||||
self.textarea.set_text(&text);
|
||||
cursor_target = cursor;
|
||||
if cursor_target.is_none() && !self.textarea.text().is_empty() {
|
||||
cursor_target = Some(self.textarea.text().len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// After completing the command, move cursor to the end.
|
||||
if !self.textarea.text().is_empty() {
|
||||
let end = self.textarea.text().len();
|
||||
self.textarea.set_cursor(end);
|
||||
if let Some(pos) = cursor_target {
|
||||
self.textarea.set_cursor(pos);
|
||||
}
|
||||
}
|
||||
(InputResult::None, true)
|
||||
@@ -415,26 +421,88 @@ impl ChatComposer {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
if let Some(sel) = popup.selected_item() {
|
||||
// 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)
|
||||
// If the current line starts with a custom prompt name and includes
|
||||
// positional args for a numeric-style template, expand and submit
|
||||
// immediately regardless of the popup selection.
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if let Some(stripped) = first_line.strip_prefix('/') {
|
||||
let mut name_end = stripped.len();
|
||||
for (idx, ch) in stripped.char_indices() {
|
||||
if ch.is_whitespace() {
|
||||
name_end = idx;
|
||||
break;
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// Hide popup since an action has been dispatched.
|
||||
self.active_popup = ActivePopup::None;
|
||||
}
|
||||
let name = &stripped[..name_end];
|
||||
if !name.is_empty()
|
||||
&& let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == name)
|
||||
{
|
||||
let has_named =
|
||||
!prompt_args::prompt_argument_names(&prompt.content).is_empty();
|
||||
let has_numeric = prompt_has_numeric_placeholders(&prompt.content);
|
||||
let args = extract_args_for_prompt(first_line, name);
|
||||
if has_numeric && !has_named && !args.is_empty() {
|
||||
let expanded = expand_prompt_with_args(&prompt.content, &args);
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Submitted(expanded), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sel) = popup.selected_item() {
|
||||
match sel {
|
||||
CommandItem::Builtin(cmd) => {
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Command(cmd), true);
|
||||
}
|
||||
CommandItem::UserPrompt(_) => {
|
||||
if let Some(contents) = prompt_content {
|
||||
return (InputResult::Submitted(contents), true);
|
||||
CommandItem::UserPrompt(idx) => {
|
||||
if let Some(prompt) = popup.prompt(idx) {
|
||||
let named_args =
|
||||
prompt_args::prompt_argument_names(&prompt.content);
|
||||
let has_numeric = prompt_has_numeric_placeholders(&prompt.content);
|
||||
|
||||
if named_args.is_empty() && !has_numeric {
|
||||
// No placeholders at all: auto-submit the literal content (legacy behavior).
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Submitted(prompt.content.clone()), true);
|
||||
}
|
||||
|
||||
if !named_args.is_empty() {
|
||||
// Insert a key=value skeleton for named placeholders.
|
||||
let (text, cursor) =
|
||||
Self::prompt_command_text(&prompt.name, &named_args);
|
||||
self.textarea.set_text(&text);
|
||||
let target = cursor.or_else(|| {
|
||||
let text = self.textarea.text();
|
||||
if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(text.len())
|
||||
}
|
||||
});
|
||||
if let Some(pos) = target {
|
||||
self.textarea.set_cursor(pos);
|
||||
}
|
||||
} else {
|
||||
// Numeric placeholders only.
|
||||
// If the user already typed positional args on the first line,
|
||||
// expand immediately and submit; otherwise insert "/name " so
|
||||
// they can type args.
|
||||
let first_line =
|
||||
self.textarea.text().lines().next().unwrap_or("");
|
||||
let args = extract_args_for_prompt(first_line, &prompt.name);
|
||||
if !args.is_empty() {
|
||||
let expanded =
|
||||
expand_prompt_with_args(&prompt.content, &args);
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Submitted(expanded), true);
|
||||
} else {
|
||||
let text = format!("/{} ", prompt.name);
|
||||
self.textarea.set_text(&text);
|
||||
self.textarea.set_cursor(self.textarea.text().len());
|
||||
}
|
||||
}
|
||||
// Popup visibility will be synchronized by the caller after this returns.
|
||||
}
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
@@ -446,6 +514,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,19 +785,44 @@ 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(char::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);
|
||||
}
|
||||
|
||||
fn prompt_command_text(name: &str, args: &[String]) -> (String, Option<usize>) {
|
||||
let mut text = format!("/{name}");
|
||||
let mut first_cursor: Option<usize> = None;
|
||||
for arg in args {
|
||||
text.push_str(format!(" {arg}=\"\"").as_str());
|
||||
if first_cursor.is_none() {
|
||||
first_cursor = Some(text.len() - 1);
|
||||
}
|
||||
}
|
||||
if first_cursor.is_none() && !text.is_empty() {
|
||||
first_cursor = Some(text.len());
|
||||
}
|
||||
(text, first_cursor)
|
||||
}
|
||||
|
||||
/// Handle key event when no popup is visible.
|
||||
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
match key_event {
|
||||
@@ -805,12 +905,14 @@ impl ChatComposer {
|
||||
if self
|
||||
.paste_burst
|
||||
.newline_should_insert_instead_of_submit(now)
|
||||
&& !in_slash_context
|
||||
{
|
||||
self.textarea.insert_str("\n");
|
||||
self.paste_burst.extend_window(now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
let mut text = self.textarea.text().to_string();
|
||||
let original_input = text.clone();
|
||||
self.textarea.set_text("");
|
||||
|
||||
// Replace all pending pastes in the text
|
||||
@@ -824,6 +926,41 @@ impl ChatComposer {
|
||||
// If there is neither text nor attachments, suppress submission entirely.
|
||||
let has_attachments = !self.attached_images.is_empty();
|
||||
text = text.trim().to_string();
|
||||
let expanded_prompt =
|
||||
match prompt_args::expand_custom_prompt(&text, &self.custom_prompts) {
|
||||
Ok(expanded) => expanded,
|
||||
Err(err) => {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(err.user_message()),
|
||||
)));
|
||||
self.textarea.set_text(&original_input);
|
||||
self.textarea.set_cursor(original_input.len());
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
};
|
||||
if let Some(expanded) = expanded_prompt {
|
||||
text = expanded;
|
||||
} else {
|
||||
// Fallback to numeric/positional placeholder expansion if this is a
|
||||
// slash command referring to a known custom prompt.
|
||||
if let Some(stripped) = text.strip_prefix('/') {
|
||||
let mut name_end = stripped.len();
|
||||
for (idx, ch) in stripped.char_indices() {
|
||||
if ch.is_whitespace() {
|
||||
name_end = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let name = &stripped[..name_end];
|
||||
if !name.is_empty()
|
||||
&& let Some(prompt) =
|
||||
self.custom_prompts.iter().find(|p| p.name == name)
|
||||
{
|
||||
let args = extract_args_for_prompt(&text, name);
|
||||
text = expand_prompt_with_args(&prompt.content, &args);
|
||||
}
|
||||
}
|
||||
}
|
||||
if text.is_empty() && !has_attachments {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
@@ -1145,18 +1282,42 @@ impl ChatComposer {
|
||||
/// textarea. This must be called after every modification that can change
|
||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||
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('/');
|
||||
// Determine whether the caret is inside the initial '/name' token on the first line.
|
||||
let text = self.textarea.text();
|
||||
let first_line_end = text.find('\n').unwrap_or(text.len());
|
||||
let first_line = &text[..first_line_end];
|
||||
let cursor = self.textarea.cursor();
|
||||
let caret_on_first_line = cursor <= first_line_end;
|
||||
|
||||
let is_editing_slash_command_name = if first_line.starts_with('/') && caret_on_first_line {
|
||||
let token_end = first_line
|
||||
.char_indices()
|
||||
.find(|(_, c)| c.is_whitespace())
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(first_line.len());
|
||||
cursor <= token_end
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// 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/...").
|
||||
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 {
|
||||
if is_editing_slash_command_name {
|
||||
popup.on_composer_text_change(first_line.to_string());
|
||||
} else {
|
||||
self.active_popup = ActivePopup::None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if input_starts_with_slash {
|
||||
if is_editing_slash_command_name {
|
||||
let mut command_popup = CommandPopup::new(self.custom_prompts.clone());
|
||||
command_popup.on_composer_text_change(first_line.to_string());
|
||||
self.active_popup = ActivePopup::Command(command_popup);
|
||||
@@ -1231,6 +1392,136 @@ 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
|
||||
}
|
||||
|
||||
/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`.
|
||||
fn prompt_has_numeric_placeholders(content: &str) -> bool {
|
||||
if content.contains("$ARGUMENTS") {
|
||||
return true;
|
||||
}
|
||||
let bytes = content.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + 1 < bytes.len() {
|
||||
if bytes[i] == b'$' {
|
||||
let b1 = bytes[i + 1];
|
||||
if (b'1'..=b'9').contains(&b1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 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 +2159,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;
|
||||
@@ -2315,7 +2622,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_custom_prompt_submits_file_contents() {
|
||||
fn selecting_custom_prompt_without_args_submits_content() {
|
||||
let prompt_text = "Hello from saved prompt";
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -2333,6 +2640,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(
|
||||
@@ -2344,6 +2653,339 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::Submitted(prompt_text.to_string()), result);
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_prompt_submission_expands_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: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}]);
|
||||
|
||||
composer
|
||||
.textarea
|
||||
.set_text("/my-prompt USER=Alice BRANCH=main");
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
InputResult::Submitted("Review Alice changes on main".to_string()),
|
||||
result
|
||||
);
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_prompt_submission_accepts_quoted_values() {
|
||||
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: "Pair $USER with $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}]);
|
||||
|
||||
composer
|
||||
.textarea
|
||||
.set_text("/my-prompt USER=\"Alice Smith\" BRANCH=dev-main");
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
InputResult::Submitted("Pair Alice Smith with dev-main".to_string()),
|
||||
result
|
||||
);
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_prompt_invalid_args_reports_error() {
|
||||
let (tx, mut 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: "Review $USER changes".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}]);
|
||||
|
||||
composer.textarea.set_text("/my-prompt USER=Alice stray");
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!("/my-prompt USER=Alice stray", composer.textarea.text());
|
||||
|
||||
let mut found_error = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
let message = cell
|
||||
.display_lines(80)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(message.contains("expected key=value"));
|
||||
found_error = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_error, "expected error history cell to be sent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_prompt_missing_required_args_reports_error() {
|
||||
let (tx, mut 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: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}]);
|
||||
|
||||
// Provide only one of the required args
|
||||
composer.textarea.set_text("/my-prompt USER=Alice");
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!("/my-prompt USER=Alice", composer.textarea.text());
|
||||
|
||||
let mut found_error = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
let message = cell
|
||||
.display_lines(80)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(message.to_lowercase().contains("missing required args"));
|
||||
assert!(message.contains("BRANCH"));
|
||||
found_error = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found_error,
|
||||
"expected missing args error history cell to be sent"
|
||||
);
|
||||
}
|
||||
|
||||
#[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 numeric_prompt_positional_args_does_not_error() {
|
||||
// Ensure that a prompt with only numeric placeholders does not trigger
|
||||
// key=value parsing errors when given positional 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: "elegant".to_string(),
|
||||
path: "/tmp/elegant.md".to_string().into(),
|
||||
content: "Echo: $ARGUMENTS".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}]);
|
||||
|
||||
// Type positional args; should submit with numeric expansion, no errors.
|
||||
composer.textarea.set_text("/elegant hi");
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result);
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_custom_prompt_with_no_args_inserts_template() {
|
||||
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));
|
||||
|
||||
// With no args typed, selecting the prompt inserts the command template
|
||||
// and does not submit immediately.
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!("/p ", composer.textarea.text());
|
||||
}
|
||||
|
||||
#[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]
|
||||
|
||||
@@ -3,13 +3,15 @@ use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use super::prompt_args;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::render_rows;
|
||||
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;
|
||||
use codex_protocol::custom_prompts::CustomPrompt; // for extracting named argument placeholders
|
||||
// no additional imports
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// A selectable item in the popup: either a built-in command or a user prompt.
|
||||
@@ -53,12 +55,8 @@ impl CommandPopup {
|
||||
self.prompts = prompts;
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_name(&self, idx: usize) -> Option<&str> {
|
||||
self.prompts.get(idx).map(|p| p.name.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_content(&self, idx: usize) -> Option<&str> {
|
||||
self.prompts.get(idx).map(|p| p.content.as_str())
|
||||
pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> {
|
||||
self.prompts.get(idx)
|
||||
}
|
||||
|
||||
/// Update the filter string based on the current composer text. The text
|
||||
@@ -96,6 +94,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 +160,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,10 +217,96 @@ 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 an explicit hint is provided via frontmatter, prefer it as-is.
|
||||
if let Some(hint) = &prompt.argument_hint {
|
||||
if !hint.is_empty() {
|
||||
return format!("{base} {hint}");
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, include minimal hints for named custom args (e.g., $USER, $BRANCH).
|
||||
let named = prompt_args::prompt_argument_names(&prompt.content);
|
||||
if !named.is_empty() {
|
||||
let tokens = named
|
||||
.into_iter()
|
||||
.map(|n| format!("<{n}>"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if !tokens.is_empty() {
|
||||
return format!("{base} {tokens}");
|
||||
}
|
||||
}
|
||||
|
||||
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(str::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", "");
|
||||
// Also strip named placeholders like $USER or $BRANCH to avoid noise.
|
||||
for name in prompt_args::prompt_argument_names(&cleaned) {
|
||||
cleaned = cleaned.replace(&format!("${name}"), "");
|
||||
}
|
||||
|
||||
// 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::*;
|
||||
use std::string::ToString;
|
||||
|
||||
#[test]
|
||||
fn filter_includes_init_when_typing_prefix() {
|
||||
@@ -276,11 +364,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);
|
||||
@@ -288,7 +380,7 @@ mod tests {
|
||||
let mut prompt_names: Vec<String> = items
|
||||
.into_iter()
|
||||
.filter_map(|it| match it {
|
||||
CommandItem::UserPrompt(i) => popup.prompt_name(i).map(ToString::to_string),
|
||||
CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -303,10 +395,12 @@ 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 {
|
||||
CommandItem::UserPrompt(i) => popup.prompt_name(i) == Some("init"),
|
||||
CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"),
|
||||
_ => false,
|
||||
});
|
||||
assert!(
|
||||
@@ -314,4 +408,107 @@ 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]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_displays_named_argument_hints_when_present() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "with-named".to_string(),
|
||||
path: "/tmp/with-named.md".into(),
|
||||
content: "Work with $USER on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
let mut popup = CommandPopup::new(prompts);
|
||||
popup.on_composer_text_change("/with-named".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);
|
||||
// Ensure the excerpt stripped named placeholders and tokens are shown as hints instead.
|
||||
assert!(screen.contains("Work with on"));
|
||||
assert!(screen.contains("<USER> <BRANCH>"));
|
||||
assert!(!screen.contains("$USER"));
|
||||
assert!(!screen.contains("$BRANCH"));
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ mod command_popup;
|
||||
pub mod custom_prompt_view;
|
||||
mod file_search_popup;
|
||||
mod list_selection_view;
|
||||
mod prompt_args;
|
||||
pub(crate) use list_selection_view::SelectionViewParams;
|
||||
mod paste_burst;
|
||||
pub mod popup_consts;
|
||||
|
||||
232
codex-rs/tui/src/bottom_pane/prompt_args.rs
Normal file
232
codex-rs/tui/src/bottom_pane/prompt_args.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use lazy_static::lazy_static;
|
||||
use regex_lite::Regex;
|
||||
use shlex::Shlex;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
lazy_static! {
|
||||
static ref PROMPT_ARG_REGEX: Regex =
|
||||
Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort());
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptArgsError {
|
||||
MissingAssignment { token: String },
|
||||
MissingKey { token: String },
|
||||
}
|
||||
|
||||
impl PromptArgsError {
|
||||
fn describe(&self, command: &str) -> String {
|
||||
match self {
|
||||
PromptArgsError::MissingAssignment { token } => format!(
|
||||
"Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces."
|
||||
),
|
||||
PromptArgsError::MissingKey { token } => {
|
||||
format!("Could not parse {command}: expected a name before '=' in '{token}'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PromptExpansionError {
|
||||
Args {
|
||||
command: String,
|
||||
error: PromptArgsError,
|
||||
},
|
||||
MissingArgs {
|
||||
command: String,
|
||||
missing: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PromptExpansionError {
|
||||
pub fn user_message(&self) -> String {
|
||||
match self {
|
||||
PromptExpansionError::Args { command, error } => error.describe(command),
|
||||
PromptExpansionError::MissingArgs { command, missing } => {
|
||||
let list = missing.join(", ");
|
||||
format!(
|
||||
"Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the unique placeholder variable names from a prompt template.
|
||||
///
|
||||
/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*`
|
||||
/// (for example `$USER`). The function returns the variable names without
|
||||
/// the leading `$`, de-duplicated and in the order of first appearance.
|
||||
pub fn prompt_argument_names(content: &str) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut names = Vec::new();
|
||||
for m in PROMPT_ARG_REGEX.find_iter(content) {
|
||||
let name = &content[m.start() + 1..m.end()];
|
||||
// Exclude special positional aggregate token from named args.
|
||||
if name == "ARGUMENTS" {
|
||||
continue;
|
||||
}
|
||||
let name = name.to_string();
|
||||
if seen.insert(name.clone()) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Parses the `key=value` pairs that follow a custom prompt name.
|
||||
///
|
||||
/// The input is split using shlex rules, so quoted values are supported
|
||||
/// (for example `USER="Alice Smith"`). The function returns a map of parsed
|
||||
/// arguments, or an error if a token is missing `=` or if the key is empty.
|
||||
pub fn parse_prompt_inputs(rest: &str) -> Result<HashMap<String, String>, PromptArgsError> {
|
||||
let mut map = HashMap::new();
|
||||
if rest.trim().is_empty() {
|
||||
return Ok(map);
|
||||
}
|
||||
|
||||
for token in Shlex::new(rest) {
|
||||
let Some((key, value)) = token.split_once('=') else {
|
||||
return Err(PromptArgsError::MissingAssignment { token });
|
||||
};
|
||||
if key.is_empty() {
|
||||
return Err(PromptArgsError::MissingKey { token });
|
||||
}
|
||||
map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Expands a message of the form `/name key=value …` using a matching saved prompt.
|
||||
///
|
||||
/// If the text does not start with `/`, or if no prompt named `name` exists,
|
||||
/// the function returns `Ok(None)`. On success it returns
|
||||
/// `Ok(Some(expanded))`; otherwise it returns a descriptive error.
|
||||
pub fn expand_custom_prompt(
|
||||
text: &str,
|
||||
custom_prompts: &[CustomPrompt],
|
||||
) -> Result<Option<String>, PromptExpansionError> {
|
||||
let Some(stripped) = text.strip_prefix('/') else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut name_end = stripped.len();
|
||||
for (idx, ch) in stripped.char_indices() {
|
||||
if ch.is_whitespace() {
|
||||
name_end = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let name = &stripped[..name_end];
|
||||
if name.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let prompt = match custom_prompts.iter().find(|p| p.name == name) {
|
||||
Some(prompt) => prompt,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let rest = stripped[name_end..].trim();
|
||||
// If the prompt has no named placeholders, defer to positional handling.
|
||||
let required = prompt_argument_names(&prompt.content);
|
||||
if required.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let inputs = parse_prompt_inputs(rest).map_err(|error| PromptExpansionError::Args {
|
||||
command: format!("/{name}"),
|
||||
error,
|
||||
})?;
|
||||
|
||||
// Ensure that all required variables are provided.
|
||||
let required = prompt_argument_names(&prompt.content);
|
||||
let missing: Vec<String> = required
|
||||
.into_iter()
|
||||
.filter(|k| !inputs.contains_key(k))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
return Err(PromptExpansionError::MissingArgs {
|
||||
command: format!("/{name}"),
|
||||
missing,
|
||||
});
|
||||
}
|
||||
|
||||
let replaced =
|
||||
PROMPT_ARG_REGEX.replace_all(&prompt.content, |caps: ®ex_lite::Captures<'_>| {
|
||||
let whole = caps.get(0).map(|m| m.as_str()).unwrap_or("");
|
||||
let key = &whole[1..];
|
||||
inputs
|
||||
.get(key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| whole.to_string())
|
||||
});
|
||||
|
||||
Ok(Some(replaced.into_owned()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_arguments_basic() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out = expand_custom_prompt("/my-prompt USER=Alice BRANCH=main", &prompts).unwrap();
|
||||
assert_eq!(out, Some("Review Alice changes on main".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_values_ok() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Pair $USER with $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
|
||||
let out = expand_custom_prompt("/my-prompt USER=\"Alice Smith\" BRANCH=dev-main", &prompts)
|
||||
.unwrap();
|
||||
assert_eq!(out, Some("Pair Alice Smith with dev-main".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_arg_token_reports_error() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
let err = expand_custom_prompt("/my-prompt USER=Alice stray", &prompts)
|
||||
.unwrap_err()
|
||||
.user_message();
|
||||
assert!(err.contains("expected key=value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_args_reports_error() {
|
||||
let prompts = vec![CustomPrompt {
|
||||
name: "my-prompt".to_string(),
|
||||
path: "/tmp/my-prompt.md".to_string().into(),
|
||||
content: "Review $USER changes on $BRANCH".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}];
|
||||
let err = expand_custom_prompt("/my-prompt USER=Alice", &prompts)
|
||||
.unwrap_err()
|
||||
.user_message();
|
||||
assert!(err.to_lowercase().contains("missing required args"));
|
||||
assert!(err.contains("BRANCH"));
|
||||
}
|
||||
}
|
||||
@@ -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"`.
|
||||
|
||||
Reference in New Issue
Block a user