- 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).
208 lines
5.9 KiB
Rust
208 lines
5.9 KiB
Rust
use crate::commands;
|
|
|
|
/// Encapsulates the command-line style palette used in command mode.
|
|
///
|
|
/// The palette keeps track of the raw buffer, matching suggestions, and the
|
|
/// currently highlighted suggestion index. It contains no terminal-specific
|
|
/// logic which makes it straightforward to unit test.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ModelPaletteEntry {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub provider: String,
|
|
}
|
|
|
|
impl ModelPaletteEntry {
|
|
fn display_name(&self) -> &str {
|
|
if self.name.is_empty() {
|
|
&self.id
|
|
} else {
|
|
&self.name
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct CommandPalette {
|
|
buffer: String,
|
|
suggestions: Vec<String>,
|
|
selected: usize,
|
|
models: Vec<ModelPaletteEntry>,
|
|
providers: Vec<String>,
|
|
}
|
|
|
|
impl CommandPalette {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn buffer(&self) -> &str {
|
|
&self.buffer
|
|
}
|
|
|
|
pub fn suggestions(&self) -> &[String] {
|
|
&self.suggestions
|
|
}
|
|
|
|
pub fn selected_index(&self) -> usize {
|
|
self.selected
|
|
}
|
|
|
|
pub fn clear(&mut self) {
|
|
self.buffer.clear();
|
|
self.suggestions.clear();
|
|
self.selected = 0;
|
|
}
|
|
|
|
pub fn set_buffer(&mut self, value: impl Into<String>) {
|
|
self.buffer = value.into();
|
|
self.refresh_suggestions();
|
|
}
|
|
|
|
pub fn push_char(&mut self, ch: char) {
|
|
self.buffer.push(ch);
|
|
self.refresh_suggestions();
|
|
}
|
|
|
|
pub fn pop_char(&mut self) {
|
|
self.buffer.pop();
|
|
self.refresh_suggestions();
|
|
}
|
|
|
|
pub fn update_dynamic_sources(
|
|
&mut self,
|
|
models: Vec<ModelPaletteEntry>,
|
|
providers: Vec<String>,
|
|
) {
|
|
self.models = models;
|
|
self.providers = providers;
|
|
self.refresh_suggestions();
|
|
}
|
|
|
|
pub fn select_previous(&mut self) {
|
|
if !self.suggestions.is_empty() {
|
|
self.selected = self.selected.saturating_sub(1);
|
|
}
|
|
}
|
|
|
|
pub fn select_next(&mut self) {
|
|
if !self.suggestions.is_empty() {
|
|
let max_index = self.suggestions.len().saturating_sub(1);
|
|
self.selected = (self.selected + 1).min(max_index);
|
|
}
|
|
}
|
|
|
|
pub fn apply_selected(&mut self) -> Option<String> {
|
|
let selected = self
|
|
.suggestions
|
|
.get(self.selected)
|
|
.cloned()
|
|
.or_else(|| self.suggestions.first().cloned());
|
|
if let Some(value) = selected.clone() {
|
|
self.buffer = value;
|
|
self.refresh_suggestions();
|
|
}
|
|
selected
|
|
}
|
|
|
|
pub fn refresh_suggestions(&mut self) {
|
|
let trimmed = self.buffer.trim();
|
|
self.suggestions = self.dynamic_suggestions(trimmed);
|
|
if self.selected >= self.suggestions.len() {
|
|
self.selected = 0;
|
|
}
|
|
}
|
|
|
|
pub fn ensure_suggestions(&mut self) {
|
|
if self.suggestions.is_empty() {
|
|
self.refresh_suggestions();
|
|
}
|
|
}
|
|
|
|
fn dynamic_suggestions(&self, trimmed: &str) -> Vec<String> {
|
|
if let Some(rest) = trimmed.strip_prefix("model ") {
|
|
let suggestions = self.model_suggestions("model", rest.trim());
|
|
if suggestions.is_empty() {
|
|
commands::suggestions(trimmed)
|
|
} else {
|
|
suggestions
|
|
}
|
|
} else if let Some(rest) = trimmed.strip_prefix("m ") {
|
|
let suggestions = self.model_suggestions("m", rest.trim());
|
|
if suggestions.is_empty() {
|
|
commands::suggestions(trimmed)
|
|
} else {
|
|
suggestions
|
|
}
|
|
} else if let Some(rest) = trimmed.strip_prefix("provider ") {
|
|
let suggestions = self.provider_suggestions("provider", rest.trim());
|
|
if suggestions.is_empty() {
|
|
commands::suggestions(trimmed)
|
|
} else {
|
|
suggestions
|
|
}
|
|
} else {
|
|
commands::suggestions(trimmed)
|
|
}
|
|
}
|
|
|
|
fn model_suggestions(&self, keyword: &str, query: &str) -> Vec<String> {
|
|
if query.is_empty() {
|
|
return self
|
|
.models
|
|
.iter()
|
|
.take(15)
|
|
.map(|entry| format!("{keyword} {}", entry.id))
|
|
.collect();
|
|
}
|
|
|
|
let mut matches: Vec<(usize, usize, &ModelPaletteEntry)> = self
|
|
.models
|
|
.iter()
|
|
.filter_map(|entry| {
|
|
commands::match_score(entry.id.as_str(), query)
|
|
.or_else(|| commands::match_score(entry.name.as_str(), query))
|
|
.or_else(|| {
|
|
let composite = format!("{} {}", entry.provider, entry.display_name());
|
|
commands::match_score(composite.as_str(), query)
|
|
})
|
|
.map(|score| (score.0, score.1, entry))
|
|
})
|
|
.collect();
|
|
|
|
matches.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.id.cmp(&b.2.id)));
|
|
matches
|
|
.into_iter()
|
|
.take(15)
|
|
.map(|(_, _, entry)| format!("{keyword} {}", entry.id))
|
|
.collect()
|
|
}
|
|
|
|
fn provider_suggestions(&self, keyword: &str, query: &str) -> Vec<String> {
|
|
if query.is_empty() {
|
|
return self
|
|
.providers
|
|
.iter()
|
|
.take(15)
|
|
.map(|provider| format!("{keyword} {}", provider))
|
|
.collect();
|
|
}
|
|
|
|
let mut matches: Vec<(usize, usize, &String)> = self
|
|
.providers
|
|
.iter()
|
|
.filter_map(|provider| {
|
|
commands::match_score(provider.as_str(), query)
|
|
.map(|score| (score.0, score.1, provider))
|
|
})
|
|
.collect();
|
|
|
|
matches.sort();
|
|
matches
|
|
.into_iter()
|
|
.take(15)
|
|
.map(|(_, _, provider)| format!("{keyword} {}", provider))
|
|
.collect()
|
|
}
|
|
}
|