Skip to content

Commit e1886bb

Browse files
committed
feat(desktop): fuzzy slash command suggestions
1 parent 9d941cb commit e1886bb

2 files changed

Lines changed: 84 additions & 10 deletions

File tree

crates/jcode-desktop/src/main_tests.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,32 @@ fn single_session_tab_autocompletes_desktop_slash_command() {
980980
assert_eq!(app.draft, "/cop");
981981
}
982982

983+
#[test]
984+
fn single_session_slash_suggestions_support_tui_style_fuzzy_abbreviations() {
985+
let mut app = SingleSessionApp::new(None);
986+
app.handle_key(KeyInput::Character("/cp".to_string()));
987+
988+
assert_eq!(app.active_inline_widget(), Some(InlineWidgetKind::SlashSuggestions));
989+
let suggestions = app.inline_widget_styled_lines();
990+
assert!(suggestions.iter().any(|line| {
991+
line.style == SingleSessionLineStyle::OverlaySelection && line.text.contains("/copy")
992+
}));
993+
994+
assert_eq!(app.handle_key(KeyInput::Autocomplete), KeyOutcome::Redraw);
995+
assert_eq!(app.draft, "/copy");
996+
}
997+
998+
#[test]
999+
fn single_session_slash_suggestions_keep_prefix_matches_before_fuzzy_matches() {
1000+
let mut app = SingleSessionApp::new(None);
1001+
app.handle_key(KeyInput::Character("/c".to_string()));
1002+
1003+
let suggestions = app.inline_widget_styled_lines();
1004+
assert!(suggestions.iter().any(|line| {
1005+
line.style == SingleSessionLineStyle::OverlaySelection && line.text.contains("/commands")
1006+
}));
1007+
}
1008+
9831009
#[test]
9841010
fn single_session_slash_suggestions_filter_select_and_submit() {
9851011
let mut app = SingleSessionApp::new(None);

crates/jcode-desktop/src/single_session.rs

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2230,16 +2230,32 @@ impl SingleSessionApp {
22302230
self.slash_suggestions.query.as_str()
22312231
};
22322232
let prefix = prefix.to_ascii_lowercase();
2233-
DESKTOP_SLASH_COMMANDS
2234-
.iter()
2235-
.copied()
2236-
.filter(|(usage, _)| {
2237-
usage
2238-
.split_whitespace()
2239-
.next()
2240-
.unwrap_or(usage)
2241-
.starts_with(&prefix)
2242-
})
2233+
2234+
let mut prefix_matches = Vec::new();
2235+
let mut fuzzy_matches: Vec<(usize, usize, &'static str, &'static str)> = Vec::new();
2236+
for (usage, description) in DESKTOP_SLASH_COMMANDS.iter().copied() {
2237+
let command = usage.split_whitespace().next().unwrap_or(usage);
2238+
let command_lower = command.to_ascii_lowercase();
2239+
if command_lower.starts_with(&prefix) {
2240+
prefix_matches.push((usage, description));
2241+
} else if let Some(score) = desktop_slash_fuzzy_score(&prefix, &command_lower) {
2242+
fuzzy_matches.push((score, command.len(), usage, description));
2243+
}
2244+
}
2245+
2246+
fuzzy_matches.sort_by(|a, b| {
2247+
a.0.cmp(&b.0)
2248+
.then_with(|| a.1.cmp(&b.1))
2249+
.then_with(|| a.2.cmp(&b.2))
2250+
});
2251+
2252+
prefix_matches
2253+
.into_iter()
2254+
.chain(
2255+
fuzzy_matches
2256+
.into_iter()
2257+
.map(|(_, _, usage, description)| (usage, description)),
2258+
)
22432259
.take(DESKTOP_SLASH_SUGGESTION_ROW_LIMIT)
22442260
.collect()
22452261
}
@@ -4751,6 +4767,38 @@ fn model_choice_search_text(choice: &DesktopModelChoice) -> String {
47514767
.to_lowercase()
47524768
}
47534769

4770+
fn desktop_slash_fuzzy_score(needle: &str, haystack: &str) -> Option<usize> {
4771+
if needle.is_empty() {
4772+
return Some(0);
4773+
}
4774+
4775+
let needle = needle.strip_prefix('/').unwrap_or(needle);
4776+
let haystack = haystack.strip_prefix('/').unwrap_or(haystack);
4777+
if needle.is_empty() {
4778+
return Some(0);
4779+
}
4780+
4781+
if let Some(first_char) = needle.chars().next()
4782+
&& !haystack.starts_with(&needle[..first_char.len_utf8()])
4783+
{
4784+
return None;
4785+
}
4786+
4787+
let mut score = 0usize;
4788+
let mut position = 0usize;
4789+
for ch in needle.chars() {
4790+
let offset = haystack[position..].find(ch)?;
4791+
score += offset;
4792+
position += offset + ch.len_utf8();
4793+
}
4794+
4795+
if needle.len() > 1 && score > needle.len() * 3 {
4796+
return None;
4797+
}
4798+
4799+
Some(score)
4800+
}
4801+
47544802
fn dedupe_model_choices(choices: Vec<DesktopModelChoice>) -> Vec<DesktopModelChoice> {
47554803
let mut deduped: Vec<DesktopModelChoice> = Vec::new();
47564804
for choice in choices {

0 commit comments

Comments
 (0)