diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a529d169c4..6f52001c61 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -149,9 +149,9 @@ use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::chat_composer_history::HistoryEntry; use super::chat_composer_history::HistoryEntryResponse; -use super::command_popup::CommandItem; use super::command_popup::CommandPopup; use super::command_popup::CommandPopupFlags; +use super::command_popup::SelectedCommand; use super::file_search_popup::FileSearchPopup; use super::footer::CollaborationModeIndicator; use super::footer::FooterMode; @@ -205,6 +205,7 @@ use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; +use crate::custom_slash_command::CustomSlashCommand; use crate::history_cell; use crate::skills_helpers::skill_display_name; use crate::tui::FrameRequester; @@ -262,6 +263,10 @@ pub enum InputResult { /// command-history entry still represents the original command invocation that should be /// committed only if dispatch accepts it. CommandWithArgs(SlashCommand, String, Vec), + /// A private prompt slash command parsed by the composer. + CustomCommand(CustomSlashCommand), + /// A private prompt slash command and its trimmed argument text. + CustomCommandWithArgs(CustomSlashCommand, String, Vec), None, } @@ -386,6 +391,7 @@ pub(crate) struct ChatComposer { // Agent label injected into the footer's contextual row when multi-agent mode is active. active_agent_label: Option, history_search: Option, + custom_slash_commands: Vec, } #[derive(Clone, Debug)] @@ -533,6 +539,7 @@ impl ChatComposer { side_conversation_context_label: None, active_agent_label: None, history_search: None, + custom_slash_commands: Vec::new(), }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -625,6 +632,15 @@ impl ChatComposer { self.audio_device_selection_enabled = enabled; } + pub fn set_custom_slash_commands(&mut self, commands: Vec) { + self.custom_slash_commands = commands; + self.sync_popups(); + } + + pub fn custom_slash_commands(&self) -> &[CustomSlashCommand] { + &self.custom_slash_commands + } + pub fn set_side_conversation_active(&mut self, active: bool) { self.side_conversation_active = active; } @@ -1497,24 +1513,21 @@ impl ChatComposer { // before applying completion. let first_line = self.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); - let selected_cmd = popup.selected_item().map(|sel| { - let CommandItem::Builtin(cmd) = sel; - cmd - }); + let selected_cmd = popup.selected_command(); if let Some(cmd) = selected_cmd { - if cmd == SlashCommand::Skills { - self.stage_selected_slash_command_history(cmd); + if cmd == SelectedCommand::Builtin(SlashCommand::Skills) { + self.stage_selected_slash_command_history(cmd.name()); self.textarea.set_text_clearing_elements(""); self.is_bash_mode = false; - return (InputResult::Command(cmd), true); + return (InputResult::Command(SlashCommand::Skills), true); } - let selected_command_text = format!("/{}", cmd.command()); + let selected_command_text = format!("/{}", cmd.name()); let starts_with_cmd = first_line.trim_start().starts_with(&selected_command_text); if !starts_with_cmd { self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); + .set_text_clearing_elements(&format!("/{} ", cmd.name())); if !self.textarea.text().is_empty() { self.textarea.set_cursor(self.textarea.text().len()); } @@ -1535,17 +1548,14 @@ impl ChatComposer { // while the slash-command popup is active. let first_line = self.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); - let selected_cmd = popup.selected_item().map(|sel| { - let CommandItem::Builtin(cmd) = sel; - cmd - }); + let selected_cmd = popup.selected_command(); if let Some(cmd) = selected_cmd { let starts_with_cmd = first_line .trim_start() - .starts_with(&format!("/{}", cmd.command())); + .starts_with(&format!("/{}", cmd.name())); if !starts_with_cmd { self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); + .set_text_clearing_elements(&format!("/{} ", cmd.name())); self.is_bash_mode = false; } if !self.textarea.text().is_empty() { @@ -1559,12 +1569,17 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - if let Some(sel) = popup.selected_item() { - let CommandItem::Builtin(cmd) = sel; - self.stage_selected_slash_command_history(cmd); + if let Some(cmd) = popup.selected_command() { + self.stage_selected_slash_command_history(cmd.name()); self.textarea.set_text_clearing_elements(""); self.is_bash_mode = false; - return (InputResult::Command(cmd), true); + return ( + match cmd { + SelectedCommand::Builtin(cmd) => InputResult::Command(cmd), + SelectedCommand::Custom(cmd) => InputResult::CustomCommand(cmd), + }, + true, + ); } // Fallback to default newline handling if no command selected. self.handle_key_event_without_popup(key_event) @@ -2362,7 +2377,10 @@ impl ChatComposer { let is_builtin = slash_commands::find_builtin_command(name, self.builtin_command_flags()) .is_some(); - if !is_builtin { + let is_custom = + slash_commands::find_custom_command(name, &self.custom_slash_commands) + .is_some(); + if !is_builtin && !is_custom { let message = format!( r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# ); @@ -2563,18 +2581,30 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line) && rest.is_empty() - && let Some(cmd) = - slash_commands::find_builtin_command(name, self.builtin_command_flags()) { - if self.reject_slash_command_if_unavailable(cmd) { + if let Some(cmd) = + slash_commands::find_builtin_command(name, self.builtin_command_flags()) + { + if self.reject_slash_command_if_unavailable(cmd) { + self.stage_slash_command_history(); + self.record_pending_slash_command_history(); + return Some(InputResult::None); + } self.stage_slash_command_history(); - self.record_pending_slash_command_history(); - return Some(InputResult::None); + self.textarea.set_text_clearing_elements(""); + self.is_bash_mode = false; + Some(InputResult::Command(cmd)) + } else if let Some(cmd) = + slash_commands::find_custom_command(name, &self.custom_slash_commands) + { + let cmd = cmd.clone(); + self.stage_slash_command_history(); + self.textarea.set_text_clearing_elements(""); + self.is_bash_mode = false; + Some(InputResult::CustomCommand(cmd)) + } else { + None } - self.stage_slash_command_history(); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; - Some(InputResult::Command(cmd)) } else { None } @@ -2596,24 +2626,31 @@ impl ChatComposer { return None; } - let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?; - - if !cmd.supports_inline_args() { - return None; - } - if self.reject_slash_command_if_unavailable(cmd) { - self.stage_slash_command_history(); - self.record_pending_slash_command_history(); - return Some(InputResult::None); - } - - self.stage_slash_command_history(); - let mut args_elements = Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements()); let trimmed_rest = rest.trim(); args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements); - Some(InputResult::CommandWithArgs( + if let Some(cmd) = slash_commands::find_builtin_command(name, self.builtin_command_flags()) + { + if !cmd.supports_inline_args() { + return None; + } + if self.reject_slash_command_if_unavailable(cmd) { + self.stage_slash_command_history(); + self.record_pending_slash_command_history(); + return Some(InputResult::None); + } + + self.stage_slash_command_history(); + return Some(InputResult::CommandWithArgs( + cmd, + trimmed_rest.to_string(), + args_elements, + )); + } + let cmd = slash_commands::find_custom_command(name, &self.custom_slash_commands)?.clone(); + self.stage_slash_command_history(); + Some(InputResult::CustomCommandWithArgs( cmd, trimmed_rest.to_string(), args_elements, @@ -2691,8 +2728,8 @@ impl ChatComposer { /// /// Popup filtering text can be partial, so recording the selected command avoids recalling /// `/di` after the user actually accepted `/diff`. - fn stage_selected_slash_command_history(&mut self, cmd: SlashCommand) { - self.stage_slash_command_history_text(format!("/{}", cmd.command())); + fn stage_selected_slash_command_history(&mut self, command_name: &str) { + self.stage_slash_command_history_text(format!("/{command_name}")); } /// Store the provided command text and the current composer adornments in the pending slot. @@ -3387,6 +3424,7 @@ impl ChatComposer { fn is_known_slash_name(&self, name: &str) -> bool { slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some() + || slash_commands::find_custom_command(name, &self.custom_slash_commands).is_some() } /// If the cursor is currently within a slash command on the first line, @@ -3428,7 +3466,11 @@ impl ChatComposer { return rest_after_name.is_empty(); } - slash_commands::has_builtin_prefix(name, self.builtin_command_flags()) + slash_commands::has_command_prefix( + name, + self.builtin_command_flags(), + &self.custom_slash_commands, + ) } /// Synchronize `self.command_popup` with the current text in the @@ -3478,17 +3520,20 @@ 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(CommandPopupFlags { - collaboration_modes_enabled, - connectors_enabled, - plugins_command_enabled, - fast_command_enabled, - personality_command_enabled, - realtime_conversation_enabled, - audio_device_selection_enabled, - windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, - side_conversation_active: self.side_conversation_active, - }); + let mut command_popup = CommandPopup::new_with_custom( + CommandPopupFlags { + collaboration_modes_enabled, + connectors_enabled, + plugins_command_enabled, + fast_command_enabled, + personality_command_enabled, + realtime_conversation_enabled, + audio_device_selection_enabled, + windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, + side_conversation_active: self.side_conversation_active, + }, + &self.custom_slash_commands, + ); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -6594,7 +6639,6 @@ mod tests { #[test] fn slash_popup_model_first_for_mo_logic() { - use super::super::command_popup::CommandItem; let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -6607,10 +6651,11 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'm', 'o']); match &composer.active_popup { - ActivePopup::Command(popup) => match popup.selected_item() { - Some(CommandItem::Builtin(cmd)) => { + ActivePopup::Command(popup) => match popup.selected_command() { + Some(SelectedCommand::Builtin(cmd)) => { assert_eq!(cmd.command(), "model") } + Some(SelectedCommand::Custom(_)) => panic!("custom command selected for '/mo'"), None => panic!("no selected command for '/mo'"), }, _ => panic!("slash popup not active after typing '/mo'"), @@ -6647,7 +6692,6 @@ mod tests { #[test] fn slash_popup_resume_for_res_logic() { - use super::super::command_popup::CommandItem; let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -6660,10 +6704,11 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); match &composer.active_popup { - ActivePopup::Command(popup) => match popup.selected_item() { - Some(CommandItem::Builtin(cmd)) => { + ActivePopup::Command(popup) => match popup.selected_command() { + Some(SelectedCommand::Builtin(cmd)) => { assert_eq!(cmd.command(), "resume") } + Some(SelectedCommand::Custom(_)) => panic!("custom command selected for '/res'"), None => panic!("no selected command for '/res'"), }, _ => panic!("slash popup not active after typing '/res'"), @@ -6727,6 +6772,9 @@ mod tests { InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/init'") } + InputResult::CustomCommand(_) | InputResult::CustomCommandWithArgs(_, _, _) => { + panic!("expected built-in command dispatch for '/init'") + } InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } @@ -7083,6 +7131,9 @@ mod tests { InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/diff'") } + InputResult::CustomCommand(_) | InputResult::CustomCommandWithArgs(_, _, _) => { + panic!("expected built-in command dispatch for '/diff'") + } InputResult::Submitted { text, .. } => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } @@ -7277,6 +7328,9 @@ mod tests { InputResult::CommandWithArgs(_, _, _) => { panic!("expected command dispatch without args for '/mention'") } + InputResult::CustomCommand(_) | InputResult::CustomCommandWithArgs(_, _, _) => { + panic!("expected built-in command dispatch for '/mention'") + } InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 28c749cb57..aa88ee51cf 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -7,6 +7,7 @@ use super::scroll_state::ScrollState; use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::render_rows; use super::slash_commands; +use crate::custom_slash_command::CustomSlashCommand; use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; @@ -16,15 +17,31 @@ use crate::slash_command::SlashCommand; // `approvals` is an alias of `permissions`. const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals]; -/// A selectable item in the popup. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum CommandItem { +enum CommandItem { Builtin(SlashCommand), + Custom(usize), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SelectedCommand { + Builtin(SlashCommand), + Custom(CustomSlashCommand), +} + +impl SelectedCommand { + pub(crate) fn name(&self) -> &str { + match self { + SelectedCommand::Builtin(cmd) => cmd.command(), + SelectedCommand::Custom(cmd) => &cmd.name, + } + } } pub(crate) struct CommandPopup { command_filter: String, builtins: Vec<(&'static str, SlashCommand)>, + custom_commands: Vec, state: ScrollState, } @@ -58,7 +75,15 @@ impl From for slash_commands::BuiltinCommandFlags { } impl CommandPopup { + #[cfg(test)] pub(crate) fn new(flags: CommandPopupFlags) -> Self { + Self::new_with_custom(flags, &[]) + } + + pub(crate) fn new_with_custom( + flags: CommandPopupFlags, + custom_commands: &[CustomSlashCommand], + ) -> Self { // Keep built-in availability in sync with the composer. let builtins: Vec<(&'static str, SlashCommand)> = slash_commands::builtins_for_input(flags.into()) @@ -69,6 +94,9 @@ impl CommandPopup { Self { command_filter: String::new(), builtins, + custom_commands: slash_commands::custom_commands_for_input(custom_commands) + .cloned() + .collect(), state: ScrollState::new(), } } @@ -126,6 +154,9 @@ impl CommandPopup { } out.push((CommandItem::Builtin(*cmd), None)); } + for idx in 0..self.custom_commands.len() { + out.push((CommandItem::Custom(idx), None)); + } return out; } @@ -159,6 +190,9 @@ impl CommandPopup { for (_, cmd) in self.builtins.iter() { push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); } + for (idx, command) in self.custom_commands.iter().enumerate() { + push_match(CommandItem::Custom(idx), &command.name, None, 0); + } out.extend(exact); out.extend(prefix); @@ -175,21 +209,38 @@ impl CommandPopup { ) -> Vec { matches .into_iter() - .map(|(item, indices)| { - let CommandItem::Builtin(cmd) = item; - let name = format!("/{}", cmd.command()); - let description = cmd.description().to_string(); - GenericDisplayRow { + .filter_map(|(item, indices)| { + let (name, description, category_tag) = match item { + CommandItem::Builtin(cmd) => ( + format!("/{}", cmd.command()), + cmd.description().to_string(), + None, + ), + CommandItem::Custom(idx) => { + let command = self.custom_commands.get(idx)?; + let suffix = command + .argument_hint + .as_ref() + .map(|hint| format!(" {hint}")) + .unwrap_or_default(); + ( + format!("/{}{}", command.name, suffix), + command.description.clone(), + Some("private".to_string()), + ) + } + }; + Some(GenericDisplayRow { name, name_prefix_spans: Vec::new(), match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), display_shortcut: None, description: Some(description), - category_tag: None, + category_tag, wrap_indent: None, is_disabled: false, disabled_reason: None, - } + }) }) .collect() } @@ -210,12 +261,23 @@ impl CommandPopup { } /// Return currently selected command, if any. - pub(crate) fn selected_item(&self) -> Option { + fn selected_item(&self) -> Option { let matches = self.filtered_items(); self.state .selected_idx .and_then(|idx| matches.get(idx).copied()) } + + pub(crate) fn selected_command(&self) -> Option { + match self.selected_item()? { + CommandItem::Builtin(cmd) => Some(SelectedCommand::Builtin(cmd)), + CommandItem::Custom(idx) => self + .custom_commands + .get(idx) + .cloned() + .map(SelectedCommand::Custom), + } + } } impl WidgetRef for CommandPopup { @@ -237,7 +299,16 @@ impl WidgetRef for CommandPopup { #[cfg(test)] mod tests { use super::*; + use crate::custom_slash_command::load_custom_slash_commands; use pretty_assertions::assert_eq; + use tempfile::TempDir; + + fn builtin_name(item: CommandItem) -> Option<&'static str> { + match item { + CommandItem::Builtin(cmd) => Some(cmd.command()), + CommandItem::Custom(_) => None, + } + } #[test] fn filter_includes_init_when_typing_prefix() { @@ -249,8 +320,9 @@ mod tests { // 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 { + let has_init = matches.iter().copied().any(|item| match item { CommandItem::Builtin(cmd) => cmd.command() == "init", + CommandItem::Custom(_) => false, }); assert!( has_init, @@ -268,7 +340,7 @@ mod tests { let selected = popup.selected_item(); match selected { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), - None => panic!("expected a selected command for exact match"), + other => panic!("expected init to be selected for exact match, got {other:?}"), } } @@ -279,7 +351,31 @@ mod tests { let matches = popup.filtered_items(); match matches.first() { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), - None => panic!("expected at least one match for '/mo'"), + other => panic!("expected model to be first match for '/mo', got {other:?}"), + } + } + + #[test] + fn custom_commands_are_filterable() { + let codex_home = TempDir::new().expect("tempdir"); + let commands_dir = codex_home.path().join("commands"); + std::fs::create_dir_all(&commands_dir).expect("commands dir"); + std::fs::write( + commands_dir.join("orient.md"), + "---\ndescription: Rebuild working context\nargument-hint: [focus]\n---\nOrient around $ARGUMENTS.", + ) + .expect("command file"); + let commands = load_custom_slash_commands(codex_home.path()); + let mut popup = CommandPopup::new_with_custom(CommandPopupFlags::default(), &commands); + + popup.on_composer_text_change("/ori".to_string()); + + match popup.selected_command() { + Some(SelectedCommand::Custom(command)) => { + assert_eq!(command.name, "orient"); + assert_eq!(command.argument_hint.as_deref(), Some("[focus]")); + } + other => panic!("expected custom orient command to be selected, got {other:?}"), } } @@ -291,9 +387,7 @@ mod tests { let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) + .filter_map(builtin_name) .collect(); assert_eq!(cmds, vec!["model", "memories", "mention", "mcp"]); } @@ -306,9 +400,7 @@ mod tests { let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) + .filter_map(builtin_name) .collect(); assert!( !cmds.contains(&"compact"), @@ -336,9 +428,7 @@ mod tests { let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) + .filter_map(builtin_name) .collect(); assert!( !cmds.contains(&"collab"), @@ -410,9 +500,7 @@ mod tests { let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) + .filter_map(builtin_name) .collect(); assert!( !cmds.contains(&"personality"), @@ -459,9 +547,7 @@ mod tests { let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) + .filter_map(builtin_name) .collect(); assert!( @@ -476,9 +562,7 @@ mod tests { let cmds: Vec<&str> = popup .filtered_items() .into_iter() - .map(|item| match item { - CommandItem::Builtin(cmd) => cmd.command(), - }) + .filter_map(builtin_name) .collect(); assert!( diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c9dc4db263..6b49187a51 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -21,6 +21,7 @@ use crate::app_event_sender::AppEventSender; use crate::bottom_pane::pending_input_preview::PendingInputPreview; use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals; use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; +use crate::custom_slash_command::CustomSlashCommand; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::render::renderable::FlexRenderable; @@ -262,6 +263,15 @@ impl BottomPane { self.request_redraw(); } + pub fn set_custom_slash_commands(&mut self, commands: Vec) { + self.composer.set_custom_slash_commands(commands); + self.request_redraw(); + } + + pub fn custom_slash_commands(&self) -> &[CustomSlashCommand] { + self.composer.custom_slash_commands() + } + /// Update image-paste behavior for the active composer and repaint immediately. /// /// Callers use this to keep composer affordances aligned with model capabilities. diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 78d99e3e67..19b6f907fa 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -7,6 +7,7 @@ use std::str::FromStr; use codex_utils_fuzzy_match::fuzzy_match; +use crate::custom_slash_command::CustomSlashCommand; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; @@ -57,6 +58,28 @@ pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Op .then_some(cmd) } +pub(crate) fn is_builtin_command_name(name: &str) -> bool { + SlashCommand::from_str(name).is_ok() +} + +pub(crate) fn custom_commands_for_input( + custom_commands: &[CustomSlashCommand], +) -> impl Iterator { + custom_commands + .iter() + .filter(|command| !is_builtin_command_name(&command.name)) +} + +pub(crate) fn find_custom_command<'a>( + name: &str, + custom_commands: &'a [CustomSlashCommand], +) -> Option<&'a CustomSlashCommand> { + if is_builtin_command_name(name) { + return None; + } + custom_commands.iter().find(|command| command.name == name) +} + /// Whether any visible built-in fuzzily matches the provided prefix. pub(crate) fn has_builtin_prefix(name: &str, flags: BuiltinCommandFlags) -> bool { builtins_for_input(flags) @@ -64,6 +87,16 @@ pub(crate) fn has_builtin_prefix(name: &str, flags: BuiltinCommandFlags) -> bool .any(|(command_name, _)| fuzzy_match(command_name, name).is_some()) } +pub(crate) fn has_command_prefix( + name: &str, + flags: BuiltinCommandFlags, + custom_commands: &[CustomSlashCommand], +) -> bool { + has_builtin_prefix(name, flags) + || custom_commands_for_input(custom_commands) + .any(|command| fuzzy_match(&command.name, name).is_some()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9e851f0fca..9baa5763d3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -336,6 +336,7 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::collaboration_modes; +use crate::custom_slash_command::load_custom_slash_commands; use crate::diff_render::display_path_for; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; @@ -1098,13 +1099,23 @@ enum ShellEscapePolicy { struct QueuedUserMessage { user_message: UserMessage, action: QueuedInputAction, + shell_escape_policy: ShellEscapePolicy, } impl QueuedUserMessage { fn new(user_message: UserMessage, action: QueuedInputAction) -> Self { + Self::new_with_shell_escape_policy(user_message, action, ShellEscapePolicy::Allow) + } + + fn new_with_shell_escape_policy( + user_message: UserMessage, + action: QueuedInputAction, + shell_escape_policy: ShellEscapePolicy, + ) -> Self { Self { user_message, action, + shell_escape_policy, } } @@ -5168,6 +5179,7 @@ impl ChatWidget { let current_cwd = Some(config.cwd.to_path_buf()); let effective_service_tier = config.service_tier; let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); + let custom_slash_commands = load_custom_slash_commands(config.codex_home.as_path()); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -5311,6 +5323,9 @@ impl ChatWidget { widget .bottom_pane .set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled()); + widget + .bottom_pane + .set_custom_slash_commands(custom_slash_commands); widget .bottom_pane .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); @@ -5527,6 +5542,16 @@ impl ChatWidget { InputResult::CommandWithArgs(cmd, args, text_elements) => { self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); } + InputResult::CustomCommand(cmd) => { + self.handle_custom_slash_command_dispatch(cmd); + } + InputResult::CustomCommandWithArgs(cmd, args, text_elements) => { + self.handle_custom_slash_command_with_args_dispatch( + cmd, + args, + text_elements, + ); + } InputResult::None => {} } if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() { @@ -5755,13 +5780,30 @@ impl ChatWidget { &mut self, user_message: UserMessage, action: QueuedInputAction, + ) { + self.queue_user_message_with_options_and_shell_escape_policy( + user_message, + action, + ShellEscapePolicy::Allow, + ); + } + + fn queue_user_message_with_options_and_shell_escape_policy( + &mut self, + user_message: UserMessage, + action: QueuedInputAction, + shell_escape_policy: ShellEscapePolicy, ) { if !self.is_session_configured() || self.is_user_turn_pending_or_running() { self.queued_user_messages - .push_back(QueuedUserMessage::new(user_message, action)); + .push_back(QueuedUserMessage::new_with_shell_escape_policy( + user_message, + action, + shell_escape_policy, + )); self.refresh_pending_input_preview(); } else { - self.submit_user_message(user_message); + self.submit_user_message_with_shell_escape_policy(user_message, shell_escape_policy); } } @@ -5804,7 +5846,11 @@ impl ChatWidget { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages - .push_front(QueuedUserMessage::from(user_message)); + .push_front(QueuedUserMessage::new_with_shell_escape_policy( + user_message, + QueuedInputAction::Plain, + shell_escape_policy, + )); self.refresh_pending_input_preview(); return None; } @@ -7559,7 +7605,11 @@ impl ChatWidget { }; match queued_message.action { QueuedInputAction::Plain => { - self.submit_user_message(queued_message.into_user_message()); + let shell_escape_policy = queued_message.shell_escape_policy; + self.submit_user_message_with_shell_escape_policy( + queued_message.into_user_message(), + shell_escape_policy, + ); break; } QueuedInputAction::ParseSlash => { diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index febd8aef4f..1b21954e0c 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -8,6 +8,7 @@ use super::*; use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::slash_commands; +use crate::custom_slash_command::CustomSlashCommand; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SlashCommandDispatchSource { @@ -55,6 +56,46 @@ impl ChatWidget { self.bottom_pane.record_pending_slash_command_history(); } + pub(super) fn handle_custom_slash_command_dispatch(&mut self, cmd: CustomSlashCommand) { + self.dispatch_prepared_custom_slash_command( + cmd, + PreparedSlashCommandArgs { + args: String::new(), + text_elements: Vec::new(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + mention_bindings: Vec::new(), + source: SlashCommandDispatchSource::Live, + }, + ); + self.bottom_pane.record_pending_slash_command_history(); + } + + pub(super) fn handle_custom_slash_command_with_args_dispatch( + &mut self, + cmd: CustomSlashCommand, + args: String, + text_elements: Vec, + ) { + let Some((prepared_args, prepared_elements)) = + self.prepare_live_inline_args(args, text_elements) + else { + return; + }; + self.dispatch_prepared_custom_slash_command( + cmd, + PreparedSlashCommandArgs { + args: prepared_args, + text_elements: prepared_elements, + local_images: Vec::new(), + remote_image_urls: Vec::new(), + mention_bindings: Vec::new(), + source: SlashCommandDispatchSource::Live, + }, + ); + self.bottom_pane.record_pending_slash_command_history(); + } + fn apply_plan_slash_command(&mut self) -> bool { if !self.collaboration_modes_enabled() { self.add_info_message( @@ -618,6 +659,44 @@ impl ChatWidget { } } + fn dispatch_prepared_custom_slash_command( + &mut self, + command: CustomSlashCommand, + prepared: PreparedSlashCommandArgs, + ) { + let PreparedSlashCommandArgs { + args, + text_elements: _, + local_images, + remote_image_urls, + mention_bindings, + source, + } = prepared; + let user_message = self.prepared_inline_user_message( + command.expanded_prompt(args.trim()), + Vec::new(), + local_images, + remote_image_urls, + mention_bindings, + source, + ); + if self.is_session_configured() { + self.submit_user_message_with_shell_escape_policy( + user_message, + ShellEscapePolicy::Disallow, + ); + } else { + self.queue_user_message_with_options_and_shell_escape_policy( + user_message, + QueuedInputAction::Plain, + ShellEscapePolicy::Disallow, + ); + } + if source == SlashCommandDispatchSource::Live { + self.bottom_pane.drain_pending_submission_state(); + } + } + pub(super) fn submit_queued_slash_prompt(&mut self, user_message: UserMessage) -> QueueDrain { let UserMessage { text, @@ -648,8 +727,24 @@ impl ChatWidget { return QueueDrain::Stop; } - let Some(cmd) = slash_commands::find_builtin_command(name, self.builtin_command_flags()) - else { + let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags()); + let custom_command = + slash_commands::find_custom_command(name, self.bottom_pane.custom_slash_commands()); + let Some(cmd) = cmd else { + if let Some(command) = custom_command { + self.dispatch_prepared_custom_slash_command( + command.clone(), + PreparedSlashCommandArgs { + args: rest.trim().to_string(), + text_elements: Vec::new(), + local_images, + remote_image_urls, + mention_bindings, + source: SlashCommandDispatchSource::Queued, + }, + ); + return QueueDrain::Stop; + } self.add_info_message( format!( r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 960ab992a0..02e4fbdbe4 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -169,6 +169,7 @@ pub(super) async fn make_chatwidget_manual( skills: None, }); bottom.set_collaboration_modes_enabled(/*enabled*/ true); + bottom.set_custom_slash_commands(load_custom_slash_commands(cfg.codex_home.as_path())); let model_catalog = test_model_catalog(&cfg); let reasoning_effort = None; let base_mode = CollaborationMode { diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 85b868ce76..4cfeb489a8 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -165,6 +165,59 @@ async fn queued_slash_review_with_args_restores_for_edit() { ); } +#[tokio::test] +async fn private_slash_command_expands_prompt_with_args() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + let commands_dir = chat.config.codex_home.join("commands"); + std::fs::create_dir_all(&commands_dir).expect("commands dir"); + std::fs::write( + commands_dir.join("orient.md"), + "---\ndescription: Rebuild local context\nargument-hint: [focus]\n---\nLoad current context for $ARGUMENTS.", + ) + .expect("command file"); + chat.bottom_pane + .set_custom_slash_commands(load_custom_slash_commands(chat.config.codex_home.as_path())); + + submit_composer_text(&mut chat, "/orient auth flow"); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "Load current context for auth flow.".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected private slash command to submit user turn, got {other:?}"), + } +} + +#[tokio::test] +async fn private_slash_command_prompt_is_not_shell_escape() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + let commands_dir = chat.config.codex_home.join("commands"); + std::fs::create_dir_all(&commands_dir).expect("commands dir"); + std::fs::write(commands_dir.join("literal-shell.md"), "!echo $ARGUMENTS") + .expect("command file"); + chat.bottom_pane + .set_custom_slash_commands(load_custom_slash_commands(chat.config.codex_home.as_path())); + + submit_composer_text(&mut chat, "/literal-shell hello"); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "!echo hello".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected private slash command to submit user turn, got {other:?}"), + } +} + #[tokio::test] async fn queued_bang_shell_dispatches_after_active_turn() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/custom_slash_command.rs b/codex-rs/tui/src/custom_slash_command.rs new file mode 100644 index 0000000000..7472de9482 --- /dev/null +++ b/codex-rs/tui/src/custom_slash_command.rs @@ -0,0 +1,232 @@ +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use tracing::warn; + +const COMMANDS_DIR: &str = "commands"; +const MARKDOWN_EXTENSION: &str = "md"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CustomSlashCommand { + pub(crate) name: String, + pub(crate) description: String, + pub(crate) argument_hint: Option, + pub(crate) path: PathBuf, + prompt: String, +} + +impl CustomSlashCommand { + pub(crate) fn expanded_prompt(&self, args: &str) -> String { + expand_placeholders(&self.prompt, args) + } +} + +#[derive(Default)] +struct CommandMetadata { + description: Option, + argument_hint: Option, +} + +pub(crate) fn load_custom_slash_commands(codex_home: &Path) -> Vec { + let root = codex_home.join(COMMANDS_DIR); + let mut commands = Vec::new(); + if let Err(err) = collect_commands(&root, &root, &mut commands) + && err.kind() != std::io::ErrorKind::NotFound + { + warn!( + "failed to load custom slash commands from {}: {err}", + root.display() + ); + } + commands.sort_by(|left, right| left.name.cmp(&right.name).then(left.path.cmp(&right.path))); + commands.dedup_by(|left, right| left.name == right.name); + commands +} + +fn collect_commands( + root: &Path, + current: &Path, + commands: &mut Vec, +) -> std::io::Result<()> { + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + collect_commands(root, &path, commands)?; + } else if file_type.is_file() + && path.extension().and_then(|ext| ext.to_str()) == Some(MARKDOWN_EXTENSION) + && let Some(command) = command_from_path(root, &path) + { + commands.push(command); + } + } + Ok(()) +} + +fn command_from_path(root: &Path, path: &Path) -> Option { + path.strip_prefix(root).ok()?; + let stem = path.file_stem()?.to_string_lossy(); + if !is_valid_command_name(&stem) { + return None; + } + let content = fs::read_to_string(path).ok()?; + let (metadata, prompt) = parse_command_file(&content); + let prompt = prompt.trim().to_string(); + if prompt.is_empty() { + return None; + } + let description = metadata + .description + .unwrap_or_else(|| default_description(&prompt)); + Some(CustomSlashCommand { + name: stem.to_string(), + description, + argument_hint: metadata.argument_hint, + path: path.to_path_buf(), + prompt, + }) +} + +fn is_valid_command_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') +} + +fn parse_command_file(content: &str) -> (CommandMetadata, String) { + let normalized = content.replace("\r\n", "\n"); + let Some(rest) = normalized.strip_prefix("---\n") else { + return (CommandMetadata::default(), normalized); + }; + let Some(end) = rest.find("\n---") else { + return (CommandMetadata::default(), normalized); + }; + let frontmatter = &rest[..end]; + let body_start = end + "\n---".len(); + let body = rest[body_start..] + .strip_prefix('\n') + .unwrap_or(&rest[body_start..]); + (parse_frontmatter(frontmatter), body.to_string()) +} + +fn parse_frontmatter(frontmatter: &str) -> CommandMetadata { + let mut metadata = CommandMetadata::default(); + for line in frontmatter.lines() { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim().trim_matches('"').trim_matches('\''); + if value.is_empty() { + continue; + } + match key.trim() { + "description" => metadata.description = Some(value.to_string()), + "argument-hint" => metadata.argument_hint = Some(value.to_string()), + _ => {} + } + } + metadata +} + +fn default_description(prompt: &str) -> String { + prompt + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(|line| line.trim_start_matches('#').trim()) + .filter(|line| !line.is_empty()) + .unwrap_or("custom prompt") + .to_string() +} + +fn expand_placeholders(prompt: &str, args: &str) -> String { + let positional_args = shlex::split(args) + .unwrap_or_else(|| args.split_whitespace().map(ToString::to_string).collect()); + let mut expanded = String::with_capacity(prompt.len() + args.len()); + let mut chars = prompt.char_indices().peekable(); + while let Some((idx, ch)) = chars.next() { + if ch != '$' { + expanded.push(ch); + continue; + } + let rest = &prompt[idx..]; + if rest.starts_with("$ARGUMENTS") { + expanded.push_str(args); + for _ in 0.."ARGUMENTS".len() { + chars.next(); + } + continue; + } + let mut arg_number = String::new(); + while let Some((_, next)) = chars.peek().copied() { + if !next.is_ascii_digit() { + break; + } + arg_number.push(next); + chars.next(); + } + if arg_number.is_empty() { + expanded.push('$'); + continue; + } + if let Ok(index) = arg_number.parse::() + && index > 0 + && let Some(value) = positional_args.get(index - 1) + { + expanded.push_str(value); + } + } + expanded +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + use super::*; + + #[test] + fn loads_private_commands_from_codex_home() { + let codex_home = TempDir::new().expect("tempdir"); + let commands_dir = codex_home.path().join("commands").join("db"); + fs::create_dir_all(&commands_dir).expect("commands dir"); + fs::write( + commands_dir.join("migrate.md"), + "---\ndescription: Run a migration review\nargument-hint: \n---\nReview migration $ARGUMENTS.", + ) + .expect("command file"); + + let commands = load_custom_slash_commands(codex_home.path()); + + assert_eq!( + commands, + vec![CustomSlashCommand { + name: "migrate".to_string(), + description: "Run a migration review".to_string(), + argument_hint: Some("".to_string()), + path: commands_dir.join("migrate.md"), + prompt: "Review migration $ARGUMENTS.".to_string(), + }] + ); + } + + #[test] + fn expands_all_and_positional_arguments() { + let command = CustomSlashCommand { + name: "review-pr".to_string(), + description: "Review PR".to_string(), + argument_hint: None, + path: PathBuf::from("review-pr.md"), + prompt: "Review PR $1 with priority $2. Raw: $ARGUMENTS.".to_string(), + }; + + assert_eq!( + command.expanded_prompt("456 high"), + "Review PR 456 with priority high. Raw: 456 high." + ); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 095a2f3477..40d303993b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -112,6 +112,7 @@ mod clipboard_copy; mod clipboard_paste; mod collaboration_modes; mod color; +mod custom_slash_command; pub(crate) mod custom_terminal; pub use custom_terminal::Terminal; mod cwd_prompt; diff --git a/docs/slash_commands.md b/docs/slash_commands.md index 4db63f7f6e..f5f7ec249a 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -1,3 +1,36 @@ # Slash commands For an overview of Codex CLI slash commands, see [this documentation](https://developers.openai.com/codex/cli/slash-commands). + +## Private prompt commands + +Codex also loads private prompt commands from `$CODEX_HOME/commands/`. Each Markdown file defines +one slash command whose body is submitted as a normal user prompt. + +For example: + +```text +$CODEX_HOME/commands/orient.md -> /orient +$CODEX_HOME/commands/db/migrate.md -> /migrate +``` + +Subdirectories are only for organization; the Markdown filename still provides the command name. + +Command files can include optional YAML frontmatter: + +```md +--- +description: Rebuild working context after clearing the session +argument-hint: [focus] +--- + +Load my current working state into this conversation. +Focus on: $ARGUMENTS +``` + +`description` is shown in the slash-command popup, and `argument-hint` is shown next to the command +name. `$ARGUMENTS` expands to all text after the command name; `$1`, `$2`, and later positional +placeholders expand from shell-style split arguments. + +Private command prompts are sent to the model as prompt text. Codex does not pre-execute +Claude-style ``!`command` `` substitutions or apply `allowed-tools` frontmatter from these files. diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 0ad5c693b3..6d342f927c 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -99,6 +99,10 @@ Built-in slash command availability is centralized in `codex-rs/tui/src/bottom_pane/slash_commands.rs` and reused by both the composer and the command popup so gating stays in sync. +Private prompt commands are loaded from `$CODEX_HOME/commands/` and threaded through the same +composer/popup path. They expand their Markdown prompt template and submit it as ordinary prompt +text; they do not run local shell substitutions before submission. + ## Submission flow (Enter/Tab) There are multiple submission paths, but they share the same core rules: