agent_ui: Add keybinding to cycle through favorited models (#45032)

Danilo Leal created

Similar to how you can use `shift-tab` to cycle through profiles/modes,
you can now use `alt-tab` to cycle through the language models you have
favorited.

<img width="500" height="312" alt="Screenshot 2025-12-16 at 5  23@2x"
src="https://github.com/user-attachments/assets/006d417d-5da1-48f9-82cc-ea06e28adb30"
/>

Release Notes:

- agent: Added the ability to cycle through favorited models using the
`alt-tab` keybinding.

Change summary

assets/keymaps/default-linux.json                   |  2 
assets/keymaps/default-macos.json                   |  5 +
assets/keymaps/default-windows.json                 |  3 
crates/agent_ui/src/acp/model_selector.rs           | 61 +++++++++++++++
crates/agent_ui/src/acp/model_selector_popover.rs   | 58 ++++++++++++-
crates/agent_ui/src/acp/thread_view.rs              | 11 ++
crates/agent_ui/src/agent_ui.rs                     |  2 
crates/agent_ui/src/language_model_selector.rs      | 35 ++++++++
crates/agent_ui/src/text_thread_editor.rs           | 54 ++++++++++++
crates/agent_ui/src/ui/model_selector_components.rs |  8 -
10 files changed, 220 insertions(+), 19 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -252,6 +252,7 @@
       "ctrl-y": "agent::AllowOnce",
       "ctrl-alt-y": "agent::AllowAlways",
       "ctrl-alt-z": "agent::RejectOnce",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -346,6 +347,7 @@
       "ctrl-shift-y": "agent::KeepAll",
       "ctrl-shift-n": "agent::RejectAll",
       "shift-tab": "agent::CycleModeSelector",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -266,6 +266,7 @@
       "cmd-g": "search::SelectNextMatch",
       "cmd-shift-g": "search::SelectPreviousMatch",
       "cmd-k l": "agent::OpenRulesLibrary",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -292,6 +293,7 @@
       "cmd-y": "agent::AllowOnce",
       "cmd-alt-y": "agent::AllowAlways",
       "cmd-alt-z": "agent::RejectOnce",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -386,6 +388,7 @@
       "cmd-shift-y": "agent::KeepAll",
       "cmd-shift-n": "agent::RejectAll",
       "shift-tab": "agent::CycleModeSelector",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -397,6 +400,7 @@
       "cmd-shift-y": "agent::KeepAll",
       "cmd-shift-n": "agent::RejectAll",
       "shift-tab": "agent::CycleModeSelector",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -880,6 +884,7 @@
     "use_key_equivalents": true,
     "bindings": {
       "cmd-alt-/": "agent::ToggleModelSelector",
+      "alt-tab": "agent::CycleFavoriteModels",
       "ctrl-[": "agent::CyclePreviousInlineAssist",
       "ctrl-]": "agent::CycleNextInlineAssist",
       "cmd-shift-enter": "inline_assistant::ThumbsUpResult",

assets/keymaps/default-windows.json 🔗

@@ -253,6 +253,7 @@
       "shift-alt-a": "agent::AllowOnce",
       "ctrl-alt-y": "agent::AllowAlways",
       "shift-alt-z": "agent::RejectOnce",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -342,6 +343,7 @@
       "ctrl-shift-y": "agent::KeepAll",
       "ctrl-shift-n": "agent::RejectAll",
       "shift-tab": "agent::CycleModeSelector",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {
@@ -353,6 +355,7 @@
       "ctrl-shift-y": "agent::KeepAll",
       "ctrl-shift-n": "agent::RejectAll",
       "shift-tab": "agent::CycleModeSelector",
+      "alt-tab": "agent::CycleFavoriteModels",
     },
   },
   {

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

@@ -119,6 +119,67 @@ impl AcpModelPickerDelegate {
     pub fn active_model(&self) -> Option<&AgentModelInfo> {
         self.selected_model.as_ref()
     }
+
+    pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        if !self.selector.supports_favorites() {
+            return;
+        }
+
+        let favorites = AgentSettings::get_global(cx).favorite_model_ids();
+
+        if favorites.is_empty() {
+            return;
+        }
+
+        let Some(models) = self.models.clone() else {
+            return;
+        };
+
+        let all_models: Vec<AgentModelInfo> = match models {
+            AgentModelList::Flat(list) => list,
+            AgentModelList::Grouped(index_map) => index_map
+                .into_values()
+                .flatten()
+                .collect::<Vec<AgentModelInfo>>(),
+        };
+
+        let favorite_models = all_models
+            .iter()
+            .filter(|model| favorites.contains(&model.id))
+            .unique_by(|model| &model.id)
+            .cloned()
+            .collect::<Vec<_>>();
+
+        let current_id = self.selected_model.as_ref().map(|m| m.id.clone());
+
+        let current_index_in_favorites = current_id
+            .as_ref()
+            .and_then(|id| favorite_models.iter().position(|m| &m.id == id))
+            .unwrap_or(usize::MAX);
+
+        let next_index = if current_index_in_favorites == usize::MAX {
+            0
+        } else {
+            (current_index_in_favorites + 1) % favorite_models.len()
+        };
+
+        let next_model = favorite_models[next_index].clone();
+
+        self.selector
+            .select_model(next_model.id.clone(), cx)
+            .detach_and_log_err(cx);
+
+        self.selected_model = Some(next_model);
+
+        // Keep the picker selection aligned with the newly-selected model
+        if let Some(new_index) = self.filtered_entries.iter().position(|entry| {
+            matches!(entry, AcpModelPickerEntry::Model(model_info, _) if self.selected_model.as_ref().is_some_and(|selected| model_info.id == selected.id))
+        }) {
+            self.set_selected_index(new_index, window, cx);
+        } else {
+            cx.notify();
+        }
+    }
 }
 
 impl PickerDelegate for AcpModelPickerDelegate {

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

@@ -3,15 +3,15 @@ use std::sync::Arc;
 
 use acp_thread::{AgentModelInfo, AgentModelSelector};
 use agent_servers::AgentServer;
+use agent_settings::AgentSettings;
 use fs::Fs;
 use gpui::{Entity, FocusHandle};
 use picker::popover_menu::PickerPopoverMenu;
-use ui::{
-    ButtonLike, Context, IntoElement, PopoverMenuHandle, SharedString, TintColor, Tooltip, Window,
-    prelude::*,
-};
+use settings::Settings as _;
+use ui::{ButtonLike, KeyBinding, PopoverMenuHandle, TintColor, Tooltip, prelude::*};
 use zed_actions::agent::ToggleModelSelector;
 
+use crate::CycleFavoriteModels;
 use crate::acp::{AcpModelSelector, model_selector::acp_model_selector};
 
 pub struct AcpModelSelectorPopover {
@@ -54,6 +54,12 @@ impl AcpModelSelectorPopover {
     pub fn active_model<'a>(&self, cx: &'a App) -> Option<&'a AgentModelInfo> {
         self.selector.read(cx).delegate.active_model()
     }
+
+    pub fn cycle_favorite_models(&self, window: &mut Window, cx: &mut Context<Self>) {
+        self.selector.update(cx, |selector, cx| {
+            selector.delegate.cycle_favorite_models(window, cx);
+        });
+    }
 }
 
 impl Render for AcpModelSelectorPopover {
@@ -74,6 +80,46 @@ impl Render for AcpModelSelectorPopover {
             (Color::Muted, IconName::ChevronDown)
         };
 
+        let tooltip = Tooltip::element({
+            move |_, cx| {
+                let focus_handle = focus_handle.clone();
+                let should_show_cycle_row = !AgentSettings::get_global(cx)
+                    .favorite_model_ids()
+                    .is_empty();
+
+                v_flex()
+                    .gap_1()
+                    .child(
+                        h_flex()
+                            .gap_2()
+                            .justify_between()
+                            .child(Label::new("Change Model"))
+                            .child(KeyBinding::for_action_in(
+                                &ToggleModelSelector,
+                                &focus_handle,
+                                cx,
+                            )),
+                    )
+                    .when(should_show_cycle_row, |this| {
+                        this.child(
+                            h_flex()
+                                .pt_1()
+                                .gap_2()
+                                .border_t_1()
+                                .border_color(cx.theme().colors().border_variant)
+                                .justify_between()
+                                .child(Label::new("Cycle Favorited Models"))
+                                .child(KeyBinding::for_action_in(
+                                    &CycleFavoriteModels,
+                                    &focus_handle,
+                                    cx,
+                                )),
+                        )
+                    })
+                    .into_any()
+            }
+        });
+
         PickerPopoverMenu::new(
             self.selector.clone(),
             ButtonLike::new("active-model")
@@ -88,9 +134,7 @@ impl Render for AcpModelSelectorPopover {
                         .ml_0p5(),
                 )
                 .child(Icon::new(icon).color(Color::Muted).size(IconSize::XSmall)),
-            move |_window, cx| {
-                Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
-            },
+            tooltip,
             gpui::Corner::BottomRight,
             cx,
         )

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

@@ -66,8 +66,8 @@ use crate::profile_selector::{ProfileProvider, ProfileSelector};
 use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip, UsageCallout};
 use crate::{
     AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode,
-    CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAgentDiff, OpenHistory,
-    RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector,
+    CycleFavoriteModels, CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread,
+    OpenAgentDiff, OpenHistory, RejectAll, RejectOnce, ToggleBurnMode, ToggleProfileSelector,
 };
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
@@ -4293,6 +4293,13 @@ impl AcpThreadView {
                         .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
                 }
             }))
+            .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
+                if let Some(model_selector) = this.model_selector.as_ref() {
+                    model_selector.update(cx, |model_selector, cx| {
+                        model_selector.cycle_favorite_models(window, cx);
+                    });
+                }
+            }))
             .p_2()
             .gap_2()
             .border_t_1()

crates/agent_ui/src/agent_ui.rs 🔗

@@ -68,6 +68,8 @@ actions!(
         ToggleProfileSelector,
         /// Cycles through available session modes.
         CycleModeSelector,
+        /// Cycles through favorited models in the ACP model selector.
+        CycleFavoriteModels,
         /// Expands the message editor to full size.
         ExpandMessageEditor,
         /// Removes all thread history.

crates/agent_ui/src/language_model_selector.rs 🔗

@@ -249,6 +249,41 @@ impl LanguageModelPickerDelegate {
     pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
         (self.get_active_model)(cx)
     }
+
+    pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        if self.all_models.favorites.is_empty() {
+            return;
+        }
+
+        let active_model = (self.get_active_model)(cx);
+        let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
+        let active_model_id = active_model.as_ref().map(|m| m.model.id());
+
+        let current_index = self
+            .all_models
+            .favorites
+            .iter()
+            .position(|info| {
+                Some(info.model.provider_id()) == active_provider_id
+                    && Some(info.model.id()) == active_model_id
+            })
+            .unwrap_or(usize::MAX);
+
+        let next_index = if current_index == usize::MAX {
+            0
+        } else {
+            (current_index + 1) % self.all_models.favorites.len()
+        };
+
+        let next_model = self.all_models.favorites[next_index].model.clone();
+
+        (self.on_model_changed)(next_model, cx);
+
+        // Align the picker selection with the newly-active model
+        let new_index =
+            Self::get_active_model_index(&self.filtered_entries, (self.get_active_model)(cx));
+        self.set_selected_index(new_index, window, cx);
+    }
 }
 
 struct GroupedModels {

crates/agent_ui/src/text_thread_editor.rs 🔗

@@ -2,7 +2,7 @@ use crate::{
     language_model_selector::{LanguageModelSelector, language_model_selector},
     ui::BurnModeTooltip,
 };
-use agent_settings::CompletionMode;
+use agent_settings::{AgentSettings, CompletionMode};
 use anyhow::Result;
 use assistant_slash_command::{SlashCommand, SlashCommandOutputSection, SlashCommandWorkingSet};
 use assistant_slash_commands::{DefaultSlashCommand, FileSlashCommand, selections_creases};
@@ -73,6 +73,8 @@ use workspace::{
 };
 use zed_actions::agent::{AddSelectionToThread, ToggleModelSelector};
 
+use crate::CycleFavoriteModels;
+
 use crate::{slash_command::SlashCommandCompletionProvider, slash_command_picker};
 use assistant_text_thread::{
     CacheStatus, Content, InvokedSlashCommandId, InvokedSlashCommandStatus, Message, MessageId,
@@ -2209,12 +2211,53 @@ impl TextThreadEditor {
         };
 
         let focus_handle = self.editor().focus_handle(cx);
+
         let (color, icon) = if self.language_model_selector_menu_handle.is_deployed() {
             (Color::Accent, IconName::ChevronUp)
         } else {
             (Color::Muted, IconName::ChevronDown)
         };
 
+        let tooltip = Tooltip::element({
+            move |_, cx| {
+                let focus_handle = focus_handle.clone();
+                let should_show_cycle_row = !AgentSettings::get_global(cx)
+                    .favorite_model_ids()
+                    .is_empty();
+
+                v_flex()
+                    .gap_1()
+                    .child(
+                        h_flex()
+                            .gap_2()
+                            .justify_between()
+                            .child(Label::new("Change Model"))
+                            .child(KeyBinding::for_action_in(
+                                &ToggleModelSelector,
+                                &focus_handle,
+                                cx,
+                            )),
+                    )
+                    .when(should_show_cycle_row, |this| {
+                        this.child(
+                            h_flex()
+                                .pt_1()
+                                .gap_2()
+                                .border_t_1()
+                                .border_color(cx.theme().colors().border_variant)
+                                .justify_between()
+                                .child(Label::new("Cycle Favorited Models"))
+                                .child(KeyBinding::for_action_in(
+                                    &CycleFavoriteModels,
+                                    &focus_handle,
+                                    cx,
+                                )),
+                        )
+                    })
+                    .into_any()
+            }
+        });
+
         PickerPopoverMenu::new(
             self.language_model_selector.clone(),
             ButtonLike::new("active-model")
@@ -2231,9 +2274,7 @@ impl TextThreadEditor {
                         )
                         .child(Icon::new(icon).color(color).size(IconSize::XSmall)),
                 ),
-            move |_window, cx| {
-                Tooltip::for_action_in("Change Model", &ToggleModelSelector, &focus_handle, cx)
-            },
+            tooltip,
             gpui::Corner::BottomRight,
             cx,
         )
@@ -2593,6 +2634,11 @@ impl Render for TextThreadEditor {
             .on_action(move |_: &ToggleModelSelector, window, cx| {
                 language_model_selector.toggle(window, cx);
             })
+            .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
+                this.language_model_selector.update(cx, |selector, cx| {
+                    selector.delegate.cycle_favorite_models(window, cx);
+                });
+            }))
             .size_full()
             .child(
                 div()

crates/agent_ui/src/ui/model_selector_components.rs 🔗

@@ -113,13 +113,9 @@ impl RenderOnce for ModelSelectorListItem {
                     .child(Label::new(self.title).truncate()),
             )
             .end_slot(div().pr_2().when(self.is_selected, |this| {
-                this.child(
-                    Icon::new(IconName::Check)
-                        .color(Color::Accent)
-                        .size(IconSize::Small),
-                )
+                this.child(Icon::new(IconName::Check).color(Color::Accent))
             }))
-            .end_hover_slot(div().pr_2().when_some(self.on_toggle_favorite, {
+            .end_hover_slot(div().pr_1p5().when_some(self.on_toggle_favorite, {
                 |this, handle_click| {
                     let (icon, color, tooltip) = if is_favorite {
                         (IconName::StarFilled, Color::Accent, "Unfavorite Model")