diff --git a/Cargo.lock b/Cargo.lock index f138041ec..7fc0ef2fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5091,6 +5091,7 @@ dependencies = [ "sha2 0.11.0", "shlex 2.0.1", "sigstore-verify", + "strsim", "strum 0.28.0", "tar", "tempfile", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 6f729a92b..9bb000eab 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -37,6 +37,7 @@ futures = { workspace = true } serde = { workspace = true } serde_yaml = { workspace = true } strum = { workspace = true } +strsim = "0.11" tempfile = { workspace = true } etcetera = { workspace = true } rand = { workspace = true } diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 96f1574b6..bdbd14007 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -33,8 +33,105 @@ use std::io::{IsTerminal, Write}; // useful for light themes where there is no discernible colour contrast between // cursor-selected and cursor-unselected items. const MULTISELECT_VISIBILITY_HINT: &str = "<"; +const MAX_PROVIDER_ROWS: usize = 10; const SHOW_CURSOR: &[u8] = b"\x1b[?25h"; +type ProviderItem = (String, String, String); + +#[derive(Clone, PartialEq, Eq)] +enum ProviderChoice { + Provider(String), + Search, + SearchAgain, +} + +fn provider_choice_items(items: &[ProviderItem]) -> Vec<(ProviderChoice, String, String)> { + items + .iter() + .map(|(name, label, hint)| { + ( + ProviderChoice::Provider(name.clone()), + label.clone(), + hint.clone(), + ) + }) + .collect() +} + +fn move_selected_item_into_view( + items: &mut Vec, + selected_index: Option, + visible_rows: usize, +) { + if let Some(index) = selected_index.filter(|&index| index >= visible_rows) { + let selected = items.remove(index); + items.insert(0, selected); + } +} + +fn fuzzy_filter_provider_items(items: &[ProviderItem], query: &str) -> Vec { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return items.to_vec(); + } + + let query_words: Vec<_> = query.split_whitespace().collect(); + let mut scored_items: Vec<_> = items + .iter() + .filter_map(|item| { + let label = item.1.to_lowercase(); + let similarity = strsim::jaro_winkler(&label, &query); + let word_match_bonus = + query_words.iter().all(|word| label.contains(*word)) as u8 as f64; + let score = similarity + word_match_bonus; + (score > 0.6).then_some((score, item)) + }) + .collect(); + + scored_items.sort_by(|a, b| b.0.total_cmp(&a.0)); + scored_items + .into_iter() + .map(|(_, item)| item.clone()) + .collect() +} + +fn search_provider_dialog(provider_items: &[ProviderItem]) -> anyhow::Result { + let mut query = String::new(); + + loop { + let input: String = cliclack::input("Search model providers") + .placeholder("e.g., OpenAI, Anthropic, local") + .default_input(&query) + .interact()?; + query = input.trim().to_string(); + + let filtered_items = fuzzy_filter_provider_items(provider_items, &query); + if filtered_items.is_empty() { + cliclack::log::warning("No matching providers. Try a different search term.")?; + continue; + } + + let mut items = provider_choice_items(&filtered_items); + items.push(( + ProviderChoice::SearchAgain, + "Search again...".to_string(), + "Enter a different search term".to_string(), + )); + + match cliclack::select("Which model provider should we use?") + .items(&items) + .max_rows(MAX_PROVIDER_ROWS) + .interact()? + { + ProviderChoice::SearchAgain => continue, + ProviderChoice::Provider(name) => return Ok(name), + ProviderChoice::Search => { + unreachable!("Search entry is not added to the results list") + } + } + } +} + struct CursorRestoreGuard; impl Drop for CursorRestoreGuard { @@ -718,27 +815,75 @@ pub async fn configure_provider_dialog() -> anyhow::Result { // Sort providers alphabetically by display name available_providers.sort_by(|a, b| a.0.display_name.cmp(&b.0.display_name)); - // Create selection items from provider metadata - let provider_items: Vec<(&String, &str, &str)> = available_providers - .iter() - .map(|(p, _)| (&p.name, p.display_name.as_str(), p.description.as_str())) - .collect(); - // Get current default provider if it exists let current_provider: Option = config.get_goose_provider().ok(); + let current_provider_index = current_provider.as_ref().and_then(|current_provider| { + available_providers + .iter() + .position(|(provider, _)| &provider.name == current_provider) + }); + let visible_provider_rows = if available_providers.len() > MAX_PROVIDER_ROWS { + MAX_PROVIDER_ROWS - 1 + } else { + MAX_PROVIDER_ROWS + }; + move_selected_item_into_view( + &mut available_providers, + current_provider_index, + visible_provider_rows, + ); + + // Create selection items from provider metadata + let provider_items: Vec = available_providers + .iter() + .map(|(p, _)| { + ( + p.name.clone(), + p.display_name.clone(), + p.description.clone(), + ) + }) + .collect(); + let default_provider = current_provider.unwrap_or_default(); - // Select provider - let provider_name = cliclack::select("Which model provider should we use?") - .initial_value(&default_provider) - .items(&provider_items) - .filter_mode() - .interact()?; + // cliclack 0.5.5 does not reset its private list offset when filtering a + // paginated select, so use a separate fuzzy-search step for long lists. + let provider_name = if provider_items.len() > MAX_PROVIDER_ROWS { + let mut paginated_items = provider_choice_items(&provider_items); + paginated_items.insert( + MAX_PROVIDER_ROWS - 1, + ( + ProviderChoice::Search, + "Search all providers...".to_string(), + "Filter the complete provider list".to_string(), + ), + ); + + match cliclack::select("Which model provider should we use?") + .initial_value(ProviderChoice::Provider(default_provider.clone())) + .items(&paginated_items) + .max_rows(MAX_PROVIDER_ROWS) + .interact()? + { + ProviderChoice::Search => search_provider_dialog(&provider_items)?, + ProviderChoice::Provider(name) => name, + ProviderChoice::SearchAgain => { + unreachable!("SearchAgain entry is not added to the paginated list") + } + } + } else { + cliclack::select("Which model provider should we use?") + .initial_value(default_provider.clone()) + .items(&provider_items) + .filter_mode() + .interact()? + }; // Get the selected provider's metadata let (provider_meta, _) = available_providers .iter() - .find(|(p, _)| &p.name == provider_name) + .find(|(p, _)| p.name == provider_name.as_str()) .expect("Selected provider must exist in metadata"); for key in provider_meta @@ -746,7 +891,7 @@ pub async fn configure_provider_dialog() -> anyhow::Result { .iter() .filter(|k| k.primary || k.oauth_flow) { - if !configure_single_key(config, provider_name, &provider_meta.display_name, key).await? { + if !configure_single_key(config, &provider_name, &provider_meta.display_name, key).await? { return Ok(false); } } @@ -762,7 +907,7 @@ pub async fn configure_provider_dialog() -> anyhow::Result { .interact()? { for key in non_primary_keys { - if !configure_single_key(config, provider_name, &provider_meta.display_name, key) + if !configure_single_key(config, &provider_name, &provider_meta.display_name, key) .await? { return Ok(false); @@ -772,7 +917,7 @@ pub async fn configure_provider_dialog() -> anyhow::Result { let spin = spinner(); spin.start("Attempting to fetch supported models..."); - let temp_provider = create(provider_name, Vec::new()).await?; + let temp_provider = create(&provider_name, Vec::new()).await?; let models_res = retry_operation(&RetryConfig::default(), || async { temp_provider .fetch_recommended_models(goose::model_config::global_toolshim()) @@ -828,10 +973,11 @@ pub async fn configure_provider_dialog() -> anyhow::Result { .unwrap_or(false); let toolshim_model = std::env::var("GOOSE_TOOLSHIM_OLLAMA_MODEL").ok(); - match test_provider_configuration(provider_name, &model, toolshim_enabled, toolshim_model).await + match test_provider_configuration(&provider_name, &model, toolshim_enabled, toolshim_model) + .await { Ok(()) => { - goose::config::set_active_provider(config, provider_name, &model)?; + goose::config::set_active_provider(config, &provider_name, &model)?; print_config_file_saved()?; Ok(true) } @@ -2174,3 +2320,60 @@ fn print_config_file_saved() -> anyhow::Result<()> { ))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selected_item_inside_visible_window_keeps_order() { + let mut items: Vec<_> = (0..MAX_PROVIDER_ROWS + 1).collect(); + let expected = items.clone(); + + move_selected_item_into_view( + &mut items, + Some(MAX_PROVIDER_ROWS - 2), + MAX_PROVIDER_ROWS - 1, + ); + + assert_eq!(items, expected); + } + + #[test] + fn selected_item_outside_visible_window_moves_to_front() { + let mut items: Vec<_> = (0..MAX_PROVIDER_ROWS + 2).collect(); + + move_selected_item_into_view( + &mut items, + Some(MAX_PROVIDER_ROWS - 1), + MAX_PROVIDER_ROWS - 1, + ); + + assert_eq!(items[0], MAX_PROVIDER_ROWS - 1); + assert_eq!( + items[1..MAX_PROVIDER_ROWS], + (0..MAX_PROVIDER_ROWS - 1).collect::>() + ); + } + + #[test] + fn fuzzy_provider_filter_keeps_relevant_matches_ranked_first() { + let items = vec![ + ( + "anthropic".to_string(), + "Anthropic".to_string(), + String::new(), + ), + ( + "openrouter".to_string(), + "OpenRouter".to_string(), + String::new(), + ), + ("openai".to_string(), "OpenAI".to_string(), String::new()), + ]; + + let filtered = fuzzy_filter_provider_items(&items, "open ai"); + + assert_eq!(filtered.first().map(|item| item.0.as_str()), Some("openai")); + } +}