From a27304bf9177914a0cb68179b5ce4d8469fc1502 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:42:07 -0300 Subject: [PATCH] agent_ui: Refine thinking effort selector (#48790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Screenshot 2026-02-09 at 10  18@2x --- - [x] Code Reviewed - [x] Manual QA Release Notes: - N/A --- 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 +- .../src/acp/thread_view/active_thread.rs | 177 +++++++++++++++--- crates/agent_ui/src/agent_ui.rs | 4 + crates/agent_ui/src/profile_selector.rs | 2 +- .../ui/src/components/button/split_button.rs | 13 +- 9 files changed, 171 insertions(+), 42 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index feb27bdcb017af2f8606624a863e8f6db483c41f..d877cfb41206087f555e47e01dccffeb9357b2c8 100644 --- a/assets/keymaps/default-linux.json +++ b/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", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 74a3d23c7d064b747a559eb6c0fa94430b54c504..19ce418bf13ef28e37149b3fc9dc3644f0fc782d 100644 --- a/assets/keymaps/default-macos.json +++ b/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", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 9ed8dc2e85f9c3b924ab6ad8d38723ea8185729d..5e0cbdc8afabf0c4e9901a256f44d653e294b02c 100644 --- a/assets/keymaps/default-windows.json +++ b/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", }, }, { diff --git a/crates/agent_ui/src/acp/mode_selector.rs b/crates/agent_ui/src/acp/mode_selector.rs index 15e2a3b768c74d4a56d906fada5fd0e992ffc8e3..1dbc05dddba849821448f74a6f98f5a72c67d684 100644 --- a/crates/agent_ui/src/acp/mode_selector.rs +++ b/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, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 61c7301fb1e9d9b0fcc2b05144393ffea4248d9b..3dfdeccf7ec9f03b84752101502c708ecb08ac88 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/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); diff --git a/crates/agent_ui/src/acp/thread_view/active_thread.rs b/crates/agent_ui/src/acp/thread_view/active_thread.rs index de3b9038c715a5141023b65f7f973350ca65ab24..b329cae4ed4821ebfbe6678b121bcba4e83feabe 100644 --- a/crates/agent_ui/src/acp/thread_view/active_thread.rs +++ b/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, pub message_editor: Entity, pub add_context_menu_handle: PopoverMenuHandle, + pub thinking_effort_menu_handle: PopoverMenuHandle, pub project: WeakEntity, pub recent_history_entries: Vec, pub hovered_recent_history_item: Option, @@ -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) -> 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) { + if !cx.has_flag::() { + 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, + ) { + 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); })) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 3bfd58ff4bb2defb8e3d7c11a52fed4575f7b747..d2beb4b7199bf1536985bed49958b0643703bce7 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/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, ] ); diff --git a/crates/agent_ui/src/profile_selector.rs b/crates/agent_ui/src/profile_selector.rs index 2742ae834b186e876aefb007e387afce5197329f..6cf4b6fb3d3017449ef07c9cbe7fb68176eb7ba5 100644 --- a/crates/agent_ui/src/profile_selector.rs +++ b/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( diff --git a/crates/ui/src/components/button/split_button.rs b/crates/ui/src/components/button/split_button.rs index 48f06ff3789e69b6d19cde2322932f4bd6e89f97..e6821e949009fe596c95652d2207be07e6eea2ae 100644 --- a/crates/ui/src/components/button/split_button.rs +++ b/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)), )