agent_ui: Refine thinking effort selector (#48790)

Danilo Leal created

This PR adds some design refinements to the thinking effort selector (to
be generally rolled out soon):
- Improved split button UI styles and consistency with other dropdowns
in the message editor
- Stopped rendering the effort selector if thinking is turned off
- Added a keybinding to trigger the effort menu
- Added a keybinding to cycle through effort options

<img width="500" height="380" alt="Screenshot 2026-02-09 at 10  18@2x"
src="https://github.com/user-attachments/assets/48d5b65c-7256-4ceb-aab2-35869f8fbf4a"
/>

---

- [x] Code Reviewed
- [x] Manual QA

Release Notes:

- N/A

Change summary

assets/keymaps/default-linux.json                    |   2 
assets/keymaps/default-macos.json                    |   2 
assets/keymaps/default-windows.json                  |   2 
crates/agent_ui/src/acp/mode_selector.rs             |   2 
crates/agent_ui/src/acp/thread_view.rs               |   9 
crates/agent_ui/src/acp/thread_view/active_thread.rs | 177 +++++++++++--
crates/agent_ui/src/agent_ui.rs                      |   4 
crates/agent_ui/src/profile_selector.rs              |   2 
crates/ui/src/components/button/split_button.rs      |  13 
9 files changed, 171 insertions(+), 42 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -322,6 +322,8 @@
       "alt-l": "agent::CycleFavoriteModels",
       "ctrl-;": "agent::OpenAddContextMenu",
       "ctrl-alt-k": "agent::ToggleThinkingMode",
+      "ctrl-alt-'": "agent::ToggleThinkingEffortMenu",
+      "ctrl-'": "agent::CycleThinkingEffort",
     },
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -366,6 +366,8 @@
       "alt-tab": "agent::CycleFavoriteModels",
       "ctrl-;": "agent::OpenAddContextMenu",
       "cmd-alt-k": "agent::ToggleThinkingMode",
+      "cmd-alt-'": "agent::ToggleThinkingEffortMenu",
+      "ctrl-'": "agent::CycleThinkingEffort",
     },
   },
   {

assets/keymaps/default-windows.json 🔗

@@ -324,6 +324,8 @@
       "alt-l": "agent::CycleFavoriteModels",
       "ctrl-;": "agent::OpenAddContextMenu",
       "ctrl-alt-k": "agent::ToggleThinkingMode",
+      "ctrl-alt-'": "agent::ToggleThinkingEffortMenu",
+      "ctrl-'": "agent::CycleThinkingEffort",
     },
   },
   {

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

@@ -190,7 +190,7 @@ impl Render for ModeSelector {
                                 h_flex()
                                     .gap_2()
                                     .justify_between()
-                                    .child(Label::new("Toggle Mode Menu"))
+                                    .child(Label::new("Change Mode"))
                                     .child(KeyBinding::for_action_in(
                                         &ToggleProfileSelector,
                                         &focus_handle,

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

@@ -75,10 +75,11 @@ use crate::profile_selector::{ProfileProvider, ProfileSelector};
 use crate::ui::{AgentNotification, AgentNotificationEvent};
 use crate::{
     AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, ClearMessageQueue,
-    CycleFavoriteModels, CycleModeSelector, EditFirstQueuedMessage, ExpandMessageEditor,
-    ExternalAgentInitialContent, Follow, KeepAll, NewThread, OpenAddContextMenu, OpenAgentDiff,
-    OpenHistory, RejectAll, RejectOnce, RemoveFirstQueuedMessage, SelectPermissionGranularity,
-    SendImmediately, SendNextQueuedMessage, ToggleProfileSelector, ToggleThinkingMode,
+    CycleFavoriteModels, CycleModeSelector, CycleThinkingEffort, EditFirstQueuedMessage,
+    ExpandMessageEditor, ExternalAgentInitialContent, Follow, KeepAll, NewThread,
+    OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce,
+    RemoveFirstQueuedMessage, SelectPermissionGranularity, SendImmediately, SendNextQueuedMessage,
+    ToggleProfileSelector, ToggleThinkingEffortMenu, ToggleThinkingMode,
 };
 
 const STOPWATCH_THRESHOLD: Duration = Duration::from_secs(30);

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

@@ -1,7 +1,7 @@
 use gpui::{Corner, List};
 use language_model::LanguageModelEffortLevel;
 use settings::update_settings_file;
-use ui::SplitButton;
+use ui::{ButtonLike, SplitButton, SplitButtonStyle};
 
 use super::*;
 
@@ -224,6 +224,7 @@ pub struct AcpThreadView {
     pub _subscriptions: Vec<Subscription>,
     pub message_editor: Entity<MessageEditor>,
     pub add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
+    pub thinking_effort_menu_handle: PopoverMenuHandle<ContextMenu>,
     pub project: WeakEntity<Project>,
     pub recent_history_entries: Vec<AgentSessionInfo>,
     pub hovered_recent_history_item: Option<usize>,
@@ -390,6 +391,7 @@ impl AcpThreadView {
             in_flight_prompt: None,
             message_editor,
             add_context_menu_handle: PopoverMenuHandle::default(),
+            thinking_effort_menu_handle: PopoverMenuHandle::default(),
             project,
             recent_history_entries,
             hovered_recent_history_item: None,
@@ -2747,18 +2749,21 @@ impl AcpThreadView {
             return Some(thinking_toggle.into_any_element());
         }
 
+        if !model.supported_effort_levels().is_empty() && !thinking {
+            return Some(thinking_toggle.into_any_element());
+        }
+
+        let left_btn = thinking_toggle;
+        let right_btn = self.render_effort_selector(
+            model.supported_effort_levels(),
+            thread.thinking_effort().cloned(),
+            cx,
+        );
+
         Some(
-            SplitButton::new(
-                thinking_toggle,
-                self.render_effort_selector(
-                    model.supported_effort_levels(),
-                    thread.thinking_effort().cloned(),
-                    cx,
-                )
+            SplitButton::new(left_btn, right_btn.into_any_element())
+                .style(SplitButtonStyle::Transparent)
                 .into_any_element(),
-            )
-            .style(ui::SplitButtonStyle::Outlined)
-            .into_any_element(),
         )
     }
 
@@ -2782,28 +2787,67 @@ impl AcpThreadView {
                 .cloned()
         });
 
+        let label = selected
+            .clone()
+            .or(default_effort_level)
+            .map_or("Select Effort".into(), |effort| effort.name);
+
+        let (label_color, icon) = if self.thinking_effort_menu_handle.is_deployed() {
+            (Color::Accent, IconName::ChevronUp)
+        } else {
+            (Color::Muted, IconName::ChevronDown)
+        };
+
+        let focus_handle = self.message_editor.focus_handle(cx);
+        let show_cycle_row = supported_effort_levels.len() > 1;
+
+        let tooltip = Tooltip::element({
+            move |_, cx| {
+                let mut content = v_flex().gap_1().child(
+                    h_flex()
+                        .gap_2()
+                        .justify_between()
+                        .child(Label::new("Change Thinking Effort"))
+                        .child(KeyBinding::for_action_in(
+                            &ToggleThinkingEffortMenu,
+                            &focus_handle,
+                            cx,
+                        )),
+                );
+
+                if show_cycle_row {
+                    content = content.child(
+                        h_flex()
+                            .pt_1()
+                            .gap_2()
+                            .justify_between()
+                            .border_t_1()
+                            .border_color(cx.theme().colors().border_variant)
+                            .child(Label::new("Cycle Thinking Effort"))
+                            .child(KeyBinding::for_action_in(
+                                &CycleThinkingEffort,
+                                &focus_handle,
+                                cx,
+                            )),
+                    );
+                }
+
+                content.into_any_element()
+            }
+        });
+
         PopoverMenu::new("effort-selector")
-            .trigger(
-                ui::ButtonLike::new_rounded_right("effort-selector-trigger")
-                    .layer(ui::ElevationIndex::ModalSurface)
-                    .size(ui::ButtonSize::None)
-                    .child(
-                        Label::new(
-                            selected
-                                .clone()
-                                .or(default_effort_level)
-                                .map_or("Select Effort".into(), |effort| effort.name),
-                        )
-                        .size(LabelSize::Small),
-                    )
-                    .child(
-                        div()
-                            .px_1()
-                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
-                    ),
+            .trigger_with_tooltip(
+                ButtonLike::new_rounded_right("effort-selector-trigger")
+                    .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                    .child(Label::new(label).size(LabelSize::Small).color(label_color))
+                    .child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)),
+                tooltip,
             )
             .menu(move |window, cx| {
                 Some(ContextMenu::build(window, cx, |mut menu, _window, _cx| {
+                    menu = menu.header("Change Thinking Effort");
+
                     for effort_level in supported_effort_levels.clone() {
                         let is_selected = selected
                             .as_ref()
@@ -2846,7 +2890,12 @@ impl AcpThreadView {
                     menu
                 }))
             })
-            .anchor(Corner::BottomRight)
+            .with_handle(self.thinking_effort_menu_handle.clone())
+            .offset(gpui::Point {
+                x: px(0.0),
+                y: px(-2.0),
+            })
+            .anchor(Corner::BottomLeft)
     }
 
     fn render_send_button(&self, cx: &mut Context<Self>) -> AnyElement {
@@ -2945,7 +2994,7 @@ impl AcpThreadView {
                     }
                 },
             )
-            .anchor(gpui::Corner::BottomLeft)
+            .anchor(Corner::BottomLeft)
             .with_handle(self.add_context_menu_handle.clone())
             .offset(gpui::Point {
                 x: px(0.0),
@@ -6895,6 +6944,68 @@ impl AcpThreadView {
             menu_handle.toggle(window, cx);
         });
     }
+
+    fn cycle_thinking_effort(&mut self, cx: &mut Context<Self>) {
+        if !cx.has_flag::<CloudThinkingEffortFeatureFlag>() {
+            return;
+        }
+
+        let Some(thread) = self.as_native_thread(cx) else {
+            return;
+        };
+
+        let (effort_levels, current_effort) = {
+            let thread_ref = thread.read(cx);
+            let Some(model) = thread_ref.model() else {
+                return;
+            };
+            if !model.supports_thinking() || !thread_ref.thinking_enabled() {
+                return;
+            }
+            let effort_levels = model.supported_effort_levels();
+            if effort_levels.is_empty() {
+                return;
+            }
+            let current_effort = thread_ref.thinking_effort().cloned();
+            (effort_levels, current_effort)
+        };
+
+        let current_index = current_effort.and_then(|current| {
+            effort_levels
+                .iter()
+                .position(|level| level.value == current)
+        });
+        let next_index = match current_index {
+            Some(index) => (index + 1) % effort_levels.len(),
+            None => 0,
+        };
+        let next_effort = effort_levels[next_index].value.to_string();
+
+        thread.update(cx, |thread, cx| {
+            thread.set_thinking_effort(Some(next_effort.clone()), cx);
+
+            let fs = thread.project().read(cx).fs().clone();
+            update_settings_file(fs, cx, move |settings, _| {
+                if let Some(agent) = settings.agent.as_mut()
+                    && let Some(default_model) = agent.default_model.as_mut()
+                {
+                    default_model.effort = Some(next_effort);
+                }
+            });
+        });
+    }
+
+    fn toggle_thinking_effort_menu(
+        &mut self,
+        _action: &ToggleThinkingEffortMenu,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let menu_handle = self.thinking_effort_menu_handle.clone();
+        window.defer(cx, move |window, cx| {
+            menu_handle.toggle(window, cx);
+        });
+    }
 }
 
 impl Render for AcpThreadView {
@@ -6936,6 +7047,10 @@ impl Render for AcpThreadView {
                     });
                 }
             }))
+            .on_action(cx.listener(|this, _: &CycleThinkingEffort, _window, cx| {
+                this.cycle_thinking_effort(cx);
+            }))
+            .on_action(cx.listener(Self::toggle_thinking_effort_menu))
             .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| {
                 this.send_queued_message_at_index(0, true, window, cx);
             }))

crates/agent_ui/src/agent_ui.rs 🔗

@@ -143,6 +143,10 @@ actions!(
         OpenPermissionDropdown,
         /// Toggles thinking mode for models that support extended thinking.
         ToggleThinkingMode,
+        /// Cycles through available thinking effort levels for the current model.
+        CycleThinkingEffort,
+        /// Toggles the thinking effort selector menu open or closed.
+        ToggleThinkingEffortMenu,
     ]
 );
 

crates/agent_ui/src/profile_selector.rs 🔗

@@ -192,7 +192,7 @@ impl Render for ProfileSelector {
                     let container = || h_flex().gap_1().justify_between();
                     v_flex()
                         .gap_1()
-                        .child(container().child(Label::new("Toggle Profile Menu")).child(
+                        .child(container().child(Label::new("Change Profile")).child(
                             KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx),
                         ))
                         .child(

crates/ui/src/components/button/split_button.rs 🔗

@@ -1,10 +1,10 @@
 use gpui::{
     AnyElement, App, BoxShadow, IntoElement, ParentElement, RenderOnce, Styled, Window, div, hsla,
-    point, prelude::FluentBuilder, px,
+    point, prelude::FluentBuilder, px, relative,
 };
 use theme::ActiveTheme;
 
-use crate::{ElevationIndex, IconButton, h_flex};
+use crate::{ElevationIndex, prelude::*};
 
 use super::ButtonLike;
 
@@ -68,18 +68,21 @@ impl RenderOnce for SplitButton {
         );
 
         h_flex()
-            .rounded_sm()
             .when(is_filled_or_outlined, |this| {
-                this.border_1()
+                this.rounded_sm()
+                    .border_1()
                     .border_color(cx.theme().colors().border.opacity(0.8))
             })
+            .when(self.style == SplitButtonStyle::Transparent, |this| {
+                this.gap_px()
+            })
             .child(div().flex_grow().child(match self.left {
                 SplitButtonKind::ButtonLike(button) => button.into_any_element(),
                 SplitButtonKind::IconButton(icon) => icon.into_any_element(),
             }))
             .child(
                 div()
-                    .h_full()
+                    .h(relative(0.8))
                     .w_px()
                     .bg(cx.theme().colors().border.opacity(0.5)),
             )