feat(tui): model picker UX polish (filters, sizing, search)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -302,7 +302,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
keymap.resolve(InputMode::Normal, &event),
|
||||
Some(AppCommand::OpenModelPicker(FilterMode::All))
|
||||
Some(AppCommand::OpenModelPicker(None))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user