diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index f831329e2cde40dbb9d4b9e882d6bc942f383422..3533c28caa93f82c96ecacdafdfdd3dc1b1643f1 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -260,11 +260,15 @@ impl AgentConfiguration { h_flex() .w_full() .gap_1p5() - .child( + .child(if let Some(icon_path) = provider.icon_path() { + Icon::from_external_svg(icon_path) + .size(IconSize::Small) + .color(Color::Muted) + } else { Icon::new(provider.icon()) .size(IconSize::Small) - .color(Color::Muted), - ) + .color(Color::Muted) + }) .child( h_flex() .w_full() diff --git a/crates/agent_ui/src/agent_model_selector.rs b/crates/agent_ui/src/agent_model_selector.rs index 43982cdda7bd887b8fd9970e836090a0e549ae11..924f37db0440dd1d4ddbdb90bdf73dfe56f0cbad 100644 --- a/crates/agent_ui/src/agent_model_selector.rs +++ b/crates/agent_ui/src/agent_model_selector.rs @@ -73,7 +73,8 @@ impl Render for AgentModelSelector { .map(|model| model.model.name().0) .unwrap_or_else(|| SharedString::from("Select a Model")); - let provider_icon = model.as_ref().map(|model| model.provider.icon()); + let provider_icon_path = model.as_ref().and_then(|model| model.provider.icon_path()); + let provider_icon_name = model.as_ref().map(|model| model.provider.icon()); let color = if self.menu_handle.is_deployed() { Color::Accent } else { @@ -85,8 +86,17 @@ impl Render for AgentModelSelector { PickerPopoverMenu::new( self.selector.clone(), ButtonLike::new("active-model") - .when_some(provider_icon, |this, icon| { - this.child(Icon::new(icon).color(color).size(IconSize::XSmall)) + .when_some(provider_icon_path.clone(), |this, icon_path| { + this.child( + Icon::from_external_svg(icon_path) + .color(color) + .size(IconSize::XSmall), + ) + }) + .when(provider_icon_path.is_none(), |this| { + this.when_some(provider_icon_name, |this, icon| { + this.child(Icon::new(icon).color(color).size(IconSize::XSmall)) + }) }) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .child( diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index 6d8c20963876c52efcf6c92885fd82507fab38e2..9fd717a597e14918c3a3adc909ff53d2bb8de740 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -5,8 +5,8 @@ use futures::{StreamExt, channel::mpsc}; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Task}; use language_model::{ - AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId, - LanguageModelRegistry, + AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProvider, + LanguageModelProviderId, LanguageModelRegistry, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; @@ -56,7 +56,7 @@ fn all_models(cx: &App) -> GroupedModels { .into_iter() .map(|model| ModelInfo { model, - icon: provider.icon(), + icon: ProviderIcon::from_provider(provider.as_ref()), }) }) .collect(); @@ -69,7 +69,7 @@ fn all_models(cx: &App) -> GroupedModels { .into_iter() .map(|model| ModelInfo { model, - icon: provider.icon(), + icon: ProviderIcon::from_provider(provider.as_ref()), }) }) .collect(); @@ -77,10 +77,26 @@ fn all_models(cx: &App) -> GroupedModels { GroupedModels::new(all, recommended) } +#[derive(Clone)] +enum ProviderIcon { + Name(IconName), + Path(SharedString), +} + +impl ProviderIcon { + fn from_provider(provider: &dyn LanguageModelProvider) -> Self { + if let Some(path) = provider.icon_path() { + Self::Path(path) + } else { + Self::Name(provider.icon()) + } + } +} + #[derive(Clone)] struct ModelInfo { model: Arc, - icon: IconName, + icon: ProviderIcon, } pub struct LanguageModelPickerDelegate { @@ -519,11 +535,16 @@ impl PickerDelegate for LanguageModelPickerDelegate { h_flex() .w_full() .gap_1p5() - .child( - Icon::new(model_info.icon) + .child(match &model_info.icon { + ProviderIcon::Name(icon_name) => Icon::new(*icon_name) .color(model_icon_color) .size(IconSize::Small), - ) + ProviderIcon::Path(icon_path) => { + Icon::from_external_svg(icon_path.clone()) + .color(model_icon_color) + .size(IconSize::Small) + } + }) .child(Label::new(model_info.model.name().0).truncate()), ) .end_slot(div().pr_3().when(is_selected, |this| { @@ -672,7 +693,7 @@ mod tests { .into_iter() .map(|(provider, name)| ModelInfo { model: Arc::new(TestLanguageModel::new(name, provider)), - icon: IconName::Ai, + icon: ProviderIcon::Name(IconName::Ai), }) .collect() } diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 161fad95e68c015f720df825b1f0ca32f5d79124..30538898b28a1d41d6c63b3e910f51c816e299ab 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -2097,7 +2097,8 @@ impl TextThreadEditor { .default_model() .map(|default| default.provider); - let provider_icon = match active_provider { + let provider_icon_path = active_provider.as_ref().and_then(|p| p.icon_path()); + let provider_icon_name = match &active_provider { Some(provider) => provider.icon(), None => IconName::Ai, }; @@ -2109,6 +2110,16 @@ impl TextThreadEditor { (Color::Muted, IconName::ChevronDown) }; + let provider_icon_element = if let Some(icon_path) = provider_icon_path { + Icon::from_external_svg(icon_path) + .color(color) + .size(IconSize::XSmall) + } else { + Icon::new(provider_icon_name) + .color(color) + .size(IconSize::XSmall) + }; + PickerPopoverMenu::new( self.language_model_selector.clone(), ButtonLike::new("active-model") @@ -2116,7 +2127,7 @@ impl TextThreadEditor { .child( h_flex() .gap_0p5() - .child(Icon::new(provider_icon).color(color).size(IconSize::XSmall)) + .child(provider_icon_element) .child( Label::new(model_name) .color(color) diff --git a/crates/ai_onboarding/src/agent_api_keys_onboarding.rs b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs index fadc4222ae44f3dbad862fd9479b89321dbd3016..bdf1ce3640bf5041b63d952625429156814dadfb 100644 --- a/crates/ai_onboarding/src/agent_api_keys_onboarding.rs +++ b/crates/ai_onboarding/src/agent_api_keys_onboarding.rs @@ -1,9 +1,25 @@ use gpui::{Action, IntoElement, ParentElement, RenderOnce, point}; -use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; +use language_model::{LanguageModelProvider, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; use ui::{Divider, List, ListBulletItem, prelude::*}; +#[derive(Clone)] +enum ProviderIcon { + Name(IconName), + Path(SharedString), +} + +impl ProviderIcon { + fn from_provider(provider: &dyn LanguageModelProvider) -> Self { + if let Some(path) = provider.icon_path() { + Self::Path(path) + } else { + Self::Name(provider.icon()) + } + } +} + pub struct ApiKeysWithProviders { - configured_providers: Vec<(IconName, SharedString)>, + configured_providers: Vec<(ProviderIcon, SharedString)>, } impl ApiKeysWithProviders { @@ -26,14 +42,19 @@ impl ApiKeysWithProviders { } } - fn compute_configured_providers(cx: &App) -> Vec<(IconName, SharedString)> { + fn compute_configured_providers(cx: &App) -> Vec<(ProviderIcon, SharedString)> { LanguageModelRegistry::read_global(cx) .providers() .iter() .filter(|provider| { provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID }) - .map(|provider| (provider.icon(), provider.name().0)) + .map(|provider| { + ( + ProviderIcon::from_provider(provider.as_ref()), + provider.name().0, + ) + }) .collect() } } @@ -47,7 +68,14 @@ impl Render for ApiKeysWithProviders { .map(|(icon, name)| { h_flex() .gap_1p5() - .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)) + .child(match icon { + ProviderIcon::Name(icon_name) => Icon::new(icon_name) + .size(IconSize::XSmall) + .color(Color::Muted), + ProviderIcon::Path(icon_path) => Icon::from_external_svg(icon_path) + .size(IconSize::XSmall) + .color(Color::Muted), + }) .child(Label::new(name)) }); div() diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs index 3c8ffc1663e0660829698b5449a006de5b3c6009..ae92268ff4db459e748b806e47f6f89851783bd9 100644 --- a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs +++ b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs @@ -11,7 +11,7 @@ use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding}; pub struct AgentPanelOnboarding { user_store: Entity, client: Arc, - configured_providers: Vec<(IconName, SharedString)>, + has_configured_providers: bool, continue_with_zed_ai: Arc, } @@ -28,7 +28,7 @@ impl AgentPanelOnboarding { language_model::Event::ProviderStateChanged(_) | language_model::Event::AddedProvider(_) | language_model::Event::RemovedProvider(_) => { - this.configured_providers = Self::compute_available_providers(cx) + this.has_configured_providers = Self::has_configured_providers(cx) } _ => {} }, @@ -38,20 +38,16 @@ impl AgentPanelOnboarding { Self { user_store, client, - configured_providers: Self::compute_available_providers(cx), + has_configured_providers: Self::has_configured_providers(cx), continue_with_zed_ai: Arc::new(continue_with_zed_ai), } } - fn compute_available_providers(cx: &App) -> Vec<(IconName, SharedString)> { + fn has_configured_providers(cx: &App) -> bool { LanguageModelRegistry::read_global(cx) .providers() .iter() - .filter(|provider| { - provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID - }) - .map(|provider| (provider.icon(), provider.name().0)) - .collect() + .any(|provider| provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID) } } @@ -81,7 +77,7 @@ impl Render for AgentPanelOnboarding { }), ) .map(|this| { - if enrolled_in_trial || is_pro_user || !self.configured_providers.is_empty() { + if enrolled_in_trial || is_pro_user || self.has_configured_providers { this } else { this.child(ApiKeysWithoutProviders::new()) diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index bfffe9bb87f2acb777156077da69c400c822726d..73747c2997a28a96a33839b8ad96ed58c7ccdae2 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -295,7 +295,7 @@ pub struct DebugLocatorManifestEntry {} pub struct LanguageModelProviderManifestEntry { /// Display name for the provider. pub name: String, - /// Icon name from Zed's icon set (optional). + /// Path to an SVG icon file relative to the extension root (e.g., "icons/provider.svg"). #[serde(default)] pub icon: Option, /// Default models to show even before API connection. diff --git a/crates/extension_api/wit/since_v0.7.0/llm-provider.wit b/crates/extension_api/wit/since_v0.7.0/llm-provider.wit index 31f19b90769fdf444a63849e917649ee7a0ee26d..5912654ebcf9e517e683d13ad2b5e6d9096095eb 100644 --- a/crates/extension_api/wit/since_v0.7.0/llm-provider.wit +++ b/crates/extension_api/wit/since_v0.7.0/llm-provider.wit @@ -5,7 +5,7 @@ interface llm-provider { id: string, /// Display name for the provider. name: string, - /// Icon name from Zed's icon set (optional). + /// Path to an SVG icon file relative to the extension root (e.g., "icons/provider.svg"). icon: option, } diff --git a/crates/extension_api/wit/since_v0.8.0/llm-provider.wit b/crates/extension_api/wit/since_v0.8.0/llm-provider.wit index 31f19b90769fdf444a63849e917649ee7a0ee26d..5912654ebcf9e517e683d13ad2b5e6d9096095eb 100644 --- a/crates/extension_api/wit/since_v0.8.0/llm-provider.wit +++ b/crates/extension_api/wit/since_v0.8.0/llm-provider.wit @@ -5,7 +5,7 @@ interface llm-provider { id: string, /// Display name for the provider. name: string, - /// Icon name from Zed's icon set (optional). + /// Path to an SVG icon file relative to the extension root (e.g., "icons/provider.svg"). icon: option, } diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index 524e14b0cedcebef259948d73b530236525180c0..24eb696b1dcba80761c5eaf65a4364da1a628317 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -254,6 +254,21 @@ async fn copy_extension_resources( } } + for (_, provider_entry) in &manifest.language_model_providers { + if let Some(icon_path) = &provider_entry.icon { + let source_icon = extension_path.join(icon_path); + let dest_icon = output_dir.join(icon_path); + + // Create parent directory if needed + if let Some(parent) = dest_icon.parent() { + fs::create_dir_all(parent)?; + } + + fs::copy(&source_icon, &dest_icon) + .with_context(|| format!("failed to copy LLM provider icon '{}'", icon_path))?; + } + } + if !manifest.languages.is_empty() { let output_languages_dir = output_dir.join("languages"); fs::create_dir_all(&output_languages_dir)?; diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index dfbf1ae3c7e9ea117e0ff3e102403f26fa86cd89..869319ded5419d815ccb53a1cb533f8d27aa1879 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -32,8 +32,8 @@ use futures::{ select_biased, }; use gpui::{ - App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, Task, WeakEntity, - actions, + App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, SharedString, Task, + WeakEntity, actions, }; use http_client::{AsyncBody, HttpClient, HttpClientWithUrl}; use language::{ @@ -67,6 +67,7 @@ struct LlmProviderWithModels { provider_info: LlmProviderInfo, models: Vec, is_authenticated: bool, + icon_path: Option, } pub use extension::{ @@ -1463,10 +1464,23 @@ impl ExtensionStore { .unwrap_or(Ok(false)) .unwrap_or(false); + // Resolve icon path if provided + let icon_path = provider_info.icon.as_ref().map(|icon| { + let icon_file_path = extension_path.join(icon); + // Canonicalize to resolve symlinks (dev extensions are symlinked) + let absolute_icon_path = icon_file_path + .canonicalize() + .unwrap_or(icon_file_path) + .to_string_lossy() + .to_string(); + SharedString::from(absolute_icon_path) + }); + llm_providers_with_models.push(LlmProviderWithModels { provider_info, models, is_authenticated, + icon_path, }); } } else { @@ -1564,12 +1578,13 @@ impl ExtensionStore { let pinfo = llm_provider.provider_info.clone(); let mods = llm_provider.models.clone(); let auth = llm_provider.is_authenticated; + let icon = llm_provider.icon_path.clone(); this.proxy.register_language_model_provider( provider_id.clone(), Box::new(move |cx: &mut App| { let provider = Arc::new(ExtensionLanguageModelProvider::new( - wasm_ext, pinfo, mods, auth, cx, + wasm_ext, pinfo, mods, auth, icon, cx, )); language_model::LanguageModelRegistry::global(cx).update( cx, diff --git a/crates/extension_host/src/wasm_host/llm_provider.rs b/crates/extension_host/src/wasm_host/llm_provider.rs index ce80432fd513eebf92202f26d1732011bc227b23..0ae833080a59a5f2a628aebb1678ce4f1f302c1d 100644 --- a/crates/extension_host/src/wasm_host/llm_provider.rs +++ b/crates/extension_host/src/wasm_host/llm_provider.rs @@ -36,6 +36,7 @@ use util::ResultExt as _; pub struct ExtensionLanguageModelProvider { pub extension: WasmExtension, pub provider_info: LlmProviderInfo, + icon_path: Option, state: Entity, } @@ -52,6 +53,7 @@ impl ExtensionLanguageModelProvider { provider_info: LlmProviderInfo, models: Vec, is_authenticated: bool, + icon_path: Option, cx: &mut App, ) -> Self { let state = cx.new(|_| ExtensionLlmProviderState { @@ -62,6 +64,7 @@ impl ExtensionLanguageModelProvider { Self { extension, provider_info, + icon_path, state, } } @@ -89,6 +92,10 @@ impl LanguageModelProvider for ExtensionLanguageModelProvider { ui::IconName::ZedAssistant } + fn icon_path(&self) -> Option { + self.icon_path.clone() + } + fn default_model(&self, cx: &App) -> Option> { let state = self.state.read(cx); state diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index c9b6391136da1a2b2e9a2ae470229179615a865a..60fe4226ac3b451f0bc79a1c4ce7bd9ea8be5754 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -746,6 +746,11 @@ pub trait LanguageModelProvider: 'static { fn icon(&self) -> IconName { IconName::ZedAssistant } + /// Returns the path to an external SVG icon for this provider, if any. + /// When present, this takes precedence over `icon()`. + fn icon_path(&self) -> Option { + None + } fn default_model(&self, cx: &App) -> Option>; fn default_fast_model(&self, cx: &App) -> Option>; fn provided_models(&self, cx: &App) -> Vec>; diff --git a/extensions/anthropic/src/anthropic.rs b/extensions/anthropic/src/anthropic.rs index 78bf1735bcd5f2c043d2e5ef725692c47ad66871..95765ebdab8a99e9664e9d803d5e441597425424 100644 --- a/extensions/anthropic/src/anthropic.rs +++ b/extensions/anthropic/src/anthropic.rs @@ -490,7 +490,7 @@ impl zed::Extension for AnthropicProvider { vec![LlmProviderInfo { id: "anthropic".into(), name: "Anthropic".into(), - icon: Some("anthropic".into()), + icon: None, }] } diff --git a/extensions/google-ai/src/google_ai.rs b/extensions/google-ai/src/google_ai.rs index 37990951581e9b593321df2d49a0cc2bd16a657d..010c5a278077f27a07f7f1f463598033a4c9d399 100644 --- a/extensions/google-ai/src/google_ai.rs +++ b/extensions/google-ai/src/google_ai.rs @@ -567,7 +567,7 @@ impl zed::Extension for GoogleAiProvider { vec![LlmProviderInfo { id: "google-ai".into(), name: "Google AI".into(), - icon: Some("google-ai".into()), + icon: None, }] } diff --git a/extensions/openai/src/openai.rs b/extensions/openai/src/openai.rs index d464066d13f54ffbdab3dbac619f0e739492bb4b..e596c5c2d6aa8aad184c1e27db120f8ced1ef9d9 100644 --- a/extensions/openai/src/openai.rs +++ b/extensions/openai/src/openai.rs @@ -445,7 +445,7 @@ impl zed::Extension for OpenAiProvider { vec![LlmProviderInfo { id: "openai".into(), name: "OpenAI".into(), - icon: Some("openai".into()), + icon: None, }] }