mirror of
https://github.com/openai/codex.git
synced 2026-05-17 09:43:19 +00:00
Compare commits
2 Commits
pr20466
...
codex/viya
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a4294e8a4 | ||
|
|
1d6b06f88d |
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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."#
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
232
codex-rs/tui/src/custom_slash_command.rs
Normal file
232
codex-rs/tui/src/custom_slash_command.rs
Normal 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user