assistant panel: Tab-less configuration view (#15682)

Thorsten Ball created

TODOs for follow-up:
- [ ] When opening panel: nudge user to sign in if they're not signed-in
and have no provider configured (or if they're not signed-in and have
Zed AI configured)
- [ ] Configuration page is not scrollable
- [ ] Design tweaks

Current status:



https://github.com/user-attachments/assets/d26d65ea-43e8-481b-81a3-b3cba01704a8


Release Notes:

- N/A

Change summary

crates/assistant/src/assistant_panel.rs            | 375 +++++----------
crates/language_model/src/language_model.rs        |   6 
crates/language_model/src/provider/anthropic.rs    | 144 +++--
crates/language_model/src/provider/cloud.rs        |  24 
crates/language_model/src/provider/copilot_chat.rs |   9 
crates/language_model/src/provider/fake.rs         |   4 
crates/language_model/src/provider/google.rs       | 152 +++--
crates/language_model/src/provider/ollama.rs       | 222 +++++----
crates/language_model/src/provider/open_ai.rs      | 160 +++--
crates/language_model/src/registry.rs              |   7 
10 files changed, 545 insertions(+), 558 deletions(-)

Detailed changes

crates/assistant/src/assistant_panel.rs 🔗

@@ -31,7 +31,7 @@ use editor::{
 use editor::{display_map::CreaseId, FoldPlaceholder};
 use fs::Fs;
 use gpui::{
-    div, percentage, point, svg, Action, Animation, AnimationExt, AnyElement, AnyView, AppContext,
+    div, percentage, point, Action, Animation, AnimationExt, AnyElement, AnyView, AppContext,
     AsyncWindowContext, ClipboardItem, Context as _, DismissEvent, Empty, Entity, EventEmitter,
     FocusHandle, FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Pixels,
     Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Transformation,
@@ -41,12 +41,16 @@ use indexed_docs::IndexedDocsStore;
 use language::{
     language_settings::SoftWrap, Capability, LanguageRegistry, LspAdapterDelegate, Point, ToOffset,
 };
-use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, Role};
+use language_model::{
+    provider::cloud::PROVIDER_ID, LanguageModelProvider, LanguageModelProviderId,
+    LanguageModelRegistry, Role,
+};
 use multi_buffer::MultiBufferRow;
 use picker::{Picker, PickerDelegate};
 use project::{Project, ProjectLspAdapterDelegate};
 use search::{buffer_search::DivRegistrar, BufferSearchBar};
 use settings::{update_settings_file, Settings};
+use smol::stream::StreamExt;
 use std::{
     borrow::Cow,
     cmp::{self, Ordering},
@@ -140,6 +144,8 @@ pub struct AssistantPanel {
     model_summary_editor: View<Editor>,
     authenticate_provider_task: Option<(LanguageModelProviderId, Task<()>)>,
     configuration_subscription: Option<Subscription>,
+    watch_client_status: Option<Task<()>>,
+    nudge_sign_in: bool,
 }
 
 #[derive(Clone)]
@@ -411,6 +417,38 @@ impl AssistantPanel {
             ),
         ];
 
+        let mut status_rx = workspace.client().clone().status();
+
+        let watch_client_status = cx.spawn(|this, mut cx| async move {
+            let mut old_status = None;
+            while let Some(status) = status_rx.next().await {
+                if old_status.is_none()
+                    || old_status.map_or(false, |old_status| old_status != status)
+                {
+                    if status.is_signed_out() {
+                        this.update(&mut cx, |this, cx| {
+                            let active_provider =
+                                LanguageModelRegistry::read_global(cx).active_provider();
+
+                            // If we're signed out and don't have a provider configured, or we're signed-out AND Zed.dev is
+                            // the provider, we want to show a nudge to sign in.
+                            if active_provider
+                                .map_or(true, |provider| provider.id().0 == PROVIDER_ID)
+                            {
+                                println!("TODO: Nudge the user to sign in and use Zed AI");
+                                this.nudge_sign_in = true;
+                            }
+                        })
+                        .log_err();
+                    };
+
+                    old_status = Some(status);
+                }
+            }
+            this.update(&mut cx, |this, _cx| this.watch_client_status = None)
+                .log_err();
+        });
+
         let mut this = Self {
             pane,
             workspace: workspace.weak_handle(),
@@ -425,17 +463,11 @@ impl AssistantPanel {
             model_summary_editor,
             authenticate_provider_task: None,
             configuration_subscription: None,
+            watch_client_status: Some(watch_client_status),
+            // TODO: This is unused!
+            nudge_sign_in: false,
         };
-
-        if LanguageModelRegistry::read_global(cx)
-            .active_provider()
-            .is_none()
-        {
-            this.show_configuration_for_provider(None, cx);
-        } else {
-            this.new_context(cx);
-        };
-
+        this.new_context(cx);
         this
     }
 
@@ -623,12 +655,7 @@ impl AssistantPanel {
                 provider.id(),
                 cx.spawn(|this, mut cx| async move {
                     let _ = load_credentials.await;
-                    this.update(&mut cx, |this, cx| {
-                        if !provider.is_authenticated(cx) {
-                            this.show_configuration_for_provider(Some(provider), cx)
-                        } else if !this.has_any_context_editors(cx) {
-                            this.new_context(cx);
-                        }
+                    this.update(&mut cx, |this, _cx| {
                         this.authenticate_provider_task = None;
                     })
                     .log_err();
@@ -908,20 +935,11 @@ impl AssistantPanel {
         }
 
         panel.update(cx, |this, cx| {
-            this.show_configuration_for_active_provider(cx);
+            this.show_configuration_tab(cx);
         })
     }
 
-    fn show_configuration_for_active_provider(&mut self, cx: &mut ViewContext<Self>) {
-        let provider = LanguageModelRegistry::read_global(cx).active_provider();
-        self.show_configuration_for_provider(provider, cx);
-    }
-
-    fn show_configuration_for_provider(
-        &mut self,
-        provider: Option<Arc<dyn LanguageModelProvider>>,
-        cx: &mut ViewContext<Self>,
-    ) {
+    fn show_configuration_tab(&mut self, cx: &mut ViewContext<Self>) {
         let configuration_item_ix = self
             .pane
             .read(cx)
@@ -931,24 +949,9 @@ impl AssistantPanel {
         if let Some(configuration_item_ix) = configuration_item_ix {
             self.pane.update(cx, |pane, cx| {
                 pane.activate_item(configuration_item_ix, true, true, cx);
-                if let Some((item, provider)) =
-                    pane.item_for_index(configuration_item_ix).zip(provider)
-                {
-                    if let Some(view) = item.downcast::<ConfigurationView>() {
-                        view.update(cx, |view, cx| {
-                            view.set_active_tab(provider, cx);
-                        });
-                    }
-                }
             });
         } else {
-            let configuration = cx.new_view(|cx| {
-                let mut view = ConfigurationView::new(cx);
-                if let Some(provider) = provider {
-                    view.set_active_tab(provider, cx);
-                }
-                view
-            });
+            let configuration = cx.new_view(|cx| ConfigurationView::new(cx));
             self.configuration_subscription = Some(cx.subscribe(
                 &configuration,
                 |this, _, event: &ConfigurationViewEvent, cx| match event {
@@ -1018,13 +1021,6 @@ impl AssistantPanel {
             .downcast::<ContextEditor>()
     }
 
-    fn has_any_context_editors(&self, cx: &AppContext) -> bool {
-        self.pane
-            .read(cx)
-            .items()
-            .any(|item| item.downcast::<ContextEditor>().is_some())
-    }
-
     pub fn active_context(&self, cx: &AppContext) -> Option<Model<Context>> {
         Some(self.active_context_editor(cx)?.read(cx).context.clone())
     }
@@ -1159,9 +1155,9 @@ impl Render for AssistantPanel {
             .on_action(cx.listener(|this, _: &workspace::NewFile, cx| {
                 this.new_context(cx);
             }))
-            .on_action(cx.listener(|this, _: &ShowConfiguration, cx| {
-                this.show_configuration_for_active_provider(cx)
-            }))
+            .on_action(
+                cx.listener(|this, _: &ShowConfiguration, cx| this.show_configuration_tab(cx)),
+            )
             .on_action(cx.listener(AssistantPanel::deploy_history))
             .on_action(cx.listener(AssistantPanel::deploy_prompt_library))
             .on_action(cx.listener(AssistantPanel::toggle_model_selector))
@@ -1231,14 +1227,7 @@ impl Panel for AssistantPanel {
     fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
         if active {
             if self.pane.read(cx).items_len() == 0 {
-                if LanguageModelRegistry::read_global(cx)
-                    .active_provider()
-                    .is_none()
-                {
-                    self.show_configuration_for_provider(None, cx);
-                } else {
-                    self.new_context(cx);
-                };
+                self.new_context(cx);
             }
 
             self.ensure_authenticated(cx);
@@ -3044,211 +3033,122 @@ impl Item for ContextHistory {
     }
 }
 
-struct ActiveTab {
-    provider: Arc<dyn LanguageModelProvider>,
-    configuration_prompt: AnyView,
-    focus_handle: Option<FocusHandle>,
-    load_credentials_task: Option<Task<()>>,
-}
-
-impl ActiveTab {
-    fn is_loading_credentials(&self) -> bool {
-        if let Some(task) = &self.load_credentials_task {
-            if let Task::Spawned(_) = task {
-                return true;
-            }
-        }
-        false
-    }
-}
-
 pub struct ConfigurationView {
     focus_handle: FocusHandle,
-    active_tab: Option<ActiveTab>,
+    configuration_views: HashMap<LanguageModelProviderId, AnyView>,
+    _registry_subscription: Subscription,
 }
 
 impl ConfigurationView {
     fn new(cx: &mut ViewContext<Self>) -> Self {
         let focus_handle = cx.focus_handle();
 
-        cx.on_focus(&focus_handle, |this, cx| {
-            if let Some(focus_handle) = this
-                .active_tab
-                .as_ref()
-                .and_then(|tab| tab.focus_handle.as_ref())
-            {
-                focus_handle.focus(cx);
-            }
-        })
-        .detach();
+        let registry_subscription = cx.subscribe(
+            &LanguageModelRegistry::global(cx),
+            |this, _, event: &language_model::Event, cx| match event {
+                language_model::Event::AddedProvider(provider_id) => {
+                    let provider = LanguageModelRegistry::read_global(cx).provider(provider_id);
+                    if let Some(provider) = provider {
+                        this.add_configuration_view(&provider, cx);
+                    }
+                }
+                language_model::Event::RemovedProvider(provider_id) => {
+                    this.remove_configuration_view(provider_id);
+                }
+                _ => {}
+            },
+        );
 
         let mut this = Self {
             focus_handle,
-            active_tab: None,
+            configuration_views: HashMap::default(),
+            _registry_subscription: registry_subscription,
         };
+        this.build_configuration_views(cx);
+        this
+    }
 
+    fn build_configuration_views(&mut self, cx: &mut ViewContext<Self>) {
         let providers = LanguageModelRegistry::read_global(cx).providers();
-        if !providers.is_empty() {
-            this.set_active_tab(providers[0].clone(), cx);
+        for provider in providers {
+            self.add_configuration_view(&provider, cx);
         }
+    }
 
-        this
+    fn remove_configuration_view(&mut self, provider_id: &LanguageModelProviderId) {
+        self.configuration_views.remove(provider_id);
     }
 
-    fn set_active_tab(
+    fn add_configuration_view(
         &mut self,
-        provider: Arc<dyn LanguageModelProvider>,
+        provider: &Arc<dyn LanguageModelProvider>,
         cx: &mut ViewContext<Self>,
     ) {
-        let (view, focus_handle) = provider.configuration_view(cx);
-
-        if let Some(focus_handle) = &focus_handle {
-            focus_handle.focus(cx);
-        } else {
-            self.focus_handle.focus(cx);
-        }
-
-        let load_credentials = provider.authenticate(cx);
-        let load_credentials_task = cx.spawn(|this, mut cx| async move {
-            let _ = load_credentials.await;
-            this.update(&mut cx, |this, cx| {
-                if let Some(active_tab) = &mut this.active_tab {
-                    active_tab.load_credentials_task = None;
-                    cx.notify();
-                }
-            })
-            .log_err();
-        });
-
-        self.active_tab = Some(ActiveTab {
-            provider,
-            configuration_prompt: view,
-            focus_handle,
-            load_credentials_task: Some(load_credentials_task),
-        });
-        cx.notify();
-    }
-
-    fn render_active_tab_view(&mut self, cx: &mut ViewContext<Self>) -> Option<Div> {
-        let Some(active_tab) = &self.active_tab else {
-            return None;
-        };
-
-        let provider = active_tab.provider.clone();
-        let provider_name = provider.name().0.clone();
-
-        let show_spinner = active_tab.is_loading_credentials();
-
-        let content = if show_spinner {
-            let loading_icon = svg()
-                .size_4()
-                .path(IconName::ArrowCircle.path())
-                .text_color(cx.text_style().color)
-                .with_animation(
-                    "icon_circle_arrow",
-                    Animation::new(Duration::from_secs(2)).repeat(),
-                    |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))),
-                );
-
-            h_flex()
-                .gap_2()
-                .child(loading_icon)
-                .child(Label::new("Loading provider configuration...").size(LabelSize::Small))
-                .into_any_element()
-        } else {
-            active_tab.configuration_prompt.clone().into_any_element()
-        };
-
-        Some(
-            v_flex()
-                .gap_4()
-                .child(
-                    div()
-                        .p(Spacing::Large.rems(cx))
-                        .bg(cx.theme().colors().title_bar_background)
-                        .border_1()
-                        .border_color(cx.theme().colors().border_variant)
-                        .rounded_md()
-                        .child(content),
-                )
-                .when(
-                    !show_spinner && provider.is_authenticated(cx),
-                    move |this| {
-                        this.child(
-                            h_flex().justify_end().child(
-                                Button::new(
-                                    "new-context",
-                                    format!("Open new context using {}", provider_name),
-                                )
-                                .icon_position(IconPosition::Start)
-                                .icon(IconName::Plus)
-                                .style(ButtonStyle::Filled)
-                                .layer(ElevationIndex::ModalSurface)
-                                .on_click(cx.listener(
-                                    move |_, _, cx| {
-                                        cx.emit(ConfigurationViewEvent::NewProviderContextEditor(
-                                            provider.clone(),
-                                        ))
-                                    },
-                                )),
-                            ),
-                        )
-                    },
-                ),
-        )
+        let configuration_view = provider.configuration_view(cx);
+        self.configuration_views
+            .insert(provider.id(), configuration_view);
     }
 
-    fn render_tab(
-        &self,
+    fn render_provider_view(
+        &mut self,
         provider: &Arc<dyn LanguageModelProvider>,
         cx: &mut ViewContext<Self>,
-    ) -> impl IntoElement {
-        let button_id = SharedString::from(format!("tab-{}", provider.id().0));
-        let is_active = self.active_tab.as_ref().map(|t| t.provider.id()) == Some(provider.id());
-        ButtonLike::new(button_id)
-            .size(ButtonSize::Compact)
-            .style(ButtonStyle::Transparent)
-            .selected(is_active)
-            .on_click(cx.listener({
-                let provider = provider.clone();
-                move |this, _, cx| {
-                    this.set_active_tab(provider.clone(), cx);
-                }
-            }))
+    ) -> Div {
+        let provider_name = provider.name().0.clone();
+        let configuration_view = self.configuration_views.get(&provider.id()).cloned();
+
+        v_flex()
+            .gap_4()
+            .child(Headline::new(provider_name.clone()).size(HeadlineSize::Medium))
             .child(
                 div()
-                    .my_3()
-                    .pb_px()
-                    .border_b_1()
-                    .border_color(if is_active {
-                        cx.theme().colors().text_accent
-                    } else {
-                        cx.theme().colors().border_transparent
-                    })
-                    .when(!is_active, |this| {
-                        this.group_hover("", |this| {
-                            this.border_color(cx.theme().colors().border_variant)
-                        })
+                    .p(Spacing::Large.rems(cx))
+                    .bg(cx.theme().colors().title_bar_background)
+                    .border_1()
+                    .border_color(cx.theme().colors().border_variant)
+                    .rounded_md()
+                    .when(configuration_view.is_none(), |this| {
+                        this.child(div().child(Label::new(format!(
+                            "No configuration view for {}",
+                            provider_name
+                        ))))
                     })
-                    .child(Label::new(provider.name().0).size(LabelSize::Small).color(
-                        if is_active {
-                            Color::Accent
-                        } else {
-                            Color::Default
-                        },
-                    )),
+                    .when_some(configuration_view, |this, configuration_view| {
+                        this.child(configuration_view)
+                    }),
             )
+            .when(provider.is_authenticated(cx), move |this| {
+                this.child(
+                    h_flex().justify_end().child(
+                        Button::new(
+                            "new-context",
+                            format!("Open new context using {}", provider_name),
+                        )
+                        .icon_position(IconPosition::Start)
+                        .icon(IconName::Plus)
+                        .style(ButtonStyle::Filled)
+                        .layer(ElevationIndex::ModalSurface)
+                        .on_click(cx.listener({
+                            let provider = provider.clone();
+                            move |_, _, cx| {
+                                cx.emit(ConfigurationViewEvent::NewProviderContextEditor(
+                                    provider.clone(),
+                                ))
+                            }
+                        })),
+                    ),
+                )
+            })
     }
 }
 
 impl Render for ConfigurationView {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let providers = LanguageModelRegistry::read_global(cx).providers();
-        let tabs = h_flex().mx_neg_1().gap_3().children(
-            providers
-                .iter()
-                .map(|provider| self.render_tab(provider, cx)),
-        );
+        let provider_views = providers
+            .into_iter()
+            .map(|provider| self.render_provider_view(&provider, cx))
+            .collect::<Vec<_>>();
 
         v_flex()
             .id("assistant-configuration-view")
@@ -3266,20 +3166,13 @@ impl Render for ConfigurationView {
             .child(
                 v_flex()
                     .gap_2()
-                    .child(Headline::new("Configure providers").size(HeadlineSize::Small))
                     .child(
                         Label::new(
                             "At least one provider must be configured to use the assistant.",
                         )
                         .color(Color::Muted),
                     )
-                    .child(tabs)
-                    .when(self.active_tab.is_some(), |this| {
-                        this.children(self.render_active_tab_view(cx))
-                    })
-                    .when(self.active_tab.is_none(), |this| {
-                        this.child(Label::new("No providers configured").color(Color::Warning))
-                    }),
+                    .child(v_flex().mt_2().gap_4().children(provider_views)),
             )
     }
 }

crates/language_model/src/language_model.rs 🔗

@@ -9,9 +9,7 @@ pub mod settings;
 use anyhow::Result;
 use client::{Client, UserStore};
 use futures::{future::BoxFuture, stream::BoxStream};
-use gpui::{
-    AnyView, AppContext, AsyncAppContext, FocusHandle, Model, SharedString, Task, WindowContext,
-};
+use gpui::{AnyView, AppContext, AsyncAppContext, Model, SharedString, Task, WindowContext};
 pub use model::*;
 use project::Fs;
 use proto::Plan;
@@ -110,7 +108,7 @@ pub trait LanguageModelProvider: 'static {
     fn load_model(&self, _model: Arc<dyn LanguageModel>, _cx: &AppContext) {}
     fn is_authenticated(&self, cx: &AppContext) -> bool;
     fn authenticate(&self, cx: &mut AppContext) -> Task<Result<()>>;
-    fn configuration_view(&self, cx: &mut WindowContext) -> (AnyView, Option<FocusHandle>);
+    fn configuration_view(&self, cx: &mut WindowContext) -> AnyView;
     fn reset_credentials(&self, cx: &mut AppContext) -> Task<Result<()>>;
 }
 

crates/language_model/src/provider/anthropic.rs 🔗

@@ -8,8 +8,8 @@ use collections::BTreeMap;
 use editor::{Editor, EditorElement, EditorStyle};
 use futures::{future::BoxFuture, stream::BoxStream, FutureExt, StreamExt};
 use gpui::{
-    AnyView, AppContext, AsyncAppContext, FocusHandle, FocusableView, FontStyle, ModelContext,
-    Subscription, Task, TextStyle, View, WhiteSpace,
+    AnyView, AppContext, AsyncAppContext, FontStyle, ModelContext, Subscription, Task, TextStyle,
+    View, WhiteSpace,
 };
 use http_client::HttpClient;
 use schemars::JsonSchema;
@@ -19,6 +19,7 @@ use std::{sync::Arc, time::Duration};
 use strum::IntoEnumIterator;
 use theme::ThemeSettings;
 use ui::{prelude::*, Indicator};
+use util::ResultExt;
 
 const PROVIDER_ID: &str = "anthropic";
 const PROVIDER_NAME: &str = "Anthropic";
@@ -83,6 +84,34 @@ impl State {
     fn is_authenticated(&self) -> bool {
         self.api_key.is_some()
     }
+
+    fn authenticate(&self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        if self.is_authenticated() {
+            Task::ready(Ok(()))
+        } else {
+            let api_url = AllLanguageModelSettings::get_global(cx)
+                .anthropic
+                .api_url
+                .clone();
+
+            cx.spawn(|this, mut cx| async move {
+                let api_key = if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") {
+                    api_key
+                } else {
+                    let (_, api_key) = cx
+                        .update(|cx| cx.read_credentials(&api_url))?
+                        .await?
+                        .ok_or_else(|| anyhow!("credentials not found"))?;
+                    String::from_utf8(api_key)?
+                };
+
+                this.update(&mut cx, |this, cx| {
+                    this.api_key = Some(api_key);
+                    cx.notify();
+                })
+            })
+        }
+    }
 }
 
 impl AnthropicLanguageModelProvider {
@@ -164,37 +193,12 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider {
     }
 
     fn authenticate(&self, cx: &mut AppContext) -> Task<Result<()>> {
-        if self.is_authenticated(cx) {
-            Task::ready(Ok(()))
-        } else {
-            let api_url = AllLanguageModelSettings::get_global(cx)
-                .anthropic
-                .api_url
-                .clone();
-            let state = self.state.clone();
-            cx.spawn(|mut cx| async move {
-                let api_key = if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") {
-                    api_key
-                } else {
-                    let (_, api_key) = cx
-                        .update(|cx| cx.read_credentials(&api_url))?
-                        .await?
-                        .ok_or_else(|| anyhow!("credentials not found"))?;
-                    String::from_utf8(api_key)?
-                };
-
-                state.update(&mut cx, |this, cx| {
-                    this.api_key = Some(api_key);
-                    cx.notify();
-                })
-            })
-        }
+        self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(&self, cx: &mut WindowContext) -> (AnyView, Option<FocusHandle>) {
-        let view = cx.new_view(|cx| ConfigurationView::new(self.state.clone(), cx));
-        let focus_handle = view.focus_handle(cx);
-        (view.into(), Some(focus_handle))
+    fn configuration_view(&self, cx: &mut WindowContext) -> AnyView {
+        cx.new_view(|cx| ConfigurationView::new(self.state.clone(), cx))
+            .into()
     }
 
     fn reset_credentials(&self, cx: &mut AppContext) -> Task<Result<()>> {
@@ -383,33 +387,46 @@ impl LanguageModel for AnthropicModel {
 }
 
 struct ConfigurationView {
-    focus_handle: FocusHandle,
     api_key_editor: View<Editor>,
     state: gpui::Model<State>,
+    load_credentials_task: Option<Task<()>>,
 }
 
 impl ConfigurationView {
-    fn new(state: gpui::Model<State>, cx: &mut ViewContext<Self>) -> Self {
-        let focus_handle = cx.focus_handle();
+    const PLACEHOLDER_TEXT: &'static str = "sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
 
-        cx.on_focus(&focus_handle, |this, cx| {
-            if this.should_render_editor(cx) {
-                this.api_key_editor.read(cx).focus_handle(cx).focus(cx)
-            }
+    fn new(state: gpui::Model<State>, cx: &mut ViewContext<Self>) -> Self {
+        cx.observe(&state, |_, _, cx| {
+            cx.notify();
         })
         .detach();
 
+        let load_credentials_task = Some(cx.spawn({
+            let state = state.clone();
+            |this, mut cx| async move {
+                if let Some(task) = state
+                    .update(&mut cx, |state, cx| state.authenticate(cx))
+                    .log_err()
+                {
+                    // We don't log an error, because "not signed in" is also an error.
+                    let _ = task.await;
+                }
+                this.update(&mut cx, |this, cx| {
+                    this.load_credentials_task = None;
+                    cx.notify();
+                })
+                .log_err();
+            }
+        }));
+
         Self {
             api_key_editor: cx.new_view(|cx| {
                 let mut editor = Editor::single_line(cx);
-                editor.set_placeholder_text(
-                    "sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
-                    cx,
-                );
+                editor.set_placeholder_text(Self::PLACEHOLDER_TEXT, cx);
                 editor
             }),
-            focus_handle,
             state,
+            load_credentials_task,
         }
     }
 
@@ -419,17 +436,30 @@ impl ConfigurationView {
             return;
         }
 
-        self.state
-            .update(cx, |state, cx| state.set_api_key(api_key, cx))
-            .detach_and_log_err(cx);
+        let state = self.state.clone();
+        cx.spawn(|_, mut cx| async move {
+            state
+                .update(&mut cx, |state, cx| state.set_api_key(api_key, cx))?
+                .await
+        })
+        .detach_and_log_err(cx);
+
+        cx.notify();
     }
 
     fn reset_api_key(&mut self, cx: &mut ViewContext<Self>) {
         self.api_key_editor
             .update(cx, |editor, cx| editor.set_text("", cx));
-        self.state
-            .update(cx, |state, cx| state.reset_api_key(cx))
-            .detach_and_log_err(cx);
+
+        let state = self.state.clone();
+        cx.spawn(|_, mut cx| async move {
+            state
+                .update(&mut cx, |state, cx| state.reset_api_key(cx))?
+                .await
+        })
+        .detach_and_log_err(cx);
+
+        cx.notify();
     }
 
     fn render_api_key_editor(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
@@ -464,12 +494,6 @@ impl ConfigurationView {
     }
 }
 
-impl FocusableView for ConfigurationView {
-    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
-        self.focus_handle.clone()
-    }
-}
-
 impl Render for ConfigurationView {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         const INSTRUCTIONS: [&str; 4] = [
@@ -479,10 +503,10 @@ impl Render for ConfigurationView {
             "Paste your Anthropic API key below and hit enter to use the assistant:",
         ];
 
-        if self.should_render_editor(cx) {
+        if self.load_credentials_task.is_some() {
+            div().child(Label::new("Loading credentials...")).into_any()
+        } else if self.should_render_editor(cx) {
             v_flex()
-                .id("anthropic-configuration-view")
-                .track_focus(&self.focus_handle)
                 .size_full()
                 .on_action(cx.listener(Self::save_api_key))
                 .children(
@@ -507,15 +531,13 @@ impl Render for ConfigurationView {
                 .into_any()
         } else {
             h_flex()
-                .id("anthropic-configuration-view")
-                .track_focus(&self.focus_handle)
                 .size_full()
                 .justify_between()
                 .child(
                     h_flex()
                         .gap_2()
                         .child(Indicator::dot().color(Color::Success))
-                        .child(Label::new("API Key configured").size(LabelSize::Small)),
+                        .child(Label::new("API key configured").size(LabelSize::Small)),
                 )
                 .child(
                     Button::new("reset-key", "Reset key")

crates/language_model/src/provider/cloud.rs 🔗

@@ -8,9 +8,7 @@ use anyhow::{anyhow, Context as _, Result};
 use client::{Client, UserStore};
 use collections::BTreeMap;
 use futures::{future::BoxFuture, stream::BoxStream, FutureExt, StreamExt};
-use gpui::{
-    AnyView, AppContext, AsyncAppContext, FocusHandle, Model, ModelContext, Subscription, Task,
-};
+use gpui::{AnyView, AppContext, AsyncAppContext, Model, ModelContext, Subscription, Task};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
@@ -60,8 +58,8 @@ pub struct State {
 }
 
 impl State {
-    fn is_connected(&self) -> bool {
-        self.status.is_connected()
+    fn is_signed_out(&self) -> bool {
+        self.status.is_signed_out()
     }
 
     fn authenticate(&self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
@@ -191,20 +189,18 @@ impl LanguageModelProvider for CloudLanguageModelProvider {
     }
 
     fn is_authenticated(&self, cx: &AppContext) -> bool {
-        self.state.read(cx).status.is_connected()
+        !self.state.read(cx).is_signed_out()
     }
 
     fn authenticate(&self, _cx: &mut AppContext) -> Task<Result<()>> {
         Task::ready(Ok(()))
     }
 
-    fn configuration_view(&self, cx: &mut WindowContext) -> (AnyView, Option<FocusHandle>) {
-        let view = cx
-            .new_view(|_cx| ConfigurationView {
-                state: self.state.clone(),
-            })
-            .into();
-        (view, None)
+    fn configuration_view(&self, cx: &mut WindowContext) -> AnyView {
+        cx.new_view(|_cx| ConfigurationView {
+            state: self.state.clone(),
+        })
+        .into()
     }
 
     fn reset_credentials(&self, _cx: &mut AppContext) -> Task<Result<()>> {
@@ -439,7 +435,7 @@ impl Render for ConfigurationView {
         const ZED_AI_URL: &str = "https://zed.dev/ai";
         const ACCOUNT_SETTINGS_URL: &str = "https://zed.dev/account";
 
-        let is_connected = self.state.read(cx).is_connected();
+        let is_connected = self.state.read(cx).is_signed_out();
         let plan = self.state.read(cx).user_store.read(cx).current_plan();
 
         let is_pro = plan == Some(proto::Plan::ZedPro);

crates/language_model/src/provider/copilot_chat.rs 🔗

@@ -11,8 +11,8 @@ use futures::future::BoxFuture;
 use futures::stream::BoxStream;
 use futures::{FutureExt, StreamExt};
 use gpui::{
-    percentage, svg, Animation, AnimationExt, AnyView, AppContext, AsyncAppContext, FocusHandle,
-    Model, Render, Subscription, Task, Transformation,
+    percentage, svg, Animation, AnimationExt, AnyView, AppContext, AsyncAppContext, Model, Render,
+    Subscription, Task, Transformation,
 };
 use settings::{Settings, SettingsStore};
 use std::time::Duration;
@@ -132,10 +132,9 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
         Task::ready(result)
     }
 
-    fn configuration_view(&self, cx: &mut WindowContext) -> (AnyView, Option<FocusHandle>) {
+    fn configuration_view(&self, cx: &mut WindowContext) -> AnyView {
         let state = self.state.clone();
-        let view = cx.new_view(|cx| ConfigurationView::new(state, cx)).into();
-        (view, None)
+        cx.new_view(|cx| ConfigurationView::new(state, cx)).into()
     }
 
     fn reset_credentials(&self, _cx: &mut AppContext) -> Task<Result<()>> {

crates/language_model/src/provider/fake.rs 🔗

@@ -6,7 +6,7 @@ use crate::{
 use anyhow::anyhow;
 use collections::HashMap;
 use futures::{channel::mpsc, future::BoxFuture, stream::BoxStream, FutureExt, StreamExt};
-use gpui::{AnyView, AppContext, AsyncAppContext, FocusHandle, Task};
+use gpui::{AnyView, AppContext, AsyncAppContext, Task};
 use http_client::Result;
 use std::{
     future,
@@ -66,7 +66,7 @@ impl LanguageModelProvider for FakeLanguageModelProvider {
         Task::ready(Ok(()))
     }
 
-    fn configuration_view(&self, _: &mut WindowContext) -> (AnyView, Option<FocusHandle>) {
+    fn configuration_view(&self, _: &mut WindowContext) -> AnyView {
         unimplemented!()
     }
 

crates/language_model/src/provider/google.rs 🔗

@@ -4,8 +4,8 @@ use editor::{Editor, EditorElement, EditorStyle};
 use futures::{future::BoxFuture, FutureExt, StreamExt};
 use google_ai::stream_generate_content;
 use gpui::{
-    AnyView, AppContext, AsyncAppContext, FocusHandle, FocusableView, FontStyle, ModelContext,
-    Subscription, Task, TextStyle, View, WhiteSpace,
+    AnyView, AppContext, AsyncAppContext, FontStyle, ModelContext, Subscription, Task, TextStyle,
+    View, WhiteSpace,
 };
 use http_client::HttpClient;
 use schemars::JsonSchema;
@@ -65,6 +65,48 @@ impl State {
             })
         })
     }
+
+    fn set_api_key(&mut self, api_key: String, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        let settings = &AllLanguageModelSettings::get_global(cx).google;
+        let write_credentials =
+            cx.write_credentials(&settings.api_url, "Bearer", api_key.as_bytes());
+
+        cx.spawn(|this, mut cx| async move {
+            write_credentials.await?;
+            this.update(&mut cx, |this, cx| {
+                this.api_key = Some(api_key);
+                cx.notify();
+            })
+        })
+    }
+
+    fn authenticate(&self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        if self.is_authenticated() {
+            Task::ready(Ok(()))
+        } else {
+            let api_url = AllLanguageModelSettings::get_global(cx)
+                .google
+                .api_url
+                .clone();
+
+            cx.spawn(|this, mut cx| async move {
+                let api_key = if let Ok(api_key) = std::env::var("GOOGLE_AI_API_KEY") {
+                    api_key
+                } else {
+                    let (_, api_key) = cx
+                        .update(|cx| cx.read_credentials(&api_url))?
+                        .await?
+                        .ok_or_else(|| anyhow!("credentials not found"))?;
+                    String::from_utf8(api_key)?
+                };
+
+                this.update(&mut cx, |this, cx| {
+                    this.api_key = Some(api_key);
+                    cx.notify();
+                })
+            })
+        }
+    }
 }
 
 impl GoogleLanguageModelProvider {
@@ -144,38 +186,12 @@ impl LanguageModelProvider for GoogleLanguageModelProvider {
     }
 
     fn authenticate(&self, cx: &mut AppContext) -> Task<Result<()>> {
-        if self.is_authenticated(cx) {
-            Task::ready(Ok(()))
-        } else {
-            let api_url = AllLanguageModelSettings::get_global(cx)
-                .google
-                .api_url
-                .clone();
-            let state = self.state.clone();
-            cx.spawn(|mut cx| async move {
-                let api_key = if let Ok(api_key) = std::env::var("GOOGLE_AI_API_KEY") {
-                    api_key
-                } else {
-                    let (_, api_key) = cx
-                        .update(|cx| cx.read_credentials(&api_url))?
-                        .await?
-                        .ok_or_else(|| anyhow!("credentials not found"))?;
-                    String::from_utf8(api_key)?
-                };
-
-                state.update(&mut cx, |this, cx| {
-                    this.api_key = Some(api_key);
-                    cx.notify();
-                })
-            })
-        }
+        self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(&self, cx: &mut WindowContext) -> (AnyView, Option<FocusHandle>) {
-        let view = cx.new_view(|cx| ConfigurationView::new(self.state.clone(), cx));
-
-        let focus_handle = view.focus_handle(cx);
-        (view.into(), Some(focus_handle))
+    fn configuration_view(&self, cx: &mut WindowContext) -> AnyView {
+        cx.new_view(|cx| ConfigurationView::new(self.state.clone(), cx))
+            .into()
     }
 
     fn reset_credentials(&self, cx: &mut AppContext) -> Task<Result<()>> {
@@ -292,22 +308,36 @@ impl LanguageModel for GoogleLanguageModel {
 }
 
 struct ConfigurationView {
-    focus_handle: FocusHandle,
     api_key_editor: View<Editor>,
     state: gpui::Model<State>,
+    load_credentials_task: Option<Task<()>>,
 }
 
 impl ConfigurationView {
     fn new(state: gpui::Model<State>, cx: &mut ViewContext<Self>) -> Self {
-        let focus_handle = cx.focus_handle();
-
-        cx.on_focus(&focus_handle, |this, cx| {
-            if this.should_render_editor(cx) {
-                this.api_key_editor.read(cx).focus_handle(cx).focus(cx)
-            }
+        cx.observe(&state, |_, _, cx| {
+            cx.notify();
         })
         .detach();
 
+        let load_credentials_task = Some(cx.spawn({
+            let state = state.clone();
+            |this, mut cx| async move {
+                if let Some(task) = state
+                    .update(&mut cx, |state, cx| state.authenticate(cx))
+                    .log_err()
+                {
+                    // We don't log an error, because "not signed in" is also an error.
+                    let _ = task.await;
+                }
+                this.update(&mut cx, |this, cx| {
+                    this.load_credentials_task = None;
+                    cx.notify();
+                })
+                .log_err();
+            }
+        }));
+
         Self {
             api_key_editor: cx.new_view(|cx| {
                 let mut editor = Editor::single_line(cx);
@@ -315,7 +345,7 @@ impl ConfigurationView {
                 editor
             }),
             state,
-            focus_handle,
+            load_credentials_task,
         }
     }
 
@@ -325,26 +355,30 @@ impl ConfigurationView {
             return;
         }
 
-        let settings = &AllLanguageModelSettings::get_global(cx).google;
-        let write_credentials =
-            cx.write_credentials(&settings.api_url, "Bearer", api_key.as_bytes());
         let state = self.state.clone();
         cx.spawn(|_, mut cx| async move {
-            write_credentials.await?;
-            state.update(&mut cx, |this, cx| {
-                this.api_key = Some(api_key);
-                cx.notify();
-            })
+            state
+                .update(&mut cx, |state, cx| state.set_api_key(api_key, cx))?
+                .await
         })
         .detach_and_log_err(cx);
+
+        cx.notify();
     }
 
     fn reset_api_key(&mut self, cx: &mut ViewContext<Self>) {
         self.api_key_editor
             .update(cx, |editor, cx| editor.set_text("", cx));
-        self.state
-            .update(cx, |state, cx| state.reset_api_key(cx))
-            .detach_and_log_err(cx);
+
+        let state = self.state.clone();
+        cx.spawn(|_, mut cx| async move {
+            state
+                .update(&mut cx, |state, cx| state.reset_api_key(cx))?
+                .await
+        })
+        .detach_and_log_err(cx);
+
+        cx.notify();
     }
 
     fn render_api_key_editor(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
@@ -379,12 +413,6 @@ impl ConfigurationView {
     }
 }
 
-impl FocusableView for ConfigurationView {
-    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
-        self.focus_handle.clone()
-    }
-}
-
 impl Render for ConfigurationView {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         const INSTRUCTIONS: [&str; 4] = [
@@ -394,10 +422,10 @@ impl Render for ConfigurationView {
             "Paste your Google AI API key below and hit enter to use the assistant:",
         ];
 
-        if self.should_render_editor(cx) {
+        if self.load_credentials_task.is_some() {
+            div().child(Label::new("Loading credentials...")).into_any()
+        } else if self.should_render_editor(cx) {
             v_flex()
-                .id("google-ai-configuration-view")
-                .track_focus(&self.focus_handle)
                 .size_full()
                 .on_action(cx.listener(Self::save_api_key))
                 .children(
@@ -422,15 +450,13 @@ impl Render for ConfigurationView {
                 .into_any()
         } else {
             h_flex()
-                .id("google-ai-configuration-view")
-                .track_focus(&self.focus_handle)
                 .size_full()
                 .justify_between()
                 .child(
                     h_flex()
                         .gap_2()
                         .child(Indicator::dot().color(Color::Success))
-                        .child(Label::new("API Key configured").size(LabelSize::Small)),
+                        .child(Label::new("API key configured").size(LabelSize::Small)),
                 )
                 .child(
                     Button::new("reset-key", "Reset key")

crates/language_model/src/provider/ollama.rs 🔗

@@ -1,6 +1,6 @@
 use anyhow::{anyhow, Result};
 use futures::{future::BoxFuture, stream::BoxStream, FutureExt, StreamExt};
-use gpui::{AnyView, AppContext, AsyncAppContext, FocusHandle, ModelContext, Subscription, Task};
+use gpui::{AnyView, AppContext, AsyncAppContext, ModelContext, Subscription, Task};
 use http_client::HttpClient;
 use ollama::{
     get_models, preload_model, stream_chat_completion, ChatMessage, ChatOptions, ChatRequest,
@@ -8,6 +8,7 @@ use ollama::{
 use settings::{Settings, SettingsStore};
 use std::{future, sync::Arc, time::Duration};
 use ui::{prelude::*, ButtonLike, Indicator};
+use util::ResultExt;
 
 use crate::{
     settings::AllLanguageModelSettings, LanguageModel, LanguageModelId, LanguageModelName,
@@ -70,6 +71,14 @@ impl State {
             })
         })
     }
+
+    fn authenticate(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        if self.is_authenticated() {
+            Task::ready(Ok(()))
+        } else {
+            self.fetch_models(cx)
+        }
+    }
 }
 
 impl OllamaLanguageModelProvider {
@@ -142,19 +151,12 @@ impl LanguageModelProvider for OllamaLanguageModelProvider {
     }
 
     fn authenticate(&self, cx: &mut AppContext) -> Task<Result<()>> {
-        if self.is_authenticated(cx) {
-            Task::ready(Ok(()))
-        } else {
-            self.state.update(cx, |state, cx| state.fetch_models(cx))
-        }
+        self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(&self, cx: &mut WindowContext) -> (AnyView, Option<FocusHandle>) {
+    fn configuration_view(&self, cx: &mut WindowContext) -> AnyView {
         let state = self.state.clone();
-        (
-            cx.new_view(|cx| ConfigurationView::new(state, cx)).into(),
-            None,
-        )
+        cx.new_view(|cx| ConfigurationView::new(state, cx)).into()
     }
 
     fn reset_credentials(&self, cx: &mut AppContext) -> Task<Result<()>> {
@@ -296,11 +298,32 @@ impl LanguageModel for OllamaLanguageModel {
 
 struct ConfigurationView {
     state: gpui::Model<State>,
+    loading_models_task: Option<Task<()>>,
 }
 
 impl ConfigurationView {
-    pub fn new(state: gpui::Model<State>, _cx: &mut ViewContext<Self>) -> Self {
-        Self { state }
+    pub fn new(state: gpui::Model<State>, cx: &mut ViewContext<Self>) -> Self {
+        let loading_models_task = Some(cx.spawn({
+            let state = state.clone();
+            |this, mut cx| async move {
+                if let Some(task) = state
+                    .update(&mut cx, |state, cx| state.authenticate(cx))
+                    .log_err()
+                {
+                    task.await.log_err();
+                }
+                this.update(&mut cx, |this, cx| {
+                    this.loading_models_task = None;
+                    cx.notify();
+                })
+                .log_err();
+            }
+        }));
+
+        Self {
+            state,
+            loading_models_task,
+        }
     }
 
     fn retry_connection(&self, cx: &mut WindowContext) {
@@ -321,94 +344,101 @@ impl Render for ConfigurationView {
         let mut inline_code_bg = cx.theme().colors().editor_background;
         inline_code_bg.fade_out(0.5);
 
-        v_flex()
-            .size_full()
-            .gap_3()
-            .child(
-                v_flex()
-                    .size_full()
-                    .gap_2()
-                    .p_1()
-                    .child(Label::new(ollama_intro))
-                    .child(Label::new(ollama_reqs))
-                    .child(
-                        h_flex()
-                            .gap_0p5()
-                            .child(Label::new("Once installed, try "))
-                            .child(
-                                div()
-                                    .bg(inline_code_bg)
-                                    .px_1p5()
-                                    .rounded_md()
-                                    .child(Label::new("ollama run llama3.1")),
-                            ),
-                    ),
-            )
-            .child(
-                h_flex()
-                    .w_full()
-                    .pt_2()
-                    .justify_between()
-                    .gap_2()
-                    .child(
-                        h_flex()
-                            .w_full()
-                            .gap_2()
-                            .map(|this| {
-                                if is_authenticated {
-                                    this.child(
-                                        Button::new("ollama-site", "Ollama")
-                                            .style(ButtonStyle::Subtle)
-                                            .icon(IconName::ExternalLink)
-                                            .icon_size(IconSize::XSmall)
-                                            .icon_color(Color::Muted)
-                                            .on_click(move |_, cx| cx.open_url(OLLAMA_SITE))
-                                            .into_any_element(),
-                                    )
-                                } else {
-                                    this.child(
-                                        Button::new("download_ollama_button", "Download Ollama")
+        if self.loading_models_task.is_some() {
+            div().child(Label::new("Loading models...")).into_any()
+        } else {
+            v_flex()
+                .size_full()
+                .gap_3()
+                .child(
+                    v_flex()
+                        .size_full()
+                        .gap_2()
+                        .p_1()
+                        .child(Label::new(ollama_intro))
+                        .child(Label::new(ollama_reqs))
+                        .child(
+                            h_flex()
+                                .gap_0p5()
+                                .child(Label::new("Once installed, try "))
+                                .child(
+                                    div()
+                                        .bg(inline_code_bg)
+                                        .px_1p5()
+                                        .rounded_md()
+                                        .child(Label::new("ollama run llama3.1")),
+                                ),
+                        ),
+                )
+                .child(
+                    h_flex()
+                        .w_full()
+                        .pt_2()
+                        .justify_between()
+                        .gap_2()
+                        .child(
+                            h_flex()
+                                .w_full()
+                                .gap_2()
+                                .map(|this| {
+                                    if is_authenticated {
+                                        this.child(
+                                            Button::new("ollama-site", "Ollama")
+                                                .style(ButtonStyle::Subtle)
+                                                .icon(IconName::ExternalLink)
+                                                .icon_size(IconSize::XSmall)
+                                                .icon_color(Color::Muted)
+                                                .on_click(move |_, cx| cx.open_url(OLLAMA_SITE))
+                                                .into_any_element(),
+                                        )
+                                    } else {
+                                        this.child(
+                                            Button::new(
+                                                "download_ollama_button",
+                                                "Download Ollama",
+                                            )
                                             .style(ButtonStyle::Subtle)
                                             .icon(IconName::ExternalLink)
                                             .icon_size(IconSize::XSmall)
                                             .icon_color(Color::Muted)
                                             .on_click(move |_, cx| cx.open_url(OLLAMA_DOWNLOAD_URL))
                                             .into_any_element(),
-                                    )
-                                }
-                            })
-                            .child(
-                                Button::new("view-models", "All Models")
-                                    .style(ButtonStyle::Subtle)
-                                    .icon(IconName::ExternalLink)
-                                    .icon_size(IconSize::XSmall)
-                                    .icon_color(Color::Muted)
-                                    .on_click(move |_, cx| cx.open_url(OLLAMA_LIBRARY_URL)),
-                            ),
-                    )
-                    .child(if is_authenticated {
-                        // This is only a button to ensure the spacing is correct
-                        // it should stay disabled
-                        ButtonLike::new("connected")
-                            .disabled(true)
-                            // Since this won't ever be clickable, we can use the arrow cursor
-                            .cursor_style(gpui::CursorStyle::Arrow)
-                            .child(
-                                h_flex()
-                                    .gap_2()
-                                    .child(Indicator::dot().color(Color::Success))
-                                    .child(Label::new("Connected"))
-                                    .into_any_element(),
-                            )
-                            .into_any_element()
-                    } else {
-                        Button::new("retry_ollama_models", "Connect")
-                            .icon_position(IconPosition::Start)
-                            .icon(IconName::ArrowCircle)
-                            .on_click(cx.listener(move |this, _, cx| this.retry_connection(cx)))
-                            .into_any_element()
-                    }),
-            )
-            .into_any()
+                                        )
+                                    }
+                                })
+                                .child(
+                                    Button::new("view-models", "All Models")
+                                        .style(ButtonStyle::Subtle)
+                                        .icon(IconName::ExternalLink)
+                                        .icon_size(IconSize::XSmall)
+                                        .icon_color(Color::Muted)
+                                        .on_click(move |_, cx| cx.open_url(OLLAMA_LIBRARY_URL)),
+                                ),
+                        )
+                        .child(if is_authenticated {
+                            // This is only a button to ensure the spacing is correct
+                            // it should stay disabled
+                            ButtonLike::new("connected")
+                                .disabled(true)
+                                // Since this won't ever be clickable, we can use the arrow cursor
+                                .cursor_style(gpui::CursorStyle::Arrow)
+                                .child(
+                                    h_flex()
+                                        .gap_2()
+                                        .child(Indicator::dot().color(Color::Success))
+                                        .child(Label::new("Connected"))
+                                        .into_any_element(),
+                                )
+                                .into_any_element()
+                        } else {
+                            Button::new("retry_ollama_models", "Connect")
+                                .icon_position(IconPosition::Start)
+                                .icon(IconName::ArrowCircle)
+                                .on_click(cx.listener(move |this, _, cx| this.retry_connection(cx)))
+                                .into_any_element()
+                        }),
+                )
+                .into_any()
+        }
     }
 }

crates/language_model/src/provider/open_ai.rs 🔗

@@ -3,8 +3,8 @@ use collections::BTreeMap;
 use editor::{Editor, EditorElement, EditorStyle};
 use futures::{future::BoxFuture, FutureExt, StreamExt};
 use gpui::{
-    AnyView, AppContext, AsyncAppContext, FocusHandle, FocusableView, FontStyle, ModelContext,
-    Subscription, Task, TextStyle, View, WhiteSpace,
+    AnyView, AppContext, AsyncAppContext, FontStyle, ModelContext, Subscription, Task, TextStyle,
+    View, WhiteSpace,
 };
 use http_client::HttpClient;
 use open_ai::stream_completion;
@@ -66,6 +66,46 @@ impl State {
             })
         })
     }
+
+    fn set_api_key(&mut self, api_key: String, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        let settings = &AllLanguageModelSettings::get_global(cx).openai;
+        let write_credentials =
+            cx.write_credentials(&settings.api_url, "Bearer", api_key.as_bytes());
+
+        cx.spawn(|this, mut cx| async move {
+            write_credentials.await?;
+            this.update(&mut cx, |this, cx| {
+                this.api_key = Some(api_key);
+                cx.notify();
+            })
+        })
+    }
+
+    fn authenticate(&self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        if self.is_authenticated() {
+            Task::ready(Ok(()))
+        } else {
+            let api_url = AllLanguageModelSettings::get_global(cx)
+                .openai
+                .api_url
+                .clone();
+            cx.spawn(|this, mut cx| async move {
+                let api_key = if let Ok(api_key) = std::env::var("OPENAI_API_KEY") {
+                    api_key
+                } else {
+                    let (_, api_key) = cx
+                        .update(|cx| cx.read_credentials(&api_url))?
+                        .await?
+                        .ok_or_else(|| anyhow!("credentials not found"))?;
+                    String::from_utf8(api_key)?
+                };
+                this.update(&mut cx, |this, cx| {
+                    this.api_key = Some(api_key);
+                    cx.notify();
+                })
+            })
+        }
+    }
 }
 
 impl OpenAiLanguageModelProvider {
@@ -145,36 +185,12 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider {
     }
 
     fn authenticate(&self, cx: &mut AppContext) -> Task<Result<()>> {
-        if self.is_authenticated(cx) {
-            Task::ready(Ok(()))
-        } else {
-            let api_url = AllLanguageModelSettings::get_global(cx)
-                .openai
-                .api_url
-                .clone();
-            let state = self.state.clone();
-            cx.spawn(|mut cx| async move {
-                let api_key = if let Ok(api_key) = std::env::var("OPENAI_API_KEY") {
-                    api_key
-                } else {
-                    let (_, api_key) = cx
-                        .update(|cx| cx.read_credentials(&api_url))?
-                        .await?
-                        .ok_or_else(|| anyhow!("credentials not found"))?;
-                    String::from_utf8(api_key)?
-                };
-                state.update(&mut cx, |this, cx| {
-                    this.api_key = Some(api_key);
-                    cx.notify();
-                })
-            })
-        }
+        self.state.update(cx, |state, cx| state.authenticate(cx))
     }
 
-    fn configuration_view(&self, cx: &mut WindowContext) -> (AnyView, Option<FocusHandle>) {
-        let view = cx.new_view(|cx| ConfigurationView::new(self.state.clone(), cx));
-        let focus_handle = view.focus_handle(cx);
-        (view.into(), Some(focus_handle))
+    fn configuration_view(&self, cx: &mut WindowContext) -> AnyView {
+        cx.new_view(|cx| ConfigurationView::new(self.state.clone(), cx))
+            .into()
     }
 
     fn reset_credentials(&self, cx: &mut AppContext) -> Task<Result<()>> {
@@ -302,33 +318,47 @@ pub fn count_open_ai_tokens(
 }
 
 struct ConfigurationView {
-    focus_handle: FocusHandle,
     api_key_editor: View<Editor>,
     state: gpui::Model<State>,
+    load_credentials_task: Option<Task<()>>,
 }
 
 impl ConfigurationView {
     fn new(state: gpui::Model<State>, cx: &mut ViewContext<Self>) -> Self {
-        let focus_handle = cx.focus_handle();
+        let api_key_editor = cx.new_view(|cx| {
+            let mut editor = Editor::single_line(cx);
+            editor.set_placeholder_text("sk-000000000000000000000000000000000000000000000000", cx);
+            editor
+        });
 
-        cx.on_focus(&focus_handle, |this, cx| {
-            if this.should_render_editor(cx) {
-                this.api_key_editor.read(cx).focus_handle(cx).focus(cx)
-            }
+        cx.observe(&state, |_, _, cx| {
+            cx.notify();
         })
         .detach();
 
+        let load_credentials_task = Some(cx.spawn({
+            let state = state.clone();
+            |this, mut cx| async move {
+                if let Some(task) = state
+                    .update(&mut cx, |state, cx| state.authenticate(cx))
+                    .log_err()
+                {
+                    // We don't log an error, because "not signed in" is also an error.
+                    let _ = task.await;
+                }
+
+                this.update(&mut cx, |this, cx| {
+                    this.load_credentials_task = None;
+                    cx.notify();
+                })
+                .log_err();
+            }
+        }));
+
         Self {
-            api_key_editor: cx.new_view(|cx| {
-                let mut editor = Editor::single_line(cx);
-                editor.set_placeholder_text(
-                    "sk-000000000000000000000000000000000000000000000000",
-                    cx,
-                );
-                editor
-            }),
+            api_key_editor,
             state,
-            focus_handle,
+            load_credentials_task,
         }
     }
 
@@ -338,26 +368,30 @@ impl ConfigurationView {
             return;
         }
 
-        let settings = &AllLanguageModelSettings::get_global(cx).openai;
-        let write_credentials =
-            cx.write_credentials(&settings.api_url, "Bearer", api_key.as_bytes());
         let state = self.state.clone();
         cx.spawn(|_, mut cx| async move {
-            write_credentials.await?;
-            state.update(&mut cx, |this, cx| {
-                this.api_key = Some(api_key);
-                cx.notify();
-            })
+            state
+                .update(&mut cx, |state, cx| state.set_api_key(api_key, cx))?
+                .await
         })
         .detach_and_log_err(cx);
+
+        cx.notify();
     }
 
     fn reset_api_key(&mut self, cx: &mut ViewContext<Self>) {
         self.api_key_editor
             .update(cx, |editor, cx| editor.set_text("", cx));
-        self.state.update(cx, |state, cx| {
-            state.reset_api_key(cx).detach_and_log_err(cx);
+
+        let state = self.state.clone();
+        cx.spawn(|_, mut cx| async move {
+            state
+                .update(&mut cx, |state, cx| state.reset_api_key(cx))?
+                .await
         })
+        .detach_and_log_err(cx);
+
+        cx.notify();
     }
 
     fn render_api_key_editor(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
@@ -392,12 +426,6 @@ impl ConfigurationView {
     }
 }
 
-impl FocusableView for ConfigurationView {
-    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
-        self.focus_handle.clone()
-    }
-}
-
 impl Render for ConfigurationView {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         const INSTRUCTIONS: [&str; 6] = [
@@ -409,10 +437,10 @@ impl Render for ConfigurationView {
             "Paste your OpenAI API key below and hit enter to use the assistant:",
         ];
 
-        if self.should_render_editor(cx) {
+        if self.load_credentials_task.is_some() {
+            div().child(Label::new("Loading credentials...")).into_any()
+        } else if self.should_render_editor(cx) {
             v_flex()
-                .id("openai-configuration-view")
-                .track_focus(&self.focus_handle)
                 .size_full()
                 .on_action(cx.listener(Self::save_api_key))
                 .children(
@@ -437,15 +465,13 @@ impl Render for ConfigurationView {
                 .into_any()
         } else {
             h_flex()
-                .id("openai-configuration-view")
-                .track_focus(&self.focus_handle)
                 .size_full()
                 .justify_between()
                 .child(
                     h_flex()
                         .gap_2()
                         .child(Indicator::dot().color(Color::Success))
-                        .child(Label::new("API Key configured").size(LabelSize::Small)),
+                        .child(Label::new("API key configured").size(LabelSize::Small)),
                 )
                 .child(
                     Button::new("reset-key", "Reset key")

crates/language_model/src/registry.rs 🔗

@@ -166,11 +166,8 @@ impl LanguageModelRegistry {
             .collect()
     }
 
-    pub fn provider(
-        &self,
-        name: &LanguageModelProviderId,
-    ) -> Option<Arc<dyn LanguageModelProvider>> {
-        self.providers.get(name).cloned()
+    pub fn provider(&self, id: &LanguageModelProviderId) -> Option<Arc<dyn LanguageModelProvider>> {
+        self.providers.get(id).cloned()
     }
 
     pub fn select_active_model(