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

45 KiB
Raw Blame History

PR #1830: Scrollable slash commands

Description

Scrollable slash commands. Part 1 of the multi PR.

Full 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

@@ -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.

@@ -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

@@ -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()?

@@ -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

@@ -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

@@ -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.