From 9588c8c562c5c6c504d51bc1e5a7baafcb0833fc Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 17 Oct 2025 04:52:38 +0200 Subject: [PATCH] feat(tui): model picker UX polish (filters, sizing, search) --- crates/owlen-tui/src/chat_app.rs | 592 +++++++++++++++---- crates/owlen-tui/src/commands/registry.rs | 12 +- crates/owlen-tui/src/state/keymap.rs | 2 +- crates/owlen-tui/src/widgets/model_picker.rs | 367 ++++++++++-- 4 files changed, 789 insertions(+), 184 deletions(-) diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index ceb09ac..6fe271f 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -113,6 +113,39 @@ pub(crate) struct ModelSelectorItem { kind: ModelSelectorItemKind, } +#[derive(Clone, Debug)] +pub(crate) struct HighlightMask { + bits: Vec, +} + +impl HighlightMask { + fn new(bits: Vec) -> Self { + Self { bits } + } + + pub(crate) fn is_marked(&self) -> bool { + self.bits.iter().any(|b| *b) + } + + pub(crate) fn bits(&self) -> &[bool] { + &self.bits + } + + pub(crate) fn truncated(&self, len: usize) -> Self { + let len = len.min(self.bits.len()); + Self::new(self.bits[..len].to_vec()) + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ModelSearchInfo { + pub(crate) score: (usize, usize), + pub(crate) name: Option, + pub(crate) id: Option, + pub(crate) provider: Option, + pub(crate) description: Option, +} + #[derive(Clone, Debug)] pub(crate) enum ModelSelectorItemKind { Header { @@ -218,6 +251,85 @@ impl ModelSelectorItem { } } +fn collect_lower_graphemes(text: &str) -> (Vec<&str>, Vec) { + let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect(); + let lower: Vec = graphemes.iter().map(|g| g.to_lowercase()).collect(); + (graphemes, lower) +} + +fn subsequence_highlight(candidate: &[String], query: &[String]) -> Option> { + if query.is_empty() { + return None; + } + let mut mask = vec![false; candidate.len()]; + let mut q_idx = 0usize; + for (idx, g) in candidate.iter().enumerate() { + if q_idx < query.len() && g == &query[q_idx] { + mask[idx] = true; + q_idx += 1; + } + } + if q_idx == query.len() { + Some(mask) + } else { + None + } +} + +fn search_candidate(candidate: &str, query: &str) -> Option<((usize, usize), HighlightMask)> { + let candidate = candidate.trim(); + let query = query.trim(); + if candidate.is_empty() || query.is_empty() { + return None; + } + + let (original_graphemes, lower_graphemes) = collect_lower_graphemes(candidate); + let candidate_lower = lower_graphemes.join(""); + let query_lower = query.to_lowercase(); + let query_graphemes: Vec = UnicodeSegmentation::graphemes(query_lower.as_str(), true) + .map(|g| g.to_string()) + .collect(); + let query_len = query_graphemes.len(); + + let mut mask = vec![false; original_graphemes.len()]; + + if candidate_lower == query_lower { + mask.fill(true); + return Some(((0, candidate.len()), HighlightMask::new(mask))); + } + + if candidate_lower.starts_with(&query_lower) { + for idx in 0..query_len.min(mask.len()) { + mask[idx] = true; + } + return Some(((1, 0), HighlightMask::new(mask))); + } + + if let Some(start_byte) = candidate_lower.find(&query_lower) { + let mut collected_bytes = 0usize; + let mut start_index = 0usize; + for (idx, grapheme) in lower_graphemes.iter().enumerate() { + if collected_bytes == start_byte { + start_index = idx; + break; + } + collected_bytes += grapheme.len(); + } + for idx in start_index..(start_index + query_len).min(mask.len()) { + mask[idx] = true; + } + return Some(((2, start_byte), HighlightMask::new(mask))); + } + + if let Some(subsequence_mask) = subsequence_highlight(&lower_graphemes, &query_graphemes) { + if subsequence_mask.iter().any(|b| *b) { + return Some(((3, candidate.len()), HighlightMask::new(subsequence_mask))); + } + } + + None +} + #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub(crate) enum ModelScope { Local, @@ -294,6 +406,11 @@ pub struct ChatApp { pub selected_model_item: Option, // Index into the flattened model selector list model_selector_items: Vec, // Flattened provider/model list for selector model_filter_mode: FilterMode, // Active filter applied to the model list + model_filter_memory: FilterMode, // Last user-selected filter mode + model_search_query: String, // Active fuzzy search query for the picker + model_search_hits: HashMap, // Cached search metadata per model index + provider_search_hits: HashMap, // Cached search highlight per provider + visible_model_count: usize, // Number of visible models in current selector view model_info_panel: ModelInfoPanel, // Dedicated model information viewer model_details_cache: HashMap, // Cached detailed metadata per model show_model_info: bool, // Whether the model info panel is visible @@ -557,6 +674,11 @@ impl ChatApp { selected_model_item: None, model_selector_items: Vec::new(), model_filter_mode: FilterMode::All, + model_filter_memory: FilterMode::All, + model_search_query: String::new(), + model_search_hits: HashMap::new(), + provider_search_hits: HashMap::new(), + visible_model_count: 0, model_info_panel: ModelInfoPanel::new(), model_details_cache: HashMap::new(), show_model_info: false, @@ -1359,10 +1481,86 @@ impl ChatApp { self.model_filter_mode } - pub(crate) fn set_model_filter_mode(&mut self, mode: FilterMode) { + pub(crate) fn model_search_query(&self) -> &str { + &self.model_search_query + } + + pub(crate) fn model_search_info(&self, index: usize) -> Option<&ModelSearchInfo> { + self.model_search_hits.get(&index) + } + + pub(crate) fn provider_search_highlight(&self, provider: &str) -> Option<&HighlightMask> { + self.provider_search_hits.get(provider) + } + + pub(crate) fn visible_model_count(&self) -> usize { + self.visible_model_count + } + + fn update_model_filter_mode(&mut self, mode: FilterMode) { if self.model_filter_mode != mode { self.model_filter_mode = mode; + self.model_filter_memory = mode; self.rebuild_model_selector_items(); + } else if !self.model_search_query.is_empty() { + // Refresh search results against current filter + self.rebuild_model_selector_items(); + } + } + + fn push_model_search_char(&mut self, ch: char) { + if ch.is_control() { + return; + } + self.model_search_query.push(ch); + self.rebuild_model_selector_items(); + self.update_model_search_status(); + } + + fn pop_model_search_char(&mut self) { + self.model_search_query.pop(); + self.rebuild_model_selector_items(); + self.update_model_search_status(); + } + + fn clear_model_search_query(&mut self) { + if self.model_search_query.is_empty() { + return; + } + self.model_search_query.clear(); + self.rebuild_model_selector_items(); + self.update_model_search_status(); + } + + fn reset_model_picker_state(&mut self) { + if !self.model_search_query.is_empty() { + self.model_search_query.clear(); + } + self.model_search_hits.clear(); + self.provider_search_hits.clear(); + self.visible_model_count = 0; + } + + fn update_model_search_status(&mut self) { + if !matches!( + self.mode, + InputMode::ModelSelection | InputMode::ProviderSelection + ) { + return; + } + if self.model_search_query.is_empty() { + self.status = "Select a model to use".to_string(); + } else { + let count = self.visible_model_count(); + if count == 1 { + self.status = format!("Search \"{}\" → 1 match", self.model_search_query.trim()); + } else { + self.status = format!( + "Search \"{}\" → {} matches", + self.model_search_query.trim(), + count + ); + } } } @@ -1593,6 +1791,15 @@ impl ChatApp { if self.mode != mode { self.mode_flash_until = Some(Instant::now() + Duration::from_millis(240)); } + if !matches!( + mode, + InputMode::ModelSelection | InputMode::ProviderSelection + ) && matches!( + self.mode, + InputMode::ModelSelection | InputMode::ProviderSelection + ) { + self.reset_model_picker_state(); + } self.mode = mode; let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode })); } @@ -5462,7 +5669,7 @@ impl ChatApp { return Ok(AppState::Running); } (KeyCode::Char('m'), KeyModifiers::NONE) => { - if let Err(err) = self.show_model_picker(FilterMode::All).await { + if let Err(err) = self.show_model_picker(None).await { self.error = Some(err.to_string()); } return Ok(AppState::Running); @@ -6315,9 +6522,7 @@ impl ChatApp { } "m" | "model" => { if args.is_empty() { - if let Err(err) = - self.show_model_picker(FilterMode::All).await - { + if let Err(err) = self.show_model_picker(None).await { self.error = Some(err.to_string()); } self.command_palette.clear(); @@ -6508,9 +6713,7 @@ impl ChatApp { } "models" => { if args.is_empty() { - if let Err(err) = - self.show_model_picker(FilterMode::All).await - { + if let Err(err) = self.show_model_picker(None).await { self.error = Some(err.to_string()); } self.command_palette.clear(); @@ -6519,8 +6722,9 @@ impl ChatApp { match args[0] { "--local" => { - if let Err(err) = - self.show_model_picker(FilterMode::LocalOnly).await + if let Err(err) = self + .show_model_picker(Some(FilterMode::LocalOnly)) + .await { self.error = Some(err.to_string()); } else if !self @@ -6536,8 +6740,9 @@ impl ChatApp { return Ok(AppState::Running); } "--cloud" => { - if let Err(err) = - self.show_model_picker(FilterMode::CloudOnly).await + if let Err(err) = self + .show_model_picker(Some(FilterMode::CloudOnly)) + .await { self.error = Some(err.to_string()); } else if !self @@ -6553,8 +6758,9 @@ impl ChatApp { return Ok(AppState::Running); } "--available" => { - if let Err(err) = - self.show_model_picker(FilterMode::Available).await + if let Err(err) = self + .show_model_picker(Some(FilterMode::Available)) + .await { self.error = Some(err.to_string()); } else if !self.focus_first_available_model() { @@ -7193,6 +7399,17 @@ impl ChatApp { } } } + KeyCode::Backspace => { + self.pop_model_search_char(); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.clear_model_search_query(); + } + KeyCode::Char(c) + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => + { + self.push_model_search_char(c); + } _ => {} }, InputMode::Help => match key.code { @@ -8124,6 +8341,9 @@ impl ChatApp { fn rebuild_model_selector_items(&mut self) { let mut items = Vec::new(); + self.model_search_hits.clear(); + self.provider_search_hits.clear(); + self.visible_model_count = 0; if self.available_providers.is_empty() { items.push(ModelSelectorItem::header( @@ -8136,148 +8356,278 @@ impl ChatApp { return; } + let search_query = self.model_search_query.trim().to_string(); + let search_active = !search_query.is_empty(); + let force_expand = search_active; let expanded = self.expanded_provider.clone(); for provider in &self.available_providers { - let is_expanded = expanded.as_ref().map(|p| p == provider).unwrap_or(false); + let provider_lower = provider.to_ascii_lowercase(); let provider_status = self.provider_overall_status(provider); let provider_type = self.provider_type_for(provider); - items.push(ModelSelectorItem::header( + let provider_highlight = if search_active { + search_candidate(provider, &search_query).map(|(_, mask)| mask) + } else { + None + }; + if let Some(mask) = provider_highlight.clone() { + self.provider_search_hits.insert(provider.clone(), mask); + } + + let is_expanded = + force_expand || expanded.as_ref().map(|p| p == provider).unwrap_or(false); + + let mut provider_block = Vec::new(); + provider_block.push(ModelSelectorItem::header( provider.clone(), is_expanded, provider_status, provider_type, )); - if is_expanded { - let relevant: Vec<(usize, &ModelInfo)> = self - .models - .iter() - .enumerate() - .filter(|(_, model)| &model.provider == provider) - .collect(); + if !is_expanded { + items.extend(provider_block); + continue; + } - let mut scoped: BTreeMap> = BTreeMap::new(); - for (idx, model) in relevant { + let status_map = self.provider_scope_status.get(provider); + + let mut scoped: BTreeMap> = BTreeMap::new(); + for (idx, model) in self.models.iter().enumerate() { + if &model.provider == provider { let scope = Self::model_scope_from_capabilities(model); scoped.entry(scope).or_default().push((idx, model)); } + } - let provider_lower = provider.to_ascii_lowercase(); - let status_map = self.provider_scope_status.get(provider); + let mut scopes_to_render: BTreeSet = BTreeSet::new(); + scopes_to_render.extend(scoped.keys().cloned()); + if let Some(statuses) = status_map { + scopes_to_render.extend(statuses.keys().cloned()); + } - let mut scopes_to_render: BTreeSet = BTreeSet::new(); - scopes_to_render.extend(scoped.keys().cloned()); - if let Some(statuses) = status_map { - scopes_to_render.extend(statuses.keys().cloned()); + let mut rendered_scope = false; + let mut rendered_body = false; + let mut provider_has_models = false; + + for scope in scopes_to_render { + if !self.filter_allows_scope(&scope) { + continue; } - let mut rendered_scope = false; - let mut rendered_body = false; + let entries = scoped.get(&scope).cloned().unwrap_or_default(); + let deduped = Self::deduplicate_models_for_scope(entries, &provider_lower, &scope); - for scope in scopes_to_render { - if !self.filter_allows_scope(&scope) { - continue; + let status_entry = status_map + .and_then(|map| map.get(&scope)) + .cloned() + .unwrap_or_default(); + + let mut filtered: Vec<(usize, &ModelInfo)> = Vec::new(); + for (idx, model) in deduped { + let search_info = if search_active { + self.evaluate_model_search(provider, model, &search_query) + } else { + None + }; + + if let Some(info) = search_info { + self.model_search_hits.insert(idx, info); + filtered.push((idx, model)); + } else if !search_active { + filtered.push((idx, model)); } + } - rendered_scope = true; - let entries = scoped.get(&scope).cloned().unwrap_or_default(); - let deduped = - Self::deduplicate_models_for_scope(entries, &provider_lower, &scope); + if search_active && filtered.is_empty() { + continue; + } - let status_entry = status_map - .and_then(|map| map.get(&scope)) - .cloned() - .unwrap_or_default(); - let label = - Self::scope_header_label(&scope, &status_entry, self.model_filter_mode); + rendered_scope = true; - items.push(ModelSelectorItem::scope( - provider.clone(), - label, - scope.clone(), - status_entry.state, - )); + let label = Self::scope_header_label(&scope, &status_entry, self.model_filter_mode); + provider_block.push(ModelSelectorItem::scope( + provider.clone(), + label, + scope.clone(), + status_entry.state, + )); - if status_entry.state != ModelAvailabilityState::Available - || status_entry.is_stale - || status_entry.message.is_some() - { - if let Some(summary) = Self::scope_status_summary(&status_entry) { - rendered_body = true; - items.push(ModelSelectorItem::empty( - provider.clone(), - Some(summary), - Some(status_entry.state), - )); - } + if status_entry.state != ModelAvailabilityState::Available + || status_entry.is_stale + || status_entry.message.is_some() + { + if let Some(summary) = Self::scope_status_summary(&status_entry) { + provider_block.push(ModelSelectorItem::empty( + provider.clone(), + Some(summary), + Some(status_entry.state), + )); + rendered_body = true; } + } - let scope_allowed = self.filter_scope_allows_models(&scope, &status_entry); - - if deduped.is_empty() { - if !scope_allowed { - let message = self.scope_filter_message(&scope, &status_entry); - 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))) - } - ModelAvailabilityState::Available => Some(format!( - "No {} models found", - Self::scope_display_name(&scope) - )), - ModelAvailabilityState::Unknown => None, - }; - - if let Some(message) = fallback_message { - rendered_body = true; - items.push(ModelSelectorItem::empty( - provider.clone(), - Some(message), - Some(status_entry.state), - )); - } - continue; - } + let scope_allowed = self.filter_scope_allows_models(&scope, &status_entry); + if filtered.is_empty() { if !scope_allowed { - let message = self.scope_filter_message(&scope, &status_entry); - if let Some(msg) = message { - rendered_body = true; - items.push(ModelSelectorItem::empty( + if let Some(msg) = self.scope_filter_message(&scope, &status_entry) { + provider_block.push(ModelSelectorItem::empty( provider.clone(), Some(msg), Some(status_entry.state), )); + rendered_body = true; } - continue; - } - - rendered_body = true; - for (idx, _) in deduped { - items.push(ModelSelectorItem::model(provider.clone(), idx)); + } else if !search_active { + let message = match status_entry.state { + ModelAvailabilityState::Unavailable => { + format!("{} unavailable", Self::scope_display_name(&scope)) + } + ModelAvailabilityState::Available => { + format!("No {} models found", Self::scope_display_name(&scope)) + } + ModelAvailabilityState::Unknown => "No models configured".to_string(), + }; + provider_block.push(ModelSelectorItem::empty( + provider.clone(), + Some(message), + Some(status_entry.state), + )); + rendered_body = true; } + continue; } - if !rendered_scope || !rendered_body { - items.push(ModelSelectorItem::empty(provider.clone(), None, None)); + if !scope_allowed { + if let Some(msg) = self.scope_filter_message(&scope, &status_entry) { + provider_block.push(ModelSelectorItem::empty( + provider.clone(), + Some(msg), + Some(status_entry.state), + )); + rendered_body = true; + } + continue; + } + + rendered_body = true; + provider_has_models = true; + + for (idx, _) in filtered { + provider_block.push(ModelSelectorItem::model(provider.clone(), idx)); } } + + if !provider_has_models && search_active && provider_highlight.is_some() { + provider_block.push(ModelSelectorItem::empty( + provider.clone(), + Some(format!( + "Provider matches '{}' but no models found", + search_query + )), + None, + )); + rendered_body = true; + } + + if !rendered_scope && !rendered_body { + if !search_active { + provider_block.push(ModelSelectorItem::empty(provider.clone(), None, None)); + } else if provider_highlight.is_some() { + provider_block.push(ModelSelectorItem::empty( + provider.clone(), + Some(format!("No models matching '{}'", search_query)), + None, + )); + } else { + continue; + } + } + + items.extend(provider_block); } + if items.is_empty() { + items.push(ModelSelectorItem::empty( + "providers", + Some(if search_active { + format!("No models matching '{}'", search_query) + } else { + "No providers configured".to_string() + }), + None, + )); + } + + self.visible_model_count = items + .iter() + .filter(|item| matches!(item.kind(), ModelSelectorItemKind::Model { .. })) + .count(); + self.model_selector_items = items; self.ensure_valid_model_selection(); + + if search_active { + let current_is_model = self + .current_model_selector_item() + .map(|item| matches!(item.kind(), ModelSelectorItemKind::Model { .. })) + .unwrap_or(false); + + if !current_is_model { + if let Some((idx, _)) = self + .model_selector_items + .iter() + .enumerate() + .find(|(_, item)| matches!(item.kind(), ModelSelectorItemKind::Model { .. })) + { + self.set_selected_model_item(idx); + } + } + } + } + + fn evaluate_model_search( + &self, + provider: &str, + model: &ModelInfo, + query: &str, + ) -> Option { + let mut info = ModelSearchInfo::default(); + let mut best: Option<(usize, usize)> = None; + + let mut consider = |candidate: Option<&str>, target: &mut Option| { + if let Some(text) = candidate { + if let Some((score, mask)) = search_candidate(text, query) { + let replace = best.is_none_or(|current| score < current); + if replace { + best = Some(score); + } + *target = Some(mask); + } + } + }; + + consider( + if model.name.trim().is_empty() { + None + } else { + Some(model.name.as_str()) + }, + &mut info.name, + ); + consider(Some(model.id.as_str()), &mut info.id); + consider(Some(provider), &mut info.provider); + if let Some(desc) = model.description.as_deref() { + consider(Some(desc), &mut info.description); + } + + if let Some(score) = best { + info.score = score; + Some(info) + } else { + None + } } fn provider_scope_state(&self, provider: &str, scope: &ModelScope) -> ModelAvailabilityState { @@ -9044,14 +9394,26 @@ impl ChatApp { Ok(()) } - async fn show_model_picker(&mut self, filter: FilterMode) -> Result<()> { + async fn show_model_picker(&mut self, filter: Option) -> Result<()> { self.refresh_models().await?; if self.models.is_empty() { return Ok(()); } - self.set_model_filter_mode(filter); + // Respect caller-specified filter or fall back to the last-used mode. + if let Some(mode) = filter { + self.model_filter_memory = mode; + self.update_model_filter_mode(mode); + } else { + let remembered = self.model_filter_memory; + self.update_model_filter_mode(remembered); + } + + // Reset transient search state when opening the picker. + self.reset_model_picker_state(); + self.rebuild_model_selector_items(); + self.update_model_search_status(); if self.available_providers.len() <= 1 { self.set_input_mode(InputMode::ModelSelection); diff --git a/crates/owlen-tui/src/commands/registry.rs b/crates/owlen-tui/src/commands/registry.rs index 050aa68..c704b1c 100644 --- a/crates/owlen-tui/src/commands/registry.rs +++ b/crates/owlen-tui/src/commands/registry.rs @@ -6,7 +6,7 @@ use crate::widgets::model_picker::FilterMode; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum AppCommand { - OpenModelPicker(FilterMode), + OpenModelPicker(Option), OpenCommandPalette, CycleFocusForward, CycleFocusBackward, @@ -26,19 +26,19 @@ impl CommandRegistry { commands.insert( "model.open_all".to_string(), - AppCommand::OpenModelPicker(FilterMode::All), + AppCommand::OpenModelPicker(None), ); commands.insert( "model.open_local".to_string(), - AppCommand::OpenModelPicker(FilterMode::LocalOnly), + AppCommand::OpenModelPicker(Some(FilterMode::LocalOnly)), ); commands.insert( "model.open_cloud".to_string(), - AppCommand::OpenModelPicker(FilterMode::CloudOnly), + AppCommand::OpenModelPicker(Some(FilterMode::CloudOnly)), ); commands.insert( "model.open_available".to_string(), - AppCommand::OpenModelPicker(FilterMode::Available), + AppCommand::OpenModelPicker(Some(FilterMode::Available)), ); commands.insert("palette.open".to_string(), AppCommand::OpenCommandPalette); commands.insert("focus.next".to_string(), AppCommand::CycleFocusForward); @@ -93,7 +93,7 @@ mod tests { ); assert_eq!( registry.resolve("model.open_cloud"), - Some(AppCommand::OpenModelPicker(FilterMode::CloudOnly)) + Some(AppCommand::OpenModelPicker(Some(FilterMode::CloudOnly))) ); } diff --git a/crates/owlen-tui/src/state/keymap.rs b/crates/owlen-tui/src/state/keymap.rs index 56ecbe3..f9e9f03 100644 --- a/crates/owlen-tui/src/state/keymap.rs +++ b/crates/owlen-tui/src/state/keymap.rs @@ -302,7 +302,7 @@ mod tests { ); assert_eq!( keymap.resolve(InputMode::Normal, &event), - Some(AppCommand::OpenModelPicker(FilterMode::All)) + Some(AppCommand::OpenModelPicker(None)) ); } } diff --git a/crates/owlen-tui/src/widgets/model_picker.rs b/crates/owlen-tui/src/widgets/model_picker.rs index 27bb56b..d3b8e07 100644 --- a/crates/owlen-tui/src/widgets/model_picker.rs +++ b/crates/owlen-tui/src/widgets/model_picker.rs @@ -12,7 +12,10 @@ use ratatui::{ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; -use crate::chat_app::{ChatApp, ModelAvailabilityState, ModelScope, ModelSelectorItemKind}; +use crate::chat_app::{ + ChatApp, HighlightMask, ModelAvailabilityState, ModelScope, ModelSearchInfo, + ModelSelectorItemKind, +}; /// Filtering modes for the model picker popup. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] @@ -36,16 +39,21 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { 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 search_query = app.model_search_query().trim().to_string(); + let search_active = !search_query.is_empty(); - let mut height = (selector_items.len().clamp(1, 10) as u16) * 3 + 6; - height = height.clamp(6, area.height); + let max_width = area.width.min(90); + let min_width = area.width.min(56); + let width = area.width.min(max_width).max(min_width).max(1); + + let visible_models = app.visible_model_count(); + let min_rows: usize = if search_active { 5 } else { 4 }; + let max_rows: usize = 12; + let row_estimate = visible_models.max(min_rows).min(max_rows); + let mut height = (row_estimate as u16) * 3 + 8; + let min_height = area.height.clamp(8, 12); + let max_height = area.height.min(32); + height = height.clamp(min_height, max_height); let x = area.x + (area.width.saturating_sub(width)) / 2; let mut y = area.y + (area.height.saturating_sub(height)) / 3; @@ -84,15 +92,110 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { 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)]) + .constraints([ + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(2), + ]) .split(inner); + let matches = app.visible_model_count(); + let search_prefix = Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM); + let bracket_style = Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM); + let caret_style = if search_active { + Style::default() + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM) + }; + + let mut search_spans = Vec::new(); + search_spans.push(Span::styled("Search ▸ ", search_prefix)); + search_spans.push(Span::styled("[", bracket_style)); + search_spans.push(Span::styled(" ", bracket_style)); + + if search_active { + search_spans.push(Span::styled( + search_query.clone(), + Style::default() + .fg(theme.selection_fg) + .add_modifier(Modifier::BOLD), + )); + } else { + search_spans.push(Span::styled( + "Type to search…", + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + )); + } + + search_spans.push(Span::styled(" ", bracket_style)); + search_spans.push(Span::styled("▎", caret_style)); + search_spans.push(Span::styled(" ", bracket_style)); + search_spans.push(Span::styled("]", bracket_style)); + search_spans.push(Span::raw(" ")); + let suffix_label = if search_active { "match" } else { "model" }; + search_spans.push(Span::styled( + format!( + "({} {}{})", + matches, + suffix_label, + if matches == 1 { "" } else { "s" } + ), + Style::default().fg(theme.placeholder), + )); + + let search_line = Line::from(search_spans); + + let instruction_line = if search_active { + Line::from(vec![ + Span::styled("Backspace", Style::default().fg(theme.placeholder)), + Span::raw(": delete "), + Span::styled("Ctrl+U", Style::default().fg(theme.placeholder)), + Span::raw(": clear "), + Span::styled("Enter", Style::default().fg(theme.placeholder)), + Span::raw(": select "), + Span::styled("Esc", Style::default().fg(theme.placeholder)), + Span::raw(": close"), + ]) + } else { + Line::from(vec![ + Span::styled("Enter", Style::default().fg(theme.placeholder)), + Span::raw(": select "), + Span::styled("Space", Style::default().fg(theme.placeholder)), + Span::raw(": toggle provider "), + Span::styled("Esc", Style::default().fg(theme.placeholder)), + Span::raw(": close"), + ]) + }; + + let search_paragraph = Paragraph::new(vec![search_line, instruction_line]) + .style(Style::default().bg(theme.background).fg(theme.text)); + frame.render_widget(search_paragraph, layout[0]); + + let highlight_style = Style::default() + .fg(theme.selection_fg) + .bg(theme.selection_bg) + .add_modifier(Modifier::BOLD); + + let highlight_symbol = " "; + let highlight_width = UnicodeWidthStr::width(highlight_symbol); + let max_line_width = layout[1] + .width + .saturating_sub(highlight_width as u16) + .max(1) as usize; + let active_model_id = app.selected_model(); let annotated = app.annotated_models(); @@ -108,12 +211,19 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { let mut spans = Vec::new(); spans.push(status_icon(*status, theme)); spans.push(Span::raw(" ")); - spans.push(Span::styled( - provider.clone(), + let header_spans = render_highlighted_text( + provider, + if search_active { + app.provider_search_highlight(provider) + } else { + None + }, Style::default() .fg(theme.mode_command) .add_modifier(Modifier::BOLD), - )); + highlight_style, + ); + spans.extend(header_spans); spans.push(Span::raw(" ")); spans.push(provider_type_badge(*provider_type, theme)); spans.push(Span::raw(" ")); @@ -145,6 +255,11 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { let badges = model_badge_icons(model); let detail = app.cached_model_detail(&model.id); let annotated_model = annotated.get(*model_index); + let search_info = if search_active { + app.model_search_info(*model_index) + } else { + None + }; let (title, metadata) = build_model_selector_lines( theme, model, @@ -152,6 +267,10 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { &badges, detail, model.id == active_model_id, + SearchRenderContext { + info: search_info, + highlight_style, + }, ); lines.push(clip_line_to_width(title, max_line_width)); if let Some(meta) = metadata { @@ -176,14 +295,9 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { .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, - ); + let mut spans = vec![Span::styled(icon, style), Span::raw(" ")]; + spans.push(Span::styled(format!(" {}", msg), style)); + let line = clip_line_to_width(Line::from(spans), max_line_width); items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); } } @@ -199,16 +313,22 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) { .highlight_symbol(" "); let mut state = ListState::default(); - state.select(app.selected_model_item); - frame.render_stateful_widget(list, layout[0], &mut state); + state.select(app.selected_model_item()); + frame.render_stateful_widget(list, layout[1], &mut state); + + let footer_text = if search_active { + "Enter: select · Space: toggle provider · Backspace: delete · Ctrl+U: clear" + } else { + "Enter: select · Space: toggle provider · Type to search · Esc: cancel" + }; let footer = Paragraph::new(Line::from(Span::styled( - "Enter: select · Space: toggle provider · ←/→ collapse/expand · Esc: cancel", + footer_text, 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]); + frame.render_widget(footer, layout[2]); } fn status_icon(status: ProviderStatus, theme: &owlen_core::theme::Theme) -> Span<'static> { @@ -302,13 +422,72 @@ fn filter_badge(mode: FilterMode, theme: &owlen_core::theme::Theme) -> Span<'sta ) } -fn build_model_selector_lines( +fn render_highlighted_text( + text: &str, + highlight: Option<&HighlightMask>, + normal_style: Style, + highlight_style: Style, +) -> Vec> { + if text.is_empty() { + return Vec::new(); + } + + let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect(); + let mask = highlight.map(|mask| mask.bits()).unwrap_or(&[]); + + let mut spans: Vec> = Vec::new(); + let mut buffer = String::new(); + let mut current_highlight = false; + + for (idx, grapheme) in graphemes.iter().enumerate() { + let mark = mask.get(idx).copied().unwrap_or(false); + if idx == 0 { + current_highlight = mark; + } + if mark != current_highlight { + if !buffer.is_empty() { + let style = if current_highlight { + highlight_style + } else { + normal_style + }; + spans.push(Span::styled(buffer.clone(), style)); + buffer.clear(); + } + current_highlight = mark; + } + buffer.push_str(grapheme); + } + + if !buffer.is_empty() { + let style = if current_highlight { + highlight_style + } else { + normal_style + }; + spans.push(Span::styled(buffer, style)); + } + + if spans.is_empty() { + spans.push(Span::styled(text.to_string(), normal_style)); + } + + spans +} + +struct SearchRenderContext<'a> { + info: Option<&'a ModelSearchInfo>, + highlight_style: Style, +} + +fn build_model_selector_lines<'a>( theme: &owlen_core::theme::Theme, - model: &ModelInfo, - annotated: Option<&AnnotatedModelInfo>, + model: &'a ModelInfo, + annotated: Option<&'a AnnotatedModelInfo>, badges: &[&'static str], - detail: Option<&owlen_core::model::DetailedModelInfo>, + detail: Option<&'a owlen_core::model::DetailedModelInfo>, is_current: bool, + search: SearchRenderContext<'a>, ) -> (Line<'static>, Option>) { let provider_type = annotated .map(|info| info.model.provider.provider_type) @@ -329,19 +508,42 @@ fn build_model_selector_lines( 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)); - } + let name_style = Style::default().fg(theme.text).add_modifier(Modifier::BOLD); + let id_style = Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM); - spans.push(Span::styled( - display_name, - Style::default().fg(theme.text).add_modifier(Modifier::BOLD), - )); + let name_trimmed = model.name.trim(); + if !name_trimmed.is_empty() { + let name_spans = render_highlighted_text( + name_trimmed, + search.info.and_then(|info| info.name.as_ref()), + name_style, + search.highlight_style, + ); + spans.extend(name_spans); + + if !model.id.eq_ignore_ascii_case(name_trimmed) { + spans.push(Span::raw(" ")); + spans.push(Span::styled("·", Style::default().fg(theme.placeholder))); + spans.push(Span::raw(" ")); + let id_spans = render_highlighted_text( + model.id.as_str(), + search.info.and_then(|info| info.id.as_ref()), + id_style, + search.highlight_style, + ); + spans.extend(id_spans); + } + } else { + let id_spans = render_highlighted_text( + model.id.as_str(), + search.info.and_then(|info| info.id.as_ref()), + name_style, + search.highlight_style, + ); + spans.extend(id_spans); + } if !badges.is_empty() { spans.push(Span::raw(" ")); @@ -359,7 +561,7 @@ fn build_model_selector_lines( )); } - let mut meta_parts: Vec = Vec::new(); + let mut meta_tags: Vec = Vec::new(); let mut seen_meta: HashSet = HashSet::new(); let mut push_meta = |value: String| { let trimmed = value.trim(); @@ -368,7 +570,7 @@ fn build_model_selector_lines( } let key = trimmed.to_ascii_lowercase(); if seen_meta.insert(key) { - meta_parts.push(trimmed.to_string()); + meta_tags.push(trimmed.to_string()); } }; @@ -437,22 +639,62 @@ fn build_model_selector_lines( push_meta(format!("max tokens {}", ctx)); } + let mut description_segment: Option<(String, Option)> = None; if let Some(desc) = model.description.as_deref() { let trimmed = desc.trim(); if !trimmed.is_empty() { - meta_parts.push(ellipsize(trimmed, 80)); + let (display, retained, truncated) = ellipsize(trimmed, 80); + let highlight = search + .info + .and_then(|info| info.description.as_ref()) + .filter(|mask| mask.is_marked()) + .map(|mask| { + if truncated { + mask.truncated(retained) + } else { + mask.clone() + } + }); + description_segment = Some((display, highlight)); } } - let metadata = if meta_parts.is_empty() { + let metadata = if meta_tags.is_empty() && description_segment.is_none() { None } else { - Some(Line::from(vec![Span::styled( - format!(" {}", meta_parts.join(" • ")), - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::DIM), - )])) + let meta_style = Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM); + let mut segments: Vec> = Vec::new(); + segments.push(Span::styled(" ", meta_style)); + + let mut first = true; + for tag in meta_tags { + if !first { + segments.push(Span::styled(" • ", meta_style)); + } + segments.push(Span::styled(tag, meta_style)); + first = false; + } + + if let Some((text, highlight)) = description_segment { + if !first { + segments.push(Span::styled(" • ", meta_style)); + } + if let Some(mask) = highlight.as_ref() { + let desc_spans = render_highlighted_text( + text.as_str(), + Some(mask), + meta_style, + search.highlight_style, + ); + segments.extend(desc_spans); + } else { + segments.push(Span::styled(text, meta_style)); + } + } + + Some(Line::from(segments)) }; (Line::from(spans), metadata) @@ -501,18 +743,19 @@ fn clip_line_to_width(line: Line<'_>, max_width: usize) -> Line<'static> { Line::from(clipped) } -fn ellipsize(text: &str, max_chars: usize) -> String { - if text.chars().count() <= max_chars { - return text.to_string(); +fn ellipsize(text: &str, max_graphemes: usize) -> (String, usize, bool) { + let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect(); + if graphemes.len() <= max_graphemes { + return (text.to_string(), graphemes.len(), false); } - let target = max_chars.saturating_sub(1).max(1); + let keep = max_graphemes.saturating_sub(1).max(1); let mut truncated = String::new(); - for ch in text.chars().take(target) { - truncated.push(ch); + for grapheme in graphemes.iter().take(keep) { + truncated.push_str(grapheme); } truncated.push('…'); - truncated + (truncated, keep, true) } fn model_badge_icons(model: &ModelInfo) -> Vec<&'static str> {