Files
llmx/codex-rs/tui/src/bottom_pane/prompt_args.rs
dedrisian-oai bf76258cdc Custom prompts begin with /prompts: (#4476)
<img width="608" height="354" alt="Screenshot 2025-09-29 at 4 41 08 PM"
src="https://github.com/user-attachments/assets/162508eb-c1ac-4bc0-95f2-5e23cb4ae428"
/>
2025-09-29 17:58:16 -07:00

162 lines
5.5 KiB
Rust

use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use shlex::Shlex;
/// Parse a first-line slash command of the form `/name <rest>`.
/// Returns `(name, rest_after_name)` if the line begins with `/` and contains
/// a non-empty name; otherwise returns `None`.
pub fn parse_slash_name(line: &str) -> Option<(&str, &str)> {
let stripped = line.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() {
return None;
}
let rest = stripped[name_end..].trim_start();
Some((name, rest))
}
/// Parse positional arguments using shlex semantics (supports quoted tokens).
pub fn parse_positional_args(rest: &str) -> Vec<String> {
Shlex::new(rest).collect()
}
/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt.
///
/// If the text does not start with `/prompts:`, 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>, ()> {
let Some((name, rest)) = parse_slash_name(text) else {
return Ok(None);
};
// Only handle custom prompts when using the explicit prompts prefix with a colon.
let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
return Ok(None);
};
let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) {
Some(prompt) => prompt,
None => return Ok(None),
};
// Only support numeric placeholders ($1..$9) and $ARGUMENTS.
if prompt_has_numeric_placeholders(&prompt.content) {
let pos_args: Vec<String> = Shlex::new(rest).collect();
let expanded = expand_numeric_placeholders(&prompt.content, &pos_args);
return Ok(Some(expanded));
}
// No recognized placeholders: return the literal content.
Ok(Some(prompt.content.clone()))
}
/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`.
pub 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
}
/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name.
/// Returns empty when the command name does not match or when there are no args.
pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> Vec<String> {
let trimmed = line.trim_start();
let Some(rest) = trimmed.strip_prefix('/') else {
return Vec::new();
};
// Require the explicit prompts prefix for custom prompt invocations.
let Some(after_prefix) = rest.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else {
return Vec::new();
};
let mut parts = after_prefix.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_positional_args(args_str)
}
/// If the prompt only uses numeric placeholders and the first line contains
/// positional args for it, expand and return Some(expanded); otherwise None.
pub fn expand_if_numeric_with_positional_args(
prompt: &CustomPrompt,
first_line: &str,
) -> Option<String> {
if !prompt_has_numeric_placeholders(&prompt.content) {
return None;
}
let args = extract_positional_args_for_prompt_line(first_line, &prompt.name);
if args.is_empty() {
return None;
}
Some(expand_numeric_placeholders(&prompt.content, &args))
}
/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`.
pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String {
let mut out = String::with_capacity(content.len());
let mut i = 0;
let mut cached_joined_args: Option<String> = None;
while let Some(off) = content[i..].find('$') {
let j = i + off;
out.push_str(&content[i..j]);
let rest = &content[j..];
let bytes = rest.as_bytes();
if bytes.len() >= 2 {
match bytes[1] {
b'$' => {
out.push_str("$$");
i = j + 2;
continue;
}
b'1'..=b'9' => {
let idx = (bytes[1] - b'1') as usize;
if let Some(val) = args.get(idx) {
out.push_str(val);
}
i = j + 2;
continue;
}
_ => {}
}
}
if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") {
if !args.is_empty() {
let joined = cached_joined_args.get_or_insert_with(|| args.join(" "));
out.push_str(joined);
}
i = j + 1 + "ARGUMENTS".len();
continue;
}
out.push('$');
i = j + 1;
}
out.push_str(&content[i..]);
out
}