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 `. /// 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 { 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, ()> { 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 = 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 { 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 { 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 = 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 }