diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8116cf972b..e2c9db7521 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -192,6 +192,7 @@ use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; +use crate::slash_command_invocation::SlashCommandInvocation; use crate::style::user_message_style; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; @@ -1423,12 +1424,13 @@ impl ChatComposer { return (InputResult::Command(cmd), true); } - let starts_with_cmd = first_line - .trim_start() - .starts_with(&format!("/{}", cmd.command())); + let bare_command = + SlashCommandInvocation::bare(cmd).into_prefixed_string(); + let starts_with_cmd = + first_line.trim_start().starts_with(&bare_command); if !starts_with_cmd { self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); + .set_text_clearing_elements(&format!("{bare_command} ")); } if !self.textarea.text().is_empty() { cursor_target = Some(self.textarea.text().len()); diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index e7269c38ef..af1f2e58a7 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -14,11 +14,6 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; -// Hide alias commands in the default popup list so each unique action appears once. -// `quit` is an alias of `exit`, so we skip `quit` here. -// `approvals` is an alias of `permissions`. -const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals]; - /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { @@ -145,7 +140,7 @@ impl CommandPopup { if filter.is_empty() { // Built-ins first, in presentation order. for (_, cmd) in self.builtins.iter() { - if ALIAS_COMMANDS.contains(cmd) { + if !cmd.show_in_command_popup() { continue; } out.push((CommandItem::Builtin(*cmd), None)); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5416200691..8647eb4494 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -122,6 +122,7 @@ mod session_log; mod shimmer; mod skills_helpers; mod slash_command; +mod slash_command_invocation; mod status; mod status_indicator_widget; mod streaming; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ec624d3fb9..d47fb07007 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -67,55 +67,278 @@ pub enum SlashCommand { } impl SlashCommand { + fn spec(self) -> SlashCommandSpec { + match self { + SlashCommand::Model => SlashCommandSpec { + description: "choose what model and reasoning effort to use", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Fast => SlashCommandSpec { + description: "toggle Fast mode to enable fastest inference at 2X plan usage", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Approvals => SlashCommandSpec { + description: "choose what Codex is allowed to do", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: true, + }, + SlashCommand::Permissions => SlashCommandSpec { + description: "choose what Codex is allowed to do", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::ElevateSandbox => SlashCommandSpec { + description: "set up elevated agent sandbox", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::SandboxReadRoot => SlashCommandSpec { + description: "let sandbox read a directory: /sandbox-add-read-dir ", + available_during_task: false, + is_disabled: !cfg!(target_os = "windows"), + hide_in_command_popup: false, + }, + SlashCommand::Experimental => SlashCommandSpec { + description: "toggle experimental features", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Skills => SlashCommandSpec { + description: "use skills to improve how Codex performs specific tasks", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Review => SlashCommandSpec { + description: "review my current changes and find issues", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Rename => SlashCommandSpec { + description: "rename the current thread", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::New => SlashCommandSpec { + description: "start a new chat during a conversation", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Resume => SlashCommandSpec { + description: "resume a saved chat", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Fork => SlashCommandSpec { + description: "fork the current chat", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Init => SlashCommandSpec { + description: "create an AGENTS.md file with instructions for Codex", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Compact => SlashCommandSpec { + description: "summarize conversation to prevent hitting the context limit", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Plan => SlashCommandSpec { + description: "switch to Plan mode", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Collab => SlashCommandSpec { + description: "change collaboration mode (experimental)", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Agent => SlashCommandSpec { + description: "switch the active agent thread", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Diff => SlashCommandSpec { + description: "show git diff (including untracked files)", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Copy => SlashCommandSpec { + description: "copy the latest Codex output to your clipboard", + available_during_task: true, + is_disabled: cfg!(target_os = "android"), + hide_in_command_popup: false, + }, + SlashCommand::Mention => SlashCommandSpec { + description: "mention a file", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Status => SlashCommandSpec { + description: "show current session configuration and token usage", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::DebugConfig => SlashCommandSpec { + description: "show config layers and requirement sources for debugging", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Title => SlashCommandSpec { + description: "configure which items appear in the terminal title", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Statusline => SlashCommandSpec { + description: "configure which items appear in the status line", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Theme => SlashCommandSpec { + description: "choose a syntax highlighting theme", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Mcp => SlashCommandSpec { + description: "list configured MCP tools", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Apps => SlashCommandSpec { + description: "manage apps", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Plugins => SlashCommandSpec { + description: "browse plugins", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Logout => SlashCommandSpec { + description: "log out of Codex", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Quit => SlashCommandSpec { + description: "exit Codex", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: true, + }, + SlashCommand::Exit => SlashCommandSpec { + description: "exit Codex", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Feedback => SlashCommandSpec { + description: "send logs to maintainers", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Rollout => SlashCommandSpec { + description: "print the rollout file path", + available_during_task: true, + is_disabled: !cfg!(debug_assertions), + hide_in_command_popup: false, + }, + SlashCommand::Ps => SlashCommandSpec { + description: "list background terminals", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Stop => SlashCommandSpec { + description: "stop all background terminals", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Clear => SlashCommandSpec { + description: "clear the terminal and start a new chat", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Personality => SlashCommandSpec { + description: "choose a communication style for Codex", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Realtime => SlashCommandSpec { + description: "toggle realtime voice mode (experimental)", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::Settings => SlashCommandSpec { + description: "configure realtime microphone/speaker", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::TestApproval => SlashCommandSpec { + description: "test approval request", + available_during_task: true, + is_disabled: !cfg!(debug_assertions), + hide_in_command_popup: false, + }, + SlashCommand::MultiAgents => SlashCommandSpec { + description: "switch the active agent thread", + available_during_task: true, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::MemoryDrop => SlashCommandSpec { + description: "DO NOT USE", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + SlashCommand::MemoryUpdate => SlashCommandSpec { + description: "DO NOT USE", + available_during_task: false, + is_disabled: false, + hide_in_command_popup: false, + }, + } + } + /// User-visible description shown in the popup. pub fn description(self) -> &'static str { - match self { - SlashCommand::Feedback => "send logs to maintainers", - SlashCommand::New => "start a new chat during a conversation", - SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", - SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", - SlashCommand::Review => "review my current changes and find issues", - SlashCommand::Rename => "rename the current thread", - SlashCommand::Resume => "resume a saved chat", - SlashCommand::Clear => "clear the terminal and start a new chat", - SlashCommand::Fork => "fork the current chat", - // SlashCommand::Undo => "ask Codex to undo a turn", - SlashCommand::Quit | SlashCommand::Exit => "exit Codex", - SlashCommand::Diff => "show git diff (including untracked files)", - SlashCommand::Copy => "copy the latest Codex output to your clipboard", - SlashCommand::Mention => "mention a file", - SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", - SlashCommand::Status => "show current session configuration and token usage", - SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", - SlashCommand::Title => "configure which items appear in the terminal title", - SlashCommand::Statusline => "configure which items appear in the status line", - SlashCommand::Theme => "choose a syntax highlighting theme", - SlashCommand::Ps => "list background terminals", - SlashCommand::Stop => "stop all background terminals", - SlashCommand::MemoryDrop => "DO NOT USE", - SlashCommand::MemoryUpdate => "DO NOT USE", - SlashCommand::Model => "choose what model and reasoning effort to use", - SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", - SlashCommand::Personality => "choose a communication style for Codex", - SlashCommand::Realtime => "toggle realtime voice mode (experimental)", - SlashCommand::Settings => "configure realtime microphone/speaker", - SlashCommand::Plan => "switch to Plan mode", - SlashCommand::Collab => "change collaboration mode (experimental)", - SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", - SlashCommand::Approvals => "choose what Codex is allowed to do", - SlashCommand::Permissions => "choose what Codex is allowed to do", - SlashCommand::ElevateSandbox => "set up elevated agent sandbox", - SlashCommand::SandboxReadRoot => { - "let sandbox read a directory: /sandbox-add-read-dir " - } - SlashCommand::Experimental => "toggle experimental features", - SlashCommand::Mcp => "list configured MCP tools", - SlashCommand::Apps => "manage apps", - SlashCommand::Plugins => "browse plugins", - SlashCommand::Logout => "log out of Codex", - SlashCommand::Rollout => "print the rollout file path", - SlashCommand::TestApproval => "test approval request", - } + self.spec().description } /// Command string without the leading '/'. Provided for compatibility with @@ -124,86 +347,89 @@ impl SlashCommand { self.into() } - /// Whether this command supports inline args (for example `/review ...`). - pub fn supports_inline_args(self) -> bool { - matches!( - self, - SlashCommand::Review - | SlashCommand::Rename - | SlashCommand::Plan - | SlashCommand::Fast - | SlashCommand::SandboxReadRoot - ) + /// User-visible usage forms for this command. + pub(crate) fn usage_lines(self) -> &'static [&'static str] { + match self { + SlashCommand::Model => &["/model"], + SlashCommand::Fast => &["/fast", "/fast [on|off|status]"], + SlashCommand::Approvals => &["/approvals"], + SlashCommand::Permissions => &["/permissions"], + SlashCommand::ElevateSandbox => &["/setup-default-sandbox"], + SlashCommand::SandboxReadRoot => &["/sandbox-add-read-dir "], + SlashCommand::Experimental => &["/experimental"], + SlashCommand::Skills => &["/skills"], + SlashCommand::Review => &["/review", "/review "], + SlashCommand::Rename => &["/rename", "/rename "], + SlashCommand::New => &["/new"], + SlashCommand::Resume => &["/resume"], + SlashCommand::Fork => &["/fork"], + SlashCommand::Init => &["/init"], + SlashCommand::Compact => &["/compact"], + SlashCommand::Plan => &["/plan", "/plan <prompt>"], + SlashCommand::Collab => &["/collab"], + SlashCommand::Agent => &["/agent"], + SlashCommand::Diff => &["/diff"], + SlashCommand::Copy => &["/copy"], + SlashCommand::Mention => &["/mention"], + SlashCommand::Status => &["/status"], + SlashCommand::DebugConfig => &["/debug-config"], + SlashCommand::Title => &["/title"], + SlashCommand::Statusline => &["/statusline"], + SlashCommand::Theme => &["/theme"], + SlashCommand::Mcp => &["/mcp"], + SlashCommand::Apps => &["/apps"], + SlashCommand::Plugins => &["/plugins"], + SlashCommand::Logout => &["/logout"], + SlashCommand::Quit => &["/quit"], + SlashCommand::Exit => &["/exit"], + SlashCommand::Feedback => &["/feedback"], + SlashCommand::Rollout => &["/rollout"], + SlashCommand::Ps => &["/ps"], + SlashCommand::Stop => &["/stop"], + SlashCommand::Clear => &["/clear"], + SlashCommand::Personality => &["/personality"], + SlashCommand::Realtime => &["/realtime"], + SlashCommand::Settings => &["/settings"], + SlashCommand::TestApproval => &["/test-approval"], + SlashCommand::MultiAgents => &["/subagents"], + SlashCommand::MemoryDrop => &["/debug-m-drop"], + SlashCommand::MemoryUpdate => &["/debug-m-update"], + } } /// Whether this command can be run while a task is in progress. pub fn available_during_task(self) -> bool { - match self { - SlashCommand::New - | SlashCommand::Resume - | SlashCommand::Fork - | SlashCommand::Init - | SlashCommand::Compact - // | SlashCommand::Undo - | SlashCommand::Model - | SlashCommand::Fast - | SlashCommand::Personality - | SlashCommand::Approvals - | SlashCommand::Permissions - | SlashCommand::ElevateSandbox - | SlashCommand::SandboxReadRoot - | SlashCommand::Experimental - | SlashCommand::Review - | SlashCommand::Plan - | SlashCommand::Clear - | SlashCommand::Logout - | SlashCommand::MemoryDrop - | SlashCommand::MemoryUpdate => false, - SlashCommand::Diff - | SlashCommand::Copy - | SlashCommand::Rename - | SlashCommand::Mention - | SlashCommand::Skills - | SlashCommand::Status - | SlashCommand::DebugConfig - | SlashCommand::Ps - | SlashCommand::Stop - | SlashCommand::Mcp - | SlashCommand::Apps - | SlashCommand::Plugins - | SlashCommand::Feedback - | SlashCommand::Quit - | SlashCommand::Exit => true, - SlashCommand::Rollout => true, - SlashCommand::TestApproval => true, - SlashCommand::Realtime => true, - SlashCommand::Settings => true, - SlashCommand::Collab => true, - SlashCommand::Agent | SlashCommand::MultiAgents => true, - SlashCommand::Statusline => false, - SlashCommand::Theme => false, - SlashCommand::Title => false, - } + self.spec().available_during_task } - fn is_visible(self) -> bool { - match self { - SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), - SlashCommand::Copy => !cfg!(target_os = "android"), - SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions), - _ => true, - } + pub(crate) fn hide_in_command_popup(self) -> bool { + self.spec().hide_in_command_popup + } + + /// Whether this command is disabled for the current build target. + /// + /// This is used for OS-specific or build-specific commands that still belong in the shared + /// enum, such as Windows-only or debug-only slash commands. + fn is_disabled(self) -> bool { + self.spec().is_disabled } } /// Return all built-in commands in a Vec paired with their command string. pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { SlashCommand::iter() - .filter(|command| command.is_visible()) + .filter(|command| !command.is_disabled()) .map(|c| (c.command(), c)) .collect() } +struct SlashCommandSpec { + description: &'static str, + available_during_task: bool, + is_disabled: bool, + hide_in_command_popup: bool, +} + #[cfg(test)] mod tests { use pretty_assertions::assert_eq; @@ -212,12 +438,30 @@ mod tests { use super::SlashCommand; #[test] - fn stop_command_is_canonical_name() { - assert_eq!(SlashCommand::Stop.command(), "stop"); + fn approvals_alias_is_hidden_from_command_popup() { + assert!(SlashCommand::Approvals.hide_in_command_popup()); } #[test] fn clean_alias_parses_to_stop_command() { assert_eq!(SlashCommand::from_str("clean"), Ok(SlashCommand::Stop)); } + + #[test] + fn stop_command_is_canonical_name() { + assert_eq!(SlashCommand::Stop.command(), "stop"); + } + + #[test] + fn fast_usage_lists_bare_and_arg_forms() { + assert_eq!( + SlashCommand::Fast.usage_lines(), + ["/fast", "/fast [on|off|status]"] + ); + } + + #[test] + fn clear_usage_is_bare_only() { + assert_eq!(SlashCommand::Clear.usage_lines(), ["/clear"]); + } } diff --git a/codex-rs/tui/src/slash_command_invocation.rs b/codex-rs/tui/src/slash_command_invocation.rs new file mode 100644 index 0000000000..2cc19ecd57 --- /dev/null +++ b/codex-rs/tui/src/slash_command_invocation.rs @@ -0,0 +1,40 @@ +use crate::slash_command::SlashCommand; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SlashCommandInvocation { + command: SlashCommand, + args: Vec<String>, +} + +impl SlashCommandInvocation { + pub(crate) fn bare(command: SlashCommand) -> Self { + Self { + command, + args: Vec::new(), + } + } + + pub(crate) fn into_prefixed_string(self) -> String { + let command = self.command.command(); + let joined = match shlex::try_join( + std::iter::once(command).chain(self.args.iter().map(String::as_str)), + ) { + Ok(joined) => joined, + Err(err) => panic!("slash command invocation should serialize: {err}"), + }; + format!("/{joined}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn bare_invocation_serializes_with_leading_slash() { + let invocation = SlashCommandInvocation::bare(SlashCommand::Model); + + assert_eq!(invocation.into_prefixed_string(), "/model"); + } +}