tui: make slash help dismissible

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-15 23:32:50 -07:00
co-authored by Codex
parent 8fda4e0fc2
commit 6a278e943f
5 changed files with 268 additions and 113 deletions
+208
View File
@@ -0,0 +1,208 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::key_hint;
use crate::slash_command::built_in_slash_commands;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
const HELP_VIEW_MAX_HEIGHT: u16 = 28;
const HELP_VIEW_MIN_BODY_ROWS: u16 = 6;
pub(crate) struct SlashHelpView {
complete: bool,
scroll_top: usize,
}
impl SlashHelpView {
pub(crate) fn new() -> Self {
Self {
complete: false,
scroll_top: 0,
}
}
fn footer_hint() -> Line<'static> {
Line::from(vec![
"Scroll with ".into(),
key_hint::plain(KeyCode::Up).into(),
"/".into(),
key_hint::plain(KeyCode::Down).into(),
" or ".into(),
key_hint::plain(KeyCode::PageUp).into(),
"/".into(),
key_hint::plain(KeyCode::PageDown).into(),
", close with ".into(),
key_hint::plain(KeyCode::Esc).into(),
".".into(),
])
}
fn visible_body_rows(area_height: u16) -> usize {
area_height
.saturating_sub(3)
.max(HELP_VIEW_MIN_BODY_ROWS)
.into()
}
fn build_lines(width: u16) -> Vec<Line<'static>> {
let width = width.max(24);
let note_opts = RtOptions::new(width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(""));
let usage_opts = RtOptions::new(width as usize)
.initial_indent(Line::from(" "))
.subsequent_indent(Line::from(" "));
let mut lines = vec![Line::from("Slash Commands".bold()), Line::from("")];
lines.extend(word_wrap_lines(
[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(),
)],
note_opts.clone(),
));
lines.extend(word_wrap_lines(
[Line::from(
"Args use shell-style quoting; quote values with spaces.".dim(),
)],
note_opts,
));
lines.push(Line::from(""));
for (_, cmd) in built_in_slash_commands() {
lines.push(Line::from(format!("/{}", cmd.command()).cyan().bold()));
lines.extend(word_wrap_lines(
[Line::from(format!(" {}", cmd.description()).dim())],
RtOptions::new(width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ")),
));
lines.push(Line::from(" Usage:".dim()));
for form in cmd.help_forms() {
let usage = if form.is_empty() {
format!("/{}", cmd.command())
} else {
format!("/{} {}", cmd.command(), form)
};
lines.extend(word_wrap_lines(
[Line::from(usage.cyan())],
usage_opts.clone(),
));
}
lines.push(Line::from(""));
}
while lines.last().is_some_and(|line| line.spans.is_empty()) {
lines.pop();
}
lines
}
fn scroll_by(&mut self, delta: isize) {
self.scroll_top = self.scroll_top.saturating_add_signed(delta);
}
}
impl BottomPaneView for SlashHelpView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} => self.scroll_by(-1),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => self.scroll_by(1),
KeyEvent {
code: KeyCode::PageUp,
..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => self.scroll_by(-(MAX_POPUP_ROWS as isize)),
KeyEvent {
code: KeyCode::PageDown,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => self.scroll_by(MAX_POPUP_ROWS as isize),
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.on_ctrl_c();
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
}
}
impl crate::render::renderable::Renderable for SlashHelpView {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let content_area = render_menu_surface(area, buf);
let [header_area, body_area, footer_area] = Layout::vertical([
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Length(1),
])
.areas(content_area);
let lines = Self::build_lines(body_area.width);
let visible_rows = Self::visible_body_rows(body_area.height);
let max_scroll = lines.len().saturating_sub(visible_rows);
let scroll_top = self.scroll_top.min(max_scroll) as u16;
Paragraph::new(lines.iter().take(2).cloned().collect::<Vec<_>>()).render(header_area, buf);
Paragraph::new(lines.iter().skip(2).cloned().collect::<Vec<_>>())
.scroll((scroll_top, 0))
.render(body_area, buf);
Paragraph::new(Self::footer_hint()).render(footer_area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let content_rows = Self::build_lines(width.saturating_sub(4)).len() as u16;
content_rows.clamp(HELP_VIEW_MIN_BODY_ROWS + 3, HELP_VIEW_MAX_HEIGHT)
}
}
+2
View File
@@ -81,6 +81,7 @@ pub mod custom_prompt_view;
mod experimental_features_view;
mod file_search_popup;
mod footer;
mod help_view;
mod list_selection_view;
mod prompt_args;
mod skill_popup;
@@ -97,6 +98,7 @@ pub(crate) use feedback_view::FeedbackAudience;
pub(crate) use feedback_view::feedback_disabled_params;
pub(crate) use feedback_view::feedback_selection_params;
pub(crate) use feedback_view::feedback_upload_consent_params;
pub(crate) use help_view::SlashHelpView;
pub(crate) use skills_toggle_view::SkillsToggleItem;
pub(crate) use skills_toggle_view::SkillsToggleView;
pub(crate) use status_line_setup::StatusLineItem;
+4 -36
View File
@@ -271,7 +271,6 @@ 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;
@@ -4348,7 +4347,8 @@ impl ChatWidget {
}
match cmd {
SlashCommand::Help => {
self.add_slash_help_output();
self.bottom_pane
.show_view(Box::new(crate::bottom_pane::SlashHelpView::new()));
QueueReplayControl::Continue
}
SlashCommand::Feedback => {
@@ -4779,7 +4779,8 @@ impl ChatWidget {
) -> QueueReplayControl {
match cmd {
SlashCommand::Help => {
self.add_slash_help_output();
self.bottom_pane
.show_view(Box::new(crate::bottom_pane::SlashHelpView::new()));
QueueReplayControl::Continue
}
SlashCommand::Approvals | SlashCommand::Permissions => {
@@ -6948,39 +6949,6 @@ 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,
@@ -1,73 +1,30 @@
---
source: tui/src/chatwidget/tests.rs
expression: rendered
expression: popup
---
Slash Commands
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.
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
/help
show slash command help
Usage:
/help
/model
choose what model and reasoning effort to use
Usage:
/model
/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
Usage:
/fast
/fast <on|off|status>
/approvals
choose what Codex is allowed to do
Scroll with ↑/↓ or pgup/pgdn, close with esc.
+29 -9
View File
@@ -6001,18 +6001,38 @@ 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;
async fn slash_help_opens_reference_popup() {
let (mut chat, _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>"));
assert!(chat.bottom_pane.has_active_view());
let popup = render_bottom_popup(&chat, 100);
assert_snapshot!("slash_help_output", popup);
assert!(popup.contains("/help"));
assert!(popup.contains("/model <model>"));
let mut scrolled = popup;
for _ in 0..8 {
if scrolled.contains("/review <instructions>") {
break;
}
chat.handle_key_event(KeyEvent::from(KeyCode::PageDown));
scrolled = render_bottom_popup(&chat, 100);
}
assert!(scrolled.contains("/review <instructions>"));
}
#[tokio::test]
async fn slash_help_esc_dismisses_popup() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::Help);
assert!(chat.bottom_pane.has_active_view());
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
assert!(!chat.bottom_pane.has_active_view());
}
#[tokio::test]