mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
tui: make slash help dismissible
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
co-authored by
Codex
parent
8fda4e0fc2
commit
6a278e943f
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+25
-68
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user