From cf8aef78bf9afd532295669aec7a6633f31d6ace Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Fri, 20 Feb 2026 15:39:32 +0530 Subject: [PATCH 01/37] feat(model): allow /model command to switch model and provider together - Add get_all_provider_models() to ForgeApp, API trait, and ForgeAPI implementation to fetch models from all configured providers - Add CliModelWithProvider display wrapper that shows model id, context length, tools support, and provider name in the selection picker - Modify select_model() to present a unified model list across all providers instead of only the current provider's models - Modify on_model_selection() to auto-switch the provider when the selected model belongs to a different provider than the current one - Update help message to inform users that provider is switched automatically when selecting a model from a different provider Co-Authored-By: ForgeCode --- crates/forge_api/src/api.rs | 5 ++ crates/forge_api/src/forge_api.rs | 4 ++ crates/forge_app/src/app.rs | 28 +++++++++ crates/forge_main/src/model.rs | 57 ++++++++++++++++++- crates/forge_main/src/ui.rs | 94 ++++++++++++++++++++++--------- 5 files changed, 159 insertions(+), 29 deletions(-) diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index 585f608db3..b5bc43058f 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -22,6 +22,11 @@ pub trait API: Sync + Send { /// Provides a list of models available in the current environment async fn get_models(&self) -> Result>; + + /// Provides models from all configured providers as `(ProviderId, + /// Vec)` pairs. Providers that fail to return models are silently + /// skipped. + async fn get_all_provider_models(&self) -> Result)>>; /// Provides a list of agents available in the current environment async fn get_agents(&self) -> Result>; /// Provides a list of providers available in the current environment diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 307b816c9b..bfe9b6b74e 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -73,6 +73,10 @@ impl< async fn get_models(&self) -> Result> { self.app().get_models().await } + + async fn get_all_provider_models(&self) -> Result)>> { + self.app().get_all_provider_models().await + } async fn get_agents(&self) -> Result> { self.services.get_agents().await } diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index d872914586..a9a8179d73 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -276,6 +276,34 @@ impl ForgeApp { self.services.models(provider).await } + + /// Gets available models from all configured providers. + /// + /// Returns a list of `(ProviderId, Vec)` pairs for each configured + /// provider. Providers that fail to return models are silently skipped. + pub async fn get_all_provider_models(&self) -> Result)>> { + let all_providers = self.services.get_all_providers().await?; + let auth_service = self.services.provider_auth_service(); + + let mut results = Vec::new(); + for any_provider in all_providers { + // Only include configured (authenticated) providers + if let Some(provider) = any_provider.into_configured() { + let provider_id = provider.id.clone(); + let Ok(refreshed) = auth_service + .refresh_provider_credential(provider) + .await + else { + continue; + }; + if let Ok(models) = self.services.models(refreshed).await { + results.push((provider_id, models)); + } + } + } + + Ok(results) + } pub async fn login(&self, init_auth: &InitAuth) -> Result<()> { self.authenticator.login(init_auth).await } diff --git a/crates/forge_main/src/model.rs b/crates/forge_main/src/model.rs index 6df47ac974..0ab2f814a8 100644 --- a/crates/forge_main/src/model.rs +++ b/crates/forge_main/src/model.rs @@ -2,7 +2,7 @@ use std::fmt::Display; use std::sync::{Arc, Mutex}; use colored::Colorize; -use forge_api::{Agent, AnyProvider, Model, ProviderId, Template}; +use forge_api::{Agent, AnyProvider, Model, ModelId, ProviderId, Template}; use forge_domain::UserCommand; use strum::{EnumProperty, IntoEnumIterator}; use strum_macros::{EnumIter, EnumProperty}; @@ -88,6 +88,61 @@ impl Display for CliProvider { } } +/// A model paired with its provider, used for unified model+provider selection. +/// +/// This wrapper enables the `/model` command to display models from all +/// configured providers in a single list, with the provider name shown +/// alongside each model entry. +#[derive(Clone)] +pub struct CliModelWithProvider { + pub model: Model, + pub provider_id: ProviderId, +} + +impl CliModelWithProvider { + /// Creates a new `CliModelWithProvider` from a model and its provider ID. + pub fn new(model: Model, provider_id: ProviderId) -> Self { + Self { model, provider_id } + } + + /// Returns the model ID. + pub fn model_id(&self) -> &ModelId { + &self.model.id + } +} + +impl Display for CliModelWithProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.model.id)?; + + let mut info_parts = Vec::new(); + + // Add context length if available + if let Some(limit) = self.model.context_length { + if limit >= 1_000_000 { + info_parts.push(format!("{}M", limit / 1_000_000)); + } else if limit >= 1000 { + info_parts.push(format!("{}k", limit / 1000)); + } else { + info_parts.push(format!("{limit}")); + } + } + + // Add tools support indicator if explicitly supported + if self.model.tools_supported == Some(true) { + info_parts.push("🛠️".to_string()); + } + + // Show provider name + info_parts.push(format!("via {}", self.provider_id)); + + let info = format!("[ {} ]", info_parts.join(" ")); + write!(f, " {}", info.dimmed())?; + + Ok(()) + } +} + /// Result of agent command registration #[derive(Debug, Clone)] pub struct AgentCommandRegistrationResult { diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 33429cacf5..9f9b8f365d 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -35,7 +35,7 @@ use crate::conversation_selector::ConversationSelector; use crate::display_constants::{CommandType, headers, markers, status}; use crate::info::Info; use crate::input::Console; -use crate::model::{CliModel, CliProvider, ForgeCommandManager, SlashCommand}; +use crate::model::{CliModelWithProvider, CliProvider, ForgeCommandManager, SlashCommand}; use crate::porcelain::Porcelain; use crate::prompt::ForgePrompt; use crate::state::UIState; @@ -1947,50 +1947,65 @@ impl A + Send + Sync> UI { Ok(()) } - /// Select a model from the available models - /// Returns Some(ModelId) if a model was selected, or None if selection was - /// canceled + /// Select a model from all configured providers. + /// + /// Presents a unified list of models across every configured provider. + /// When the user selects a model from a different provider than the current + /// one, the provider is switched automatically. Returns `Some(ModelId)` if + /// a model was selected, or `None` if the selection was cancelled. #[async_recursion::async_recursion] - async fn select_model(&mut self) -> Result> { - // Check if provider is set otherwise first ask to select a provider - if self.api.get_default_provider().await.is_err() { + async fn select_model(&mut self) -> Result> { + // Fetch models from all configured providers + let all_provider_models = self.api.get_all_provider_models().await?; + + // If no providers are configured, fall back to provider selection flow + if all_provider_models.is_empty() { self.on_provider_selection().await?; // Check if a model was already selected during provider activation - // Return None to signal the model selection is complete and message was already - // printed - if self.api.get_default_model().await.is_some() { - return Ok(None); + if let Some(model) = self.api.get_default_model().await { + let provider = self + .api + .get_default_provider() + .await + .map(|p| p.id) + .unwrap_or(ProviderId::OPENAI); + return Ok(Some((model, provider))); } + return Ok(None); } - // Fetch available models - let mut models = self - .get_models() - .await? + // Build a flat list of (provider_id, model) entries sorted by model name + let mut models: Vec = all_provider_models .into_iter() - .map(CliModel) - .collect::>(); + .flat_map(|(provider_id, models)| { + models + .into_iter() + .map(move |m| CliModelWithProvider::new(m, provider_id.clone())) + }) + .collect(); - // Sort the models by their names in ascending order - models.sort_by(|a, b| a.0.name.cmp(&b.0.name)); + models.sort_by(|a, b| a.model.id.to_string().cmp(&b.model.id.to_string())); - // Find the index of the current model + // Find the index of the current model to pre-position the cursor let current_model = self .get_agent_model(self.api.get_active_agent().await) .await; let starting_cursor = current_model .as_ref() - .and_then(|current| models.iter().position(|m| &m.0.id == current)) + .and_then(|current| models.iter().position(|m| m.model_id() == current)) .unwrap_or(0); // Use the centralized select module match ForgeSelect::select("Select a model:", models) .with_starting_cursor(starting_cursor) - .with_help_message("Type a name or use arrow keys to navigate and Enter to select") + .with_help_message( + "Type a name or use arrow keys to navigate and Enter to select. Provider is \ + switched automatically.", + ) .prompt()? { - Some(model) => Ok(Some(model.0.id)), + Some(entry) => Ok(Some((entry.model.id, entry.provider_id))), None => Ok(None), } } @@ -2384,18 +2399,41 @@ impl A + Send + Sync> UI { Ok(Some(provider.0)) } - // Helper method to handle model selection and update the conversation + /// Handles model selection and optionally switches the provider. + /// + /// Presents a unified picker of models from all configured providers. When + /// the selected model belongs to a different provider than the current one, + /// the provider is switched automatically before setting the model. #[async_recursion::async_recursion] async fn on_model_selection(&mut self) -> Result> { - // Select a model - let model_option = self.select_model().await?; + // Select a model (returns model id + the provider it belongs to) + let selection = self.select_model().await?; // If no model was selected (user canceled), return early - let model = match model_option { - Some(model) => model, + let (model, selected_provider_id) = match selection { + Some(pair) => pair, None => return Ok(None), }; + // Determine the current provider (if any) + let current_provider_id = self + .api + .get_default_provider() + .await + .ok() + .map(|p| p.id); + + // Switch provider first if the selected model belongs to a different one + if current_provider_id.as_ref() != Some(&selected_provider_id) { + self.api + .set_default_provider(selected_provider_id.clone()) + .await?; + self.writeln_title( + TitleFormat::action(format!("{}", selected_provider_id)) + .sub_title("is now the default provider"), + )?; + } + // Update the operating model via API self.api.set_default_model(model.clone()).await?; From 4614727e7bd411bf5e32ea7376274cf266777041 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Fri, 20 Feb 2026 15:43:13 +0530 Subject: [PATCH 02/37] perf(model): fetch models from all providers concurrently Replace sequential for-loop with futures::future::join_all so all provider model fetches are fired in parallel, avoiding O(n) latency where n is the number of configured providers. Co-Authored-By: ForgeCode --- crates/forge_app/src/app.rs | 43 ++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index a9a8179d73..3f3b7c4931 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -277,30 +277,39 @@ impl ForgeApp { self.services.models(provider).await } - /// Gets available models from all configured providers. + /// Gets available models from all configured providers concurrently. /// /// Returns a list of `(ProviderId, Vec)` pairs for each configured - /// provider. Providers that fail to return models are silently skipped. + /// provider. All providers are queried in parallel; providers that fail to + /// return models are silently skipped. pub async fn get_all_provider_models(&self) -> Result)>> { let all_providers = self.services.get_all_providers().await?; - let auth_service = self.services.provider_auth_service(); - let mut results = Vec::new(); - for any_provider in all_providers { - // Only include configured (authenticated) providers - if let Some(provider) = any_provider.into_configured() { + // Build one future per configured provider + let futures: Vec<_> = all_providers + .into_iter() + .filter_map(|any_provider| any_provider.into_configured()) + .map(|provider| { let provider_id = provider.id.clone(); - let Ok(refreshed) = auth_service - .refresh_provider_credential(provider) - .await - else { - continue; - }; - if let Ok(models) = self.services.models(refreshed).await { - results.push((provider_id, models)); + let services = self.services.clone(); + async move { + let refreshed = services + .provider_auth_service() + .refresh_provider_credential(provider) + .await + .ok()?; + let models = services.models(refreshed).await.ok()?; + Some((provider_id, models)) } - } - } + }) + .collect(); + + // Execute all provider fetches concurrently and collect successful results + let results = futures::future::join_all(futures) + .await + .into_iter() + .flatten() + .collect(); Ok(results) } From 02dd3753055ba91e023b309c362f7df5ce2c3233 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:16:15 +0000 Subject: [PATCH 03/37] [autofix.ci] apply automated fixes --- crates/forge_main/src/ui.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 9f9b8f365d..83bc12f445 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1985,7 +1985,7 @@ impl A + Send + Sync> UI { }) .collect(); - models.sort_by(|a, b| a.model.id.to_string().cmp(&b.model.id.to_string())); + models.sort_by_key(|a| a.model.id.to_string()); // Find the index of the current model to pre-position the cursor let current_model = self @@ -2416,12 +2416,7 @@ impl A + Send + Sync> UI { }; // Determine the current provider (if any) - let current_provider_id = self - .api - .get_default_provider() - .await - .ok() - .map(|p| p.id); + let current_provider_id = self.api.get_default_provider().await.ok().map(|p| p.id); // Switch provider first if the selected model belongs to a different one if current_provider_id.as_ref() != Some(&selected_provider_id) { From f97091cd99ac6331061447de29929af0c33aa756 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Fri, 20 Feb 2026 15:48:23 +0530 Subject: [PATCH 04/37] refactor(model): remove CliModel struct and its display implementation --- crates/forge_main/src/model.rs | 40 ---------------------------------- 1 file changed, 40 deletions(-) diff --git a/crates/forge_main/src/model.rs b/crates/forge_main/src/model.rs index 0ab2f814a8..f4d9017c26 100644 --- a/crates/forge_main/src/model.rs +++ b/crates/forge_main/src/model.rs @@ -10,46 +10,6 @@ use strum_macros::{EnumIter, EnumProperty}; use crate::display_constants::markers; use crate::info::Info; -/// Wrapper for displaying models in selection menus -/// -/// This component provides consistent formatting for model selection across -/// the application, showing model ID with contextual information like -/// context length and tools support. -#[derive(Clone)] -pub struct CliModel(pub Model); - -impl Display for CliModel { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0.id)?; - - let mut info_parts = Vec::new(); - - // Add context length if available - if let Some(limit) = self.0.context_length { - if limit >= 1_000_000 { - info_parts.push(format!("{}M", limit / 1_000_000)); - } else if limit >= 1000 { - info_parts.push(format!("{}k", limit / 1000)); - } else { - info_parts.push(format!("{limit}")); - } - } - - // Add tools support indicator if explicitly supported - if self.0.tools_supported == Some(true) { - info_parts.push("🛠️".to_string()); - } - - // Only show brackets if we have info to display - if !info_parts.is_empty() { - let info = format!("[ {} ]", info_parts.join(" ")); - write!(f, " {}", info.dimmed())?; - } - - Ok(()) - } -} - /// Wrapper for displaying providers in selection menus /// /// This component provides consistent formatting for provider selection across From baca17d0ed4542190de918076411b7a7db7b1fb2 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Fri, 20 Feb 2026 17:38:22 +0530 Subject: [PATCH 05/37] feat(model): display provider as separate suffix in model output --- crates/forge_main/src/model.rs | 58 +++++++++++++++-------------- crates/forge_main/src/ui.rs | 26 +++++++++++-- shell-plugin/lib/actions/config.zsh | 57 +++++++++++++++++++++++++++- 3 files changed, 107 insertions(+), 34 deletions(-) diff --git a/crates/forge_main/src/model.rs b/crates/forge_main/src/model.rs index f4d9017c26..f812adf9ef 100644 --- a/crates/forge_main/src/model.rs +++ b/crates/forge_main/src/model.rs @@ -93,12 +93,13 @@ impl Display for CliModelWithProvider { info_parts.push("🛠️".to_string()); } - // Show provider name - info_parts.push(format!("via {}", self.provider_id)); - + // Only show brackets if we have info to display let info = format!("[ {} ]", info_parts.join(" ")); write!(f, " {}", info.dimmed())?; + // Show provider as a separate suffix + write!(f, " {}", format!("[{}]", self.provider_id).dimmed())?; + Ok(()) } } @@ -932,8 +933,8 @@ mod tests { id: &str, context_length: Option, tools_supported: Option, - ) -> Model { - Model { + ) -> CliModelWithProvider { + let model = Model { id: ModelId::new(id), name: None, description: None, @@ -942,105 +943,106 @@ mod tests { supports_parallel_tool_calls: None, supports_reasoning: None, input_modalities: vec![InputModality::Text], - } + }; + CliModelWithProvider::new(model, ProviderId::OPENAI) } #[test] fn test_cli_model_display_with_context_and_tools() { let fixture = create_model_fixture("gpt-4", Some(128000), Some(true)); - let formatted = format!("{}", CliModel(fixture)); + let formatted = fixture.to_string(); let actual = strip_ansi_codes(&formatted); - let expected = "gpt-4 [ 128k 🛠️ ]"; + let expected = "gpt-4 [ 128k 🛠️ ] [OpenAI]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_with_large_context() { let fixture = create_model_fixture("claude-3", Some(2000000), Some(true)); - let formatted = format!("{}", CliModel(fixture)); + let formatted = fixture.to_string(); let actual = strip_ansi_codes(&formatted); - let expected = "claude-3 [ 2M 🛠️ ]"; + let expected = "claude-3 [ 2M 🛠️ ] [OpenAI]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_with_small_context() { let fixture = create_model_fixture("small-model", Some(512), Some(false)); - let formatted = format!("{}", CliModel(fixture)); + let formatted = fixture.to_string(); let actual = strip_ansi_codes(&formatted); - let expected = "small-model [ 512 ]"; + let expected = "small-model [ 512 ] [OpenAI]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_with_context_only() { let fixture = create_model_fixture("text-model", Some(4096), Some(false)); - let formatted = format!("{}", CliModel(fixture)); + let formatted = fixture.to_string(); let actual = strip_ansi_codes(&formatted); - let expected = "text-model [ 4k ]"; + let expected = "text-model [ 4k ] [OpenAI]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_with_tools_only() { let fixture = create_model_fixture("tool-model", None, Some(true)); - let formatted = format!("{}", CliModel(fixture)); + let formatted = fixture.to_string(); let actual = strip_ansi_codes(&formatted); - let expected = "tool-model [ 🛠️ ]"; + let expected = "tool-model [ 🛠️ ] [OpenAI]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_empty_context_and_no_tools() { let fixture = create_model_fixture("basic-model", None, Some(false)); - let formatted = format!("{}", CliModel(fixture)); + let formatted = fixture.to_string(); let actual = strip_ansi_codes(&formatted); - let expected = "basic-model"; + let expected = "basic-model [OpenAI]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_empty_context_and_none_tools() { let fixture = create_model_fixture("unknown-model", None, None); - let formatted = format!("{}", CliModel(fixture)); + let formatted = fixture.to_string(); let actual = strip_ansi_codes(&formatted); - let expected = "unknown-model"; + let expected = "unknown-model [OpenAI]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_exact_thousands() { let fixture = create_model_fixture("exact-k", Some(8000), Some(true)); - let formatted = format!("{}", CliModel(fixture)); + let formatted = fixture.to_string(); let actual = strip_ansi_codes(&formatted); - let expected = "exact-k [ 8k 🛠️ ]"; + let expected = "exact-k [ 8k 🛠️ ] [OpenAI]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_exact_millions() { let fixture = create_model_fixture("exact-m", Some(1000000), Some(true)); - let formatted = format!("{}", CliModel(fixture)); + let formatted = fixture.to_string(); let actual = strip_ansi_codes(&formatted); - let expected = "exact-m [ 1M 🛠️ ]"; + let expected = "exact-m [ 1M 🛠️ ] [OpenAI]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_edge_case_999() { let fixture = create_model_fixture("edge-999", Some(999), None); - let formatted = format!("{}", CliModel(fixture)); + let formatted = fixture.to_string(); let actual = strip_ansi_codes(&formatted); - let expected = "edge-999 [ 999 ]"; + let expected = "edge-999 [ 999 ] [OpenAI]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_edge_case_1001() { let fixture = create_model_fixture("edge-1001", Some(1001), None); - let formatted = format!("{}", CliModel(fixture)); + let formatted = fixture.to_string(); let actual = strip_ansi_codes(&formatted); - let expected = "edge-1001 [ 1k ]"; + let expected = "edge-1001 [ 1k ] [OpenAI]"; assert_eq!(actual, expected); } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 83bc12f445..13067e066e 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1075,20 +1075,38 @@ impl A + Send + Sync> UI { /// Lists all the models async fn on_show_models(&mut self, porcelain: bool) -> anyhow::Result<()> { - let models = self.get_models().await?; + self.spinner.start(Some("Loading"))?; + let all_provider_models = self.api.get_all_provider_models().await?; + self.spinner.stop(None)?; - if models.is_empty() { + if all_provider_models.is_empty() { return Ok(()); } + // Flatten into (provider_id, model) pairs sorted by provider then model id + let mut models: Vec<(ProviderId, Model)> = all_provider_models + .into_iter() + .flat_map(|(provider_id, models)| { + models.into_iter().map(move |m| (provider_id.clone(), m)) + }) + .collect(); + models.sort_by(|(pa, a), (pb, b)| { + pa.to_string() + .cmp(&pb.to_string()) + .then(a.id.to_string().cmp(&b.id.to_string())) + }); + let mut info = Info::new(); - for model in models.iter() { + for (provider_id, model) in models.iter() { let id = model.id.to_string(); + let p_id: &str = provider_id; info = info .add_title(model.name.as_ref().unwrap_or(&id)) - .add_key_value("Id", id); + .add_key_value("Id", id) + .add_key_value("Provider", provider_id.to_string()) + .add_key_value("Provider Id", p_id); // Add context length if available, otherwise use "unknown" if let Some(limit) = model.context_length { diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index 17443f346e..98e78e4c8b 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -90,10 +90,63 @@ function _forge_action_provider() { fi } -# Action handler: Select model +# Action handler: Select model (across all configured providers) +# +# Uses `forge list models --porcelain` which outputs columns (after swap): +# 1:model_id 2:model_name 3:provider(display) 4:provider_id(raw) 5:context 6:tools 7:image +# Shows the picker hiding model_id (field 1) and provider_id (field 4). +# When the selected model belongs to a different provider, switches it first. function _forge_action_model() { local input_text="$1" - _forge_select_and_set_config "list models" "model" "Model" "$($_FORGE_BIN config get model --porcelain)" "2,3.." "$input_text" + ( + echo + local output + output=$($_FORGE_BIN list models --porcelain 2>/dev/null) + + if [[ -z "$output" ]]; then + return 0 + fi + + local current_model + current_model=$($_FORGE_BIN config get model --porcelain 2>/dev/null) + + local fzf_args=( + --delimiter="$_FORGE_DELIMITER" + --prompt="Model ❯ " + --with-nth="2,3,5.." + ) + + if [[ -n "$input_text" ]]; then + fzf_args+=(--query="$input_text") + fi + + if [[ -n "$current_model" ]]; then + local index=$(_forge_find_index "$output" "$current_model" 1) + fzf_args+=(--bind="start:pos($index)") + fi + + local selected + selected=$(echo "$output" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + + if [[ -n "$selected" ]]; then + # Field 1 = model_id (raw), field 3 = provider display name, + # field 4 = provider_id (raw, for config set) + local model_id provider_id provider_display + model_id=$(echo "$selected" | awk -F ' +' '{print $1}' | xargs) + provider_id=$(echo "$selected" | awk -F ' +' '{print $4}' | xargs) + provider_display=$(echo "$selected" | awk -F ' +' '{print $3}' | xargs) + + # Switch provider first if it differs from the current one + # config get provider returns the display name, so compare against that + local current_provider + current_provider=$(_forge_exec config get provider --porcelain 2>/dev/null) + if [[ -n "$provider_display" && "$provider_display" != "$current_provider" ]]; then + _forge_exec config set provider "$provider_id" + fi + + _forge_exec config set model "$model_id" + fi + ) } # Action handler: Sync workspace for codebase search From f69f6ee40a4d987eb2b5878322f784966181f21f Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Fri, 20 Feb 2026 17:42:16 +0530 Subject: [PATCH 06/37] fix(model): hide empty info brackets in model display --- crates/forge_main/src/model.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/forge_main/src/model.rs b/crates/forge_main/src/model.rs index f812adf9ef..cd7fd74606 100644 --- a/crates/forge_main/src/model.rs +++ b/crates/forge_main/src/model.rs @@ -94,8 +94,10 @@ impl Display for CliModelWithProvider { } // Only show brackets if we have info to display - let info = format!("[ {} ]", info_parts.join(" ")); - write!(f, " {}", info.dimmed())?; + if !info_parts.is_empty() { + let info = format!("[ {} ]", info_parts.join(" ")); + write!(f, " {}", info.dimmed())?; + } // Show provider as a separate suffix write!(f, " {}", format!("[{}]", self.provider_id).dimmed())?; From 2619c18e43049627f71d2b0d3438dc5e5e14be06 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Fri, 20 Feb 2026 18:05:25 +0530 Subject: [PATCH 07/37] fix(ui): stop spinner on model loading error --- crates/forge_main/src/ui.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 13067e066e..c9c258acb7 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1076,8 +1076,14 @@ impl A + Send + Sync> UI { /// Lists all the models async fn on_show_models(&mut self, porcelain: bool) -> anyhow::Result<()> { self.spinner.start(Some("Loading"))?; - let all_provider_models = self.api.get_all_provider_models().await?; - self.spinner.stop(None)?; + + let all_provider_models = match self.api.get_all_provider_models().await { + Ok(provider_models) => provider_models, + Err(err) => { + self.spinner.stop(None)?; + return Err(err); + } + }; if all_provider_models.is_empty() { return Ok(()); @@ -1090,14 +1096,9 @@ impl A + Send + Sync> UI { models.into_iter().map(move |m| (provider_id.clone(), m)) }) .collect(); - models.sort_by(|(pa, a), (pb, b)| { - pa.to_string() - .cmp(&pb.to_string()) - .then(a.id.to_string().cmp(&b.id.to_string())) - }); + models.sort_by(|(pa, a), (pb, b)| pa.as_ref().cmp(&pb.as_ref()).then(a.id.as_str().cmp(&b.id.as_str()))); let mut info = Info::new(); - for (provider_id, model) in models.iter() { let id = model.id.to_string(); let p_id: &str = provider_id; From a954b953bc77f6c7fd307378a1896ebb3e5f6dfa Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:37:16 +0000 Subject: [PATCH 08/37] [autofix.ci] apply automated fixes --- crates/forge_main/src/ui.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index c9c258acb7..9e0b292487 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1096,7 +1096,11 @@ impl A + Send + Sync> UI { models.into_iter().map(move |m| (provider_id.clone(), m)) }) .collect(); - models.sort_by(|(pa, a), (pb, b)| pa.as_ref().cmp(&pb.as_ref()).then(a.id.as_str().cmp(&b.id.as_str()))); + models.sort_by(|(pa, a), (pb, b)| { + pa.as_ref() + .cmp(pb.as_ref()) + .then(a.id.as_str().cmp(b.id.as_str())) + }); let mut info = Info::new(); for (provider_id, model) in models.iter() { From 8601d9b3eaa9312c4be60ec4006d862ea9a43046 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Fri, 20 Feb 2026 18:20:40 +0530 Subject: [PATCH 09/37] refactor(provider): use ProviderModels struct instead of tuple --- crates/forge_api/src/api.rs | 9 +-- crates/forge_api/src/forge_api.rs | 2 +- crates/forge_app/src/app.rs | 11 ++- crates/forge_domain/src/provider.rs | 9 +++ crates/forge_main/src/ui.rs | 110 ++++++++++++++-------------- 5 files changed, 74 insertions(+), 67 deletions(-) diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index b5bc43058f..39512f57b7 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use forge_app::dto::ToolsOverview; use forge_app::{User, UserUsage}; -use forge_domain::{AgentId, InitAuth, ModelId}; +use forge_domain::{AgentId, InitAuth, ModelId, ProviderModels}; use forge_stream::MpscStream; use futures::stream::BoxStream; use url::Url; @@ -23,10 +23,9 @@ pub trait API: Sync + Send { /// Provides a list of models available in the current environment async fn get_models(&self) -> Result>; - /// Provides models from all configured providers as `(ProviderId, - /// Vec)` pairs. Providers that fail to return models are silently - /// skipped. - async fn get_all_provider_models(&self) -> Result)>>; + /// Provides models from all configured providers. Providers that fail to + /// return models are silently skipped. + async fn get_all_provider_models(&self) -> Result>; /// Provides a list of agents available in the current environment async fn get_agents(&self) -> Result>; /// Provides a list of providers available in the current environment diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index bfe9b6b74e..836716eccf 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -74,7 +74,7 @@ impl< self.app().get_models().await } - async fn get_all_provider_models(&self) -> Result)>> { + async fn get_all_provider_models(&self) -> Result> { self.app().get_all_provider_models().await } async fn get_agents(&self) -> Result> { diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index 3f3b7c4931..f95773b9d7 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -279,10 +279,10 @@ impl ForgeApp { /// Gets available models from all configured providers concurrently. /// - /// Returns a list of `(ProviderId, Vec)` pairs for each configured - /// provider. All providers are queried in parallel; providers that fail to + /// Returns a list of `ProviderModels` for each configured provider. + /// All providers are queried in parallel; providers that fail to /// return models are silently skipped. - pub async fn get_all_provider_models(&self) -> Result)>> { + pub async fn get_all_provider_models(&self) -> Result> { let all_providers = self.services.get_all_providers().await?; // Build one future per configured provider @@ -299,7 +299,10 @@ impl ForgeApp { .await .ok()?; let models = services.models(refreshed).await.ok()?; - Some((provider_id, models)) + Some(ProviderModels { + provider_id, + models, + }) } }) .collect(); diff --git a/crates/forge_domain/src/provider.rs b/crates/forge_domain/src/provider.rs index 6114e9530a..e1e4b1ca0f 100644 --- a/crates/forge_domain/src/provider.rs +++ b/crates/forge_domain/src/provider.rs @@ -296,6 +296,15 @@ impl AnyProvider { } } +/// Represents a provider with its available models +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProviderModels { + /// The provider identifier + pub provider_id: ProviderId, + /// Available models from this provider + pub models: Vec, +} + #[cfg(test)] mod test_helpers { use std::collections::HashMap; diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 9e0b292487..f730198c0a 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1077,7 +1077,7 @@ impl A + Send + Sync> UI { async fn on_show_models(&mut self, porcelain: bool) -> anyhow::Result<()> { self.spinner.start(Some("Loading"))?; - let all_provider_models = match self.api.get_all_provider_models().await { + let mut all_provider_models = match self.api.get_all_provider_models().await { Ok(provider_models) => provider_models, Err(err) => { self.spinner.stop(None)?; @@ -1089,66 +1089,62 @@ impl A + Send + Sync> UI { return Ok(()); } - // Flatten into (provider_id, model) pairs sorted by provider then model id - let mut models: Vec<(ProviderId, Model)> = all_provider_models - .into_iter() - .flat_map(|(provider_id, models)| { - models.into_iter().map(move |m| (provider_id.clone(), m)) - }) - .collect(); - models.sort_by(|(pa, a), (pb, b)| { - pa.as_ref() - .cmp(pb.as_ref()) - .then(a.id.as_str().cmp(b.id.as_str())) - }); + // Sort models and then providers + all_provider_models + .iter_mut() + .for_each(|pm| pm.models.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()))); + all_provider_models.sort_by(|a, b| a.provider_id.as_ref().cmp(b.provider_id.as_ref())); let mut info = Info::new(); - for (provider_id, model) in models.iter() { - let id = model.id.to_string(); - let p_id: &str = provider_id; + for pm in &all_provider_models { + let provider_id: &str = &pm.provider_id; + let provider_display = pm.provider_id.to_string(); + for model in &pm.models { + let id = model.id.to_string(); + + info = info + .add_title(model.name.as_ref().unwrap_or(&id)) + .add_key_value("Id", id) + .add_key_value("Provider", &provider_display) + .add_key_value("Provider Id", provider_id); + + // Add context length if available, otherwise use "unknown" + if let Some(limit) = model.context_length { + let context = if limit >= 1_000_000 { + format!("{}M", limit / 1_000_000) + } else if limit >= 1000 { + format!("{}k", limit / 1000) + } else { + format!("{limit}") + }; + info = info.add_key_value("Context Window", context); + } else { + info = info.add_key_value("Context Window", markers::EMPTY) + } - info = info - .add_title(model.name.as_ref().unwrap_or(&id)) - .add_key_value("Id", id) - .add_key_value("Provider", provider_id.to_string()) - .add_key_value("Provider Id", p_id); - - // Add context length if available, otherwise use "unknown" - if let Some(limit) = model.context_length { - let context = if limit >= 1_000_000 { - format!("{}M", limit / 1_000_000) - } else if limit >= 1000 { - format!("{}k", limit / 1000) + // Add tools support indicator if explicitly supported + if let Some(supported) = model.tools_supported { + info = info.add_key_value( + "Tool Supported", + if supported { status::YES } else { status::NO }, + ) } else { - format!("{limit}") - }; - info = info.add_key_value("Context Window", context); - } else { - info = info.add_key_value("Context Window", markers::EMPTY) - } + info = info.add_key_value("Tools", markers::EMPTY) + } - // Add tools support indicator if explicitly supported - if let Some(supported) = model.tools_supported { + // Add image modality support indicator + let supports_image = model + .input_modalities + .contains(&forge_domain::InputModality::Image); info = info.add_key_value( - "Tool Supported", - if supported { status::YES } else { status::NO }, - ) - } else { - info = info.add_key_value("Tools", markers::EMPTY) + "Image", + if supports_image { + status::YES + } else { + status::NO + }, + ); } - - // Add image modality support indicator - let supports_image = model - .input_modalities - .contains(&forge_domain::InputModality::Image); - info = info.add_key_value( - "Image", - if supports_image { - status::YES - } else { - status::NO - }, - ); } if porcelain { @@ -2001,10 +1997,10 @@ impl A + Send + Sync> UI { // Build a flat list of (provider_id, model) entries sorted by model name let mut models: Vec = all_provider_models .into_iter() - .flat_map(|(provider_id, models)| { - models + .flat_map(|pm| { + pm.models .into_iter() - .map(move |m| CliModelWithProvider::new(m, provider_id.clone())) + .map(move |m| CliModelWithProvider::new(m, pm.provider_id.clone())) }) .collect(); From 8c3fc658403a2286acc66531653832c553d94203 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:52:21 +0000 Subject: [PATCH 10/37] [autofix.ci] apply automated fixes --- crates/forge_app/src/app.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index f95773b9d7..0cedbe98c4 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -299,10 +299,7 @@ impl ForgeApp { .await .ok()?; let models = services.models(refreshed).await.ok()?; - Some(ProviderModels { - provider_id, - models, - }) + Some(ProviderModels { provider_id, models }) } }) .collect(); From ec290704679bd7ae14194a515402756b2eea4e0a Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 23 Feb 2026 11:30:34 +0530 Subject: [PATCH 11/37] refactor(model): move caching from in-memory to CacacheStorage with TTL --- crates/forge_app/src/app.rs | 1 + crates/forge_repo/src/provider/chat.rs | 29 ++++- crates/forge_services/src/provider_service.rs | 116 ++---------------- 3 files changed, 35 insertions(+), 111 deletions(-) diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index 0cedbe98c4..763526ec44 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -313,6 +313,7 @@ impl ForgeApp { Ok(results) } + pub async fn login(&self, init_auth: &InitAuth) -> Result<()> { self.authenticator.login(init_auth).await } diff --git a/crates/forge_repo/src/provider/chat.rs b/crates/forge_repo/src/provider/chat.rs index bea7542fdd..97fd7f0b2b 100644 --- a/crates/forge_repo/src/provider/chat.rs +++ b/crates/forge_repo/src/provider/chat.rs @@ -5,6 +5,7 @@ use forge_app::domain::{ }; use forge_app::{EnvironmentInfra, HttpInfra}; use forge_domain::{ChatRepository, Provider, ProviderId}; +use forge_infra::CacacheStorage; use url::Url; use crate::provider::anthropic::AnthropicResponseRepository; @@ -13,6 +14,9 @@ use crate::provider::google::GoogleResponseRepository; use crate::provider::openai::OpenAIResponseRepository; use crate::provider::openai_responses::OpenAIResponsesResponseRepository; +/// TTL for cached model lists: 2 hours in seconds. +const MODEL_CACHE_TTL_SECS: u128 = 7200; + /// Repository responsible for routing chat requests to the appropriate provider /// implementation based on the provider's response type. pub struct ForgeChatRepository { @@ -21,6 +25,7 @@ pub struct ForgeChatRepository { anthropic_repo: AnthropicResponseRepository, bedrock_repo: BedrockResponseRepository, google_repo: GoogleResponseRepository, + model_cache: Arc, } impl ForgeChatRepository { @@ -47,12 +52,18 @@ impl ForgeChatRepository { let google_repo = GoogleResponseRepository::new(infra.clone()).retry_config(retry_config.clone()); + let model_cache = Arc::new(CacacheStorage::new( + env.cache_dir().join("model_cache"), + Some(MODEL_CACHE_TTL_SECS), + )); + Self { openai_repo, codex_repo, anthropic_repo, bedrock_repo, google_repo, + model_cache, } } } @@ -99,8 +110,16 @@ impl ChatRepository for ForgeChatReposit } async fn models(&self, provider: Provider) -> anyhow::Result> { - // Route based on provider response type - match provider.response { + use forge_app::KVStore; + + let cache_key = format!("models:{}", provider.id); + + if let Ok(Some(cached)) = self.model_cache.cache_get::<_, Vec>(&cache_key).await { + tracing::debug!(provider_id = %provider.id, "returning cached models"); + return Ok(cached); + } + + let models = match provider.response { Some(ProviderResponse::OpenAI) => self.openai_repo.models(provider).await, Some(ProviderResponse::Anthropic) => self.anthropic_repo.models(provider).await, Some(ProviderResponse::Bedrock) => self.bedrock_repo.models(provider).await, @@ -109,6 +128,12 @@ impl ChatRepository for ForgeChatReposit "Provider response type not configured for provider: {}", provider.id )), + }?; + + if let Err(err) = self.model_cache.cache_set(&cache_key, &models).await { + tracing::warn!(error = %err, "failed to cache model list"); } + + Ok(models) } } diff --git a/crates/forge_services/src/provider_service.rs b/crates/forge_services/src/provider_service.rs index acc38a40ee..3badc0ce5e 100644 --- a/crates/forge_services/src/provider_service.rs +++ b/crates/forge_services/src/provider_service.rs @@ -10,23 +10,17 @@ use forge_domain::{ AuthCredential, ChatRepository, Context, MigrationResult, ModelSource, Provider, ProviderRepository, ProviderTemplate, }; -use tokio::sync::Mutex; use url::Url; -/// Service layer wrapper for ProviderRepository that handles model caching and -/// template rendering +/// Service layer wrapper for ProviderRepository that handles template rendering pub struct ForgeProviderService { repository: Arc, - cached_models: Arc>>>, } impl ForgeProviderService { /// Creates a new ForgeProviderService instance pub fn new(repository: Arc) -> Self { - Self { - repository, - cached_models: Arc::new(Mutex::new(HashMap::new())), - } + Self { repository } } /// Renders a URL template with provided parameters @@ -94,26 +88,7 @@ impl ProviderService for ForgeProviderSe } async fn models(&self, provider: Provider) -> Result> { - let provider_id = provider.id.clone(); - - // Check cache first - { - let models_guard = self.cached_models.lock().await; - if let Some(cached_models) = models_guard.get(&provider_id) { - return Ok(cached_models.clone()); - } - } - - // Models not in cache, fetch from repository - let models = self.repository.models(provider).await?; - - // Cache the models for this provider - { - let mut models_guard = self.cached_models.lock().await; - models_guard.insert(provider_id, models.clone()); - } - - Ok(models) + self.repository.models(provider).await } async fn get_all_providers(&self) -> Result> { @@ -171,27 +146,18 @@ mod tests { // Mock repository for testing struct MockProviderRepository { models: Vec, - call_count: Arc>, providers: Vec, } impl MockProviderRepository { fn new(models: Vec) -> Self { - Self { - models, - call_count: Arc::new(Mutex::new(0)), - providers: vec![], - } + Self { models, providers: vec![] } } fn with_providers(mut self, providers: Vec) -> Self { self.providers = providers; self } - - async fn get_call_count(&self) -> usize { - *self.call_count.lock().await - } } #[async_trait::async_trait] @@ -206,8 +172,6 @@ mod tests { } async fn models(&self, _provider: Provider) -> Result> { - let mut count = self.call_count.lock().await; - *count += 1; Ok(self.models.clone()) } } @@ -297,81 +261,15 @@ mod tests { } #[tokio::test] - async fn test_cache_initialization() { - let repository = Arc::new(MockProviderRepository::new(vec![])); - let service = ForgeProviderService::new(repository); - - // Verify cache is initialized as empty - let cache = service.cached_models.lock().await; - assert!(cache.is_empty()); - } - - #[tokio::test] - async fn test_models_caches_on_first_call() { - let models = vec![test_model("gpt-4"), test_model("gpt-3.5-turbo")]; - let repository = Arc::new(MockProviderRepository::new(models.clone())); - let service = ForgeProviderService::new(repository.clone()); - let provider = test_provider(); - - // First call - should fetch from repository - let actual = service.models(provider.clone()).await.unwrap(); - assert_eq!(actual, models); - assert_eq!(repository.get_call_count().await, 1); - - // Verify cache is populated - let cache = service.cached_models.lock().await; - assert_eq!(cache.len(), 1); - assert!(cache.contains_key(&ProviderId::OPENAI)); - } - - #[tokio::test] - async fn test_models_returns_cached_on_second_call() { + async fn test_models_delegates_to_repository() { let models = vec![test_model("gpt-4"), test_model("gpt-3.5-turbo")]; let repository = Arc::new(MockProviderRepository::new(models.clone())); - let service = ForgeProviderService::new(repository.clone()); + let service = ForgeProviderService::new(repository); let provider = test_provider(); - // First call - populates cache - let _ = service.models(provider.clone()).await.unwrap(); - assert_eq!(repository.get_call_count().await, 1); + let actual = service.models(provider).await.unwrap(); - // Second call - should use cache, not call repository - let actual = service.models(provider.clone()).await.unwrap(); assert_eq!(actual, models); - assert_eq!(repository.get_call_count().await, 1); // Still 1, no additional call - } - - #[tokio::test] - async fn test_models_caches_per_provider() { - let openai_models = vec![test_model("gpt-4")]; - let repository = Arc::new(MockProviderRepository::new(openai_models.clone())); - let service = ForgeProviderService::new(repository.clone()); - - let openai_provider = test_provider(); - let mut anthropic_provider = test_provider(); - anthropic_provider.id = ProviderId::ANTHROPIC; - - // Fetch models for OpenAI - let _ = service.models(openai_provider).await.unwrap(); - - // Fetch models for Anthropic - let _ = service.models(anthropic_provider).await.unwrap(); - - // Verify both providers are cached separately - let cache = service.cached_models.lock().await; - assert_eq!(cache.len(), 2); - assert!(cache.contains_key(&ProviderId::OPENAI)); - assert!(cache.contains_key(&ProviderId::ANTHROPIC)); - } - - #[tokio::test] - async fn test_service_initialization_with_default() { - let repository = Arc::new(MockProviderRepository::new(vec![])); - let service = ForgeProviderService::new(repository); - - // Verify service is properly initialized - let cache = service.cached_models.lock().await; - assert!(cache.is_empty()); } #[tokio::test] From 652bc5d744dfbf20b7d01af37a629ef895b3eb30 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 23 Feb 2026 11:50:22 +0530 Subject: [PATCH 12/37] refactor(model): remove provider switching from model selection --- crates/forge_main/src/model.rs | 38 ++++---------- crates/forge_main/src/ui.rs | 90 +++++++++++----------------------- 2 files changed, 39 insertions(+), 89 deletions(-) diff --git a/crates/forge_main/src/model.rs b/crates/forge_main/src/model.rs index cd7fd74606..641c06be67 100644 --- a/crates/forge_main/src/model.rs +++ b/crates/forge_main/src/model.rs @@ -2,7 +2,7 @@ use std::fmt::Display; use std::sync::{Arc, Mutex}; use colored::Colorize; -use forge_api::{Agent, AnyProvider, Model, ModelId, ProviderId, Template}; +use forge_api::{Agent, AnyProvider, Model, ProviderId, Template}; use forge_domain::UserCommand; use strum::{EnumProperty, IntoEnumIterator}; use strum_macros::{EnumIter, EnumProperty}; @@ -48,37 +48,22 @@ impl Display for CliProvider { } } -/// A model paired with its provider, used for unified model+provider selection. +/// Wrapper for displaying models in selection menus /// -/// This wrapper enables the `/model` command to display models from all -/// configured providers in a single list, with the provider name shown -/// alongside each model entry. +/// This component provides consistent formatting for model selection across +/// the application, showing model ID with contextual information like +/// context length and tools support. #[derive(Clone)] -pub struct CliModelWithProvider { - pub model: Model, - pub provider_id: ProviderId, -} - -impl CliModelWithProvider { - /// Creates a new `CliModelWithProvider` from a model and its provider ID. - pub fn new(model: Model, provider_id: ProviderId) -> Self { - Self { model, provider_id } - } +pub struct CliModel(pub Model); - /// Returns the model ID. - pub fn model_id(&self) -> &ModelId { - &self.model.id - } -} - -impl Display for CliModelWithProvider { +impl Display for CliModel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.model.id)?; + write!(f, "{}", self.0.id)?; let mut info_parts = Vec::new(); // Add context length if available - if let Some(limit) = self.model.context_length { + if let Some(limit) = self.0.context_length { if limit >= 1_000_000 { info_parts.push(format!("{}M", limit / 1_000_000)); } else if limit >= 1000 { @@ -89,7 +74,7 @@ impl Display for CliModelWithProvider { } // Add tools support indicator if explicitly supported - if self.model.tools_supported == Some(true) { + if self.0.tools_supported == Some(true) { info_parts.push("🛠️".to_string()); } @@ -99,9 +84,6 @@ impl Display for CliModelWithProvider { write!(f, " {}", info.dimmed())?; } - // Show provider as a separate suffix - write!(f, " {}", format!("[{}]", self.provider_id).dimmed())?; - Ok(()) } } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 00382b5049..34f34aab04 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -35,7 +35,7 @@ use crate::conversation_selector::ConversationSelector; use crate::display_constants::{CommandType, headers, markers, status}; use crate::info::Info; use crate::input::Console; -use crate::model::{CliModelWithProvider, CliProvider, ForgeCommandManager, SlashCommand}; +use crate::model::{CliModel, CliProvider, ForgeCommandManager, SlashCommand}; use crate::porcelain::Porcelain; use crate::prompt::ForgePrompt; use crate::state::UIState; @@ -1966,68 +1966,54 @@ impl A + Send + Sync> UI { Ok(()) } - /// Select a model from all configured providers. - /// - /// Presents a unified list of models across every configured provider. - /// When the user selects a model from a different provider than the current - /// one, the provider is switched automatically. Returns `Some(ModelId)` if - /// a model was selected, or `None` if the selection was cancelled. + /// Select a model from the available models + /// Returns Some(ModelId) if a model was selected, or None if selection was + /// canceled #[async_recursion::async_recursion] - async fn select_model(&mut self) -> Result> { - // Fetch models from all configured providers - let all_provider_models = self.api.get_all_provider_models().await?; - - // If no providers are configured, fall back to provider selection flow - if all_provider_models.is_empty() { + async fn select_model(&mut self) -> Result> { + // Check if provider is set otherwise first ask to select a provider + if self.api.get_default_provider().await.is_err() { self.on_provider_selection().await?; // Check if a model was already selected during provider activation - if let Some(model) = self.api.get_default_model().await { - let provider = self - .api - .get_default_provider() - .await - .map(|p| p.id) - .unwrap_or(ProviderId::OPENAI); - return Ok(Some((model, provider))); + // Return None to signal the model selection is complete and message was already + // printed + if self.api.get_default_model().await.is_some() { + return Ok(None); } - return Ok(None); } - // Build a flat list of (provider_id, model) entries sorted by model name - let mut models: Vec = all_provider_models + // Fetch available models + let mut models = self + .get_models() + .await? .into_iter() - .flat_map(|pm| { - pm.models - .into_iter() - .map(move |m| CliModelWithProvider::new(m, pm.provider_id.clone())) - }) - .collect(); + .map(CliModel) + .collect::>(); - models.sort_by_key(|a| a.model.id.to_string()); + // Sort the models by their names in ascending order + models.sort_by(|a, b| a.0.name.cmp(&b.0.name)); - // Find the index of the current model to pre-position the cursor + // Find the index of the current model let current_model = self .get_agent_model(self.api.get_active_agent().await) .await; let starting_cursor = current_model .as_ref() - .and_then(|current| models.iter().position(|m| m.model_id() == current)) + .and_then(|current| models.iter().position(|m| &m.0.id == current)) .unwrap_or(0); // Use the centralized select module match ForgeSelect::select("Select a model:", models) .with_starting_cursor(starting_cursor) - .with_help_message( - "Type a name or use arrow keys to navigate and Enter to select. Provider is \ - switched automatically.", - ) + .with_help_message("Type a name or use arrow keys to navigate and Enter to select") .prompt()? { - Some(entry) => Ok(Some((entry.model.id, entry.provider_id))), + Some(model) => Ok(Some(model.0.id)), None => Ok(None), } } + async fn handle_api_key_input( &mut self, provider_id: ProviderId, @@ -2418,36 +2404,18 @@ impl A + Send + Sync> UI { Ok(Some(provider.0)) } - /// Handles model selection and optionally switches the provider. - /// - /// Presents a unified picker of models from all configured providers. When - /// the selected model belongs to a different provider than the current one, - /// the provider is switched automatically before setting the model. + // Helper method to handle model selection and update the conversation #[async_recursion::async_recursion] async fn on_model_selection(&mut self) -> Result> { - // Select a model (returns model id + the provider it belongs to) - let selection = self.select_model().await?; + // Select a model + let model_option = self.select_model().await?; // If no model was selected (user canceled), return early - let (model, selected_provider_id) = match selection { - Some(pair) => pair, + let model = match model_option { + Some(model) => model, None => return Ok(None), }; - // Determine the current provider (if any) - let current_provider_id = self.api.get_default_provider().await.ok().map(|p| p.id); - - // Switch provider first if the selected model belongs to a different one - if current_provider_id.as_ref() != Some(&selected_provider_id) { - self.api - .set_default_provider(selected_provider_id.clone()) - .await?; - self.writeln_title( - TitleFormat::action(format!("{}", selected_provider_id)) - .sub_title("is now the default provider"), - )?; - } - // Update the operating model via API self.api.set_default_model(model.clone()).await?; From fb59e2530c46c2560285f4ba8339e4edc5bfa005 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 23 Feb 2026 11:55:20 +0530 Subject: [PATCH 13/37] refactor(model): reorder CliProvider impl and update display tests --- crates/forge_main/src/model.rs | 127 ++++++++++++++++----------------- 1 file changed, 63 insertions(+), 64 deletions(-) diff --git a/crates/forge_main/src/model.rs b/crates/forge_main/src/model.rs index 641c06be67..6df47ac974 100644 --- a/crates/forge_main/src/model.rs +++ b/crates/forge_main/src/model.rs @@ -10,44 +10,6 @@ use strum_macros::{EnumIter, EnumProperty}; use crate::display_constants::markers; use crate::info::Info; -/// Wrapper for displaying providers in selection menus -/// -/// This component provides consistent formatting for provider selection across -/// the application, showing provider ID with domain information. -#[derive(Clone)] -pub struct CliProvider(pub AnyProvider); - -impl Display for CliProvider { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // Use fixed width for alignment - // Format: "✓ " + name_padded + " [" + domain + "]" - // Longest built-in provider display name is "AnthropicCompatible" (20 chars) - // But we use 19 to account for the space before "[" - let name_width = ProviderId::built_in_providers() - .iter() - .map(|id| id.to_string().len()) - .max() - .unwrap_or(10); - - let name = self.0.id().to_string(); - - match &self.0 { - AnyProvider::Url(provider) => { - write!(f, "{} {: { - write!(f, " {name:) -> std::fmt::Result { + // Use fixed width for alignment + // Format: "✓ " + name_padded + " [" + domain + "]" + // Longest built-in provider display name is "AnthropicCompatible" (20 chars) + // But we use 19 to account for the space before "[" + let name_width = ProviderId::built_in_providers() + .iter() + .map(|id| id.to_string().len()) + .max() + .unwrap_or(10); + + let name = self.0.id().to_string(); + + match &self.0 { + AnyProvider::Url(provider) => { + write!(f, "{} {: { + write!(f, " {name:, tools_supported: Option, - ) -> CliModelWithProvider { - let model = Model { + ) -> Model { + Model { id: ModelId::new(id), name: None, description: None, @@ -927,106 +927,105 @@ mod tests { supports_parallel_tool_calls: None, supports_reasoning: None, input_modalities: vec![InputModality::Text], - }; - CliModelWithProvider::new(model, ProviderId::OPENAI) + } } #[test] fn test_cli_model_display_with_context_and_tools() { let fixture = create_model_fixture("gpt-4", Some(128000), Some(true)); - let formatted = fixture.to_string(); + let formatted = format!("{}", CliModel(fixture)); let actual = strip_ansi_codes(&formatted); - let expected = "gpt-4 [ 128k 🛠️ ] [OpenAI]"; + let expected = "gpt-4 [ 128k 🛠️ ]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_with_large_context() { let fixture = create_model_fixture("claude-3", Some(2000000), Some(true)); - let formatted = fixture.to_string(); + let formatted = format!("{}", CliModel(fixture)); let actual = strip_ansi_codes(&formatted); - let expected = "claude-3 [ 2M 🛠️ ] [OpenAI]"; + let expected = "claude-3 [ 2M 🛠️ ]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_with_small_context() { let fixture = create_model_fixture("small-model", Some(512), Some(false)); - let formatted = fixture.to_string(); + let formatted = format!("{}", CliModel(fixture)); let actual = strip_ansi_codes(&formatted); - let expected = "small-model [ 512 ] [OpenAI]"; + let expected = "small-model [ 512 ]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_with_context_only() { let fixture = create_model_fixture("text-model", Some(4096), Some(false)); - let formatted = fixture.to_string(); + let formatted = format!("{}", CliModel(fixture)); let actual = strip_ansi_codes(&formatted); - let expected = "text-model [ 4k ] [OpenAI]"; + let expected = "text-model [ 4k ]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_with_tools_only() { let fixture = create_model_fixture("tool-model", None, Some(true)); - let formatted = fixture.to_string(); + let formatted = format!("{}", CliModel(fixture)); let actual = strip_ansi_codes(&formatted); - let expected = "tool-model [ 🛠️ ] [OpenAI]"; + let expected = "tool-model [ 🛠️ ]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_empty_context_and_no_tools() { let fixture = create_model_fixture("basic-model", None, Some(false)); - let formatted = fixture.to_string(); + let formatted = format!("{}", CliModel(fixture)); let actual = strip_ansi_codes(&formatted); - let expected = "basic-model [OpenAI]"; + let expected = "basic-model"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_empty_context_and_none_tools() { let fixture = create_model_fixture("unknown-model", None, None); - let formatted = fixture.to_string(); + let formatted = format!("{}", CliModel(fixture)); let actual = strip_ansi_codes(&formatted); - let expected = "unknown-model [OpenAI]"; + let expected = "unknown-model"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_exact_thousands() { let fixture = create_model_fixture("exact-k", Some(8000), Some(true)); - let formatted = fixture.to_string(); + let formatted = format!("{}", CliModel(fixture)); let actual = strip_ansi_codes(&formatted); - let expected = "exact-k [ 8k 🛠️ ] [OpenAI]"; + let expected = "exact-k [ 8k 🛠️ ]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_exact_millions() { let fixture = create_model_fixture("exact-m", Some(1000000), Some(true)); - let formatted = fixture.to_string(); + let formatted = format!("{}", CliModel(fixture)); let actual = strip_ansi_codes(&formatted); - let expected = "exact-m [ 1M 🛠️ ] [OpenAI]"; + let expected = "exact-m [ 1M 🛠️ ]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_edge_case_999() { let fixture = create_model_fixture("edge-999", Some(999), None); - let formatted = fixture.to_string(); + let formatted = format!("{}", CliModel(fixture)); let actual = strip_ansi_codes(&formatted); - let expected = "edge-999 [ 999 ] [OpenAI]"; + let expected = "edge-999 [ 999 ]"; assert_eq!(actual, expected); } #[test] fn test_cli_model_display_edge_case_1001() { let fixture = create_model_fixture("edge-1001", Some(1001), None); - let formatted = fixture.to_string(); + let formatted = format!("{}", CliModel(fixture)); let actual = strip_ansi_codes(&formatted); - let expected = "edge-1001 [ 1k ] [OpenAI]"; + let expected = "edge-1001 [ 1k ]"; assert_eq!(actual, expected); } From bc240e167c6b6dc935148c0e2c4ceb7c1148a479 Mon Sep 17 00:00:00 2001 From: laststylebender <43403528+laststylebender14@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:58:25 +0530 Subject: [PATCH 14/37] - add new lines --- crates/forge_api/src/api.rs | 1 + crates/forge_api/src/forge_api.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index 39512f57b7..a9564b1cdb 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -26,6 +26,7 @@ pub trait API: Sync + Send { /// Provides models from all configured providers. Providers that fail to /// return models are silently skipped. async fn get_all_provider_models(&self) -> Result>; + /// Provides a list of agents available in the current environment async fn get_agents(&self) -> Result>; /// Provides a list of providers available in the current environment diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 836716eccf..051fce0789 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -77,6 +77,7 @@ impl< async fn get_all_provider_models(&self) -> Result> { self.app().get_all_provider_models().await } + async fn get_agents(&self) -> Result> { self.services.get_agents().await } From 29dd26f39033c4964ce70662b40b5a085c8f94ff Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 23 Feb 2026 12:11:12 +0530 Subject: [PATCH 15/37] perf(shell-plugin): consolidate model field extraction --- shell-plugin/lib/actions/config.zsh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index 98e78e4c8b..c7f71f73c5 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -131,10 +131,14 @@ function _forge_action_model() { if [[ -n "$selected" ]]; then # Field 1 = model_id (raw), field 3 = provider display name, # field 4 = provider_id (raw, for config set) + # Extract all fields in a single awk call for efficiency local model_id provider_id provider_display - model_id=$(echo "$selected" | awk -F ' +' '{print $1}' | xargs) - provider_id=$(echo "$selected" | awk -F ' +' '{print $4}' | xargs) - provider_display=$(echo "$selected" | awk -F ' +' '{print $3}' | xargs) + read -r model_id provider_display provider_id <<<$(echo "$selected" | awk -F ' +' '{print $1, $3, $4}') + + # Trim whitespace + model_id=${model_id//[[:space:]]/} + provider_id=${provider_id//[[:space:]]/} + provider_display=${provider_display//[[:space:]]/} # Switch provider first if it differs from the current one # config get provider returns the display name, so compare against that From bbc287811c5a49be48d5deca93a6d42eabc4eaf5 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 23 Feb 2026 15:10:31 +0530 Subject: [PATCH 16/37] feat(config): add commit model configuration command --- crates/forge_api/src/api.rs | 9 ++ crates/forge_api/src/forge_api.rs | 8 ++ crates/forge_app/src/command_generator.rs | 8 ++ crates/forge_app/src/git_app.rs | 58 +++++++++-- crates/forge_app/src/services.rs | 20 ++++ crates/forge_domain/src/app_config.rs | 4 +- crates/forge_domain/src/commit_config.rs | 28 ++++++ crates/forge_domain/src/lib.rs | 2 + crates/forge_domain/src/model.rs | 8 ++ crates/forge_main/src/built_in_commands.json | 4 + crates/forge_main/src/cli.rs | 100 ++++++++++++++----- crates/forge_main/src/ui.rs | 69 +++++++++---- crates/forge_services/src/app_config.rs | 15 +++ shell-plugin/lib/actions/config.zsh | 97 ++++++++++++------ shell-plugin/lib/dispatcher.zsh | 3 + 15 files changed, 353 insertions(+), 80 deletions(-) create mode 100644 crates/forge_domain/src/commit_config.rs diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index a9564b1cdb..64712e022b 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -157,6 +157,15 @@ pub trait API: Sync + Send { /// Sets the operating model async fn set_default_model(&self, model_id: ModelId) -> anyhow::Result<()>; + /// Gets the commit configuration (provider and model for commit message generation). + async fn get_commit_config(&self) -> anyhow::Result>; + + /// Sets the commit configuration (provider and model for commit message generation). + async fn set_commit_config( + &self, + config: forge_domain::CommitConfig, + ) -> anyhow::Result<()>; + /// Refresh MCP caches by fetching fresh data async fn reload_mcp(&self) -> Result<()>; diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 051fce0789..0892490aa1 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -284,6 +284,14 @@ impl< result } + async fn get_commit_config(&self) -> anyhow::Result> { + self.services.get_commit_config().await + } + + async fn set_commit_config(&self, config: CommitConfig) -> anyhow::Result<()> { + self.services.set_commit_config(config).await + } + async fn get_login_info(&self) -> Result> { self.services.auth_service().get_auth_token().await } diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 3ddf728bfc..3079510148 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -241,6 +241,14 @@ mod tests { async fn set_default_model(&self, _model: ModelId) -> Result<()> { Ok(()) } + + async fn get_commit_config(&self) -> Result> { + Ok(None) + } + + async fn set_commit_config(&self, _config: forge_domain::CommitConfig) -> Result<()> { + Ok(()) + } } #[tokio::test] diff --git a/crates/forge_app/src/git_app.rs b/crates/forge_app/src/git_app.rs index d3d7622053..fafc1b082c 100644 --- a/crates/forge_app/src/git_app.rs +++ b/crates/forge_app/src/git_app.rs @@ -287,18 +287,60 @@ where Ok((diff_output.output.stdout, size, has_staged_files)) } + /// Resolves the provider and model from the active agent's configuration. + async fn resolve_agent_provider_and_model( + &self, + resolver: &AgentProviderResolver, + agent_id: Option, + ) -> Result<(Provider, ModelId)> { + let (provider_template, model) = tokio::try_join!( + resolver.get_provider(agent_id.clone()), + resolver.get_model(agent_id) + )?; + let provider = self.services.refresh_provider_credential(provider_template).await?; + Ok((provider, model)) + } + /// Generates a commit message from the provided diff and git context async fn generate_message_from_diff(&self, ctx: DiffContext) -> Result { - // Get required services and data in parallel let agent_id = self.services.get_active_agent_id().await?; let agent_provider_resolver = AgentProviderResolver::new(self.services.clone()); - let (rendered_prompt, provider, model) = tokio::try_join!( - self.services - .render_template(Template::new("{{> forge-commit-message-prompt.md }}"), &()), - agent_provider_resolver.get_provider(agent_id.clone()), - agent_provider_resolver.get_model(agent_id) - )?; - let provider = self.services.refresh_provider_credential(provider).await?; + let commit_config = self.services.get_commit_config().await?; + + // Resolve provider and model: commit config takes priority over agent defaults. + // If the configured provider is unavailable (e.g. logged out), fall back to the + // agent's provider/model with a warning. + let (provider, model) = + match commit_config.and_then(|c| c.provider.zip(c.model)) { + Some((provider_id, commit_model)) => { + match self.services.get_provider(provider_id).await { + Ok(template) => { + let provider = + self.services.refresh_provider_credential(template).await?; + (provider, commit_model) + } + Err(_) => { + tracing::warn!( + "Configured commit provider is not authenticated. Falling back to the active provider." + ); + self.resolve_agent_provider_and_model( + &agent_provider_resolver, + agent_id, + ) + .await? + } + } + } + None => { + self.resolve_agent_provider_and_model(&agent_provider_resolver, agent_id) + .await? + } + }; + + let rendered_prompt = self + .services + .render_template(Template::new("{{> forge-commit-message-prompt.md }}"), &()) + .await?; // Build user message using structured JSON format let user_data = serde_json::json!({ diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 5e0acf12cd..a8f838c96d 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -207,6 +207,15 @@ pub trait AppConfigService: Send + Sync { /// # Errors /// Returns an error if no default provider is configured. async fn set_default_model(&self, model: ModelId) -> anyhow::Result<()>; + + /// Gets the commit configuration (provider and model for commit message generation). + async fn get_commit_config(&self) -> anyhow::Result>; + + /// Sets the commit configuration (provider and model for commit message generation). + async fn set_commit_config( + &self, + config: forge_domain::CommitConfig, + ) -> anyhow::Result<()>; } #[async_trait::async_trait] @@ -1025,6 +1034,17 @@ impl AppConfigService for I { async fn set_default_model(&self, model: ModelId) -> anyhow::Result<()> { self.config_service().set_default_model(model).await } + + async fn get_commit_config(&self) -> anyhow::Result> { + self.config_service().get_commit_config().await + } + + async fn set_commit_config( + &self, + config: forge_domain::CommitConfig, + ) -> anyhow::Result<()> { + self.config_service().set_commit_config(config).await + } } #[async_trait::async_trait] diff --git a/crates/forge_domain/src/app_config.rs b/crates/forge_domain/src/app_config.rs index 300eff9415..87f35136cf 100644 --- a/crates/forge_domain/src/app_config.rs +++ b/crates/forge_domain/src/app_config.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use derive_more::From; use serde::{Deserialize, Serialize}; -use crate::{ModelId, ProviderId}; +use crate::{CommitConfig, ModelId, ProviderId}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -21,6 +21,8 @@ pub struct AppConfig { pub provider: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub model: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commit: Option, } #[derive(Clone, Serialize, Deserialize, From, Debug, PartialEq)] diff --git a/crates/forge_domain/src/commit_config.rs b/crates/forge_domain/src/commit_config.rs new file mode 100644 index 0000000000..c6003cb07c --- /dev/null +++ b/crates/forge_domain/src/commit_config.rs @@ -0,0 +1,28 @@ +use derive_setters::Setters; +use merge::Merge; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{ModelId, ProviderId}; + +/// Configuration for commit message generation. +/// +/// Allows specifying a dedicated provider and model for commit message generation, +/// instead of using the active agent's provider and model. This is useful when +/// you want to use a cheaper or faster model for simple commit message generation. +#[derive(Default, Debug, Clone, Serialize, Deserialize, Merge, Setters, JsonSchema, PartialEq)] +#[setters(strip_option, into)] +pub struct CommitConfig { + /// Provider ID to use for commit message generation. + /// If not specified, the active agent's provider will be used. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[merge(strategy = crate::merge::option)] + pub provider: Option, + + /// Model ID to use for commit message generation. + /// If not specified, the provider's default model or the active agent's + /// model will be used. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[merge(strategy = crate::merge::option)] + pub model: Option, +} diff --git a/crates/forge_domain/src/lib.rs b/crates/forge_domain/src/lib.rs index 589bfd4773..0ea328e366 100644 --- a/crates/forge_domain/src/lib.rs +++ b/crates/forge_domain/src/lib.rs @@ -6,6 +6,7 @@ mod auth; mod chat_request; mod chat_response; mod compact; +mod commit_config; mod console; mod context; mod conversation; @@ -62,6 +63,7 @@ pub use attachment::*; pub use chat_request::*; pub use chat_response::*; pub use compact::*; +pub use commit_config::*; pub use console::*; pub use context::*; pub use conversation::*; diff --git a/crates/forge_domain/src/model.rs b/crates/forge_domain/src/model.rs index 07cacdd084..1d2600939d 100644 --- a/crates/forge_domain/src/model.rs +++ b/crates/forge_domain/src/model.rs @@ -75,3 +75,11 @@ impl ModelId { &self.0 } } + +impl std::str::FromStr for ModelId { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(ModelId(s.to_string())) + } +} diff --git a/crates/forge_main/src/built_in_commands.json b/crates/forge_main/src/built_in_commands.json index a1f4cf1b60..d0d4ee9fee 100644 --- a/crates/forge_main/src/built_in_commands.json +++ b/crates/forge_main/src/built_in_commands.json @@ -15,6 +15,10 @@ "command": "model", "description": "Switch the models [alias: m]" }, + { + "command": "config-commit-model", + "description": "Set the model used for commit message generation [alias: ccm]" + }, { "command": "new", "description": "Start new conversation [alias: n]" diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index ed3850483d..b2b515c8aa 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use clap::{Parser, Subcommand, ValueEnum}; -use forge_domain::{AgentId, ConversationId, ProviderId}; +use forge_domain::{AgentId, ConversationId, ModelId, ProviderId}; #[derive(Parser)] #[command(version = env!("CARGO_PKG_VERSION"))] @@ -493,28 +493,51 @@ pub enum ConfigCommand { List, } +/// Arguments for `forge config set`. #[derive(Parser, Debug, Clone)] pub struct ConfigSetArgs { - /// Configuration field to set. - pub field: ConfigField, + #[command(subcommand)] + pub field: ConfigSetField, +} - /// Value to set. - pub value: String, +/// Arguments for `forge config get`. +#[derive(Parser, Debug, Clone)] +pub struct ConfigGetArgs { + #[command(subcommand)] + pub field: ConfigGetField, } -/// Configuration fields that can be managed. -#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConfigField { - /// The active model. - Model, - /// The active provider. - Provider, +/// Type-safe subcommands for `forge config set`. +#[derive(Subcommand, Debug, Clone)] +pub enum ConfigSetField { + /// Set the active model. + Model { + /// Model ID to set as default. + model: ModelId, + }, + /// Set the active provider. + Provider { + /// Provider ID to set as default. + provider: ProviderId, + }, + /// Set the provider and model for commit message generation. + Commit { + /// Provider ID to use for commit message generation. + provider: ProviderId, + /// Model ID to use for commit message generation. + model: ModelId, + }, } -#[derive(Parser, Debug, Clone)] -pub struct ConfigGetArgs { - /// Configuration field to get. - pub field: ConfigField, +/// Type-safe subcommands for `forge config get`. +#[derive(Subcommand, Debug, Clone)] +pub enum ConfigGetField { + /// Get the active model. + Model, + /// Get the active provider. + Provider, + /// Get the commit message generation config. + Commit, } /// Command group for conversation management. @@ -777,9 +800,10 @@ mod tests { ]); let actual = match fixture.subcommands { Some(TopLevelCommand::Config(config)) => match config.command { - ConfigCommand::Set(args) if args.field == ConfigField::Model => { - Some(args.value.clone()) - } + ConfigCommand::Set(args) => match args.field { + ConfigSetField::Model { model } => Some(model.as_str().to_string()), + _ => None, + }, _ => None, }, _ => None, @@ -793,14 +817,15 @@ mod tests { let fixture = Cli::parse_from(["forge", "config", "set", "provider", "OpenAI"]); let actual = match fixture.subcommands { Some(TopLevelCommand::Config(config)) => match config.command { - ConfigCommand::Set(args) if args.field == ConfigField::Provider => { - Some(args.value.clone()) - } + ConfigCommand::Set(args) => match args.field { + ConfigSetField::Provider { provider } => Some(provider.to_string()), + _ => None, + }, _ => None, }, _ => None, }; - let expected = Some("OpenAI".to_string()); + let expected = Some("OpenAi".to_string()); assert_eq!(actual, expected); } @@ -820,12 +845,37 @@ mod tests { let fixture = Cli::parse_from(["forge", "config", "get", "model"]); let actual = match fixture.subcommands { Some(TopLevelCommand::Config(config)) => match config.command { - ConfigCommand::Get(args) => args.field, + ConfigCommand::Get(args) => matches!(args.field, ConfigGetField::Model), _ => panic!("Expected ConfigCommand::Get"), }, _ => panic!("Expected TopLevelCommand::Config"), }; - let expected = ConfigField::Model; + assert!(actual); + } + + #[test] + fn test_config_set_commit_with_provider_and_model() { + let fixture = Cli::parse_from([ + "forge", + "config", + "set", + "commit", + "anthropic", + "claude-haiku-4-20250514", + ]); + let actual = match fixture.subcommands { + Some(TopLevelCommand::Config(config)) => match config.command { + ConfigCommand::Set(args) => match args.field { + ConfigSetField::Commit { provider, model } => { + Some((provider.to_string(), model.as_str().to_string())) + } + _ => None, + }, + _ => None, + }, + _ => None, + }; + let expected = Some(("Anthropic".to_string(), "claude-haiku-4-20250514".to_string())); assert_eq!(actual, expected); } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 34f34aab04..61298c9732 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1313,11 +1313,24 @@ impl A + Send + Sync> UI { .ok() .map(|p| p.id.to_string()) .unwrap_or_else(|| markers::EMPTY.to_string()); + let commit_config = self.api.get_commit_config().await.ok().flatten(); + let commit_provider = commit_config + .as_ref() + .and_then(|c| c.provider.as_ref()) + .map(|p| p.to_string()) + .unwrap_or_else(|| markers::EMPTY.to_string()); + let commit_model = commit_config + .as_ref() + .and_then(|c| c.model.as_ref()) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| markers::EMPTY.to_string()); let info = Info::new() .add_title("CONFIGURATION") .add_key_value("Default Model", model) - .add_key_value("Default Provider", provider); + .add_key_value("Default Provider", provider) + .add_key_value("Commit Provider", commit_provider) + .add_key_value("Commit Model", commit_model); if porcelain { self.writeln( @@ -3107,27 +3120,33 @@ impl A + Send + Sync> UI { /// Handle config set command async fn handle_config_set(&mut self, args: crate::cli::ConfigSetArgs) -> Result<()> { - use crate::cli::ConfigField; + use crate::cli::ConfigSetField; - // Set the specified field match args.field { - ConfigField::Provider => { - // Parse provider ID (any string is valid for custom providers) - let provider_id = - ProviderId::from_str(&args.value).expect("from_str is infallible"); - - // Get the provider - let provider = self.api.get_provider(&provider_id).await?; - // Activate the provider (will configure if needed and set as default) + ConfigSetField::Provider { provider } => { + let provider = self.api.get_provider(&provider).await?; self.activate_provider(provider).await?; } - ConfigField::Model => { - let model_id = self.validate_model(&args.value).await?; + ConfigSetField::Model { model } => { + let model_id = self.validate_model(model.as_str()).await?; self.api.set_default_model(model_id.clone()).await?; self.writeln_title( TitleFormat::action(model_id.as_str()).sub_title("is now the default model"), )?; } + ConfigSetField::Commit { provider, model } => { + // Validate provider exists and model is known + self.api.get_provider(&provider).await?; + let validated_model = self.validate_model(model.as_str()).await?; + let commit_config = forge_domain::CommitConfig::default() + .provider(provider.clone()) + .model(validated_model.clone()); + self.api.set_commit_config(commit_config).await?; + self.writeln_title( + TitleFormat::action(validated_model.as_str()) + .sub_title(&format!("is now the commit model for provider '{provider}'")), + )?; + } } Ok(()) @@ -3135,11 +3154,10 @@ impl A + Send + Sync> UI { /// Handle config get command async fn handle_config_get(&mut self, args: crate::cli::ConfigGetArgs) -> Result<()> { - use crate::cli::ConfigField; + use crate::cli::ConfigGetField; - // Get specific field match args.field { - ConfigField::Model => { + ConfigGetField::Model => { let model = self .api .get_default_model() @@ -3150,7 +3168,7 @@ impl A + Send + Sync> UI { None => self.writeln("Model: Not set")?, } } - ConfigField::Provider => { + ConfigGetField::Provider => { let provider = self .api .get_default_provider() @@ -3162,6 +3180,23 @@ impl A + Send + Sync> UI { None => self.writeln("Provider: Not set")?, } } + ConfigGetField::Commit => { + let commit_config = self.api.get_commit_config().await?; + match commit_config { + Some(config) => { + let provider = config + .provider + .map(|p| p.to_string()) + .unwrap_or_else(|| "Not set".to_string()); + let model = config + .model + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "Not set".to_string()); + self.writeln(format!("Commit provider: {provider}, Commit model: {model}"))?; + } + None => self.writeln("Commit config: Not set")?, + } + } } Ok(()) diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index 93e5d13660..f68ae0ce03 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -80,6 +80,21 @@ impl AppConfigService }) .await } + + async fn get_commit_config(&self) -> anyhow::Result> { + let config = self.infra.get_app_config().await?; + Ok(config.commit) + } + + async fn set_commit_config( + &self, + commit_config: forge_domain::CommitConfig, + ) -> anyhow::Result<()> { + self.update(|config| { + config.commit = Some(commit_config); + }) + .await + } } #[cfg(test)] diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index c7f71f73c5..b9ac6399c4 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -90,52 +90,66 @@ function _forge_action_provider() { fi } -# Action handler: Select model (across all configured providers) +# Helper: Open an fzf model picker and print the raw selected line. # -# Uses `forge list models --porcelain` which outputs columns (after swap): +# Model list columns (from `forge list models --porcelain`): # 1:model_id 2:model_name 3:provider(display) 4:provider_id(raw) 5:context 6:tools 7:image -# Shows the picker hiding model_id (field 1) and provider_id (field 4). +# The picker hides model_id (field 1) and provider_id (field 4) via --with-nth. +# +# Arguments: +# $1 prompt_text - fzf prompt label (e.g. "Model ❯ ") +# $2 current_model - model_id to pre-position the cursor on (may be empty) +# $3 input_text - optional pre-fill query for fzf +# +# Outputs the raw selected line to stdout, or nothing if cancelled. +function _forge_pick_model() { + local prompt_text="$1" + local current_model="$2" + local input_text="$3" + + local output + output=$($_FORGE_BIN list models --porcelain 2>/dev/null) + + if [[ -z "$output" ]]; then + return 1 + fi + + local fzf_args=( + --delimiter="$_FORGE_DELIMITER" + --prompt="$prompt_text" + --with-nth="2,3,5.." + ) + + if [[ -n "$input_text" ]]; then + fzf_args+=(--query="$input_text") + fi + + if [[ -n "$current_model" ]]; then + local index=$(_forge_find_index "$output" "$current_model" 1) + fzf_args+=(--bind="start:pos($index)") + fi + + echo "$output" | _forge_fzf --header-lines=1 "${fzf_args[@]}" +} + +# Action handler: Select model (across all configured providers) # When the selected model belongs to a different provider, switches it first. function _forge_action_model() { local input_text="$1" ( echo - local output - output=$($_FORGE_BIN list models --porcelain 2>/dev/null) - - if [[ -z "$output" ]]; then - return 0 - fi - local current_model current_model=$($_FORGE_BIN config get model --porcelain 2>/dev/null) - local fzf_args=( - --delimiter="$_FORGE_DELIMITER" - --prompt="Model ❯ " - --with-nth="2,3,5.." - ) - - if [[ -n "$input_text" ]]; then - fzf_args+=(--query="$input_text") - fi - - if [[ -n "$current_model" ]]; then - local index=$(_forge_find_index "$output" "$current_model" 1) - fzf_args+=(--bind="start:pos($index)") - fi - local selected - selected=$(echo "$output" | _forge_fzf --header-lines=1 "${fzf_args[@]}") + selected=$(_forge_pick_model "Model ❯ " "$current_model" "$input_text") if [[ -n "$selected" ]]; then # Field 1 = model_id (raw), field 3 = provider display name, # field 4 = provider_id (raw, for config set) - # Extract all fields in a single awk call for efficiency - local model_id provider_id provider_display + local model_id provider_display provider_id read -r model_id provider_display provider_id <<<$(echo "$selected" | awk -F ' +' '{print $1, $3, $4}') - # Trim whitespace model_id=${model_id//[[:space:]]/} provider_id=${provider_id//[[:space:]]/} provider_display=${provider_display//[[:space:]]/} @@ -153,6 +167,31 @@ function _forge_action_model() { ) } +# Action handler: Select model for commit message generation +# Calls `forge config set commit ` on selection. +function _forge_action_commit_model() { + local input_text="$1" + ( + echo + local current_commit_model + current_commit_model=$($_FORGE_BIN config get commit --porcelain 2>/dev/null | awk '{print $NF}') + + local selected + selected=$(_forge_pick_model "Commit Model ❯ " "$current_commit_model" "$input_text") + + if [[ -n "$selected" ]]; then + # Field 1 = model_id (raw), field 4 = provider_id (raw) + local model_id provider_id + read -r model_id provider_id <<<$(echo "$selected" | awk -F ' +' '{print $1, $4}') + + model_id=${model_id//[[:space:]]/} + provider_id=${provider_id//[[:space:]]/} + + _forge_exec config set commit "$provider_id" "$model_id" + fi + ) +} + # Action handler: Sync workspace for codebase search function _forge_action_sync() { echo diff --git a/shell-plugin/lib/dispatcher.zsh b/shell-plugin/lib/dispatcher.zsh index a5ecc1840e..5a3512cdb4 100644 --- a/shell-plugin/lib/dispatcher.zsh +++ b/shell-plugin/lib/dispatcher.zsh @@ -162,6 +162,9 @@ function forge-accept-line() { model|m) _forge_action_model "$input_text" ;; + config-commit-model|ccm) + _forge_action_commit_model "$input_text" + ;; tools|t) _forge_action_tools ;; From 7efa21385c75de750f5e93b84bee1ce2ad59ec15 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 23 Feb 2026 16:26:51 +0530 Subject: [PATCH 17/37] refactor(model): validate models against specific provider --- crates/forge_main/src/ui.rs | 54 +++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 61298c9732..c30566dcfb 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3128,16 +3128,15 @@ impl A + Send + Sync> UI { self.activate_provider(provider).await?; } ConfigSetField::Model { model } => { - let model_id = self.validate_model(model.as_str()).await?; + let model_id = self.validate_model(model.as_str(), None).await?; self.api.set_default_model(model_id.clone()).await?; self.writeln_title( TitleFormat::action(model_id.as_str()).sub_title("is now the default model"), )?; } ConfigSetField::Commit { provider, model } => { - // Validate provider exists and model is known - self.api.get_provider(&provider).await?; - let validated_model = self.validate_model(model.as_str()).await?; + // Validate provider exists and model belongs to that specific provider + let validated_model = self.validate_model(model.as_str(), Some(&provider)).await?; let commit_config = forge_domain::CommitConfig::default() .provider(provider.clone()) .model(validated_model.clone()); @@ -3264,26 +3263,35 @@ impl A + Send + Sync> UI { Some(rprompt.to_string()) } - /// Validate model exists - async fn validate_model(&self, model_str: &str) -> Result { - let models = self.api.get_models().await?; + /// Validates that a model exists, optionally scoped to a specific provider. + /// When `provider` is `None`, models are fetched from the default provider. + async fn validate_model( + &self, + model_str: &str, + provider: Option<&forge_domain::ProviderId>, + ) -> Result { + let models = match provider { + None => self.api.get_models().await?, + Some(provider_id) => { + self.api + .get_all_provider_models() + .await? + .into_iter() + .find(|pm| &pm.provider_id == provider_id) + .with_context(|| format!("Provider '{provider_id}' not found or returned no models"))? + .models + } + }; let model_id = ModelId::new(model_str); - - if models.iter().any(|m| m.id == model_id) { - Ok(model_id) - } else { - // Show first 10 models as suggestions - let available: Vec<_> = models.iter().take(10).map(|m| m.id.as_str()).collect(); - let suggestion = if models.len() > 10 { - format!("{} (and {} more)", available.join(", "), models.len() - 10) - } else { - available.join(", ") - }; - - Err(anyhow::anyhow!( - "Model '{model_str}' not found. Available models: {suggestion}" - )) - } + models + .iter() + .find(|m| m.id == model_id) + .map(|_| model_id) + .with_context(|| { + let hints = models.iter().take(10).map(|m| m.id.as_str()).collect::>().join(", "); + let suggestion = if models.len() > 10 { format!("{hints} (and {} more)", models.len() - 10) } else { hints }; + format!("Model '{model_str}' not found. Available models: {suggestion}") + }) } /// Shows the last message from a conversation From cf5b5e1d56aebf77b21f6c7025ee014943257dde Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 23 Feb 2026 16:29:18 +0530 Subject: [PATCH 18/37] refactor(git_app): simplify provider and model resolution --- crates/forge_app/src/git_app.rs | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/crates/forge_app/src/git_app.rs b/crates/forge_app/src/git_app.rs index fafc1b082c..cd4cdfded6 100644 --- a/crates/forge_app/src/git_app.rs +++ b/crates/forge_app/src/git_app.rs @@ -312,29 +312,16 @@ where // agent's provider/model with a warning. let (provider, model) = match commit_config.and_then(|c| c.provider.zip(c.model)) { - Some((provider_id, commit_model)) => { - match self.services.get_provider(provider_id).await { - Ok(template) => { - let provider = - self.services.refresh_provider_credential(template).await?; - (provider, commit_model) - } - Err(_) => { - tracing::warn!( - "Configured commit provider is not authenticated. Falling back to the active provider." - ); - self.resolve_agent_provider_and_model( - &agent_provider_resolver, - agent_id, - ) - .await? - } + Some((provider_id, commit_model)) => match self.services.get_provider(provider_id).await { + Ok(provider) => (self.services.refresh_provider_credential(provider).await?, commit_model), + Err(_) => { + tracing::warn!( + "Configured commit provider is not authenticated. Falling back to the active provider." + ); + self.resolve_agent_provider_and_model(&agent_provider_resolver, agent_id).await? } - } - None => { - self.resolve_agent_provider_and_model(&agent_provider_resolver, agent_id) - .await? - } + }, + None => self.resolve_agent_provider_and_model(&agent_provider_resolver, agent_id).await?, }; let rendered_prompt = self From f85e09aca9d029b2da4eb5f1085093a55d188f35 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 23 Feb 2026 16:30:01 +0530 Subject: [PATCH 19/37] refactor(git_app): parallelize async operations --- crates/forge_app/src/git_app.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/forge_app/src/git_app.rs b/crates/forge_app/src/git_app.rs index cd4cdfded6..351ccc2d42 100644 --- a/crates/forge_app/src/git_app.rs +++ b/crates/forge_app/src/git_app.rs @@ -303,9 +303,11 @@ where /// Generates a commit message from the provided diff and git context async fn generate_message_from_diff(&self, ctx: DiffContext) -> Result { - let agent_id = self.services.get_active_agent_id().await?; + let (agent_id, commit_config) = tokio::try_join!( + self.services.get_active_agent_id(), + self.services.get_commit_config() + )?; let agent_provider_resolver = AgentProviderResolver::new(self.services.clone()); - let commit_config = self.services.get_commit_config().await?; // Resolve provider and model: commit config takes priority over agent defaults. // If the configured provider is unavailable (e.g. logged out), fall back to the From 4a885dced497750b8635806b0fd0ba576eded543 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 23 Feb 2026 16:31:35 +0530 Subject: [PATCH 20/37] fix(git_app): improve error logging for unavailable commit provider --- crates/forge_app/src/git_app.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/forge_app/src/git_app.rs b/crates/forge_app/src/git_app.rs index 351ccc2d42..99279d39b7 100644 --- a/crates/forge_app/src/git_app.rs +++ b/crates/forge_app/src/git_app.rs @@ -316,9 +316,10 @@ where match commit_config.and_then(|c| c.provider.zip(c.model)) { Some((provider_id, commit_model)) => match self.services.get_provider(provider_id).await { Ok(provider) => (self.services.refresh_provider_credential(provider).await?, commit_model), - Err(_) => { + Err(err) => { tracing::warn!( - "Configured commit provider is not authenticated. Falling back to the active provider." + error = %err, + "Configured commit provider unavailable. Falling back to the active provider." ); self.resolve_agent_provider_and_model(&agent_provider_resolver, agent_id).await? } From 8ec080a49a1090ce0a2049548924e989dc613978 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 23 Feb 2026 16:38:53 +0530 Subject: [PATCH 21/37] refactor(ui): streamline commit configuration display --- crates/forge_main/src/ui.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index c30566dcfb..7c239ab945 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3183,17 +3183,12 @@ impl A + Send + Sync> UI { let commit_config = self.api.get_commit_config().await?; match commit_config { Some(config) => { - let provider = config - .provider - .map(|p| p.to_string()) - .unwrap_or_else(|| "Not set".to_string()); - let model = config - .model - .map(|m| m.as_str().to_string()) - .unwrap_or_else(|| "Not set".to_string()); - self.writeln(format!("Commit provider: {provider}, Commit model: {model}"))?; + let provider = config.provider.map(|p| p.to_string()).unwrap_or_else(|| "Not set".to_string()); + let model = config.model.map(|m| m.as_str().to_string()).unwrap_or_else(|| "Not set".to_string()); + self.writeln(provider)?; + self.writeln(model)?; } - None => self.writeln("Commit config: Not set")?, + None => self.writeln("Commit: Not set")?, } } } From 4e096736eff5adc756dbed39cf2b690d2b108822 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 23 Feb 2026 16:44:12 +0530 Subject: [PATCH 22/37] refactor(shell-plugin): simplify config get commands --- shell-plugin/lib/actions/config.zsh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index b9ac6399c4..b9f0934066 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -139,7 +139,7 @@ function _forge_action_model() { ( echo local current_model - current_model=$($_FORGE_BIN config get model --porcelain 2>/dev/null) + current_model=$(_forge_exec config get model 2>/dev/null) local selected selected=$(_forge_pick_model "Model ❯ " "$current_model" "$input_text") @@ -157,7 +157,7 @@ function _forge_action_model() { # Switch provider first if it differs from the current one # config get provider returns the display name, so compare against that local current_provider - current_provider=$(_forge_exec config get provider --porcelain 2>/dev/null) + current_provider=$(_forge_exec config get provider 2>/dev/null) if [[ -n "$provider_display" && "$provider_display" != "$current_provider" ]]; then _forge_exec config set provider "$provider_id" fi @@ -174,7 +174,7 @@ function _forge_action_commit_model() { ( echo local current_commit_model - current_commit_model=$($_FORGE_BIN config get commit --porcelain 2>/dev/null | awk '{print $NF}') + current_commit_model=$(_forge_exec config get commit 2>/dev/null | tail -n 1) local selected selected=$(_forge_pick_model "Commit Model ❯ " "$current_commit_model" "$input_text") From 5343a44c503d81fa1c336eb0f5a5518febc3c6cd Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 05:06:44 +0000 Subject: [PATCH 23/37] [autofix.ci] apply automated fixes --- crates/forge_api/src/api.rs | 2 +- crates/forge_api/src/forge_api.rs | 2 +- crates/forge_app/src/app.rs | 2 +- crates/forge_repo/src/provider/chat.rs | 6 +++++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index a9564b1cdb..89c8cdd495 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -26,7 +26,7 @@ pub trait API: Sync + Send { /// Provides models from all configured providers. Providers that fail to /// return models are silently skipped. async fn get_all_provider_models(&self) -> Result>; - + /// Provides a list of agents available in the current environment async fn get_agents(&self) -> Result>; /// Provides a list of providers available in the current environment diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 051fce0789..6e8d2669c0 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -77,7 +77,7 @@ impl< async fn get_all_provider_models(&self) -> Result> { self.app().get_all_provider_models().await } - + async fn get_agents(&self) -> Result> { self.services.get_agents().await } diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index 763526ec44..5308d67dd2 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -313,7 +313,7 @@ impl ForgeApp { Ok(results) } - + pub async fn login(&self, init_auth: &InitAuth) -> Result<()> { self.authenticator.login(init_auth).await } diff --git a/crates/forge_repo/src/provider/chat.rs b/crates/forge_repo/src/provider/chat.rs index 97fd7f0b2b..ce702625b0 100644 --- a/crates/forge_repo/src/provider/chat.rs +++ b/crates/forge_repo/src/provider/chat.rs @@ -114,7 +114,11 @@ impl ChatRepository for ForgeChatReposit let cache_key = format!("models:{}", provider.id); - if let Ok(Some(cached)) = self.model_cache.cache_get::<_, Vec>(&cache_key).await { + if let Ok(Some(cached)) = self + .model_cache + .cache_get::<_, Vec>(&cache_key) + .await + { tracing::debug!(provider_id = %provider.id, "returning cached models"); return Ok(cached); } From c5137754db5887ca51bf6671da5e72ee047d6623 Mon Sep 17 00:00:00 2001 From: Tushar Date: Mon, 2 Mar 2026 10:37:11 +0530 Subject: [PATCH 24/37] fix(ui): update spinner text to 'Fetching Models' on model list load --- crates/forge_main/src/ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index e888adcf0b..72f58e15d7 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1080,7 +1080,7 @@ impl A + Send + Sync> UI { /// Lists all the models async fn on_show_models(&mut self, porcelain: bool) -> anyhow::Result<()> { - self.spinner.start(Some("Loading"))?; + self.spinner.start(Some("Fetching Models"))?; let mut all_provider_models = match self.api.get_all_provider_models().await { Ok(provider_models) => provider_models, From e51490a19654be4e775ce7ca4e2063e9551f7598 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 2 Mar 2026 13:08:54 +0530 Subject: [PATCH 25/37] refactor(chat): extract provider router and add background cache refresh --- crates/forge_repo/src/provider/chat.rs | 106 +++++++++++++++++-------- 1 file changed, 71 insertions(+), 35 deletions(-) diff --git a/crates/forge_repo/src/provider/chat.rs b/crates/forge_repo/src/provider/chat.rs index ce702625b0..78d7f2dd91 100644 --- a/crates/forge_repo/src/provider/chat.rs +++ b/crates/forge_repo/src/provider/chat.rs @@ -20,11 +20,7 @@ const MODEL_CACHE_TTL_SECS: u128 = 7200; /// Repository responsible for routing chat requests to the appropriate provider /// implementation based on the provider's response type. pub struct ForgeChatRepository { - openai_repo: OpenAIResponseRepository, - codex_repo: OpenAIResponsesResponseRepository, - anthropic_repo: AnthropicResponseRepository, - bedrock_repo: BedrockResponseRepository, - google_repo: GoogleResponseRepository, + router: Arc>, model_cache: Arc, } @@ -40,15 +36,11 @@ impl ForgeChatRepository { let openai_repo = OpenAIResponseRepository::new(infra.clone()).retry_config(retry_config.clone()); - let codex_repo = OpenAIResponsesResponseRepository::new(infra.clone()) .retry_config(retry_config.clone()); - let anthropic_repo = AnthropicResponseRepository::new(infra.clone()).retry_config(retry_config.clone()); - let bedrock_repo = BedrockResponseRepository::new(retry_config.clone()); - let google_repo = GoogleResponseRepository::new(infra.clone()).retry_config(retry_config.clone()); @@ -58,11 +50,13 @@ impl ForgeChatRepository { )); Self { - openai_repo, - codex_repo, - anthropic_repo, - bedrock_repo, - google_repo, + router: Arc::new(ProviderRouter { + openai_repo, + codex_repo, + anthropic_repo, + bedrock_repo, + google_repo, + }), model_cache, } } @@ -76,7 +70,68 @@ impl ChatRepository for ForgeChatReposit context: Context, provider: Provider, ) -> ResultStream { - // Route based on provider response type + self.router.chat(model_id, context, provider).await + } + + async fn models(&self, provider: Provider) -> anyhow::Result> { + use forge_app::KVStore; + + let cache_key = format!("models:{}", provider.id); + + if let Ok(Some(cached)) = self + .model_cache + .cache_get::<_, Vec>(&cache_key) + .await + { + tracing::debug!(provider_id = %provider.id, "returning cached models; refreshing in background"); + + // Spawn a fire-and-forget task to refresh the disk cache. The cloned + // Arc references keep the router and cache alive for the full duration. + let cache = self.model_cache.clone(); + let router = self.router.clone(); + let key = cache_key; + tokio::spawn(async move { + match router.models(provider).await { + Ok(models) => { + if let Err(err) = cache.cache_set(&key, &models).await { + tracing::warn!(error = %err, "background refresh: failed to cache model list"); + } + } + Err(err) => { + tracing::warn!(error = %err, "background refresh: failed to fetch models"); + } + } + }); + + return Ok(cached); + } + + let models = self.router.models(provider).await?; + + if let Err(err) = self.model_cache.cache_set(&cache_key, &models).await { + tracing::warn!(error = %err, "failed to cache model list"); + } + + Ok(models) + } +} + +/// Routes chat and model requests to the correct provider backend. +struct ProviderRouter { + openai_repo: OpenAIResponseRepository, + codex_repo: OpenAIResponsesResponseRepository, + anthropic_repo: AnthropicResponseRepository, + bedrock_repo: BedrockResponseRepository, + google_repo: GoogleResponseRepository, +} + +impl ProviderRouter { + async fn chat( + &self, + model_id: &ModelId, + context: Context, + provider: Provider, + ) -> ResultStream { match provider.response { Some(ProviderResponse::OpenAI) => { // Check if model is a Codex model @@ -110,20 +165,7 @@ impl ChatRepository for ForgeChatReposit } async fn models(&self, provider: Provider) -> anyhow::Result> { - use forge_app::KVStore; - - let cache_key = format!("models:{}", provider.id); - - if let Ok(Some(cached)) = self - .model_cache - .cache_get::<_, Vec>(&cache_key) - .await - { - tracing::debug!(provider_id = %provider.id, "returning cached models"); - return Ok(cached); - } - - let models = match provider.response { + match provider.response { Some(ProviderResponse::OpenAI) => self.openai_repo.models(provider).await, Some(ProviderResponse::Anthropic) => self.anthropic_repo.models(provider).await, Some(ProviderResponse::Bedrock) => self.bedrock_repo.models(provider).await, @@ -132,12 +174,6 @@ impl ChatRepository for ForgeChatReposit "Provider response type not configured for provider: {}", provider.id )), - }?; - - if let Err(err) = self.model_cache.cache_set(&cache_key, &models).await { - tracing::warn!(error = %err, "failed to cache model list"); } - - Ok(models) } } From def765daf78d53c6f1a452b7f3c68955fd3a2751 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 2 Mar 2026 13:20:03 +0530 Subject: [PATCH 26/37] feat(chat): implement background task management for model cache refresh --- crates/forge_repo/src/provider/chat.rs | 34 +++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/forge_repo/src/provider/chat.rs b/crates/forge_repo/src/provider/chat.rs index 78d7f2dd91..6ab3a2cbd9 100644 --- a/crates/forge_repo/src/provider/chat.rs +++ b/crates/forge_repo/src/provider/chat.rs @@ -6,8 +6,8 @@ use forge_app::domain::{ use forge_app::{EnvironmentInfra, HttpInfra}; use forge_domain::{ChatRepository, Provider, ProviderId}; use forge_infra::CacacheStorage; +use tokio::task::AbortHandle; use url::Url; - use crate::provider::anthropic::AnthropicResponseRepository; use crate::provider::bedrock::BedrockResponseRepository; use crate::provider::google::GoogleResponseRepository; @@ -22,6 +22,7 @@ const MODEL_CACHE_TTL_SECS: u128 = 7200; pub struct ForgeChatRepository { router: Arc>, model_cache: Arc, + bg_refresh: BgRefresh, } impl ForgeChatRepository { @@ -58,6 +59,7 @@ impl ForgeChatRepository { google_repo, }), model_cache, + bg_refresh: BgRefresh::default(), } } } @@ -85,12 +87,12 @@ impl ChatRepository for ForgeChatReposit { tracing::debug!(provider_id = %provider.id, "returning cached models; refreshing in background"); - // Spawn a fire-and-forget task to refresh the disk cache. The cloned - // Arc references keep the router and cache alive for the full duration. + // Spawn a background task to refresh the disk cache. The abort + // handle is stored so the task is cancelled if the service is dropped. let cache = self.model_cache.clone(); let router = self.router.clone(); let key = cache_key; - tokio::spawn(async move { + let handle = tokio::spawn(async move { match router.models(provider).await { Ok(models) => { if let Err(err) = cache.cache_set(&key, &models).await { @@ -102,6 +104,7 @@ impl ChatRepository for ForgeChatReposit } } }); + self.bg_refresh.register(handle.abort_handle()); return Ok(cached); } @@ -177,3 +180,26 @@ impl ProviderRouter { } } } + +/// Tracks abort handles for background tasks and cancels them on drop. +#[derive(Default)] +struct BgRefresh(std::sync::Mutex>); + +impl BgRefresh { + /// Registers an abort handle to be cancelled when this guard is dropped. + fn register(&self, handle: AbortHandle) { + if let Ok(mut handles) = self.0.lock() { + handles.push(handle); + } + } +} + +impl Drop for BgRefresh { + fn drop(&mut self) { + if let Ok(mut handles) = self.0.lock() { + for handle in handles.drain(..) { + handle.abort(); + } + } + } +} From a546138d1b94bc915eb8bd8f2995e8026e3e8ee0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 07:51:43 +0000 Subject: [PATCH 27/37] [autofix.ci] apply automated fixes --- crates/forge_repo/src/provider/chat.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/forge_repo/src/provider/chat.rs b/crates/forge_repo/src/provider/chat.rs index 6ab3a2cbd9..cc18942e1f 100644 --- a/crates/forge_repo/src/provider/chat.rs +++ b/crates/forge_repo/src/provider/chat.rs @@ -8,6 +8,7 @@ use forge_domain::{ChatRepository, Provider, ProviderId}; use forge_infra::CacacheStorage; use tokio::task::AbortHandle; use url::Url; + use crate::provider::anthropic::AnthropicResponseRepository; use crate::provider::bedrock::BedrockResponseRepository; use crate::provider::google::GoogleResponseRepository; From 926b52a4f343cb8447b57718a3c52645a8929ce7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:37:26 +0000 Subject: [PATCH 28/37] [autofix.ci] apply automated fixes --- crates/forge_api/src/api.rs | 11 ++++---- crates/forge_app/src/git_app.rs | 29 +++++++++++++------- crates/forge_app/src/services.rs | 16 +++++------ crates/forge_domain/src/commit_config.rs | 7 ++--- crates/forge_domain/src/lib.rs | 4 +-- crates/forge_main/src/cli.rs | 5 +++- crates/forge_main/src/ui.rs | 34 +++++++++++++++++------- 7 files changed, 66 insertions(+), 40 deletions(-) diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index 5095760edb..9ca4cc05ba 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -157,14 +157,13 @@ pub trait API: Sync + Send { /// Sets the operating model async fn set_default_model(&self, model_id: ModelId) -> anyhow::Result<()>; - /// Gets the commit configuration (provider and model for commit message generation). + /// Gets the commit configuration (provider and model for commit message + /// generation). async fn get_commit_config(&self) -> anyhow::Result>; - /// Sets the commit configuration (provider and model for commit message generation). - async fn set_commit_config( - &self, - config: forge_domain::CommitConfig, - ) -> anyhow::Result<()>; + /// Sets the commit configuration (provider and model for commit message + /// generation). + async fn set_commit_config(&self, config: forge_domain::CommitConfig) -> anyhow::Result<()>; /// Refresh MCP caches by fetching fresh data async fn reload_mcp(&self) -> Result<()>; diff --git a/crates/forge_app/src/git_app.rs b/crates/forge_app/src/git_app.rs index 99279d39b7..fe885673e8 100644 --- a/crates/forge_app/src/git_app.rs +++ b/crates/forge_app/src/git_app.rs @@ -297,7 +297,10 @@ where resolver.get_provider(agent_id.clone()), resolver.get_model(agent_id) )?; - let provider = self.services.refresh_provider_credential(provider_template).await?; + let provider = self + .services + .refresh_provider_credential(provider_template) + .await?; Ok((provider, model)) } @@ -312,20 +315,28 @@ where // Resolve provider and model: commit config takes priority over agent defaults. // If the configured provider is unavailable (e.g. logged out), fall back to the // agent's provider/model with a warning. - let (provider, model) = - match commit_config.and_then(|c| c.provider.zip(c.model)) { - Some((provider_id, commit_model)) => match self.services.get_provider(provider_id).await { - Ok(provider) => (self.services.refresh_provider_credential(provider).await?, commit_model), + let (provider, model) = match commit_config.and_then(|c| c.provider.zip(c.model)) { + Some((provider_id, commit_model)) => { + match self.services.get_provider(provider_id).await { + Ok(provider) => ( + self.services.refresh_provider_credential(provider).await?, + commit_model, + ), Err(err) => { tracing::warn!( error = %err, "Configured commit provider unavailable. Falling back to the active provider." ); - self.resolve_agent_provider_and_model(&agent_provider_resolver, agent_id).await? + self.resolve_agent_provider_and_model(&agent_provider_resolver, agent_id) + .await? } - }, - None => self.resolve_agent_provider_and_model(&agent_provider_resolver, agent_id).await?, - }; + } + } + None => { + self.resolve_agent_provider_and_model(&agent_provider_resolver, agent_id) + .await? + } + }; let rendered_prompt = self .services diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index a8f838c96d..efe7c1667d 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -208,14 +208,13 @@ pub trait AppConfigService: Send + Sync { /// Returns an error if no default provider is configured. async fn set_default_model(&self, model: ModelId) -> anyhow::Result<()>; - /// Gets the commit configuration (provider and model for commit message generation). + /// Gets the commit configuration (provider and model for commit message + /// generation). async fn get_commit_config(&self) -> anyhow::Result>; - /// Sets the commit configuration (provider and model for commit message generation). - async fn set_commit_config( - &self, - config: forge_domain::CommitConfig, - ) -> anyhow::Result<()>; + /// Sets the commit configuration (provider and model for commit message + /// generation). + async fn set_commit_config(&self, config: forge_domain::CommitConfig) -> anyhow::Result<()>; } #[async_trait::async_trait] @@ -1039,10 +1038,7 @@ impl AppConfigService for I { self.config_service().get_commit_config().await } - async fn set_commit_config( - &self, - config: forge_domain::CommitConfig, - ) -> anyhow::Result<()> { + async fn set_commit_config(&self, config: forge_domain::CommitConfig) -> anyhow::Result<()> { self.config_service().set_commit_config(config).await } } diff --git a/crates/forge_domain/src/commit_config.rs b/crates/forge_domain/src/commit_config.rs index c6003cb07c..f075a0ed2d 100644 --- a/crates/forge_domain/src/commit_config.rs +++ b/crates/forge_domain/src/commit_config.rs @@ -7,9 +7,10 @@ use crate::{ModelId, ProviderId}; /// Configuration for commit message generation. /// -/// Allows specifying a dedicated provider and model for commit message generation, -/// instead of using the active agent's provider and model. This is useful when -/// you want to use a cheaper or faster model for simple commit message generation. +/// Allows specifying a dedicated provider and model for commit message +/// generation, instead of using the active agent's provider and model. This is +/// useful when you want to use a cheaper or faster model for simple commit +/// message generation. #[derive(Default, Debug, Clone, Serialize, Deserialize, Merge, Setters, JsonSchema, PartialEq)] #[setters(strip_option, into)] pub struct CommitConfig { diff --git a/crates/forge_domain/src/lib.rs b/crates/forge_domain/src/lib.rs index 0ea328e366..24d903b512 100644 --- a/crates/forge_domain/src/lib.rs +++ b/crates/forge_domain/src/lib.rs @@ -5,8 +5,8 @@ mod attachment; mod auth; mod chat_request; mod chat_response; -mod compact; mod commit_config; +mod compact; mod console; mod context; mod conversation; @@ -62,8 +62,8 @@ pub use agent_definition::*; pub use attachment::*; pub use chat_request::*; pub use chat_response::*; -pub use compact::*; pub use commit_config::*; +pub use compact::*; pub use console::*; pub use context::*; pub use conversation::*; diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index 0a1f71cf0d..ac4fa78931 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -906,7 +906,10 @@ mod tests { }, _ => None, }; - let expected = Some(("Anthropic".to_string(), "claude-haiku-4-20250514".to_string())); + let expected = Some(( + "Anthropic".to_string(), + "claude-haiku-4-20250514".to_string(), + )); assert_eq!(actual, expected); } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 9f92fc6e22..7fa5682f0a 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3146,10 +3146,9 @@ impl A + Send + Sync> UI { .provider(provider.clone()) .model(validated_model.clone()); self.api.set_commit_config(commit_config).await?; - self.writeln_title( - TitleFormat::action(validated_model.as_str()) - .sub_title(&format!("is now the commit model for provider '{provider}'")), - )?; + self.writeln_title(TitleFormat::action(validated_model.as_str()).sub_title( + format!("is now the commit model for provider '{provider}'"), + ))?; } } @@ -3188,8 +3187,14 @@ impl A + Send + Sync> UI { let commit_config = self.api.get_commit_config().await?; match commit_config { Some(config) => { - let provider = config.provider.map(|p| p.to_string()).unwrap_or_else(|| "Not set".to_string()); - let model = config.model.map(|m| m.as_str().to_string()).unwrap_or_else(|| "Not set".to_string()); + let provider = config + .provider + .map(|p| p.to_string()) + .unwrap_or_else(|| "Not set".to_string()); + let model = config + .model + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "Not set".to_string()); self.writeln(provider)?; self.writeln(model)?; } @@ -3278,7 +3283,9 @@ impl A + Send + Sync> UI { .await? .into_iter() .find(|pm| &pm.provider_id == provider_id) - .with_context(|| format!("Provider '{provider_id}' not found or returned no models"))? + .with_context(|| { + format!("Provider '{provider_id}' not found or returned no models") + })? .models } }; @@ -3288,8 +3295,17 @@ impl A + Send + Sync> UI { .find(|m| m.id == model_id) .map(|_| model_id) .with_context(|| { - let hints = models.iter().take(10).map(|m| m.id.as_str()).collect::>().join(", "); - let suggestion = if models.len() > 10 { format!("{hints} (and {} more)", models.len() - 10) } else { hints }; + let hints = models + .iter() + .take(10) + .map(|m| m.id.as_str()) + .collect::>() + .join(", "); + let suggestion = if models.len() > 10 { + format!("{hints} (and {} more)", models.len() - 10) + } else { + hints + }; format!("Model '{model_str}' not found. Available models: {suggestion}") }) } From fd8fdc99243476228cf8132ec1066ad0f9a27c24 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:39:02 +0000 Subject: [PATCH 29/37] [autofix.ci] apply automated fixes (attempt 2/3) --- crates/forge_main/src/ui.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 7fa5682f0a..03b3bf76d8 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3146,9 +3146,10 @@ impl A + Send + Sync> UI { .provider(provider.clone()) .model(validated_model.clone()); self.api.set_commit_config(commit_config).await?; - self.writeln_title(TitleFormat::action(validated_model.as_str()).sub_title( - format!("is now the commit model for provider '{provider}'"), - ))?; + self.writeln_title( + TitleFormat::action(validated_model.as_str()) + .sub_title(format!("is now the commit model for provider '{provider}'")), + )?; } } From b91651d2457a7537a693c96bad626de361d51991 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 19:22:43 +0530 Subject: [PATCH 30/37] fix(git-app): fall back to active provider when credential refresh fails --- crates/forge_app/src/git_app.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/forge_app/src/git_app.rs b/crates/forge_app/src/git_app.rs index fe885673e8..df6c3c11ab 100644 --- a/crates/forge_app/src/git_app.rs +++ b/crates/forge_app/src/git_app.rs @@ -318,10 +318,22 @@ where let (provider, model) = match commit_config.and_then(|c| c.provider.zip(c.model)) { Some((provider_id, commit_model)) => { match self.services.get_provider(provider_id).await { - Ok(provider) => ( - self.services.refresh_provider_credential(provider).await?, - commit_model, - ), + Ok(provider) => { + (match self.services.refresh_provider_credential(provider).await { + Ok(provider) => (provider, commit_model), + Err(err) => { + tracing::warn!( + error = %err, + "Failed to refresh credentials for configured commit provider. Falling back to the active provider." + ); + self.resolve_agent_provider_and_model( + &agent_provider_resolver, + agent_id, + ) + .await? + } + }) + } Err(err) => { tracing::warn!( error = %err, From cbd5e6aff446023566b6ca797ff9892f62263565 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 19:33:30 +0530 Subject: [PATCH 31/37] feat(config): add suggest-model command for command suggestion --- crates/forge_api/src/api.rs | 8 +++++ crates/forge_api/src/forge_api.rs | 8 +++++ crates/forge_app/src/command_generator.rs | 27 +++++++++++--- crates/forge_app/src/services.rs | 16 +++++++++ crates/forge_domain/src/app_config.rs | 4 ++- crates/forge_domain/src/lib.rs | 2 ++ crates/forge_domain/src/suggest_config.rs | 21 +++++++++++ crates/forge_main/src/built_in_commands.json | 4 +++ crates/forge_main/src/cli.rs | 9 +++++ crates/forge_main/src/ui.rs | 37 +++++++++++++++++++- crates/forge_services/src/app_config.rs | 15 ++++++++ shell-plugin/lib/actions/config.zsh | 25 +++++++++++++ shell-plugin/lib/dispatcher.zsh | 3 ++ 13 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 crates/forge_domain/src/suggest_config.rs diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index 9ca4cc05ba..152e3e2d08 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -165,6 +165,14 @@ pub trait API: Sync + Send { /// generation). async fn set_commit_config(&self, config: forge_domain::CommitConfig) -> anyhow::Result<()>; + /// Gets the suggest configuration (provider and model for command suggestion + /// generation). + async fn get_suggest_config(&self) -> anyhow::Result>; + + /// Sets the suggest configuration (provider and model for command suggestion + /// generation). + async fn set_suggest_config(&self, config: forge_domain::SuggestConfig) -> anyhow::Result<()>; + /// Refresh MCP caches by fetching fresh data async fn reload_mcp(&self) -> Result<()>; diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index cf8f5f06f7..1aa57c769a 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -301,6 +301,14 @@ impl< self.services.set_commit_config(config).await } + async fn get_suggest_config(&self) -> anyhow::Result> { + self.services.get_suggest_config().await + } + + async fn set_suggest_config(&self, config: SuggestConfig) -> anyhow::Result<()> { + self.services.set_suggest_config(config).await + } + async fn get_login_info(&self) -> Result> { self.services.auth_service().get_auth_token().await } diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 3079510148..c5a04e3e95 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -44,10 +44,21 @@ where &serde_json::json!({"env": env, "files": files}), )?; - // Get required services and data - let provider_id = self.services.get_default_provider().await?; - let provider = self.services.get_provider(provider_id).await?; - let model = self.services.get_provider_model(Some(&provider.id)).await?; + // Get required services and data - use suggest config if available, + // otherwise fall back to default provider/model + let suggest_config = self.services.get_suggest_config().await?; + let (provider, model) = match suggest_config { + Some(config) => { + let provider = self.services.get_provider(config.provider).await?; + (provider, config.model) + } + None => { + let provider_id = self.services.get_default_provider().await?; + let provider = self.services.get_provider(provider_id).await?; + let model = self.services.get_provider_model(Some(&provider.id)).await?; + (provider, model) + } + }; // Build user prompt with task and recent commands let user_content = format!("{}", prompt.as_str()); @@ -249,6 +260,14 @@ mod tests { async fn set_commit_config(&self, _config: forge_domain::CommitConfig) -> Result<()> { Ok(()) } + + async fn get_suggest_config(&self) -> Result> { + Ok(None) + } + + async fn set_suggest_config(&self, _config: forge_domain::SuggestConfig) -> Result<()> { + Ok(()) + } } #[tokio::test] diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 13276ef9ff..a536c5f6eb 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -222,6 +222,14 @@ pub trait AppConfigService: Send + Sync { /// Sets the commit configuration (provider and model for commit message /// generation). async fn set_commit_config(&self, config: forge_domain::CommitConfig) -> anyhow::Result<()>; + + /// Gets the suggest configuration (provider and model for command suggestion + /// generation). + async fn get_suggest_config(&self) -> anyhow::Result>; + + /// Sets the suggest configuration (provider and model for command suggestion + /// generation). + async fn set_suggest_config(&self, config: forge_domain::SuggestConfig) -> anyhow::Result<()>; } #[async_trait::async_trait] @@ -1048,6 +1056,14 @@ impl AppConfigService for I { async fn set_commit_config(&self, config: forge_domain::CommitConfig) -> anyhow::Result<()> { self.config_service().set_commit_config(config).await } + + async fn get_suggest_config(&self) -> anyhow::Result> { + self.config_service().get_suggest_config().await + } + + async fn set_suggest_config(&self, config: forge_domain::SuggestConfig) -> anyhow::Result<()> { + self.config_service().set_suggest_config(config).await + } } #[async_trait::async_trait] diff --git a/crates/forge_domain/src/app_config.rs b/crates/forge_domain/src/app_config.rs index 87f35136cf..9f1fc3894c 100644 --- a/crates/forge_domain/src/app_config.rs +++ b/crates/forge_domain/src/app_config.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use derive_more::From; use serde::{Deserialize, Serialize}; -use crate::{CommitConfig, ModelId, ProviderId}; +use crate::{CommitConfig, ModelId, ProviderId, SuggestConfig}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -23,6 +23,8 @@ pub struct AppConfig { pub model: HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suggest: Option, } #[derive(Clone, Serialize, Deserialize, From, Debug, PartialEq)] diff --git a/crates/forge_domain/src/lib.rs b/crates/forge_domain/src/lib.rs index 24d903b512..70c543ed8b 100644 --- a/crates/forge_domain/src/lib.rs +++ b/crates/forge_domain/src/lib.rs @@ -41,6 +41,7 @@ mod session_metrics; mod shell; mod skill; mod snapshot; +mod suggest_config; mod suggestion; mod system_context; mod temperature; @@ -98,6 +99,7 @@ pub use session_metrics::*; pub use shell::*; pub use skill::*; pub use snapshot::*; +pub use suggest_config::*; pub use suggestion::*; pub use system_context::*; pub use temperature::*; diff --git a/crates/forge_domain/src/suggest_config.rs b/crates/forge_domain/src/suggest_config.rs new file mode 100644 index 0000000000..553f822b0b --- /dev/null +++ b/crates/forge_domain/src/suggest_config.rs @@ -0,0 +1,21 @@ +use derive_setters::Setters; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{ModelId, ProviderId}; + +/// Configuration for shell command suggestion generation. +/// +/// Allows specifying a dedicated provider and model for shell command suggestion +/// generation, instead of using the active agent's provider and model. This is +/// useful when you want to use a cheaper or faster model for simple command +/// suggestions. Both provider and model must be specified together. +#[derive(Debug, Clone, Serialize, Deserialize, Setters, JsonSchema, PartialEq)] +#[setters(into)] +pub struct SuggestConfig { + /// Provider ID to use for command suggestion generation. + pub provider: ProviderId, + + /// Model ID to use for command suggestion generation. + pub model: ModelId, +} diff --git a/crates/forge_main/src/built_in_commands.json b/crates/forge_main/src/built_in_commands.json index 55dffbfc57..c24237668c 100644 --- a/crates/forge_main/src/built_in_commands.json +++ b/crates/forge_main/src/built_in_commands.json @@ -19,6 +19,10 @@ "command": "config-commit-model", "description": "Set the model used for commit message generation [alias: ccm]" }, + { + "command": "suggest-model", + "description": "Set the model used for command suggestion generation [alias: sm]" + }, { "command": "new", "description": "Start new conversation [alias: n]" diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index 1c169f3597..5f095e86d3 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -553,6 +553,13 @@ pub enum ConfigSetField { /// Model ID to use for commit message generation. model: ModelId, }, + /// Set the provider and model for command suggestion generation. + Suggest { + /// Provider ID to use for command suggestion generation. + provider: ProviderId, + /// Model ID to use for command suggestion generation. + model: ModelId, + }, } /// Type-safe subcommands for `forge config get`. @@ -564,6 +571,8 @@ pub enum ConfigGetField { Provider, /// Get the commit message generation config. Commit, + /// Get the command suggestion generation config. + Suggest, } /// Command group for conversation management. diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 659a419e44..132ce32269 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1337,12 +1337,24 @@ impl A + Send + Sync> UI { .map(|m| m.as_str().to_string()) .unwrap_or_else(|| markers::EMPTY.to_string()); + let suggest_config = self.api.get_suggest_config().await.ok().flatten(); + let suggest_provider = suggest_config + .as_ref() + .map(|c| c.provider.to_string()) + .unwrap_or_else(|| markers::EMPTY.to_string()); + let suggest_model = suggest_config + .as_ref() + .map(|c| c.model.as_str().to_string()) + .unwrap_or_else(|| markers::EMPTY.to_string()); + let info = Info::new() .add_title("CONFIGURATION") .add_key_value("Default Model", model) .add_key_value("Default Provider", provider) .add_key_value("Commit Provider", commit_provider) - .add_key_value("Commit Model", commit_model); + .add_key_value("Commit Model", commit_model) + .add_key_value("Suggest Provider", suggest_provider) + .add_key_value("Suggest Model", suggest_model); if porcelain { self.writeln( @@ -3169,6 +3181,19 @@ impl A + Send + Sync> UI { .sub_title(format!("is now the commit model for provider '{provider}'")), )?; } + ConfigSetField::Suggest { provider, model } => { + // Validate provider exists and model belongs to that specific provider + let validated_model = self.validate_model(model.as_str(), Some(&provider)).await?; + let suggest_config = forge_domain::SuggestConfig { + provider: provider.clone(), + model: validated_model.clone(), + }; + self.api.set_suggest_config(suggest_config).await?; + self.writeln_title( + TitleFormat::action(validated_model.as_str()) + .sub_title(format!("is now the suggest model for provider '{provider}'")), + )?; + } } Ok(()) @@ -3220,6 +3245,16 @@ impl A + Send + Sync> UI { None => self.writeln("Commit: Not set")?, } } + ConfigGetField::Suggest => { + let suggest_config = self.api.get_suggest_config().await?; + match suggest_config { + Some(config) => { + self.writeln(config.provider.to_string())?; + self.writeln(config.model.as_str().to_string())?; + } + None => self.writeln("Suggest: Not set")?, + } + } } Ok(()) diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index f68ae0ce03..42c2f017e7 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -95,6 +95,21 @@ impl AppConfigService }) .await } + + async fn get_suggest_config(&self) -> anyhow::Result> { + let config = self.infra.get_app_config().await?; + Ok(config.suggest) + } + + async fn set_suggest_config( + &self, + suggest_config: forge_domain::SuggestConfig, + ) -> anyhow::Result<()> { + self.update(|config| { + config.suggest = Some(suggest_config); + }) + .await + } } #[cfg(test)] diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index bce098a0d2..2b3d90a1c7 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -191,6 +191,31 @@ function _forge_action_commit_model() { ) } +# Action handler: Select model for command suggestion generation +# Calls `forge config set suggest ` on selection. +function _forge_action_suggest_model() { + local input_text="$1" + ( + echo + local current_suggest_model + current_suggest_model=$(_forge_exec config get suggest 2>/dev/null | tail -n 1) + + local selected + selected=$(_forge_pick_model "Suggest Model ❯ " "$current_suggest_model" "$input_text") + + if [[ -n "$selected" ]]; then + # Field 1 = model_id (raw), field 4 = provider_id (raw) + local model_id provider_id + read -r model_id provider_id <<<$(echo "$selected" | awk -F ' +' '{print $1, $4}') + + model_id=${model_id//[[:space:]]/} + provider_id=${provider_id//[[:space:]]/} + + _forge_exec config set suggest "$provider_id" "$model_id" + fi + ) +} + # Action handler: Sync workspace for codebase search function _forge_action_sync() { echo diff --git a/shell-plugin/lib/dispatcher.zsh b/shell-plugin/lib/dispatcher.zsh index 46061c111b..0518cb161a 100644 --- a/shell-plugin/lib/dispatcher.zsh +++ b/shell-plugin/lib/dispatcher.zsh @@ -169,6 +169,9 @@ function forge-accept-line() { config-commit-model|ccm) _forge_action_commit_model "$input_text" ;; + suggest-model|sm) + _forge_action_suggest_model "$input_text" + ;; tools|t) _forge_action_tools ;; From 4e0883e8f99b73b6aa41f54a8bf136de20b3f959 Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 19:36:03 +0530 Subject: [PATCH 32/37] fix(git-app): correct match statement for provider credential refresh --- crates/forge_app/src/git_app.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge_app/src/git_app.rs b/crates/forge_app/src/git_app.rs index df6c3c11ab..c798aa463f 100644 --- a/crates/forge_app/src/git_app.rs +++ b/crates/forge_app/src/git_app.rs @@ -319,7 +319,7 @@ where Some((provider_id, commit_model)) => { match self.services.get_provider(provider_id).await { Ok(provider) => { - (match self.services.refresh_provider_credential(provider).await { + match self.services.refresh_provider_credential(provider).await { Ok(provider) => (provider, commit_model), Err(err) => { tracing::warn!( @@ -332,7 +332,7 @@ where ) .await? } - }) + } } Err(err) => { tracing::warn!( From b4b9643b1bb07ee1d6be02ce21a6e14690303dbf Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:07:33 +0000 Subject: [PATCH 33/37] [autofix.ci] apply automated fixes --- crates/forge_app/src/git_app.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge_app/src/git_app.rs b/crates/forge_app/src/git_app.rs index df6c3c11ab..cbaa495a86 100644 --- a/crates/forge_app/src/git_app.rs +++ b/crates/forge_app/src/git_app.rs @@ -319,7 +319,7 @@ where Some((provider_id, commit_model)) => { match self.services.get_provider(provider_id).await { Ok(provider) => { - (match self.services.refresh_provider_credential(provider).await { + match self.services.refresh_provider_credential(provider).await { Ok(provider) => (provider, commit_model), Err(err) => { tracing::warn!( @@ -332,7 +332,7 @@ where ) .await? } - }) + } } Err(err) => { tracing::warn!( From 7a1d5de23d0f263bcaa9979ace01968c3dee329a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:09:23 +0000 Subject: [PATCH 34/37] [autofix.ci] apply automated fixes (attempt 2/3) --- crates/forge_app/src/git_app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge_app/src/git_app.rs b/crates/forge_app/src/git_app.rs index cbaa495a86..c798aa463f 100644 --- a/crates/forge_app/src/git_app.rs +++ b/crates/forge_app/src/git_app.rs @@ -332,7 +332,7 @@ where ) .await? } - } + } } Err(err) => { tracing::warn!( From a305b5691c73e0310e4e8e968d4c7a1cddab7c8c Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Mon, 9 Mar 2026 19:41:49 +0530 Subject: [PATCH 35/37] refactor(command_generator): streamline suggest config retrieval in command generation --- crates/forge_app/src/command_generator.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index c5a04e3e95..01ad5b33d5 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -46,8 +46,7 @@ where // Get required services and data - use suggest config if available, // otherwise fall back to default provider/model - let suggest_config = self.services.get_suggest_config().await?; - let (provider, model) = match suggest_config { + let (provider, model) = match self.services.get_suggest_config().await? { Some(config) => { let provider = self.services.get_provider(config.provider).await?; (provider, config.model) From 5dc7cbccf9d73c167d4e552909d1242fa743312c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:12:57 +0000 Subject: [PATCH 36/37] [autofix.ci] apply automated fixes --- crates/forge_api/src/api.rs | 8 ++++---- crates/forge_app/src/services.rs | 8 ++++---- crates/forge_domain/src/suggest_config.rs | 9 +++++---- crates/forge_main/src/ui.rs | 7 +++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index 152e3e2d08..c254e6c5ef 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -165,12 +165,12 @@ pub trait API: Sync + Send { /// generation). async fn set_commit_config(&self, config: forge_domain::CommitConfig) -> anyhow::Result<()>; - /// Gets the suggest configuration (provider and model for command suggestion - /// generation). + /// Gets the suggest configuration (provider and model for command + /// suggestion generation). async fn get_suggest_config(&self) -> anyhow::Result>; - /// Sets the suggest configuration (provider and model for command suggestion - /// generation). + /// Sets the suggest configuration (provider and model for command + /// suggestion generation). async fn set_suggest_config(&self, config: forge_domain::SuggestConfig) -> anyhow::Result<()>; /// Refresh MCP caches by fetching fresh data diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index a536c5f6eb..7b5a6dd3c3 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -223,12 +223,12 @@ pub trait AppConfigService: Send + Sync { /// generation). async fn set_commit_config(&self, config: forge_domain::CommitConfig) -> anyhow::Result<()>; - /// Gets the suggest configuration (provider and model for command suggestion - /// generation). + /// Gets the suggest configuration (provider and model for command + /// suggestion generation). async fn get_suggest_config(&self) -> anyhow::Result>; - /// Sets the suggest configuration (provider and model for command suggestion - /// generation). + /// Sets the suggest configuration (provider and model for command + /// suggestion generation). async fn set_suggest_config(&self, config: forge_domain::SuggestConfig) -> anyhow::Result<()>; } diff --git a/crates/forge_domain/src/suggest_config.rs b/crates/forge_domain/src/suggest_config.rs index 553f822b0b..0ac93ee54c 100644 --- a/crates/forge_domain/src/suggest_config.rs +++ b/crates/forge_domain/src/suggest_config.rs @@ -6,10 +6,11 @@ use crate::{ModelId, ProviderId}; /// Configuration for shell command suggestion generation. /// -/// Allows specifying a dedicated provider and model for shell command suggestion -/// generation, instead of using the active agent's provider and model. This is -/// useful when you want to use a cheaper or faster model for simple command -/// suggestions. Both provider and model must be specified together. +/// Allows specifying a dedicated provider and model for shell command +/// suggestion generation, instead of using the active agent's provider and +/// model. This is useful when you want to use a cheaper or faster model for +/// simple command suggestions. Both provider and model must be specified +/// together. #[derive(Debug, Clone, Serialize, Deserialize, Setters, JsonSchema, PartialEq)] #[setters(into)] pub struct SuggestConfig { diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 132ce32269..e2a42a814c 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3189,10 +3189,9 @@ impl A + Send + Sync> UI { model: validated_model.clone(), }; self.api.set_suggest_config(suggest_config).await?; - self.writeln_title( - TitleFormat::action(validated_model.as_str()) - .sub_title(format!("is now the suggest model for provider '{provider}'")), - )?; + self.writeln_title(TitleFormat::action(validated_model.as_str()).sub_title( + format!("is now the suggest model for provider '{provider}'"), + ))?; } } From 5789a73d1ac43e087fb5099fde1226c19725017b Mon Sep 17 00:00:00 2001 From: laststylebender14 Date: Tue, 10 Mar 2026 18:10:01 +0530 Subject: [PATCH 37/37] fix(ui): update provider handling to use as_ref for better string conversion --- crates/forge_main/src/ui.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index e2a42a814c..03926c9055 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -3232,7 +3232,7 @@ impl A + Send + Sync> UI { Some(config) => { let provider = config .provider - .map(|p| p.to_string()) + .map(|p| p.as_ref().to_string()) .unwrap_or_else(|| "Not set".to_string()); let model = config .model @@ -3248,7 +3248,7 @@ impl A + Send + Sync> UI { let suggest_config = self.api.get_suggest_config().await?; match suggest_config { Some(config) => { - self.writeln(config.provider.to_string())?; + self.writeln(config.provider.as_ref())?; self.writeln(config.model.as_str().to_string())?; } None => self.writeln("Suggest: Not set")?,