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 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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
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