refactor(tui): extract model selector UI into dedicated widget module

Added `widgets::model_picker` containing the full model picker rendering logic and moved related helper functions there. Updated `ui.rs` to use `render_model_picker` and removed the now‑duplicate model selector implementation. This cleanly separates UI concerns and improves code reuse.
This commit is contained in:
2025-10-16 21:39:50 +02:00
parent dc0fee2ee3
commit 7effade1d3
6 changed files with 986 additions and 517 deletions

View File

@@ -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<String>,
status: Option<ModelAvailabilityState>,
},
}
impl ModelSelectorItem {
fn header(provider: impl Into<String>, expanded: bool) -> Self {
fn header(
provider: impl Into<String>,
expanded: bool,
status: ProviderStatus,
provider_type: ProviderType,
) -> Self {
Self {
kind: ModelSelectorItemKind::Header {
provider: provider.into(),
expanded,
status,
provider_type,
},
}
}
fn scope(provider: impl Into<String>, label: impl Into<String>, scope: ModelScope) -> Self {
fn scope(
provider: impl Into<String>,
label: impl Into<String>,
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<String>, message: Option<String>) -> Self {
fn empty(
provider: impl Into<String>,
message: Option<String>,
status: Option<ModelAvailabilityState>,
) -> Self {
Self {
kind: ModelSelectorItemKind::Empty {
provider: provider.into(),
message,
status,
},
}
}
@@ -250,13 +277,15 @@ pub struct ChatApp {
mode_flash_until: Option<Instant>,
pub status: String,
pub error: Option<String>,
models: Vec<ModelInfo>, // All models fetched
models: Vec<ModelInfo>, // All models fetched
annotated_models: Vec<AnnotatedModelInfo>, // Models annotated with provider metadata
provider_scope_status: HashMap<String, ProviderScopeStatus>,
pub available_providers: Vec<String>, // 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<usize>, // Index into the flattened model selector list
model_selector_items: Vec<ModelSelectorItem>, // 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<String, DetailedModelInfo>, // 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<usize> {
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<ModelAvailabilityState>,
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<String> {
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<usize> {
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();

View File

@@ -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",

View File

@@ -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;

View File

@@ -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<ListItem> = 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<Line<'static>> = 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(
" <model unavailable>",
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::<Span<'static>>::new());
}
let mut used = 0usize;
let mut clipped: Vec<Span<'static>> = 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<String>) {
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<String> = Vec::new();
let mut seen_meta: HashSet<String> = 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::<Vec<_>>()
.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();

View File

@@ -0,0 +1,3 @@
//! Reusable widgets composed specifically for the Owlen TUI.
pub mod model_picker;

View File

@@ -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<ListItem> = 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<Line<'static>> = 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(
" <model unavailable>",
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<ModelAvailabilityState>,
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<Line<'static>>) {
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<Span<'static>> = 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<String> = Vec::new();
let mut seen_meta: HashSet<String> = 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::<Vec<_>>()
.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::<Span<'static>>::new());
}
let mut used = 0usize;
let mut clipped: Vec<Span<'static>> = 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(&"🎧"));
}
}