Add svg icons to llm provider extensions

Richard Feldman created

Change summary

crates/agent_ui/src/agent_configuration.rs                 | 10 +
crates/agent_ui/src/agent_model_selector.rs                | 16 ++
crates/agent_ui/src/language_model_selector.rs             | 39 ++++++-
crates/agent_ui/src/text_thread_editor.rs                  | 15 ++
crates/ai_onboarding/src/agent_api_keys_onboarding.rs      | 38 ++++++-
crates/ai_onboarding/src/agent_panel_onboarding_content.rs | 16 +--
crates/extension/src/extension_manifest.rs                 |  2 
crates/extension_api/wit/since_v0.7.0/llm-provider.wit     |  2 
crates/extension_api/wit/since_v0.8.0/llm-provider.wit     |  2 
crates/extension_cli/src/main.rs                           | 15 +++
crates/extension_host/src/extension_host.rs                | 21 +++
crates/extension_host/src/wasm_host/llm_provider.rs        |  7 +
crates/language_model/src/language_model.rs                |  5 +
extensions/anthropic/src/anthropic.rs                      |  2 
extensions/google-ai/src/google_ai.rs                      |  2 
extensions/openai/src/openai.rs                            |  2 
16 files changed, 153 insertions(+), 41 deletions(-)

Detailed changes

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()

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(

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<dyn LanguageModel>,
-    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()
     }

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)

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()

crates/ai_onboarding/src/agent_panel_onboarding_content.rs 🔗

@@ -11,7 +11,7 @@ use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding};
 pub struct AgentPanelOnboarding {
     user_store: Entity<UserStore>,
     client: Arc<Client>,
-    configured_providers: Vec<(IconName, SharedString)>,
+    has_configured_providers: bool,
     continue_with_zed_ai: Arc<dyn Fn(&mut Window, &mut App)>,
 }
 
@@ -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())

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<String>,
     /// Default models to show even before API connection.

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<string>,
     }
 

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<string>,
     }
 

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)?;

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<LlmModelInfo>,
     is_authenticated: bool,
+    icon_path: Option<SharedString>,
 }
 
 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,

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<SharedString>,
     state: Entity<ExtensionLlmProviderState>,
 }
 
@@ -52,6 +53,7 @@ impl ExtensionLanguageModelProvider {
         provider_info: LlmProviderInfo,
         models: Vec<LlmModelInfo>,
         is_authenticated: bool,
+        icon_path: Option<SharedString>,
         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<SharedString> {
+        self.icon_path.clone()
+    }
+
     fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
         let state = self.state.read(cx);
         state

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<SharedString> {
+        None
+    }
     fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>>;
     fn default_fast_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>>;
     fn provided_models(&self, cx: &App) -> Vec<Arc<dyn LanguageModel>>;

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,
         }]
     }
 

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,
         }]
     }
 

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,
         }]
     }