feat: tab completion and switch provider for model (#10585)

Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
Davide Colazingari
2026-07-27 21:37:55 +02:00
committed by GitHub
parent a074d8eb3e
commit c95f0e205a
3 changed files with 451 additions and 40 deletions
+178 -6
View File
@@ -1,5 +1,5 @@
use goose::agents::execute_commands::list_commands;
use goose::config::GooseMode;
use goose::config::{Config, GooseMode};
use rustyline::completion::{Completer, FilenameCompleter, Pair};
use rustyline::highlight::{CmdKind, Highlighter};
use rustyline::hint::Hinter;
@@ -146,9 +146,89 @@ impl GooseCompleter {
Ok((pos, candidates))
}
/// Complete model names for the /model command.
fn complete_model_names(&self, line: &str) -> Result<(usize, Vec<Pair>)> {
Ok((line.len(), vec![]))
let after_cmd = line.strip_prefix("/model").unwrap_or("").trim_start();
if after_cmd == "--provider" || after_cmd.starts_with("--provider ") {
let flag_rest = after_cmd.strip_prefix("--provider").unwrap_or("").trim();
if after_cmd == "--provider" {
return Ok((line.len(), vec![]));
}
let parts: Vec<&str> = flag_rest.split_whitespace().collect();
let trailing_space = after_cmd.ends_with(' ');
if parts.is_empty() || (parts.len() == 1 && !trailing_space) {
let partial = if parts.is_empty() { "" } else { parts[0] };
let cache = self.completion_cache.read().unwrap();
let candidates: Vec<Pair> = cache
.provider_names
.iter()
.filter(|name| name.starts_with(partial))
.map(|name| Pair {
display: name.clone(),
replacement: format!("{} ", name),
})
.collect();
let pos = line.len() - partial.len();
return Ok((pos, candidates));
}
let provider_name = parts[0];
let partial = if parts.len() > 1 && !trailing_space {
parts[1]
} else {
""
};
return self.models_completion_from_cache(provider_name, partial, line);
}
if after_cmd.starts_with("--") {
let flag_partial = &after_cmd;
if "--provider".starts_with(flag_partial) {
return Ok((
line.len() - flag_partial.len(),
vec![Pair {
display: "--provider".to_string(),
replacement: "--provider ".to_string(),
}],
));
}
return Ok((line.len(), vec![]));
}
let current_provider = {
let cache = self.completion_cache.read().unwrap();
if cache.current_session_provider.is_empty() {
Config::global().get_goose_provider().unwrap_or_default()
} else {
cache.current_session_provider.clone()
}
};
self.models_completion_from_cache(&current_provider, after_cmd, line)
}
fn models_completion_from_cache(
&self,
provider_name: &str,
partial: &str,
full_line: &str,
) -> Result<(usize, Vec<Pair>)> {
let cache = self.completion_cache.read().unwrap();
let models = cache.provider_models.get(provider_name);
let candidates: Vec<Pair> = match models {
Some(names) if !names.is_empty() => names
.iter()
.filter(|name| name.starts_with(partial))
.map(|name| Pair {
display: name.clone(),
replacement: format!("{} ", name),
})
.collect(),
_ => vec![],
};
let pos = full_line.len() - partial.len();
Ok((pos, candidates))
}
/// Complete slash commands
@@ -556,6 +636,24 @@ mod tests {
.prompt_info
.insert("other_prompt".to_string(), other_prompt_info);
cache.provider_names = vec![
"anthropic".to_string(),
"openai".to_string(),
"zai".to_string(),
];
cache.current_session_provider = "anthropic".to_string();
cache.provider_models.insert(
"anthropic".to_string(),
vec!["claude-sonnet-4".to_string(), "claude-haiku-4".to_string()],
);
cache.provider_models.insert(
"openai".to_string(),
vec!["gpt-4.1".to_string(), "gpt-4.1-mini".to_string()],
);
cache
.provider_models
.insert("zai".to_string(), vec!["glm-4.5".to_string()]);
Arc::new(RwLock::new(cache))
}
@@ -601,13 +699,87 @@ mod tests {
let cache = create_test_cache();
let completer = GooseCompleter::new(cache);
let (pos, candidates) = completer.complete_model_names("/model ").unwrap();
let (pos, candidates) = completer
.complete_model_names("/model --provider ")
.unwrap();
assert_eq!(pos, "/model --provider ".len());
assert!(candidates.len() >= 3);
assert!(candidates.iter().any(|c| c.display == "anthropic"));
assert!(candidates.iter().any(|c| c.display == "openai"));
assert!(candidates.iter().any(|c| c.display == "zai"));
let (pos, candidates) = completer
.complete_model_names("/model --provider a")
.unwrap();
assert_eq!(pos, "/model --provider ".len());
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].display, "anthropic");
let (pos, candidates) = completer
.complete_model_names("/model --provider anthropic ")
.unwrap();
assert_eq!(pos, "/model --provider anthropic ".len());
assert_eq!(candidates.len(), 2);
assert!(candidates.iter().any(|c| c.display == "claude-sonnet-4"));
let (pos, candidates) = completer
.complete_model_names("/model --provider anthropic claude-s")
.unwrap();
assert_eq!(pos, "/model --provider anthropic ".len());
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].display, "claude-sonnet-4");
let (pos, candidates) = completer.complete_model_names("/model --p").unwrap();
assert_eq!(pos, "/model ".len());
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].display, "--provider");
}
#[test]
fn test_complete_model_names_edge_cases() {
let cache = create_test_cache();
let completer = GooseCompleter::new(cache);
let (pos, candidates) = completer
.complete_model_names("/model --provider z")
.unwrap();
assert_eq!(pos, "/model --provider ".len());
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].display, "zai");
let (pos, candidates) = completer
.complete_model_names("/model --provider zai ")
.unwrap();
assert_eq!(pos, "/model --provider zai ".len());
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].display, "glm-4.5");
let (_pos, candidates) = completer
.complete_model_names("/model --provider nonexistent ")
.unwrap();
assert!(candidates.is_empty());
let (pos, candidates) = completer.complete_model_names("/model gpt").unwrap();
assert_eq!(pos, "/model gpt".len());
let (_pos, candidates) = completer
.complete_model_names("/model --provider anthropic nonexistent_model")
.unwrap();
assert!(candidates.is_empty());
let (_pos, candidates) = completer.complete_model_names("/model --xyz").unwrap();
assert!(candidates.is_empty());
let (_pos, candidates) = completer
.complete_model_names("/model --provider nosuchprovider")
.unwrap();
assert!(candidates.is_empty());
let (_pos, candidates) = completer.complete_model_names("/model ").unwrap();
assert!(candidates.iter().any(|c| c.display == "claude-sonnet-4"));
assert!(candidates.iter().any(|c| c.display == "claude-haiku-4"));
let (pos, candidates) = completer.complete_model_names("/model claude-s").unwrap();
assert_eq!(pos, "/model ".len());
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].display, "claude-sonnet-4");
}
#[test]
+87 -10
View File
@@ -23,7 +23,7 @@ pub enum InputResult {
ListPrompts(Option<String>),
PromptCommand(PromptCommandOptions),
GooseMode(String),
Model(Option<String>),
Model(ModelCommandOptions),
Plan(PlanCommandOptions),
EndPlan,
Clear,
@@ -47,6 +47,12 @@ pub struct PlanCommandOptions {
pub message_text: String,
}
#[derive(Debug, Default)]
pub struct ModelCommandOptions {
pub provider: Option<String>,
pub model: Option<String>,
}
struct CtrlCHandler {
completion_cache: Arc<std::sync::RwLock<CompletionCache>>,
}
@@ -295,17 +301,33 @@ fn handle_slash_command(input: &str) -> Option<InputResult> {
s if s.starts_with(CMD_MODE) => Some(InputResult::GooseMode(
s.get(CMD_MODE.len()..).unwrap_or("").to_string(),
)),
s if s == CMD_MODEL => Some(InputResult::Model(None)),
s if s == CMD_MODEL => Some(InputResult::Model(ModelCommandOptions::default())),
s if s.starts_with(CMD_MODEL_WITH_SPACE) => {
let model = s
let rest = s
.get(CMD_MODEL_WITH_SPACE.len()..)
.unwrap_or("")
.trim()
.to_string();
if model.is_empty() {
Some(InputResult::Model(None))
if rest.is_empty() {
Some(InputResult::Model(ModelCommandOptions::default()))
} else if let Some(after_flag) = rest.strip_prefix("--provider ") {
let parts: Vec<&str> = after_flag.split_whitespace().collect();
let provider = parts.first().map(|s| s.to_string());
let model = parts
.get(1..)
.filter(|parts| !parts.is_empty())
.map(|parts| parts.join(" "));
Some(InputResult::Model(ModelCommandOptions { provider, model }))
} else if rest == "--provider" {
Some(InputResult::Model(ModelCommandOptions {
provider: Some(String::new()),
model: None,
}))
} else {
Some(InputResult::Model(Some(model)))
Some(InputResult::Model(ModelCommandOptions {
provider: None,
model: Some(rest),
}))
}
}
s if s.starts_with(CMD_PLAN) => {
@@ -454,6 +476,7 @@ fn help_text() -> String {
/prompt <n> [--info] [key=value...] - Get prompt info or execute a prompt
/mode <name> - Set the goose mode to use ({modes})
/model [name] - Show the current model, or switch models for this session while keeping the same provider
/model --provider <name> [model] - Switch to a different provider (optionally specifying a model)
/plan <message_text> - Enters 'plan' mode with optional message. Create a plan based on the current messages and asks user if they want to act on it.
If user acts on the plan, goose mode is set to 'auto' and returns to 'normal' goose mode.
To warm up goose before using '/plan', we recommend setting '/mode approve' & putting appropriate context into goose.
@@ -574,18 +597,72 @@ mod tests {
// Test model command
assert!(matches!(
handle_slash_command("/model"),
Some(InputResult::Model(None))
Some(InputResult::Model(ModelCommandOptions {
provider: None,
model: None
}))
));
assert!(matches!(
handle_slash_command("/model "),
Some(InputResult::Model(None))
Some(InputResult::Model(ModelCommandOptions {
provider: None,
model: None
}))
));
if let Some(InputResult::Model(Some(model))) = handle_slash_command("/model gpt-4.1") {
assert_eq!(model, "gpt-4.1");
if let Some(InputResult::Model(ModelCommandOptions { provider, model })) =
handle_slash_command("/model gpt-4.1")
{
assert_eq!(model.as_deref(), Some("gpt-4.1"));
assert!(provider.is_none());
} else {
panic!("Expected Model");
}
if let Some(InputResult::Model(ModelCommandOptions { provider, model })) =
handle_slash_command("/model --provider anthropic")
{
assert_eq!(provider.as_deref(), Some("anthropic"));
assert!(model.is_none());
} else {
panic!("Expected Model with provider");
}
if let Some(InputResult::Model(ModelCommandOptions { provider, model })) =
handle_slash_command("/model --provider anthropic claude-sonnet-4")
{
assert_eq!(provider.as_deref(), Some("anthropic"));
assert_eq!(model.as_deref(), Some("claude-sonnet-4"));
} else {
panic!("Expected Model with provider and model");
}
if let Some(InputResult::Model(ModelCommandOptions { provider, model })) =
handle_slash_command("/model --provider")
{
assert_eq!(provider.as_deref(), Some(""));
assert!(model.is_none());
} else {
panic!("Expected Model with empty provider");
}
if let Some(InputResult::Model(ModelCommandOptions { provider, model })) =
handle_slash_command("/model --provider ")
{
assert_eq!(provider.as_deref(), Some(""));
assert!(model.is_none());
} else {
panic!("Expected Model with empty provider (trailing space)");
}
if let Some(InputResult::Model(ModelCommandOptions { provider, model })) =
handle_slash_command("/model --provider anthropic claude-sonnet-4")
{
assert_eq!(provider.as_deref(), Some("anthropic"));
assert_eq!(model.as_deref(), Some("claude-sonnet-4"));
} else {
panic!("Expected Model with extra whitespace handled");
}
// Test unknown commands
assert!(handle_slash_command("/unknown").is_none());
}
+186 -24
View File
@@ -46,7 +46,10 @@ use rmcp::model::{ErrorCode, ErrorData};
use strum::VariantNames;
use goose::config::paths::Paths;
use goose::config::providers;
use goose::conversation::message::{ActionRequiredData, Message, MessageContent};
use goose::providers::inventory::ProviderInventoryService;
use goose::session::SessionManager;
use rustyline::EditMode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -208,6 +211,9 @@ pub enum HintStatus {
pub struct CompletionCache {
pub prompts: HashMap<String, Vec<String>>,
pub prompt_info: HashMap<String, output::PromptInfo>,
pub provider_names: Vec<String>,
pub provider_models: HashMap<String, Vec<String>>,
pub current_session_provider: String,
pub last_updated: Instant,
pub hint_status: HintStatus,
}
@@ -217,6 +223,9 @@ impl CompletionCache {
Self {
prompts: HashMap::new(),
prompt_info: HashMap::new(),
provider_names: Vec::new(),
provider_models: HashMap::new(),
current_session_provider: String::new(),
last_updated: Instant::now(),
hint_status: HintStatus::Default,
}
@@ -641,9 +650,9 @@ impl CliSession {
history.save(editor);
self.handle_goose_mode(&mode).await?;
}
InputResult::Model(model) => {
InputResult::Model(options) => {
history.save(editor);
self.handle_model(model.as_deref()).await?;
self.handle_model(options).await?;
}
InputResult::Plan(options) => {
self.handle_plan_mode(options).await?;
@@ -834,7 +843,7 @@ impl CliSession {
Ok(())
}
async fn handle_model(&self, model: Option<&str>) -> Result<()> {
async fn handle_model(&mut self, options: input::ModelCommandOptions) -> Result<()> {
let provider = self.agent.provider().await?;
let current_provider_name = provider.get_name().to_string();
let current_model_config = self
@@ -843,21 +852,39 @@ impl CliSession {
.await?;
let current_model_name = current_model_config.model_name.clone();
if model.is_none() {
if options.provider.is_none() && options.model.is_none() {
output::goose_mode_message(&format!(
"Current session model: '{}' (provider '{}')",
"Current session model: '{}' (provider '{}')\n\
Tip: use '/model <name>' to switch model, or '/model --provider <name> [model]' to switch provider.",
current_model_name, current_provider_name
));
return Ok(());
}
let model_name = model.unwrap_or_default().trim();
if model_name.is_empty() {
output::render_error("Model name cannot be empty");
let requested_provider = options
.provider
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let target_provider_name = requested_provider.unwrap_or(&current_provider_name);
if options.provider.is_some() && requested_provider.is_none() {
output::render_error("Provider name is required after '--provider'.");
return Ok(());
}
if current_provider_name.ends_with("-acp") {
let target_entry = match goose::providers::get_from_registry(target_provider_name).await {
Ok(entry) => entry,
Err(_) => {
output::render_error(&format!(
"Unknown provider '{}'. Use tab-completion to see available providers.",
target_provider_name
));
return Ok(());
}
};
if target_provider_name.ends_with("-acp") {
output::render_error(
"Session model switching is not supported for ACP providers in the CLI.",
);
@@ -866,19 +893,54 @@ impl CliSession {
if provider.manages_own_context() {
output::render_error(&format!(
"Session model switching is not supported for provider '{}' because it manages its own conversation context.",
"Session model or provider switching is not supported for provider '{}' because it manages its own conversation context.",
current_provider_name
));
return Ok(());
}
let new_model_config =
build_switched_model_config(&current_provider_name, model_name, &current_model_config)?;
if options
.model
.as_deref()
.is_some_and(|model| model.split_whitespace().count() > 1)
{
output::render_error("Unexpected arguments after model name.");
return Ok(());
}
let target_model_name = match options.model.as_deref().map(str::trim) {
Some(m) if !m.is_empty() => m.to_string(),
_ => {
if target_provider_name == current_provider_name {
current_model_name.clone()
} else {
let known: Vec<&str> = target_entry
.metadata()
.known_models
.iter()
.map(|m| m.name.as_str())
.collect();
if known.contains(&current_model_name.as_str()) {
current_model_name.clone()
} else {
target_entry.metadata().default_model.clone()
}
}
}
};
let new_model_config = build_switched_model_config(
target_provider_name,
&target_model_name,
&current_model_config,
)?;
let configured_effort = Config::global().get_goose_thinking_effort();
let new_effort = new_model_config.thinking_effort().or(configured_effort);
let current_effort = current_model_config.thinking_effort().or(configured_effort);
if new_model_config.model_name == current_model_config.model_name
let provider_unchanged = target_provider_name == current_provider_name;
if provider_unchanged
&& new_model_config.model_name == current_model_config.model_name
&& new_effort == current_effort
{
output::goose_mode_message(&format!(
@@ -888,10 +950,48 @@ impl CliSession {
return Ok(());
}
if let Some(model_info) = target_entry
.metadata()
.known_models
.iter()
.find(|m| m.name == target_model_name)
{
if model_info.context_limit < current_model_config.context_limit.unwrap_or(0) {
eprintln!(
"{}",
console::style(format!(
"Warning: '{}' has a smaller context window ({} tokens) than the current session ({} tokens). \
You may need to use /compact.",
target_model_name,
model_info.context_limit,
current_model_config.context_limit.unwrap_or(0)
))
.yellow()
);
}
}
let extensions = self.agent.get_extension_configs().await;
let new_provider = goose::providers::create(&current_provider_name, extensions)
.await
.map_err(|e| anyhow::anyhow!("Failed to create provider: {e}"))?;
let new_provider = match goose::providers::create(target_provider_name, extensions).await {
Ok(p) => p,
Err(e) => {
output::render_error(&format!(
"Cannot switch to provider '{}': {}\n\
Set credentials via `goose configure` or the appropriate environment variable.\n\
Session continues with current provider '{}'.",
target_provider_name, e, current_provider_name
));
return Ok(());
}
};
if new_provider.manages_own_context() {
output::render_error(&format!(
"Session provider switching is not supported for '{}' because it manages its own conversation context.",
target_provider_name
));
return Ok(());
}
self.agent
.update_provider(new_provider, new_model_config, &self.session_id)
@@ -899,10 +999,20 @@ impl CliSession {
let mode = self.agent.goose_mode().await;
self.agent.update_goose_mode(mode, &self.session_id).await?;
output::goose_mode_message(&format!(
"Session model switched from '{}' to '{}' for provider '{}'",
current_model_name, model_name, current_provider_name
));
self.update_completion_cache().await?;
if provider_unchanged {
output::goose_mode_message(&format!(
"Session model switched from '{}' to '{}' for provider '{}'",
current_model_name, target_model_name, current_provider_name
));
} else {
output::goose_mode_message(&format!(
"Session switched from provider '{}' / model '{}' to provider '{}' / model '{}'",
current_provider_name, current_model_name, target_provider_name, target_model_name
));
}
Ok(())
}
@@ -1566,13 +1676,38 @@ impl CliSession {
Ok(())
}
/// Update the completion cache with fresh data
/// This should be called before the interactive session starts
pub async fn update_completion_cache(&mut self) -> Result<()> {
// Get fresh data
let prompts = self.agent.list_extension_prompts(&self.session_id).await;
let all_providers = goose::providers::providers().await;
let session_provider = self.agent.provider().await?.get_name().to_string();
let provider_ids: Vec<String> = all_providers.iter().map(|(m, _)| m.name.clone()).collect();
let inventory_models: HashMap<String, Vec<String>> = {
let storage = SessionManager::instance().storage().clone();
let inventory = ProviderInventoryService::new(storage);
inventory
.entries(&provider_ids)
.await
.unwrap_or_default()
.into_iter()
.map(|entry| {
let model_ids: Vec<String> =
entry.models.iter().map(|m| m.id.clone()).collect();
(entry.provider_id, model_ids)
})
.collect()
};
let config = Config::global();
let configured_models: HashMap<String, String> = all_providers
.iter()
.filter_map(|(m, _)| {
providers::get_provider_entry(config, &m.name)
.map(|entry| (m.name.clone(), entry.model))
.filter(|(_, model)| !model.is_empty())
})
.collect();
// Update the cache with write lock
let mut cache = self.completion_cache.write().unwrap();
cache.prompts.clear();
cache.prompt_info.clear();
@@ -1594,6 +1729,33 @@ impl CliSession {
}
}
cache.provider_names = all_providers.iter().map(|(m, _)| m.name.clone()).collect();
cache.current_session_provider = session_provider;
cache.provider_models.clear();
for (metadata, _) in &all_providers {
let mut models: Vec<String> = metadata
.known_models
.iter()
.map(|m| m.name.clone())
.collect();
if let Some(inv_models) = inventory_models.get(&metadata.name) {
for model_id in inv_models {
if !models.contains(model_id) {
models.push(model_id.clone());
}
}
}
if let Some(model) = configured_models.get(&metadata.name) {
if !models.contains(model) {
models.push(model.clone());
}
}
cache.provider_models.insert(metadata.name.clone(), models);
}
cache.last_updated = Instant::now();
Ok(())
}