diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f8cc9c..c9a1582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). - 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. +- 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. ### 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. - `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. +- Header displays the active model together with its provider (e.g., `Model (Provider)`), improving clarity when swapping backends. --- diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index c1aa6ec..cd5c57d 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -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 { + 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 ")) + } 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 ")) + } else { + self.ensure_model_details(&target, true) + .await + } + } }; - if target.trim().is_empty() { - Err(anyhow!("Usage: :model info ")) - } 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 ")) - } 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 ".to_string()); + self.status = "Usage: :provider ".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 { + 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 { + 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() { diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs index d8fca3b..01c91e4 100644 --- a/crates/owlen-tui/src/commands/mod.rs +++ b/crates/owlen-tui/src/commands/mod.rs @@ -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 { if trimmed.is_empty() { return default_suggestions(); } - COMMANDS .iter() .filter_map(|spec| { @@ -189,3 +192,52 @@ pub fn suggestions(input: &str) -> Vec { }) .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 +} diff --git a/crates/owlen-tui/src/state/command_palette.rs b/crates/owlen-tui/src/state/command_palette.rs index 9b7d3df..f55379b 100644 --- a/crates/owlen-tui/src/state/command_palette.rs +++ b/crates/owlen-tui/src/state/command_palette.rs @@ -5,11 +5,30 @@ use crate::commands; /// 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, selected: usize, + models: Vec, + providers: Vec, } impl CommandPalette { @@ -50,6 +69,16 @@ impl CommandPalette { self.refresh_suggestions(); } + pub fn update_dynamic_sources( + &mut self, + models: Vec, + providers: Vec, + ) { + 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); @@ -78,7 +107,7 @@ impl CommandPalette { pub fn refresh_suggestions(&mut self) { let trimmed = self.buffer.trim(); - self.suggestions = commands::suggestions(trimmed); + self.suggestions = self.dynamic_suggestions(trimmed); if self.selected >= self.suggestions.len() { self.selected = 0; } @@ -89,4 +118,90 @@ impl CommandPalette { self.refresh_suggestions(); } } + + fn dynamic_suggestions(&self, trimmed: &str) -> Vec { + 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 { + 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 { + 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() + } } diff --git a/crates/owlen-tui/src/state/mod.rs b/crates/owlen-tui/src/state/mod.rs index 920b259..d184d9d 100644 --- a/crates/owlen-tui/src/state/mod.rs +++ b/crates/owlen-tui/src/state/mod.rs @@ -7,4 +7,4 @@ mod command_palette; -pub use command_palette::CommandPalette; +pub use command_palette::{CommandPalette, ModelPaletteEntry}; diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 7c0f854..47b5c1d 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -504,12 +504,9 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { .fg(theme.focused_panel_border) .add_modifier(Modifier::BOLD), ); - let provider_span = Span::styled( - app.current_provider().to_string(), - Style::default().fg(theme.text), - ); - let model_span = Span::styled( - app.selected_model().to_string(), + let model_label = app.active_model_label(); + let model_with_provider_span = Span::styled( + format!("{} ({})", model_label, app.current_provider()), Style::default() .fg(theme.user_message_role) .add_modifier(Modifier::BOLD), @@ -527,11 +524,8 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { Line::default(), Line::from(vec![ Span::raw(" "), - Span::styled("Provider: ", Style::default().fg(theme.placeholder)), - provider_span, - Span::raw(" "), - Span::styled("Model: ", Style::default().fg(theme.placeholder)), - model_span, + Span::styled("Model (Provider): ", Style::default().fg(theme.placeholder)), + model_with_provider_span, ]), ];