feat(tui): model picker UX polish (filters, sizing, search)

This commit is contained in:
2025-10-17 04:52:38 +02:00
parent 1948ac1284
commit 9588c8c562
4 changed files with 789 additions and 184 deletions

View File

@@ -113,6 +113,39 @@ pub(crate) struct ModelSelectorItem {
kind: ModelSelectorItemKind,
}
#[derive(Clone, Debug)]
pub(crate) struct HighlightMask {
bits: Vec<bool>,
}
impl HighlightMask {
fn new(bits: Vec<bool>) -> 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<HighlightMask>,
pub(crate) id: Option<HighlightMask>,
pub(crate) provider: Option<HighlightMask>,
pub(crate) description: Option<HighlightMask>,
}
#[derive(Clone, Debug)]
pub(crate) enum ModelSelectorItemKind {
Header {
@@ -218,6 +251,85 @@ impl ModelSelectorItem {
}
}
fn collect_lower_graphemes(text: &str) -> (Vec<&str>, Vec<String>) {
let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect();
let lower: Vec<String> = graphemes.iter().map(|g| g.to_lowercase()).collect();
(graphemes, lower)
}
fn subsequence_highlight(candidate: &[String], query: &[String]) -> Option<Vec<bool>> {
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<String> = 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<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_filter_memory: FilterMode, // Last user-selected filter mode
model_search_query: String, // Active fuzzy search query for the picker
model_search_hits: HashMap<usize, ModelSearchInfo>, // Cached search metadata per model index
provider_search_hits: HashMap<String, HighlightMask>, // 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<String, DetailedModelInfo>, // 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<ModelScope, Vec<(usize, &ModelInfo)>> = BTreeMap::new();
for (idx, model) in relevant {
let status_map = self.provider_scope_status.get(provider);
let mut scoped: BTreeMap<ModelScope, Vec<(usize, &ModelInfo)>> = 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<ModelScope> = 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<ModelScope> = 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<ModelSearchInfo> {
let mut info = ModelSearchInfo::default();
let mut best: Option<(usize, usize)> = None;
let mut consider = |candidate: Option<&str>, target: &mut Option<HighlightMask>| {
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<FilterMode>) -> 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);

View File

@@ -6,7 +6,7 @@ use crate::widgets::model_picker::FilterMode;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AppCommand {
OpenModelPicker(FilterMode),
OpenModelPicker(Option<FilterMode>),
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)))
);
}

View File

@@ -302,7 +302,7 @@ mod tests {
);
assert_eq!(
keymap.resolve(InputMode::Normal, &event),
Some(AppCommand::OpenModelPicker(FilterMode::All))
Some(AppCommand::OpenModelPicker(None))
);
}
}

View File

@@ -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<Span<'static>> {
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<Span<'static>> = 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<Line<'static>>) {
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<String> = Vec::new();
let mut meta_tags: Vec<String> = Vec::new();
let mut seen_meta: HashSet<String> = 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<HighlightMask>)> = 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<Span<'static>> = 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> {