acp: Add keybindings for session config option categories (#46484)

Ben Brandt created

Adds the normal mode/model keybindings for the first config option of a
given category

Release Notes:

- N/A

Change summary

crates/agent_ui/src/acp/config_options.rs | 117 ++++++++++++++++++++++++
crates/agent_ui/src/acp/thread_view.rs    |  52 +++++++++++
2 files changed, 168 insertions(+), 1 deletion(-)

Detailed changes

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

@@ -8,7 +8,7 @@ use collections::HashSet;
 use fs::Fs;
 use fuzzy::StringMatchCandidate;
 use gpui::{
-    BackgroundExecutor, Context, DismissEvent, Entity, Subscription, Task, Window, prelude::*,
+    App, BackgroundExecutor, Context, DismissEvent, Entity, Subscription, Task, Window, prelude::*,
 };
 use ordered_float::OrderedFloat;
 use picker::popover_menu::PickerPopoverMenu;
@@ -67,6 +67,113 @@ impl ConfigOptionsView {
         }
     }
 
+    pub fn toggle_category_picker(
+        &mut self,
+        category: acp::SessionConfigOptionCategory,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> bool {
+        let Some(config_id) = self.first_config_option_id(category) else {
+            return false;
+        };
+
+        let Some(selector) = self.selector_for_config_id(&config_id, cx) else {
+            return false;
+        };
+
+        selector.update(cx, |selector, cx| {
+            selector.toggle_picker(window, cx);
+        });
+
+        true
+    }
+
+    pub fn cycle_category_option(
+        &mut self,
+        category: acp::SessionConfigOptionCategory,
+        favorites_only: bool,
+        cx: &mut Context<Self>,
+    ) -> bool {
+        let Some(config_id) = self.first_config_option_id(category) else {
+            return false;
+        };
+
+        let Some(next_value) = self.next_value_for_config(&config_id, favorites_only, cx) else {
+            return false;
+        };
+
+        let task = self
+            .config_options
+            .set_config_option(config_id, next_value, cx);
+
+        cx.spawn(async move |_, _| {
+            if let Err(err) = task.await {
+                log::error!("Failed to set config option: {:?}", err);
+            }
+        })
+        .detach();
+
+        true
+    }
+
+    fn first_config_option_id(
+        &self,
+        category: acp::SessionConfigOptionCategory,
+    ) -> Option<acp::SessionConfigId> {
+        self.config_options
+            .config_options()
+            .into_iter()
+            .find(|option| option.category.as_ref() == Some(&category))
+            .map(|option| option.id)
+    }
+
+    fn selector_for_config_id(
+        &self,
+        config_id: &acp::SessionConfigId,
+        cx: &App,
+    ) -> Option<Entity<ConfigOptionSelector>> {
+        self.selectors
+            .iter()
+            .find(|selector| selector.read(cx).config_id() == config_id)
+            .cloned()
+    }
+
+    fn next_value_for_config(
+        &self,
+        config_id: &acp::SessionConfigId,
+        favorites_only: bool,
+        cx: &mut Context<Self>,
+    ) -> Option<acp::SessionConfigValueId> {
+        let mut options = extract_options(&self.config_options, config_id);
+        if options.is_empty() {
+            return None;
+        }
+
+        if favorites_only {
+            let favorites = self
+                .agent_server
+                .favorite_config_option_value_ids(config_id, cx);
+            options.retain(|option| favorites.contains(&option.value));
+            if options.is_empty() {
+                return None;
+            }
+        }
+
+        let current_value = get_current_value(&self.config_options, config_id);
+        let current_index = current_value
+            .as_ref()
+            .and_then(|current| options.iter().position(|option| &option.value == current))
+            .unwrap_or(usize::MAX);
+
+        let next_index = if current_index == usize::MAX {
+            0
+        } else {
+            (current_index + 1) % options.len()
+        };
+
+        Some(options[next_index].value.clone())
+    }
+
     fn config_option_ids(
         config_options: &Rc<dyn AgentSessionConfigOptions>,
     ) -> Vec<acp::SessionConfigId> {
@@ -206,6 +313,14 @@ impl ConfigOptionSelector {
             .find(|opt| opt.id == self.config_id)
     }
 
+    fn config_id(&self) -> &acp::SessionConfigId {
+        &self.config_id
+    }
+
+    fn toggle_picker(&self, window: &mut Window, cx: &mut Context<Self>) {
+        self.picker_handle.toggle(window, cx);
+    }
+
     fn current_value_name(&self) -> String {
         let Some(option) = self.current_option() else {
             return "Unknown".to_string();

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

@@ -6953,6 +6953,19 @@ impl Render for AcpThreadView {
                 cx.notify();
             }))
             .on_action(cx.listener(|this, _: &ToggleProfileSelector, window, cx| {
+                if let Some(config_options_view) = this.config_options_view.as_ref() {
+                    let handled = config_options_view.update(cx, |view, cx| {
+                        view.toggle_category_picker(
+                            acp::SessionConfigOptionCategory::Mode,
+                            window,
+                            cx,
+                        )
+                    });
+                    if handled {
+                        return;
+                    }
+                }
+
                 if let Some(profile_selector) = this.profile_selector.as_ref() {
                     profile_selector.read(cx).menu_handle().toggle(window, cx);
                 } else if let Some(mode_selector) = this.mode_selector() {
@@ -6960,6 +6973,19 @@ impl Render for AcpThreadView {
                 }
             }))
             .on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
+                if let Some(config_options_view) = this.config_options_view.as_ref() {
+                    let handled = config_options_view.update(cx, |view, cx| {
+                        view.cycle_category_option(
+                            acp::SessionConfigOptionCategory::Mode,
+                            false,
+                            cx,
+                        )
+                    });
+                    if handled {
+                        return;
+                    }
+                }
+
                 if let Some(profile_selector) = this.profile_selector.as_ref() {
                     profile_selector.update(cx, |profile_selector, cx| {
                         profile_selector.cycle_profile(cx);
@@ -6971,12 +6997,38 @@ impl Render for AcpThreadView {
                 }
             }))
             .on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
+                if let Some(config_options_view) = this.config_options_view.as_ref() {
+                    let handled = config_options_view.update(cx, |view, cx| {
+                        view.toggle_category_picker(
+                            acp::SessionConfigOptionCategory::Model,
+                            window,
+                            cx,
+                        )
+                    });
+                    if handled {
+                        return;
+                    }
+                }
+
                 if let Some(model_selector) = this.model_selector.as_ref() {
                     model_selector
                         .update(cx, |model_selector, cx| model_selector.toggle(window, cx));
                 }
             }))
             .on_action(cx.listener(|this, _: &CycleFavoriteModels, window, cx| {
+                if let Some(config_options_view) = this.config_options_view.as_ref() {
+                    let handled = config_options_view.update(cx, |view, cx| {
+                        view.cycle_category_option(
+                            acp::SessionConfigOptionCategory::Model,
+                            true,
+                            cx,
+                        )
+                    });
+                    if handled {
+                        return;
+                    }
+                }
+
                 if let Some(model_selector) = this.model_selector.as_ref() {
                     model_selector.update(cx, |model_selector, cx| {
                         model_selector.cycle_favorite_models(window, cx);