Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 54 additions & 33 deletions codex-rs/tui/src/bottom_pane/command_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Vec<usize>>, i32)> {
fn filtered(&self) -> Vec<(CommandItem, Option<Vec<usize>>)> {
let filter = self.command_filter.trim();
let mut out: Vec<(CommandItem, Option<Vec<usize>>, i32)> = Vec::new();
let mut out: Vec<(CommandItem, Option<Vec<usize>>)> = 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<usize>>)> = Vec::new();
let mut prefix: Vec<(CommandItem, Option<Vec<usize>>)> = 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<CommandItem> {
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<Vec<usize>>, i32)>,
matches: Vec<(CommandItem, Option<Vec<usize>>)>,
) -> Vec<GenericDisplayRow> {
matches
.into_iter()
.map(|(item, indices, _)| {
.map(|(item, indices)| {
let (name, description) = match item {
CommandItem::Builtin(cmd) => {
(format!("/{}", cmd.command()), cmd.description().to_string())
Expand Down Expand Up @@ -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());

Expand All @@ -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]
Expand Down Expand Up @@ -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,
Expand All @@ -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());

Expand All @@ -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:?}"
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Loading