diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d367479187..4fae7ce156 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4234,10 +4234,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 @@ -4357,12 +4353,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::ListCustomPromptsResponseEvent; + use codex_protocol::protocol::ListRemoteSkillsResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; @@ -4716,23 +4711,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, @@ -6619,7 +6597,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 54ccaa62fe..0000000000 --- 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/lib.rs b/codex-rs/core/src/lib.rs index 51d9fcf8d6..3acef235d5 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -31,7 +31,6 @@ pub mod config_loader; pub mod connectors; mod context_manager; mod contextual_user_message; -pub mod custom_prompts; pub mod env; mod environment_context; pub mod error; diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 4600431c64..35b4830fac 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -162,7 +162,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/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 092e5d9990..ff685ede20 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -868,7 +868,6 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::ExecCommandOutputDelta(_) | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) | EventMsg::RawResponseItem(_) | EventMsg::UserMessage(_) @@ -1028,7 +1027,6 @@ impl EventProcessorWithHumanOutput { | EventMsg::ExecCommandOutputDelta(_) | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ListSkillsResponse(_) | EventMsg::RawResponseItem(_) | EventMsg::UserMessage(_) diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 780a808038..8c75ba365d 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -337,7 +337,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 7472d1b4c5..0000000000 --- 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 d6adf2c585..89e711060c 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -3,7 +3,6 @@ mod thread_id; 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 f74afb9563..bf87add43f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -21,7 +21,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; @@ -436,9 +435,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. @@ -520,7 +516,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", @@ -1281,9 +1276,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), @@ -2896,12 +2888,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/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6aa250b529..98877189d0 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -39,7 +39,7 @@ //! //! - 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. +//! - Preserves text elements and pending pasted content through submission processing. //! - 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 +182,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 +259,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 +356,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, @@ -505,7 +480,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(), @@ -1407,44 +1381,22 @@ impl ChatComposer { // before applying completion. 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() { + if let Some(CommandItem::Builtin(cmd)) = 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); - } + 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 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()); } if let Some(pos) = cursor_target { self.textarea.set_cursor(pos); @@ -1457,82 +1409,9 @@ 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(); + if let Some(CommandItem::Builtin(cmd)) = popup.selected_item() { 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); - } - } + return (InputResult::Command(cmd), true); } // Fallback to default newline handling if no command selected. self.handle_key_event_without_popup(key_event) @@ -2320,16 +2199,7 @@ 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_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 { + if !is_builtin { let message = format!( r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# ); @@ -2349,30 +2219,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); @@ -3371,20 +3217,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, @@ -3416,7 +3249,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() { @@ -3426,13 +3259,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 @@ -3481,30 +3308,21 @@ 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, - 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, + 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); } } } } - 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. @@ -4436,62 +4254,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. @@ -4518,7 +4280,8 @@ mod tests { 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::prompt_args::parse_positional_args; + use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -6482,9 +6245,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'"), @@ -6538,9 +6298,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'"), @@ -6921,11 +6678,12 @@ mod tests { #[test] fn extract_args_supports_quoted_paths_single_arg() { - let args = extract_positional_args_for_prompt_line( - "/prompts:review \"docs/My File.md\"", - "review", - &[], - ); + let Some((name, rest, _rest_offset)) = parse_slash_name("/review \"docs/My File.md\"") + else { + panic!("expected slash command"); + }; + assert_eq!(name, "review"); + let args = parse_positional_args(rest, &[]); assert_eq!( args, vec![PromptArg { @@ -6937,11 +6695,12 @@ mod tests { #[test] fn extract_args_supports_mixed_quoted_and_unquoted() { - let args = extract_positional_args_for_prompt_line( - "/prompts:cmd \"with spaces\" simple", - "cmd", - &[], - ); + let Some((name, rest, _rest_offset)) = parse_slash_name("/review \"with spaces\" simple") + else { + panic!("expected slash command"); + }; + assert_eq!(name, "review"); + let args = parse_positional_args(rest, &[]); assert_eq!( args, vec![ @@ -8254,398 +8013,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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - 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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes on $BRANCH".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Pair $USER with $BRANCH".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $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(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $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(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review 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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - 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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $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(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; @@ -8718,541 +8085,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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_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(80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(message.contains("expected key=value")); - found_error = true; - break; - } - } - assert!(found_error, "expected error history cell to be sent"); - } - - #[test] - fn custom_prompt_missing_required_args_reports_error() { - let (tx, mut rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes on $BRANCH".to_string(), - description: None, - argument_hint: None, - }]); - - // Provide only one of the required args - composer - .textarea - .set_text_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(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - // Type the slash command with two args and hit Enter to submit. - type_chars_humanlike( - &mut composer, - &[ - '/', '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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Hello".to_string(), - description: None, - argument_hint: None, - }]); - - composer.attach_image(PathBuf::from("/tmp/unused.png")); - composer.textarea.set_cursor(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "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(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - 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(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "elegant".to_string(), - path: "/tmp/elegant.md".to_string().into(), - content: "Echo: $ARGUMENTS".to_string(), - description: None, - argument_hint: None, - }]); - - // Type positional args; should submit with numeric expansion, no errors. - composer - .textarea - .set_text_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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "p".to_string(), - path: "/tmp/p.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &['/', 'p', '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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "price".to_string(), - path: "/tmp/price.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', '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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "repeat".to_string(), - path: "/tmp/repeat.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &[ - '/', '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 8508849689..d1dc06f406 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -10,27 +10,20 @@ 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. #[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, } @@ -60,73 +53,36 @@ impl From for slash_commands::BuiltinCommandFlags { } impl CommandPopup { - pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { - // Keep built-in availability in sync with the composer. + pub(crate) fn new(flags: CommandPopupFlags) -> Self { let builtins: Vec<(&'static str, SlashCommand)> = slash_commands::builtins_for_input(flags.into()) .into_iter() .filter(|(name, _)| !name.starts_with("debug")) .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(), } } - 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 - /// to narrow down the list of available commands. pub(crate) fn on_composer_text_change(&mut self, text: String) { let first_line = text.lines().next().unwrap_or(""); if let Some(stripped) = first_line.strip_prefix('/') { - // Extract the *first* token (sequence of non-whitespace - // characters) after the slash so that `/clear something` still - // shows the help for `/clear`. let token = stripped.trim_start(); let cmd_token = token.split_whitespace().next().unwrap_or(""); - - // Update the filter keeping the original case (commands are all - // lower-case for now but this may change in the future). self.command_filter = cmd_token.to_string(); } else { - // The composer no longer starts with '/'. Reset the filter so the - // popup shows the *full* command list if it is still displayed - // for some reason. self.command_filter.clear(); } - // Reset or clamp selected index based on new filtered list. let matches_len = self.filtered_items().len(); self.state.clamp_selection(matches_len); self.state .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); } - /// Determine the preferred height of the popup for a given width. - /// Accounts for wrapped descriptions so that long tooltips don't overflow. pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { use super::selection_popup_common::measure_rows_height; let rows = self.rows_from_matches(self.filtered()); @@ -134,69 +90,33 @@ impl CommandPopup { measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) } - /// Compute exact/prefix matches over built-in commands and user prompts, - /// paired with optional highlight indices. Preserves the original - /// presentation order for built-ins and prompts. fn filtered(&self) -> Vec<(CommandItem, Option>)> { 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() { + for (_, cmd) in &self.builtins { 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; } let filter_lower = filter.to_lowercase(); 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 exact = Vec::new(); + let mut prefix = Vec::new(); + let indices_for = || Some((0..filter_chars).collect()); - let mut push_match = - |item: CommandItem, display: &str, name: Option<&str>, name_offset: usize| { - let display_lower = display.to_lowercase(); - let name_lower = name.map(str::to_lowercase); - let display_exact = display_lower == filter_lower; - let name_exact = name_lower.as_deref() == Some(filter_lower.as_str()); - if display_exact || name_exact { - let offset = if display_exact { 0 } else { name_offset }; - exact.push((item, indices_for(offset))); - return; - } - let display_prefix = display_lower.starts_with(&filter_lower); - let name_prefix = name_lower - .as_ref() - .is_some_and(|name| name.starts_with(&filter_lower)); - if display_prefix || name_prefix { - let offset = if display_prefix { 0 } else { name_offset }; - prefix.push((item, indices_for(offset))); - } - }; - - 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, - ); + for (_, cmd) in &self.builtins { + let command = cmd.command(); + let command_lower = command.to_lowercase(); + if command_lower == filter_lower { + exact.push((CommandItem::Builtin(*cmd), indices_for())); + } else if command_lower.starts_with(&filter_lower) { + prefix.push((CommandItem::Builtin(*cmd), indices_for())); + } } out.extend(exact); @@ -205,7 +125,10 @@ impl CommandPopup { } fn filtered_items(&self) -> Vec { - self.filtered().into_iter().map(|(c, _)| c).collect() + self.filtered() + .into_iter() + .map(|(command, _)| command) + .collect() } fn rows_from_matches( @@ -215,28 +138,13 @@ 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; GenericDisplayRow { - name, + name: format!("/{}", cmd.command()), name_prefix_spans: Vec::new(), match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), display_shortcut: None, - description: Some(description), + description: Some(cmd.description().to_string()), category_tag: None, wrap_indent: None, is_disabled: false, @@ -246,14 +154,12 @@ impl CommandPopup { .collect() } - /// Move the selection cursor one step up. pub(crate) fn move_up(&mut self) { let len = self.filtered_items().len(); self.state.move_up_wrap(len); self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } - /// Move the selection cursor one step down. pub(crate) fn move_down(&mut self) { let matches_len = self.filtered_items().len(); self.state.move_down_wrap(matches_len); @@ -261,7 +167,6 @@ impl CommandPopup { .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); } - /// Return currently selected command, if any. pub(crate) fn selected_item(&self) -> Option { let matches = self.filtered_items(); self.state @@ -293,17 +198,12 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - // Simulate the composer line starting with '/in' so the popup filters - // matching commands by prefix. + let mut popup = CommandPopup::new(CommandPopupFlags::default()); popup.on_composer_text_change("/in".to_string()); - // Access the filtered list via the selected command and ensure that - // one of the matches is the new "init" command. 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, @@ -313,152 +213,52 @@ 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 - // command by default. 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!( @@ -469,7 +269,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))); @@ -481,15 +281,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!( @@ -504,18 +303,15 @@ 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, - 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, + 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() { @@ -526,18 +322,15 @@ 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, - 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, + 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() { @@ -548,26 +341,22 @@ mod tests { #[test] fn personality_command_hidden_when_disabled() { - let mut popup = CommandPopup::new( - Vec::new(), - CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_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, + 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!( @@ -578,18 +367,15 @@ mod tests { #[test] fn personality_command_visible_when_enabled() { - let mut popup = CommandPopup::new( - Vec::new(), - CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_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, + 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() { @@ -600,26 +386,22 @@ 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, - 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, + 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(); @@ -631,13 +413,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/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index de85b9ffe7..11291b1a5d 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -141,7 +141,6 @@ use crate::bottom_pane::prompt_args::parse_slash_name; pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposerConfig; pub(crate) use chat_composer::InputResult; -use codex_protocol::custom_prompts::CustomPrompt; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::status_indicator_widget::StatusIndicatorWidget; @@ -856,12 +855,6 @@ impl BottomPane { } } - /// Update custom prompts available for the slash popup. - pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { - self.composer.set_custom_prompts(prompts); - self.request_redraw(); - } - pub(crate) fn composer_is_empty(&self) -> bool { self.composer.is_empty() } diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index efe0a00713..46cec29242 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -1,62 +1,6 @@ -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 `/` @@ -80,7 +24,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)) } @@ -91,12 +34,6 @@ pub struct PromptArg { 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`. @@ -104,283 +41,9 @@ pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec

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)) @@ -394,9 +57,6 @@ struct ElementReplacement { 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], @@ -410,7 +70,6 @@ fn replace_text_elements_with_sentinels( 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('_'); } @@ -427,8 +86,6 @@ fn replace_text_elements_with_sentinels( (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 { @@ -466,7 +123,6 @@ fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement } } -/// Find the earliest sentinel occurrence at or after `cursor`. fn next_replacement<'a>( token: &str, cursor: usize, @@ -485,194 +141,11 @@ fn next_replacement<'a>( 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]"; @@ -710,77 +183,6 @@ mod tests { ); } - #[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]"; @@ -817,38 +219,4 @@ mod tests { ] ); } - - #[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 b32fbf8f15..2e096f974b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -118,7 +118,6 @@ use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::ImageGenerationBeginEvent; use codex_protocol::protocol::ImageGenerationEndEvent; -use codex_protocol::protocol::ListCustomPromptsResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpListToolsResponseEvent; use codex_protocol::protocol::McpStartupCompleteEvent; @@ -1421,8 +1420,6 @@ impl ChatWidget { if let Some(messages) = initial_messages { self.replay_initial_messages(messages); } - // Ask codex-core to enumerate custom prompts for this session. - self.submit_op(Op::ListCustomPrompts); self.submit_op(Op::ListSkills { cwds: Vec::new(), force_reload: true, @@ -5352,7 +5349,6 @@ impl ChatWidget { EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev), EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), - EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), EventMsg::SkillsUpdateAvailable => { self.submit_op(Op::ListSkills { @@ -8871,13 +8867,6 @@ impl ChatWidget { )); } - fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { - let len = ev.custom_prompts.len(); - debug!("received {len} custom prompts"); - // Forward to bottom pane so the slash popup can show them now. - self.bottom_pane.set_custom_prompts(ev.custom_prompts); - } - fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) { self.set_skills_from_response(&ev); self.refresh_plugin_mentions(); diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index f796c040d1..98877189d0 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -39,7 +39,7 @@ //! //! - 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. +//! - Preserves text elements and pending pasted content through submission processing. //! - 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 +182,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 +259,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 +356,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, @@ -505,7 +480,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(), @@ -1407,44 +1381,22 @@ impl ChatComposer { // before applying completion. 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() { + if let Some(CommandItem::Builtin(cmd)) = 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); - } + 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 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()); } if let Some(pos) = cursor_target { self.textarea.set_cursor(pos); @@ -1457,82 +1409,9 @@ 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(); + if let Some(CommandItem::Builtin(cmd)) = popup.selected_item() { 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); - } - } + return (InputResult::Command(cmd), true); } // Fallback to default newline handling if no command selected. self.handle_key_event_without_popup(key_event) @@ -2320,36 +2199,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 app-server TUI yet" - ); - "Not available in app-server 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, @@ -2363,30 +2219,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); @@ -3385,20 +3217,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, @@ -3430,7 +3249,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() { @@ -3440,13 +3259,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 @@ -3495,31 +3308,21 @@ 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, - 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, + 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. @@ -4451,62 +4254,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. @@ -4533,7 +4280,8 @@ mod tests { 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::prompt_args::parse_positional_args; + use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -6497,9 +6245,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'"), @@ -6553,9 +6298,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'"), @@ -6936,11 +6678,12 @@ mod tests { #[test] fn extract_args_supports_quoted_paths_single_arg() { - let args = extract_positional_args_for_prompt_line( - "/prompts:review \"docs/My File.md\"", - "review", - &[], - ); + let Some((name, rest, _rest_offset)) = parse_slash_name("/review \"docs/My File.md\"") + else { + panic!("expected slash command"); + }; + assert_eq!(name, "review"); + let args = parse_positional_args(rest, &[]); assert_eq!( args, vec![PromptArg { @@ -6952,11 +6695,12 @@ mod tests { #[test] fn extract_args_supports_mixed_quoted_and_unquoted() { - let args = extract_positional_args_for_prompt_line( - "/prompts:cmd \"with spaces\" simple", - "cmd", - &[], - ); + let Some((name, rest, _rest_offset)) = parse_slash_name("/review \"with spaces\" simple") + else { + panic!("expected slash command"); + }; + assert_eq!(name, "review"); + let args = parse_positional_args(rest, &[]); assert_eq!( args, vec![ @@ -8269,398 +8013,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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - 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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes on $BRANCH".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Pair $USER with $BRANCH".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $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(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $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(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review 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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - 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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $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(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; @@ -8733,576 +8085,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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text_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(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - 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(80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(message.contains("Not available in app-server 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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes on $BRANCH".to_string(), - description: None, - argument_hint: None, - }]); - - // Provide only one of the required args - composer - .textarea - .set_text_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(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - // Type the slash command with two args and hit Enter to submit. - type_chars_humanlike( - &mut composer, - &[ - '/', '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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Hello".to_string(), - description: None, - argument_hint: None, - }]); - - composer.attach_image(PathBuf::from("/tmp/unused.png")); - composer.textarea.set_cursor(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "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(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - 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(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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "elegant".to_string(), - path: "/tmp/elegant.md".to_string().into(), - content: "Echo: $ARGUMENTS".to_string(), - description: None, - argument_hint: None, - }]); - - // Type positional args; should submit with numeric expansion, no errors. - composer - .textarea - .set_text_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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "p".to_string(), - path: "/tmp/p.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &['/', 'p', '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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "price".to_string(), - path: "/tmp/price.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', '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( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "repeat".to_string(), - path: "/tmp/repeat.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &[ - '/', '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_app_server/src/bottom_pane/command_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs index 05b15b7935..48e31cf55f 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs @@ -10,27 +10,17 @@ 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. #[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, } @@ -60,74 +50,36 @@ impl From for slash_commands::BuiltinCommandFlags { } impl CommandPopup { - pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { - // Keep built-in availability in sync with the composer. + pub(crate) fn new(flags: CommandPopupFlags) -> Self { let builtins: Vec<(&'static str, SlashCommand)> = slash_commands::builtins_for_input(flags.into()) .into_iter() .filter(|(name, _)| !name.starts_with("debug")) .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 - /// to narrow down the list of available commands. pub(crate) fn on_composer_text_change(&mut self, text: String) { let first_line = text.lines().next().unwrap_or(""); if let Some(stripped) = first_line.strip_prefix('/') { - // Extract the *first* token (sequence of non-whitespace - // characters) after the slash so that `/clear something` still - // shows the help for `/clear`. let token = stripped.trim_start(); let cmd_token = token.split_whitespace().next().unwrap_or(""); - - // Update the filter keeping the original case (commands are all - // lower-case for now but this may change in the future). self.command_filter = cmd_token.to_string(); } else { - // The composer no longer starts with '/'. Reset the filter so the - // popup shows the *full* command list if it is still displayed - // for some reason. self.command_filter.clear(); } - // Reset or clamp selected index based on new filtered list. let matches_len = self.filtered_items().len(); self.state.clamp_selection(matches_len); self.state .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); } - /// Determine the preferred height of the popup for a given width. - /// Accounts for wrapped descriptions so that long tooltips don't overflow. pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { use super::selection_popup_common::measure_rows_height; let rows = self.rows_from_matches(self.filtered()); @@ -135,69 +87,33 @@ impl CommandPopup { measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) } - /// Compute exact/prefix matches over built-in commands and user prompts, - /// paired with optional highlight indices. Preserves the original - /// presentation order for built-ins and prompts. fn filtered(&self) -> Vec<(CommandItem, Option>)> { 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() { + for (_, cmd) in &self.builtins { 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; } let filter_lower = filter.to_lowercase(); 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 exact = Vec::new(); + let mut prefix = Vec::new(); + let indices_for = || Some((0..filter_chars).collect()); - let mut push_match = - |item: CommandItem, display: &str, name: Option<&str>, name_offset: usize| { - let display_lower = display.to_lowercase(); - let name_lower = name.map(str::to_lowercase); - let display_exact = display_lower == filter_lower; - let name_exact = name_lower.as_deref() == Some(filter_lower.as_str()); - if display_exact || name_exact { - let offset = if display_exact { 0 } else { name_offset }; - exact.push((item, indices_for(offset))); - return; - } - let display_prefix = display_lower.starts_with(&filter_lower); - let name_prefix = name_lower - .as_ref() - .is_some_and(|name| name.starts_with(&filter_lower)); - if display_prefix || name_prefix { - let offset = if display_prefix { 0 } else { name_offset }; - prefix.push((item, indices_for(offset))); - } - }; - - 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, - ); + for (_, cmd) in &self.builtins { + let command = cmd.command(); + let command_lower = command.to_lowercase(); + if command_lower == filter_lower { + exact.push((CommandItem::Builtin(*cmd), indices_for())); + } else if command_lower.starts_with(&filter_lower) { + prefix.push((CommandItem::Builtin(*cmd), indices_for())); + } } out.extend(exact); @@ -206,7 +122,10 @@ impl CommandPopup { } fn filtered_items(&self) -> Vec { - self.filtered().into_iter().map(|(c, _)| c).collect() + self.filtered() + .into_iter() + .map(|(command, _)| command) + .collect() } fn rows_from_matches( @@ -216,28 +135,13 @@ 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; GenericDisplayRow { - name, + name: format!("/{}", cmd.command()), name_prefix_spans: Vec::new(), match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), display_shortcut: None, - description: Some(description), + description: Some(cmd.description().to_string()), category_tag: None, wrap_indent: None, is_disabled: false, @@ -247,14 +151,12 @@ impl CommandPopup { .collect() } - /// Move the selection cursor one step up. pub(crate) fn move_up(&mut self) { let len = self.filtered_items().len(); self.state.move_up_wrap(len); self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } - /// Move the selection cursor one step down. pub(crate) fn move_down(&mut self) { let matches_len = self.filtered_items().len(); self.state.move_down_wrap(matches_len); @@ -262,7 +164,6 @@ impl CommandPopup { .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); } - /// Return currently selected command, if any. pub(crate) fn selected_item(&self) -> Option { let matches = self.filtered_items(); self.state @@ -294,17 +195,12 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - // Simulate the composer line starting with '/in' so the popup filters - // matching commands by prefix. + let mut popup = CommandPopup::new(CommandPopupFlags::default()); popup.on_composer_text_change("/in".to_string()); - // Access the filtered list via the selected command and ensure that - // one of the matches is the new "init" command. 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, @@ -314,152 +210,52 @@ 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 - // command by default. 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!( @@ -470,7 +266,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))); @@ -482,15 +278,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!( @@ -505,18 +300,15 @@ 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, - 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, + 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() { @@ -527,18 +319,15 @@ 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, - 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, + 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() { @@ -549,26 +338,22 @@ mod tests { #[test] fn personality_command_hidden_when_disabled() { - let mut popup = CommandPopup::new( - Vec::new(), - CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_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, + 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!( @@ -579,18 +364,15 @@ mod tests { #[test] fn personality_command_visible_when_enabled() { - let mut popup = CommandPopup::new( - Vec::new(), - CommandPopupFlags { - collaboration_modes_enabled: true, - connectors_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, + 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() { @@ -601,26 +383,22 @@ 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, - 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, + 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(); @@ -632,13 +410,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_app_server/src/bottom_pane/prompt_args.rs b/codex-rs/tui_app_server/src/bottom_pane/prompt_args.rs index efe0a00713..46cec29242 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/prompt_args.rs @@ -1,62 +1,6 @@ -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 `/` @@ -80,7 +24,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)) } @@ -91,12 +34,6 @@ pub struct PromptArg { 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`. @@ -104,283 +41,9 @@ pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec

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)) @@ -394,9 +57,6 @@ struct ElementReplacement { 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], @@ -410,7 +70,6 @@ fn replace_text_elements_with_sentinels( 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('_'); } @@ -427,8 +86,6 @@ fn replace_text_elements_with_sentinels( (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 { @@ -466,7 +123,6 @@ fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement } } -/// Find the earliest sentinel occurrence at or after `cursor`. fn next_replacement<'a>( token: &str, cursor: usize, @@ -485,194 +141,11 @@ fn next_replacement<'a>( 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]"; @@ -710,77 +183,6 @@ mod tests { ); } - #[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]"; @@ -817,38 +219,4 @@ mod tests { ] ); } - - #[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_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 922279657c..6e80c07e9e 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -5183,11 +5183,6 @@ impl ChatWidget { EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev), EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), - EventMsg::ListCustomPromptsResponse(_) => { - tracing::warn!( - "ignoring unsupported custom prompt list response in app-server 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 fa3da5b3bd..0000000000 --- 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 5630b84eca..59035a25f9 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,16 +105,12 @@ 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 -`/review`) so pasted content and text elements are preserved when extracting args. +The same parsing helpers are reused for built-in slash commands with arguments (for example +`/plan` and `/review`) so pasted content and text elements are preserved when extracting args. The composer also treats the textarea kill buffer as separate editing state from the visible draft. After submit or slash-command dispatch clears the textarea, the most recent `Ctrl+K` payload is @@ -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.