Files
codex/prs/bolinfest/PR-1830.md
2025-09-02 15:17:45 -07:00

1285 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR #1830: Scrollable slash commands
- URL: https://github.com/openai/codex/pull/1830
- Author: easong-openai
- Created: 2025-08-04 22:39:02 UTC
- Updated: 2025-08-07 04:23:19 UTC
- Changes: +523/-207, Files changed: 8, Commits: 10
## Description
Scrollable slash commands. Part 1 of the multi PR.
## Full Diff
```diff
diff --git a/codex-rs/common/src/fuzzy_match.rs b/codex-rs/common/src/fuzzy_match.rs
new file mode 100644
index 0000000000..836848d6a4
--- /dev/null
+++ b/codex-rs/common/src/fuzzy_match.rs
@@ -0,0 +1,177 @@
+/// Simple case-insensitive subsequence matcher used for fuzzy filtering.
+///
+/// Returns the indices (character positions) of the matched characters in the
+/// ORIGINAL `haystack` string and a score where smaller is better.
+///
+/// Unicode correctness: we perform the match on a lowercased copy of the
+/// haystack and needle but maintain a mapping from each character in the
+/// lowercased haystack back to the original character index in `haystack`.
+/// This ensures the returned indices can be safely used with
+/// `str::chars().enumerate()` consumers for highlighting, even when
+/// lowercasing expands certain characters (e.g., ß → ss, İ → i̇).
+pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
+ if needle.is_empty() {
+ return Some((Vec::new(), i32::MAX));
+ }
+
+ let mut lowered_chars: Vec<char> = Vec::new();
+ let mut lowered_to_orig_char_idx: Vec<usize> = Vec::new();
+ for (orig_idx, ch) in haystack.chars().enumerate() {
+ for lc in ch.to_lowercase() {
+ lowered_chars.push(lc);
+ lowered_to_orig_char_idx.push(orig_idx);
+ }
+ }
+
+ let lowered_needle: Vec<char> = needle.to_lowercase().chars().collect();
+
+ let mut result_orig_indices: Vec<usize> = Vec::with_capacity(lowered_needle.len());
+ let mut last_lower_pos: Option<usize> = None;
+ let mut cur = 0usize;
+ for &nc in lowered_needle.iter() {
+ let mut found_at: Option<usize> = None;
+ while cur < lowered_chars.len() {
+ if lowered_chars[cur] == nc {
+ found_at = Some(cur);
+ cur += 1;
+ break;
+ }
+ cur += 1;
+ }
+ let pos = found_at?;
+ result_orig_indices.push(lowered_to_orig_char_idx[pos]);
+ last_lower_pos = Some(pos);
+ }
+
+ let first_lower_pos = if result_orig_indices.is_empty() {
+ 0usize
+ } else {
+ let target_orig = result_orig_indices[0];
+ lowered_to_orig_char_idx
+ .iter()
+ .position(|&oi| oi == target_orig)
+ .unwrap_or(0)
+ };
+ // last defaults to first for single-hit; score = extra span between first/last hit
+ // minus needle len (≥0).
+ // Strongly reward prefix matches by subtracting 100 when the first hit is at index 0.
+ let last_lower_pos = last_lower_pos.unwrap_or(first_lower_pos);
+ let window =
+ (last_lower_pos as i32 - first_lower_pos as i32 + 1) - (lowered_needle.len() as i32);
+ let mut score = window.max(0);
+ if first_lower_pos == 0 {
+ score -= 100;
+ }
+
+ result_orig_indices.sort_unstable();
+ result_orig_indices.dedup();
+ Some((result_orig_indices, score))
+}
+
+/// Convenience wrapper to get only the indices for a fuzzy match.
+pub fn fuzzy_indices(haystack: &str, needle: &str) -> Option<Vec<usize>> {
+ fuzzy_match(haystack, needle).map(|(mut idx, _)| {
+ idx.sort_unstable();
+ idx.dedup();
+ idx
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn ascii_basic_indices() {
+ let (idx, score) = match fuzzy_match("hello", "hl") {
+ Some(v) => v,
+ None => panic!("expected a match"),
+ };
+ assert_eq!(idx, vec![0, 2]);
+ // 'h' at 0, 'l' at 2 -> window 1; start-of-string bonus applies (-100)
+ assert_eq!(score, -99);
+ }
+
+ #[test]
+ fn unicode_dotted_i_istanbul_highlighting() {
+ let (idx, score) = match fuzzy_match("İstanbul", "is") {
+ Some(v) => v,
+ None => panic!("expected a match"),
+ };
+ assert_eq!(idx, vec![0, 1]);
+ // Matches at lowered positions 0 and 2 -> window 1; start-of-string bonus applies
+ assert_eq!(score, -99);
+ }
+
+ #[test]
+ fn unicode_german_sharp_s_casefold() {
+ assert!(fuzzy_match("straße", "strasse").is_none());
+ }
+
+ #[test]
+ fn prefer_contiguous_match_over_spread() {
+ let (_idx_a, score_a) = match fuzzy_match("abc", "abc") {
+ Some(v) => v,
+ None => panic!("expected a match"),
+ };
+ let (_idx_b, score_b) = match fuzzy_match("a-b-c", "abc") {
+ Some(v) => v,
+ None => panic!("expected a match"),
+ };
+ // Contiguous window -> 0; start-of-string bonus -> -100
+ assert_eq!(score_a, -100);
+ // Spread over 5 chars for 3-letter needle -> window 2; with bonus -> -98
+ assert_eq!(score_b, -98);
+ assert!(score_a < score_b);
+ }
+
+ #[test]
+ fn start_of_string_bonus_applies() {
+ let (_idx_a, score_a) = match fuzzy_match("file_name", "file") {
+ Some(v) => v,
+ None => panic!("expected a match"),
+ };
+ let (_idx_b, score_b) = match fuzzy_match("my_file_name", "file") {
+ Some(v) => v,
+ None => panic!("expected a match"),
+ };
+ // Start-of-string contiguous -> window 0; bonus -> -100
+ assert_eq!(score_a, -100);
+ // Non-prefix contiguous -> window 0; no bonus -> 0
+ assert_eq!(score_b, 0);
+ assert!(score_a < score_b);
+ }
+
+ #[test]
+ fn empty_needle_matches_with_max_score_and_no_indices() {
+ let (idx, score) = match fuzzy_match("anything", "") {
+ Some(v) => v,
+ None => panic!("empty needle should match"),
+ };
+ assert!(idx.is_empty());
+ assert_eq!(score, i32::MAX);
+ }
+
+ #[test]
+ fn case_insensitive_matching_basic() {
+ let (idx, score) = match fuzzy_match("FooBar", "foO") {
+ Some(v) => v,
+ None => panic!("expected a match"),
+ };
+ assert_eq!(idx, vec![0, 1, 2]);
+ // Contiguous prefix match (case-insensitive) -> window 0 with bonus
+ assert_eq!(score, -100);
+ }
+
+ #[test]
+ fn indices_are_deduped_for_multichar_lowercase_expansion() {
+ let needle = "\u{0069}\u{0307}"; // "i" + combining dot above
+ let (idx, score) = match fuzzy_match("İ", needle) {
+ Some(v) => v,
+ None => panic!("expected a match"),
+ };
+ assert_eq!(idx, vec![0]);
+ // Lowercasing 'İ' expands to two chars; contiguous prefix -> window 0 with bonus
+ assert_eq!(score, -100);
+ }
+}
diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs
index 38f3832bfd..8595262cc0 100644
--- a/codex-rs/common/src/lib.rs
+++ b/codex-rs/common/src/lib.rs
@@ -27,3 +27,5 @@ pub use sandbox_summary::summarize_sandbox_policy;
mod config_summary;
pub use config_summary::create_config_summary_entries;
+// Shared fuzzy matcher (used by TUI selection popups and other UI filtering)
+pub mod fuzzy_match;
diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs
index 1027df1a67..b7a203e9fd 100644
--- a/codex-rs/tui/src/bottom_pane/command_popup.rs
+++ b/codex-rs/tui/src/bottom_pane/command_popup.rs
@@ -1,30 +1,19 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
-use ratatui::style::Color;
-use ratatui::style::Style;
-use ratatui::style::Stylize;
-use ratatui::symbols::border::QUADRANT_LEFT_HALF;
-use ratatui::text::Line;
-use ratatui::text::Span;
-use ratatui::widgets::Cell;
-use ratatui::widgets::Row;
-use ratatui::widgets::Table;
-use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
+use super::popup_consts::MAX_POPUP_ROWS;
+use super::scroll_state::ScrollState;
+use super::selection_popup_common::GenericDisplayRow;
+use super::selection_popup_common::render_rows;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
-
-const MAX_POPUP_ROWS: usize = 5;
-/// Ideally this is enough to show the longest command name.
-const FIRST_COLUMN_WIDTH: u16 = 20;
-
-use ratatui::style::Modifier;
+use codex_common::fuzzy_match::fuzzy_match;
pub(crate) struct CommandPopup {
command_filter: String,
all_commands: Vec<(&'static str, SlashCommand)>,
- selected_idx: Option<usize>,
+ state: ScrollState,
}
impl CommandPopup {
@@ -32,7 +21,7 @@ impl CommandPopup {
Self {
command_filter: String::new(),
all_commands: built_in_slash_commands(),
- selected_idx: None,
+ state: ScrollState::new(),
}
}
@@ -62,130 +51,84 @@ impl CommandPopup {
// Reset or clamp selected index based on new filtered list.
let matches_len = self.filtered_commands().len();
- self.selected_idx = match matches_len {
- 0 => None,
- _ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)),
- };
+ self.state.clamp_selection(matches_len);
+ self.state
+ .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
/// Determine the preferred height of the popup. This is the number of
- /// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
- /// table/border overhead (one line at the top and one at the bottom).
+ /// rows required to show at most MAX_POPUP_ROWS commands.
pub(crate) fn calculate_required_height(&self) -> u16 {
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
}
- /// Return the list of commands that match the current filter. Matching is
- /// performed using a *prefix* comparison on the command name.
- fn filtered_commands(&self) -> Vec<&SlashCommand> {
- self.all_commands
- .iter()
- .filter_map(|(_name, cmd)| {
- if self.command_filter.is_empty()
- || cmd
- .command()
- .starts_with(&self.command_filter.to_ascii_lowercase())
- {
- Some(cmd)
- } else {
- None
+ /// Compute fuzzy-filtered matches paired with optional highlight indices and score.
+ /// Sorted by ascending score, then by command name for stability.
+ fn filtered(&self) -> Vec<(&SlashCommand, Option<Vec<usize>>, i32)> {
+ let filter = self.command_filter.trim();
+ let mut out: Vec<(&SlashCommand, Option<Vec<usize>>, i32)> = Vec::new();
+ if filter.is_empty() {
+ for (_, cmd) in self.all_commands.iter() {
+ out.push((cmd, None, 0));
+ }
+ } else {
+ for (_, cmd) in self.all_commands.iter() {
+ if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
+ out.push((cmd, Some(indices), score));
}
- })
- .collect::<Vec<&SlashCommand>>()
+ }
+ }
+ out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.command().cmp(b.0.command())));
+ out
+ }
+
+ fn filtered_commands(&self) -> Vec<&SlashCommand> {
+ self.filtered().into_iter().map(|(c, _, _)| c).collect()
}
/// Move the selection cursor one step up.
pub(crate) fn move_up(&mut self) {
- if let Some(len) = self.filtered_commands().len().checked_sub(1) {
- if len == usize::MAX {
- return;
- }
- }
-
- if let Some(idx) = self.selected_idx {
- if idx > 0 {
- self.selected_idx = Some(idx - 1);
- }
- } else if !self.filtered_commands().is_empty() {
- self.selected_idx = Some(0);
- }
+ let matches = self.filtered_commands();
+ let len = matches.len();
+ self.state.move_up_wrap(len);
+ self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
/// Move the selection cursor one step down.
pub(crate) fn move_down(&mut self) {
- let matches_len = self.filtered_commands().len();
- if matches_len == 0 {
- self.selected_idx = None;
- return;
- }
-
- match self.selected_idx {
- Some(idx) if idx + 1 < matches_len => {
- self.selected_idx = Some(idx + 1);
- }
- None => {
- self.selected_idx = Some(0);
- }
- _ => {}
- }
+ let matches = self.filtered_commands();
+ let matches_len = matches.len();
+ self.state.move_down_wrap(matches_len);
+ self.state
+ .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
/// Return currently selected command, if any.
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
let matches = self.filtered_commands();
- self.selected_idx.and_then(|idx| matches.get(idx).copied())
+ self.state
+ .selected_idx
+ .and_then(|idx| matches.get(idx).copied())
}
}
impl WidgetRef for CommandPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
- let matches = self.filtered_commands();
-
- let mut rows: Vec<Row> = Vec::new();
- let visible_matches: Vec<&SlashCommand> =
- matches.into_iter().take(MAX_POPUP_ROWS).collect();
-
- if visible_matches.is_empty() {
- rows.push(Row::new(vec![
- Cell::from(""),
- Cell::from("No matching commands").add_modifier(Modifier::ITALIC),
- ]));
+ let matches = self.filtered();
+ let rows_all: Vec<GenericDisplayRow> = if matches.is_empty() {
+ Vec::new()
} else {
- let default_style = Style::default();
- let command_style = Style::default().fg(Color::LightBlue);
- for (idx, cmd) in visible_matches.iter().enumerate() {
- rows.push(Row::new(vec![
- Cell::from(Line::from(vec![
- if Some(idx) == self.selected_idx {
- Span::styled(
- "",
- Style::default().bg(Color::DarkGray).fg(Color::LightCyan),
- )
- } else {
- Span::styled(QUADRANT_LEFT_HALF, Style::default().fg(Color::DarkGray))
- },
- Span::styled(format!("/{}", cmd.command()), command_style),
- ])),
- Cell::from(cmd.description().to_string()).style(default_style),
- ]));
- }
- }
-
- use ratatui::layout::Constraint;
-
- let table = Table::new(
- rows,
- [Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)],
- )
- .column_spacing(0);
- // .block(
- // Block::default()
- // .borders(Borders::LEFT)
- // .border_type(BorderType::QuadrantOutside)
- // .border_style(Style::default().fg(Color::DarkGray)),
- // );
-
- table.render(area, buf);
+ matches
+ .into_iter()
+ .map(|(cmd, indices, _)| GenericDisplayRow {
+ name: format!("/{}", cmd.command()),
+ match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
+ is_current: false,
+ description: Some(cmd.description().to_string()),
+ })
+ .collect()
+ };
+ render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
}
}
diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs
index ac6c91cf47..c30a24f984 100644
--- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs
+++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs
@@ -1,23 +1,12 @@
use codex_file_search::FileMatch;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
-use ratatui::prelude::Constraint;
-use ratatui::style::Color;
-use ratatui::style::Modifier;
-use ratatui::style::Style;
-use ratatui::text::Line;
-use ratatui::text::Span;
-use ratatui::widgets::Block;
-use ratatui::widgets::BorderType;
-use ratatui::widgets::Borders;
-use ratatui::widgets::Cell;
-use ratatui::widgets::Row;
-use ratatui::widgets::Table;
-use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
-/// Maximum number of suggestions shown in the popup.
-const MAX_RESULTS: usize = 8;
+use super::popup_consts::MAX_POPUP_ROWS;
+use super::scroll_state::ScrollState;
+use super::selection_popup_common::GenericDisplayRow;
+use super::selection_popup_common::render_rows;
/// Visual state for the file-search popup.
pub(crate) struct FileSearchPopup {
@@ -30,8 +19,8 @@ pub(crate) struct FileSearchPopup {
waiting: bool,
/// Cached matches; paths relative to the search dir.
matches: Vec<FileMatch>,
- /// Currently selected index inside `matches` (if any).
- selected_idx: Option<usize>,
+ /// Shared selection/scroll state.
+ state: ScrollState,
}
impl FileSearchPopup {
@@ -41,7 +30,7 @@ impl FileSearchPopup {
pending_query: String::new(),
waiting: true,
matches: Vec::new(),
- selected_idx: None,
+ state: ScrollState::new(),
}
}
@@ -61,7 +50,7 @@ impl FileSearchPopup {
if !keep_existing {
self.matches.clear();
- self.selected_idx = None;
+ self.state.reset();
}
}
@@ -75,40 +64,32 @@ impl FileSearchPopup {
self.display_query = query.to_string();
self.matches = matches;
self.waiting = false;
- self.selected_idx = if self.matches.is_empty() {
- None
- } else {
- Some(0)
- };
+ let len = self.matches.len();
+ self.state.clamp_selection(len);
+ self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
/// Move selection cursor up.
pub(crate) fn move_up(&mut self) {
- if let Some(idx) = self.selected_idx {
- if idx > 0 {
- self.selected_idx = Some(idx - 1);
- }
- }
+ let len = self.matches.len();
+ self.state.move_up_wrap(len);
+ self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
/// Move selection cursor down.
pub(crate) fn move_down(&mut self) {
- if let Some(idx) = self.selected_idx {
- if idx + 1 < self.matches.len() {
- self.selected_idx = Some(idx + 1);
- }
- } else if !self.matches.is_empty() {
- self.selected_idx = Some(0);
- }
+ let len = self.matches.len();
+ self.state.move_down_wrap(len);
+ self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
pub(crate) fn selected_match(&self) -> Option<&str> {
- self.selected_idx
+ self.state
+ .selected_idx
.and_then(|idx| self.matches.get(idx))
.map(|file_match| file_match.path.as_str())
}
- /// Preferred height (rows) including border.
pub(crate) fn calculate_required_height(&self) -> u16 {
// Row count depends on whether we already have matches. If no matches
// yet (e.g. initial search or query with no results) reserve a single
@@ -116,71 +97,35 @@ impl FileSearchPopup {
// up to MAX_RESULTS regardless of the waiting flag so the list
// remains stable while a newer search is in-flight.
- self.matches.len().clamp(1, MAX_RESULTS) as u16
+ self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16
}
}
impl WidgetRef for &FileSearchPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
- // Prepare rows.
- let rows: Vec<Row> = if self.matches.is_empty() {
- vec![Row::new(vec![
- Cell::from(if self.waiting {
- "(searching …)"
- } else {
- "no matches"
- })
- .style(Style::new().add_modifier(Modifier::ITALIC | Modifier::DIM)),
- ])]
+ // Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
+ let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {
+ Vec::new()
} else {
self.matches
.iter()
- .take(MAX_RESULTS)
- .enumerate()
- .map(|(i, file_match)| {
- let FileMatch { path, indices, .. } = file_match;
- let path = path.as_str();
- #[allow(clippy::expect_used)]
- let indices = indices.as_ref().expect("indices should be present");
-
- // Build spans with bold on matching indices.
- let mut idx_iter = indices.iter().peekable();
- let mut spans: Vec<Span> = Vec::with_capacity(path.len());
-
- for (char_idx, ch) in path.chars().enumerate() {
- let mut style = Style::default();
- if idx_iter
- .peek()
- .is_some_and(|next| **next == char_idx as u32)
- {
- idx_iter.next();
- style = style.add_modifier(Modifier::BOLD);
- }
- spans.push(Span::styled(ch.to_string(), style));
- }
-
- // Create cell from the spans.
- let mut cell = Cell::from(Line::from(spans));
-
- // If selected, also paint yellow.
- if Some(i) == self.selected_idx {
- cell = cell.style(Style::default().fg(Color::Yellow));
- }
-
- Row::new(vec![cell])
+ .map(|m| GenericDisplayRow {
+ name: m.path.clone(),
+ match_indices: m
+ .indices
+ .as_ref()
+ .map(|v| v.iter().map(|&i| i as usize).collect()),
+ is_current: false,
+ description: None,
})
.collect()
};
- let table = Table::new(rows, vec![Constraint::Percentage(100)])
- .block(
- Block::default()
- .borders(Borders::LEFT)
- .border_type(BorderType::QuadrantOutside)
- .border_style(Style::default().fg(Color::DarkGray)),
- )
- .widths([Constraint::Percentage(100)]);
-
- table.render(area, buf);
+ if self.waiting && rows_all.is_empty() {
+ // Render a minimal waiting stub using the shared renderer (no rows -> "no matches").
+ render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS);
+ } else {
+ render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
+ }
}
}
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index cdb01ba06a..ff3cf2f2c4 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -19,6 +19,9 @@ mod chat_composer_history;
mod command_popup;
mod file_search_popup;
mod live_ring_widget;
+mod popup_consts;
+mod scroll_state;
+mod selection_popup_common;
mod status_indicator_view;
mod textarea;
diff --git a/codex-rs/tui/src/bottom_pane/popup_consts.rs b/codex-rs/tui/src/bottom_pane/popup_consts.rs
new file mode 100644
index 0000000000..5f447d735c
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/popup_consts.rs
@@ -0,0 +1,5 @@
+//! Shared popup-related constants for bottom pane widgets.
+
+/// Maximum number of rows any popup should attempt to display.
+/// Keep this consistent across all popups for a uniform feel.
+pub(crate) const MAX_POPUP_ROWS: usize = 8;
diff --git a/codex-rs/tui/src/bottom_pane/scroll_state.rs b/codex-rs/tui/src/bottom_pane/scroll_state.rs
new file mode 100644
index 0000000000..a9728d1a0d
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/scroll_state.rs
@@ -0,0 +1,115 @@
+/// Generic scroll/selection state for a vertical list menu.
+///
+/// Encapsulates the common behavior of a selectable list that supports:
+/// - Optional selection (None when list is empty)
+/// - Wrap-around navigation on Up/Down
+/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible
+#[derive(Debug, Default, Clone, Copy)]
+pub(crate) struct ScrollState {
+ pub selected_idx: Option<usize>,
+ pub scroll_top: usize,
+}
+
+impl ScrollState {
+ pub fn new() -> Self {
+ Self {
+ selected_idx: None,
+ scroll_top: 0,
+ }
+ }
+
+ /// Reset selection and scroll.
+ pub fn reset(&mut self) {
+ self.selected_idx = None;
+ self.scroll_top = 0;
+ }
+
+ /// Clamp selection to be within the [0, len-1] range, or None when empty.
+ pub fn clamp_selection(&mut self, len: usize) {
+ self.selected_idx = match len {
+ 0 => None,
+ _ => Some(self.selected_idx.unwrap_or(0).min(len - 1)),
+ };
+ if len == 0 {
+ self.scroll_top = 0;
+ }
+ }
+
+ /// Move selection up by one, wrapping to the bottom when necessary.
+ pub fn move_up_wrap(&mut self, len: usize) {
+ if len == 0 {
+ self.selected_idx = None;
+ self.scroll_top = 0;
+ return;
+ }
+ self.selected_idx = Some(match self.selected_idx {
+ Some(idx) if idx > 0 => idx - 1,
+ Some(_) => len - 1,
+ None => 0,
+ });
+ }
+
+ /// Move selection down by one, wrapping to the top when necessary.
+ pub fn move_down_wrap(&mut self, len: usize) {
+ if len == 0 {
+ self.selected_idx = None;
+ self.scroll_top = 0;
+ return;
+ }
+ self.selected_idx = Some(match self.selected_idx {
+ Some(idx) if idx + 1 < len => idx + 1,
+ _ => 0,
+ });
+ }
+
+ /// Adjust `scroll_top` so that the current `selected_idx` is visible within
+ /// the window of `visible_rows`.
+ pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) {
+ if len == 0 || visible_rows == 0 {
+ self.scroll_top = 0;
+ return;
+ }
+ if let Some(sel) = self.selected_idx {
+ if sel < self.scroll_top {
+ self.scroll_top = sel;
+ } else {
+ let bottom = self.scroll_top + visible_rows - 1;
+ if sel > bottom {
+ self.scroll_top = sel + 1 - visible_rows;
+ }
+ }
+ } else {
+ self.scroll_top = 0;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::ScrollState;
+
+ #[test]
+ fn wrap_navigation_and_visibility() {
+ let mut s = ScrollState::new();
+ let len = 10;
+ let vis = 5;
+
+ s.clamp_selection(len);
+ assert_eq!(s.selected_idx, Some(0));
+ s.ensure_visible(len, vis);
+ assert_eq!(s.scroll_top, 0);
+
+ s.move_up_wrap(len);
+ s.ensure_visible(len, vis);
+ assert_eq!(s.selected_idx, Some(len - 1));
+ match s.selected_idx {
+ Some(sel) => assert!(s.scroll_top <= sel),
+ None => panic!("expected Some(selected_idx) after wrap"),
+ }
+
+ s.move_down_wrap(len);
+ s.ensure_visible(len, vis);
+ assert_eq!(s.selected_idx, Some(0));
+ assert_eq!(s.scroll_top, 0);
+ }
+}
diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs
new file mode 100644
index 0000000000..1a31115d77
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs
@@ -0,0 +1,126 @@
+use ratatui::buffer::Buffer;
+use ratatui::layout::Rect;
+use ratatui::prelude::Constraint;
+use ratatui::style::Color;
+use ratatui::style::Modifier;
+use ratatui::style::Style;
+use ratatui::text::Line;
+use ratatui::text::Span;
+use ratatui::widgets::Block;
+use ratatui::widgets::BorderType;
+use ratatui::widgets::Borders;
+use ratatui::widgets::Cell;
+use ratatui::widgets::Row;
+use ratatui::widgets::Table;
+use ratatui::widgets::Widget;
+
+use super::scroll_state::ScrollState;
+
+/// A generic representation of a display row for selection popups.
+pub(crate) struct GenericDisplayRow {
+ pub name: String,
+ pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
+ pub is_current: bool,
+ pub description: Option<String>, // optional grey text after the name
+}
+
+impl GenericDisplayRow {}
+
+/// Render a list of rows using the provided ScrollState, with shared styling
+/// and behavior for selection popups.
+pub(crate) fn render_rows(
+ area: Rect,
+ buf: &mut Buffer,
+ rows_all: &[GenericDisplayRow],
+ state: &ScrollState,
+ max_results: usize,
+) {
+ let mut rows: Vec<Row> = Vec::new();
+ if rows_all.is_empty() {
+ rows.push(Row::new(vec![Cell::from(Line::from(Span::styled(
+ "no matches",
+ Style::default().add_modifier(Modifier::ITALIC | Modifier::DIM),
+ )))]));
+ } else {
+ let max_rows_from_area = area.height as usize;
+ let visible_rows = max_results
+ .min(rows_all.len())
+ .min(max_rows_from_area.max(1));
+
+ // Compute starting index based on scroll state and selection.
+ let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
+ if let Some(sel) = state.selected_idx {
+ if sel < start_idx {
+ start_idx = sel;
+ } else if visible_rows > 0 {
+ let bottom = start_idx + visible_rows - 1;
+ if sel > bottom {
+ start_idx = sel + 1 - visible_rows;
+ }
+ }
+ }
+
+ for (i, row) in rows_all
+ .iter()
+ .enumerate()
+ .skip(start_idx)
+ .take(visible_rows)
+ {
+ let GenericDisplayRow {
+ name,
+ match_indices,
+ is_current,
+ description,
+ } = row;
+
+ // Highlight fuzzy indices when present.
+ let mut spans: Vec<Span> = Vec::with_capacity(name.len());
+ if let Some(idxs) = match_indices.as_ref() {
+ let mut idx_iter = idxs.iter().peekable();
+ for (char_idx, ch) in name.chars().enumerate() {
+ let mut style = Style::default();
+ if idx_iter.peek().is_some_and(|next| **next == char_idx) {
+ idx_iter.next();
+ style = style.add_modifier(Modifier::BOLD);
+ }
+ spans.push(Span::styled(ch.to_string(), style));
+ }
+ } else {
+ spans.push(Span::raw(name.clone()));
+ }
+
+ if let Some(desc) = description.as_ref() {
+ spans.push(Span::raw(" "));
+ spans.push(Span::styled(
+ desc.clone(),
+ Style::default()
+ .fg(Color::DarkGray)
+ .add_modifier(Modifier::DIM),
+ ));
+ }
+
+ let mut cell = Cell::from(Line::from(spans));
+ if Some(i) == state.selected_idx {
+ cell = cell.style(
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::BOLD),
+ );
+ } else if *is_current {
+ cell = cell.style(Style::default().fg(Color::Cyan));
+ }
+ rows.push(Row::new(vec![cell]));
+ }
+ }
+
+ let table = Table::new(rows, vec![Constraint::Percentage(100)])
+ .block(
+ Block::default()
+ .borders(Borders::LEFT)
+ .border_type(BorderType::QuadrantOutside)
+ .border_style(Style::default().fg(Color::DarkGray)),
+ )
+ .widths([Constraint::Percentage(100)]);
+
+ table.render(area, buf);
+}
```
## Review Comments
### codex-rs/common/src/fuzzy_match.rs
- Created: 2025-08-05 18:52:21 UTC | Link: https://github.com/openai/codex/pull/1830#discussion_r2255088809
```diff
@@ -0,0 +1,158 @@
+/// Simple case-insensitive subsequence matcher used for fuzzy filtering.
+///
+/// Returns the indices (character positions) of the matched characters in the
+/// ORIGINAL `haystack` string and a score where smaller is better.
+///
+/// Unicode correctness: we perform the match on a lowercased copy of the
+/// haystack and needle but maintain a mapping from each character in the
+/// lowercased haystack back to the original character index in `haystack`.
+/// This ensures the returned indices can be safely used with
+/// `str::chars().enumerate()` consumers for highlighting, even when
+/// lowercasing expands certain characters (e.g., ß → ss, İ → i̇).
+pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
+ if needle.is_empty() {
+ return Some((Vec::new(), i32::MAX));
+ }
+
+ let mut lowered_chars: Vec<char> = Vec::new();
+ let mut lowered_to_orig_char_idx: Vec<usize> = Vec::new();
+ for (orig_idx, ch) in haystack.chars().enumerate() {
+ for lc in ch.to_lowercase() {
+ lowered_chars.push(lc);
+ lowered_to_orig_char_idx.push(orig_idx);
+ }
+ }
+
+ let lowered_needle: Vec<char> = needle.to_lowercase().chars().collect();
+
+ let mut result_orig_indices: Vec<usize> = Vec::with_capacity(lowered_needle.len());
+ let mut last_lower_pos: Option<usize> = None;
+ let mut cur = 0usize;
+ for &nc in lowered_needle.iter() {
+ let mut found_at: Option<usize> = None;
+ while cur < lowered_chars.len() {
+ if lowered_chars[cur] == nc {
+ found_at = Some(cur);
+ cur += 1;
+ break;
+ }
+ cur += 1;
+ }
+ let pos = found_at?;
+ result_orig_indices.push(lowered_to_orig_char_idx[pos]);
+ last_lower_pos = Some(pos);
+ }
+
+ let first_lower_pos = if result_orig_indices.is_empty() {
+ 0usize
+ } else {
+ let target_orig = result_orig_indices[0];
+ lowered_to_orig_char_idx
+ .iter()
+ .position(|&oi| oi == target_orig)
+ .unwrap_or(0)
+ };
+ let last_lower_pos = last_lower_pos.unwrap_or(first_lower_pos);
+ let window =
+ (last_lower_pos as i32 - first_lower_pos as i32 + 1) - (lowered_needle.len() as i32);
+ let mut score = window.max(0);
+ if first_lower_pos == 0 {
+ score -= 100;
```
> This could use a comment. Not sure where this number comes from or how significant it is in the scoring.
- Created: 2025-08-05 18:53:41 UTC | Link: https://github.com/openai/codex/pull/1830#discussion_r2255091299
```diff
@@ -0,0 +1,158 @@
+/// Simple case-insensitive subsequence matcher used for fuzzy filtering.
+///
+/// Returns the indices (character positions) of the matched characters in the
+/// ORIGINAL `haystack` string and a score where smaller is better.
+///
+/// Unicode correctness: we perform the match on a lowercased copy of the
+/// haystack and needle but maintain a mapping from each character in the
+/// lowercased haystack back to the original character index in `haystack`.
+/// This ensures the returned indices can be safely used with
+/// `str::chars().enumerate()` consumers for highlighting, even when
+/// lowercasing expands certain characters (e.g., ß → ss, İ → i̇).
+pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
+ if needle.is_empty() {
+ return Some((Vec::new(), i32::MAX));
+ }
+
+ let mut lowered_chars: Vec<char> = Vec::new();
+ let mut lowered_to_orig_char_idx: Vec<usize> = Vec::new();
+ for (orig_idx, ch) in haystack.chars().enumerate() {
+ for lc in ch.to_lowercase() {
+ lowered_chars.push(lc);
+ lowered_to_orig_char_idx.push(orig_idx);
+ }
+ }
+
+ let lowered_needle: Vec<char> = needle.to_lowercase().chars().collect();
+
+ let mut result_orig_indices: Vec<usize> = Vec::with_capacity(lowered_needle.len());
+ let mut last_lower_pos: Option<usize> = None;
+ let mut cur = 0usize;
+ for &nc in lowered_needle.iter() {
+ let mut found_at: Option<usize> = None;
+ while cur < lowered_chars.len() {
+ if lowered_chars[cur] == nc {
+ found_at = Some(cur);
+ cur += 1;
+ break;
+ }
+ cur += 1;
+ }
+ let pos = found_at?;
+ result_orig_indices.push(lowered_to_orig_char_idx[pos]);
+ last_lower_pos = Some(pos);
+ }
+
+ let first_lower_pos = if result_orig_indices.is_empty() {
+ 0usize
+ } else {
+ let target_orig = result_orig_indices[0];
+ lowered_to_orig_char_idx
+ .iter()
+ .position(|&oi| oi == target_orig)
+ .unwrap_or(0)
+ };
+ let last_lower_pos = last_lower_pos.unwrap_or(first_lower_pos);
+ let window =
+ (last_lower_pos as i32 - first_lower_pos as i32 + 1) - (lowered_needle.len() as i32);
+ let mut score = window.max(0);
+ if first_lower_pos == 0 {
+ score -= 100;
+ }
+
+ result_orig_indices.sort_unstable();
+ result_orig_indices.dedup();
+ Some((result_orig_indices, score))
+}
+
+/// Convenience wrapper to get only the indices for a fuzzy match.
+pub fn fuzzy_indices(haystack: &str, needle: &str) -> Option<Vec<usize>> {
+ fuzzy_match(haystack, needle).map(|(mut idx, _)| {
+ idx.sort_unstable();
+ idx.dedup();
+ idx
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn ascii_basic_indices() {
+ let (idx, _score) = match fuzzy_match("hello", "hl") {
```
> Can you please verify the score in all these tests, as well? This is executable documentation.
### codex-rs/tui/src/bottom_pane/command_popup.rs
- Created: 2025-08-05 18:57:05 UTC | Link: https://github.com/openai/codex/pull/1830#discussion_r2255097684
```diff
@@ -62,129 +51,84 @@ impl CommandPopup {
// Reset or clamp selected index based on new filtered list.
let matches_len = self.filtered_commands().len();
- self.selected_idx = match matches_len {
- 0 => None,
- _ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)),
- };
+ self.state.clamp_selection(matches_len);
+ self.state
+ .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
/// Determine the preferred height of the popup. This is the number of
- /// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
- /// table/border overhead (one line at the top and one at the bottom).
+ /// rows required to show at most MAX_POPUP_ROWS commands.
pub(crate) fn calculate_required_height(&self) -> u16 {
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
}
- /// Return the list of commands that match the current filter. Matching is
- /// performed using a *prefix* comparison on the command name.
- fn filtered_commands(&self) -> Vec<&SlashCommand> {
- self.all_commands
- .iter()
- .filter_map(|(_name, cmd)| {
- if self.command_filter.is_empty()
- || cmd
- .command()
- .starts_with(&self.command_filter.to_ascii_lowercase())
- {
- Some(cmd)
- } else {
- None
+ /// Compute fuzzy-filtered matches paired with optional highlight indices and score.
+ /// Sorted by ascending score, then by command name for stability.
+ fn filtered(&self) -> Vec<(&SlashCommand, Option<Vec<usize>>, i32)> {
+ let filter = self.command_filter.trim();
+ let mut out: Vec<(&SlashCommand, Option<Vec<usize>>, i32)> = Vec::new();
+ if filter.is_empty() {
+ for (_, cmd) in self.all_commands.iter() {
+ out.push((cmd, None, 0));
+ }
+ } else {
+ for (_, cmd) in self.all_commands.iter() {
+ if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
+ out.push((cmd, Some(indices), score));
}
- })
- .collect::<Vec<&SlashCommand>>()
+ }
+ }
+ out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.command().cmp(b.0.command())));
+ out
+ }
+
+ /// Backwards-compatible helper used by tests.
```
> It seems that this is also used in non-test code, like `move_up()`?
- Created: 2025-08-05 18:57:38 UTC | Link: https://github.com/openai/codex/pull/1830#discussion_r2255098754
```diff
@@ -62,129 +51,84 @@ impl CommandPopup {
// Reset or clamp selected index based on new filtered list.
let matches_len = self.filtered_commands().len();
- self.selected_idx = match matches_len {
- 0 => None,
- _ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)),
- };
+ self.state.clamp_selection(matches_len);
+ self.state
+ .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
/// Determine the preferred height of the popup. This is the number of
- /// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
- /// table/border overhead (one line at the top and one at the bottom).
+ /// rows required to show at most MAX_POPUP_ROWS commands.
pub(crate) fn calculate_required_height(&self) -> u16 {
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
}
- /// Return the list of commands that match the current filter. Matching is
- /// performed using a *prefix* comparison on the command name.
- fn filtered_commands(&self) -> Vec<&SlashCommand> {
- self.all_commands
- .iter()
- .filter_map(|(_name, cmd)| {
- if self.command_filter.is_empty()
- || cmd
- .command()
- .starts_with(&self.command_filter.to_ascii_lowercase())
- {
- Some(cmd)
- } else {
- None
+ /// Compute fuzzy-filtered matches paired with optional highlight indices and score.
+ /// Sorted by ascending score, then by command name for stability.
+ fn filtered(&self) -> Vec<(&SlashCommand, Option<Vec<usize>>, i32)> {
+ let filter = self.command_filter.trim();
+ let mut out: Vec<(&SlashCommand, Option<Vec<usize>>, i32)> = Vec::new();
+ if filter.is_empty() {
+ for (_, cmd) in self.all_commands.iter() {
+ out.push((cmd, None, 0));
+ }
+ } else {
+ for (_, cmd) in self.all_commands.iter() {
+ if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
+ out.push((cmd, Some(indices), score));
}
- })
- .collect::<Vec<&SlashCommand>>()
+ }
+ }
+ out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.command().cmp(b.0.command())));
+ out
+ }
+
+ /// Backwards-compatible helper used by tests.
+ fn filtered_commands(&self) -> Vec<&SlashCommand> {
+ self.filtered().into_iter().map(|(c, _, _)| c).collect()
}
/// Move the selection cursor one step up.
pub(crate) fn move_up(&mut self) {
- if let Some(len) = self.filtered_commands().len().checked_sub(1) {
- if len == usize::MAX {
- return;
- }
- }
-
- if let Some(idx) = self.selected_idx {
- if idx > 0 {
- self.selected_idx = Some(idx - 1);
- }
- } else if !self.filtered_commands().is_empty() {
- self.selected_idx = Some(0);
- }
+ let matches = self.filtered_commands();
```
> Seems a little unfortunate to re-run the filter logic every time up/down is hit, but I guess it won't break the CPU bank?
### codex-rs/tui/src/bottom_pane/scroll_state.rs
- Created: 2025-08-05 19:00:04 UTC | Link: https://github.com/openai/codex/pull/1830#discussion_r2255103228
```diff
@@ -0,0 +1,115 @@
+/// Generic scroll/selection state for a vertical list menu.
+///
+/// Encapsulates the common behavior of a selectable list that supports:
+/// - Optional selection (None when list is empty)
+/// - Wrap-around navigation on Up/Down
```
> Not sure wrap-around is necessary/expected, but I guess it's ok?
### codex-rs/tui/src/bottom_pane/selection_popup_common.rs
- Created: 2025-08-05 23:58:49 UTC | Link: https://github.com/openai/codex/pull/1830#discussion_r2255562690
```diff
@@ -0,0 +1,126 @@
+use ratatui::buffer::Buffer;
+use ratatui::layout::Rect;
+use ratatui::prelude::Constraint;
+use ratatui::style::Color;
+use ratatui::style::Modifier;
+use ratatui::style::Style;
+use ratatui::text::Line;
+use ratatui::text::Span;
+use ratatui::widgets::Block;
+use ratatui::widgets::BorderType;
+use ratatui::widgets::Borders;
+use ratatui::widgets::Cell;
+use ratatui::widgets::Row;
+use ratatui::widgets::Table;
+use ratatui::widgets::Widget;
+
+use super::scroll_state::ScrollState;
+
+/// A generic representation of a display row for selection popups.
+pub(crate) struct GenericDisplayRow {
+ pub name: String,
+ pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
+ pub is_current: bool,
```
> I can't seem to find an example where this field is set to `true`.