diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 83afdca30..2682da3ff 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -10,7 +10,6 @@ use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; -use codex_common::fuzzy_match::fuzzy_match; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; @@ -119,52 +118,84 @@ impl CommandPopup { measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) } - /// Compute fuzzy-filtered matches over built-in commands and user prompts, - /// paired with optional highlight indices and score. Preserves the original + /// Compute exact/prefix matches over built-in commands and user prompts, + /// paired with optional highlight indices. Preserves the original /// presentation order for built-ins and prompts. - fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { + fn filtered(&self) -> Vec<(CommandItem, Option>)> { let filter = self.command_filter.trim(); - let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); + let mut out: Vec<(CommandItem, Option>)> = Vec::new(); if filter.is_empty() { // Built-ins first, in presentation order. for (_, cmd) in self.builtins.iter() { - out.push((CommandItem::Builtin(*cmd), None, 0)); + out.push((CommandItem::Builtin(*cmd), None)); } // Then prompts, already sorted by name. for idx in 0..self.prompts.len() { - out.push((CommandItem::UserPrompt(idx), None, 0)); + out.push((CommandItem::UserPrompt(idx), None)); } return out; } + let filter_lower = filter.to_lowercase(); + let filter_chars = filter.chars().count(); + let mut exact: Vec<(CommandItem, Option>)> = Vec::new(); + let mut prefix: Vec<(CommandItem, Option>)> = Vec::new(); + let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1; + let indices_for = |offset| Some((offset..offset + filter_chars).collect()); + + let mut push_match = + |item: CommandItem, display: &str, name: Option<&str>, name_offset: usize| { + let display_lower = display.to_lowercase(); + let name_lower = name.map(str::to_lowercase); + let display_exact = display_lower == filter_lower; + let name_exact = name_lower.as_deref() == Some(filter_lower.as_str()); + if display_exact || name_exact { + let offset = if display_exact { 0 } else { name_offset }; + exact.push((item, indices_for(offset))); + return; + } + let display_prefix = display_lower.starts_with(&filter_lower); + let name_prefix = name_lower + .as_ref() + .is_some_and(|name| name.starts_with(&filter_lower)); + if display_prefix || name_prefix { + let offset = if display_prefix { 0 } else { name_offset }; + prefix.push((item, indices_for(offset))); + } + }; + for (_, cmd) in self.builtins.iter() { - if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { - out.push((CommandItem::Builtin(*cmd), Some(indices), score)); - } + push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); } // Support both search styles: // - Typing "name" should surface "/prompts:name" results. // - Typing "prompts:name" should also work. for (idx, p) in self.prompts.iter().enumerate() { let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name); - if let Some((indices, score)) = fuzzy_match(&display, filter) { - out.push((CommandItem::UserPrompt(idx), Some(indices), score)); - } + push_match( + CommandItem::UserPrompt(idx), + &display, + Some(&p.name), + prompt_prefix_len, + ); } + + out.extend(exact); + out.extend(prefix); out } fn filtered_items(&self) -> Vec { - self.filtered().into_iter().map(|(c, _, _)| c).collect() + self.filtered().into_iter().map(|(c, _)| c).collect() } fn rows_from_matches( &self, - matches: Vec<(CommandItem, Option>, i32)>, + matches: Vec<(CommandItem, Option>)>, ) -> Vec { matches .into_iter() - .map(|(item, indices, _)| { + .map(|(item, indices)| { let (name, description) = match item { CommandItem::Builtin(cmd) => { (format!("/{}", cmd.command()), cmd.description().to_string()) @@ -286,7 +317,7 @@ mod tests { } #[test] - fn filtered_commands_keep_presentation_order() { + fn filtered_commands_keep_presentation_order_for_prefix() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/m".to_string()); @@ -298,17 +329,7 @@ mod tests { CommandItem::UserPrompt(_) => None, }) .collect(); - assert_eq!( - cmds, - vec![ - "model", - "experimental", - "resume", - "compact", - "mention", - "mcp" - ] - ); + assert_eq!(cmds, vec!["model", "mention", "mcp"]); } #[test] @@ -378,7 +399,7 @@ mod tests { }], CommandPopupFlags::default(), ); - let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]); let description = rows.first().and_then(|row| row.description.as_deref()); assert_eq!( description, @@ -398,13 +419,13 @@ mod tests { }], CommandPopupFlags::default(), ); - let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]); let description = rows.first().and_then(|row| row.description.as_deref()); assert_eq!(description, Some("send saved prompt")); } #[test] - fn fuzzy_filter_matches_subsequence_for_ac() { + fn prefix_filter_limits_matches_for_ac() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/ac".to_string()); @@ -417,8 +438,8 @@ mod tests { }) .collect(); assert!( - cmds.contains(&"compact") && cmds.contains(&"feedback"), - "expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}" + !cmds.contains(&"compact"), + "expected prefix search for '/ac' to exclude 'compact', got {cmds:?}" ); } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap index 661e82e3a..2d5b29038 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -5,5 +5,5 @@ expression: terminal.backend() " " "› /mo " " " -" /model choose what model and reasoning effort to use " -" /mention mention a file " +" " +" /model choose what model and reasoning effort to use "