tui: add slash command help page

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-12 12:10:15 -07:00
parent ad586ba24c
commit 8fda4e0fc2
8 changed files with 286 additions and 0 deletions

View File

@@ -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::<AppEvent>();
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::<AppEvent>();
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;

View File

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

View File

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

View File

@@ -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:<DESCRIPTION_COLUMN$}");
lines.push(Line::from(vec![padded.cyan(), cmd.description().dim()]));
for form in &forms[1..] {
let syntax = format!(" /{} {}", cmd.command(), form);
lines.push(Line::from(syntax.dim()));
}
}
self.add_to_history(crate::history_cell::PlainHistoryCell::new(lines));
}
pub(crate) fn add_debug_config_output(&mut self) {
self.add_to_history(crate::debug_config::new_debug_config_output(
&self.config,

View File

@@ -0,0 +1,73 @@
---
source: tui/src/chatwidget/tests.rs
expression: rendered
---
Slash Commands
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.
Args use shell-style quoting; quote values with spaces.
/help show slash command help
/model choose what model and reasoning effort to use
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
/fast toggle Fast mode to enable fastest inference at 2X plan usage
/fast <on|off|status>
/approvals choose what Codex is allowed to do
/approvals <read-only|auto|full-access> [--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 <read-only|auto|full-access> [--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 <feature-key>=on|off ...
/skills use skills to improve how Codex performs specific tasks
/skills <list|manage>
/review review my current changes and find issues
/review uncommitted
/review branch <name>
/review commit <sha> [title]
/review <instructions>
/rename rename the current thread
/rename <title...>
/new start a new chat during a conversation
/resume resume a saved chat
/resume <thread-id>
/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 <prompt...>
/collab change collaboration mode (experimental)
/collab <default|plan>
/agent switch the active agent thread
/agent <thread-id>
/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 <item-id>...
/statusline none
/theme choose a syntax highlighting theme
/theme <theme-name>
/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 <bug|bad-result|good-result|safety-check|other>
/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 <none|friendly|pragmatic>
/realtime toggle realtime voice mode (experimental)
/settings configure realtime microphone/speaker
/settings <microphone|speaker> [default|<device-name>]
/test-approval test approval request
/multi-agents switch the active agent thread
/multi-agents <thread-id>
/debug-m-drop DO NOT USE
/debug-m-update DO NOT USE

View File

@@ -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 <model>"));
assert!(rendered.contains("/review <instructions>"));
}
#[tokio::test]
async fn slash_copy_state_is_preserved_during_running_task() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;

View File

@@ -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 => &[
"",
"<model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]",
],
SlashCommand::Fast => &["", "<on|off|status>"],
SlashCommand::Approvals | SlashCommand::Permissions => &[
"",
"<read-only|auto|full-access> [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]",
],
SlashCommand::ElevateSandbox => &[""],
SlashCommand::SandboxReadRoot => &["<absolute-directory-path>"],
SlashCommand::Experimental => &["", "<feature-key>=on|off ..."],
SlashCommand::Skills => &["", "<list|manage>"],
SlashCommand::Review => &[
"",
"uncommitted",
"branch <name>",
"commit <sha> [title]",
"<instructions>",
],
SlashCommand::Rename => &["", "<title...>"],
SlashCommand::New => &[""],
SlashCommand::Resume => &["", "<thread-id>"],
SlashCommand::Fork => &[""],
SlashCommand::Init => &[""],
SlashCommand::Compact => &[""],
SlashCommand::Plan => &["", "<prompt...>"],
SlashCommand::Collab => &["", "<default|plan>"],
SlashCommand::Agent | SlashCommand::MultiAgents => &["", "<thread-id>"],
SlashCommand::Diff => &[""],
SlashCommand::Copy => &[""],
SlashCommand::Mention => &[""],
SlashCommand::Status => &[""],
SlashCommand::DebugConfig => &[""],
SlashCommand::Statusline => &["", "<item-id>...", "none"],
SlashCommand::Theme => &["", "<theme-name>"],
SlashCommand::Mcp => &[""],
SlashCommand::Apps => &[""],
SlashCommand::Logout => &[""],
SlashCommand::Quit | SlashCommand::Exit => &[""],
SlashCommand::Feedback => &["", "<bug|bad-result|good-result|safety-check|other>"],
SlashCommand::Rollout => &[""],
SlashCommand::Ps => &[""],
SlashCommand::Clean => &[""],
SlashCommand::Clear => &[""],
SlashCommand::Personality => &["", "<none|friendly|pragmatic>"],
SlashCommand::Realtime => &[""],
SlashCommand::Settings => &["", "<microphone|speaker> [default|<device-name>]"],
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