diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 10bbba7..36769a5 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -3,6 +3,10 @@ use chrono::{DateTime, Local, Utc}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use owlen_core::mcp::remote_client::RemoteMcpClient; use owlen_core::mcp::{McpToolDescriptor, McpToolResponse}; +use owlen_core::provider::{ + AnnotatedModelInfo, ModelInfo as ProviderModelInfo, ProviderMetadata, ProviderStatus, + ProviderType, +}; use owlen_core::{ Provider, ProviderConfig, config::McpResourceConfig, @@ -40,6 +44,7 @@ use crate::state::{ }; use crate::toast::{Toast, ToastLevel, ToastManager}; use crate::ui::format_tool_output; +use crate::widgets::model_picker::FilterMode; use crate::{commands, highlight}; use owlen_core::config::{ OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY, @@ -102,11 +107,14 @@ pub(crate) enum ModelSelectorItemKind { Header { provider: String, expanded: bool, + status: ProviderStatus, + provider_type: ProviderType, }, Scope { provider: String, label: String, scope: ModelScope, + status: ModelAvailabilityState, }, Model { provider: String, @@ -115,25 +123,39 @@ pub(crate) enum ModelSelectorItemKind { Empty { provider: String, message: Option, + status: Option, }, } impl ModelSelectorItem { - fn header(provider: impl Into, expanded: bool) -> Self { + fn header( + provider: impl Into, + expanded: bool, + status: ProviderStatus, + provider_type: ProviderType, + ) -> Self { Self { kind: ModelSelectorItemKind::Header { provider: provider.into(), expanded, + status, + provider_type, }, } } - fn scope(provider: impl Into, label: impl Into, scope: ModelScope) -> Self { + fn scope( + provider: impl Into, + label: impl Into, + scope: ModelScope, + status: ModelAvailabilityState, + ) -> Self { Self { kind: ModelSelectorItemKind::Scope { provider: provider.into(), label: label.into(), scope, + status, }, } } @@ -147,11 +169,16 @@ impl ModelSelectorItem { } } - fn empty(provider: impl Into, message: Option) -> Self { + fn empty( + provider: impl Into, + message: Option, + status: Option, + ) -> Self { Self { kind: ModelSelectorItemKind::Empty { provider: provider.into(), message, + status, }, } } @@ -250,13 +277,15 @@ pub struct ChatApp { mode_flash_until: Option, pub status: String, pub error: Option, - models: Vec, // All models fetched + models: Vec, // All models fetched + annotated_models: Vec, // Models annotated with provider metadata provider_scope_status: HashMap, pub available_providers: Vec, // Unique providers from models pub selected_provider: String, // The currently selected provider pub selected_provider_index: usize, // Index into the available_providers list pub selected_model_item: Option, // Index into the flattened model selector list model_selector_items: Vec, // Flattened provider/model list for selector + model_filter_mode: FilterMode, // Active filter applied to the model list model_info_panel: ModelInfoPanel, // Dedicated model information viewer model_details_cache: HashMap, // Cached detailed metadata per model show_model_info: bool, // Whether the model info panel is visible @@ -500,12 +529,14 @@ impl ChatApp { }, error: None, models: Vec::new(), + annotated_models: Vec::new(), provider_scope_status: HashMap::new(), available_providers: Vec::new(), selected_provider: "ollama_local".to_string(), // Default, will be updated in initialize_models selected_provider_index: 0, selected_model_item: None, model_selector_items: Vec::new(), + model_filter_mode: FilterMode::All, model_info_panel: ModelInfoPanel::new(), model_details_cache: HashMap::new(), show_model_info: false, @@ -1210,6 +1241,21 @@ impl ChatApp { &self.model_selector_items } + pub(crate) fn annotated_models(&self) -> &[AnnotatedModelInfo] { + &self.annotated_models + } + + pub(crate) fn model_filter_mode(&self) -> FilterMode { + self.model_filter_mode + } + + pub(crate) fn set_model_filter_mode(&mut self, mode: FilterMode) { + if self.model_filter_mode != mode { + self.model_filter_mode = mode; + self.rebuild_model_selector_items(); + } + } + pub fn selected_model_item(&self) -> Option { self.selected_model_item } @@ -5200,7 +5246,7 @@ impl ChatApp { return Ok(AppState::Running); } (KeyCode::Char('m'), KeyModifiers::NONE) => { - if let Err(err) = self.show_model_picker().await { + if let Err(err) = self.show_model_picker(FilterMode::All).await { self.error = Some(err.to_string()); } return Ok(AppState::Running); @@ -6066,7 +6112,9 @@ impl ChatApp { } "m" | "model" => { if args.is_empty() { - if let Err(err) = self.show_model_picker().await { + if let Err(err) = + self.show_model_picker(FilterMode::All).await + { self.error = Some(err.to_string()); } self.command_palette.clear(); @@ -6257,7 +6305,9 @@ impl ChatApp { } "models" => { if args.is_empty() { - if let Err(err) = self.show_model_picker().await { + if let Err(err) = + self.show_model_picker(FilterMode::All).await + { self.error = Some(err.to_string()); } self.command_palette.clear(); @@ -6266,7 +6316,9 @@ impl ChatApp { match args[0] { "--local" => { - if let Err(err) = self.show_model_picker().await { + if let Err(err) = + self.show_model_picker(FilterMode::LocalOnly).await + { self.error = Some(err.to_string()); } else if !self .focus_first_model_in_scope(&ModelScope::Local) @@ -6281,7 +6333,9 @@ impl ChatApp { return Ok(AppState::Running); } "--cloud" => { - if let Err(err) = self.show_model_picker().await { + if let Err(err) = + self.show_model_picker(FilterMode::CloudOnly).await + { self.error = Some(err.to_string()); } else if !self .focus_first_model_in_scope(&ModelScope::Cloud) @@ -6295,6 +6349,22 @@ impl ChatApp { self.command_palette.clear(); return Ok(AppState::Running); } + "--available" => { + if let Err(err) = + self.show_model_picker(FilterMode::Available).await + { + self.error = Some(err.to_string()); + } else if !self.focus_first_available_model() { + self.status = + "No available models right now".to_string(); + } else { + self.status = + "Showing available models".to_string(); + self.error = None; + } + self.command_palette.clear(); + return Ok(AppState::Running); + } "info" => { let force_refresh = args .get(1) @@ -6743,7 +6813,9 @@ impl ChatApp { KeyCode::Enter => { if let Some(item) = self.current_model_selector_item() { match item.kind() { - ModelSelectorItemKind::Header { provider, expanded } => { + ModelSelectorItemKind::Header { + provider, expanded, .. + } => { if *expanded { let provider_name = provider.clone(); self.collapse_provider(&provider_name); @@ -6839,7 +6911,9 @@ impl ChatApp { KeyCode::Left => { if let Some(item) = self.current_model_selector_item() { match item.kind() { - ModelSelectorItemKind::Header { provider, expanded } => { + ModelSelectorItemKind::Header { + provider, expanded, .. + } => { if *expanded { let provider_name = provider.clone(); self.collapse_provider(&provider_name); @@ -6873,7 +6947,9 @@ impl ChatApp { KeyCode::Right => { if let Some(item) = self.current_model_selector_item() { match item.kind() { - ModelSelectorItemKind::Header { provider, expanded } => { + ModelSelectorItemKind::Header { + provider, expanded, .. + } => { if !expanded { let provider_name = provider.clone(); self.expand_provider(&provider_name, true); @@ -6895,8 +6971,9 @@ impl ChatApp { } KeyCode::Char(' ') => { if let Some(item) = self.current_model_selector_item() { - if let ModelSelectorItemKind::Header { provider, expanded } = - item.kind() + if let ModelSelectorItemKind::Header { + provider, expanded, .. + } = item.kind() { if *expanded { let provider_name = provider.clone(); @@ -7575,17 +7652,29 @@ impl ChatApp { } fn scope_header_label( - provider: &str, + _provider: &str, scope: &ModelScope, status: Option, + filter: FilterMode, ) -> String { let icon = Self::scope_icon(scope); let scope_name = Self::scope_display_name(scope); - let provider_name = capitalize_first(provider); - let mut label = format!("{icon} {scope_name} · {provider_name}"); + let mut label = format!("{icon} {scope_name}"); - if let Some(ModelAvailabilityState::Unavailable) = status { - label.push_str(" (Unavailable)"); + if let Some(state) = status { + match state { + ModelAvailabilityState::Available => { + if matches!(filter, FilterMode::Available) { + label.push_str(" · ✓"); + } + } + ModelAvailabilityState::Unavailable => label.push_str(" · ✗"), + ModelAvailabilityState::Unknown => label.push_str(" · ⚙"), + } + } + + if matches!(filter, FilterMode::Available) { + label.push_str(" · available only"); } label @@ -7694,11 +7783,66 @@ impl ChatApp { result } + fn rebuild_annotated_models(&mut self) { + let mut annotated = Vec::with_capacity(self.models.len()); + for model in &self.models { + let provider_id = model.provider.clone(); + let scope = Self::model_scope_from_capabilities(model); + let scope_state = self.provider_scope_state(provider_id.as_str(), &scope); + let provider_status = Self::provider_status_from_state(scope_state); + let provider_type = Self::infer_provider_type(&provider_id, &scope); + + let mut provider_metadata = ProviderMetadata::new( + provider_id.clone(), + Self::provider_display_name(&provider_id), + provider_type, + matches!(provider_type, ProviderType::Cloud), + ); + provider_metadata.metadata.insert( + "scope".to_string(), + Value::String(Self::scope_display_name(&scope)), + ); + + let mut model_metadata = HashMap::new(); + if !model.name.trim().is_empty() && model.name != model.id { + model_metadata.insert( + "display_name".to_string(), + Value::String(model.name.clone()), + ); + } + if let Some(ctx) = model.context_window { + model_metadata.insert("context_window".to_string(), Value::from(ctx)); + } + + let provider_model = ProviderModelInfo { + name: model.id.clone(), + size_bytes: None, + capabilities: model.capabilities.clone(), + description: model.description.clone(), + provider: provider_metadata, + metadata: model_metadata, + }; + + annotated.push(AnnotatedModelInfo { + provider_id, + provider_status, + model: provider_model, + }); + } + + self.annotated_models = annotated; + } + fn rebuild_model_selector_items(&mut self) { let mut items = Vec::new(); if self.available_providers.is_empty() { - items.push(ModelSelectorItem::header("ollama_local", false)); + items.push(ModelSelectorItem::header( + "ollama_local", + false, + ProviderStatus::RequiresSetup, + ProviderType::Local, + )); self.model_selector_items = items; return; } @@ -7707,7 +7851,14 @@ impl ChatApp { for provider in &self.available_providers { let is_expanded = expanded.as_ref().map(|p| p == provider).unwrap_or(false); - items.push(ModelSelectorItem::header(provider.clone(), is_expanded)); + let provider_status = self.provider_overall_status(provider); + let provider_type = self.provider_type_for(provider); + items.push(ModelSelectorItem::header( + provider.clone(), + is_expanded, + provider_status, + provider_type, + )); if is_expanded { let relevant: Vec<(usize, &ModelInfo)> = self @@ -7736,6 +7887,10 @@ impl ChatApp { let mut rendered_body = false; for scope in scopes_to_render { + if !self.filter_allows_scope(&scope) { + continue; + } + rendered_scope = true; let entries = scoped.get(&scope).cloned().unwrap_or_default(); let deduped = @@ -7745,16 +7900,36 @@ impl ChatApp { .and_then(|map| map.get(&scope)) .cloned() .unwrap_or_default(); - let label = - Self::scope_header_label(provider, &scope, Some(status_entry.state)); + let label = Self::scope_header_label( + provider, + &scope, + Some(status_entry.state), + self.model_filter_mode, + ); items.push(ModelSelectorItem::scope( provider.clone(), label, scope.clone(), + status_entry.state, )); + let scope_allowed = self.filter_scope_allows_models(&scope, status_entry.state); + if deduped.is_empty() { + if !scope_allowed { + let message = self.scope_filter_message(&scope, status_entry.state); + if let Some(msg) = message { + rendered_body = true; + items.push(ModelSelectorItem::empty( + provider.clone(), + Some(msg), + Some(status_entry.state), + )); + } + continue; + } + let fallback_message = match status_entry.state { ModelAvailabilityState::Unavailable => { Some(format!("{} unavailable", Self::scope_display_name(&scope))) @@ -7768,7 +7943,24 @@ impl ChatApp { if let Some(message) = fallback_message { rendered_body = true; - items.push(ModelSelectorItem::empty(provider.clone(), Some(message))); + items.push(ModelSelectorItem::empty( + provider.clone(), + Some(message), + Some(status_entry.state), + )); + } + continue; + } + + if !scope_allowed { + let message = self.scope_filter_message(&scope, status_entry.state); + if let Some(msg) = message { + rendered_body = true; + items.push(ModelSelectorItem::empty( + provider.clone(), + Some(msg), + Some(status_entry.state), + )); } continue; } @@ -7780,7 +7972,7 @@ impl ChatApp { } if !rendered_scope || !rendered_body { - items.push(ModelSelectorItem::empty(provider.clone(), None)); + items.push(ModelSelectorItem::empty(provider.clone(), None, None)); } } } @@ -7789,6 +7981,131 @@ impl ChatApp { self.ensure_valid_model_selection(); } + fn provider_scope_state(&self, provider: &str, scope: &ModelScope) -> ModelAvailabilityState { + self.provider_scope_status + .get(provider) + .and_then(|map| map.get(scope)) + .map(|entry| entry.state) + .unwrap_or(ModelAvailabilityState::Unknown) + } + + fn provider_overall_status(&self, provider: &str) -> ProviderStatus { + if let Some(status_map) = self.provider_scope_status.get(provider) { + let mut saw_unknown = false; + for entry in status_map.values() { + match entry.state { + ModelAvailabilityState::Unavailable => return ProviderStatus::Unavailable, + ModelAvailabilityState::Unknown => saw_unknown = true, + ModelAvailabilityState::Available => {} + } + } + if saw_unknown { + ProviderStatus::RequiresSetup + } else { + ProviderStatus::Available + } + } else { + self.annotated_models + .iter() + .find(|m| m.provider_id == provider) + .map(|m| m.provider_status) + .unwrap_or(ProviderStatus::RequiresSetup) + } + } + + fn provider_type_for(&self, provider: &str) -> ProviderType { + self.annotated_models + .iter() + .find(|m| m.provider_id == provider) + .map(|m| m.model.provider.provider_type) + .unwrap_or_else(|| { + if provider.to_ascii_lowercase().contains("cloud") { + ProviderType::Cloud + } else { + ProviderType::Local + } + }) + } + + fn filter_allows_scope(&self, scope: &ModelScope) -> bool { + match self.model_filter_mode { + FilterMode::All => true, + FilterMode::LocalOnly => matches!(scope, ModelScope::Local), + FilterMode::CloudOnly => matches!(scope, ModelScope::Cloud), + FilterMode::Available => true, + } + } + + fn filter_scope_allows_models( + &self, + scope: &ModelScope, + status: ModelAvailabilityState, + ) -> bool { + match self.model_filter_mode { + FilterMode::Available => status == ModelAvailabilityState::Available, + FilterMode::LocalOnly => matches!(scope, ModelScope::Local), + FilterMode::CloudOnly => matches!(scope, ModelScope::Cloud), + FilterMode::All => true, + } + } + + fn scope_filter_message( + &self, + scope: &ModelScope, + status: ModelAvailabilityState, + ) -> Option { + match self.model_filter_mode { + FilterMode::Available => match status { + ModelAvailabilityState::Available => None, + ModelAvailabilityState::Unavailable => { + Some(format!("{} unavailable", Self::scope_display_name(scope))) + } + ModelAvailabilityState::Unknown => Some(format!( + "{} setup required", + Self::scope_display_name(scope) + )), + }, + FilterMode::LocalOnly | FilterMode::CloudOnly => { + if status == ModelAvailabilityState::Unavailable { + Some(format!("{} unavailable", Self::scope_display_name(scope))) + } else { + None + } + } + FilterMode::All => None, + } + } + + fn provider_display_name(provider: &str) -> String { + if provider.trim().is_empty() { + return "Provider".to_string(); + } + let normalized = provider.replace(['_', '-'], " "); + capitalize_first(normalized.as_str()) + } + + fn infer_provider_type(provider: &str, scope: &ModelScope) -> ProviderType { + match scope { + ModelScope::Local => ProviderType::Local, + ModelScope::Cloud => ProviderType::Cloud, + ModelScope::Other(_) => { + if provider.to_ascii_lowercase().contains("cloud") { + ProviderType::Cloud + } else { + ProviderType::Local + } + } + } + } + + fn provider_status_from_state(state: ModelAvailabilityState) -> ProviderStatus { + match state { + ModelAvailabilityState::Available => ProviderStatus::Available, + ModelAvailabilityState::Unavailable => ProviderStatus::Unavailable, + ModelAvailabilityState::Unknown => ProviderStatus::RequiresSetup, + } + } + fn first_model_item_index(&self) -> Option { self.model_selector_items .iter() @@ -7900,6 +8217,19 @@ impl ChatApp { true } + fn focus_first_available_model(&mut self) -> bool { + if self.model_selector_items.is_empty() { + return false; + } + + if let Some(idx) = self.first_model_item_index() { + self.set_selected_model_item(idx); + true + } else { + false + } + } + fn ensure_valid_model_selection(&mut self) { if self.model_selector_items.is_empty() { self.selected_model_item = None; @@ -8091,6 +8421,7 @@ impl ChatApp { self.models = all_models; self.provider_scope_status = scope_status; + self.rebuild_annotated_models(); self.model_info_panel.clear(); self.set_model_info_visible(false); self.populate_model_details_cache_from_session().await; @@ -8137,6 +8468,7 @@ impl ChatApp { self.models.len(), self.available_providers.len() ); + self.rebuild_model_selector_items(); self.update_command_palette_catalog(); @@ -8401,13 +8733,15 @@ impl ChatApp { Ok(()) } - async fn show_model_picker(&mut self) -> Result<()> { + async fn show_model_picker(&mut self, filter: FilterMode) -> Result<()> { self.refresh_models().await?; if self.models.is_empty() { return Ok(()); } + self.set_model_filter_mode(filter); + if self.available_providers.len() <= 1 { self.set_input_mode(InputMode::ModelSelection); self.ensure_valid_model_selection(); diff --git a/crates/owlen-tui/src/commands/mod.rs b/crates/owlen-tui/src/commands/mod.rs index e6fc6cc..4632cea 100644 --- a/crates/owlen-tui/src/commands/mod.rs +++ b/crates/owlen-tui/src/commands/mod.rs @@ -148,6 +148,10 @@ const COMMANDS: &[CommandSpec] = &[ keyword: "models --cloud", description: "Open model picker focused on cloud models", }, + CommandSpec { + keyword: "models --available", + description: "Open model picker showing available models", + }, CommandSpec { keyword: "new", description: "Start a new conversation", diff --git a/crates/owlen-tui/src/lib.rs b/crates/owlen-tui/src/lib.rs index 08fe107..e0ce4e8 100644 --- a/crates/owlen-tui/src/lib.rs +++ b/crates/owlen-tui/src/lib.rs @@ -27,6 +27,7 @@ pub mod state; pub mod toast; pub mod tui_controller; pub mod ui; +pub mod widgets; pub use chat_app::{ChatApp, SessionEvent}; pub use code_app::CodeApp; diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 7bde330..ed257ce 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -11,19 +11,16 @@ use tui_textarea::TextArea; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; -use crate::chat_app::{ - ChatApp, HELP_TAB_COUNT, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext, ModelScope, - ModelSelectorItemKind, -}; +use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext}; use crate::highlight; use crate::state::{ CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId, RepoSearchRowKind, SplitAxis, VisibleFileEntry, }; use crate::toast::{Toast, ToastLevel}; -use owlen_core::model::DetailedModelInfo; +use crate::widgets::model_picker::render_model_picker; use owlen_core::theme::Theme; -use owlen_core::types::{ModelInfo, Role}; +use owlen_core::types::Role; use owlen_core::ui::{FocusedPanel, InputMode, RoleLabelDisplay}; use textwrap::wrap; @@ -337,7 +334,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) { } else { match app.mode() { InputMode::ProviderSelection => render_provider_selector(frame, app), - InputMode::ModelSelection => render_model_selector(frame, app), + InputMode::ModelSelection => render_model_picker(frame, app), InputMode::Help => render_help(frame, app), InputMode::SessionBrowser => render_session_browser(frame, app), InputMode::ThemeBrowser => render_theme_browser(frame, app), @@ -2653,429 +2650,6 @@ fn render_provider_selector(frame: &mut Frame<'_>, app: &ChatApp) { frame.render_stateful_widget(list, area, &mut state); } -fn model_badge_icons(model: &ModelInfo) -> Vec<&'static str> { - let mut badges = Vec::new(); - - if model.supports_tools { - badges.push("🔧"); - } - - if model_has_feature(model, &["think", "reason"]) { - badges.push("🧠"); - } - - if model_has_feature(model, &["vision", "multimodal", "image"]) { - badges.push("👁️"); - } - - if model_has_feature(model, &["audio", "speech", "voice"]) { - badges.push("🎧"); - } - - badges -} - -fn model_has_feature(model: &ModelInfo, keywords: &[&str]) -> bool { - let name_lower = model.name.to_ascii_lowercase(); - if keywords.iter().any(|kw| name_lower.contains(kw)) { - return true; - } - - if let Some(description) = &model.description { - let description_lower = description.to_ascii_lowercase(); - if keywords.iter().any(|kw| description_lower.contains(kw)) { - return true; - } - } - - model.capabilities.iter().any(|cap| { - let lower = cap.to_ascii_lowercase(); - keywords.iter().any(|kw| lower.contains(kw)) - }) -} - -fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) { - let theme = app.theme(); - let area = frame.area(); - if area.width == 0 || area.height == 0 { - return; - } - - let selector_items = app.model_selector_items(); - if selector_items.is_empty() { - return; - } - - let max_width: u16 = 80; - let min_width: u16 = 50; - let mut width = area.width.min(max_width); - if area.width >= min_width { - width = width.max(min_width); - } - width = width.max(1); - - let mut height = (selector_items.len().clamp(1, 10) as u16) * 3 + 6; - height = height.clamp(6, area.height); - - let x = area.x + (area.width.saturating_sub(width)) / 2; - let mut y = area.y + (area.height.saturating_sub(height)) / 3; - if y < area.y { - y = area.y; - } - - let popup_area = Rect::new(x, y, width, height); - frame.render_widget(Clear, popup_area); - - let title_line = Line::from(vec![ - Span::styled( - " Model Selector ", - Style::default().fg(theme.info).add_modifier(Modifier::BOLD), - ), - Span::styled( - format!("· Provider: {}", app.selected_provider), - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::DIM), - ), - ]); - - let block = Block::default() - .title(title_line) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.info)) - .style(Style::default().bg(theme.background).fg(theme.text)); - - let inner = block.inner(popup_area); - frame.render_widget(block, popup_area); - if inner.width == 0 || inner.height == 0 { - return; - } - let highlight_symbol = " "; - let highlight_width = UnicodeWidthStr::width(highlight_symbol); - let max_line_width = inner.width.saturating_sub(highlight_width as u16).max(1) as usize; - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(4), Constraint::Length(2)]) - .split(inner); - - let active_model_id = app.selected_model(); - - let mut items: Vec = Vec::new(); - for item in selector_items.iter() { - match item.kind() { - ModelSelectorItemKind::Header { provider, expanded } => { - let marker = if *expanded { "▼" } else { "▶" }; - let line = clip_line_to_width( - Line::from(vec![ - Span::styled( - marker, - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - provider.clone(), - Style::default() - .fg(theme.mode_command) - .add_modifier(Modifier::BOLD), - ), - ]), - max_line_width, - ); - items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); - } - ModelSelectorItemKind::Scope { label, scope, .. } => { - let (fg, modifier) = match scope { - ModelScope::Local => (theme.mode_normal, Modifier::BOLD), - ModelScope::Cloud => (theme.mode_help, Modifier::BOLD), - ModelScope::Other(_) => (theme.placeholder, Modifier::ITALIC), - }; - let style = Style::default().fg(fg).add_modifier(modifier); - let line = clip_line_to_width( - Line::from(Span::styled(format!(" {label}"), style)), - max_line_width, - ); - items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); - } - ModelSelectorItemKind::Model { model_index, .. } => { - let mut lines: Vec> = Vec::new(); - if let Some(model) = app.model_info_by_index(*model_index) { - let badges = model_badge_icons(model); - let detail = app.cached_model_detail(&model.id); - let (title, metadata) = build_model_selector_label( - model, - detail, - &badges, - model.id == active_model_id, - ); - lines.push(clip_line_to_width( - Line::from(Span::styled(title, Style::default().fg(theme.text))), - max_line_width, - )); - if let Some(meta) = metadata { - lines.push(clip_line_to_width( - Line::from(Span::styled( - meta, - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::DIM), - )), - max_line_width, - )); - } - } else { - lines.push(clip_line_to_width( - Line::from(Span::styled( - " ", - Style::default().fg(theme.error), - )), - max_line_width, - )); - } - items.push(ListItem::new(lines).style(Style::default().bg(theme.background))); - } - ModelSelectorItemKind::Empty { provider, message } => { - let text = message - .as_ref() - .map(|msg| format!(" {msg}")) - .unwrap_or_else(|| format!(" (no models configured for {provider})")); - let is_unavailable = message - .as_ref() - .map(|msg| msg.to_ascii_lowercase().contains("unavailable")) - .unwrap_or(false); - - let style = if is_unavailable { - Style::default() - .fg(theme.error) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::DIM | Modifier::ITALIC) - }; - - let line = - clip_line_to_width(Line::from(Span::styled(text, style)), max_line_width); - items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); - } - } - } - - let highlight_style = Style::default() - .bg(theme.selection_bg) - .fg(theme.selection_fg) - .add_modifier(Modifier::BOLD); - - let mut state = ListState::default(); - state.select(app.selected_model_item()); - - let list = List::new(items) - .highlight_style(highlight_style) - .highlight_symbol(" ") - .style(Style::default().bg(theme.background).fg(theme.text)); - - frame.render_stateful_widget(list, layout[0], &mut state); - - let footer = Paragraph::new(Line::from(Span::styled( - "Enter: select · Space: toggle provider · ←/→ collapse/expand · Esc: cancel", - Style::default().fg(theme.placeholder), - ))) - .alignment(Alignment::Center) - .style(Style::default().bg(theme.background).fg(theme.placeholder)); - frame.render_widget(footer, layout[1]); -} - -fn clip_line_to_width(line: Line<'_>, max_width: usize) -> Line<'static> { - if max_width == 0 { - return Line::from(Vec::>::new()); - } - - let mut used = 0usize; - let mut clipped: Vec> = Vec::new(); - - for span in line.spans { - if used >= max_width { - break; - } - let text = span.content.to_string(); - let span_width = UnicodeWidthStr::width(text.as_str()); - if used + span_width <= max_width { - if !text.is_empty() { - clipped.push(Span::styled(text, span.style)); - } - used += span_width; - } else { - let mut buf = String::new(); - for grapheme in span.content.as_ref().graphemes(true) { - let g_width = UnicodeWidthStr::width(grapheme); - if g_width == 0 { - buf.push_str(grapheme); - continue; - } - if used + g_width > max_width { - break; - } - buf.push_str(grapheme); - used += g_width; - } - if !buf.is_empty() { - clipped.push(Span::styled(buf, span.style)); - } - break; - } - } - - Line::from(clipped) -} - -fn build_model_selector_label( - model: &ModelInfo, - detail: Option<&DetailedModelInfo>, - badges: &[&'static str], - is_current: bool, -) -> (String, Option) { - let scope = ChatApp::model_scope_from_capabilities(model); - let scope_icon = ChatApp::scope_icon(&scope); - let scope_label = ChatApp::scope_display_name(&scope); - let mut display_name = if model.name.trim().is_empty() { - model.id.clone() - } else { - model.name.clone() - }; - - if !display_name.eq_ignore_ascii_case(&model.id) { - display_name.push_str(&format!(" · {}", model.id)); - } - - let mut title = format!(" {} {}", scope_icon, display_name); - if !badges.is_empty() { - title.push(' '); - title.push_str(&badges.join(" ")); - } - if is_current { - title.push_str(" ✓"); - } - - let mut meta_parts: Vec = Vec::new(); - let mut seen_meta: HashSet = HashSet::new(); - let mut push_meta = |value: String| { - let trimmed = value.trim(); - if trimmed.is_empty() { - return; - } - let key = trimmed.to_ascii_lowercase(); - if seen_meta.insert(key) { - meta_parts.push(trimmed.to_string()); - } - }; - - if !scope_label.eq_ignore_ascii_case("unknown") { - push_meta(scope_label.clone()); - } - - if let Some(detail) = detail { - if let Some(ctx) = detail.context_length { - push_meta(format!("max tokens {}", ctx)); - } else if let Some(ctx) = model.context_window { - push_meta(format!("max tokens {}", ctx)); - } - - if let Some(parameters) = detail - .parameter_size - .as_ref() - .or(detail.parameters.as_ref()) - && !parameters.trim().is_empty() - { - push_meta(parameters.trim().to_string()); - } - - if let Some(arch) = detail.architecture.as_deref() { - let trimmed = arch.trim(); - if !trimmed.is_empty() { - push_meta(format!("arch {}", trimmed)); - } - } else if let Some(family) = detail.family.as_deref() { - let trimmed = family.trim(); - if !trimmed.is_empty() { - push_meta(format!("family {}", trimmed)); - } - } else if !detail.families.is_empty() { - let families = detail - .families - .iter() - .map(|f| f.trim()) - .filter(|f| !f.is_empty()) - .take(2) - .collect::>() - .join("/"); - if !families.is_empty() { - push_meta(format!("family {}", families)); - } - } - - if let Some(embedding) = detail.embedding_length { - push_meta(format!("embedding {}", embedding)); - } - - if let Some(size) = detail.size { - push_meta(format_short_size(size)); - } - - if let Some(quant) = detail - .quantization - .as_ref() - .filter(|q| !q.trim().is_empty()) - { - push_meta(format!("quant {}", quant.trim())); - } - } else if let Some(ctx) = model.context_window { - push_meta(format!("max tokens {}", ctx)); - } - - if let Some(desc) = model.description.as_deref() { - let trimmed = desc.trim(); - if !trimmed.is_empty() { - meta_parts.push(ellipsize(trimmed, 80)); - } - } - - let metadata = if meta_parts.is_empty() { - None - } else { - Some(format!(" {}", meta_parts.join(" • "))) - }; - - (title, metadata) -} - -fn ellipsize(text: &str, max_chars: usize) -> String { - if text.chars().count() <= max_chars { - return text.to_string(); - } - - let target = max_chars.saturating_sub(1).max(1); - let mut truncated = String::new(); - for ch in text.chars().take(target) { - truncated.push(ch); - } - truncated.push('…'); - truncated -} - -fn format_short_size(bytes: u64) -> String { - if bytes >= 1_000_000_000 { - format!("{:.1} GB", bytes as f64 / 1_000_000_000_f64) - } else if bytes >= 1_000_000 { - format!("{:.1} MB", bytes as f64 / 1_000_000_f64) - } else if bytes >= 1_000 { - format!("{:.1} KB", bytes as f64 / 1_000_f64) - } else { - format!("{} B", bytes) - } -} - fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); @@ -3232,67 +2806,6 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) { frame.render_widget(paragraph, area); } -#[cfg(test)] -mod tests { - use super::*; - - fn model_with(capabilities: Vec<&str>, description: Option<&str>) -> ModelInfo { - ModelInfo { - id: "model".into(), - name: "model".into(), - description: description.map(|s| s.to_string()), - provider: "test".into(), - context_window: None, - capabilities: capabilities.into_iter().map(|s| s.to_string()).collect(), - supports_tools: false, - } - } - - #[test] - fn badges_include_tool_icon() { - let model = ModelInfo { - id: "tool-model".into(), - name: "tool-model".into(), - description: None, - provider: "test".into(), - context_window: None, - capabilities: vec![], - supports_tools: true, - }; - - assert!(model_badge_icons(&model).contains(&"🔧")); - } - - #[test] - fn badges_detect_thinking_capability() { - let model = model_with(vec!["Thinking"], None); - let icons = model_badge_icons(&model); - assert!(icons.contains(&"🧠")); - } - - #[test] - fn badges_detect_vision_from_description() { - let model = model_with(vec!["chat"], Some("Supports multimodal vision")); - let icons = model_badge_icons(&model); - assert!(icons.contains(&"👁️")); - } - - #[test] - fn badges_detect_audio_from_name() { - let model = ModelInfo { - id: "voice-specialist".into(), - name: "Voice-Specialist".into(), - description: None, - provider: "test".into(), - context_window: None, - capabilities: vec![], - supports_tools: false, - }; - let icons = model_badge_icons(&model); - assert!(icons.contains(&"🎧")); - } -} - fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let theme = app.theme(); let config = app.config(); diff --git a/crates/owlen-tui/src/widgets/mod.rs b/crates/owlen-tui/src/widgets/mod.rs new file mode 100644 index 0000000..e267d4c --- /dev/null +++ b/crates/owlen-tui/src/widgets/mod.rs @@ -0,0 +1,3 @@ +//! Reusable widgets composed specifically for the Owlen TUI. + +pub mod model_picker; diff --git a/crates/owlen-tui/src/widgets/model_picker.rs b/crates/owlen-tui/src/widgets/model_picker.rs new file mode 100644 index 0000000..030ba0c --- /dev/null +++ b/crates/owlen-tui/src/widgets/model_picker.rs @@ -0,0 +1,614 @@ +use std::collections::HashSet; + +use owlen_core::provider::{AnnotatedModelInfo, ProviderStatus, ProviderType}; +use owlen_core::types::ModelInfo; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, +}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use crate::chat_app::{ChatApp, ModelAvailabilityState, ModelScope, ModelSelectorItemKind}; + +/// Filtering modes for the model picker popup. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum FilterMode { + #[default] + All, + LocalOnly, + CloudOnly, + Available, +} + +pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { + let theme = app.theme(); + let area = frame.area(); + if area.width == 0 || area.height == 0 { + return; + } + + let selector_items = app.model_selector_items(); + if selector_items.is_empty() { + return; + } + + let max_width: u16 = 80; + let min_width: u16 = 50; + let mut width = area.width.min(max_width); + if area.width >= min_width { + width = width.max(min_width); + } + width = width.max(1); + + let mut height = (selector_items.len().clamp(1, 10) as u16) * 3 + 6; + height = height.clamp(6, area.height); + + let x = area.x + (area.width.saturating_sub(width)) / 2; + let mut y = area.y + (area.height.saturating_sub(height)) / 3; + if y < area.y { + y = area.y; + } + + let popup_area = Rect::new(x, y, width, height); + frame.render_widget(Clear, popup_area); + + let mut title_spans = vec![ + Span::styled( + " Model Selector ", + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("· Provider: {}", app.selected_provider), + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + ), + ]; + if app.model_filter_mode() != FilterMode::All { + title_spans.push(Span::raw(" ")); + title_spans.push(filter_badge(app.model_filter_mode(), theme)); + } + + let block = Block::default() + .title(Line::from(title_spans)) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.info)) + .style(Style::default().bg(theme.background).fg(theme.text)); + + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + if inner.width == 0 || inner.height == 0 { + return; + } + let highlight_symbol = " "; + let highlight_width = UnicodeWidthStr::width(highlight_symbol); + let max_line_width = inner.width.saturating_sub(highlight_width as u16).max(1) as usize; + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(4), Constraint::Length(2)]) + .split(inner); + + let active_model_id = app.selected_model(); + let annotated = app.annotated_models(); + + let mut items: Vec = Vec::new(); + for item in selector_items.iter() { + match item.kind() { + ModelSelectorItemKind::Header { + provider, + expanded, + status, + provider_type, + } => { + let mut spans = Vec::new(); + spans.push(status_icon(*status, theme)); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + provider.clone(), + Style::default() + .fg(theme.mode_command) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + spans.push(provider_type_badge(*provider_type, theme)); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + if *expanded { "▼" } else { "▶" }, + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + )); + + let line = clip_line_to_width(Line::from(spans), max_line_width); + items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); + } + ModelSelectorItemKind::Scope { label, status, .. } => { + let (style, icon) = scope_status_style(*status, theme); + let line = clip_line_to_width( + Line::from(vec![ + Span::styled(icon, style), + Span::raw(" "), + Span::styled(label.clone(), style), + ]), + max_line_width, + ); + items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); + } + ModelSelectorItemKind::Model { model_index, .. } => { + let mut lines: Vec> = Vec::new(); + if let Some(model) = app.model_info_by_index(*model_index) { + let badges = model_badge_icons(model); + let detail = app.cached_model_detail(&model.id); + let annotated_model = annotated.get(*model_index); + let (title, metadata) = build_model_selector_lines( + theme, + model, + annotated_model, + &badges, + detail, + model.id == active_model_id, + ); + lines.push(clip_line_to_width(title, max_line_width)); + if let Some(meta) = metadata { + lines.push(clip_line_to_width(meta, max_line_width)); + } + } else { + lines.push(clip_line_to_width( + Line::from(Span::styled( + " ", + Style::default().fg(theme.error), + )), + max_line_width, + )); + } + items.push(ListItem::new(lines).style(Style::default().bg(theme.background))); + } + ModelSelectorItemKind::Empty { + message, status, .. + } => { + let (style, icon) = empty_status_style(*status, theme); + let msg = message + .as_ref() + .map(|msg| msg.as_str()) + .unwrap_or("(no models configured)"); + let line = clip_line_to_width( + Line::from(vec![ + Span::styled(icon, style), + Span::raw(" "), + Span::styled(format!(" {}", msg), style), + ]), + max_line_width, + ); + items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); + } + } + } + + let list = List::new(items) + .highlight_style( + Style::default() + .bg(theme.selection_bg) + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(" "); + + let mut state = ListState::default(); + state.select(app.selected_model_item); + frame.render_stateful_widget(list, layout[0], &mut state); + + let footer = Paragraph::new(Line::from(Span::styled( + "Enter: select · Space: toggle provider · ←/→ collapse/expand · Esc: cancel", + Style::default().fg(theme.placeholder), + ))) + .alignment(ratatui::layout::Alignment::Center) + .style(Style::default().bg(theme.background).fg(theme.placeholder)); + frame.render_widget(footer, layout[1]); +} + +fn status_icon(status: ProviderStatus, theme: &owlen_core::theme::Theme) -> Span<'static> { + let (symbol, color) = match status { + ProviderStatus::Available => ("✓", theme.info), + ProviderStatus::Unavailable => ("✗", theme.error), + ProviderStatus::RequiresSetup => ("⚙", Color::Yellow), + }; + Span::styled( + symbol, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ) +} + +fn provider_type_badge( + provider_type: ProviderType, + theme: &owlen_core::theme::Theme, +) -> Span<'static> { + let (label, color) = match provider_type { + ProviderType::Local => ("[Local]", theme.mode_normal), + ProviderType::Cloud => ("[Cloud]", theme.mode_help), + }; + Span::styled( + label, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ) +} + +fn scope_status_style( + status: ModelAvailabilityState, + theme: &owlen_core::theme::Theme, +) -> (Style, &'static str) { + match status { + ModelAvailabilityState::Available => ( + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + "✓", + ), + ModelAvailabilityState::Unavailable => ( + Style::default() + .fg(theme.error) + .add_modifier(Modifier::BOLD), + "✗", + ), + ModelAvailabilityState::Unknown => ( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + "⚙", + ), + } +} + +fn empty_status_style( + status: Option, + theme: &owlen_core::theme::Theme, +) -> (Style, &'static str) { + match status.unwrap_or(ModelAvailabilityState::Unknown) { + ModelAvailabilityState::Available => ( + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + "•", + ), + ModelAvailabilityState::Unavailable => ( + Style::default() + .fg(theme.error) + .add_modifier(Modifier::BOLD), + "✗", + ), + ModelAvailabilityState::Unknown => ( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + "⚙", + ), + } +} + +fn filter_badge(mode: FilterMode, theme: &owlen_core::theme::Theme) -> Span<'static> { + let label = match mode { + FilterMode::All => return Span::raw(""), + FilterMode::LocalOnly => "Local", + FilterMode::CloudOnly => "Cloud", + FilterMode::Available => "Available", + }; + Span::styled( + format!("[{label}]"), + Style::default() + .fg(theme.mode_provider_selection) + .add_modifier(Modifier::BOLD), + ) +} + +fn build_model_selector_lines( + theme: &owlen_core::theme::Theme, + model: &ModelInfo, + annotated: Option<&AnnotatedModelInfo>, + badges: &[&'static str], + detail: Option<&owlen_core::model::DetailedModelInfo>, + is_current: bool, +) -> (Line<'static>, Option>) { + let provider_type = annotated + .map(|info| info.model.provider.provider_type) + .unwrap_or_else(|| match ChatApp::model_scope_from_capabilities(model) { + ModelScope::Cloud => ProviderType::Cloud, + ModelScope::Local => ProviderType::Local, + ModelScope::Other(_) => { + if model.provider.to_ascii_lowercase().contains("cloud") { + ProviderType::Cloud + } else { + ProviderType::Local + } + } + }); + + let mut spans: Vec> = Vec::new(); + spans.push(Span::raw(" ")); + spans.push(provider_type_badge(provider_type, theme)); + spans.push(Span::raw(" ")); + + let mut display_name = if model.name.trim().is_empty() { + model.id.clone() + } else { + model.name.clone() + }; + if !display_name.eq_ignore_ascii_case(&model.id) { + display_name.push_str(&format!(" · {}", model.id)); + } + + spans.push(Span::styled( + display_name, + Style::default().fg(theme.text).add_modifier(Modifier::BOLD), + )); + + if !badges.is_empty() { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + badges.join(" "), + Style::default().fg(theme.placeholder), + )); + } + + if is_current { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + "✓", + Style::default().fg(theme.info).add_modifier(Modifier::BOLD), + )); + } + + let mut meta_parts: Vec = Vec::new(); + let mut seen_meta: HashSet = HashSet::new(); + let mut push_meta = |value: String| { + let trimmed = value.trim(); + if trimmed.is_empty() { + return; + } + let key = trimmed.to_ascii_lowercase(); + if seen_meta.insert(key) { + meta_parts.push(trimmed.to_string()); + } + }; + + let scope = ChatApp::model_scope_from_capabilities(model); + let scope_label = ChatApp::scope_display_name(&scope); + if !scope_label.eq_ignore_ascii_case("unknown") { + push_meta(scope_label.clone()); + } + + if let Some(detail) = detail { + if let Some(ctx) = detail.context_length { + push_meta(format!("max tokens {}", ctx)); + } else if let Some(ctx) = model.context_window { + push_meta(format!("max tokens {}", ctx)); + } + + if let Some(parameters) = detail + .parameter_size + .as_ref() + .or(detail.parameters.as_ref()) + && !parameters.trim().is_empty() + { + push_meta(parameters.trim().to_string()); + } + + if let Some(arch) = detail.architecture.as_deref() { + let trimmed = arch.trim(); + if !trimmed.is_empty() { + push_meta(format!("arch {}", trimmed)); + } + } else if let Some(family) = detail.family.as_deref() { + let trimmed = family.trim(); + if !trimmed.is_empty() { + push_meta(format!("family {}", trimmed)); + } + } else if !detail.families.is_empty() { + let families = detail + .families + .iter() + .map(|f| f.trim()) + .filter(|f| !f.is_empty()) + .take(2) + .collect::>() + .join("/"); + if !families.is_empty() { + push_meta(format!("family {}", families)); + } + } + + if let Some(embedding) = detail.embedding_length { + push_meta(format!("embedding {}", embedding)); + } + + if let Some(size) = detail.size { + push_meta(format_short_size(size)); + } + + if let Some(quant) = detail + .quantization + .as_ref() + .filter(|q| !q.trim().is_empty()) + { + push_meta(format!("quant {}", quant.trim())); + } + } else if let Some(ctx) = model.context_window { + push_meta(format!("max tokens {}", ctx)); + } + + if let Some(desc) = model.description.as_deref() { + let trimmed = desc.trim(); + if !trimmed.is_empty() { + meta_parts.push(ellipsize(trimmed, 80)); + } + } + + let metadata = if meta_parts.is_empty() { + None + } else { + Some(Line::from(vec![Span::styled( + format!(" {}", meta_parts.join(" • ")), + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + )])) + }; + + (Line::from(spans), metadata) +} + +fn clip_line_to_width(line: Line<'_>, max_width: usize) -> Line<'static> { + if max_width == 0 { + return Line::from(Vec::>::new()); + } + + let mut used = 0usize; + let mut clipped: Vec> = Vec::new(); + + for span in line.spans { + if used >= max_width { + break; + } + let text = span.content.to_string(); + let span_width = UnicodeWidthStr::width(text.as_str()); + if used + span_width <= max_width { + if !text.is_empty() { + clipped.push(Span::styled(text, span.style)); + } + used += span_width; + } else { + let mut buf = String::new(); + for grapheme in span.content.as_ref().graphemes(true) { + let g_width = UnicodeWidthStr::width(grapheme); + if g_width == 0 { + buf.push_str(grapheme); + continue; + } + if used + g_width > max_width { + break; + } + buf.push_str(grapheme); + used += g_width; + } + if !buf.is_empty() { + clipped.push(Span::styled(buf, span.style)); + } + break; + } + } + + Line::from(clipped) +} + +fn ellipsize(text: &str, max_chars: usize) -> String { + if text.chars().count() <= max_chars { + return text.to_string(); + } + + let target = max_chars.saturating_sub(1).max(1); + let mut truncated = String::new(); + for ch in text.chars().take(target) { + truncated.push(ch); + } + truncated.push('…'); + truncated +} + +fn model_badge_icons(model: &ModelInfo) -> Vec<&'static str> { + let mut badges = Vec::new(); + + if model.supports_tools { + badges.push("🔧"); + } + + if model_has_feature(model, &["think", "reason"]) { + badges.push("🧠"); + } + + if model_has_feature(model, &["vision", "multimodal", "image"]) { + badges.push("👁️"); + } + + if model_has_feature(model, &["audio", "speech", "voice"]) { + badges.push("🎧"); + } + + badges +} + +fn model_has_feature(model: &ModelInfo, keywords: &[&str]) -> bool { + let name_lower = model.name.to_ascii_lowercase(); + if keywords.iter().any(|kw| name_lower.contains(kw)) { + return true; + } + + if let Some(description) = &model.description { + let description_lower = description.to_ascii_lowercase(); + if keywords.iter().any(|kw| description_lower.contains(kw)) { + return true; + } + } + + keywords + .iter() + .any(|kw| model.provider.to_ascii_lowercase().contains(kw)) +} + +fn format_short_size(bytes: u64) -> String { + if bytes >= 1_000_000_000 { + format!("{:.1} GB", bytes as f64 / 1_000_000_000_f64) + } else if bytes >= 1_000_000 { + format!("{:.1} MB", bytes as f64 / 1_000_000_f64) + } else if bytes >= 1_000 { + format!("{:.1} KB", bytes as f64 / 1_000_f64) + } else { + format!("{} B", bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use owlen_core::types::ModelInfo; + + fn model_with(capabilities: Vec<&str>, description: Option<&str>) -> ModelInfo { + ModelInfo { + id: "model".into(), + name: "model".into(), + description: description.map(|s| s.to_string()), + provider: "test".into(), + context_window: None, + capabilities: capabilities.into_iter().map(|s| s.to_string()).collect(), + supports_tools: false, + } + } + + #[test] + fn model_badges_recognize_thinking_capability() { + let model = model_with(vec!["think"], None); + assert!(model_badge_icons(&model).contains(&"🧠")); + } + + #[test] + fn model_badges_detect_tool_support() { + let mut model = model_with(vec![], None); + model.supports_tools = true; + let icons = model_badge_icons(&model); + assert!(icons.contains(&"🔧")); + } + + #[test] + fn model_badges_detect_vision_capability() { + let model = model_with(vec![], Some("Supports vision tasks")); + let icons = model_badge_icons(&model); + assert!(icons.contains(&"👁️")); + } + + #[test] + fn model_badges_detect_audio_capability() { + let model = model_with(vec!["audio"], None); + let icons = model_badge_icons(&model); + assert!(icons.contains(&"🎧")); + } +}