From b9e46375f8b4b3a1b4cce855012caf6545370bf4 Mon Sep 17 00:00:00 2001 From: Daniel Edrisian Date: Mon, 29 Sep 2025 15:48:48 -0700 Subject: [PATCH] Numeric custom prompt args (cherry picked from https://github.com/openai/codex/pull/3565) --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 290 ++++++++++++++++-- codex-rs/tui/src/bottom_pane/command_popup.rs | 13 +- codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/bottom_pane/prompt_args.rs | 151 +++++++++ docs/prompts.md | 5 + 5 files changed, 421 insertions(+), 39 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/prompt_args.rs diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index f9e9dd769d..41e7f87981 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -24,6 +24,10 @@ 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; use crate::style::user_message_style; use crate::terminal_palette; @@ -387,6 +391,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 = None; match sel { CommandItem::Builtin(cmd) => { let starts_with_cmd = first_line @@ -395,21 +400,27 @@ 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 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} ")); + self.textarea.set_text(format!("/{name} ").as_str()); + } + if !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) @@ -419,26 +430,49 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - if let Some(sel) = popup.selected_item() { - // Clear textarea so no residual text remains. + // 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((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(""); - // Capture any needed data from popup before clearing it. - let prompt_content = match sel { - CommandItem::UserPrompt(idx) => { - popup.prompt_content(idx).map(str::to_string) - } - _ => None, - }; - // Hide popup since an action has been dispatched. - self.active_popup = ActivePopup::None; + 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 has_numeric = prompt_has_numeric_placeholders(&prompt.content); + + if !has_numeric { + // No placeholders at all: auto-submit the literal content + 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); + self.textarea.set_text(&text); + self.textarea.set_cursor(self.textarea.text().len()); + } } return (InputResult::None, true); } @@ -450,6 +484,7 @@ impl ChatComposer { input => self.handle_input_basic(input), } } + #[inline] fn clamp_to_char_boundary(text: &str, pos: usize) -> usize { let mut p = pos.min(text.len()); @@ -714,16 +749,26 @@ impl ChatComposer { .unwrap_or(after_cursor.len()); let end_idx = safe_cursor + end_rel_idx; + // If the path contains whitespace, wrap it in double quotes so the + // local prompt arg parser treats it as a single argument. Avoid adding + // quotes when the path already contains one to keep behavior simple. + let needs_quotes = path.chars().any(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); } @@ -809,6 +854,7 @@ 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); @@ -828,6 +874,13 @@ 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() + { + text = expanded; + } + if text.is_empty() && !has_attachments { return (InputResult::None, true); } @@ -1149,18 +1202,43 @@ 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 { + // Compute the end of the initial '/name' token (name may be empty yet). + 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); @@ -1304,6 +1382,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; @@ -1793,6 +1872,18 @@ mod tests { assert!(composer.textarea.is_empty(), "composer should be cleared"); } + #[test] + fn extract_args_supports_quoted_paths_single_arg() { + let 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"); + assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]); + } + #[test] fn slash_tab_completion_moves_cursor_to_end() { use crossterm::event::KeyCode; @@ -2240,7 +2331,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::(); @@ -2271,6 +2362,145 @@ 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 selecting_custom_prompt_with_args_expands_placeholders() { + // Support $1..$9 and $ARGUMENTS in prompt content. + let prompt_text = "Header: $1\nArgs: $ARGUMENTS\nNinth: $9\n"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + // Type the slash command with two args and hit Enter to submit. + type_chars_humanlike( + &mut composer, + &[ + '/', 'm', 'y', '-', 'p', 'r', 'o', 'm', 'p', 't', ' ', 'f', 'o', 'o', ' ', 'b', + 'a', 'r', + ], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string(); + assert_eq!(InputResult::Submitted(expected), result); + } + + #[test] + fn selecting_custom_prompt_with_no_args_inserts_template() { + let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "p".to_string(), + path: "/tmp/p.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike(&mut composer, &['/', 'p']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + // 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::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "price".to_string(), + path: "/tmp/price.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike(&mut composer, &['/', 'p', 'r', 'i', 'c', 'e', ' ', 'x']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + InputResult::Submitted("Cost: $$ and first: x".to_string()), + result + ); + } + + #[test] + fn selecting_custom_prompt_reuses_cached_arguments_join() { + let prompt_text = "First: $ARGUMENTS\nSecond: $ARGUMENTS"; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "repeat".to_string(), + path: "/tmp/repeat.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'r', 'e', 'p', 'e', 'a', 't', ' ', 'o', 'n', 'e', ' ', 't', 'w', 'o', + ], + ); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let expected = "First: one two\nSecond: one two".to_string(); + assert_eq!(InputResult::Submitted(expected), result); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index f002acfe1e..cda0a8464d 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -53,12 +53,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 @@ -218,7 +214,6 @@ impl WidgetRef for CommandPopup { #[cfg(test)] mod tests { use super::*; - use std::string::ToString; #[test] fn filter_includes_init_when_typing_prefix() { @@ -292,7 +287,7 @@ mod tests { let mut prompt_names: Vec = 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(); @@ -312,7 +307,7 @@ mod tests { }]); 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!( diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a01ffb498f..dd72ebaab8 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -26,6 +26,7 @@ pub mod custom_prompt_view; mod file_search_popup; mod footer; mod list_selection_view; +mod prompt_args; pub(crate) use list_selection_view::SelectionViewParams; mod paste_burst; pub mod popup_consts; diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs new file mode 100644 index 0000000000..a0c67b6e29 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -0,0 +1,151 @@ +use codex_protocol::custom_prompts::CustomPrompt; +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 `/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, ()> { + let Some((name, rest)) = parse_slash_name(text) else { + return Ok(None); + }; + + let prompt = match custom_prompts.iter().find(|p| p.name == 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(); + }; + 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_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 +} diff --git a/docs/prompts.md b/docs/prompts.md index b98240d2ad..5157c4ecea 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -6,6 +6,11 @@ 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"`. - 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.