From 48144a7fa41e2343c7622f01e82eb6065e29b7b7 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 28 Mar 2026 13:49:37 -0600 Subject: [PATCH] Remove remaining custom prompt support (#16115) ## Summary - remove protocol and core support for discovering and listing custom prompts - simplify the TUI slash-command flow and command popup to built-in commands only - delete obsolete custom prompt tests, helpers, and docs references - clean up downstream event handling for the removed protocol events --- codex-rs/core/src/codex.rs | 24 - codex-rs/core/src/custom_prompts.rs | 149 -- codex-rs/core/src/custom_prompts_tests.rs | 95 -- codex-rs/core/src/lib.rs | 1 - codex-rs/mcp-server/src/codex_tool_runner.rs | 1 - codex-rs/protocol/src/custom_prompts.rs | 20 - codex-rs/protocol/src/lib.rs | 1 - codex-rs/protocol/src/protocol.rs | 14 - codex-rs/rollout/src/policy.rs | 1 - codex-rs/tui/src/bottom_pane/chat_composer.rs | 1338 +---------------- codex-rs/tui/src/bottom_pane/command_popup.rs | 326 +--- codex-rs/tui/src/bottom_pane/prompt_args.rs | 829 ---------- codex-rs/tui/src/chatwidget.rs | 3 - docs/prompts.md | 3 - docs/tui-chat-composer.md | 20 +- 15 files changed, 114 insertions(+), 2711 deletions(-) delete mode 100644 codex-rs/core/src/custom_prompts.rs delete mode 100644 codex-rs/core/src/custom_prompts_tests.rs delete mode 100644 codex-rs/protocol/src/custom_prompts.rs delete mode 100644 docs/prompts.md diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9400af721..4dca8dc68 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4385,10 +4385,6 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::reload_user_config(&sess).await; false } - Op::ListCustomPrompts => { - handlers::list_custom_prompts(&sess, sub.id.clone()).await; - false - } Op::ListSkills { cwds, force_reload } => { handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await; false @@ -4514,13 +4510,11 @@ mod handlers { use crate::tasks::UserShellCommandMode; use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; - use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InterAgentCommunication; - use codex_protocol::protocol::ListCustomPromptsResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; @@ -4907,23 +4901,6 @@ mod handlers { sess.send_event_raw(event).await; } - pub async fn list_custom_prompts(sess: &Session, sub_id: String) { - let custom_prompts: Vec = - if let Some(dir) = crate::custom_prompts::default_prompts_dir() { - crate::custom_prompts::discover_prompts_in(&dir).await - } else { - Vec::new() - }; - - let event = Event { - id: sub_id, - msg: EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent { - custom_prompts, - }), - }; - sess.send_event_raw(event).await; - } - pub async fn list_skills( sess: &Session, sub_id: String, @@ -6845,7 +6822,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::TurnDiff(_) | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) diff --git a/codex-rs/core/src/custom_prompts.rs b/codex-rs/core/src/custom_prompts.rs deleted file mode 100644 index 54ccaa62f..000000000 --- a/codex-rs/core/src/custom_prompts.rs +++ /dev/null @@ -1,149 +0,0 @@ -use codex_protocol::custom_prompts::CustomPrompt; -use std::collections::HashSet; -use std::path::Path; -use std::path::PathBuf; -use tokio::fs; - -/// Return the default prompts directory: `$CODEX_HOME/prompts`. -/// If `CODEX_HOME` cannot be resolved, returns `None`. -pub fn default_prompts_dir() -> Option { - crate::config::find_codex_home() - .ok() - .map(|home| home.join("prompts")) -} - -/// Discover prompt files in the given directory, returning entries sorted by name. -/// Non-files are ignored. If the directory does not exist or cannot be read, returns empty. -pub async fn discover_prompts_in(dir: &Path) -> Vec { - discover_prompts_in_excluding(dir, &HashSet::new()).await -} - -/// Discover prompt files in the given directory, excluding any with names in `exclude`. -/// Returns entries sorted by name. Non-files are ignored. Missing/unreadable dir yields empty. -pub async fn discover_prompts_in_excluding( - dir: &Path, - exclude: &HashSet, -) -> Vec { - let mut out: Vec = Vec::new(); - let mut entries = match fs::read_dir(dir).await { - Ok(entries) => entries, - Err(_) => return out, - }; - - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - let is_file_like = fs::metadata(&path) - .await - .map(|m| m.is_file()) - .unwrap_or(false); - if !is_file_like { - continue; - } - // Only include Markdown files with a .md extension. - let is_md = path - .extension() - .and_then(|s| s.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("md")) - .unwrap_or(false); - if !is_md { - continue; - } - let Some(name) = path - .file_stem() - .and_then(|s| s.to_str()) - .map(str::to_string) - else { - continue; - }; - if exclude.contains(&name) { - continue; - } - let content = match fs::read_to_string(&path).await { - Ok(s) => s, - Err(_) => continue, - }; - let (description, argument_hint, body) = parse_frontmatter(&content); - out.push(CustomPrompt { - name, - path, - content: body, - description, - argument_hint, - }); - } - out.sort_by(|a, b| a.name.cmp(&b.name)); - out -} - -/// Parse optional YAML-like frontmatter at the beginning of `content`. -/// Supported keys: -/// - `description`: short description shown in the slash popup -/// - `argument-hint` or `argument_hint`: brief hint string shown after the description -/// Returns (description, argument_hint, body_without_frontmatter). -fn parse_frontmatter(content: &str) -> (Option, Option, String) { - let mut segments = content.split_inclusive('\n'); - let Some(first_segment) = segments.next() else { - return (None, None, String::new()); - }; - let first_line = first_segment.trim_end_matches(['\r', '\n']); - if first_line.trim() != "---" { - return (None, None, content.to_string()); - } - - let mut desc: Option = None; - let mut hint: Option = None; - let mut frontmatter_closed = false; - let mut consumed = first_segment.len(); - - for segment in segments { - let line = segment.trim_end_matches(['\r', '\n']); - let trimmed = line.trim(); - - if trimmed == "---" { - frontmatter_closed = true; - consumed += segment.len(); - break; - } - - if trimmed.is_empty() || trimmed.starts_with('#') { - consumed += segment.len(); - continue; - } - - if let Some((k, v)) = trimmed.split_once(':') { - let key = k.trim().to_ascii_lowercase(); - let mut val = v.trim().to_string(); - if val.len() >= 2 { - let bytes = val.as_bytes(); - let first = bytes[0]; - let last = bytes[bytes.len() - 1]; - if (first == b'\"' && last == b'\"') || (first == b'\'' && last == b'\'') { - val = val[1..val.len().saturating_sub(1)].to_string(); - } - } - match key.as_str() { - "description" => desc = Some(val), - "argument-hint" | "argument_hint" => hint = Some(val), - _ => {} - } - } - - consumed += segment.len(); - } - - if !frontmatter_closed { - // Unterminated frontmatter: treat input as-is. - return (None, None, content.to_string()); - } - - let body = if consumed >= content.len() { - String::new() - } else { - content[consumed..].to_string() - }; - (desc, hint, body) -} - -#[cfg(test)] -#[path = "custom_prompts_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/custom_prompts_tests.rs b/codex-rs/core/src/custom_prompts_tests.rs deleted file mode 100644 index b1208a04e..000000000 --- a/codex-rs/core/src/custom_prompts_tests.rs +++ /dev/null @@ -1,95 +0,0 @@ -use super::*; -use std::fs; -use tempfile::tempdir; - -#[tokio::test] -async fn empty_when_dir_missing() { - let tmp = tempdir().expect("create TempDir"); - let missing = tmp.path().join("nope"); - let found = discover_prompts_in(&missing).await; - assert!(found.is_empty()); -} - -#[tokio::test] -async fn discovers_and_sorts_files() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - fs::write(dir.join("b.md"), b"b").unwrap(); - fs::write(dir.join("a.md"), b"a").unwrap(); - fs::create_dir(dir.join("subdir")).unwrap(); - let found = discover_prompts_in(dir).await; - let names: Vec = found.into_iter().map(|e| e.name).collect(); - assert_eq!(names, vec!["a", "b"]); -} - -#[tokio::test] -async fn excludes_builtins() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - fs::write(dir.join("init.md"), b"ignored").unwrap(); - fs::write(dir.join("foo.md"), b"ok").unwrap(); - let mut exclude = HashSet::new(); - exclude.insert("init".to_string()); - let found = discover_prompts_in_excluding(dir, &exclude).await; - let names: Vec = found.into_iter().map(|e| e.name).collect(); - assert_eq!(names, vec!["foo"]); -} - -#[tokio::test] -async fn skips_non_utf8_files() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - // Valid UTF-8 file - fs::write(dir.join("good.md"), b"hello").unwrap(); - // Invalid UTF-8 content in .md file (e.g., lone 0xFF byte) - fs::write(dir.join("bad.md"), vec![0xFF, 0xFE, b'\n']).unwrap(); - let found = discover_prompts_in(dir).await; - let names: Vec = found.into_iter().map(|e| e.name).collect(); - assert_eq!(names, vec!["good"]); -} - -#[tokio::test] -#[cfg(unix)] -async fn discovers_symlinked_md_files() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - - // Create a real file - fs::write(dir.join("real.md"), b"real content").unwrap(); - - // Create a symlink to the real file - std::os::unix::fs::symlink(dir.join("real.md"), dir.join("link.md")).unwrap(); - - let found = discover_prompts_in(dir).await; - let names: Vec = found.into_iter().map(|e| e.name).collect(); - - // Both real and link should be discovered, sorted alphabetically - assert_eq!(names, vec!["link", "real"]); -} - -#[tokio::test] -async fn parses_frontmatter_and_strips_from_body() { - let tmp = tempdir().expect("create TempDir"); - let dir = tmp.path(); - let file = dir.join("withmeta.md"); - let text = "---\nname: ignored\ndescription: \"Quick review command\"\nargument-hint: \"[file] [priority]\"\n---\nActual body with $1 and $ARGUMENTS"; - fs::write(&file, text).unwrap(); - - let found = discover_prompts_in(dir).await; - assert_eq!(found.len(), 1); - let p = &found[0]; - assert_eq!(p.name, "withmeta"); - assert_eq!(p.description.as_deref(), Some("Quick review command")); - assert_eq!(p.argument_hint.as_deref(), Some("[file] [priority]")); - // Body should not include the frontmatter delimiters. - assert_eq!(p.content, "Actual body with $1 and $ARGUMENTS"); -} - -#[test] -fn parse_frontmatter_preserves_body_newlines() { - let content = "---\r\ndescription: \"Line endings\"\r\nargument_hint: \"[arg]\"\r\n---\r\nFirst line\r\nSecond line\r\n"; - let (desc, hint, body) = parse_frontmatter(content); - assert_eq!(desc.as_deref(), Some("Line endings")); - assert_eq!(hint.as_deref(), Some("[arg]")); - assert_eq!(body, "First line\r\nSecond line\r\n"); -} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index f0390c805..5276b09de 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -30,7 +30,6 @@ pub mod config_loader; pub mod connectors; mod context_manager; mod contextual_user_message; -pub mod custom_prompts; pub use codex_utils_path::env; mod environment_context; pub mod error; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index a08f93007..64d553bbf 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -336,7 +336,6 @@ async fn run_codex_tool_session_inner( | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) | EventMsg::ExecCommandBegin(_) | EventMsg::TerminalInteraction(_) diff --git a/codex-rs/protocol/src/custom_prompts.rs b/codex-rs/protocol/src/custom_prompts.rs deleted file mode 100644 index 7472d1b4c..000000000 --- a/codex-rs/protocol/src/custom_prompts.rs +++ /dev/null @@ -1,20 +0,0 @@ -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; -use std::path::PathBuf; -use ts_rs::TS; - -/// Base namespace for custom prompt slash commands (without trailing colon). -/// Example usage forms constructed in code: -/// - Command token after '/': `"{PROMPTS_CMD_PREFIX}:name"` -/// - Full slash prefix: `"/{PROMPTS_CMD_PREFIX}:"` -pub const PROMPTS_CMD_PREFIX: &str = "prompts"; - -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] -pub struct CustomPrompt { - pub name: String, - pub path: PathBuf, - pub content: String, - pub description: Option, - pub argument_hint: Option, -} diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 56924cc50..97e204d0a 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -5,7 +5,6 @@ pub use agent_path::AgentPath; pub use thread_id::ThreadId; pub mod approvals; pub mod config_types; -pub mod custom_prompts; pub mod dynamic_tools; pub mod items; pub mod mcp; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 27e7b4e46..aa9f319ff 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -23,7 +23,6 @@ use crate::config_types::Personality; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::config_types::ServiceTier; use crate::config_types::WindowsSandboxLevel; -use crate::custom_prompts::CustomPrompt; use crate::dynamic_tools::DynamicToolCallOutputContentItem; use crate::dynamic_tools::DynamicToolCallRequest; use crate::dynamic_tools::DynamicToolResponse; @@ -451,9 +450,6 @@ pub enum Op { /// enable/disable state) without restarting the thread. ReloadUserConfig, - /// Request the list of available custom prompts. - ListCustomPrompts, - /// Request the list of skills for the provided `cwd` values or the session default. ListSkills { /// Working directories to scope repo skills discovery. @@ -595,7 +591,6 @@ impl Op { Self::ListMcpTools => "list_mcp_tools", Self::RefreshMcpServers { .. } => "refresh_mcp_servers", Self::ReloadUserConfig => "reload_user_config", - Self::ListCustomPrompts => "list_custom_prompts", Self::ListSkills { .. } => "list_skills", Self::Compact => "compact", Self::DropMemories => "drop_memories", @@ -1370,9 +1365,6 @@ pub enum EventMsg { /// List of MCP tools available to the agent. McpListToolsResponse(McpListToolsResponseEvent), - /// List of custom prompts available to the agent. - ListCustomPromptsResponse(ListCustomPromptsResponseEvent), - /// List of skills available to the agent. ListSkillsResponse(ListSkillsResponseEvent), @@ -3102,12 +3094,6 @@ impl fmt::Display for McpAuthStatus { } } -/// Response payload for `Op::ListCustomPrompts`. -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct ListCustomPromptsResponseEvent { - pub custom_prompts: Vec, -} - /// Response payload for `Op::ListSkills`. #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ListSkillsResponseEvent { diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index c4b4b8c33..8e099c38d 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -161,7 +161,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::McpListToolsResponse(_) | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) - | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a85dd131b..d8b862e63 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -39,7 +39,6 @@ //! //! - Expands pending paste placeholders so element ranges align with the final text. //! - Trims whitespace and rebases text elements accordingly. -//! - Expands `/prompts:` custom prompts (named or numeric args), preserving text elements. //! - Prunes local attached images so only placeholders that survive expansion are sent. //! - Preserves remote image URLs as separate attachments even when text is empty. //! @@ -182,24 +181,16 @@ use super::skill_popup::SkillPopup; use super::slash_commands; use super::slash_commands::BuiltinCommandFlags; 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_command_with_arg_placeholders; -use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::style::user_message_style; -use codex_protocol::custom_prompts::CustomPrompt; -use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use codex_protocol::models::local_image_label_text; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::TextElement; -use codex_utils_fuzzy_match::fuzzy_match; use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; @@ -267,22 +258,6 @@ struct AttachedImage { path: PathBuf, } -enum PromptSelectionMode { - Completion, - Submit, -} - -enum PromptSelectionAction { - Insert { - text: String, - cursor: Option, - }, - Submit { - text: String, - text_elements: Vec, - }, -} - /// Feature flags for reusing the chat composer in other bottom-pane surfaces. /// /// The default keeps today's behavior intact. Other call sites can opt out of @@ -380,7 +355,6 @@ pub(crate) struct ChatComposer { paste_burst: PasteBurst, // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, - custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, remote_image_urls: Vec, @@ -507,7 +481,6 @@ impl ChatComposer { input_disabled_placeholder: None, paste_burst: PasteBurst::default(), disable_paste_burst: false, - custom_prompts: Vec::new(), footer_mode: FooterMode::ComposerEmpty, footer_hint_override: None, remote_image_urls: Vec::new(), @@ -1415,46 +1388,21 @@ 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) => { - if cmd == SlashCommand::Skills { - self.textarea.set_text_clearing_elements(""); - return (InputResult::Command(cmd), true); - } - - let starts_with_cmd = first_line - .trim_start() - .starts_with(&format!("/{}", cmd.command())); - if !starts_with_cmd { - self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); - } - if !self.textarea.text().is_empty() { - cursor_target = Some(self.textarea.text().len()); - } - } - CommandItem::UserPrompt(idx) => { - if let Some(prompt) = popup.prompt(idx) { - match prompt_selection_action( - prompt, - first_line, - PromptSelectionMode::Completion, - &self.textarea.text_elements(), - ) { - PromptSelectionAction::Insert { text, cursor } => { - let target = cursor.unwrap_or(text.len()); - // Inserted prompt text is plain input; discard any elements. - self.textarea.set_text_clearing_elements(&text); - cursor_target = Some(target); - } - PromptSelectionAction::Submit { .. } => {} - } - } - } + let CommandItem::Builtin(cmd) = sel; + if cmd == SlashCommand::Skills { + self.textarea.set_text_clearing_elements(""); + return (InputResult::Command(cmd), true); } - if let Some(pos) = cursor_target { - self.textarea.set_cursor(pos); + + let starts_with_cmd = first_line + .trim_start() + .starts_with(&format!("/{}", cmd.command())); + if !starts_with_cmd { + self.textarea + .set_text_clearing_elements(&format!("/{} ", cmd.command())); + } + if !self.textarea.text().is_empty() { + self.textarea.set_cursor(self.textarea.text().len()); } } (InputResult::None, true) @@ -1464,82 +1412,10 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - // 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 mut text = self.textarea.text().to_string(); - let mut text_elements = self.textarea.text_elements(); - if !self.pending_pastes.is_empty() { - let (expanded, expanded_elements) = - Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); - text = expanded; - text_elements = expanded_elements; - } - let first_line = text.lines().next().unwrap_or(""); - if let Some((name, _rest, _rest_offset)) = parse_slash_name(first_line) - && 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) = - expand_if_numeric_with_positional_args(prompt, first_line, &text_elements) - { - self.prune_attached_images_for_submission( - &expanded.text, - &expanded.text_elements, - ); - self.pending_pastes.clear(); - self.textarea.set_text_clearing_elements(""); - return ( - InputResult::Submitted { - text: expanded.text, - text_elements: expanded.text_elements, - }, - true, - ); - } - if let Some(sel) = popup.selected_item() { - match sel { - CommandItem::Builtin(cmd) => { - self.textarea.set_text_clearing_elements(""); - return (InputResult::Command(cmd), true); - } - CommandItem::UserPrompt(idx) => { - if let Some(prompt) = popup.prompt(idx) { - match prompt_selection_action( - prompt, - first_line, - PromptSelectionMode::Submit, - &self.textarea.text_elements(), - ) { - PromptSelectionAction::Submit { - text, - text_elements, - } => { - self.prune_attached_images_for_submission( - &text, - &text_elements, - ); - self.textarea.set_text_clearing_elements(""); - return ( - InputResult::Submitted { - text, - text_elements, - }, - true, - ); - } - PromptSelectionAction::Insert { text, cursor } => { - let target = cursor.unwrap_or(text.len()); - // Inserted prompt text is plain input; discard any elements. - self.textarea.set_text_clearing_elements(&text); - self.textarea.set_cursor(target); - return (InputResult::None, true); - } - } - } - return (InputResult::None, true); - } - } + let CommandItem::Builtin(cmd) = sel; + self.textarea.set_text_clearing_elements(""); + return (InputResult::Command(cmd), true); } // Fallback to default newline handling if no command selected. self.handle_key_event_without_popup(key_event) @@ -2327,34 +2203,13 @@ impl ChatComposer { let is_builtin = slash_commands::find_builtin_command(name, self.builtin_command_flags()) .is_some(); - let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); - let is_custom_prompt_command = name.starts_with(&prompt_prefix); - let is_known_prompt = name - .strip_prefix(&prompt_prefix) - .map(|prompt_name| { - self.custom_prompts - .iter() - .any(|prompt| prompt.name == prompt_name) - }) - .unwrap_or(false); - if !is_builtin && !is_known_prompt { - let message = if is_custom_prompt_command && self.custom_prompts.is_empty() { - tracing::warn!("custom prompt listing/picker is not available in TUI yet"); - "Not available in TUI yet.".to_string() - } else { - format!( - r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# - ) - }; - if is_custom_prompt_command && self.custom_prompts.is_empty() { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(message), - ))); - } else { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event(message, /*hint*/ None), - ))); - } + if !is_builtin { + let message = format!( + r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, /*hint*/ None), + ))); self.set_text_content_with_mention_bindings( original_input.clone(), original_text_elements, @@ -2368,30 +2223,6 @@ impl ChatComposer { } } - if self.slash_commands_enabled() { - let expanded_prompt = - match expand_custom_prompt(&text, &text_elements, &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.set_text_content_with_mention_bindings( - original_input.clone(), - original_text_elements, - original_local_image_paths, - original_mention_bindings, - ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); - return None; - } - }; - if let Some(expanded) = expanded_prompt { - text = expanded.text; - text_elements = expanded.text_elements; - } - } let actual_chars = text.chars().count(); if actual_chars > MAX_USER_INPUT_TEXT_CHARS { let message = user_input_too_large_message(actual_chars); @@ -2408,8 +2239,6 @@ impl ChatComposer { self.textarea.set_cursor(original_input.len()); return None; } - // Custom prompt expansion can remove or rewrite image placeholders, so prune any - // attachments that no longer have a corresponding placeholder in the expanded text. self.prune_attached_images_for_submission(&text, &text_elements); if text.is_empty() && self.attached_images.is_empty() && self.remote_image_urls.is_empty() { return None; @@ -3390,20 +3219,7 @@ impl ChatComposer { } fn is_known_slash_name(&self, name: &str) -> bool { - let is_builtin = - slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some(); - if is_builtin { - return true; - } - if let Some(rest) = name.strip_prefix(PROMPTS_CMD_PREFIX) - && let Some(prompt_name) = rest.strip_prefix(':') - { - return self - .custom_prompts - .iter() - .any(|prompt| prompt.name == prompt_name); - } - false + slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some() } /// If the cursor is currently within a slash command on the first line, @@ -3435,7 +3251,7 @@ impl ChatComposer { } /// Heuristic for whether the typed slash command looks like a valid - /// prefix for any known command (built-in or custom prompt). + /// prefix for any known built-in command. /// Empty names only count when there is no extra content after the '/'. fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool { if !self.slash_commands_enabled() { @@ -3445,13 +3261,7 @@ impl ChatComposer { return rest_after_name.is_empty(); } - if slash_commands::has_builtin_prefix(name, self.builtin_command_flags()) { - return true; - } - - self.custom_prompts.iter().any(|prompt| { - fuzzy_match(&format!("{PROMPTS_CMD_PREFIX}:{}", prompt.name), name).is_some() - }) + slash_commands::has_builtin_prefix(name, self.builtin_command_flags()) } /// Synchronize `self.command_popup` with the current text in the @@ -3501,32 +3311,22 @@ impl ChatComposer { let personality_command_enabled = self.personality_command_enabled; let realtime_conversation_enabled = self.realtime_conversation_enabled; let audio_device_selection_enabled = self.audio_device_selection_enabled; - let mut command_popup = CommandPopup::new( - self.custom_prompts.clone(), - CommandPopupFlags { - collaboration_modes_enabled, - connectors_enabled, - plugins_command_enabled, - fast_command_enabled, - personality_command_enabled, - realtime_conversation_enabled, - audio_device_selection_enabled, - windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, - }, - ); + let mut command_popup = CommandPopup::new(CommandPopupFlags { + collaboration_modes_enabled, + connectors_enabled, + plugins_command_enabled, + fast_command_enabled, + personality_command_enabled, + realtime_conversation_enabled, + audio_device_selection_enabled, + windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, + }); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } } } } - #[cfg(test)] - pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { - self.custom_prompts = prompts.clone(); - if let ActivePopup::Command(popup) = &mut self.active_popup { - popup.set_prompts(prompts); - } - } /// Synchronize `self.file_search_popup` with the current text in the textarea. /// Note this is only called when self.active_popup is NOT Command. @@ -4457,62 +4257,6 @@ impl ChatComposer { } } -fn prompt_selection_action( - prompt: &CustomPrompt, - first_line: &str, - mode: PromptSelectionMode, - text_elements: &[TextElement], -) -> 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, text_elements) - { - return PromptSelectionAction::Submit { - text: expanded.text, - text_elements: expanded.text_elements, - }; - } - let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); - return PromptSelectionAction::Insert { text, cursor: None }; - } - PromptSelectionAction::Submit { - text: prompt.content.clone(), - // By now we know this custom prompt has no args, so no text elements to preserve. - text_elements: Vec::new(), - } - } - } -} - impl Drop for ChatComposer { fn drop(&mut self) { // Stop any running spinner tasks. @@ -4538,8 +4282,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::PromptArg; - use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -6716,9 +6458,6 @@ mod tests { Some(CommandItem::Builtin(cmd)) => { assert_eq!(cmd.command(), "model") } - Some(CommandItem::UserPrompt(_)) => { - panic!("unexpected prompt selected for '/mo'") - } None => panic!("no selected command for '/mo'"), }, _ => panic!("slash popup not active after typing '/mo'"), @@ -6772,9 +6511,6 @@ mod tests { Some(CommandItem::Builtin(cmd)) => { assert_eq!(cmd.command(), "resume") } - Some(CommandItem::UserPrompt(_)) => { - panic!("unexpected prompt selected for '/res'") - } None => panic!("no selected command for '/res'"), }, _ => panic!("slash popup not active after typing '/res'"), @@ -7153,44 +6889,6 @@ mod tests { assert!(composer.spinner_stop_flags.is_empty()); } - #[test] - fn extract_args_supports_quoted_paths_single_arg() { - let args = extract_positional_args_for_prompt_line( - "/prompts:review \"docs/My File.md\"", - "review", - &[], - ); - assert_eq!( - args, - vec![PromptArg { - text: "docs/My File.md".to_string(), - text_elements: Vec::new(), - }] - ); - } - - #[test] - fn extract_args_supports_mixed_quoted_and_unquoted() { - let args = extract_positional_args_for_prompt_line( - "/prompts:cmd \"with spaces\" simple", - "cmd", - &[], - ); - assert_eq!( - args, - vec![ - PromptArg { - text: "with spaces".to_string(), - text_elements: Vec::new(), - }, - PromptArg { - text: "simple".to_string(), - text_elements: Vec::new(), - } - ] - ); - } - #[test] fn slash_tab_completion_moves_cursor_to_end() { use crossterm::event::KeyCode; @@ -8494,398 +8192,6 @@ mod tests { assert_eq!(imgs, vec![tmp_path]); } - #[test] - fn selecting_custom_prompt_without_args_submits_content() { - let prompt_text = "Hello from saved prompt"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - - // Inject prompts as if received via event. - 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_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', - 'p', 't', - ], - ); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } if text == prompt_text - )); - 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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ 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_clearing_elements("/prompts:my-prompt USER=Alice BRANCH=main"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } - if text == "Review Alice changes on main" - )); - 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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ 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_clearing_elements("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } - if text == "Pair Alice Smith with dev-main" - )); - assert!(composer.textarea.is_empty()); - } - - #[test] - fn custom_prompt_submission_preserves_image_placeholder_unquoted() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $IMG".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt IMG="); - composer.textarea.set_cursor(composer.textarea.text().len()); - let path = PathBuf::from("/tmp/image_prompt.png"); - composer.attach_image(path); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - let placeholder = local_image_label_text(/*label_number*/ 1); - assert_eq!(text, format!("Review {placeholder}")); - assert_eq!( - text_elements, - vec![TextElement::new( - ByteRange { - start: "Review ".len(), - end: "Review ".len() + placeholder.len(), - }, - Some(placeholder), - )] - ); - } - _ => panic!("expected Submitted"), - } - } - - #[test] - fn custom_prompt_submission_preserves_image_placeholder_quoted() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $IMG".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt IMG=\""); - composer.textarea.set_cursor(composer.textarea.text().len()); - let path = PathBuf::from("/tmp/image_prompt_quoted.png"); - composer.attach_image(path); - composer.handle_paste("\"".to_string()); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - let placeholder = local_image_label_text(/*label_number*/ 1); - assert_eq!(text, format!("Review {placeholder}")); - assert_eq!( - text_elements, - vec![TextElement::new( - ByteRange { - start: "Review ".len(), - end: "Review ".len() + placeholder.len(), - }, - Some(placeholder), - )] - ); - } - _ => panic!("expected Submitted"), - } - } - - #[test] - fn custom_prompt_submission_drops_unused_image_arg() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review changes".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt IMG="); - composer.textarea.set_cursor(composer.textarea.text().len()); - let path = PathBuf::from("/tmp/unused_image.png"); - composer.attach_image(path); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - assert_eq!(text, "Review changes"); - assert!(text_elements.is_empty()); - } - _ => panic!("expected Submitted"), - } - assert!(composer.take_recent_submission_images().is_empty()); - } - - /// Behavior: selecting a custom prompt that includes a large paste placeholder should expand - /// to the full pasted content before submission. - #[test] - fn custom_prompt_with_large_paste_expands_correctly() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - - // Create a custom prompt with positional args (no named args like $USER) - composer.set_custom_prompts(vec![CustomPrompt { - name: "code-review".to_string(), - path: "/tmp/code-review.md".to_string().into(), - content: "Please review the following code:\n\n$1".to_string(), - description: None, - argument_hint: None, - }]); - - // Type the slash command - let command_text = "/prompts:code-review "; - composer.textarea.set_text_clearing_elements(command_text); - composer.textarea.set_cursor(command_text.len()); - - // Paste large content (>3000 chars) to trigger placeholder - let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3000); - composer.handle_paste(large_content.clone()); - - // Verify placeholder was created - let placeholder = format!("[Pasted Content {} chars]", large_content.chars().count()); - assert_eq!( - composer.textarea.text(), - format!("/prompts:code-review {}", placeholder) - ); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder); - assert_eq!(composer.pending_pastes[0].1, large_content); - - // Submit by pressing Enter - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // Verify the custom prompt was expanded with the large content as positional arg - match result { - InputResult::Submitted { text, .. } => { - // The prompt should be expanded, with the large content replacing $1 - assert_eq!( - text, - format!("Please review the following code:\n\n{}", large_content), - "Expected prompt expansion with large content as $1" - ); - } - _ => panic!("expected Submitted, got: {result:?}"), - } - assert!(composer.textarea.is_empty()); - assert!(composer.pending_pastes.is_empty()); - } - - #[test] - fn custom_prompt_with_large_paste_and_image_preserves_elements() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $IMG\n\n$CODE".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt IMG="); - composer.textarea.set_cursor(composer.textarea.text().len()); - let path = PathBuf::from("/tmp/image_prompt_combo.png"); - composer.attach_image(path); - composer.handle_paste(" CODE=".to_string()); - let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); - composer.handle_paste(large_content.clone()); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted { - text, - text_elements, - } => { - let placeholder = local_image_label_text(/*label_number*/ 1); - assert_eq!(text, format!("Review {placeholder}\n\n{large_content}")); - assert_eq!( - text_elements, - vec![TextElement::new( - ByteRange { - start: "Review ".len(), - end: "Review ".len() + placeholder.len(), - }, - Some(placeholder), - )] - ); - } - _ => panic!("expected Submitted"), - } - } - #[test] fn slash_path_input_submits_without_command_error() { use crossterm::event::KeyCode; @@ -8958,576 +8264,6 @@ mod tests { } } - #[test] - fn custom_prompt_invalid_args_reports_error() { - let (tx, mut rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ 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_clearing_elements("/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!( - "/prompts: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(/*width*/ 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_command_is_stubbed_when_prompt_listing_is_unavailable() { - let (tx, mut rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(InputResult::None, result); - assert_eq!("/prompts:my-prompt", composer.textarea.text()); - - let AppEvent::InsertHistoryCell(cell) = rx.try_recv().expect("expected stub history cell") - else { - panic!("expected stub history cell"); - }; - let message = cell - .display_lines(/*width*/ 80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(message.contains("Not available in TUI yet.")); - } - - #[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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ 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_clearing_elements("/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!("/prompts: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(/*width*/ 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. - 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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ 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, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', '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!(matches!( - result, - InputResult::Submitted { text, .. } if text == expected - )); - } - - #[test] - fn popup_prompt_submission_prunes_unused_image_attachments() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Hello".to_string(), - description: None, - argument_hint: None, - }]); - - composer.attach_image(PathBuf::from("/tmp/unused.png")); - composer.textarea.set_cursor(/*pos*/ 0); - composer.handle_paste(format!("/{PROMPTS_CMD_PREFIX}:my-prompt ")); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } if text == "Hello" - )); - assert!( - composer - .take_recent_submission_images_with_placeholders() - .is_empty() - ); - } - - #[test] - fn numeric_prompt_auto_submit_prunes_unused_image_attachments() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Hello $1".to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', - 'p', 't', ' ', 'f', 'o', 'o', ' ', - ], - ); - composer.attach_image(PathBuf::from("/tmp/unused.png")); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } if text == "Hello foo" - )); - assert!( - composer - .take_recent_submission_images_with_placeholders() - .is_empty() - ); - } - - #[test] - fn numeric_prompt_auto_submit_expands_pending_pastes() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Echo: $1".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt "); - composer.textarea.set_cursor(composer.textarea.text().len()); - let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); - composer.handle_paste(large_content.clone()); - - assert_eq!(composer.pending_pastes.len(), 1); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - let expected = format!("Echo: {large_content}"); - assert!(matches!( - result, - InputResult::Submitted { text, .. } if text == expected - )); - assert!(composer.pending_pastes.is_empty()); - } - - #[test] - fn queued_prompt_submission_prunes_unused_image_attachments() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Hello $1".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_clearing_elements("/prompts:my-prompt foo "); - composer.textarea.set_cursor(composer.textarea.text().len()); - composer.attach_image(PathBuf::from("/tmp/unused.png")); - composer.set_task_running(/*running*/ true); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Queued { text, .. } if text == "Hello foo" - )); - assert!( - composer - .take_recent_submission_images_with_placeholders() - .is_empty() - ); - } - - #[test] - fn prompt_expansion_over_character_limit_reports_error_and_restores_draft() { - let (tx, mut rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Echo: $1".to_string(), - description: None, - argument_hint: None, - }]); - - let oversized_arg = "x".repeat(MAX_USER_INPUT_TEXT_CHARS); - let original_input = format!("/prompts:my-prompt {oversized_arg}"); - composer - .textarea - .set_text_clearing_elements(&original_input); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(InputResult::None, result); - assert_eq!(composer.textarea.text(), original_input); - - let actual_chars = format!("Echo: {oversized_arg}").chars().count(); - let mut found_error = false; - while let Ok(event) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - let message = cell - .display_lines(/*width*/ 80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(message.contains(&user_input_too_large_message(actual_chars))); - found_error = true; - break; - } - } - assert!(found_error, "expected oversized-input error history cell"); - } - - #[test] - fn selecting_custom_prompt_with_positional_args_submits_numeric_expansion() { - let prompt_text = "Header: $1\nArgs: $ARGUMENTS\n"; - - let prompt = CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }; - - let action = prompt_selection_action( - &prompt, - "/prompts:my-prompt foo bar", - PromptSelectionMode::Submit, - &[], - ); - match action { - PromptSelectionAction::Submit { - text, - text_elements, - } => { - assert_eq!(text, "Header: foo\nArgs: foo bar\n"); - assert!(text_elements.is_empty()); - } - _ => panic!("expected Submit action"), - } - } - - #[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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ 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_clearing_elements("/prompts:elegant hi"); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } if text == "Echo: hi" - )); - assert!(composer.textarea.is_empty()); - } - - #[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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ 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', 'r', 'o', 'm', 'p', 't', 's', ':', '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!("/prompts: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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ 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', 'o', 'm', 'p', 't', 's', ':', 'p', 'r', 'i', 'c', 'e', ' ', 'x', - ], - ); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - result, - InputResult::Submitted { text, .. } - if text == "Cost: $$ and first: x" - )); - } - - #[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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ 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, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', '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!(matches!( - result, - InputResult::Submitted { text, .. } if text == expected - )); - } - /// Behavior: the first fast ASCII character is held briefly to avoid flicker; if no burst /// follows, it should eventually flush as normal typed input (not as a paste). #[test] diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 5ad3df5c2..8bef8ddbc 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -10,27 +10,21 @@ use super::slash_commands; use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; -use codex_protocol::custom_prompts::CustomPrompt; -use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; -use std::collections::HashSet; // Hide alias commands in the default popup list so each unique action appears once. // `quit` is an alias of `exit`, so we skip `quit` here. // `approvals` is an alias of `permissions`. const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals]; -/// A selectable item in the popup: either a built-in command or a user prompt. +/// A selectable item in the popup. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { Builtin(SlashCommand), - // Index into `prompts` - UserPrompt(usize), } pub(crate) struct CommandPopup { command_filter: String, builtins: Vec<(&'static str, SlashCommand)>, - prompts: Vec, state: ScrollState, } @@ -62,7 +56,7 @@ impl From for slash_commands::BuiltinCommandFlags { } impl CommandPopup { - pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { + pub(crate) fn new(flags: CommandPopupFlags) -> Self { // Keep built-in availability in sync with the composer. let builtins: Vec<(&'static str, SlashCommand)> = slash_commands::builtins_for_input(flags.into()) @@ -70,34 +64,13 @@ impl CommandPopup { .filter(|(name, _)| !name.starts_with("debug")) .filter(|(_, cmd)| *cmd != SlashCommand::Apps) .collect(); - // Exclude prompts that collide with builtin command names and sort by name. - let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); - prompts.retain(|p| !exclude.contains(&p.name)); - prompts.sort_by(|a, b| a.name.cmp(&b.name)); Self { command_filter: String::new(), builtins, - prompts, state: ScrollState::new(), } } - #[cfg(test)] - pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { - let exclude: HashSet = self - .builtins - .iter() - .map(|(n, _)| (*n).to_string()) - .collect(); - prompts.retain(|p| !exclude.contains(&p.name)); - prompts.sort_by(|a, b| a.name.cmp(&b.name)); - self.prompts = prompts; - } - - 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 /// passed in is expected to start with a leading '/'. Everything after the /// *first* '/' on the *first* line becomes the active filter that is used @@ -145,17 +118,12 @@ impl CommandPopup { let filter = self.command_filter.trim(); let mut out: Vec<(CommandItem, Option>)> = Vec::new(); if filter.is_empty() { - // Built-ins first, in presentation order. for (_, cmd) in self.builtins.iter() { if ALIAS_COMMANDS.contains(cmd) { continue; } out.push((CommandItem::Builtin(*cmd), None)); } - // Then prompts, already sorted by name. - for idx in 0..self.prompts.len() { - out.push((CommandItem::UserPrompt(idx), None)); - } return out; } @@ -163,7 +131,6 @@ impl CommandPopup { let filter_chars = filter.chars().count(); let mut exact: Vec<(CommandItem, Option>)> = Vec::new(); let mut prefix: Vec<(CommandItem, Option>)> = Vec::new(); - let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1; let indices_for = |offset| Some((offset..offset + filter_chars).collect()); let mut push_match = @@ -190,18 +157,6 @@ impl CommandPopup { for (_, cmd) in self.builtins.iter() { push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); } - // Support both search styles: - // - Typing "name" should surface "/prompts:name" results. - // - Typing "prompts:name" should also work. - for (idx, p) in self.prompts.iter().enumerate() { - let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name); - push_match( - CommandItem::UserPrompt(idx), - &display, - Some(&p.name), - prompt_prefix_len, - ); - } out.extend(exact); out.extend(prefix); @@ -219,22 +174,9 @@ impl CommandPopup { matches .into_iter() .map(|(item, indices)| { - let (name, description) = match item { - CommandItem::Builtin(cmd) => { - (format!("/{}", cmd.command()), cmd.description().to_string()) - } - CommandItem::UserPrompt(i) => { - let prompt = &self.prompts[i]; - let description = prompt - .description - .clone() - .unwrap_or_else(|| "send saved prompt".to_string()); - ( - format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name), - description, - ) - } - }; + let CommandItem::Builtin(cmd) = item; + let name = format!("/{}", cmd.command()); + let description = cmd.description().to_string(); GenericDisplayRow { name, name_prefix_spans: Vec::new(), @@ -297,7 +239,7 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default()); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -307,7 +249,6 @@ mod tests { let matches = popup.filtered_items(); let has_init = matches.iter().any(|item| match item { CommandItem::Builtin(cmd) => cmd.command() == "init", - CommandItem::UserPrompt(_) => false, }); assert!( has_init, @@ -317,7 +258,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default()); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -325,144 +266,46 @@ mod tests { let selected = popup.selected_item(); match selected { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), - Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"), None => panic!("expected a selected command for exact match"), } } #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default()); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), - Some(CommandItem::UserPrompt(_)) => { - panic!("unexpected prompt ranked before '/model' for '/mo'") - } None => panic!("expected at least one match for '/mo'"), } } #[test] fn filtered_commands_keep_presentation_order_for_prefix() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default()); popup.on_composer_text_change("/m".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .filter_map(|item| match item { - CommandItem::Builtin(cmd) => Some(cmd.command()), - CommandItem::UserPrompt(_) => None, + .map(|item| match item { + CommandItem::Builtin(cmd) => cmd.command(), }) .collect(); assert_eq!(cmds, vec!["model", "mention", "mcp"]); } - #[test] - fn prompt_discovery_lists_custom_prompts() { - let prompts = vec![ - CustomPrompt { - name: "foo".to_string(), - path: "/tmp/foo.md".to_string().into(), - content: "hello from foo".to_string(), - description: None, - argument_hint: None, - }, - CustomPrompt { - name: "bar".to_string(), - path: "/tmp/bar.md".to_string().into(), - content: "hello from bar".to_string(), - description: None, - argument_hint: None, - }, - ]; - let popup = CommandPopup::new(prompts, CommandPopupFlags::default()); - let items = popup.filtered_items(); - let mut prompt_names: Vec = items - .into_iter() - .filter_map(|it| match it { - CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()), - _ => None, - }) - .collect(); - prompt_names.sort(); - assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]); - } - - #[test] - fn prompt_name_collision_with_builtin_is_ignored() { - // Create a prompt named like a builtin (e.g. "init"). - let popup = CommandPopup::new( - vec![CustomPrompt { - name: "init".to_string(), - path: "/tmp/init.md".to_string().into(), - content: "should be ignored".to_string(), - description: None, - argument_hint: None, - }], - CommandPopupFlags::default(), - ); - let items = popup.filtered_items(); - let has_collision_prompt = items.into_iter().any(|it| match it { - CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"), - _ => false, - }); - assert!( - !has_collision_prompt, - "prompt with builtin name should be ignored" - ); - } - - #[test] - fn prompt_description_uses_frontmatter_metadata() { - let popup = CommandPopup::new( - vec![CustomPrompt { - name: "draftpr".to_string(), - path: "/tmp/draftpr.md".to_string().into(), - content: "body".to_string(), - description: Some("Create feature branch, commit and open draft PR.".to_string()), - argument_hint: None, - }], - CommandPopupFlags::default(), - ); - let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]); - let description = rows.first().and_then(|row| row.description.as_deref()); - assert_eq!( - description, - Some("Create feature branch, commit and open draft PR.") - ); - } - - #[test] - fn prompt_description_falls_back_when_missing() { - let popup = CommandPopup::new( - vec![CustomPrompt { - name: "foo".to_string(), - path: "/tmp/foo.md".to_string().into(), - content: "body".to_string(), - description: None, - argument_hint: None, - }], - CommandPopupFlags::default(), - ); - let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]); - let description = rows.first().and_then(|row| row.description.as_deref()); - assert_eq!(description, Some("send saved prompt")); - } - #[test] fn prefix_filter_limits_matches_for_ac() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default()); popup.on_composer_text_change("/ac".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .filter_map(|item| match item { - CommandItem::Builtin(cmd) => Some(cmd.command()), - CommandItem::UserPrompt(_) => None, + .map(|item| match item { + CommandItem::Builtin(cmd) => cmd.command(), }) .collect(); assert!( @@ -473,7 +316,7 @@ mod tests { #[test] fn quit_hidden_in_empty_filter_but_shown_for_prefix() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default()); popup.on_composer_text_change("/".to_string()); let items = popup.filtered_items(); assert!(!items.contains(&CommandItem::Builtin(SlashCommand::Quit))); @@ -485,15 +328,14 @@ mod tests { #[test] fn collab_command_hidden_when_collaboration_modes_disabled() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + let mut popup = CommandPopup::new(CommandPopupFlags::default()); popup.on_composer_text_change("/".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .filter_map(|item| match item { - CommandItem::Builtin(cmd) => Some(cmd.command()), - CommandItem::UserPrompt(_) => None, + .map(|item| match item { + CommandItem::Builtin(cmd) => cmd.command(), }) .collect(); assert!( @@ -508,19 +350,16 @@ mod tests { #[test] fn collab_command_visible_when_collaboration_modes_enabled() { - let mut popup = CommandPopup::new( - Vec::new(), - CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - personality_command_enabled: true, - realtime_conversation_enabled: false, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - }, - ); + let mut popup = CommandPopup::new(CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + fast_command_enabled: false, + personality_command_enabled: true, + realtime_conversation_enabled: false, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + }); popup.on_composer_text_change("/collab".to_string()); match popup.selected_item() { @@ -531,19 +370,16 @@ mod tests { #[test] fn plan_command_visible_when_collaboration_modes_enabled() { - let mut popup = CommandPopup::new( - Vec::new(), - CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - personality_command_enabled: true, - realtime_conversation_enabled: false, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - }, - ); + let mut popup = CommandPopup::new(CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + fast_command_enabled: false, + personality_command_enabled: true, + realtime_conversation_enabled: false, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + }); popup.on_composer_text_change("/plan".to_string()); match popup.selected_item() { @@ -554,27 +390,23 @@ mod tests { #[test] fn personality_command_hidden_when_disabled() { - let mut popup = CommandPopup::new( - Vec::new(), - CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - personality_command_enabled: false, - realtime_conversation_enabled: false, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - }, - ); + let mut popup = CommandPopup::new(CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + fast_command_enabled: false, + personality_command_enabled: false, + realtime_conversation_enabled: false, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + }); popup.on_composer_text_change("/pers".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .filter_map(|item| match item { - CommandItem::Builtin(cmd) => Some(cmd.command()), - CommandItem::UserPrompt(_) => None, + .map(|item| match item { + CommandItem::Builtin(cmd) => cmd.command(), }) .collect(); assert!( @@ -585,19 +417,16 @@ mod tests { #[test] fn personality_command_visible_when_enabled() { - let mut popup = CommandPopup::new( - Vec::new(), - CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - personality_command_enabled: true, - realtime_conversation_enabled: false, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - }, - ); + let mut popup = CommandPopup::new(CommandPopupFlags { + collaboration_modes_enabled: true, + connectors_enabled: false, + plugins_command_enabled: false, + fast_command_enabled: false, + personality_command_enabled: true, + realtime_conversation_enabled: false, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + }); popup.on_composer_text_change("/personality".to_string()); match popup.selected_item() { @@ -608,27 +437,23 @@ mod tests { #[test] fn settings_command_hidden_when_audio_device_selection_is_disabled() { - let mut popup = CommandPopup::new( - Vec::new(), - CommandPopupFlags { - collaboration_modes_enabled: false, - connectors_enabled: false, - plugins_command_enabled: false, - fast_command_enabled: false, - personality_command_enabled: true, - realtime_conversation_enabled: true, - audio_device_selection_enabled: false, - windows_degraded_sandbox_active: false, - }, - ); + let mut popup = CommandPopup::new(CommandPopupFlags { + collaboration_modes_enabled: false, + connectors_enabled: false, + plugins_command_enabled: false, + fast_command_enabled: false, + personality_command_enabled: true, + realtime_conversation_enabled: true, + audio_device_selection_enabled: false, + windows_degraded_sandbox_active: false, + }); popup.on_composer_text_change("/aud".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .filter_map(|item| match item { - CommandItem::Builtin(cmd) => Some(cmd.command()), - CommandItem::UserPrompt(_) => None, + .map(|item| match item { + CommandItem::Builtin(cmd) => cmd.command(), }) .collect(); @@ -640,13 +465,12 @@ mod tests { #[test] fn debug_commands_are_hidden_from_popup() { - let popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + let popup = CommandPopup::new(CommandPopupFlags::default()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .filter_map(|item| match item { - CommandItem::Builtin(cmd) => Some(cmd.command()), - CommandItem::UserPrompt(_) => None, + .map(|item| match item { + CommandItem::Builtin(cmd) => cmd.command(), }) .collect(); diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index efe0a0071..7c816d5ee 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -1,63 +1,3 @@ -use codex_protocol::custom_prompts::CustomPrompt; -use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; -use codex_protocol::user_input::ByteRange; -use codex_protocol::user_input::TextElement; -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, rest_offset)` if the line begins with `/` /// and contains a non-empty name; otherwise returns `None`. @@ -80,775 +20,6 @@ pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> { let rest_untrimmed = &stripped[name_end_in_stripped..]; let rest = rest_untrimmed.trim_start(); let rest_start_in_stripped = name_end_in_stripped + (rest_untrimmed.len() - rest.len()); - // `stripped` is `line` without the leading '/', so add 1 to get the original offset. let rest_offset = rest_start_in_stripped + 1; Some((name, rest, rest_offset)) } - -#[derive(Debug, Clone, PartialEq)] -pub struct PromptArg { - pub text: String, - pub text_elements: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct PromptExpansion { - pub text: String, - pub text_elements: Vec, -} - -/// Parse positional arguments using shlex semantics (supports quoted tokens). -/// -/// `text_elements` must be relative to `rest`. -pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec { - parse_tokens_with_elements(rest, text_elements) -} - -/// 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) { - 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" { - continue; - } - let name = name.to_string(); - if seen.insert(name.clone()) { - names.push(name); - } - } - names -} - -/// Shift a text element's byte range left by `offset`, returning `None` if empty. -/// -/// `offset` is the byte length of the prefix removed from the original text. -fn shift_text_element_left(elem: &TextElement, offset: usize) -> Option { - if elem.byte_range.end <= offset { - return None; - } - let start = elem.byte_range.start.saturating_sub(offset); - let end = elem.byte_range.end.saturating_sub(offset); - (start < end).then_some(elem.map_range(|_| ByteRange { start, end })) -} - -/// 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, - text_elements: &[TextElement], -) -> Result, PromptArgsError> { - let mut map = HashMap::new(); - if rest.trim().is_empty() { - return Ok(map); - } - - // Tokenize the rest of the command using shlex rules, but keep text element - // ranges relative to each emitted token. - for token in parse_tokens_with_elements(rest, text_elements) { - let Some((key, value)) = token.text.split_once('=') else { - return Err(PromptArgsError::MissingAssignment { token: token.text }); - }; - if key.is_empty() { - return Err(PromptArgsError::MissingKey { token: token.text }); - } - // The token is `key=value`; translate element ranges into the value-only - // coordinate space by subtracting the `key=` prefix length. - let value_start = key.len() + 1; - let value_elements = token - .text_elements - .iter() - .filter_map(|elem| shift_text_element_left(elem, value_start)) - .collect(); - map.insert( - key.to_string(), - PromptArg { - text: value.to_string(), - text_elements: value_elements, - }, - ); - } - Ok(map) -} - -/// 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, - text_elements: &[TextElement], - custom_prompts: &[CustomPrompt], -) -> Result, PromptExpansionError> { - let Some((name, rest, rest_offset)) = 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), - }; - // If there are named placeholders, expect key=value inputs. - let required = prompt_argument_names(&prompt.content); - let local_elements: Vec = text_elements - .iter() - .filter_map(|elem| { - let mut shifted = shift_text_element_left(elem, rest_offset)?; - if shifted.byte_range.start >= rest.len() { - return None; - } - let end = shifted.byte_range.end.min(rest.len()); - shifted.byte_range.end = end; - (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) - }) - .collect(); - if !required.is_empty() { - let inputs = parse_prompt_inputs(rest, &local_elements).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 (text, elements) = expand_named_placeholders_with_elements(&prompt.content, &inputs); - return Ok(Some(PromptExpansion { - text, - text_elements: elements, - })); - } - - // Otherwise, treat it as numeric/positional placeholder prompt (or none). - let pos_args = parse_positional_args(rest, &local_elements); - Ok(Some(expand_numeric_placeholders( - &prompt.content, - &pos_args, - ))) -} - -/// 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, - text_elements: &[TextElement], -) -> Vec { - let trimmed = line.trim_start(); - let trim_offset = line.len() - trimmed.len(); - let Some((name, rest, rest_offset)) = parse_slash_name(trimmed) else { - return Vec::new(); - }; - // Require the explicit prompts prefix for custom prompt invocations. - let Some(after_prefix) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { - return Vec::new(); - }; - if after_prefix != prompt_name { - return Vec::new(); - } - let rest_trimmed_start = rest.trim_start(); - let args_str = rest_trimmed_start.trim_end(); - if args_str.is_empty() { - return Vec::new(); - } - let args_offset = trim_offset + rest_offset + (rest.len() - rest_trimmed_start.len()); - let local_elements: Vec = text_elements - .iter() - .filter_map(|elem| { - let mut shifted = shift_text_element_left(elem, args_offset)?; - if shifted.byte_range.start >= args_str.len() { - return None; - } - let end = shifted.byte_range.end.min(args_str.len()); - shifted.byte_range.end = end; - (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) - }) - .collect(); - parse_positional_args(args_str, &local_elements) -} - -/// 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, - text_elements: &[TextElement], -) -> Option { - if !prompt_argument_names(&prompt.content).is_empty() { - return None; - } - if !prompt_has_numeric_placeholders(&prompt.content) { - return None; - } - let args = extract_positional_args_for_prompt_line(first_line, &prompt.name, text_elements); - 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: &[PromptArg]) -> PromptExpansion { - let mut out = String::with_capacity(content.len()); - let mut out_elements = Vec::new(); - let mut i = 0; - 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(arg) = args.get(idx) { - append_arg_with_elements(&mut out, &mut out_elements, arg); - } - i = j + 2; - continue; - } - _ => {} - } - } - if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { - if !args.is_empty() { - append_joined_args_with_elements(&mut out, &mut out_elements, args); - } - i = j + 1 + "ARGUMENTS".len(); - continue; - } - out.push('$'); - i = j + 1; - } - out.push_str(&content[i..]); - PromptExpansion { - text: out, - text_elements: out_elements, - } -} - -fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec { - let mut elements = text_elements.to_vec(); - elements.sort_by_key(|elem| elem.byte_range.start); - // Keep element placeholders intact across shlex splitting by replacing - // each element range with a unique sentinel token first. - let (rest_for_shlex, replacements) = replace_text_elements_with_sentinels(rest, &elements); - Shlex::new(&rest_for_shlex) - .map(|token| apply_replacements_to_token(token, &replacements)) - .collect() -} - -#[derive(Debug, Clone)] -struct ElementReplacement { - sentinel: String, - text: String, - placeholder: Option, -} - -/// Replace each text element range with a unique sentinel token. -/// -/// The sentinel is chosen so it will survive shlex tokenization as a single word. -fn replace_text_elements_with_sentinels( - rest: &str, - elements: &[TextElement], -) -> (String, Vec) { - let mut out = String::with_capacity(rest.len()); - let mut replacements = Vec::new(); - let mut cursor = 0; - - for (idx, elem) in elements.iter().enumerate() { - let start = elem.byte_range.start; - let end = elem.byte_range.end; - out.push_str(&rest[cursor..start]); - let mut sentinel = format!("__CODEX_ELEM_{idx}__"); - // Ensure we never collide with user content so a sentinel can't be mistaken for text. - while rest.contains(&sentinel) { - sentinel.push('_'); - } - out.push_str(&sentinel); - replacements.push(ElementReplacement { - sentinel, - text: rest[start..end].to_string(), - placeholder: elem.placeholder(rest).map(str::to_string), - }); - cursor = end; - } - - out.push_str(&rest[cursor..]); - (out, replacements) -} - -/// Rehydrate a shlex token by swapping sentinels back to the original text -/// and rebuilding text element ranges relative to the resulting token. -fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement]) -> PromptArg { - if replacements.is_empty() { - return PromptArg { - text: token, - text_elements: Vec::new(), - }; - } - - let mut out = String::with_capacity(token.len()); - let mut out_elements = Vec::new(); - let mut cursor = 0; - - while cursor < token.len() { - let Some((offset, replacement)) = next_replacement(&token, cursor, replacements) else { - out.push_str(&token[cursor..]); - break; - }; - let start_in_token = cursor + offset; - out.push_str(&token[cursor..start_in_token]); - let start = out.len(); - out.push_str(&replacement.text); - let end = out.len(); - if start < end { - out_elements.push(TextElement::new( - ByteRange { start, end }, - replacement.placeholder.clone(), - )); - } - cursor = start_in_token + replacement.sentinel.len(); - } - - PromptArg { - text: out, - text_elements: out_elements, - } -} - -/// Find the earliest sentinel occurrence at or after `cursor`. -fn next_replacement<'a>( - token: &str, - cursor: usize, - replacements: &'a [ElementReplacement], -) -> Option<(usize, &'a ElementReplacement)> { - let slice = &token[cursor..]; - let mut best: Option<(usize, &'a ElementReplacement)> = None; - for replacement in replacements { - if let Some(pos) = slice.find(&replacement.sentinel) { - match best { - Some((best_pos, _)) if best_pos <= pos => {} - _ => best = Some((pos, replacement)), - } - } - } - best -} - -fn expand_named_placeholders_with_elements( - content: &str, - args: &HashMap, -) -> (String, Vec) { - let mut out = String::with_capacity(content.len()); - let mut out_elements = Vec::new(); - let mut cursor = 0; - for m in PROMPT_ARG_REGEX.find_iter(content) { - let start = m.start(); - let end = m.end(); - if start > 0 && content.as_bytes()[start - 1] == b'$' { - out.push_str(&content[cursor..end]); - cursor = end; - continue; - } - out.push_str(&content[cursor..start]); - cursor = end; - let key = &content[start + 1..end]; - if let Some(arg) = args.get(key) { - append_arg_with_elements(&mut out, &mut out_elements, arg); - } else { - out.push_str(&content[start..end]); - } - } - out.push_str(&content[cursor..]); - (out, out_elements) -} - -fn append_arg_with_elements( - out: &mut String, - out_elements: &mut Vec, - arg: &PromptArg, -) { - let start = out.len(); - out.push_str(&arg.text); - if arg.text_elements.is_empty() { - return; - } - out_elements.extend(arg.text_elements.iter().map(|elem| { - elem.map_range(|range| ByteRange { - start: start + range.start, - end: start + range.end, - }) - })); -} - -fn append_joined_args_with_elements( - out: &mut String, - out_elements: &mut Vec, - args: &[PromptArg], -) { - // `$ARGUMENTS` joins args with single spaces while preserving element ranges. - for (idx, arg) in args.iter().enumerate() { - if idx > 0 { - out.push(' '); - } - append_arg_with_elements(out, out_elements, arg); - } -} - -/// 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::*; - use pretty_assertions::assert_eq; - - #[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("/prompts:my-prompt USER=Alice BRANCH=main", &[], &prompts) - .unwrap(); - assert_eq!( - out, - Some(PromptExpansion { - text: "Review Alice changes on main".to_string(), - text_elements: Vec::new(), - }) - ); - } - - #[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( - "/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main", - &[], - &prompts, - ) - .unwrap(); - assert_eq!( - out, - Some(PromptExpansion { - text: "Pair Alice Smith with dev-main".to_string(), - text_elements: Vec::new(), - }) - ); - } - - #[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("/prompts: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("/prompts:my-prompt USER=Alice", &[], &prompts) - .unwrap_err() - .user_message(); - 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("/prompts:my-prompt", &[], &prompts).unwrap(); - assert_eq!( - out, - Some(PromptExpansion { - text: "literal $$USER".to_string(), - text_elements: Vec::new(), - }) - ); - } - - #[test] - fn positional_args_treat_placeholder_with_spaces_as_single_token() { - let placeholder = "[Image #1]"; - let rest = format!("alpha {placeholder} beta"); - let start = rest.find(placeholder).expect("placeholder"); - let end = start + placeholder.len(); - let text_elements = vec![TextElement::new( - ByteRange { start, end }, - Some(placeholder.to_string()), - )]; - - let args = parse_positional_args(&rest, &text_elements); - assert_eq!( - args, - vec![ - PromptArg { - text: "alpha".to_string(), - text_elements: Vec::new(), - }, - PromptArg { - text: placeholder.to_string(), - text_elements: vec![TextElement::new( - ByteRange { - start: 0, - end: placeholder.len(), - }, - Some(placeholder.to_string()), - )], - }, - PromptArg { - text: "beta".to_string(), - text_elements: Vec::new(), - } - ] - ); - } - - #[test] - fn extract_positional_args_shifts_element_offsets_into_args_str() { - let placeholder = "[Image #1]"; - let line = format!(" /{PROMPTS_CMD_PREFIX}:my-prompt alpha {placeholder} beta "); - let start = line.find(placeholder).expect("placeholder"); - let end = start + placeholder.len(); - let text_elements = vec![TextElement::new( - ByteRange { start, end }, - Some(placeholder.to_string()), - )]; - - let args = extract_positional_args_for_prompt_line(&line, "my-prompt", &text_elements); - assert_eq!( - args, - vec![ - PromptArg { - text: "alpha".to_string(), - text_elements: Vec::new(), - }, - PromptArg { - text: placeholder.to_string(), - text_elements: vec![TextElement::new( - ByteRange { - start: 0, - end: placeholder.len(), - }, - Some(placeholder.to_string()), - )], - }, - PromptArg { - text: "beta".to_string(), - text_elements: Vec::new(), - } - ] - ); - } - - #[test] - fn key_value_args_treat_placeholder_with_spaces_as_single_token() { - let placeholder = "[Image #1]"; - let rest = format!("IMG={placeholder} NOTE=hello"); - let start = rest.find(placeholder).expect("placeholder"); - let end = start + placeholder.len(); - let text_elements = vec![TextElement::new( - ByteRange { start, end }, - Some(placeholder.to_string()), - )]; - - let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs"); - assert_eq!( - args.get("IMG"), - Some(&PromptArg { - text: placeholder.to_string(), - text_elements: vec![TextElement::new( - ByteRange { - start: 0, - end: placeholder.len(), - }, - Some(placeholder.to_string()), - )], - }) - ); - assert_eq!( - args.get("NOTE"), - Some(&PromptArg { - text: "hello".to_string(), - text_elements: Vec::new(), - }) - ); - } - - #[test] - fn positional_args_allow_placeholder_inside_quotes() { - let placeholder = "[Image #1]"; - let rest = format!("alpha \"see {placeholder} here\" beta"); - let start = rest.find(placeholder).expect("placeholder"); - let end = start + placeholder.len(); - let text_elements = vec![TextElement::new( - ByteRange { start, end }, - Some(placeholder.to_string()), - )]; - - let args = parse_positional_args(&rest, &text_elements); - assert_eq!( - args, - vec![ - PromptArg { - text: "alpha".to_string(), - text_elements: Vec::new(), - }, - PromptArg { - text: format!("see {placeholder} here"), - text_elements: vec![TextElement::new( - ByteRange { - start: "see ".len(), - end: "see ".len() + placeholder.len(), - }, - Some(placeholder.to_string()), - )], - }, - PromptArg { - text: "beta".to_string(), - text_elements: Vec::new(), - } - ] - ); - } - - #[test] - fn key_value_args_allow_placeholder_inside_quotes() { - let placeholder = "[Image #1]"; - let rest = format!("IMG=\"see {placeholder} here\" NOTE=ok"); - let start = rest.find(placeholder).expect("placeholder"); - let end = start + placeholder.len(); - let text_elements = vec![TextElement::new( - ByteRange { start, end }, - Some(placeholder.to_string()), - )]; - - let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs"); - assert_eq!( - args.get("IMG"), - Some(&PromptArg { - text: format!("see {placeholder} here"), - text_elements: vec![TextElement::new( - ByteRange { - start: "see ".len(), - end: "see ".len() + placeholder.len(), - }, - Some(placeholder.to_string()), - )], - }) - ); - assert_eq!( - args.get("NOTE"), - Some(&PromptArg { - text: "ok".to_string(), - text_elements: Vec::new(), - }) - ); - } -} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index cb5c039ce..dd04aec37 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6821,9 +6821,6 @@ impl ChatWidget { EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev), EventMsg::GetHistoryEntryResponse(ev) => self.handle_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), - EventMsg::ListCustomPromptsResponse(_) => { - tracing::warn!("ignoring unsupported custom prompt list response in TUI"); - } EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), EventMsg::SkillsUpdateAvailable => { self.submit_op(AppCommand::list_skills( diff --git a/docs/prompts.md b/docs/prompts.md deleted file mode 100644 index fa3da5b3b..000000000 --- a/docs/prompts.md +++ /dev/null @@ -1,3 +0,0 @@ -# Custom prompts - -For an overview of custom prompts, see [this documentation](https://developers.openai.com/codex/custom-prompts). diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 5630b84ec..a61a0b5be 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -81,8 +81,6 @@ Key effects when disabled: - When `popups_enabled` is `false`, `sync_popups()` forces `ActivePopup::None`. - When `slash_commands_enabled` is `false`, the composer does not treat `/...` input as commands. -- When `slash_commands_enabled` is `false`, the composer does not expand custom prompts in - `prepare_submission_text`. - When `slash_commands_enabled` is `false`, slash-context paste-burst exceptions are disabled. - When `image_paste_enabled` is `false`, file-path paste image attachment is skipped. - `ChatWidget` may toggle `image_paste_enabled` at runtime based on the selected model's @@ -107,12 +105,8 @@ the input starts with `!` (shell command). 1. Expands any pending paste placeholders so element ranges align with the final text. 2. Trims whitespace and rebases element ranges to the trimmed buffer. -3. Expands `/prompts:` custom prompts: - - Named args use key=value parsing. - - Numeric args use positional parsing for `$1..$9` and `$ARGUMENTS`. - The expansion preserves text elements and yields the final submission payload. -4. Prunes attachments so only placeholders that survive expansion are sent. -5. Clears pending pastes on success and suppresses submission if the final text is empty and there +3. Prunes attachments so only placeholders that survive trimming are sent. +4. Clears pending pastes on success and suppresses submission if the final text is empty and there are no attachments. The same preparation path is reused for slash commands with arguments (for example `/plan` and @@ -124,16 +118,6 @@ still available for `Ctrl+Y`. This supports flows where a user kills part of a d composer action such as changing reasoning level, and then yanks that text back into the cleared draft. -### Numeric auto-submit path - -When the slash popup is open and the first line matches a numeric-only custom prompt with -positional args, Enter auto-submits without calling `prepare_submission_text`. That path still: - -- Expands pending pastes before parsing positional args. -- Uses expanded text elements for prompt expansion. -- Prunes attachments based on expanded placeholders. -- Clears pending pastes after a successful auto-submit. - ## Remote image rows (selection/deletion flow) Remote image URLs are shown as `[Image #N]` rows above the textarea, inside the same composer box.