feat(tui): model picker UX polish (filters, sizing, search)
This commit is contained in:
@@ -113,6 +113,39 @@ pub(crate) struct ModelSelectorItem {
|
|||||||
kind: ModelSelectorItemKind,
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) enum ModelSelectorItemKind {
|
pub(crate) enum ModelSelectorItemKind {
|
||||||
Header {
|
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)]
|
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
||||||
pub(crate) enum ModelScope {
|
pub(crate) enum ModelScope {
|
||||||
Local,
|
Local,
|
||||||
@@ -294,6 +406,11 @@ pub struct ChatApp {
|
|||||||
pub selected_model_item: Option<usize>, // Index into the flattened model selector list
|
pub selected_model_item: Option<usize>, // Index into the flattened model selector list
|
||||||
model_selector_items: Vec<ModelSelectorItem>, // Flattened provider/model list for selector
|
model_selector_items: Vec<ModelSelectorItem>, // Flattened provider/model list for selector
|
||||||
model_filter_mode: FilterMode, // Active filter applied to the model list
|
model_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_info_panel: ModelInfoPanel, // Dedicated model information viewer
|
||||||
model_details_cache: HashMap<String, DetailedModelInfo>, // Cached detailed metadata per model
|
model_details_cache: HashMap<String, DetailedModelInfo>, // Cached detailed metadata per model
|
||||||
show_model_info: bool, // Whether the model info panel is visible
|
show_model_info: bool, // Whether the model info panel is visible
|
||||||
@@ -557,6 +674,11 @@ impl ChatApp {
|
|||||||
selected_model_item: None,
|
selected_model_item: None,
|
||||||
model_selector_items: Vec::new(),
|
model_selector_items: Vec::new(),
|
||||||
model_filter_mode: FilterMode::All,
|
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_info_panel: ModelInfoPanel::new(),
|
||||||
model_details_cache: HashMap::new(),
|
model_details_cache: HashMap::new(),
|
||||||
show_model_info: false,
|
show_model_info: false,
|
||||||
@@ -1359,10 +1481,86 @@ impl ChatApp {
|
|||||||
self.model_filter_mode
|
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 {
|
if self.model_filter_mode != mode {
|
||||||
self.model_filter_mode = mode;
|
self.model_filter_mode = mode;
|
||||||
|
self.model_filter_memory = mode;
|
||||||
self.rebuild_model_selector_items();
|
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 {
|
if self.mode != mode {
|
||||||
self.mode_flash_until = Some(Instant::now() + Duration::from_millis(240));
|
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;
|
self.mode = mode;
|
||||||
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode }));
|
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode }));
|
||||||
}
|
}
|
||||||
@@ -5462,7 +5669,7 @@ impl ChatApp {
|
|||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
(KeyCode::Char('m'), KeyModifiers::NONE) => {
|
(KeyCode::Char('m'), KeyModifiers::NONE) => {
|
||||||
if let Err(err) = self.show_model_picker(FilterMode::All).await {
|
if let Err(err) = self.show_model_picker(None).await {
|
||||||
self.error = Some(err.to_string());
|
self.error = Some(err.to_string());
|
||||||
}
|
}
|
||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
@@ -6315,9 +6522,7 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
"m" | "model" => {
|
"m" | "model" => {
|
||||||
if args.is_empty() {
|
if args.is_empty() {
|
||||||
if let Err(err) =
|
if let Err(err) = self.show_model_picker(None).await {
|
||||||
self.show_model_picker(FilterMode::All).await
|
|
||||||
{
|
|
||||||
self.error = Some(err.to_string());
|
self.error = Some(err.to_string());
|
||||||
}
|
}
|
||||||
self.command_palette.clear();
|
self.command_palette.clear();
|
||||||
@@ -6508,9 +6713,7 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
"models" => {
|
"models" => {
|
||||||
if args.is_empty() {
|
if args.is_empty() {
|
||||||
if let Err(err) =
|
if let Err(err) = self.show_model_picker(None).await {
|
||||||
self.show_model_picker(FilterMode::All).await
|
|
||||||
{
|
|
||||||
self.error = Some(err.to_string());
|
self.error = Some(err.to_string());
|
||||||
}
|
}
|
||||||
self.command_palette.clear();
|
self.command_palette.clear();
|
||||||
@@ -6519,8 +6722,9 @@ impl ChatApp {
|
|||||||
|
|
||||||
match args[0] {
|
match args[0] {
|
||||||
"--local" => {
|
"--local" => {
|
||||||
if let Err(err) =
|
if let Err(err) = self
|
||||||
self.show_model_picker(FilterMode::LocalOnly).await
|
.show_model_picker(Some(FilterMode::LocalOnly))
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
self.error = Some(err.to_string());
|
self.error = Some(err.to_string());
|
||||||
} else if !self
|
} else if !self
|
||||||
@@ -6536,8 +6740,9 @@ impl ChatApp {
|
|||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
"--cloud" => {
|
"--cloud" => {
|
||||||
if let Err(err) =
|
if let Err(err) = self
|
||||||
self.show_model_picker(FilterMode::CloudOnly).await
|
.show_model_picker(Some(FilterMode::CloudOnly))
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
self.error = Some(err.to_string());
|
self.error = Some(err.to_string());
|
||||||
} else if !self
|
} else if !self
|
||||||
@@ -6553,8 +6758,9 @@ impl ChatApp {
|
|||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
"--available" => {
|
"--available" => {
|
||||||
if let Err(err) =
|
if let Err(err) = self
|
||||||
self.show_model_picker(FilterMode::Available).await
|
.show_model_picker(Some(FilterMode::Available))
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
self.error = Some(err.to_string());
|
self.error = Some(err.to_string());
|
||||||
} else if !self.focus_first_available_model() {
|
} 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 {
|
InputMode::Help => match key.code {
|
||||||
@@ -8124,6 +8341,9 @@ impl ChatApp {
|
|||||||
|
|
||||||
fn rebuild_model_selector_items(&mut self) {
|
fn rebuild_model_selector_items(&mut self) {
|
||||||
let mut items = Vec::new();
|
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() {
|
if self.available_providers.is_empty() {
|
||||||
items.push(ModelSelectorItem::header(
|
items.push(ModelSelectorItem::header(
|
||||||
@@ -8136,35 +8356,49 @@ impl ChatApp {
|
|||||||
return;
|
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();
|
let expanded = self.expanded_provider.clone();
|
||||||
|
|
||||||
for provider in &self.available_providers {
|
for provider in &self.available_providers {
|
||||||
let is_expanded = expanded.as_ref().map(|p| p == provider).unwrap_or(false);
|
let provider_lower = provider.to_ascii_lowercase();
|
||||||
let provider_status = self.provider_overall_status(provider);
|
let provider_status = self.provider_overall_status(provider);
|
||||||
let provider_type = self.provider_type_for(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(),
|
provider.clone(),
|
||||||
is_expanded,
|
is_expanded,
|
||||||
provider_status,
|
provider_status,
|
||||||
provider_type,
|
provider_type,
|
||||||
));
|
));
|
||||||
|
|
||||||
if is_expanded {
|
if !is_expanded {
|
||||||
let relevant: Vec<(usize, &ModelInfo)> = self
|
items.extend(provider_block);
|
||||||
.models
|
continue;
|
||||||
.iter()
|
}
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, model)| &model.provider == provider)
|
let status_map = self.provider_scope_status.get(provider);
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut scoped: BTreeMap<ModelScope, Vec<(usize, &ModelInfo)>> = BTreeMap::new();
|
let mut scoped: BTreeMap<ModelScope, Vec<(usize, &ModelInfo)>> = BTreeMap::new();
|
||||||
for (idx, model) in relevant {
|
for (idx, model) in self.models.iter().enumerate() {
|
||||||
|
if &model.provider == provider {
|
||||||
let scope = Self::model_scope_from_capabilities(model);
|
let scope = Self::model_scope_from_capabilities(model);
|
||||||
scoped.entry(scope).or_default().push((idx, 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();
|
let mut scopes_to_render: BTreeSet<ModelScope> = BTreeSet::new();
|
||||||
scopes_to_render.extend(scoped.keys().cloned());
|
scopes_to_render.extend(scoped.keys().cloned());
|
||||||
@@ -8174,25 +8408,45 @@ impl ChatApp {
|
|||||||
|
|
||||||
let mut rendered_scope = false;
|
let mut rendered_scope = false;
|
||||||
let mut rendered_body = false;
|
let mut rendered_body = false;
|
||||||
|
let mut provider_has_models = false;
|
||||||
|
|
||||||
for scope in scopes_to_render {
|
for scope in scopes_to_render {
|
||||||
if !self.filter_allows_scope(&scope) {
|
if !self.filter_allows_scope(&scope) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
rendered_scope = true;
|
|
||||||
let entries = scoped.get(&scope).cloned().unwrap_or_default();
|
let entries = scoped.get(&scope).cloned().unwrap_or_default();
|
||||||
let deduped =
|
let deduped = Self::deduplicate_models_for_scope(entries, &provider_lower, &scope);
|
||||||
Self::deduplicate_models_for_scope(entries, &provider_lower, &scope);
|
|
||||||
|
|
||||||
let status_entry = status_map
|
let status_entry = status_map
|
||||||
.and_then(|map| map.get(&scope))
|
.and_then(|map| map.get(&scope))
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let label =
|
|
||||||
Self::scope_header_label(&scope, &status_entry, self.model_filter_mode);
|
|
||||||
|
|
||||||
items.push(ModelSelectorItem::scope(
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if search_active && filtered.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered_scope = true;
|
||||||
|
|
||||||
|
let label = Self::scope_header_label(&scope, &status_entry, self.model_filter_mode);
|
||||||
|
provider_block.push(ModelSelectorItem::scope(
|
||||||
provider.clone(),
|
provider.clone(),
|
||||||
label,
|
label,
|
||||||
scope.clone(),
|
scope.clone(),
|
||||||
@@ -8204,80 +8458,176 @@ impl ChatApp {
|
|||||||
|| status_entry.message.is_some()
|
|| status_entry.message.is_some()
|
||||||
{
|
{
|
||||||
if let Some(summary) = Self::scope_status_summary(&status_entry) {
|
if let Some(summary) = Self::scope_status_summary(&status_entry) {
|
||||||
rendered_body = true;
|
provider_block.push(ModelSelectorItem::empty(
|
||||||
items.push(ModelSelectorItem::empty(
|
|
||||||
provider.clone(),
|
provider.clone(),
|
||||||
Some(summary),
|
Some(summary),
|
||||||
Some(status_entry.state),
|
Some(status_entry.state),
|
||||||
));
|
));
|
||||||
|
rendered_body = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let scope_allowed = self.filter_scope_allows_models(&scope, &status_entry);
|
let scope_allowed = self.filter_scope_allows_models(&scope, &status_entry);
|
||||||
|
|
||||||
if deduped.is_empty() {
|
if filtered.is_empty() {
|
||||||
if !scope_allowed {
|
if !scope_allowed {
|
||||||
let message = self.scope_filter_message(&scope, &status_entry);
|
if let Some(msg) = self.scope_filter_message(&scope, &status_entry) {
|
||||||
if let Some(msg) = message {
|
provider_block.push(ModelSelectorItem::empty(
|
||||||
rendered_body = true;
|
|
||||||
items.push(ModelSelectorItem::empty(
|
|
||||||
provider.clone(),
|
provider.clone(),
|
||||||
Some(msg),
|
Some(msg),
|
||||||
Some(status_entry.state),
|
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;
|
rendered_body = true;
|
||||||
items.push(ModelSelectorItem::empty(
|
}
|
||||||
|
} 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(),
|
provider.clone(),
|
||||||
Some(message),
|
Some(message),
|
||||||
Some(status_entry.state),
|
Some(status_entry.state),
|
||||||
));
|
));
|
||||||
|
rendered_body = true;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !scope_allowed {
|
if !scope_allowed {
|
||||||
let message = self.scope_filter_message(&scope, &status_entry);
|
if let Some(msg) = self.scope_filter_message(&scope, &status_entry) {
|
||||||
if let Some(msg) = message {
|
provider_block.push(ModelSelectorItem::empty(
|
||||||
rendered_body = true;
|
|
||||||
items.push(ModelSelectorItem::empty(
|
|
||||||
provider.clone(),
|
provider.clone(),
|
||||||
Some(msg),
|
Some(msg),
|
||||||
Some(status_entry.state),
|
Some(status_entry.state),
|
||||||
));
|
));
|
||||||
|
rendered_body = true;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
rendered_body = true;
|
rendered_body = true;
|
||||||
for (idx, _) in deduped {
|
provider_has_models = true;
|
||||||
items.push(ModelSelectorItem::model(provider.clone(), idx));
|
|
||||||
|
for (idx, _) in filtered {
|
||||||
|
provider_block.push(ModelSelectorItem::model(provider.clone(), idx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !rendered_scope || !rendered_body {
|
if !provider_has_models && search_active && provider_highlight.is_some() {
|
||||||
items.push(ModelSelectorItem::empty(provider.clone(), None, None));
|
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.model_selector_items = items;
|
||||||
self.ensure_valid_model_selection();
|
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 {
|
fn provider_scope_state(&self, provider: &str, scope: &ModelScope) -> ModelAvailabilityState {
|
||||||
@@ -9044,14 +9394,26 @@ impl ChatApp {
|
|||||||
Ok(())
|
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?;
|
self.refresh_models().await?;
|
||||||
|
|
||||||
if self.models.is_empty() {
|
if self.models.is_empty() {
|
||||||
return Ok(());
|
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 {
|
if self.available_providers.len() <= 1 {
|
||||||
self.set_input_mode(InputMode::ModelSelection);
|
self.set_input_mode(InputMode::ModelSelection);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::widgets::model_picker::FilterMode;
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum AppCommand {
|
pub enum AppCommand {
|
||||||
OpenModelPicker(FilterMode),
|
OpenModelPicker(Option<FilterMode>),
|
||||||
OpenCommandPalette,
|
OpenCommandPalette,
|
||||||
CycleFocusForward,
|
CycleFocusForward,
|
||||||
CycleFocusBackward,
|
CycleFocusBackward,
|
||||||
@@ -26,19 +26,19 @@ impl CommandRegistry {
|
|||||||
|
|
||||||
commands.insert(
|
commands.insert(
|
||||||
"model.open_all".to_string(),
|
"model.open_all".to_string(),
|
||||||
AppCommand::OpenModelPicker(FilterMode::All),
|
AppCommand::OpenModelPicker(None),
|
||||||
);
|
);
|
||||||
commands.insert(
|
commands.insert(
|
||||||
"model.open_local".to_string(),
|
"model.open_local".to_string(),
|
||||||
AppCommand::OpenModelPicker(FilterMode::LocalOnly),
|
AppCommand::OpenModelPicker(Some(FilterMode::LocalOnly)),
|
||||||
);
|
);
|
||||||
commands.insert(
|
commands.insert(
|
||||||
"model.open_cloud".to_string(),
|
"model.open_cloud".to_string(),
|
||||||
AppCommand::OpenModelPicker(FilterMode::CloudOnly),
|
AppCommand::OpenModelPicker(Some(FilterMode::CloudOnly)),
|
||||||
);
|
);
|
||||||
commands.insert(
|
commands.insert(
|
||||||
"model.open_available".to_string(),
|
"model.open_available".to_string(),
|
||||||
AppCommand::OpenModelPicker(FilterMode::Available),
|
AppCommand::OpenModelPicker(Some(FilterMode::Available)),
|
||||||
);
|
);
|
||||||
commands.insert("palette.open".to_string(), AppCommand::OpenCommandPalette);
|
commands.insert("palette.open".to_string(), AppCommand::OpenCommandPalette);
|
||||||
commands.insert("focus.next".to_string(), AppCommand::CycleFocusForward);
|
commands.insert("focus.next".to_string(), AppCommand::CycleFocusForward);
|
||||||
@@ -93,7 +93,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.resolve("model.open_cloud"),
|
registry.resolve("model.open_cloud"),
|
||||||
Some(AppCommand::OpenModelPicker(FilterMode::CloudOnly))
|
Some(AppCommand::OpenModelPicker(Some(FilterMode::CloudOnly)))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
keymap.resolve(InputMode::Normal, &event),
|
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_segmentation::UnicodeSegmentation;
|
||||||
use unicode_width::UnicodeWidthStr;
|
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.
|
/// Filtering modes for the model picker popup.
|
||||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
@@ -36,16 +39,21 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let max_width: u16 = 80;
|
let search_query = app.model_search_query().trim().to_string();
|
||||||
let min_width: u16 = 50;
|
let search_active = !search_query.is_empty();
|
||||||
let mut width = area.width.min(max_width);
|
|
||||||
if area.width >= min_width {
|
|
||||||
width = width.max(min_width);
|
|
||||||
}
|
|
||||||
width = width.max(1);
|
|
||||||
|
|
||||||
let mut height = (selector_items.len().clamp(1, 10) as u16) * 3 + 6;
|
let max_width = area.width.min(90);
|
||||||
height = height.clamp(6, area.height);
|
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 x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||||
let mut y = area.y + (area.height.saturating_sub(height)) / 3;
|
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 {
|
if inner.width == 0 || inner.height == 0 {
|
||||||
return;
|
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()
|
let layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Min(4), Constraint::Length(2)])
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(4),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
.split(inner);
|
.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 active_model_id = app.selected_model();
|
||||||
let annotated = app.annotated_models();
|
let annotated = app.annotated_models();
|
||||||
|
|
||||||
@@ -108,12 +211,19 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
spans.push(status_icon(*status, theme));
|
spans.push(status_icon(*status, theme));
|
||||||
spans.push(Span::raw(" "));
|
spans.push(Span::raw(" "));
|
||||||
spans.push(Span::styled(
|
let header_spans = render_highlighted_text(
|
||||||
provider.clone(),
|
provider,
|
||||||
|
if search_active {
|
||||||
|
app.provider_search_highlight(provider)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(theme.mode_command)
|
.fg(theme.mode_command)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
));
|
highlight_style,
|
||||||
|
);
|
||||||
|
spans.extend(header_spans);
|
||||||
spans.push(Span::raw(" "));
|
spans.push(Span::raw(" "));
|
||||||
spans.push(provider_type_badge(*provider_type, theme));
|
spans.push(provider_type_badge(*provider_type, theme));
|
||||||
spans.push(Span::raw(" "));
|
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 badges = model_badge_icons(model);
|
||||||
let detail = app.cached_model_detail(&model.id);
|
let detail = app.cached_model_detail(&model.id);
|
||||||
let annotated_model = annotated.get(*model_index);
|
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(
|
let (title, metadata) = build_model_selector_lines(
|
||||||
theme,
|
theme,
|
||||||
model,
|
model,
|
||||||
@@ -152,6 +267,10 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
&badges,
|
&badges,
|
||||||
detail,
|
detail,
|
||||||
model.id == active_model_id,
|
model.id == active_model_id,
|
||||||
|
SearchRenderContext {
|
||||||
|
info: search_info,
|
||||||
|
highlight_style,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
lines.push(clip_line_to_width(title, max_line_width));
|
lines.push(clip_line_to_width(title, max_line_width));
|
||||||
if let Some(meta) = metadata {
|
if let Some(meta) = metadata {
|
||||||
@@ -176,14 +295,9 @@ pub fn render_model_picker(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|msg| msg.as_str())
|
.map(|msg| msg.as_str())
|
||||||
.unwrap_or("(no models configured)");
|
.unwrap_or("(no models configured)");
|
||||||
let line = clip_line_to_width(
|
let mut spans = vec![Span::styled(icon, style), Span::raw(" ")];
|
||||||
Line::from(vec![
|
spans.push(Span::styled(format!(" {}", msg), style));
|
||||||
Span::styled(icon, style),
|
let line = clip_line_to_width(Line::from(spans), max_line_width);
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(format!(" {}", msg), style),
|
|
||||||
]),
|
|
||||||
max_line_width,
|
|
||||||
);
|
|
||||||
items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background)));
|
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(" ");
|
.highlight_symbol(" ");
|
||||||
|
|
||||||
let mut state = ListState::default();
|
let mut state = ListState::default();
|
||||||
state.select(app.selected_model_item);
|
state.select(app.selected_model_item());
|
||||||
frame.render_stateful_widget(list, layout[0], &mut state);
|
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(
|
let footer = Paragraph::new(Line::from(Span::styled(
|
||||||
"Enter: select · Space: toggle provider · ←/→ collapse/expand · Esc: cancel",
|
footer_text,
|
||||||
Style::default().fg(theme.placeholder),
|
Style::default().fg(theme.placeholder),
|
||||||
)))
|
)))
|
||||||
.alignment(ratatui::layout::Alignment::Center)
|
.alignment(ratatui::layout::Alignment::Center)
|
||||||
.style(Style::default().bg(theme.background).fg(theme.placeholder));
|
.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> {
|
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,
|
theme: &owlen_core::theme::Theme,
|
||||||
model: &ModelInfo,
|
model: &'a ModelInfo,
|
||||||
annotated: Option<&AnnotatedModelInfo>,
|
annotated: Option<&'a AnnotatedModelInfo>,
|
||||||
badges: &[&'static str],
|
badges: &[&'static str],
|
||||||
detail: Option<&owlen_core::model::DetailedModelInfo>,
|
detail: Option<&'a owlen_core::model::DetailedModelInfo>,
|
||||||
is_current: bool,
|
is_current: bool,
|
||||||
|
search: SearchRenderContext<'a>,
|
||||||
) -> (Line<'static>, Option<Line<'static>>) {
|
) -> (Line<'static>, Option<Line<'static>>) {
|
||||||
let provider_type = annotated
|
let provider_type = annotated
|
||||||
.map(|info| info.model.provider.provider_type)
|
.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(provider_type_badge(provider_type, theme));
|
||||||
spans.push(Span::raw(" "));
|
spans.push(Span::raw(" "));
|
||||||
|
|
||||||
let mut display_name = if model.name.trim().is_empty() {
|
let name_style = Style::default().fg(theme.text).add_modifier(Modifier::BOLD);
|
||||||
model.id.clone()
|
let id_style = Style::default()
|
||||||
} else {
|
.fg(theme.placeholder)
|
||||||
model.name.clone()
|
.add_modifier(Modifier::DIM);
|
||||||
};
|
|
||||||
if !display_name.eq_ignore_ascii_case(&model.id) {
|
|
||||||
display_name.push_str(&format!(" · {}", model.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
spans.push(Span::styled(
|
let name_trimmed = model.name.trim();
|
||||||
display_name,
|
if !name_trimmed.is_empty() {
|
||||||
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
|
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() {
|
if !badges.is_empty() {
|
||||||
spans.push(Span::raw(" "));
|
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 seen_meta: HashSet<String> = HashSet::new();
|
||||||
let mut push_meta = |value: String| {
|
let mut push_meta = |value: String| {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
@@ -368,7 +570,7 @@ fn build_model_selector_lines(
|
|||||||
}
|
}
|
||||||
let key = trimmed.to_ascii_lowercase();
|
let key = trimmed.to_ascii_lowercase();
|
||||||
if seen_meta.insert(key) {
|
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));
|
push_meta(format!("max tokens {}", ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut description_segment: Option<(String, Option<HighlightMask>)> = None;
|
||||||
if let Some(desc) = model.description.as_deref() {
|
if let Some(desc) = model.description.as_deref() {
|
||||||
let trimmed = desc.trim();
|
let trimmed = desc.trim();
|
||||||
if !trimmed.is_empty() {
|
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
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(Line::from(vec![Span::styled(
|
let meta_style = Style::default()
|
||||||
format!(" {}", meta_parts.join(" • ")),
|
|
||||||
Style::default()
|
|
||||||
.fg(theme.placeholder)
|
.fg(theme.placeholder)
|
||||||
.add_modifier(Modifier::DIM),
|
.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)
|
(Line::from(spans), metadata)
|
||||||
@@ -501,18 +743,19 @@ fn clip_line_to_width(line: Line<'_>, max_width: usize) -> Line<'static> {
|
|||||||
Line::from(clipped)
|
Line::from(clipped)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ellipsize(text: &str, max_chars: usize) -> String {
|
fn ellipsize(text: &str, max_graphemes: usize) -> (String, usize, bool) {
|
||||||
if text.chars().count() <= max_chars {
|
let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect();
|
||||||
return text.to_string();
|
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();
|
let mut truncated = String::new();
|
||||||
for ch in text.chars().take(target) {
|
for grapheme in graphemes.iter().take(keep) {
|
||||||
truncated.push(ch);
|
truncated.push_str(grapheme);
|
||||||
}
|
}
|
||||||
truncated.push('…');
|
truncated.push('…');
|
||||||
truncated
|
(truncated, keep, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn model_badge_icons(model: &ModelInfo) -> Vec<&'static str> {
|
fn model_badge_icons(model: &ModelInfo) -> Vec<&'static str> {
|
||||||
|
|||||||
Reference in New Issue
Block a user