diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4961486e65..81a8fc931b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1460,6 +1460,7 @@ dependencies = [ "lazy_static", "libc", "mcp-types", + "once_cell", "opentelemetry-appender-tracing", "pathdiff", "pretty_assertions", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c56aba0fcf..d69c4e626f 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -71,6 +71,7 @@ strum_macros = { workspace = true } supports-color = { workspace = true } tempfile = { workspace = true } textwrap = { workspace = true } +once_cell = "1" tree-sitter-highlight = { workspace = true } tree-sitter-bash = { workspace = true } tokio = { workspace = true, features = [ diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8691b5b127..4c6564dd7f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -36,7 +36,7 @@ use crate::bottom_pane::prompt_args::prompt_argument_names; use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders; use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; use crate::slash_command::SlashCommand; -use crate::slash_command::built_in_slash_commands; +use crate::slash_command::resolve_slash_command; use crate::style::user_message_style; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; @@ -932,9 +932,7 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, rest)) = parse_slash_name(first_line) && rest.is_empty() - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .find(|(n, _)| *n == name) + && let Some(cmd) = resolve_slash_command(name) { self.textarea.set_text(""); return (InputResult::Command(cmd), true); @@ -1003,9 +1001,7 @@ impl ChatComposer { if let Some((name, _rest)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = built_in_slash_commands() - .into_iter() - .any(|(command_name, _)| command_name == name); + let is_builtin = resolve_slash_command(name).is_some(); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name .strip_prefix(&prompt_prefix) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index d7501cebbc..d780e33f7d 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -34,7 +34,12 @@ impl CommandPopup { pub(crate) fn new(mut prompts: Vec) -> Self { let builtins = built_in_slash_commands(); // Exclude prompts that collide with builtin command names and sort by name. - let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); + let mut exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); + for (_, cmd) in &builtins { + for alias in cmd.aliases() { + exclude.insert((*alias).to_string()); + } + } prompts.retain(|p| !exclude.contains(&p.name)); prompts.sort_by(|a, b| a.name.cmp(&b.name)); Self { @@ -46,11 +51,16 @@ impl CommandPopup { } pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { - let exclude: HashSet = self + let mut exclude: HashSet = self .builtins .iter() .map(|(n, _)| (*n).to_string()) .collect(); + for (_, cmd) in &self.builtins { + for alias in cmd.aliases() { + exclude.insert((*alias).to_string()); + } + } prompts.retain(|p| !exclude.contains(&p.name)); prompts.sort_by(|a, b| a.name.cmp(&b.name)); self.prompts = prompts; @@ -121,6 +131,18 @@ impl CommandPopup { for (_, cmd) in self.builtins.iter() { if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { out.push((CommandItem::Builtin(*cmd), Some(indices), score)); + continue; + } + let mut best_alias_score: Option = None; + for alias in cmd.aliases() { + if let Some((_indices, score)) = fuzzy_match(alias, filter) + && best_alias_score.is_none_or(|best| score < best) + { + best_alias_score = Some(score); + } + } + if let Some(score) = best_alias_score { + out.push((CommandItem::Builtin(*cmd), None, score)); } } // Support both search styles: diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 969d279b07..485a8a47d7 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -24,6 +24,7 @@ pub enum SlashCommand { Status, Mcp, Logout, + #[strum(serialize = "exit")] Quit, Exit, Feedback, @@ -83,6 +84,22 @@ impl SlashCommand { } } + /// Additional slash names that map to this command. + pub fn aliases(self) -> &'static [&'static str] { + match self { + SlashCommand::Quit => &["exit", "e"], + _ => &[], + } + } + + /// Return true if `name` matches this command's canonical name or an alias. + pub fn matches_name(self, name: &str) -> bool { + if self.command() == name { + return true; + } + self.aliases().contains(&name) + } + fn is_visible(self) -> bool { match self { SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions), @@ -98,3 +115,9 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { .map(|c| (c.command(), c)) .collect() } + +/// Resolve a slash command name (including aliases) to the corresponding command. +pub fn resolve_slash_command(name: &str) -> Option { + use std::str::FromStr; + SlashCommand::from_str(name).ok() +}