tui: add slash help search and status

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-15 23:32:51 -07:00
co-authored by Codex
parent 8fbc9d32fd
commit 77e1e8029a
4 changed files with 627 additions and 72 deletions
+330 -71
View File
@@ -1,3 +1,5 @@
use std::cell::Cell;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -5,10 +7,12 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthStr;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
@@ -21,35 +25,45 @@ use crate::wrapping::word_wrap_lines;
const HELP_VIEW_MIN_BODY_ROWS: u16 = 6;
#[derive(Clone, Copy)]
enum HelpRowWrap {
None,
Note,
Description,
Usage,
}
#[derive(Clone)]
struct HelpRow {
plain_text: String,
line: Line<'static>,
wrap: HelpRowWrap,
}
#[derive(Default)]
struct HelpSearch {
active_query: String,
input: Option<String>,
selected_match: usize,
}
pub(crate) struct SlashHelpView {
complete: bool,
scroll_top: usize,
scroll_top: Cell<usize>,
follow_selected_match: Cell<bool>,
search: HelpSearch,
}
impl SlashHelpView {
pub(crate) fn new() -> Self {
Self {
complete: false,
scroll_top: 0,
scroll_top: Cell::new(0),
follow_selected_match: Cell::new(false),
search: HelpSearch::default(),
}
}
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)
@@ -57,69 +71,258 @@ impl SlashHelpView {
.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(""));
fn build_document() -> Vec<HelpRow> {
let mut rows = vec![
HelpRow {
plain_text: "Slash Commands".to_string(),
line: Line::from("Slash Commands".bold()),
wrap: HelpRowWrap::None,
},
HelpRow {
plain_text: String::new(),
line: Line::from(""),
wrap: HelpRowWrap::None,
},
HelpRow {
plain_text: "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.".to_string(),
line: 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(),
),
wrap: HelpRowWrap::Note,
},
HelpRow {
plain_text: "Args use shell-style quoting; quote values with spaces.".to_string(),
line: Line::from("Args use shell-style quoting; quote values with spaces.".dim()),
wrap: HelpRowWrap::Note,
},
HelpRow {
plain_text: String::new(),
line: Line::from(""),
wrap: HelpRowWrap::None,
},
];
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()));
rows.push(HelpRow {
plain_text: format!("/{}", cmd.command()),
line: Line::from(format!("/{}", cmd.command()).cyan().bold()),
wrap: HelpRowWrap::None,
});
rows.push(HelpRow {
plain_text: format!(" {}", cmd.description()),
line: Line::from(format!(" {}", cmd.description()).dim()),
wrap: HelpRowWrap::Description,
});
rows.push(HelpRow {
plain_text: " Usage:".to_string(),
line: Line::from(" Usage:".dim()),
wrap: HelpRowWrap::None,
});
for form in cmd.help_forms() {
let usage = if form.is_empty() {
let plain_text = if form.is_empty() {
format!("/{}", cmd.command())
} else {
format!("/{} {}", cmd.command(), form)
};
lines.extend(word_wrap_lines(
[Line::from(usage.cyan())],
usage_opts.clone(),
));
rows.push(HelpRow {
plain_text: plain_text.clone(),
line: Line::from(plain_text.cyan()),
wrap: HelpRowWrap::Usage,
});
}
lines.push(Line::from(""));
rows.push(HelpRow {
plain_text: String::new(),
line: Line::from(""),
wrap: HelpRowWrap::None,
});
}
while lines.last().is_some_and(|line| line.spans.is_empty()) {
lines.pop();
while rows.last().is_some_and(|row| row.plain_text.is_empty()) {
rows.pop();
}
lines
rows
}
fn scroll_by(&mut self, delta: isize) {
self.scroll_top = self.scroll_top.saturating_add_signed(delta);
self.scroll_top
.set(self.scroll_top.get().saturating_add_signed(delta));
self.follow_selected_match.set(false);
}
fn matching_logical_rows(rows: &[HelpRow], query: &str) -> Vec<usize> {
let query = query.to_ascii_lowercase();
rows.iter()
.enumerate()
.filter_map(|(idx, row)| {
row.plain_text
.to_ascii_lowercase()
.contains(query.as_str())
.then_some(idx)
})
.collect()
}
fn current_query(&self) -> Option<&str> {
if let Some(input) = self.search.input.as_deref() {
return (!input.is_empty()).then_some(input);
}
(!self.search.active_query.is_empty()).then_some(self.search.active_query.as_str())
}
fn search_indicator(
&self,
rows: &[HelpRow],
total_rows: usize,
visible_rows: usize,
scroll_top: usize,
) -> String {
let start_row = if total_rows == 0 { 0 } else { scroll_top + 1 };
let end_row = (scroll_top + visible_rows).min(total_rows);
let viewport = format!("{start_row}-{end_row}/{total_rows}");
let Some(query) = self.current_query() else {
return viewport;
};
let match_count = Self::matching_logical_rows(rows, query).len();
if self.search.input.is_some() {
return format!(
"{} match{} | {viewport}",
match_count,
if match_count == 1 { "" } else { "es" }
);
}
if match_count == 0 {
return format!("0/0 | {viewport}");
}
let current_match = self.search.selected_match.min(match_count - 1) + 1;
format!("{current_match}/{match_count} | {viewport}")
}
fn footer_line(&self) -> Line<'static> {
if let Some(input) = self.search.input.as_deref() {
return Line::from(vec![
"Search: ".dim(),
format!("/{input}").cyan(),
" ".into(),
key_hint::plain(KeyCode::Enter).into(),
" apply ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" cancel".dim(),
]);
}
Line::from(vec![
key_hint::plain(KeyCode::Up).into(),
"/".into(),
key_hint::plain(KeyCode::Down).into(),
" scroll ".dim(),
key_hint::plain(KeyCode::PageUp).into(),
"/".into(),
key_hint::plain(KeyCode::PageDown).into(),
" page ".dim(),
"/ search ".dim(),
"n/p match ".dim(),
"q/".dim(),
key_hint::plain(KeyCode::Esc).into(),
" close".dim(),
])
}
fn wrap_rows(rows: &[HelpRow], width: u16) -> (Vec<Line<'static>>, Vec<usize>, Vec<usize>) {
let width = width.max(24);
let note_opts = RtOptions::new(width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(""));
let description_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 wrapped_rows = Vec::new();
let mut row_starts = Vec::with_capacity(rows.len());
let mut row_ends = Vec::with_capacity(rows.len());
for row in rows {
row_starts.push(wrapped_rows.len());
let wrapped = match row.wrap {
HelpRowWrap::None => vec![row.line.clone()],
HelpRowWrap::Note => word_wrap_lines([row.line.clone()], note_opts.clone()),
HelpRowWrap::Description => {
word_wrap_lines([row.line.clone()], description_opts.clone())
}
HelpRowWrap::Usage => word_wrap_lines([row.line.clone()], usage_opts.clone()),
};
wrapped_rows.extend(wrapped);
row_ends.push(wrapped_rows.len());
}
(wrapped_rows, row_starts, row_ends)
}
fn move_to_match(&mut self, delta: isize) {
if self.search.input.is_some() || self.search.active_query.is_empty() {
return;
}
let matches =
Self::matching_logical_rows(&Self::build_document(), &self.search.active_query);
if matches.is_empty() {
return;
}
let next = (self.search.selected_match as isize + delta).rem_euclid(matches.len() as isize);
self.search.selected_match = next as usize;
self.follow_selected_match.set(true);
}
}
impl BottomPaneView for SlashHelpView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if let Some(input) = self.search.input.as_mut() {
match key_event {
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.search.input = None;
}
KeyEvent {
code: KeyCode::Enter,
..
} => {
self.search.active_query = self.search.input.take().unwrap_or_default();
self.search.selected_match = 0;
self.follow_selected_match
.set(!self.search.active_query.is_empty());
}
KeyEvent {
code: KeyCode::Backspace,
..
} => {
input.pop();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
input.push(c);
}
_ => {}
}
return;
}
match key_event {
KeyEvent {
code: KeyCode::Char('/'),
modifiers: KeyModifiers::NONE,
..
} => {
self.search.input = Some(self.search.active_query.clone());
}
KeyEvent {
code: KeyCode::Up, ..
}
@@ -156,15 +359,22 @@ impl BottomPaneView for SlashHelpView {
..
} => self.scroll_by(MAX_POPUP_ROWS as isize),
KeyEvent {
code: KeyCode::Esc, ..
}
| KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::NONE,
..
} => {
self.on_ctrl_c();
}
KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::NONE,
..
} => self.move_to_match(1),
KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::NONE,
..
} => self.move_to_match(-1),
_ => {}
}
}
@@ -177,6 +387,10 @@ impl BottomPaneView for SlashHelpView {
self.complete = true;
CancellationEvent::Handled
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
self.search.input.is_some()
}
}
impl crate::render::renderable::Renderable for SlashHelpView {
@@ -193,20 +407,65 @@ impl crate::render::renderable::Renderable for SlashHelpView {
])
.areas(content_area);
let lines = Self::build_lines(body_area.width);
let rows = Self::build_document();
let (lines, row_starts, row_ends) = Self::wrap_rows(&rows, body_area.width);
let header_lines = lines.iter().take(2).cloned().collect::<Vec<_>>();
let mut body_lines = lines.iter().skip(2).cloned().collect::<Vec<_>>();
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;
let max_scroll = body_lines.len().saturating_sub(visible_rows);
let mut scroll_top = self.scroll_top.get().min(max_scroll);
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))
if self.search.input.is_none()
&& !self.search.active_query.is_empty()
&& let Some(selected_row_idx) =
Self::matching_logical_rows(&rows, &self.search.active_query)
.get(self.search.selected_match)
.copied()
{
let start = row_starts[selected_row_idx].saturating_sub(2);
let end = row_ends[selected_row_idx].saturating_sub(2);
if self.follow_selected_match.get() || start < scroll_top {
scroll_top = start;
} else if end > scroll_top + visible_rows {
scroll_top = end.saturating_sub(visible_rows);
}
scroll_top = scroll_top.min(max_scroll);
self.scroll_top.set(scroll_top);
self.follow_selected_match.set(false);
for line in body_lines.iter_mut().take(end).skip(start) {
*line = line.clone().patch_style(Style::new().reversed());
}
}
self.scroll_top.set(scroll_top);
Paragraph::new(header_lines).render(header_area, buf);
Paragraph::new(body_lines.clone())
.scroll((scroll_top as u16, 0))
.render(body_area, buf);
Paragraph::new(Self::footer_hint()).render(footer_area, buf);
let footer_line = self.footer_line();
Paragraph::new(footer_line.clone()).render(footer_area, buf);
let indicator = self.search_indicator(&rows, body_lines.len(), visible_rows, scroll_top);
let indicator_width = UnicodeWidthStr::width(indicator.as_str()) as u16;
let footer_width = footer_line.width() as u16;
if footer_width + indicator_width + 2 <= footer_area.width {
Paragraph::new(indicator.dim()).render(
Rect::new(
footer_area.x + footer_area.width - indicator_width,
footer_area.y,
indicator_width,
footer_area.height,
),
buf,
);
}
}
fn desired_height(&self, width: u16) -> u16 {
let content_rows = Self::build_lines(width.saturating_sub(4)).len() as u16;
let rows = Self::build_document();
let (wrapped_rows, _, _) = Self::wrap_rows(&rows, width.saturating_sub(4));
let content_rows = wrapped_rows.len() as u16;
content_rows.max(HELP_VIEW_MIN_BODY_ROWS + 3)
}
}
@@ -240,4 +240,4 @@ expression: popup
/debug-m-drop
/debug-m-update
Scroll with ↑/↓ or pgup/pgdn, close with esc.
↑/↓ scroll pgup/pgdn page / search n/p match q/esc close 1-233/239
@@ -0,0 +1,243 @@
---
source: tui/src/chatwidget/tests.rs
expression: searching
---
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
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
Usage:
/approvals
/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
Usage:
/permissions
/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
Usage:
/setup-default-sandbox
/experimental
toggle experimental features
Usage:
/experimental
/experimental <feature-key>=on|off ...
/skills
use skills to improve how Codex performs specific tasks
Usage:
/skills
/skills <list|manage>
/review
review my current changes and find issues
Usage:
/review
/review uncommitted
/review branch <name>
/review commit <sha> [title]
/review <instructions>
/rename
rename the current thread
Usage:
/rename
/rename <title...>
/new
start a new chat during a conversation
Usage:
/new
/resume
resume a saved chat
Usage:
/resume
/resume <thread-id>
/fork
fork the current chat
Usage:
/fork
/init
create an AGENTS.md file with instructions for Codex
Usage:
/init
/compact
summarize conversation to prevent hitting the context limit
Usage:
/compact
/plan
switch to Plan mode
Usage:
/plan
/plan <prompt...>
/collab
change collaboration mode (experimental)
Usage:
/collab
/collab <default|plan>
/agent
switch the active agent thread
Usage:
/agent
/agent <thread-id>
/diff
show git diff (including untracked files)
Usage:
/diff
/copy
copy the latest Codex output to your clipboard
Usage:
/copy
/mention
mention a file
Usage:
/mention
/status
show current session configuration and token usage
Usage:
/status
/debug-config
show config layers and requirement sources for debugging
Usage:
/debug-config
/statusline
configure which items appear in the status line
Usage:
/statusline
/statusline <item-id>...
/statusline none
/theme
choose a syntax highlighting theme
Usage:
/theme
/theme <theme-name>
/mcp
list configured MCP tools
Usage:
/mcp
/apps
manage apps
Usage:
/apps
/logout
log out of Codex
Usage:
/logout
/quit
exit Codex
Usage:
/quit
/exit
exit Codex
Usage:
/exit
/feedback
send logs to maintainers
Usage:
/feedback
/feedback <bug|bad-result|good-result|safety-check|other>
/rollout
print the rollout file path
Usage:
/rollout
/ps
list background terminals
Usage:
/ps
/clean
stop all background terminals
Usage:
/clean
/clear
clear the terminal and start a new chat
Usage:
/clear
/personality
choose a communication style for Codex
Usage:
/personality
/personality <none|friendly|pragmatic>
/realtime
toggle realtime voice mode (experimental)
Usage:
/realtime
/settings
configure realtime microphone/speaker
Usage:
/settings
/settings <microphone|speaker> [default|<device-name>]
/test-approval
test approval request
Usage:
/test-approval
/multi-agents
switch the active agent thread
Usage:
/multi-agents
/multi-agents <thread-id>
/debug-m-drop
DO NOT USE
Usage:
/debug-m-drop
/debug-m-update
Search: /maintainers enter apply esc cancel 1 match | 1-233/239
+53
View File
@@ -6023,6 +6023,59 @@ async fn slash_help_opens_reference_popup() {
assert!(scrolled.contains("/review <instructions>"));
}
#[tokio::test]
async fn slash_help_search_jumps_to_lower_match() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::Help);
let _ = render_bottom_popup(&chat, 100);
chat.handle_key_event(KeyEvent::from(KeyCode::Char('/')));
for ch in "maintainers".chars() {
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
let searching = render_bottom_popup(&chat, 100);
assert_snapshot!("slash_help_search_output", searching);
assert!(searching.contains("Search: /maintainers"));
assert!(searching.contains("1 match"));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let popup = render_bottom_popup(&chat, 100);
assert!(popup.contains("/feedback"));
assert!(popup.contains("1/1 |"));
}
#[tokio::test]
async fn slash_help_search_navigates_matches_with_n_and_p() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::Help);
let _ = render_bottom_popup(&chat, 100);
chat.handle_key_event(KeyEvent::from(KeyCode::Char('/')));
for ch in "toggle".chars() {
chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch)));
}
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let first = render_bottom_popup(&chat, 100);
assert!(first.contains("1/3 |"));
chat.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
let second = render_bottom_popup(&chat, 100);
assert!(second.contains("2/3 |"));
chat.handle_key_event(KeyEvent::from(KeyCode::Char('n')));
let third = render_bottom_popup(&chat, 100);
assert!(third.contains("3/3 |"));
assert!(third.contains("/realtime"));
chat.handle_key_event(KeyEvent::from(KeyCode::Char('p')));
let previous = render_bottom_popup(&chat, 100);
assert!(previous.contains("2/3 |"));
}
#[tokio::test]
async fn slash_help_esc_dismisses_popup() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;