Compare commits

...

2 Commits

Author SHA1 Message Date
viyatb-oai
2a4294e8a4 docs: remove private command comparison
Remove the external-tool comparison from the private slash command docs so the feature is described in Codex-native terms.

Co-authored-by: Codex <noreply@openai.com>
2026-04-24 10:23:07 -07:00
viyatb-oai
1d6b06f88d feat(tui): add private slash commands
Load private Markdown slash commands from CODEX_HOME/commands, surface them in the TUI slash popup, expand prompt arguments, and keep generated prompts out of shell-escape execution.

Co-authored-by: Codex <noreply@openai.com>
2026-04-24 10:20:49 -07:00
12 changed files with 751 additions and 101 deletions

View File

@@ -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<TextElement>),
/// 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<TextElement>),
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<String>,
history_search: Option<HistorySearchSession>,
custom_slash_commands: Vec<CustomSlashCommand>,
}
#[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<CustomSlashCommand>) {
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::<AppEvent>();
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::<AppEvent>();
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}")
}

View File

@@ -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<CustomSlashCommand>,
state: ScrollState,
}
@@ -58,7 +75,15 @@ impl From<CommandPopupFlags> 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<GenericDisplayRow> {
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<CommandItem> {
fn selected_item(&self) -> Option<CommandItem> {
let matches = self.filtered_items();
self.state
.selected_idx
.and_then(|idx| matches.get(idx).copied())
}
pub(crate) fn selected_command(&self) -> Option<SelectedCommand> {
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!(

View File

@@ -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<CustomSlashCommand>) {
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.

View File

@@ -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<Item = &CustomSlashCommand> {
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::*;

View File

@@ -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 => {

View File

@@ -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<TextElement>,
) {
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."#

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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<String>,
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<String>,
argument_hint: Option<String>,
}
pub(crate) fn load_custom_slash_commands(codex_home: &Path) -> Vec<CustomSlashCommand> {
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<CustomSlashCommand>,
) -> 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<CustomSlashCommand> {
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::<usize>()
&& 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: <revision>\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("<revision>".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."
);
}
}

View File

@@ -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;

View File

@@ -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
``!`command` `` substitutions or apply tool-restriction frontmatter from these files.

View File

@@ -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: