tui: centralize slash command serialization

Introduce a small slash command invocation serializer and consolidate built-in command metadata behind a single spec table without changing slash command runtime behavior.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-16 20:43:16 -07:00
co-authored by Codex
parent 49c2b66ece
commit b231db7dfa
5 changed files with 370 additions and 115 deletions
@@ -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;
@@ -1416,12 +1417,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());
@@ -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 {
@@ -143,7 +138,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));
+1
View File
@@ -114,6 +114,7 @@ mod session_log;
mod shimmer;
mod skills_helpers;
mod slash_command;
mod slash_command_invocation;
mod status;
mod status_indicator_widget;
mod streaming;
+322 -105
View File
@@ -65,53 +65,308 @@ pub enum SlashCommand {
}
impl SlashCommand {
fn spec(self) -> SlashCommandSpec {
match self {
SlashCommand::Model => SlashCommandSpec {
description: "choose what model and reasoning effort to use",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Fast => SlashCommandSpec {
description: "toggle Fast mode to enable fastest inference at 2X plan usage",
supports_inline_args: true,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Approvals => SlashCommandSpec {
description: "choose what Codex is allowed to do",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: false,
},
SlashCommand::Permissions => SlashCommandSpec {
description: "choose what Codex is allowed to do",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::ElevateSandbox => SlashCommandSpec {
description: "set up elevated agent sandbox",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::SandboxReadRoot => SlashCommandSpec {
description: "let sandbox read a directory: /sandbox-add-read-dir <absolute_path>",
supports_inline_args: true,
available_during_task: false,
is_visible: cfg!(target_os = "windows"),
show_in_command_popup: true,
},
SlashCommand::Experimental => SlashCommandSpec {
description: "toggle experimental features",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Skills => SlashCommandSpec {
description: "use skills to improve how Codex performs specific tasks",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Review => SlashCommandSpec {
description: "review my current changes and find issues",
supports_inline_args: true,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Rename => SlashCommandSpec {
description: "rename the current thread",
supports_inline_args: true,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::New => SlashCommandSpec {
description: "start a new chat during a conversation",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Resume => SlashCommandSpec {
description: "resume a saved chat",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Fork => SlashCommandSpec {
description: "fork the current chat",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Init => SlashCommandSpec {
description: "create an AGENTS.md file with instructions for Codex",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Compact => SlashCommandSpec {
description: "summarize conversation to prevent hitting the context limit",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Plan => SlashCommandSpec {
description: "switch to Plan mode",
supports_inline_args: true,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Collab => SlashCommandSpec {
description: "change collaboration mode (experimental)",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Agent => SlashCommandSpec {
description: "switch the active agent thread",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Diff => SlashCommandSpec {
description: "show git diff (including untracked files)",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Copy => SlashCommandSpec {
description: "copy the latest Codex output to your clipboard",
supports_inline_args: false,
available_during_task: true,
is_visible: !cfg!(target_os = "android"),
show_in_command_popup: true,
},
SlashCommand::Mention => SlashCommandSpec {
description: "mention a file",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Status => SlashCommandSpec {
description: "show current session configuration and token usage",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::DebugConfig => SlashCommandSpec {
description: "show config layers and requirement sources for debugging",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Statusline => SlashCommandSpec {
description: "configure which items appear in the status line",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Theme => SlashCommandSpec {
description: "choose a syntax highlighting theme",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Mcp => SlashCommandSpec {
description: "list configured MCP tools",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Apps => SlashCommandSpec {
description: "manage apps",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Logout => SlashCommandSpec {
description: "log out of Codex",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Quit => SlashCommandSpec {
description: "exit Codex",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: false,
},
SlashCommand::Exit => SlashCommandSpec {
description: "exit Codex",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Feedback => SlashCommandSpec {
description: "send logs to maintainers",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Rollout => SlashCommandSpec {
description: "print the rollout file path",
supports_inline_args: false,
available_during_task: true,
is_visible: cfg!(debug_assertions),
show_in_command_popup: true,
},
SlashCommand::Ps => SlashCommandSpec {
description: "list background terminals",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Stop => SlashCommandSpec {
description: "stop all background terminals",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Clear => SlashCommandSpec {
description: "clear the terminal and start a new chat",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Personality => SlashCommandSpec {
description: "choose a communication style for Codex",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Realtime => SlashCommandSpec {
description: "toggle realtime voice mode (experimental)",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::Settings => SlashCommandSpec {
description: "configure realtime microphone/speaker",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::TestApproval => SlashCommandSpec {
description: "test approval request",
supports_inline_args: false,
available_during_task: true,
is_visible: cfg!(debug_assertions),
show_in_command_popup: true,
},
SlashCommand::MultiAgents => SlashCommandSpec {
description: "switch the active agent thread",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::MemoryDrop => SlashCommandSpec {
description: "DO NOT USE",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
SlashCommand::MemoryUpdate => SlashCommandSpec {
description: "DO NOT USE",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
show_in_command_popup: true,
},
}
}
/// 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::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 <absolute_path>"
}
SlashCommand::Experimental => "toggle experimental features",
SlashCommand::Mcp => "list configured MCP tools",
SlashCommand::Apps => "manage apps",
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
@@ -122,71 +377,20 @@ impl SlashCommand {
/// 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
)
self.spec().supports_inline_args
}
/// 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::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,
}
self.spec().available_during_task
}
pub(crate) fn show_in_command_popup(self) -> bool {
self.spec().show_in_command_popup
}
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,
}
self.spec().is_visible
}
}
@@ -198,6 +402,14 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
.collect()
}
struct SlashCommandSpec {
description: &'static str,
supports_inline_args: bool,
available_during_task: bool,
is_visible: bool,
show_in_command_popup: bool,
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
@@ -206,12 +418,17 @@ 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_eq!(SlashCommand::Approvals.show_in_command_popup(), false);
}
#[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");
}
}
@@ -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");
}
}