diff --git a/codex-rs/tui/src/bottom_pane/help_view.rs b/codex-rs/tui/src/bottom_pane/help_view.rs new file mode 100644 index 0000000000..c65c78a9c7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/help_view.rs @@ -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> { + 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::>()).render(header_area, buf); + Paragraph::new(lines.iter().skip(2).cloned().collect::>()) + .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) + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ce436fa1c2..73d5577a5b 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 44bb4c9b29..227c816e1d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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: [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 + /help + show slash command help + Usage: + /help + + /model + choose what model and reasoning effort to use + Usage: + /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 + + /approvals + choose what Codex is allowed to do + Scroll with ↑/↓ or pgup/pgdn, close with esc. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index af8b040b18..17c390ef47 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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 ")); - assert!(rendered.contains("/review ")); + 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 ")); + + let mut scrolled = popup; + for _ in 0..8 { + if scrolled.contains("/review ") { + break; + } + chat.handle_key_event(KeyEvent::from(KeyCode::PageDown)); + scrolled = render_bottom_popup(&chat, 100); + } + assert!(scrolled.contains("/review ")); +} + +#[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]