From a9ef0f4a3e8ad8cfa96070a32a79c56b207d82b1 Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Mon, 29 Sep 2025 16:20:58 -0700 Subject: [PATCH 01/10] Named args for custom prompts --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 308 +++++++++++++++--- codex-rs/tui/src/bottom_pane/prompt_args.rs | 212 +++++++++++- 2 files changed, 471 insertions(+), 49 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 852acc2453..8baaca8e63 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -24,8 +24,6 @@ use super::footer::render_footer; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use crate::bottom_pane::paste_burst::FlushResult; -use crate::bottom_pane::prompt_args::expand_custom_prompt; -use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; use crate::slash_command::SlashCommand; @@ -33,12 +31,14 @@ use crate::style::user_message_style; use crate::terminal_palette; 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::ui_consts::LIVE_PREFIX_COLS; use codex_file_search::FileMatch; use std::cell::RefCell; @@ -406,16 +406,10 @@ impl ChatComposer { } CommandItem::UserPrompt(idx) => { if let Some(prompt) = popup.prompt(idx) { - let name = prompt.name.clone(); - let starts_with_cmd = first_line - .trim_start() - .starts_with(format!("/{name}").as_str()); - if !starts_with_cmd { - self.textarea.set_text(format!("/{name} ").as_str()); - } - if !self.textarea.text().is_empty() { - cursor_target = Some(self.textarea.text().len()); - } + 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 = Some(cursor); } } } @@ -436,11 +430,13 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, _rest)) = parse_slash_name(first_line) && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == name) - && let Some(expanded) = - expand_if_numeric_with_positional_args(prompt, first_line) { - self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); + if let Some(expanded) = + prompt_args::expand_if_numeric_with_positional_args(prompt, first_line) + { + self.textarea.set_text(""); + return (InputResult::Submitted(expanded), true); + } } if let Some(sel) = popup.selected_item() { @@ -451,27 +447,41 @@ impl ChatComposer { } 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 !has_numeric { - // No placeholders at all: auto-submit the literal 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); } - // Numeric placeholders present. - // 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(""); - if let Some(expanded) = - expand_if_numeric_with_positional_args(prompt, first_line) - { - self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); - } else { - let text = format!("/{} ", prompt.name); + + 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); - self.textarea.set_cursor(self.textarea.text().len()); + self.textarea.set_cursor(cursor); + } 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(""); + if let Some(expanded) = + prompt_args::expand_if_numeric_with_positional_args( + &prompt, first_line, + ) + { + 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()); + } } } return (InputResult::None, true); @@ -772,6 +782,18 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + fn prompt_command_text(name: &str, args: &[String]) -> (String, usize) { + let mut text = format!("/{name}"); + let mut cursor: usize = text.len(); + for (i, arg) in args.iter().enumerate() { + text.push_str(format!(" {arg}=\"\"").as_str()); + if i == 0 { + cursor = text.len() - 1; // inside first "" + } + } + (text, 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 { @@ -861,6 +883,7 @@ impl ChatComposer { 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 @@ -874,13 +897,21 @@ 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(); - - if let Some(expanded) = - expand_custom_prompt(&text, &self.custom_prompts).unwrap_or_default() - { + 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; } - if text.is_empty() && !has_attachments { return (InputResult::None, true); } @@ -1376,7 +1407,6 @@ mod tests { use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::AttachedImage; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; - use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -1868,13 +1898,19 @@ mod tests { #[test] fn extract_args_supports_quoted_paths_single_arg() { - let args = extract_positional_args_for_prompt_line("/review \"docs/My File.md\"", "review"); + let args = prompt_args::extract_positional_args_for_prompt_line( + "/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 = extract_positional_args_for_prompt_line("/cmd \"with spaces\" simple", "cmd"); + let args = prompt_args::extract_positional_args_for_prompt_line( + "/cmd \"with spaces\" simple", + "cmd", + ); assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]); } @@ -2359,6 +2395,169 @@ mod tests { assert!(composer.textarea.is_empty()); } + #[test] + fn custom_prompt_submission_expands_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: "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::(); + 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::(); + 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::>() + .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::(); + 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::>() + .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. @@ -2397,6 +2596,37 @@ mod tests { 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::(); + 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]"; diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index a0c67b6e29..1291a897c0 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -1,5 +1,59 @@ 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, + }, +} + +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)." + ) + } + } + } +} /// Parse a first-line slash command of the form `/name `. /// Returns `(name, rest_after_name)` if the line begins with `/` and contains @@ -26,6 +80,51 @@ pub fn parse_positional_args(rest: &str) -> Vec { Shlex::new(rest).collect() } +/// 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 { + 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, 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, @@ -34,7 +133,7 @@ pub fn parse_positional_args(rest: &str) -> Vec { pub fn expand_custom_prompt( text: &str, custom_prompts: &[CustomPrompt], -) -> Result, ()> { +) -> Result, PromptExpansionError> { let Some((name, rest)) = parse_slash_name(text) else { return Ok(None); }; @@ -43,14 +142,39 @@ pub fn expand_custom_prompt( 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())) + // If there are named placeholders, expect key=value inputs. + let required = prompt_argument_names(&prompt.content); + if !required.is_empty() { + let inputs = parse_prompt_inputs(rest).map_err(|error| PromptExpansionError::Args { + command: format!("/{name}"), + error, + })?; + let missing: Vec = 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()) + }); + return Ok(Some(replaced.into_owned())); + } + + // Otherwise, treat it as numeric/positional placeholder prompt (or none). + let pos_args: Vec = Shlex::new(rest).collect(); + let expanded = expand_numeric_placeholders(&prompt.content, &pos_args); + Ok(Some(expanded)) } /// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`. @@ -97,6 +221,9 @@ pub fn expand_if_numeric_with_positional_args( prompt: &CustomPrompt, first_line: &str, ) -> Option { + if !prompt_argument_names(&prompt.content).is_empty() { + return None; + } if !prompt_has_numeric_placeholders(&prompt.content) { return None; } @@ -135,7 +262,7 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { _ => {} } } - if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { + if rest.len() >= 1 + "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); @@ -149,3 +276,68 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { out.push_str(&content[i..]); out } + +#[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")); + } +} From 367ceed32ababf61c443df8b16b042047f323c92 Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Mon, 29 Sep 2025 16:26:17 -0700 Subject: [PATCH 02/10] autofix clippy --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 14 ++++++-------- codex-rs/tui/src/bottom_pane/prompt_args.rs | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8baaca8e63..d980d31275 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -430,13 +430,11 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, _rest)) = parse_slash_name(first_line) && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == name) - { - if let Some(expanded) = + && let Some(expanded) = prompt_args::expand_if_numeric_with_positional_args(prompt, first_line) - { - self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); - } + { + self.textarea.set_text(""); + return (InputResult::Submitted(expanded), true); } if let Some(sel) = popup.selected_item() { @@ -452,7 +450,7 @@ impl ChatComposer { 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). + // No placeholders at all: auto-submit the literal content self.textarea.set_text(""); return (InputResult::Submitted(prompt.content.clone()), true); } @@ -472,7 +470,7 @@ impl ChatComposer { self.textarea.text().lines().next().unwrap_or(""); if let Some(expanded) = prompt_args::expand_if_numeric_with_positional_args( - &prompt, first_line, + prompt, first_line, ) { self.textarea.set_text(""); diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index 1291a897c0..461c8f1bc4 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -262,7 +262,7 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { _ => {} } } - if rest.len() >= 1 + "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { + 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); From f0c97838f1fc5f3bfcb35cd060118ea9fcce4340 Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Mon, 29 Sep 2025 16:51:45 -0700 Subject: [PATCH 03/10] Handle escaped prompt arguments --- codex-rs/tui/src/bottom_pane/prompt_args.rs | 53 +++++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index 461c8f1bc4..e1bdcb7aea 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -89,6 +89,9 @@ pub fn prompt_argument_names(content: &str) -> Vec { let mut seen = HashSet::new(); let mut names = Vec::new(); for m in PROMPT_ARG_REGEX.find_iter(content) { + if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' { + continue; + } let name = &content[m.start() + 1..m.end()]; // Exclude special positional aggregate token from named args. if name == "ARGUMENTS" { @@ -159,15 +162,21 @@ pub fn expand_custom_prompt( 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()) - }); + let content = &prompt.content; + let replaced = PROMPT_ARG_REGEX.replace_all(content, |caps: ®ex_lite::Captures<'_>| { + let matched = caps + .get(0) + .expect("prompt arg regex should provide whole match"); + if matched.start() > 0 && content.as_bytes()[matched.start() - 1] == b'$' { + return matched.as_str().to_string(); + } + let whole = matched.as_str(); + let key = &whole[1..]; + inputs + .get(key) + .cloned() + .unwrap_or_else(|| whole.to_string()) + }); return Ok(Some(replaced.into_owned())); } @@ -340,4 +349,30 @@ mod tests { assert!(err.to_lowercase().contains("missing required args")); assert!(err.contains("BRANCH")); } + + #[test] + fn escaped_placeholder_is_ignored() { + assert_eq!( + prompt_argument_names("literal $$USER"), + Vec::::new() + ); + assert_eq!( + prompt_argument_names("literal $$USER and $REAL"), + vec!["REAL".to_string()] + ); + } + + #[test] + fn escaped_placeholder_remains_literal() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "literal $$USER".to_string(), + description: None, + argument_hint: None, + }]; + + let out = expand_custom_prompt("/my-prompt", &prompts).unwrap(); + assert_eq!(out, Some("literal $$USER".to_string())); + } } From 177ab272615da7d2d181caca8f499918ebe6a16a Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Mon, 29 Sep 2025 17:17:10 -0700 Subject: [PATCH 04/10] Autofix clippy --- codex-rs/tui/src/bottom_pane/prompt_args.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index e1bdcb7aea..06a0bca5a5 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -164,13 +164,13 @@ pub fn expand_custom_prompt( } let content = &prompt.content; let replaced = PROMPT_ARG_REGEX.replace_all(content, |caps: ®ex_lite::Captures<'_>| { - let matched = caps - .get(0) - .expect("prompt arg regex should provide whole match"); - if matched.start() > 0 && content.as_bytes()[matched.start() - 1] == b'$' { + if let Some(matched) = caps.get(0) + && matched.start() > 0 + && content.as_bytes()[matched.start() - 1] == b'$' + { return matched.as_str().to_string(); } - let whole = matched.as_str(); + let whole = &caps[0]; let key = &whole[1..]; inputs .get(key) From 7e96bd6883334c8540445371cbf01cf79ab11fe4 Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Mon, 29 Sep 2025 18:04:15 -0700 Subject: [PATCH 05/10] Fix --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 431a82c02d..00551160c1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1513,6 +1513,7 @@ mod tests { use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::AttachedImage; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; + use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; From 41ec5a2019fab3f799d4e38a6210d51b12f892c9 Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Mon, 29 Sep 2025 18:54:42 -0700 Subject: [PATCH 06/10] Fix tests --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 22 ++++++++++++------- codex-rs/tui/src/bottom_pane/prompt_args.rs | 16 +++++++++----- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 00551160c1..3d50d53732 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -507,7 +507,8 @@ impl ChatComposer { self.textarea.set_text(""); return (InputResult::Submitted(expanded), true); } else { - let text = format!("/{} ", prompt.name); + let text = + format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); self.textarea.set_text(&text); self.textarea.set_cursor(self.textarea.text().len()); } @@ -2672,7 +2673,7 @@ mod tests { composer .textarea - .set_text("/my-prompt USER=Alice BRANCH=main"); + .set_text("/prompts:my-prompt USER=Alice BRANCH=main"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -2706,7 +2707,7 @@ mod tests { composer .textarea - .set_text("/my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); + .set_text("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -2738,13 +2739,18 @@ mod tests { argument_hint: None, }]); - composer.textarea.set_text("/my-prompt USER=Alice stray"); + composer + .textarea + .set_text("/prompts: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()); + assert_eq!( + "/prompts:my-prompt USER=Alice stray", + composer.textarea.text() + ); let mut found_error = false; while let Ok(event) = rx.try_recv() { @@ -2784,13 +2790,13 @@ mod tests { }]); // Provide only one of the required args - composer.textarea.set_text("/my-prompt USER=Alice"); + composer.textarea.set_text("/prompts: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()); + assert_eq!("/prompts:my-prompt USER=Alice", composer.textarea.text()); let mut found_error = false; while let Ok(event) = rx.try_recv() { @@ -2874,7 +2880,7 @@ mod tests { }]); // Type positional args; should submit with numeric expansion, no errors. - composer.textarea.set_text("/elegant hi"); + composer.textarea.set_text("/prompts:elegant hi"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index ff27abb3a9..2b3b89b32d 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -310,7 +310,8 @@ mod tests { argument_hint: None, }]; - let out = expand_custom_prompt("/my-prompt USER=Alice BRANCH=main", &prompts).unwrap(); + let out = + expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &prompts).unwrap(); assert_eq!(out, Some("Review Alice changes on main".to_string())); } @@ -324,8 +325,11 @@ mod tests { argument_hint: None, }]; - let out = expand_custom_prompt("/my-prompt USER=\"Alice Smith\" BRANCH=dev-main", &prompts) - .unwrap(); + let out = expand_custom_prompt( + "/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main", + &prompts, + ) + .unwrap(); assert_eq!(out, Some("Pair Alice Smith with dev-main".to_string())); } @@ -338,7 +342,7 @@ mod tests { description: None, argument_hint: None, }]; - let err = expand_custom_prompt("/my-prompt USER=Alice stray", &prompts) + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &prompts) .unwrap_err() .user_message(); assert!(err.contains("expected key=value")); @@ -353,7 +357,7 @@ mod tests { description: None, argument_hint: None, }]; - let err = expand_custom_prompt("/my-prompt USER=Alice", &prompts) + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &prompts) .unwrap_err() .user_message(); assert!(err.to_lowercase().contains("missing required args")); @@ -382,7 +386,7 @@ mod tests { argument_hint: None, }]; - let out = expand_custom_prompt("/my-prompt", &prompts).unwrap(); + let out = expand_custom_prompt("/prompts:my-prompt", &prompts).unwrap(); assert_eq!(out, Some("literal $$USER".to_string())); } } From 82f39d1b66dd7d8dc1490d2388d88d913c89b979 Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Mon, 29 Sep 2025 19:00:53 -0700 Subject: [PATCH 07/10] nit --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3d50d53732..780fff6578 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -507,8 +507,8 @@ impl ChatComposer { self.textarea.set_text(""); return (InputResult::Submitted(expanded), true); } else { - let text = - format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); + let name = prompt.name.clone(); + let text = format!("/{PROMPTS_CMD_PREFIX}:{name} "); self.textarea.set_text(&text); self.textarea.set_cursor(self.textarea.text().len()); } From 17b3c4eeafeb48abc89a5867fde3f20689dd58cb Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Mon, 29 Sep 2025 19:06:07 -0700 Subject: [PATCH 08/10] fmt --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 780fff6578..861315341f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -28,7 +28,10 @@ use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use crate::bottom_pane::paste_burst::FlushResult; +use crate::bottom_pane::prompt_args::expand_custom_prompt; +use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; use crate::bottom_pane::prompt_args::parse_slash_name; +use crate::bottom_pane::prompt_args::prompt_argument_names; use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; use crate::slash_command::SlashCommand; use crate::style::user_message_style; @@ -36,7 +39,6 @@ use crate::terminal_palette; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; -use super::prompt_args; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::textarea::TextArea; @@ -436,7 +438,7 @@ impl ChatComposer { } CommandItem::UserPrompt(idx) => { if let Some(prompt) = popup.prompt(idx) { - let args = prompt_args::prompt_argument_names(&prompt.content); + let args = prompt_argument_names(&prompt.content); let (text, cursor) = Self::prompt_command_text(&prompt.name, &args); self.textarea.set_text(&text); cursor_target = Some(cursor); @@ -462,7 +464,7 @@ impl ChatComposer { && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name) && let Some(expanded) = - prompt_args::expand_if_numeric_with_positional_args(prompt, first_line) + expand_if_numeric_with_positional_args(prompt, first_line) { self.textarea.set_text(""); return (InputResult::Submitted(expanded), true); @@ -476,8 +478,7 @@ impl ChatComposer { } CommandItem::UserPrompt(idx) => { if let Some(prompt) = popup.prompt(idx) { - let named_args = - prompt_args::prompt_argument_names(&prompt.content); + let named_args = prompt_argument_names(&prompt.content); let has_numeric = prompt_has_numeric_placeholders(&prompt.content); if named_args.is_empty() && !has_numeric { @@ -500,9 +501,7 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some(expanded) = - prompt_args::expand_if_numeric_with_positional_args( - prompt, first_line, - ) + expand_if_numeric_with_positional_args(prompt, first_line) { self.textarea.set_text(""); return (InputResult::Submitted(expanded), true); @@ -951,18 +950,17 @@ 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); - } - }; + let expanded_prompt = match 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; } From 1a561a8b769916e32dcdbe996188168fd3793672 Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Mon, 29 Sep 2025 19:22:48 -0700 Subject: [PATCH 09/10] Make easier for the brain --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 129 +++++++++++------- codex-rs/tui/src/bottom_pane/prompt_args.rs | 14 ++ 2 files changed, 97 insertions(+), 46 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 861315341f..5d52a22a73 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -32,6 +32,7 @@ use crate::bottom_pane::prompt_args::expand_custom_prompt; use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::prompt_args::prompt_argument_names; +use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders; use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; use crate::slash_command::SlashCommand; use crate::style::user_message_style; @@ -73,6 +74,16 @@ struct AttachedImage { path: PathBuf, } +enum PromptSelectionMode { + Completion, + Submit, +} + +enum PromptSelectionAction { + Insert { text: String, cursor: Option }, + Submit { text: String }, +} + pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, @@ -438,10 +449,18 @@ impl ChatComposer { } CommandItem::UserPrompt(idx) => { if let Some(prompt) = popup.prompt(idx) { - let args = prompt_argument_names(&prompt.content); - let (text, cursor) = Self::prompt_command_text(&prompt.name, &args); - self.textarea.set_text(&text); - cursor_target = Some(cursor); + match prompt_selection_action( + prompt, + first_line, + PromptSelectionMode::Completion, + ) { + PromptSelectionAction::Insert { text, cursor } => { + let target = cursor.unwrap_or_else(|| text.len()); + self.textarea.set_text(&text); + cursor_target = Some(target); + } + PromptSelectionAction::Submit { .. } => {} + } } } } @@ -478,38 +497,20 @@ impl ChatComposer { } CommandItem::UserPrompt(idx) => { if let Some(prompt) = popup.prompt(idx) { - let named_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 - 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); - self.textarea.set_cursor(cursor); - } 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(""); - if let Some(expanded) = - expand_if_numeric_with_positional_args(prompt, first_line) - { + match prompt_selection_action( + prompt, + first_line, + PromptSelectionMode::Submit, + ) { + PromptSelectionAction::Submit { text } => { self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); - } else { - let name = prompt.name.clone(); - let text = format!("/{PROMPTS_CMD_PREFIX}:{name} "); + return (InputResult::Submitted(text), true); + } + PromptSelectionAction::Insert { text, cursor } => { + let target = cursor.unwrap_or_else(|| text.len()); self.textarea.set_text(&text); - self.textarea.set_cursor(self.textarea.text().len()); + self.textarea.set_cursor(target); + return (InputResult::None, true); } } } @@ -823,18 +824,6 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } - fn prompt_command_text(name: &str, args: &[String]) -> (String, usize) { - let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}"); - let mut cursor: usize = text.len(); - for (i, arg) in args.iter().enumerate() { - text.push_str(format!(" {arg}=\"\"").as_str()); - if i == 0 { - cursor = text.len() - 1; // inside first "" - } - } - (text, cursor) - } - /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.handle_shortcut_overlay_key(&key_event) { @@ -1497,6 +1486,54 @@ impl WidgetRef for ChatComposer { } } +fn prompt_selection_action( + prompt: &CustomPrompt, + first_line: &str, + mode: PromptSelectionMode, +) -> PromptSelectionAction { + let named_args = prompt_argument_names(&prompt.content); + let has_numeric = prompt_has_numeric_placeholders(&prompt.content); + + match mode { + PromptSelectionMode::Completion => { + if !named_args.is_empty() { + let (text, cursor) = + prompt_command_with_arg_placeholders(&prompt.name, &named_args); + return PromptSelectionAction::Insert { + text, + cursor: Some(cursor), + }; + } + if has_numeric { + let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); + return PromptSelectionAction::Insert { text, cursor: None }; + } + let text = format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name); + PromptSelectionAction::Insert { text, cursor: None } + } + PromptSelectionMode::Submit => { + if !named_args.is_empty() { + let (text, cursor) = + prompt_command_with_arg_placeholders(&prompt.name, &named_args); + return PromptSelectionAction::Insert { + text, + cursor: Some(cursor), + }; + } + if has_numeric { + if let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { + return PromptSelectionAction::Submit { text: expanded }; + } + let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); + return PromptSelectionAction::Insert { text, cursor: None }; + } + PromptSelectionAction::Submit { + text: prompt.content.clone(), + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index 2b3b89b32d..48c3cedfab 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -296,6 +296,20 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { out } +/// Constructs a command text for a custom prompt with arguments. +/// Returns the text and the cursor position (inside the first double quote). +pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) { + let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}"); + let mut cursor: usize = text.len(); + for (i, arg) in args.iter().enumerate() { + text.push_str(format!(" {arg}=\"\"").as_str()); + if i == 0 { + cursor = text.len() - 1; // inside first "" + } + } + (text, cursor) +} + #[cfg(test)] mod tests { use super::*; From 826f66a7eb8347507a1664d73ebff77b1351ee7c Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Mon, 29 Sep 2025 19:28:37 -0700 Subject: [PATCH 10/10] clippy fix --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5d52a22a73..d0018610a8 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -455,7 +455,7 @@ impl ChatComposer { PromptSelectionMode::Completion, ) { PromptSelectionAction::Insert { text, cursor } => { - let target = cursor.unwrap_or_else(|| text.len()); + let target = cursor.unwrap_or(text.len()); self.textarea.set_text(&text); cursor_target = Some(target); } @@ -507,7 +507,7 @@ impl ChatComposer { return (InputResult::Submitted(text), true); } PromptSelectionAction::Insert { text, cursor } => { - let target = cursor.unwrap_or_else(|| text.len()); + let target = cursor.unwrap_or(text.len()); self.textarea.set_text(&text); self.textarea.set_cursor(target); return (InputResult::None, true);