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

@@ -15,10 +15,11 @@ use tokio::{sync::mpsc, task::JoinHandle};
use tui_textarea::{Input, TextArea};
use uuid::Uuid;
use crate::commands;
use crate::config;
use crate::events::Event;
use crate::model_info_panel::ModelInfoPanel;
use crate::state::CommandPalette;
use crate::state::{CommandPalette, ModelPaletteEntry};
use crate::ui::format_tool_output;
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
@@ -226,7 +227,7 @@ impl ChatApp {
Theme::default()
});
let app = Self {
let mut app = Self {
controller,
mode: InputMode::Normal,
status: if show_onboarding {
@@ -294,6 +295,8 @@ impl ChatApp {
new_message_alert: false,
};
app.update_command_palette_catalog();
if show_onboarding {
let mut cfg = app.controller.config_mut();
if cfg.ui.show_onboarding {
@@ -689,6 +692,19 @@ impl ChatApp {
config.ui.input_max_rows.max(1)
}
pub fn active_model_label(&self) -> String {
let active_id = self.controller.selected_model();
if let Some(model) = self
.models
.iter()
.find(|m| m.id == active_id || m.name == active_id)
{
Self::display_name_for_model(model)
} else {
active_id.to_string()
}
}
pub fn scrollback_limit(&self) -> usize {
let limit = {
let config = self.controller.config();
@@ -717,6 +733,32 @@ impl ChatApp {
}
}
fn model_palette_entries(&self) -> Vec<ModelPaletteEntry> {
self.models
.iter()
.map(|model| ModelPaletteEntry {
id: model.id.clone(),
name: model.name.clone(),
provider: model.provider.clone(),
})
.collect()
}
fn update_command_palette_catalog(&mut self) {
let providers = self.available_providers.clone();
let models = self.model_palette_entries();
self.command_palette
.update_dynamic_sources(models, providers);
}
fn display_name_for_model(model: &ModelInfo) -> String {
if model.name.trim().is_empty() {
model.id.clone()
} else {
model.name.clone()
}
}
pub fn apply_chat_scrollback_trim(&mut self, removed: usize, remaining: usize) {
if removed == 0 {
self.chat_line_offset = 0;
@@ -936,6 +978,8 @@ impl ChatApp {
self.error = Some(errors.join("; "));
}
self.update_command_palette_catalog();
Ok(())
}
@@ -1505,6 +1549,12 @@ impl ChatApp {
};
self.status = format!("Focus: {}", panel_name);
}
(KeyCode::Char('m'), KeyModifiers::NONE) => {
if let Err(err) = self.show_model_picker().await {
self.error = Some(err.to_string());
}
return Ok(AppState::Running);
}
(KeyCode::Esc, KeyModifiers::NONE) => {
self.pending_key = None;
self.mode = InputMode::Normal;
@@ -2033,60 +2083,147 @@ impl ChatApp {
}
"m" | "model" => {
if args.is_empty() {
self.refresh_models().await?;
self.mode = InputMode::ProviderSelection;
if let Err(err) = self.show_model_picker().await {
self.error = Some(err.to_string());
}
self.command_palette.clear();
return Ok(AppState::Running);
}
let subcommand = args[0];
let outcome: Result<()> = match subcommand {
"info" => {
let target = if args.len() > 1 {
args[1..].join(" ")
} else {
self.controller.selected_model().to_string()
let subcommand = args[0].to_lowercase();
match subcommand.as_str() {
"info" | "details" | "refresh" => {
let outcome: Result<()> = match subcommand.as_str() {
"info" => {
let target = if args.len() > 1 {
args[1..].join(" ")
} else {
self.controller.selected_model().to_string()
};
if target.trim().is_empty() {
Err(anyhow!("Usage: :model info <name>"))
} else {
self.ensure_model_details(&target, false)
.await
}
}
"details" => {
let target = self
.controller
.selected_model()
.to_string();
if target.trim().is_empty() {
Err(anyhow!(
"No active model set. Use :model to choose one first"
))
} else {
self.ensure_model_details(&target, false)
.await
}
}
_ => {
let target = if args.len() > 1 {
args[1..].join(" ")
} else {
self.controller.selected_model().to_string()
};
if target.trim().is_empty() {
Err(anyhow!("Usage: :model refresh <name>"))
} else {
self.ensure_model_details(&target, true)
.await
}
}
};
if target.trim().is_empty() {
Err(anyhow!("Usage: :model info <name>"))
} else {
self.ensure_model_details(&target, false).await
}
}
"details" => {
let target =
self.controller.selected_model().to_string();
if target.trim().is_empty() {
Err(anyhow!(
"No active model set. Use :model to choose one first"
))
} else {
self.ensure_model_details(&target, false).await
}
}
"refresh" => {
let target = if args.len() > 1 {
args[1..].join(" ")
} else {
self.controller.selected_model().to_string()
};
if target.trim().is_empty() {
Err(anyhow!("Usage: :model refresh <name>"))
} else {
self.ensure_model_details(&target, true).await
}
}
_ => Err(anyhow!(format!(
"Unknown model subcommand: {}",
subcommand
))),
};
match outcome {
Ok(_) => self.error = None,
Err(err) => self.error = Some(err.to_string()),
match outcome {
Ok(_) => self.error = None,
Err(err) => self.error = Some(err.to_string()),
}
self.mode = InputMode::Normal;
self.command_palette.clear();
return Ok(AppState::Running);
}
_ => {
let filter = args.join(" ");
match self.select_model_with_filter(&filter).await {
Ok(_) => self.error = None,
Err(err) => {
self.status = err.to_string();
self.error = Some(err.to_string());
}
}
self.mode = InputMode::Normal;
self.command_palette.clear();
return Ok(AppState::Running);
}
}
}
"provider" => {
if args.is_empty() {
self.error = Some("Usage: :provider <name>".to_string());
self.status = "Usage: :provider <name>".to_string();
} else {
let filter = args.join(" ");
if self.available_providers.is_empty()
&& let Err(err) = self.refresh_models().await
{
self.error = Some(format!(
"Failed to refresh providers: {}",
err
));
self.status = "Unable to refresh providers".to_string();
}
if let Some(provider) = self.best_provider_match(&filter) {
match self.switch_to_provider(&provider).await {
Ok(_) => {
self.selected_provider = provider.clone();
self.update_selected_provider_index();
self.controller
.config_mut()
.general
.default_provider = provider.clone();
match config::save_config(
&self.controller.config(),
) {
Ok(_) => self.error = None,
Err(err) => {
self.error = Some(format!(
"Provider switched but config save failed: {}",
err
));
self.status = "Provider switch saved with warnings"
.to_string();
}
}
self.status =
format!("Active provider: {}", provider);
if let Err(err) = self.refresh_models().await {
self.error = Some(format!(
"Provider switched but refreshing models failed: {}",
err
));
self.status =
"Provider switched; failed to refresh models"
.to_string();
}
}
Err(err) => {
self.error = Some(format!(
"Failed to switch provider: {}",
err
));
self.status =
"Provider switch failed".to_string();
}
}
} else {
self.error =
Some(format!("No provider matching '{}'", filter));
self.status =
format!("No provider matching '{}'", filter.trim());
}
}
self.mode = InputMode::Normal;
self.command_palette.clear();
return Ok(AppState::Running);
@@ -2380,49 +2517,9 @@ impl ChatApp {
}
ModelSelectorItemKind::Model { .. } => {
if let Some(model) = self.selected_model_info().cloned() {
let model_id = model.id.clone();
let model_label = if model.name.is_empty() {
model.id.clone()
} else {
model.name.clone()
};
if let Err(err) =
self.switch_to_provider(&model.provider).await
{
self.error = Some(format!(
"Failed to switch provider: {}",
err
));
self.status = "Provider switch failed".to_string();
return Ok(AppState::Running);
if self.apply_model_selection(model).await.is_err() {
// apply_model_selection already sets status/error
}
self.selected_provider = model.provider.clone();
self.update_selected_provider_index();
// Set the selected model asynchronously
self.controller.set_model(model_id.clone()).await;
self.status = format!(
"Using model: {} (provider: {})",
model_label, self.selected_provider
);
// Save the selected provider and model to config
self.controller.config_mut().general.default_model =
Some(model_id.clone());
self.controller.config_mut().general.default_provider =
self.selected_provider.clone();
match config::save_config(&self.controller.config()) {
Ok(_) => self.error = None,
Err(err) => {
self.error = Some(format!(
"Failed to save config: {}",
err
));
}
}
self.mode = InputMode::Normal;
self.set_model_info_visible(false);
} else {
self.error = Some(
"No model available for the selected provider"
@@ -3372,6 +3469,7 @@ impl ChatApp {
self.model_details_cache.clear();
self.model_info_panel.clear();
self.set_model_info_visible(false);
self.update_command_palette_catalog();
Ok(())
}
@@ -3399,6 +3497,7 @@ impl ChatApp {
self.selected_model_item = None;
self.status = "No models available".to_string();
self.update_selected_provider_index();
self.update_command_palette_catalog();
return Ok(());
}
@@ -3450,9 +3549,153 @@ impl ChatApp {
self.available_providers.len()
);
self.update_command_palette_catalog();
Ok(())
}
async fn apply_model_selection(&mut self, model: ModelInfo) -> Result<()> {
let model_id = model.id.clone();
let model_label = Self::display_name_for_model(&model);
if let Err(err) = self.switch_to_provider(&model.provider).await {
self.error = Some(format!("Failed to switch provider: {}", err));
self.status = "Provider switch failed".to_string();
return Err(err);
}
self.selected_provider = model.provider.clone();
self.update_selected_provider_index();
self.controller.set_model(model_id.clone()).await;
self.status = format!(
"Using model: {} (provider: {})",
model_label, self.selected_provider
);
self.controller.config_mut().general.default_model = Some(model_id.clone());
self.controller.config_mut().general.default_provider = self.selected_provider.clone();
match config::save_config(&self.controller.config()) {
Ok(_) => self.error = None,
Err(err) => {
self.error = Some(format!("Failed to save config: {}", err));
}
}
self.mode = InputMode::Normal;
self.set_model_info_visible(false);
Ok(())
}
async fn show_model_picker(&mut self) -> Result<()> {
self.refresh_models().await?;
if self.models.is_empty() {
return Ok(());
}
if self.available_providers.len() <= 1 {
self.mode = InputMode::ModelSelection;
self.ensure_valid_model_selection();
} else {
self.mode = InputMode::ProviderSelection;
}
self.status = "Select a model to use".to_string();
Ok(())
}
fn best_model_match_index(&self, query: &str) -> Option<usize> {
let query = query.trim();
if query.is_empty() {
return None;
}
let mut best: Option<(usize, usize, usize)> = None;
for (idx, model) in self.models.iter().enumerate() {
let mut candidates = Vec::new();
candidates.push(commands::match_score(model.id.as_str(), query));
if !model.name.is_empty() {
candidates.push(commands::match_score(model.name.as_str(), query));
}
candidates.push(commands::match_score(
format!("{} {}", model.provider, model.id).as_str(),
query,
));
if !model.name.is_empty() {
candidates.push(commands::match_score(
format!("{} {}", model.provider, model.name).as_str(),
query,
));
}
candidates.push(commands::match_score(
format!("{}::{}", model.provider, model.id).as_str(),
query,
));
if let Some(score) = candidates.into_iter().flatten().min() {
let entry = (score.0, score.1, idx);
let replace = match best.as_ref() {
Some(current) => entry < *current,
None => true,
};
if replace {
best = Some(entry);
}
}
}
best.map(|(_, _, idx)| idx)
}
fn best_provider_match(&self, query: &str) -> Option<String> {
let query = query.trim();
if query.is_empty() {
return None;
}
let mut best: Option<(usize, usize, &String)> = None;
for provider in &self.available_providers {
if let Some(score) = commands::match_score(provider.as_str(), query) {
let entry = (score.0, score.1, provider);
let replace = match best.as_ref() {
Some(current) => entry < *current,
None => true,
};
if replace {
best = Some(entry);
}
}
}
best.map(|(_, _, provider)| provider.clone())
}
async fn select_model_with_filter(&mut self, filter: &str) -> Result<()> {
let query = filter.trim();
if query.is_empty() {
return Err(anyhow!(
"Provide a model filter (e.g. :model llama3) or omit arguments to open the picker."
));
}
self.refresh_models().await?;
if self.models.is_empty() {
return Err(anyhow!(
"No models available. Use :model to refresh once a provider is reachable."
));
}
if let Some(idx) = self.best_model_match_index(query)
&& let Some(model) = self.models.get(idx).cloned()
{
self.apply_model_selection(model).await?;
return Ok(());
}
Err(anyhow!(format!(
"No model matching '{}'. Use :model to browse available models.",
filter
)))
}
fn send_user_message_and_request_response(&mut self) {
let content = self.controller.input_buffer().text().trim().to_string();
if content.is_empty() {