fix(cli): limit provider selector height (#10420)

This commit is contained in:
乔冉
2026-07-16 04:39:44 +08:00
committed by GitHub
parent da574166ac
commit e359b35aee
3 changed files with 223 additions and 18 deletions
Generated
+1
View File
@@ -5091,6 +5091,7 @@ dependencies = [
"sha2 0.11.0",
"shlex 2.0.1",
"sigstore-verify",
"strsim",
"strum 0.28.0",
"tar",
"tempfile",
+1
View File
@@ -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 }
+221 -18
View File
@@ -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<T>(
items: &mut Vec<T>,
selected_index: Option<usize>,
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<ProviderItem> {
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<String> {
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<bool> {
// 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<String> = 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<ProviderItem> = 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<bool> {
.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<bool> {
.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<bool> {
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<bool> {
.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::<Vec<_>>()
);
}
#[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"));
}
}