From 77e1e8029ae848f706cc57e5f8aea9e69f334f44 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 12 Mar 2026 13:42:05 -0700 Subject: [PATCH] tui: add slash help search and status Co-authored-by: Codex --- codex-rs/tui/src/bottom_pane/help_view.rs | 401 ++++++++++++++---- ..._chatwidget__tests__slash_help_output.snap | 2 +- ...dget__tests__slash_help_search_output.snap | 243 +++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 53 +++ 4 files changed, 627 insertions(+), 72 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_search_output.snap diff --git a/codex-rs/tui/src/bottom_pane/help_view.rs b/codex-rs/tui/src/bottom_pane/help_view.rs index 51efd1f4a8..2c1cd7bde0 100644 --- a/codex-rs/tui/src/bottom_pane/help_view.rs +++ b/codex-rs/tui/src/bottom_pane/help_view.rs @@ -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, + selected_match: usize, +} + pub(crate) struct SlashHelpView { complete: bool, - scroll_top: usize, + scroll_top: Cell, + follow_selected_match: Cell, + 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> { - 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 { + 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 { + 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>, Vec, Vec) { + 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::>(); + let mut body_lines = lines.iter().skip(2).cloned().collect::>(); 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::>()).render(header_area, buf); - Paragraph::new(lines.iter().skip(2).cloned().collect::>()) - .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) } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_output.snap index 37b2292018..9da66934a4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_output.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_output.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_search_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_search_output.snap new file mode 100644 index 0000000000..3cd788bc4a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_help_search_output.snap @@ -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 [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 + Usage: + /approvals + /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 + Usage: + /permissions + /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 + Usage: + /setup-default-sandbox + + /experimental + toggle experimental features + Usage: + /experimental + /experimental =on|off ... + + /skills + use skills to improve how Codex performs specific tasks + Usage: + /skills + /skills + + /review + review my current changes and find issues + Usage: + /review + /review uncommitted + /review branch + /review commit [title] + /review + + /rename + rename the current thread + Usage: + /rename + /rename + + /new + start a new chat during a conversation + Usage: + /new + + /resume + resume a saved chat + Usage: + /resume + /resume + + /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 + + /collab + change collaboration mode (experimental) + Usage: + /collab + /collab + + /agent + switch the active agent thread + Usage: + /agent + /agent + + /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 ... + /statusline none + + /theme + choose a syntax highlighting theme + Usage: + /theme + /theme + + /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 + + /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 + + /realtime + toggle realtime voice mode (experimental) + Usage: + /realtime + + /settings + configure realtime microphone/speaker + Usage: + /settings + /settings [default|] + + /test-approval + test approval request + Usage: + /test-approval + + /multi-agents + switch the active agent thread + Usage: + /multi-agents + /multi-agents + + /debug-m-drop + DO NOT USE + Usage: + /debug-m-drop + + /debug-m-update + Search: /maintainers enter apply esc cancel 1 match | 1-233/239 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ccfedc0801..0c459db9cd 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6023,6 +6023,59 @@ async fn slash_help_opens_reference_popup() { assert!(scrolled.contains("/review ")); } +#[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;