new model popup

This commit is contained in:
pap
2025-07-31 15:00:08 +01:00
parent f8e5b02320
commit fbc1ee7d62
7 changed files with 453 additions and 474 deletions

View File

@@ -284,6 +284,11 @@ impl App<'_> {
widget.update_model_and_reconfigure(model);
}
}
AppEvent::OpenModelSelector => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_model_selector();
}
}
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
AppState::GitWarning { .. } => {}
@@ -340,21 +345,13 @@ impl App<'_> {
}));
}
SlashCommand::Model => {
// No explicit args were provided; open the selector.
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_model_selector();
}
// Disallow `/model` without arguments; no action.
}
},
AppEvent::DispatchCommandWithArgs(command, args) => match command {
SlashCommand::Model => {
let arg = args.trim();
if arg.is_empty() {
// Same as `/model` without args.
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_model_selector();
}
} else if let AppState::Chat { widget } = &mut self.app_state {
if let AppState::Chat { widget } = &mut self.app_state {
// Normalize commonly quoted inputs like \"o3\" or 'o3' or “o3”.
let normalized = strip_surrounding_quotes(arg).trim().to_string();
if !normalized.is_empty() {

View File

@@ -55,4 +55,7 @@ pub(crate) enum AppEvent {
/// User selected a model from the model-selection dropdown.
SelectModel(String),
/// Request the app to open the model selector (populate options and show popup).
OpenModelSelector,
}

View File

@@ -52,10 +52,4 @@ pub(crate) trait BottomPaneView<'a> {
) -> Option<ApprovalRequest> {
Some(request)
}
/// Optional hook: if this view is a model selector, update the options.
/// Returns true if options were applied and a redraw is desired.
fn set_model_options(&mut self, _current_model: &str, _options: Vec<String>) -> bool {
false
}
}

View File

@@ -19,10 +19,12 @@ use tui_textarea::TextArea;
use super::chat_composer_history::ChatComposerHistory;
use super::command_popup::CommandPopup;
use super::file_search_popup::FileSearchPopup;
use super::model_selection_popup::ModelSelectionPopup;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use codex_file_search::FileMatch;
use crate::slash_command::SlashCommand;
const BASE_PLACEHOLDER_TEXT: &str = "...";
/// If the pasted content exceeds this number of characters, replace it with a
@@ -51,6 +53,7 @@ enum ActivePopup {
None,
Command(CommandPopup),
File(FileSearchPopup),
Model(ModelSelectionPopup),
}
impl ChatComposer<'_> {
@@ -79,6 +82,7 @@ impl ChatComposer<'_> {
ActivePopup::None => 1u16,
ActivePopup::Command(c) => c.calculate_required_height(),
ActivePopup::File(c) => c.calculate_required_height(),
ActivePopup::Model(c) => c.calculate_required_height(),
}
}
@@ -174,20 +178,46 @@ impl ChatComposer<'_> {
self.update_border(has_focus);
}
/// Open or update the model-selection popup with the provided options.
pub(crate) fn open_model_selector(&mut self, current_model: &str, options: Vec<String>) {
match &mut self.active_popup {
ActivePopup::Model(popup) => {
popup.set_options(current_model, options);
}
_ => {
self.active_popup = ActivePopup::Model(ModelSelectionPopup::new(current_model, options));
}
}
// Initialize/update the query from the composer.
self.sync_model_popup();
}
/// Handle a key event coming from the main UI.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let result = match &mut self.active_popup {
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
ActivePopup::Model(_) => self.handle_key_event_with_model_popup(key_event),
ActivePopup::None => self.handle_key_event_without_popup(key_event),
};
// Update (or hide/show) popup after processing the key.
self.sync_command_popup();
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.dismissed_file_popup_token = None;
} else {
self.sync_file_search_popup();
match &self.active_popup {
ActivePopup::Model(_) => {
// Only keep model popup in sync when active; do not interfere with other popups.
self.sync_model_popup();
}
ActivePopup::Command(_) => {
self.sync_command_popup();
// When slash popup active, suppress file popup.
self.dismissed_file_popup_token = None;
}
_ => {
self.sync_command_popup();
if !matches!(self.active_popup, ActivePopup::Command(_)) {
self.sync_file_search_popup();
}
}
}
result
@@ -253,11 +283,21 @@ impl ChatComposer<'_> {
String::new()
};
// If `/model` has no args, autocomplete to "/model " and open the model selector.
if *cmd == SlashCommand::Model && args.trim().is_empty() {
self.textarea.select_all();
self.textarea.cut();
let _ = self.textarea.insert_str(format!("/{} ", cmd.command()));
// Hide the command popup; model popup will be shown by sync_command_popup.
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
// Send command + args to the app layer.
self.app_event_tx
.send(AppEvent::DispatchCommandWithArgs(*cmd, args));
// Clear textarea so no residual text remains.
// Clear textarea so no residual text remains (default behavior).
self.textarea.select_all();
self.textarea.cut();
@@ -315,6 +355,47 @@ impl ChatComposer<'_> {
}
}
/// Handle key events when model selection popup is visible.
fn handle_key_event_with_model_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let ActivePopup::Model(popup) = &mut self.active_popup else {
unreachable!();
};
match key_event.into() {
Input { key: Key::Up, .. } => {
popup.move_up();
(InputResult::None, true)
}
Input { key: Key::Down, .. } => {
popup.move_down();
(InputResult::None, true)
}
Input { key: Key::Esc, .. } => {
// Hide model popup; keep composer content unchanged.
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
Input {
key: Key::Enter,
ctrl: false,
alt: false,
shift: false,
} => {
if let Some(model) = popup.selected_model() {
self.app_event_tx.send(AppEvent::SelectModel(model));
// Clear composer input and close the popup.
self.textarea.select_all();
self.textarea.cut();
self.pending_pastes.clear();
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
(InputResult::None, false)
}
input => self.handle_input_basic(input),
}
}
/// Extract the `@token` that the cursor is currently positioned on, if any.
///
/// The returned string **does not** include the leading `@`.
@@ -600,9 +681,32 @@ impl ChatComposer<'_> {
.unwrap_or("");
let input_starts_with_slash = first_line.starts_with('/');
// Special handling: if the user typed `/model ` (with a space), open the model selector
// and do not show the slash-command popup.
let should_open_model_selector = if let Some(stripped) = first_line.strip_prefix('/') {
let token = stripped.trim_start();
let cmd_token = token.split_whitespace().next().unwrap_or("");
if cmd_token == SlashCommand::Model.command() {
let rest = &token[cmd_token.len()..];
// Show model popup as soon as a whitespace after the command is present.
rest.chars().next().is_some_and(|c| c.is_whitespace())
} else {
false
}
} else {
false
};
match &mut self.active_popup {
ActivePopup::Command(popup) => {
if input_starts_with_slash {
if should_open_model_selector {
// Switch away from command popup and request opening the model selector.
self.active_popup = ActivePopup::None;
self.app_event_tx.send(AppEvent::OpenModelSelector);
return;
}
popup.on_composer_text_change(first_line.to_string());
} else {
self.active_popup = ActivePopup::None;
@@ -610,9 +714,15 @@ impl ChatComposer<'_> {
}
_ => {
if input_starts_with_slash {
let mut command_popup = CommandPopup::new();
command_popup.on_composer_text_change(first_line.to_string());
self.active_popup = ActivePopup::Command(command_popup);
if should_open_model_selector {
// Request the app to open the model selector; popup will render once options arrive.
self.app_event_tx.send(AppEvent::OpenModelSelector);
return;
} else {
let mut command_popup = CommandPopup::new();
command_popup.on_composer_text_change(first_line.to_string());
self.active_popup = ActivePopup::Command(command_popup);
}
}
}
}
@@ -654,6 +764,36 @@ impl ChatComposer<'_> {
self.dismissed_file_popup_token = None;
}
/// Synchronize the model-selection popup filter with the current composer text.
/// When the first line starts with `/model`, everything after the command becomes the query.
fn sync_model_popup(&mut self) {
let first_line = self
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");
// Expect `/model` as the first token on the first line.
if let Some(stripped) = first_line.strip_prefix('/') {
let token = stripped.trim_start();
let cmd_token = token.split_whitespace().next().unwrap_or("");
if cmd_token == SlashCommand::Model.command() {
let rest = &token[cmd_token.len()..];
let args = rest.trim_start();
if let ActivePopup::Model(popup) = &mut self.active_popup {
popup.set_query(args);
}
return;
}
}
// Not a `/model` line anymore; hide the model popup if visible.
if matches!(self.active_popup, ActivePopup::Model(_)) {
self.active_popup = ActivePopup::None;
}
}
fn update_border(&mut self, has_focus: bool) {
let border_style = if has_focus {
Style::default().fg(Color::Cyan)
@@ -715,6 +855,26 @@ impl WidgetRef for &ChatComposer<'_> {
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
}
ActivePopup::Model(popup) => {
let popup_height = popup.calculate_required_height();
let popup_height = popup_height.min(area.height);
let textarea_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.saturating_sub(popup_height),
};
let popup_rect = Rect {
x: area.x,
y: area.y + textarea_rect.height,
width: area.width,
height: popup_height,
};
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
}
ActivePopup::None => {
let mut textarea_rect = area;
textarea_rect.height = textarea_rect.height.saturating_sub(1);

View File

@@ -18,7 +18,7 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
mod file_search_popup;
mod model_selection_view;
mod model_selection_popup;
mod status_indicator_view;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -31,7 +31,6 @@ pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
use approval_modal_view::ApprovalModalView;
use model_selection_view::ModelSelectionView;
use status_indicator_view::StatusIndicatorView;
/// Pane displayed in the lower half of the chat UI.
@@ -66,12 +65,9 @@ impl BottomPane<'_> {
}
}
/// Show the model-selection dropdown view.
/// Show the model-selection popup in the composer.
pub(crate) fn show_model_selector(&mut self, current_model: &str, options: Vec<String>) {
let mut view = ModelSelectionView::new(current_model, self.app_event_tx.clone());
// Apply options so the view starts populated and not in a loading state.
let _ = view.set_model_options(current_model, options);
self.active_view = Some(Box::new(view));
self.composer.open_model_selector(current_model, options);
self.request_redraw();
}

View File

@@ -0,0 +1,271 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Constraint;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, Table, Widget, WidgetRef};
/// Maximum number of options shown in the popup.
const MAX_RESULTS: usize = 8;
/// Visual state for the model-selection popup.
pub(crate) struct ModelSelectionPopup {
/// The current model (pinned and color-coded when visible).
current_model: String,
/// All available model options (deduplicated externally as needed).
options: Vec<String>,
/// Current filter query (derived from the composer, e.g. after `/model`).
query: String,
/// Currently selected index among the visible rows (if any).
selected_idx: Option<usize>,
}
impl ModelSelectionPopup {
pub(crate) fn new(current_model: &str, options: Vec<String>) -> Self {
Self {
current_model: current_model.to_string(),
options,
query: String::new(),
selected_idx: None,
}
}
/// Update the current model and option list. Resets/clamps selection as needed.
pub(crate) fn set_options(&mut self, current_model: &str, options: Vec<String>) {
self.current_model = current_model.to_string();
self.options = options;
let visible_len = self.visible_rows().len();
self.selected_idx = match visible_len {
0 => None,
_ => Some(self.selected_idx.unwrap_or(0).min(visible_len - 1)),
};
}
/// Update the fuzzy filter query.
pub(crate) fn set_query(&mut self, query: &str) {
if self.query == query { return; }
self.query.clear();
self.query.push_str(query);
// Reset/clamp selection based on new filtered list.
let visible_len = self.visible_rows().len();
self.selected_idx = match visible_len {
0 => None,
_ => Some(0),
};
}
/// Move selection cursor up.
pub(crate) fn move_up(&mut self) {
if let Some(idx) = self.selected_idx {
if idx > 0 {
self.selected_idx = Some(idx - 1);
}
} else if !self.visible_rows().is_empty() {
self.selected_idx = Some(0);
}
}
/// Move selection cursor down.
pub(crate) fn move_down(&mut self) {
let len = self.visible_rows().len();
if len == 0 { self.selected_idx = None; return; }
match self.selected_idx {
Some(idx) if idx + 1 < len => self.selected_idx = Some(idx + 1),
None => self.selected_idx = Some(0),
_ => {}
}
}
/// Currently selected model name, if any.
pub(crate) fn selected_model(&self) -> Option<String> {
let rows = self.visible_rows();
self.selected_idx.and_then(|idx| match rows.get(idx) {
Some(DisplayRow::Model { name, .. }) => Some(name.clone()),
None => None,
})
}
/// Preferred height (rows) including border.
pub(crate) fn calculate_required_height(&self) -> u16 {
self.visible_rows().len().clamp(1, MAX_RESULTS) as u16
}
/// Compute rows to display applying fuzzy filtering and pinning current model.
fn visible_rows(&self) -> Vec<DisplayRow> {
// Build candidate list excluding the current model.
let mut others: Vec<&str> = self
.options
.iter()
.map(|s| s.as_str())
.filter(|m| *m != self.current_model)
.collect();
// Keep original ordering for non-search.
if self.query.trim().is_empty() {
let mut rows: Vec<DisplayRow> = Vec::new();
// Current model first.
rows.push(DisplayRow::Model {
name: self.current_model.clone(),
match_indices: None,
is_current: true,
});
for name in others.drain(..) {
rows.push(DisplayRow::Model {
name: name.to_string(),
match_indices: None,
is_current: false,
});
}
return rows;
}
// Searching: include current model only if it matches.
let mut rows: Vec<DisplayRow> = Vec::new();
if let Some(indices) = fuzzy_indices(&self.current_model, &self.query) {
rows.push(DisplayRow::Model {
name: self.current_model.clone(),
match_indices: Some(indices),
is_current: true,
});
}
// Fuzzy-match the rest and sort by score, then name, then match tightness.
let mut matches: Vec<(String, Vec<usize>, i32)> = Vec::new();
for name in others.into_iter() {
if let Some((indices, score)) = fuzzy_match(name, &self.query) {
matches.push((name.to_string(), indices, score));
}
}
matches.sort_by(|(a_name, a_idx, a_score), (b_name, b_idx, b_score)| {
a_score
.cmp(b_score)
.then_with(|| a_name.cmp(b_name))
.then_with(|| a_idx.len().cmp(&b_idx.len()))
});
for (name, indices, _score) in matches.into_iter() {
if name != self.current_model {
rows.push(DisplayRow::Model {
name,
match_indices: Some(indices),
is_current: false,
});
}
}
rows
}
}
/// Row in the model popup.
enum DisplayRow {
Model {
name: String,
match_indices: Option<Vec<usize>>, // indices to bold (char positions)
is_current: bool,
},
}
/// Return indices for a simple case-insensitive subsequence match and a score.
/// Smaller score is better.
fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
if needle.is_empty() {
return Some((Vec::new(), i32::MAX));
}
let h_lower = haystack.to_lowercase();
let n_lower = needle.to_lowercase();
let mut indices: Vec<usize> = Vec::with_capacity(n_lower.len());
let mut h_iter = h_lower.char_indices();
let mut last_pos: Option<usize> = None;
for ch in n_lower.chars() {
let mut found = None;
for (i, hc) in h_iter.by_ref() {
if hc == ch {
found = Some(i);
break;
}
}
if let Some(pos) = found {
indices.push(pos);
last_pos = Some(pos);
} else {
return None;
}
}
// Score: window length minus needle length (tighter is better), with a bonus for prefix match.
let first = *indices.first().unwrap_or(&0);
let last = last_pos.unwrap_or(first);
let window = (last as i32 - first as i32 + 1) - (n_lower.len() as i32);
let mut score = window.max(0);
if first == 0 {
score -= 100; // strong bonus for prefix match
}
Some((indices, score))
}
fn fuzzy_indices(haystack: &str, needle: &str) -> Option<Vec<usize>> {
fuzzy_match(haystack, needle).map(|(idx, _)| idx)
}
impl WidgetRef for &ModelSelectionPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let rows_all = self.visible_rows();
let mut rows: Vec<Row> = Vec::new();
if rows_all.is_empty() {
rows.push(Row::new(vec![Cell::from(
Line::from(Span::styled(
"no matches",
Style::default().add_modifier(Modifier::ITALIC | Modifier::DIM),
)),
)]));
} else {
for (i, row) in rows_all.into_iter().take(MAX_RESULTS).enumerate() {
match row {
DisplayRow::Model {
name,
match_indices,
is_current,
} => {
// Highlight fuzzy indices when present.
let mut spans: Vec<Span> = Vec::with_capacity(name.len());
if let Some(idxs) = match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in name.chars().enumerate() {
let mut style = Style::default();
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
style = style.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(ch.to_string(), style));
}
} else {
spans.push(Span::raw(name.clone()));
}
let mut cell = Cell::from(Line::from(spans));
if Some(i) == self.selected_idx {
cell = cell.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
} else if is_current {
cell = cell.style(Style::default().fg(Color::Cyan));
}
rows.push(Row::new(vec![cell]));
}
}
}
}
let table = Table::new(rows, vec![Constraint::Percentage(100)])
.block(
Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().fg(Color::DarkGray)),
)
.widths([Constraint::Percentage(100)]);
table.render(area, buf);
}
}

View File

@@ -1,442 +0,0 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Cell;
use ratatui::widgets::Clear;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use super::BottomPane;
use super::BottomPaneView;
/// Simple dropdown to select a model.
pub(crate) struct ModelSelectionView {
/// Full list of models from provider (deduplicated).
options: Vec<String>,
/// Current model pinned at the top of the list.
current_model: String,
/// Current zero-based selection index among rendered rows.
selected_idx: usize,
/// Query used to filter models via fuzzy match.
query: String,
is_complete: bool,
app_event_tx: AppEventSender,
}
impl ModelSelectionView {
pub fn new(current_model: &str, app_event_tx: AppEventSender) -> Self {
// Initially no options; will be populated asynchronously.
Self {
options: Vec::new(),
current_model: current_model.to_string(),
selected_idx: 0,
query: String::new(),
is_complete: false,
app_event_tx,
}
}
/// Produce the sequence of display rows respecting pinned current model,
/// sort preference, and search filter.
fn build_display_rows(&self) -> Vec<DisplayRow> {
// Determine candidate list excluding the current model (it is always pinned first).
let others: Vec<&str> = self
.options
.iter()
.map(|s| s.as_str())
.filter(|m| *m != self.current_model)
.collect();
// If not searching, maintain provided ordering; otherwise, we'll score by fuzzy match.
if self.query.is_empty() {
let mut rows: Vec<DisplayRow> = Vec::new();
// Pinned current model always first.
rows.push(DisplayRow::Model {
name: self.current_model.clone(),
match_indices: None,
is_current: true,
});
if !others.is_empty() {
for name in others {
rows.push(DisplayRow::Model {
name: name.to_string(),
match_indices: None,
is_current: false,
});
}
}
return rows;
}
// Searching: only include current model if it matches the query.
let mut rows: Vec<DisplayRow> = Vec::new();
if let Some(indices) = fuzzy_indices(&self.current_model, &self.query) {
rows.push(DisplayRow::Model {
name: self.current_model.clone(),
match_indices: Some(indices),
is_current: true,
});
}
// Build list of matches among others.
let mut matches: Vec<(String, Vec<usize>, i32)> = Vec::new();
for name in others {
if let Some((indices, score)) = fuzzy_match(name, &self.query) {
matches.push((name.to_string(), indices, score));
}
}
// Sort by score (ascending => better). If equal, fall back to alphabetical and match tightness.
matches.sort_by(|(a_name, a_idx, a_score), (b_name, b_idx, b_score)| {
a_score
.cmp(b_score)
.then_with(|| a_name.cmp(b_name))
.then_with(|| a_idx.len().cmp(&b_idx.len()))
});
for (name, indices, _score) in matches {
// Don't duplicate the current model if it matched above
if name != self.current_model {
rows.push(DisplayRow::Model {
name,
match_indices: Some(indices),
is_current: false,
});
}
}
rows
}
/// Count how many rows will be rendered (excluding the bottom stats line).
fn row_count(&self) -> usize {
let mut count = 0usize;
if self.query.is_empty() {
// current model + others
count += 1; // current
// Others count (excluding current)
let others = self
.options
.iter()
.filter(|m| m.as_str() != self.current_model)
.count();
count + others
} else {
// searching: pinned current + matches
let mut matches = 1; // current always present
for name in self
.options
.iter()
.filter(|m| m.as_str() != self.current_model)
{
if fuzzy_match(name, &self.query).is_some() {
matches += 1;
}
}
matches
}
}
/// Map selected_idx to a selected model name, if any.
fn selected_model(&self) -> Option<String> {
if self.row_count() == 0 {
return None;
}
let rows = self.build_display_rows();
match rows.get(self.selected_idx) {
Some(DisplayRow::Model { name, .. }) => Some(name.clone()),
_ => None, // no other placeholder rows are not selectable
}
}
/// Compute the 1-based index of the selected model among all visible models (after filter).
fn selected_model_position(&self) -> Option<usize> {
let rows = self.build_display_rows();
if self.selected_idx >= rows.len() {
return None;
}
let mut pos = 0usize;
for (i, row) in rows.iter().enumerate() {
if matches!(row, DisplayRow::Model { .. }) {
pos += 1;
}
if i == self.selected_idx {
return Some(pos);
}
}
None
}
}
/// Row that can be rendered in the selector.
enum DisplayRow {
Model {
name: String,
match_indices: Option<Vec<usize>>, // indices to bold (char positions)
is_current: bool,
},
}
/// Return indices for a simple case-insensitive subsequence match and a score.
/// Smaller score is better.
fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
if needle.is_empty() {
return Some((Vec::new(), i32::MAX));
}
let h_lower = haystack.to_lowercase();
let n_lower = needle.to_lowercase();
let mut indices: Vec<usize> = Vec::with_capacity(n_lower.len());
let mut h_iter = h_lower.char_indices();
let mut last_pos: Option<usize> = None;
for ch in n_lower.chars() {
let mut found = None;
for (i, hc) in h_iter.by_ref() {
if hc == ch {
found = Some(i);
break;
}
}
if let Some(pos) = found {
indices.push(pos);
last_pos = Some(pos);
} else {
return None;
}
}
// Score: window length minus needle length (tighter is better), with a bonus for prefix match.
let first = *indices.first().unwrap_or(&0);
let last = last_pos.unwrap_or(first);
let window = (last as i32 - first as i32 + 1) - (n_lower.len() as i32);
let mut score = window.max(0);
if first == 0 {
score -= 100; // strong bonus for prefix match
}
Some((indices, score))
}
fn fuzzy_indices(haystack: &str, needle: &str) -> Option<Vec<usize>> {
fuzzy_match(haystack, needle).map(|(idx, _)| idx)
}
impl<'a> BottomPaneView<'a> for ModelSelectionView {
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up => {
if self.selected_idx > 0 {
self.selected_idx -= 1;
}
}
KeyCode::Down => {
let max_idx = self.row_count().saturating_sub(1);
if self.selected_idx < max_idx {
self.selected_idx += 1;
}
}
KeyCode::Home => {
self.selected_idx = 0;
}
KeyCode::End => {
self.selected_idx = self.row_count().saturating_sub(1);
}
KeyCode::Enter => {
if let Some(model) = self.selected_model() {
self.app_event_tx.send(AppEvent::SelectModel(model));
self.is_complete = true;
}
}
KeyCode::Esc => {
if self.query.is_empty() {
self.is_complete = true;
} else {
self.query.clear();
self.selected_idx = 0; // reset on clear
}
}
KeyCode::Backspace => {
self.query.pop();
// After editing, snap to first match if searching; otherwise clamp.
if self.query.is_empty() {
self.selected_idx = self.selected_idx.min(self.row_count().saturating_sub(1));
} else {
self.selected_idx = if self.row_count() > 1 { 1 } else { 0 };
}
}
KeyCode::Char(c) => {
// Append printable characters to the query.
if !c.is_control() {
self.query.push(c);
// When typing, move selection to first match (index 1 because 0 is pinned current).
self.selected_idx = if self.row_count() > 1 { 1 } else { 0 };
}
}
_ => {}
}
}
fn is_complete(&self) -> bool {
self.is_complete
}
fn desired_height(&self, _width: u16) -> u16 {
const MAX_VISIBLE_ROWS: usize = 10;
let list_rows = self.row_count().min(MAX_VISIBLE_ROWS) as u16;
let stats_rows = 1u16; // persistent status line at bottom
let border_rows = 2u16; // top + bottom borders
list_rows + stats_rows + border_rows
}
fn render(&self, area: Rect, buf: &mut Buffer) {
// Clear the area to prevent ghosting when the list height/width changes between frames.
Clear.render(area, buf);
// Compute rows and counts.
let rows_all = self.build_display_rows();
let total_rows = rows_all.len();
let total_models = rows_all
.iter()
.filter(|r| matches!(r, DisplayRow::Model { .. }))
.count();
let selected_model_pos = self.selected_model_position();
// Determine content height and rows available for the list (leave one row for stats).
let content_height = area.height.saturating_sub(2) as usize; // minus borders
let stats_rows = 1usize; // persistent status line at bottom
let list_window = content_height.saturating_sub(stats_rows);
// Mutable self required to adjust scroll_offset; work with a local mutable copy via interior mutability
// is not necessary; instead compute desired offset and then render using that offset only.
let mut scroll_offset = 0usize;
if list_window > 0 {
// Ensure selected row is visible within [scroll_offset, scroll_offset + list_window)
if self.selected_idx < scroll_offset {
scroll_offset = self.selected_idx;
} else if self.selected_idx >= scroll_offset + list_window {
scroll_offset = self.selected_idx + 1 - list_window;
}
// Clamp to range.
if total_rows > list_window {
let max_offset = total_rows - list_window;
if scroll_offset > max_offset {
scroll_offset = max_offset;
}
} else {
scroll_offset = 0;
}
} else {
scroll_offset = 0; // no space for list; still show stats
}
// Prepare visible rows slice for list portion.
let mut visible_rows: Vec<Row> = Vec::new();
if list_window > 0 {
let end = (scroll_offset + list_window).min(total_rows);
for (abs_idx, row) in rows_all.iter().enumerate().take(end).skip(scroll_offset) {
match row {
DisplayRow::Model {
name,
match_indices,
is_current,
} => {
// Build spans for optional fuzzy highlight.
let mut spans: Vec<Span> = Vec::with_capacity(name.len());
if let Some(idxs) = match_indices {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in name.chars().enumerate() {
let mut style = Style::default();
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
style = style.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(ch.to_string(), style));
}
} else {
spans.push(Span::raw(name.clone()));
}
let mut cell = Cell::from(Line::from(spans));
// Selected row style takes precedence.
if abs_idx == self.selected_idx {
cell = cell.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
} else if *is_current {
// Special color for the current model when not selected.
cell = cell.style(Style::default().fg(Color::Cyan));
}
visible_rows.push(Row::new(vec![cell]));
}
}
}
// Fill with blank rows if we have fewer rows than window size, so the stats line stays at bottom.
while visible_rows.len() < list_window {
visible_rows.push(Row::new(vec![Cell::from(" ")]));
}
}
// Stats line text: selected position / total models.
let stats_text = match selected_model_pos {
Some(pos) => format!(" {pos}/{total_models} models "),
None => format!(" -/{total_models} models "),
};
let mut stats_row = Row::new(vec![Cell::from(stats_text)]);
stats_row = stats_row.style(Style::default().fg(Color::DarkGray));
visible_rows.push(stats_row);
let mut title = String::from(" Select model ");
if !self.query.is_empty() {
title.push(' ');
title.push('(');
title.push_str(&self.query);
title.push(')');
}
let table = Table::new(
visible_rows,
vec![ratatui::prelude::Constraint::Percentage(100)],
)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
)
.widths([ratatui::prelude::Constraint::Percentage(100)]);
table.render(area, buf);
}
fn set_model_options(&mut self, current_model: &str, options: Vec<String>) -> bool {
self.current_model = current_model.to_string();
// Deduplicate while preserving first occurrence order.
let mut seen = std::collections::HashSet::new();
let mut unique: Vec<String> = Vec::with_capacity(options.len());
for m in options.into_iter() {
if seen.insert(m.clone()) {
unique.push(m);
}
}
// Preserve provided ordering without applying preference ranking.
self.options = unique;
// Clamp selection to available rows.
self.selected_idx = self.selected_idx.min(self.row_count().saturating_sub(1));
true
}
}