From 8fda4e0fc27c2f6ff803a1917e566eef4e8bc021 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Mar 2026 12:10:15 -0700 Subject: [PATCH] tui: add slash command help page Co-authored-by: Codex --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 57 +++++++++++++++ codex-rs/tui/src/bottom_pane/command_popup.rs | 14 ++++ ...hat_composer__tests__slash_popup_root.snap | 12 +++ codex-rs/tui/src/chatwidget.rs | 42 +++++++++++ ..._chatwidget__tests__slash_help_output.snap | 73 +++++++++++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 15 ++++ codex-rs/tui/src/slash_command.rs | 63 ++++++++++++++++ docs/slash_commands.md | 10 +++ 8 files changed, 286 insertions(+) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_root.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_output.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 9ee6126a35..d87076e002 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -6381,6 +6381,63 @@ mod tests { }); } + #[test] + fn slash_popup_help_first_for_root_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + type_chars_humanlike(&mut composer, &['/']); + + let mut terminal = match Terminal::new(TestBackend::new(60, 8)) { + Ok(t) => t, + Err(e) => panic!("Failed to create terminal: {e}"), + }; + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); + + insta::assert_snapshot!("slash_popup_root", terminal.backend()); + } + + #[test] + fn slash_popup_help_first_for_root_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + type_chars_humanlike(&mut composer, &['/']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "help") + } + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt selected for '/'") + } + None => panic!("no selected command for '/'"), + }, + _ => panic!("slash popup not active after typing '/'"), + } + } + #[test] fn slash_popup_model_first_for_mo_ui() { use ratatui::Terminal; diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 0631f0362c..5bed9fdf16 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -338,6 +338,20 @@ mod tests { } } + #[test] + fn help_is_first_suggestion_for_root_popup() { + let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); + popup.on_composer_text_change("/".to_string()); + let matches = popup.filtered_items(); + match matches.first() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "help"), + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt ranked before '/help' for '/'") + } + None => panic!("expected at least one match for '/'"), + } + } + #[test] fn filtered_commands_keep_presentation_order_for_prefix() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_root.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_root.snap new file mode 100644 index 0000000000..7fa61b4ba7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_root.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› / " +" " +" /help show slash command help " +" /model choose what model and reasoning effort to " +" use " +" /permissions choose what Codex is allowed to do " +" /experimental toggle experimental features " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fad6eeb666..44bb4c9b29 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -271,6 +271,7 @@ use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt; use crate::render::renderable::RenderableItem; use crate::slash_command::SlashCommand; +use crate::slash_command::built_in_slash_commands; use crate::status::RateLimitSnapshotDisplay; use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; @@ -4346,6 +4347,10 @@ impl ChatWidget { return QueueReplayControl::Stop; } match cmd { + SlashCommand::Help => { + self.add_slash_help_output(); + QueueReplayControl::Continue + } SlashCommand::Feedback => { if !self.config.feedback_enabled { let params = crate::bottom_pane::feedback_disabled_params(); @@ -4773,6 +4778,10 @@ impl ChatWidget { args_message: UserMessage, ) -> QueueReplayControl { match cmd { + SlashCommand::Help => { + self.add_slash_help_output(); + QueueReplayControl::Continue + } SlashCommand::Approvals | SlashCommand::Permissions => { let args = match SlashCommandInvocation::parse_args( &args_message.text, @@ -6939,6 +6948,39 @@ impl ChatWidget { )); } + pub(crate) fn add_slash_help_output(&mut self) { + let mut lines = vec![ + Line::from("Slash Commands".bold()), + Line::from(""), + Line::from( + "Type / to open the command popup. For commands with both a picker and an arg form, bare /command opens the picker and /command ... runs directly." + .dim(), + ), + Line::from("Args use shell-style quoting; quote values with spaces.".dim()), + Line::from(""), + ]; + + const DESCRIPTION_COLUMN: usize = 56; + + for (_, cmd) in built_in_slash_commands() { + let forms = cmd.help_forms(); + let primary = if forms[0].is_empty() { + format!("/{}", cmd.command()) + } else { + format!("/{} {}", cmd.command(), forms[0]) + }; + let padded = format!(" {primary: [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes] + /fast toggle Fast mode to enable fastest inference at 2X plan usage + /fast + /approvals choose what Codex is allowed to do + /approvals [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy] + /permissions choose what Codex is allowed to do + /permissions [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy] + /setup-default-sandbox set up elevated agent sandbox + /experimental toggle experimental features + /experimental =on|off ... + /skills use skills to improve how Codex performs specific tasks + /skills + /review review my current changes and find issues + /review uncommitted + /review branch + /review commit [title] + /review + /rename rename the current thread + /rename + /new start a new chat during a conversation + /resume resume a saved chat + /resume + /fork fork the current chat + /init create an AGENTS.md file with instructions for Codex + /compact summarize conversation to prevent hitting the context limit + /plan switch to Plan mode + /plan + /collab change collaboration mode (experimental) + /collab + /agent switch the active agent thread + /agent + /diff show git diff (including untracked files) + /copy copy the latest Codex output to your clipboard + /mention mention a file + /status show current session configuration and token usage + /debug-config show config layers and requirement sources for debugging + /statusline configure which items appear in the status line + /statusline ... + /statusline none + /theme choose a syntax highlighting theme + /theme + /mcp list configured MCP tools + /apps manage apps + /logout log out of Codex + /quit exit Codex + /exit exit Codex + /feedback send logs to maintainers + /feedback + /rollout print the rollout file path + /ps list background terminals + /clean stop all background terminals + /clear clear the terminal and start a new chat + /personality choose a communication style for Codex + /personality + /realtime toggle realtime voice mode (experimental) + /settings configure realtime microphone/speaker + /settings [default|] + /test-approval test approval request + /multi-agents switch the active agent thread + /multi-agents + /debug-m-drop DO NOT USE + /debug-m-update DO NOT USE diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 60b310e325..af8b040b18 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6000,6 +6000,21 @@ async fn slash_copy_reports_when_no_copyable_output_exists() { ); } +#[tokio::test] +async fn slash_help_renders_reference_page() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Help); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one help cell"); + let rendered = lines_to_single_string(&cells[0]); + assert_snapshot!("slash_help_output", rendered); + assert!(rendered.contains("/help")); + assert!(rendered.contains("/model ")); + assert!(rendered.contains("/review ")); +} + #[tokio::test] async fn slash_copy_state_is_preserved_during_running_task() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 276fdd73e3..c6092be030 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -12,6 +12,7 @@ use strum_macros::IntoStaticStr; pub enum SlashCommand { // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so // more frequently used commands should be listed first. + Help, Model, Fast, Approvals, @@ -68,6 +69,7 @@ impl SlashCommand { /// User-visible description shown in the popup. pub fn description(self) -> &'static str { match self { + SlashCommand::Help => "show slash command help", 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", @@ -120,9 +122,69 @@ impl SlashCommand { self.into() } + /// Human-facing forms accepted by the TUI. + /// + /// An empty string represents the bare `/command` form. + pub fn help_forms(self) -> &'static [&'static str] { + match self { + SlashCommand::Help => &[""], + SlashCommand::Model => &[ + "", + " [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]", + ], + SlashCommand::Fast => &["", ""], + SlashCommand::Approvals | SlashCommand::Permissions => &[ + "", + " [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]", + ], + SlashCommand::ElevateSandbox => &[""], + SlashCommand::SandboxReadRoot => &[""], + SlashCommand::Experimental => &["", "=on|off ..."], + SlashCommand::Skills => &["", ""], + SlashCommand::Review => &[ + "", + "uncommitted", + "branch ", + "commit [title]", + "", + ], + SlashCommand::Rename => &["", ""], + SlashCommand::New => &[""], + SlashCommand::Resume => &["", ""], + SlashCommand::Fork => &[""], + SlashCommand::Init => &[""], + SlashCommand::Compact => &[""], + SlashCommand::Plan => &["", ""], + SlashCommand::Collab => &["", ""], + SlashCommand::Agent | SlashCommand::MultiAgents => &["", ""], + SlashCommand::Diff => &[""], + SlashCommand::Copy => &[""], + SlashCommand::Mention => &[""], + SlashCommand::Status => &[""], + SlashCommand::DebugConfig => &[""], + SlashCommand::Statusline => &["", "...", "none"], + SlashCommand::Theme => &["", ""], + SlashCommand::Mcp => &[""], + SlashCommand::Apps => &[""], + SlashCommand::Logout => &[""], + SlashCommand::Quit | SlashCommand::Exit => &[""], + SlashCommand::Feedback => &["", ""], + SlashCommand::Rollout => &[""], + SlashCommand::Ps => &[""], + SlashCommand::Clean => &[""], + SlashCommand::Clear => &[""], + SlashCommand::Personality => &["", ""], + SlashCommand::Realtime => &[""], + SlashCommand::Settings => &["", " [default|]"], + SlashCommand::TestApproval => &[""], + SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate => &[""], + } + } + /// Whether bare dispatch opens interactive UI that should be resolved before queueing. pub fn requires_interaction(self) -> bool { match self { + SlashCommand::Help => false, SlashCommand::Feedback | SlashCommand::Resume | SlashCommand::Review @@ -171,6 +233,7 @@ impl SlashCommand { /// Whether this command can be run while a task is in progress. pub fn available_during_task(self) -> bool { match self { + SlashCommand::Help => true, SlashCommand::New | SlashCommand::Resume | SlashCommand::Fork diff --git a/docs/slash_commands.md b/docs/slash_commands.md index 4db63f7f6e..88f9640df6 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -1,3 +1,13 @@ # Slash commands For an overview of Codex CLI slash commands, see [this documentation](https://developers.openai.com/codex/cli/slash-commands). + +## TUI + +In the TUI, type `/` to open the slash-command popup. The popup uses the same command order as the +in-app `/help` page, with `/help` pinned at the top for discovery. + +For commands that have both an interactive picker flow and a direct argument form, the bare +`/command` form opens the picker and `/command ...` runs the direct argument form instead. Use +`/help` inside the TUI for the current list of supported commands and argument syntax. Argument +parsing uses shell-style quoting, so quote values with spaces when needed.