From 301d7fbc6163df5e8404853f7686ee54e4168e85 Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Tue, 16 Dec 2025 18:23:30 -0300
Subject: [PATCH] agent_ui: Add keybinding to cycle through favorited models
(#45032)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
Release Notes:
- agent: Added the ability to cycle through favorited models using the
`alt-tab` keybinding.
---
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 +++++++++++++++++++
.../src/acp/model_selector_popover.rs | 58 +++++++++++++++---
crates/agent_ui/src/acp/thread_view.rs | 11 +++-
crates/agent_ui/src/agent_ui.rs | 2 +
.../agent_ui/src/language_model_selector.rs | 35 +++++++++++
crates/agent_ui/src/text_thread_editor.rs | 54 ++++++++++++++--
.../src/ui/model_selector_components.rs | 8 +--
10 files changed, 220 insertions(+), 19 deletions(-)
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index 5a37614180d46b4a79b97f9a23665cbf5372cc0a..1016a20bd6facdc8f5ef9163ebda3e03d451c5cf 100644
--- a/assets/keymaps/default-linux.json
+++ b/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",
},
},
{
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index 8c8094495e16a9f26adaa380f584abe5e3bc2947..c80edf01a02347cf678fe9cb24390f2fca41d70e 100644
--- a/assets/keymaps/default-macos.json
+++ b/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",
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 74320ae637080da92108f195eabca537e3a71406..dcc828ddf2ef63f3fef6e7e12d9349bead57572e 100644
--- a/assets/keymaps/default-windows.json
+++ b/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",
},
},
{
diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs
index f885ff12e598168abdf7727dc03e4814e5de3b49..cff5334a00472fd6f49abcb17897b4ed3c9f590e 100644
--- a/crates/agent_ui/src/acp/model_selector.rs
+++ b/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>) {
+ 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 = match models {
+ AgentModelList::Flat(list) => list,
+ AgentModelList::Grouped(index_map) => index_map
+ .into_values()
+ .flatten()
+ .collect::>(),
+ };
+
+ let favorite_models = all_models
+ .iter()
+ .filter(|model| favorites.contains(&model.id))
+ .unique_by(|model| &model.id)
+ .cloned()
+ .collect::>();
+
+ 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 {
diff --git a/crates/agent_ui/src/acp/model_selector_popover.rs b/crates/agent_ui/src/acp/model_selector_popover.rs
index e2393c11bd6c23b79397abf274fb6539c0c7063f..d6709081863c9545fba4c6e2304f195e77b013df 100644
--- a/crates/agent_ui/src/acp/model_selector_popover.rs
+++ b/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.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,
)
diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs
index 90134ebfb458a37f01ed99fe7345238c763e5418..05162348db060bff05aa7b1dd223815895f02e2d 100644
--- a/crates/agent_ui/src/acp/thread_view.rs
+++ b/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()
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 1622d17f5852d825b9c8d69996fad7c89bb89dce..c80c7b43644ab949e748609435e33dfe9f31d54e 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/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.
diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs
index 7bb42fb330dcccb4b5401217d0181d3d616fe66f..77c8c95255908dc54639ad7ac6c55f1e8b8151f0 100644
--- a/crates/agent_ui/src/language_model_selector.rs
+++ b/crates/agent_ui/src/language_model_selector.rs
@@ -249,6 +249,41 @@ impl LanguageModelPickerDelegate {
pub fn active_model(&self, cx: &App) -> Option {
(self.get_active_model)(cx)
}
+
+ pub fn cycle_favorite_models(&mut self, window: &mut Window, cx: &mut Context>) {
+ 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 {
diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs
index 881eb213a3886b894a778a34cb6ba129bf42c1a4..947afe050639f89922873a12baa8b1eadfc44995 100644
--- a/crates/agent_ui/src/text_thread_editor.rs
+++ b/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()
diff --git a/crates/agent_ui/src/ui/model_selector_components.rs b/crates/agent_ui/src/ui/model_selector_components.rs
index 184c8e0ba2d3ea307c869e42a13b75f36e713c42..061b4f58288798696b068a091fb392c033906627 100644
--- a/crates/agent_ui/src/ui/model_selector_components.rs
+++ b/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")