agent_ui: Display footer for model selector when in Zed agent (#43294)

Danilo Leal created

This PR adds back the footer with the "Configure" button in the model
selector but only when the seeing it from the Zed agent (or inline
assistant/text threads). I had removed it a while back because seeing
the "Configure" button, which takes you to the agent panel settings
view, when clicking from an external agent didn't make much sense, given
there's nothing model-wise you can configure from Zed (at least yet) for
an external agent.

This also makes the button in the footer a bit nicer by making it full
screen and displaying a keybinding, so that you can easily do the whole
"trigger model selector → go to settings view" all with the keyboard.

<img width="400" height="870" alt="Screenshot 2025-11-21 at 10  38@2x"
src="https://github.com/user-attachments/assets/c14f2acf-b793-4bc1-ac53-8a8a53b219e6"
/>

Release Notes:

- N/A

Change summary

crates/acp_thread/src/connection.rs                              |  5 
crates/agent/src/agent.rs                                        |  4 
crates/agent_ui/src/acp/model_selector.rs                        | 47 +
crates/agent_ui/src/acp/model_selector_popover.rs                | 12 
crates/agent_ui/src/agent_configuration/manage_profiles_modal.rs |  1 
crates/agent_ui/src/agent_model_selector.rs                      |  3 
crates/agent_ui/src/language_model_selector.rs                   | 33 
crates/agent_ui/src/text_thread_editor.rs                        |  3 
8 files changed, 91 insertions(+), 17 deletions(-)

Detailed changes

crates/acp_thread/src/connection.rs 🔗

@@ -197,6 +197,11 @@ pub trait AgentModelSelector: 'static {
     fn watch(&self, _cx: &mut App) -> Option<watch::Receiver<()>> {
         None
     }
+
+    /// Returns whether the model picker should render a footer.
+    fn should_render_footer(&self) -> bool {
+        false
+    }
 }
 
 #[derive(Debug, Clone, PartialEq, Eq)]

crates/agent/src/agent.rs 🔗

@@ -961,6 +961,10 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
     fn watch(&self, cx: &mut App) -> Option<watch::Receiver<()>> {
         Some(self.connection.0.read(cx).models.watch())
     }
+
+    fn should_render_footer(&self) -> bool {
+        true
+    }
 }
 
 impl acp_thread::AgentConnection for NativeAgentConnection {

crates/agent_ui/src/acp/model_selector.rs 🔗

@@ -7,14 +7,17 @@ use collections::IndexMap;
 use fs::Fs;
 use futures::FutureExt;
 use fuzzy::{StringMatchCandidate, match_strings};
-use gpui::{AsyncWindowContext, BackgroundExecutor, DismissEvent, Task, WeakEntity};
+use gpui::{
+    Action, AsyncWindowContext, BackgroundExecutor, DismissEvent, FocusHandle, Task, WeakEntity,
+};
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate};
 use ui::{
-    DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, ListItem,
+    DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, KeyBinding, ListItem,
     ListItemSpacing, prelude::*,
 };
 use util::ResultExt;
+use zed_actions::agent::OpenSettings;
 
 use crate::ui::HoldForDefault;
 
@@ -24,10 +27,12 @@ pub fn acp_model_selector(
     selector: Rc<dyn AgentModelSelector>,
     agent_server: Rc<dyn AgentServer>,
     fs: Arc<dyn Fs>,
+    focus_handle: FocusHandle,
     window: &mut Window,
     cx: &mut Context<AcpModelSelector>,
 ) -> AcpModelSelector {
-    let delegate = AcpModelPickerDelegate::new(selector, agent_server, fs, window, cx);
+    let delegate =
+        AcpModelPickerDelegate::new(selector, agent_server, fs, focus_handle, window, cx);
     Picker::list(delegate, window, cx)
         .show_scrollbar(true)
         .width(rems(20.))
@@ -49,6 +54,7 @@ pub struct AcpModelPickerDelegate {
     selected_description: Option<(usize, SharedString, bool)>,
     selected_model: Option<AgentModelInfo>,
     _refresh_models_task: Task<()>,
+    focus_handle: FocusHandle,
 }
 
 impl AcpModelPickerDelegate {
@@ -56,6 +62,7 @@ impl AcpModelPickerDelegate {
         selector: Rc<dyn AgentModelSelector>,
         agent_server: Rc<dyn AgentServer>,
         fs: Arc<dyn Fs>,
+        focus_handle: FocusHandle,
         window: &mut Window,
         cx: &mut Context<AcpModelSelector>,
     ) -> Self {
@@ -104,6 +111,7 @@ impl AcpModelPickerDelegate {
             selected_index: 0,
             selected_description: None,
             _refresh_models_task: refresh_models_task,
+            focus_handle,
         }
     }
 
@@ -331,6 +339,39 @@ impl PickerDelegate for AcpModelPickerDelegate {
                 )
             })
     }
+
+    fn render_footer(
+        &self,
+        _window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<AnyElement> {
+        let focus_handle = self.focus_handle.clone();
+
+        if !self.selector.should_render_footer() {
+            return None;
+        }
+
+        Some(
+            h_flex()
+                .w_full()
+                .p_1p5()
+                .border_t_1()
+                .border_color(cx.theme().colors().border_variant)
+                .child(
+                    Button::new("configure", "Configure")
+                        .full_width()
+                        .style(ButtonStyle::Outlined)
+                        .key_binding(
+                            KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx)
+                                .map(|kb| kb.size(rems_from_px(12.))),
+                        )
+                        .on_click(|_, window, cx| {
+                            window.dispatch_action(OpenSettings.boxed_clone(), cx);
+                        }),
+                )
+                .into_any(),
+        )
+    }
 }
 
 fn info_list_to_picker_entries(

crates/agent_ui/src/acp/model_selector_popover.rs 🔗

@@ -30,8 +30,18 @@ impl AcpModelSelectorPopover {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
+        let focus_handle_clone = focus_handle.clone();
         Self {
-            selector: cx.new(move |cx| acp_model_selector(selector, agent_server, fs, window, cx)),
+            selector: cx.new(move |cx| {
+                acp_model_selector(
+                    selector,
+                    agent_server,
+                    fs,
+                    focus_handle_clone.clone(),
+                    window,
+                    cx,
+                )
+            }),
             menu_handle,
             focus_handle,
         }

crates/agent_ui/src/agent_model_selector.rs 🔗

@@ -25,6 +25,8 @@ impl AgentModelSelector {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
+        let focus_handle_clone = focus_handle.clone();
+
         Self {
             selector: cx.new(move |cx| {
                 let fs = fs.clone();
@@ -48,6 +50,7 @@ impl AgentModelSelector {
                         }
                     },
                     true, // Use popover styles for picker
+                    focus_handle_clone,
                     window,
                     cx,
                 )

crates/agent_ui/src/language_model_selector.rs 🔗

@@ -2,14 +2,17 @@ use std::{cmp::Reverse, sync::Arc};
 
 use collections::IndexMap;
 use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
-use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task};
+use gpui::{
+    Action, AnyElement, App, BackgroundExecutor, DismissEvent, FocusHandle, Subscription, Task,
+};
 use language_model::{
     AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
     LanguageModelRegistry,
 };
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate};
-use ui::{ListItem, ListItemSpacing, prelude::*};
+use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*};
+use zed_actions::agent::OpenSettings;
 
 type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
 type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
@@ -20,6 +23,7 @@ pub fn language_model_selector(
     get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
     on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
     popover_styles: bool,
+    focus_handle: FocusHandle,
     window: &mut Window,
     cx: &mut Context<LanguageModelSelector>,
 ) -> LanguageModelSelector {
@@ -27,6 +31,7 @@ pub fn language_model_selector(
         get_active_model,
         on_model_changed,
         popover_styles,
+        focus_handle,
         window,
         cx,
     );
@@ -88,6 +93,7 @@ pub struct LanguageModelPickerDelegate {
     _authenticate_all_providers_task: Task<()>,
     _subscriptions: Vec<Subscription>,
     popover_styles: bool,
+    focus_handle: FocusHandle,
 }
 
 impl LanguageModelPickerDelegate {
@@ -95,6 +101,7 @@ impl LanguageModelPickerDelegate {
         get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
         on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
         popover_styles: bool,
+        focus_handle: FocusHandle,
         window: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) -> Self {
@@ -128,6 +135,7 @@ impl LanguageModelPickerDelegate {
                 },
             )],
             popover_styles,
+            focus_handle,
         }
     }
 
@@ -521,6 +529,8 @@ impl PickerDelegate for LanguageModelPickerDelegate {
         _window: &mut Window,
         cx: &mut Context<Picker<Self>>,
     ) -> Option<gpui::AnyElement> {
+        let focus_handle = self.focus_handle.clone();
+
         if !self.popover_styles {
             return None;
         }
@@ -528,22 +538,19 @@ impl PickerDelegate for LanguageModelPickerDelegate {
         Some(
             h_flex()
                 .w_full()
+                .p_1p5()
                 .border_t_1()
                 .border_color(cx.theme().colors().border_variant)
-                .p_1()
-                .gap_4()
-                .justify_between()
                 .child(
                     Button::new("configure", "Configure")
-                        .icon(IconName::Settings)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted)
-                        .icon_position(IconPosition::Start)
+                        .full_width()
+                        .style(ButtonStyle::Outlined)
+                        .key_binding(
+                            KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx)
+                                .map(|kb| kb.size(rems_from_px(12.))),
+                        )
                         .on_click(|_, window, cx| {
-                            window.dispatch_action(
-                                zed_actions::agent::OpenSettings.boxed_clone(),
-                                cx,
-                            );
+                            window.dispatch_action(OpenSettings.boxed_clone(), cx);
                         }),
                 )
                 .into_any(),

crates/agent_ui/src/text_thread_editor.rs 🔗

@@ -280,6 +280,8 @@ impl TextThreadEditor {
             .thought_process_output_sections()
             .to_vec();
         let slash_commands = text_thread.read(cx).slash_commands().clone();
+        let focus_handle = editor.read(cx).focus_handle(cx);
+
         let mut this = Self {
             text_thread,
             slash_commands,
@@ -315,6 +317,7 @@ impl TextThreadEditor {
                         });
                     },
                     true, // Use popover styles for picker
+                    focus_handle,
                     window,
                     cx,
                 )