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:
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Footer status line includes provider connectivity/credential summaries (e.g., cloud auth failures, missing API keys).
|
- Footer status line includes provider connectivity/credential summaries (e.g., cloud auth failures, missing API keys).
|
||||||
- Secure credential vault integration for Ollama Cloud API keys when `privacy.encrypt_local_data = true`.
|
- Secure credential vault integration for Ollama Cloud API keys when `privacy.encrypt_local_data = true`.
|
||||||
- Input panel respects a new `ui.input_max_rows` setting so long prompts expand predictably before scrolling kicks in.
|
- Input panel respects a new `ui.input_max_rows` setting so long prompts expand predictably before scrolling kicks in.
|
||||||
|
- Command palette offers fuzzy `:model` filtering and `:provider` completions for fast switching.
|
||||||
- Chat history honors `ui.scrollback_lines`, trimming older rows to keep the TUI responsive and surfacing a "↓ New messages" badge whenever updates land off-screen.
|
- Chat history honors `ui.scrollback_lines`, trimming older rows to keep the TUI responsive and surfacing a "↓ New messages" badge whenever updates land off-screen.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -35,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- `owlen` warns when the active terminal likely lacks 256-color support.
|
- `owlen` warns when the active terminal likely lacks 256-color support.
|
||||||
- `config.toml` now carries a schema version (`1.2.0`) and is migrated automatically; deprecated keys such as `agent.max_tool_calls` trigger warnings instead of hard failures.
|
- `config.toml` now carries a schema version (`1.2.0`) and is migrated automatically; deprecated keys such as `agent.max_tool_calls` trigger warnings instead of hard failures.
|
||||||
- Model selector navigation (Tab/Shift-Tab) now switches between local and cloud tabs while preserving selection state.
|
- Model selector navigation (Tab/Shift-Tab) now switches between local and cloud tabs while preserving selection state.
|
||||||
|
- Header displays the active model together with its provider (e.g., `Model (Provider)`), improving clarity when swapping backends.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ use tokio::{sync::mpsc, task::JoinHandle};
|
|||||||
use tui_textarea::{Input, TextArea};
|
use tui_textarea::{Input, TextArea};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::commands;
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::events::Event;
|
use crate::events::Event;
|
||||||
use crate::model_info_panel::ModelInfoPanel;
|
use crate::model_info_panel::ModelInfoPanel;
|
||||||
use crate::state::CommandPalette;
|
use crate::state::{CommandPalette, ModelPaletteEntry};
|
||||||
use crate::ui::format_tool_output;
|
use crate::ui::format_tool_output;
|
||||||
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
||||||
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
|
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
|
||||||
@@ -226,7 +227,7 @@ impl ChatApp {
|
|||||||
Theme::default()
|
Theme::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
let app = Self {
|
let mut app = Self {
|
||||||
controller,
|
controller,
|
||||||
mode: InputMode::Normal,
|
mode: InputMode::Normal,
|
||||||
status: if show_onboarding {
|
status: if show_onboarding {
|
||||||
@@ -294,6 +295,8 @@ impl ChatApp {
|
|||||||
new_message_alert: false,
|
new_message_alert: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
app.update_command_palette_catalog();
|
||||||
|
|
||||||
if show_onboarding {
|
if show_onboarding {
|
||||||
let mut cfg = app.controller.config_mut();
|
let mut cfg = app.controller.config_mut();
|
||||||
if cfg.ui.show_onboarding {
|
if cfg.ui.show_onboarding {
|
||||||
@@ -689,6 +692,19 @@ impl ChatApp {
|
|||||||
config.ui.input_max_rows.max(1)
|
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 {
|
pub fn scrollback_limit(&self) -> usize {
|
||||||
let limit = {
|
let limit = {
|
||||||
let config = self.controller.config();
|
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) {
|
pub fn apply_chat_scrollback_trim(&mut self, removed: usize, remaining: usize) {
|
||||||
if removed == 0 {
|
if removed == 0 {
|
||||||
self.chat_line_offset = 0;
|
self.chat_line_offset = 0;
|
||||||
@@ -936,6 +978,8 @@ impl ChatApp {
|
|||||||
self.error = Some(errors.join("; "));
|
self.error = Some(errors.join("; "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.update_command_palette_catalog();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1505,6 +1549,12 @@ impl ChatApp {
|
|||||||
};
|
};
|
||||||
self.status = format!("Focus: {}", panel_name);
|
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) => {
|
(KeyCode::Esc, KeyModifiers::NONE) => {
|
||||||
self.pending_key = None;
|
self.pending_key = None;
|
||||||
self.mode = InputMode::Normal;
|
self.mode = InputMode::Normal;
|
||||||
@@ -2033,60 +2083,147 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
"m" | "model" => {
|
"m" | "model" => {
|
||||||
if args.is_empty() {
|
if args.is_empty() {
|
||||||
self.refresh_models().await?;
|
if let Err(err) = self.show_model_picker().await {
|
||||||
self.mode = InputMode::ProviderSelection;
|
self.error = Some(err.to_string());
|
||||||
|
}
|
||||||
self.command_palette.clear();
|
self.command_palette.clear();
|
||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
|
let subcommand = args[0].to_lowercase();
|
||||||
let subcommand = args[0];
|
match subcommand.as_str() {
|
||||||
let outcome: Result<()> = match subcommand {
|
"info" | "details" | "refresh" => {
|
||||||
"info" => {
|
let outcome: Result<()> = match subcommand.as_str() {
|
||||||
let target = if args.len() > 1 {
|
"info" => {
|
||||||
args[1..].join(" ")
|
let target = if args.len() > 1 {
|
||||||
} else {
|
args[1..].join(" ")
|
||||||
self.controller.selected_model().to_string()
|
} 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 {
|
match outcome {
|
||||||
Ok(_) => self.error = None,
|
Ok(_) => self.error = None,
|
||||||
Err(err) => self.error = Some(err.to_string()),
|
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.mode = InputMode::Normal;
|
||||||
self.command_palette.clear();
|
self.command_palette.clear();
|
||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
@@ -2380,49 +2517,9 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
ModelSelectorItemKind::Model { .. } => {
|
ModelSelectorItemKind::Model { .. } => {
|
||||||
if let Some(model) = self.selected_model_info().cloned() {
|
if let Some(model) = self.selected_model_info().cloned() {
|
||||||
let model_id = model.id.clone();
|
if self.apply_model_selection(model).await.is_err() {
|
||||||
let model_label = if model.name.is_empty() {
|
// apply_model_selection already sets status/error
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
self.error = Some(
|
self.error = Some(
|
||||||
"No model available for the selected provider"
|
"No model available for the selected provider"
|
||||||
@@ -3372,6 +3469,7 @@ impl ChatApp {
|
|||||||
self.model_details_cache.clear();
|
self.model_details_cache.clear();
|
||||||
self.model_info_panel.clear();
|
self.model_info_panel.clear();
|
||||||
self.set_model_info_visible(false);
|
self.set_model_info_visible(false);
|
||||||
|
self.update_command_palette_catalog();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3399,6 +3497,7 @@ impl ChatApp {
|
|||||||
self.selected_model_item = None;
|
self.selected_model_item = None;
|
||||||
self.status = "No models available".to_string();
|
self.status = "No models available".to_string();
|
||||||
self.update_selected_provider_index();
|
self.update_selected_provider_index();
|
||||||
|
self.update_command_palette_catalog();
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3450,9 +3549,153 @@ impl ChatApp {
|
|||||||
self.available_providers.len()
|
self.available_providers.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
self.update_command_palette_catalog();
|
||||||
|
|
||||||
Ok(())
|
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) {
|
fn send_user_message_and_request_response(&mut self) {
|
||||||
let content = self.controller.input_buffer().text().trim().to_string();
|
let content = self.controller.input_buffer().text().trim().to_string();
|
||||||
if content.is_empty() {
|
if content.is_empty() {
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ const COMMANDS: &[CommandSpec] = &[
|
|||||||
keyword: "model",
|
keyword: "model",
|
||||||
description: "Select a model",
|
description: "Select a model",
|
||||||
},
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "provider",
|
||||||
|
description: "Switch active provider",
|
||||||
|
},
|
||||||
CommandSpec {
|
CommandSpec {
|
||||||
keyword: "model info",
|
keyword: "model info",
|
||||||
description: "Show detailed information for a model",
|
description: "Show detailed information for a model",
|
||||||
@@ -177,7 +181,6 @@ pub fn suggestions(input: &str) -> Vec<String> {
|
|||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return default_suggestions();
|
return default_suggestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
COMMANDS
|
COMMANDS
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|spec| {
|
.filter_map(|spec| {
|
||||||
@@ -189,3 +192,52 @@ pub fn suggestions(input: &str) -> Vec<String> {
|
|||||||
})
|
})
|
||||||
.collect()
|
.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
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,30 @@ use crate::commands;
|
|||||||
/// The palette keeps track of the raw buffer, matching suggestions, and the
|
/// The palette keeps track of the raw buffer, matching suggestions, and the
|
||||||
/// currently highlighted suggestion index. It contains no terminal-specific
|
/// currently highlighted suggestion index. It contains no terminal-specific
|
||||||
/// logic which makes it straightforward to unit test.
|
/// 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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct CommandPalette {
|
pub struct CommandPalette {
|
||||||
buffer: String,
|
buffer: String,
|
||||||
suggestions: Vec<String>,
|
suggestions: Vec<String>,
|
||||||
selected: usize,
|
selected: usize,
|
||||||
|
models: Vec<ModelPaletteEntry>,
|
||||||
|
providers: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandPalette {
|
impl CommandPalette {
|
||||||
@@ -50,6 +69,16 @@ impl CommandPalette {
|
|||||||
self.refresh_suggestions();
|
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) {
|
pub fn select_previous(&mut self) {
|
||||||
if !self.suggestions.is_empty() {
|
if !self.suggestions.is_empty() {
|
||||||
self.selected = self.selected.saturating_sub(1);
|
self.selected = self.selected.saturating_sub(1);
|
||||||
@@ -78,7 +107,7 @@ impl CommandPalette {
|
|||||||
|
|
||||||
pub fn refresh_suggestions(&mut self) {
|
pub fn refresh_suggestions(&mut self) {
|
||||||
let trimmed = self.buffer.trim();
|
let trimmed = self.buffer.trim();
|
||||||
self.suggestions = commands::suggestions(trimmed);
|
self.suggestions = self.dynamic_suggestions(trimmed);
|
||||||
if self.selected >= self.suggestions.len() {
|
if self.selected >= self.suggestions.len() {
|
||||||
self.selected = 0;
|
self.selected = 0;
|
||||||
}
|
}
|
||||||
@@ -89,4 +118,90 @@ impl CommandPalette {
|
|||||||
self.refresh_suggestions();
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,4 @@
|
|||||||
|
|
||||||
mod command_palette;
|
mod command_palette;
|
||||||
|
|
||||||
pub use command_palette::CommandPalette;
|
pub use command_palette::{CommandPalette, ModelPaletteEntry};
|
||||||
|
|||||||
@@ -504,12 +504,9 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
|||||||
.fg(theme.focused_panel_border)
|
.fg(theme.focused_panel_border)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
);
|
);
|
||||||
let provider_span = Span::styled(
|
let model_label = app.active_model_label();
|
||||||
app.current_provider().to_string(),
|
let model_with_provider_span = Span::styled(
|
||||||
Style::default().fg(theme.text),
|
format!("{} ({})", model_label, app.current_provider()),
|
||||||
);
|
|
||||||
let model_span = Span::styled(
|
|
||||||
app.selected_model().to_string(),
|
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.user_message_role)
|
.fg(theme.user_message_role)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
@@ -527,11 +524,8 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
|||||||
Line::default(),
|
Line::default(),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled("Provider: ", Style::default().fg(theme.placeholder)),
|
Span::styled("Model (Provider): ", Style::default().fg(theme.placeholder)),
|
||||||
provider_span,
|
model_with_provider_span,
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled("Model: ", Style::default().fg(theme.placeholder)),
|
|
||||||
model_span,
|
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user