diff --git a/codex-rs/core/src/custom_prompts.rs b/codex-rs/core/src/custom_prompts.rs index 357abef55b..e497e51c09 100644 --- a/codex-rs/core/src/custom_prompts.rs +++ b/codex-rs/core/src/custom_prompts.rs @@ -63,16 +63,77 @@ pub async fn discover_prompts_in_excluding( Ok(s) => s, Err(_) => continue, }; + // Extract optional description from simple front matter and strip it from the body. + // We support a minimal subset: + // ---\n + // description: \n + // ---\n + let (description, body) = parse_front_matter_description_and_body(&content); out.push(CustomPrompt { name, path, - content, + content: body, + description, }); } out.sort_by(|a, b| a.name.cmp(&b.name)); out } +/// Parse a minimal YAML-like front matter from the beginning of `content` and +/// return `(description, body_without_front_matter)`. +/// +/// Front matter is recognized only when the very first line is exactly `---` (ignoring +/// surrounding whitespace), and it ends at the next line that is exactly `---`. +/// Within the block, if a line with `description:` is present, its value is captured +/// (with surrounding quotes stripped) and returned. +/// If front matter is malformed (no closing `---`), returns `(None, content.to_string())`. +fn parse_front_matter_description_and_body(content: &str) -> (Option, String) { + let mut lines_iter = content.lines(); + match lines_iter.next() { + Some(first) if first.trim() == "---" => {} + _ => return (None, content.to_string()), + } + + let mut desc: Option = None; + let mut in_front = true; + let mut body_lines: Vec<&str> = Vec::new(); + + for line in content.lines().skip(1) { + let trimmed = line.trim(); + if in_front { + if trimmed == "---" { + in_front = false; + continue; + } + if let Some(rest) = trimmed.strip_prefix("description:") { + let mut val = rest.trim().to_string(); + if (val.starts_with('"') && val.ends_with('"')) + || (val.starts_with('\'') && val.ends_with('\'')) + { + val = val[1..val.len().saturating_sub(1)].to_string(); + } + if !val.is_empty() { + desc = Some(val); + } + } + } else { + body_lines.push(line); + } + } + + if in_front { + // No closing '---' + return (None, content.to_string()); + } + + let mut body = body_lines.join("\n"); + if content.ends_with('\n') && (!body.is_empty()) { + body.push('\n'); + } + (desc, body) +} + #[cfg(test)] mod tests { use super::*; @@ -124,4 +185,17 @@ mod tests { let names: Vec = found.into_iter().map(|e| e.name).collect(); assert_eq!(names, vec!["good"]); } + + #[tokio::test] + async fn parses_front_matter_description() { + let tmp = tempdir().expect("create TempDir"); + let dir = tmp.path(); + let content = b"---\ndescription: Hello world\n---\nBody text"; + fs::write(dir.join("desc.md"), content).unwrap(); + let found = discover_prompts_in(dir).await; + assert_eq!(found.len(), 1); + assert_eq!(found[0].name, "desc"); + assert_eq!(found[0].description.as_deref(), Some("Hello world")); + assert_eq!(found[0].content.as_str(), "Body text"); + } } diff --git a/codex-rs/protocol/src/custom_prompts.rs b/codex-rs/protocol/src/custom_prompts.rs index be402051b5..1520cb0d9f 100644 --- a/codex-rs/protocol/src/custom_prompts.rs +++ b/codex-rs/protocol/src/custom_prompts.rs @@ -8,4 +8,6 @@ pub struct CustomPrompt { pub name: String, pub path: PathBuf, pub content: String, + /// Optional short description (from front matter) to show in the UI popup. + pub description: Option, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 65203b5193..919440ce27 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -87,6 +87,8 @@ pub(crate) struct ChatComposer { // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, custom_prompts: Vec, + next_history_override: Option, + custom_prompts_loaded: bool, } /// Popup state – at most one can be visible at any time. @@ -130,6 +132,8 @@ impl ChatComposer { paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), + next_history_override: None, + custom_prompts_loaded: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -416,29 +420,42 @@ impl ChatComposer { .. } => { 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) - } - _ => None, - }; - // Hide popup since an action has been dispatched. - self.active_popup = ActivePopup::None; - + // Capture raw input for potential history override and compute action + let raw_input = self.textarea.text().to_string(); + let mut maybe_builtin: Option = None; + let mut maybe_submission: Option = None; match sel { CommandItem::Builtin(cmd) => { - return (InputResult::Command(cmd), true); + maybe_builtin = Some(cmd); } - CommandItem::UserPrompt(_) => { - if let Some(contents) = prompt_content { - return (InputResult::Submitted(contents), true); - } - return (InputResult::None, true); + CommandItem::UserPrompt(idx) => { + let template = popup + .prompt_content(idx) + .map(|s| s.to_string()) + .unwrap_or_default(); + let prompt_name = popup.prompt_name(idx).unwrap_or(""); + let args = Self::extract_args_from_input(&raw_input); + let final_text = + Self::substitute_prompt_args(&template, prompt_name, &args); + maybe_submission = Some(final_text); + // For custom prompts, record the raw input as a local history entry + // and also set a one-shot override for persistent history. + self.history.record_local_submission(&raw_input); + self.set_next_history_override(raw_input); } } + + // Clear textarea and hide popup since an action has been dispatched. + self.textarea.set_text(""); + self.active_popup = ActivePopup::None; + + if let Some(cmd) = maybe_builtin { + return (InputResult::Command(cmd), true); + } + if let Some(text) = maybe_submission { + return (InputResult::Submitted(text), true); + } + return (InputResult::None, true); } // Fallback to default newline handling if no command selected. self.handle_key_event_without_popup(key_event) @@ -744,6 +761,11 @@ impl ChatComposer { code: KeyCode::Up | KeyCode::Down, .. } => { + // Block history search for slash commands until custom prompts have loaded + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if first_line.starts_with('/') && !self.custom_prompts_loaded { + return self.handle_input_basic(key_event); + } if self .history .should_handle_navigation(self.textarea.text(), self.textarea.cursor()) @@ -828,6 +850,27 @@ impl ChatComposer { return (InputResult::None, true); } if !text.is_empty() { + // If this is a typed custom prompt like "/name ..." and prompts are loaded, + // expand it immediately instead of sending the literal slash text. + if text.starts_with('/') && self.custom_prompts_loaded { + // Extract the command token and args + let first = text.lines().next().unwrap_or(""); + let stripped = first.trim_start_matches('/'); + let mut parts = stripped.split_whitespace(); + let cmd_token = parts.next().unwrap_or(""); + if let Some(prompt) = + self.custom_prompts.iter().find(|p| p.name == cmd_token) + { + let args = Self::extract_args_from_input(&text); + let final_text = + Self::substitute_prompt_args(&prompt.content, cmd_token, &args); + let full_text = first.to_string(); + self.history.record_local_submission(&full_text); + self.set_next_history_override(full_text); + return (InputResult::Submitted(final_text), true); + } + } + // Otherwise, record literal text in local (in-session) history self.history.record_local_submission(&text); } // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). @@ -1167,11 +1210,104 @@ impl ChatComposer { pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.custom_prompts = prompts.clone(); + self.custom_prompts_loaded = true; if let ActivePopup::Command(popup) = &mut self.active_popup { popup.set_prompts(prompts); } } + pub(crate) fn set_next_history_override(&mut self, text: String) { + self.next_history_override = Some(text); + } + + pub(crate) fn take_next_history_override(&mut self) -> Option { + self.next_history_override.take() + } + /// Extract positional arguments from raw input that may contain a slash command. + /// - Parses args from the first line after the command token (e.g. "/name arg1 arg2"). + /// - If there is multiline remainder, it is appended to the last positional argument + /// with a leading '\n'. If there are no positionals, the remainder becomes a single + /// argument starting with a leading '\n'. This preserves the user's exact newline + /// boundary when `$ARGUMENTS` is expanded with a simple join(" "). + fn extract_args_from_input(raw_input: &str) -> Vec { + let first_line = raw_input.lines().next().unwrap_or(""); + let Some(stripped) = first_line.strip_prefix('/') else { + return Vec::new(); + }; + let token = stripped.trim_start(); + let mut parts = token.split_whitespace(); + let _cmd = parts.next(); + let mut args: Vec = parts.map(|s| s.to_string()).collect(); + if let Some(rest) = raw_input.splitn(2, '\n').nth(1) { + if !rest.is_empty() { + if let Some(last) = args.last_mut() { + last.push('\n'); + last.push_str(rest); + } else { + args.push(format!("\n{rest}")); + } + } + } + args + } + + /// Substitute $ARGUMENTS and $0..$9 tokens in `template`. + /// - `$0` expands to the `prompt_name`. + /// - `$1..$9` expand to positional arguments if present; otherwise the + /// token is preserved literally (e.g., `$9`). + /// - `$ARGUMENTS` expands to all args joined by a single space; if there + /// are no args it is preserved literally as `$ARGUMENTS`. + fn substitute_prompt_args(template: &str, prompt_name: &str, args: &[String]) -> String { + let mut out = String::with_capacity(template.len()); + let bytes = template.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'$' { + // Try $ARGUMENTS + if template[i..].starts_with("$ARGUMENTS") { + if args.is_empty() { + out.push_str("$ARGUMENTS"); + } else { + out.push_str(&args.join(" ")); + } + i += "$ARGUMENTS".len(); + continue; + } + // Try $0..$9 + if i + 1 < bytes.len() { + let d = bytes[i + 1]; + if d.is_ascii_digit() { + if d == b'0' { + out.push_str(prompt_name); + } else { + let idx = (d - b'1') as usize; + if let Some(val) = args.get(idx) { + out.push_str(val); + } else { + out.push('$'); + out.push(char::from(d)); + } + } + i += 2; + continue; + } + } + // Not a recognized token, emit '$' literally + out.push('$'); + i += 1; + } else { + // Advance over a full UTF-8 character instead of a single byte. + if let Some(ch) = template[i..].chars().next() { + out.push(ch); + i += ch.len_utf8(); + } else { + break; + } + } + } + out + } + /// Synchronize `self.file_search_popup` with the current text in the textarea. /// Note this is only called when self.active_popup is NOT Command. fn sync_file_search_popup(&mut self) { @@ -2333,6 +2469,7 @@ mod tests { name: "my-prompt".to_string(), path: "/tmp/my-prompt.md".to_string().into(), content: prompt_text.to_string(), + description: None, }]); type_chars_humanlike( @@ -2445,4 +2582,141 @@ mod tests { assert_eq!(composer.textarea.text(), "z".repeat(count)); assert!(composer.pending_pastes.is_empty()); } + + #[test] + fn custom_prompt_substitutes_arguments_joined() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let template = "Hello, $ARGUMENTS. My name is codex."; + 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, + ); + // Use a custom prompt named "test" backed by a test.md path. + composer.set_custom_prompts(vec![CustomPrompt { + name: "test".to_string(), + path: "/tmp/test.md".to_string().into(), + content: template.to_string(), + description: None, + }]); + + // Provide a multiline input; the remaining lines should be appended as + // the last argument so that $ARGUMENTS covers the full input. + composer.handle_paste("/test Claude\nand a second line".to_string()); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + InputResult::Submitted( + "Hello, Claude\nand a second line. My name is codex.".to_string() + ), + result + ); + } + + #[test] + fn custom_prompt_substitutes_positional_and_prompt_name() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let template = "[$0] Hello, $1. My name is $2."; + 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: "test1".to_string(), + path: "/tmp/test1.md".to_string().into(), + content: template.to_string(), + description: None, + }]); + type_chars_humanlike( + &mut composer, + &[ + '/', 't', 'e', 's', 't', '1', ' ', 'C', 'l', 'a', 'u', 'd', 'e', ' ', 'C', 'o', + 'd', 'e', 'x', + ], + ); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + InputResult::Submitted("[test1] Hello, Claude. My name is Codex.".to_string()), + result + ); + } + + #[test] + fn custom_prompt_preserves_missing_positionals() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let template = "A[$1][$2][$9] end"; + 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: "test2".to_string(), + path: "/tmp/test2.md".to_string().into(), + content: template.to_string(), + description: None, + }]); + + // Provide only one positional argument; $2 and $9 should remain verbatim. + type_chars_humanlike(&mut composer, &['/', 't', 'e', 's', 't', '2', ' ', 'X']); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + InputResult::Submitted("A[X][$2][$9] end".to_string()), + result + ); + } + + #[test] + fn substitute_prompt_args_allows_multiline_arguments() { + // $ARGUMENTS should preserve embedded newlines inside individual args. + let template = "X:$ARGUMENTS:Y"; + let prompt_name = "name"; + let args = vec!["one\nline2".to_string(), "last".to_string()]; + let out = ChatComposer::substitute_prompt_args(template, prompt_name, &args); + assert_eq!(out, "X:one\nline2 last:Y"); + } + + #[test] + fn substitute_prompt_args_preserves_utf8_and_expands_tokens() { + // Non-ASCII characters should remain intact after substitution. + let template = "안녕하세요 세계 — $0 — $1 — $ARGUMENTS — 끝"; + let prompt_name = "테스트명"; + let args = vec!["첫째".to_string(), "둘째".to_string()]; + let out = ChatComposer::substitute_prompt_args(template, prompt_name, &args); + assert_eq!(out, "안녕하세요 세계 — 테스트명 — 첫째 — 첫째 둘째 — 끝"); + } + + #[test] + fn substitute_prompt_args_preserves_literal_when_missing() { + // When arguments are missing, $ARGUMENTS and $9 should be preserved literally. + let template = "Привет мир $ARGUMENTS $9"; + let prompt_name = "имя"; + let args: Vec = vec![]; + let out = ChatComposer::substitute_prompt_args(template, prompt_name, &args); + assert_eq!(out, "Привет мир $ARGUMENTS $9"); + } } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index a0933f4ae6..a35fd43e26 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -161,10 +161,15 @@ 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]; + let desc = prompt + .description + .as_deref() + .unwrap_or("send saved prompt") + .to_string(); + (format!("/{}", prompt.name), desc) + } }; GenericDisplayRow { name, @@ -276,11 +281,13 @@ mod tests { name: "foo".to_string(), path: "/tmp/foo.md".to_string().into(), content: "hello from foo".to_string(), + description: None, }, CustomPrompt { name: "bar".to_string(), path: "/tmp/bar.md".to_string().into(), content: "hello from bar".to_string(), + description: None, }, ]; let popup = CommandPopup::new(prompts); @@ -303,6 +310,7 @@ mod tests { name: "init".to_string(), path: "/tmp/init.md".to_string().into(), content: "should be ignored".to_string(), + description: None, }]); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index fc9f06c39d..6c8a2baff3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -433,6 +433,11 @@ impl BottomPane { self.composer.set_history_metadata(log_id, entry_count); } + /// Take and clear the one-shot history override captured by the composer. + pub(crate) fn take_next_history_override(&mut self) -> Option { + self.composer.take_next_history_override() + } + pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { self.composer.flush_paste_burst_if_due() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fc36809672..bd31a84a0c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -202,6 +202,8 @@ pub(crate) struct ChatWidget { struct UserMessage { text: String, image_paths: Vec, + // Optional override to record in persistent history instead of `text`. + history_override: Option, } impl From for UserMessage { @@ -209,6 +211,7 @@ impl From for UserMessage { Self { text, image_paths: Vec::new(), + history_override: None, } } } @@ -217,7 +220,11 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio if text.is_empty() && image_paths.is_empty() { None } else { - Some(UserMessage { text, image_paths }) + Some(UserMessage { + text, + image_paths, + history_override: None, + }) } } @@ -906,6 +913,7 @@ impl ChatWidget { let user_message = UserMessage { text, image_paths: self.bottom_pane.take_recent_submission_images(), + history_override: self.bottom_pane.take_next_history_override(), }; if self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); @@ -1087,7 +1095,11 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let UserMessage { text, image_paths } = user_message; + let UserMessage { + text, + image_paths, + history_override, + } = user_message; let mut items: Vec = Vec::new(); if !text.is_empty() { @@ -1110,8 +1122,11 @@ impl ChatWidget { // Persist the text to cross-session message history. if !text.is_empty() { + let history_text = history_override + .or_else(|| self.bottom_pane.take_next_history_override()) + .unwrap_or_else(|| text.clone()); self.codex_op_tx - .send(Op::AddToHistory { text: text.clone() }) + .send(Op::AddToHistory { text: history_text }) .unwrap_or_else(|e| { tracing::error!("failed to send AddHistory op: {e}"); }); diff --git a/docs/prompts.md b/docs/prompts.md index b98240d2ad..14822c6080 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -6,6 +6,9 @@ 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. +- Argument substitution: Use `$0` (prompt name), `$1..$9` (positionals), and `$ARGUMENTS` (all args joined). Missing positionals remain literal. Works both when selecting from the popup and when typing `/name args` directly. +- Description in popup: Add a minimal front matter block at the top (`---` + `description: ...` + `---`); the block is stripped from the submitted body. +- History (custom prompts): Saved in history and reusable (previously not supported). - 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 +16,5 @@ 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. + +