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:
@@ -3,6 +3,10 @@ use chrono::{DateTime, Local, Utc};
|
|||||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||||
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
||||||
use owlen_core::mcp::{McpToolDescriptor, McpToolResponse};
|
use owlen_core::mcp::{McpToolDescriptor, McpToolResponse};
|
||||||
|
use owlen_core::provider::{
|
||||||
|
AnnotatedModelInfo, ModelInfo as ProviderModelInfo, ProviderMetadata, ProviderStatus,
|
||||||
|
ProviderType,
|
||||||
|
};
|
||||||
use owlen_core::{
|
use owlen_core::{
|
||||||
Provider, ProviderConfig,
|
Provider, ProviderConfig,
|
||||||
config::McpResourceConfig,
|
config::McpResourceConfig,
|
||||||
@@ -40,6 +44,7 @@ use crate::state::{
|
|||||||
};
|
};
|
||||||
use crate::toast::{Toast, ToastLevel, ToastManager};
|
use crate::toast::{Toast, ToastLevel, ToastManager};
|
||||||
use crate::ui::format_tool_output;
|
use crate::ui::format_tool_output;
|
||||||
|
use crate::widgets::model_picker::FilterMode;
|
||||||
use crate::{commands, highlight};
|
use crate::{commands, highlight};
|
||||||
use owlen_core::config::{
|
use owlen_core::config::{
|
||||||
OLLAMA_CLOUD_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
|
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 {
|
Header {
|
||||||
provider: String,
|
provider: String,
|
||||||
expanded: bool,
|
expanded: bool,
|
||||||
|
status: ProviderStatus,
|
||||||
|
provider_type: ProviderType,
|
||||||
},
|
},
|
||||||
Scope {
|
Scope {
|
||||||
provider: String,
|
provider: String,
|
||||||
label: String,
|
label: String,
|
||||||
scope: ModelScope,
|
scope: ModelScope,
|
||||||
|
status: ModelAvailabilityState,
|
||||||
},
|
},
|
||||||
Model {
|
Model {
|
||||||
provider: String,
|
provider: String,
|
||||||
@@ -115,25 +123,39 @@ pub(crate) enum ModelSelectorItemKind {
|
|||||||
Empty {
|
Empty {
|
||||||
provider: String,
|
provider: String,
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
|
status: Option<ModelAvailabilityState>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModelSelectorItem {
|
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 {
|
Self {
|
||||||
kind: ModelSelectorItemKind::Header {
|
kind: ModelSelectorItemKind::Header {
|
||||||
provider: provider.into(),
|
provider: provider.into(),
|
||||||
expanded,
|
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 {
|
Self {
|
||||||
kind: ModelSelectorItemKind::Scope {
|
kind: ModelSelectorItemKind::Scope {
|
||||||
provider: provider.into(),
|
provider: provider.into(),
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
scope,
|
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 {
|
Self {
|
||||||
kind: ModelSelectorItemKind::Empty {
|
kind: ModelSelectorItemKind::Empty {
|
||||||
provider: provider.into(),
|
provider: provider.into(),
|
||||||
message,
|
message,
|
||||||
|
status,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,12 +278,14 @@ pub struct ChatApp {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub error: Option<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>,
|
provider_scope_status: HashMap<String, ProviderScopeStatus>,
|
||||||
pub available_providers: Vec<String>, // Unique providers from models
|
pub available_providers: Vec<String>, // Unique providers from models
|
||||||
pub selected_provider: String, // The currently selected provider
|
pub selected_provider: String, // The currently selected provider
|
||||||
pub selected_provider_index: usize, // Index into the available_providers list
|
pub selected_provider_index: usize, // Index into the available_providers list
|
||||||
pub selected_model_item: Option<usize>, // Index into the flattened model selector 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_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_info_panel: ModelInfoPanel, // Dedicated model information viewer
|
||||||
model_details_cache: HashMap<String, DetailedModelInfo>, // Cached detailed metadata per model
|
model_details_cache: HashMap<String, DetailedModelInfo>, // Cached detailed metadata per model
|
||||||
show_model_info: bool, // Whether the model info panel is visible
|
show_model_info: bool, // Whether the model info panel is visible
|
||||||
@@ -500,12 +529,14 @@ impl ChatApp {
|
|||||||
},
|
},
|
||||||
error: None,
|
error: None,
|
||||||
models: Vec::new(),
|
models: Vec::new(),
|
||||||
|
annotated_models: Vec::new(),
|
||||||
provider_scope_status: HashMap::new(),
|
provider_scope_status: HashMap::new(),
|
||||||
available_providers: Vec::new(),
|
available_providers: Vec::new(),
|
||||||
selected_provider: "ollama_local".to_string(), // Default, will be updated in initialize_models
|
selected_provider: "ollama_local".to_string(), // Default, will be updated in initialize_models
|
||||||
selected_provider_index: 0,
|
selected_provider_index: 0,
|
||||||
selected_model_item: None,
|
selected_model_item: None,
|
||||||
model_selector_items: Vec::new(),
|
model_selector_items: Vec::new(),
|
||||||
|
model_filter_mode: FilterMode::All,
|
||||||
model_info_panel: ModelInfoPanel::new(),
|
model_info_panel: ModelInfoPanel::new(),
|
||||||
model_details_cache: HashMap::new(),
|
model_details_cache: HashMap::new(),
|
||||||
show_model_info: false,
|
show_model_info: false,
|
||||||
@@ -1210,6 +1241,21 @@ impl ChatApp {
|
|||||||
&self.model_selector_items
|
&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> {
|
pub fn selected_model_item(&self) -> Option<usize> {
|
||||||
self.selected_model_item
|
self.selected_model_item
|
||||||
}
|
}
|
||||||
@@ -5200,7 +5246,7 @@ impl ChatApp {
|
|||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
(KeyCode::Char('m'), KeyModifiers::NONE) => {
|
(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());
|
self.error = Some(err.to_string());
|
||||||
}
|
}
|
||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
@@ -6066,7 +6112,9 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
"m" | "model" => {
|
"m" | "model" => {
|
||||||
if args.is_empty() {
|
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.error = Some(err.to_string());
|
||||||
}
|
}
|
||||||
self.command_palette.clear();
|
self.command_palette.clear();
|
||||||
@@ -6257,7 +6305,9 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
"models" => {
|
"models" => {
|
||||||
if args.is_empty() {
|
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.error = Some(err.to_string());
|
||||||
}
|
}
|
||||||
self.command_palette.clear();
|
self.command_palette.clear();
|
||||||
@@ -6266,7 +6316,9 @@ impl ChatApp {
|
|||||||
|
|
||||||
match args[0] {
|
match args[0] {
|
||||||
"--local" => {
|
"--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());
|
self.error = Some(err.to_string());
|
||||||
} else if !self
|
} else if !self
|
||||||
.focus_first_model_in_scope(&ModelScope::Local)
|
.focus_first_model_in_scope(&ModelScope::Local)
|
||||||
@@ -6281,7 +6333,9 @@ impl ChatApp {
|
|||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
"--cloud" => {
|
"--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());
|
self.error = Some(err.to_string());
|
||||||
} else if !self
|
} else if !self
|
||||||
.focus_first_model_in_scope(&ModelScope::Cloud)
|
.focus_first_model_in_scope(&ModelScope::Cloud)
|
||||||
@@ -6295,6 +6349,22 @@ impl ChatApp {
|
|||||||
self.command_palette.clear();
|
self.command_palette.clear();
|
||||||
return Ok(AppState::Running);
|
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" => {
|
"info" => {
|
||||||
let force_refresh = args
|
let force_refresh = args
|
||||||
.get(1)
|
.get(1)
|
||||||
@@ -6743,7 +6813,9 @@ impl ChatApp {
|
|||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if let Some(item) = self.current_model_selector_item() {
|
if let Some(item) = self.current_model_selector_item() {
|
||||||
match item.kind() {
|
match item.kind() {
|
||||||
ModelSelectorItemKind::Header { provider, expanded } => {
|
ModelSelectorItemKind::Header {
|
||||||
|
provider, expanded, ..
|
||||||
|
} => {
|
||||||
if *expanded {
|
if *expanded {
|
||||||
let provider_name = provider.clone();
|
let provider_name = provider.clone();
|
||||||
self.collapse_provider(&provider_name);
|
self.collapse_provider(&provider_name);
|
||||||
@@ -6839,7 +6911,9 @@ impl ChatApp {
|
|||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
if let Some(item) = self.current_model_selector_item() {
|
if let Some(item) = self.current_model_selector_item() {
|
||||||
match item.kind() {
|
match item.kind() {
|
||||||
ModelSelectorItemKind::Header { provider, expanded } => {
|
ModelSelectorItemKind::Header {
|
||||||
|
provider, expanded, ..
|
||||||
|
} => {
|
||||||
if *expanded {
|
if *expanded {
|
||||||
let provider_name = provider.clone();
|
let provider_name = provider.clone();
|
||||||
self.collapse_provider(&provider_name);
|
self.collapse_provider(&provider_name);
|
||||||
@@ -6873,7 +6947,9 @@ impl ChatApp {
|
|||||||
KeyCode::Right => {
|
KeyCode::Right => {
|
||||||
if let Some(item) = self.current_model_selector_item() {
|
if let Some(item) = self.current_model_selector_item() {
|
||||||
match item.kind() {
|
match item.kind() {
|
||||||
ModelSelectorItemKind::Header { provider, expanded } => {
|
ModelSelectorItemKind::Header {
|
||||||
|
provider, expanded, ..
|
||||||
|
} => {
|
||||||
if !expanded {
|
if !expanded {
|
||||||
let provider_name = provider.clone();
|
let provider_name = provider.clone();
|
||||||
self.expand_provider(&provider_name, true);
|
self.expand_provider(&provider_name, true);
|
||||||
@@ -6895,8 +6971,9 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
KeyCode::Char(' ') => {
|
KeyCode::Char(' ') => {
|
||||||
if let Some(item) = self.current_model_selector_item() {
|
if let Some(item) = self.current_model_selector_item() {
|
||||||
if let ModelSelectorItemKind::Header { provider, expanded } =
|
if let ModelSelectorItemKind::Header {
|
||||||
item.kind()
|
provider, expanded, ..
|
||||||
|
} = item.kind()
|
||||||
{
|
{
|
||||||
if *expanded {
|
if *expanded {
|
||||||
let provider_name = provider.clone();
|
let provider_name = provider.clone();
|
||||||
@@ -7575,17 +7652,29 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn scope_header_label(
|
fn scope_header_label(
|
||||||
provider: &str,
|
_provider: &str,
|
||||||
scope: &ModelScope,
|
scope: &ModelScope,
|
||||||
status: Option<ModelAvailabilityState>,
|
status: Option<ModelAvailabilityState>,
|
||||||
|
filter: FilterMode,
|
||||||
) -> String {
|
) -> String {
|
||||||
let icon = Self::scope_icon(scope);
|
let icon = Self::scope_icon(scope);
|
||||||
let scope_name = Self::scope_display_name(scope);
|
let scope_name = Self::scope_display_name(scope);
|
||||||
let provider_name = capitalize_first(provider);
|
let mut label = format!("{icon} {scope_name}");
|
||||||
let mut label = format!("{icon} {scope_name} · {provider_name}");
|
|
||||||
|
|
||||||
if let Some(ModelAvailabilityState::Unavailable) = status {
|
if let Some(state) = status {
|
||||||
label.push_str(" (Unavailable)");
|
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
|
label
|
||||||
@@ -7694,11 +7783,66 @@ impl ChatApp {
|
|||||||
result
|
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) {
|
fn rebuild_model_selector_items(&mut self) {
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
|
|
||||||
if self.available_providers.is_empty() {
|
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;
|
self.model_selector_items = items;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -7707,7 +7851,14 @@ impl ChatApp {
|
|||||||
|
|
||||||
for provider in &self.available_providers {
|
for provider in &self.available_providers {
|
||||||
let is_expanded = expanded.as_ref().map(|p| p == provider).unwrap_or(false);
|
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 {
|
if is_expanded {
|
||||||
let relevant: Vec<(usize, &ModelInfo)> = self
|
let relevant: Vec<(usize, &ModelInfo)> = self
|
||||||
@@ -7736,6 +7887,10 @@ impl ChatApp {
|
|||||||
let mut rendered_body = false;
|
let mut rendered_body = false;
|
||||||
|
|
||||||
for scope in scopes_to_render {
|
for scope in scopes_to_render {
|
||||||
|
if !self.filter_allows_scope(&scope) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
rendered_scope = true;
|
rendered_scope = true;
|
||||||
let entries = scoped.get(&scope).cloned().unwrap_or_default();
|
let entries = scoped.get(&scope).cloned().unwrap_or_default();
|
||||||
let deduped =
|
let deduped =
|
||||||
@@ -7745,16 +7900,36 @@ impl ChatApp {
|
|||||||
.and_then(|map| map.get(&scope))
|
.and_then(|map| map.get(&scope))
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let label =
|
let label = Self::scope_header_label(
|
||||||
Self::scope_header_label(provider, &scope, Some(status_entry.state));
|
provider,
|
||||||
|
&scope,
|
||||||
|
Some(status_entry.state),
|
||||||
|
self.model_filter_mode,
|
||||||
|
);
|
||||||
|
|
||||||
items.push(ModelSelectorItem::scope(
|
items.push(ModelSelectorItem::scope(
|
||||||
provider.clone(),
|
provider.clone(),
|
||||||
label,
|
label,
|
||||||
scope.clone(),
|
scope.clone(),
|
||||||
|
status_entry.state,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let scope_allowed = self.filter_scope_allows_models(&scope, status_entry.state);
|
||||||
|
|
||||||
if deduped.is_empty() {
|
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 {
|
let fallback_message = match status_entry.state {
|
||||||
ModelAvailabilityState::Unavailable => {
|
ModelAvailabilityState::Unavailable => {
|
||||||
Some(format!("{} unavailable", Self::scope_display_name(&scope)))
|
Some(format!("{} unavailable", Self::scope_display_name(&scope)))
|
||||||
@@ -7768,7 +7943,24 @@ impl ChatApp {
|
|||||||
|
|
||||||
if let Some(message) = fallback_message {
|
if let Some(message) = fallback_message {
|
||||||
rendered_body = true;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -7780,7 +7972,7 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !rendered_scope || !rendered_body {
|
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();
|
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> {
|
fn first_model_item_index(&self) -> Option<usize> {
|
||||||
self.model_selector_items
|
self.model_selector_items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -7900,6 +8217,19 @@ impl ChatApp {
|
|||||||
true
|
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) {
|
fn ensure_valid_model_selection(&mut self) {
|
||||||
if self.model_selector_items.is_empty() {
|
if self.model_selector_items.is_empty() {
|
||||||
self.selected_model_item = None;
|
self.selected_model_item = None;
|
||||||
@@ -8091,6 +8421,7 @@ impl ChatApp {
|
|||||||
|
|
||||||
self.models = all_models;
|
self.models = all_models;
|
||||||
self.provider_scope_status = scope_status;
|
self.provider_scope_status = scope_status;
|
||||||
|
self.rebuild_annotated_models();
|
||||||
self.model_info_panel.clear();
|
self.model_info_panel.clear();
|
||||||
self.set_model_info_visible(false);
|
self.set_model_info_visible(false);
|
||||||
self.populate_model_details_cache_from_session().await;
|
self.populate_model_details_cache_from_session().await;
|
||||||
@@ -8137,6 +8468,7 @@ impl ChatApp {
|
|||||||
self.models.len(),
|
self.models.len(),
|
||||||
self.available_providers.len()
|
self.available_providers.len()
|
||||||
);
|
);
|
||||||
|
self.rebuild_model_selector_items();
|
||||||
|
|
||||||
self.update_command_palette_catalog();
|
self.update_command_palette_catalog();
|
||||||
|
|
||||||
@@ -8401,13 +8733,15 @@ impl ChatApp {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show_model_picker(&mut self) -> Result<()> {
|
async fn show_model_picker(&mut self, filter: FilterMode) -> Result<()> {
|
||||||
self.refresh_models().await?;
|
self.refresh_models().await?;
|
||||||
|
|
||||||
if self.models.is_empty() {
|
if self.models.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.set_model_filter_mode(filter);
|
||||||
|
|
||||||
if self.available_providers.len() <= 1 {
|
if self.available_providers.len() <= 1 {
|
||||||
self.set_input_mode(InputMode::ModelSelection);
|
self.set_input_mode(InputMode::ModelSelection);
|
||||||
self.ensure_valid_model_selection();
|
self.ensure_valid_model_selection();
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ const COMMANDS: &[CommandSpec] = &[
|
|||||||
keyword: "models --cloud",
|
keyword: "models --cloud",
|
||||||
description: "Open model picker focused on cloud models",
|
description: "Open model picker focused on cloud models",
|
||||||
},
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "models --available",
|
||||||
|
description: "Open model picker showing available models",
|
||||||
|
},
|
||||||
CommandSpec {
|
CommandSpec {
|
||||||
keyword: "new",
|
keyword: "new",
|
||||||
description: "Start a new conversation",
|
description: "Start a new conversation",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub mod state;
|
|||||||
pub mod toast;
|
pub mod toast;
|
||||||
pub mod tui_controller;
|
pub mod tui_controller;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
pub mod widgets;
|
||||||
|
|
||||||
pub use chat_app::{ChatApp, SessionEvent};
|
pub use chat_app::{ChatApp, SessionEvent};
|
||||||
pub use code_app::CodeApp;
|
pub use code_app::CodeApp;
|
||||||
|
|||||||
@@ -11,19 +11,16 @@ use tui_textarea::TextArea;
|
|||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
use crate::chat_app::{
|
use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext};
|
||||||
ChatApp, HELP_TAB_COUNT, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext, ModelScope,
|
|
||||||
ModelSelectorItemKind,
|
|
||||||
};
|
|
||||||
use crate::highlight;
|
use crate::highlight;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId,
|
CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId,
|
||||||
RepoSearchRowKind, SplitAxis, VisibleFileEntry,
|
RepoSearchRowKind, SplitAxis, VisibleFileEntry,
|
||||||
};
|
};
|
||||||
use crate::toast::{Toast, ToastLevel};
|
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::theme::Theme;
|
||||||
use owlen_core::types::{ModelInfo, Role};
|
use owlen_core::types::Role;
|
||||||
use owlen_core::ui::{FocusedPanel, InputMode, RoleLabelDisplay};
|
use owlen_core::ui::{FocusedPanel, InputMode, RoleLabelDisplay};
|
||||||
use textwrap::wrap;
|
use textwrap::wrap;
|
||||||
|
|
||||||
@@ -337,7 +334,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
|||||||
} else {
|
} else {
|
||||||
match app.mode() {
|
match app.mode() {
|
||||||
InputMode::ProviderSelection => render_provider_selector(frame, app),
|
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::Help => render_help(frame, app),
|
||||||
InputMode::SessionBrowser => render_session_browser(frame, app),
|
InputMode::SessionBrowser => render_session_browser(frame, app),
|
||||||
InputMode::ThemeBrowser => render_theme_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);
|
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) {
|
fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||||
let theme = app.theme();
|
let theme = app.theme();
|
||||||
|
|
||||||
@@ -3232,67 +2806,6 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
frame.render_widget(paragraph, area);
|
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) {
|
fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||||
let theme = app.theme();
|
let theme = app.theme();
|
||||||
let config = app.config();
|
let config = app.config();
|
||||||
|
|||||||
3
crates/owlen-tui/src/widgets/mod.rs
Normal file
3
crates/owlen-tui/src/widgets/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
//! Reusable widgets composed specifically for the Owlen TUI.
|
||||||
|
|
||||||
|
pub mod model_picker;
|
||||||
614
crates/owlen-tui/src/widgets/model_picker.rs
Normal file
614
crates/owlen-tui/src/widgets/model_picker.rs
Normal 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(&"🎧"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user