mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
new model popup
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
271
codex-rs/tui/src/bottom_pane/model_selection_popup.rs
Normal file
271
codex-rs/tui/src/bottom_pane/model_selection_popup.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user