feat(command-palette): add fuzzy model/provider filtering, expose ModelPaletteEntry, and show active model with provider in UI header

- Introduce `ModelPaletteEntry` and re‑export it for external use.
- Extend `CommandPalette` with dynamic sources (models, providers) and methods to refresh suggestions based on `:model` and `:provider` prefixes.
- Implement fuzzy matching via `match_score` and subsequence checks for richer suggestion ranking.
- Add `provider` command spec and completions.
- Update UI to display “Model (Provider)” in the header and use the new active model label helper.
- Wire catalog updates throughout `ChatApp` (model palette entries, command palette refresh on state changes, model picker integration).
This commit is contained in:
2025-10-12 14:41:02 +02:00
parent 60c859b3ab
commit acbfe47a4b
6 changed files with 511 additions and 105 deletions

View File

@@ -80,6 +80,10 @@ const COMMANDS: &[CommandSpec] = &[
keyword: "model",
description: "Select a model",
},
CommandSpec {
keyword: "provider",
description: "Switch active provider",
},
CommandSpec {
keyword: "model info",
description: "Show detailed information for a model",
@@ -177,7 +181,6 @@ pub fn suggestions(input: &str) -> Vec<String> {
if trimmed.is_empty() {
return default_suggestions();
}
COMMANDS
.iter()
.filter_map(|spec| {
@@ -189,3 +192,52 @@ pub fn suggestions(input: &str) -> Vec<String> {
})
.collect()
}
pub fn match_score(candidate: &str, query: &str) -> Option<(usize, usize)> {
let query = query.trim();
if query.is_empty() {
return Some((usize::MAX, candidate.len()));
}
let candidate_normalized = candidate.trim().to_lowercase();
if candidate_normalized.is_empty() {
return None;
}
let query_normalized = query.to_lowercase();
if candidate_normalized == query_normalized {
Some((0, candidate.len()))
} else if candidate_normalized.starts_with(&query_normalized) {
Some((1, candidate.len()))
} else if let Some(pos) = candidate_normalized.find(&query_normalized) {
Some((2, pos))
} else if is_subsequence(&candidate_normalized, &query_normalized) {
Some((3, candidate.len()))
} else {
None
}
}
fn is_subsequence(text: &str, pattern: &str) -> bool {
if pattern.is_empty() {
return true;
}
let mut pattern_chars = pattern.chars();
let mut current = match pattern_chars.next() {
Some(ch) => ch,
None => return true,
};
for ch in text.chars() {
if ch == current {
match pattern_chars.next() {
Some(next_ch) => current = next_ch,
None => return true,
}
}
}
false
}