assistant2: Add ability to configure tools for profiles in the UI (#27562)

Marshall Bowers created

This PR adds the ability to configure tools for a profile in the UI:


https://github.com/user-attachments/assets/16642f14-8faa-4a91-bb9e-1d480692f1f2

Note: Doesn't yet work for customizing tools for the default profiles.

Release Notes:

- N/A

Change summary

Cargo.lock                                                             |   1 
crates/assistant2/Cargo.toml                                           |   1 
crates/assistant2/src/assistant_configuration.rs                       |   1 
crates/assistant2/src/assistant_configuration/manage_profiles_modal.rs | 150 
crates/assistant2/src/assistant_configuration/profile_picker.rs        |  19 
crates/assistant2/src/assistant_configuration/tool_picker.rs           | 267 
crates/assistant2/src/profile_selector.rs                              |  89 
crates/assistant_settings/src/agent_profile.rs                         |   2 
crates/assistant_settings/src/assistant_settings.rs                    |   2 
crates/ui/src/components/navigable.rs                                  |   1 
10 files changed, 427 insertions(+), 106 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -491,7 +491,6 @@ dependencies = [
  "prompt_store",
  "proto",
  "rand 0.8.5",
- "regex",
  "release_channel",
  "rope",
  "serde",

crates/assistant2/Cargo.toml πŸ”—

@@ -62,7 +62,6 @@ prompt_library.workspace = true
 prompt_store.workspace = true
 proto.workspace = true
 release_channel.workspace = true
-regex.workspace = true
 rope.workspace = true
 serde.workspace = true
 serde_json.workspace = true

crates/assistant2/src/assistant_configuration/manage_profiles_modal.rs πŸ”—

@@ -1,17 +1,33 @@
-use gpui::{prelude::*, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity};
-use ui::prelude::*;
+use std::sync::Arc;
+
+use assistant_settings::AssistantSettings;
+use assistant_tool::ToolWorkingSet;
+use fs::Fs;
+use gpui::{prelude::*, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable};
+use settings::Settings as _;
+use ui::{prelude::*, ListItem, ListItemSpacing, Navigable, NavigableEntry};
 use workspace::{ModalView, Workspace};
 
 use crate::assistant_configuration::profile_picker::{ProfilePicker, ProfilePickerDelegate};
-use crate::ManageProfiles;
+use crate::assistant_configuration::tool_picker::{ToolPicker, ToolPickerDelegate};
+use crate::{AssistantPanel, ManageProfiles};
 
 enum Mode {
     ChooseProfile(Entity<ProfilePicker>),
+    ViewProfile(ViewProfileMode),
+    ConfigureTools(Entity<ToolPicker>),
+}
+
+#[derive(Clone)]
+pub struct ViewProfileMode {
+    profile_id: Arc<str>,
+    configure_tools: NavigableEntry,
 }
 
 pub struct ManageProfilesModal {
-    #[allow(dead_code)]
-    workspace: WeakEntity<Workspace>,
+    fs: Arc<dyn Fs>,
+    tools: Arc<ToolWorkingSet>,
+    focus_handle: FocusHandle,
     mode: Mode,
 }
 
@@ -22,27 +38,79 @@ impl ManageProfilesModal {
         _cx: &mut Context<Workspace>,
     ) {
         workspace.register_action(|workspace, _: &ManageProfiles, window, cx| {
-            let workspace_handle = cx.entity().downgrade();
-            workspace.toggle_modal(window, cx, |window, cx| {
-                Self::new(workspace_handle, window, cx)
-            })
+            if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
+                let fs = workspace.app_state().fs.clone();
+                let thread_store = panel.read(cx).thread_store().read(cx);
+                let tools = thread_store.tools();
+                workspace.toggle_modal(window, cx, |window, cx| Self::new(fs, tools, window, cx))
+            }
         });
     }
 
     pub fn new(
-        workspace: WeakEntity<Workspace>,
+        fs: Arc<dyn Fs>,
+        tools: Arc<ToolWorkingSet>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
+        let focus_handle = cx.focus_handle();
+        let handle = cx.entity();
+
         Self {
-            workspace,
+            fs,
+            tools,
+            focus_handle,
             mode: Mode::ChooseProfile(cx.new(|cx| {
-                let delegate = ProfilePickerDelegate::new(cx);
+                let delegate = ProfilePickerDelegate::new(
+                    move |profile_id, window, cx| {
+                        handle.update(cx, |this, cx| {
+                            this.view_profile(profile_id.clone(), window, cx);
+                        })
+                    },
+                    cx,
+                );
                 ProfilePicker::new(delegate, window, cx)
             })),
         }
     }
 
+    pub fn view_profile(
+        &mut self,
+        profile_id: Arc<str>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.mode = Mode::ViewProfile(ViewProfileMode {
+            profile_id,
+            configure_tools: NavigableEntry::focusable(cx),
+        });
+        self.focus_handle(cx).focus(window);
+    }
+
+    fn configure_tools(
+        &mut self,
+        profile_id: Arc<str>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let settings = AssistantSettings::get_global(cx);
+        let Some(profile) = settings.profiles.get(&profile_id).cloned() else {
+            return;
+        };
+
+        self.mode = Mode::ConfigureTools(cx.new(|cx| {
+            let delegate = ToolPickerDelegate::new(
+                self.fs.clone(),
+                self.tools.clone(),
+                profile_id,
+                profile,
+                cx,
+            );
+            ToolPicker::new(delegate, window, cx)
+        }));
+        self.focus_handle(cx).focus(window);
+    }
+
     fn confirm(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
 
     fn cancel(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
@@ -53,15 +121,65 @@ impl ModalView for ManageProfilesModal {}
 impl Focusable for ManageProfilesModal {
     fn focus_handle(&self, cx: &App) -> FocusHandle {
         match &self.mode {
-            Mode::ChooseProfile(profile_picker) => profile_picker.read(cx).focus_handle(cx),
+            Mode::ChooseProfile(profile_picker) => profile_picker.focus_handle(cx),
+            Mode::ConfigureTools(tool_picker) => tool_picker.focus_handle(cx),
+            Mode::ViewProfile(_) => self.focus_handle.clone(),
         }
     }
 }
 
 impl EventEmitter<DismissEvent> for ManageProfilesModal {}
 
+impl ManageProfilesModal {
+    fn render_view_profile(
+        &mut self,
+        mode: ViewProfileMode,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        Navigable::new(
+            div()
+                .track_focus(&self.focus_handle(cx))
+                .size_full()
+                .child(
+                    v_flex().child(
+                        div()
+                            .id("configure-tools")
+                            .track_focus(&mode.configure_tools.focus_handle)
+                            .on_action({
+                                let profile_id = mode.profile_id.clone();
+                                cx.listener(move |this, _: &menu::Confirm, window, cx| {
+                                    this.configure_tools(profile_id.clone(), window, cx);
+                                })
+                            })
+                            .child(
+                                ListItem::new("configure-tools")
+                                    .toggle_state(
+                                        mode.configure_tools
+                                            .focus_handle
+                                            .contains_focused(window, cx),
+                                    )
+                                    .inset(true)
+                                    .spacing(ListItemSpacing::Sparse)
+                                    .start_slot(Icon::new(IconName::Cog))
+                                    .child(Label::new("Configure Tools"))
+                                    .on_click({
+                                        let profile_id = mode.profile_id.clone();
+                                        cx.listener(move |this, _, window, cx| {
+                                            this.configure_tools(profile_id.clone(), window, cx);
+                                        })
+                                    }),
+                            ),
+                    ),
+                )
+                .into_any_element(),
+        )
+        .entry(mode.configure_tools)
+    }
+}
+
 impl Render for ManageProfilesModal {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         div()
             .elevation_3(cx)
             .w(rems(34.))
@@ -74,6 +192,10 @@ impl Render for ManageProfilesModal {
             .on_mouse_down_out(cx.listener(|_this, _, _, cx| cx.emit(DismissEvent)))
             .child(match &self.mode {
                 Mode::ChooseProfile(profile_picker) => profile_picker.clone().into_any_element(),
+                Mode::ViewProfile(mode) => self
+                    .render_view_profile(mode.clone(), window, cx)
+                    .into_any_element(),
+                Mode::ConfigureTools(tool_picker) => tool_picker.clone().into_any_element(),
             })
     }
 }

crates/assistant2/src/assistant_configuration/profile_picker.rs πŸ”—

@@ -42,7 +42,6 @@ impl Render for ProfilePicker {
 
 #[derive(Debug)]
 pub struct ProfileEntry {
-    #[allow(dead_code)]
     pub id: Arc<str>,
     pub name: SharedString,
 }
@@ -52,10 +51,14 @@ pub struct ProfilePickerDelegate {
     profiles: Vec<ProfileEntry>,
     matches: Vec<StringMatch>,
     selected_index: usize,
+    on_confirm: Arc<dyn Fn(&Arc<str>, &mut Window, &mut App) + 'static>,
 }
 
 impl ProfilePickerDelegate {
-    pub fn new(cx: &mut Context<ProfilePicker>) -> Self {
+    pub fn new(
+        on_confirm: impl Fn(&Arc<str>, &mut Window, &mut App) + 'static,
+        cx: &mut Context<ProfilePicker>,
+    ) -> Self {
         let settings = AssistantSettings::get_global(cx);
 
         let profiles = settings
@@ -72,6 +75,7 @@ impl ProfilePickerDelegate {
             profiles,
             matches: Vec::new(),
             selected_index: 0,
+            on_confirm: Arc::new(on_confirm),
         }
     }
 }
@@ -149,7 +153,16 @@ impl PickerDelegate for ProfilePickerDelegate {
         })
     }
 
-    fn confirm(&mut self, _secondary: bool, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {
+    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        if self.matches.is_empty() {
+            self.dismissed(window, cx);
+            return;
+        }
+
+        let candidate_id = self.matches[self.selected_index].candidate_id;
+        let profile = &self.profiles[candidate_id];
+
+        (self.on_confirm)(&profile.id, window, cx);
     }
 
     fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {

crates/assistant2/src/assistant_configuration/tool_picker.rs πŸ”—

@@ -0,0 +1,267 @@
+use std::sync::Arc;
+
+use assistant_settings::{
+    AgentProfile, AssistantSettings, AssistantSettingsContent, VersionedAssistantSettingsContent,
+};
+use assistant_tool::{ToolSource, ToolWorkingSet};
+use fs::Fs;
+use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
+use gpui::{App, Context, DismissEvent, Entity, EventEmitter, Focusable, Task, WeakEntity, Window};
+use picker::{Picker, PickerDelegate};
+use settings::update_settings_file;
+use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
+use util::ResultExt as _;
+
+pub struct ToolPicker {
+    picker: Entity<Picker<ToolPickerDelegate>>,
+}
+
+impl ToolPicker {
+    pub fn new(delegate: ToolPickerDelegate, window: &mut Window, cx: &mut Context<Self>) -> Self {
+        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
+        Self { picker }
+    }
+}
+
+impl EventEmitter<DismissEvent> for ToolPicker {}
+
+impl Focusable for ToolPicker {
+    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
+        self.picker.focus_handle(cx)
+    }
+}
+
+impl Render for ToolPicker {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex().w(rems(34.)).child(self.picker.clone())
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct ToolEntry {
+    pub name: Arc<str>,
+    pub source: ToolSource,
+}
+
+pub struct ToolPickerDelegate {
+    tool_picker: WeakEntity<ToolPicker>,
+    fs: Arc<dyn Fs>,
+    tools: Vec<ToolEntry>,
+    profile_id: Arc<str>,
+    profile: AgentProfile,
+    matches: Vec<StringMatch>,
+    selected_index: usize,
+}
+
+impl ToolPickerDelegate {
+    pub fn new(
+        fs: Arc<dyn Fs>,
+        tool_set: Arc<ToolWorkingSet>,
+        profile_id: Arc<str>,
+        profile: AgentProfile,
+        cx: &mut Context<ToolPicker>,
+    ) -> Self {
+        let mut tool_entries = Vec::new();
+
+        for (source, tools) in tool_set.tools_by_source(cx) {
+            tool_entries.extend(tools.into_iter().map(|tool| ToolEntry {
+                name: tool.name().into(),
+                source: source.clone(),
+            }));
+        }
+
+        Self {
+            tool_picker: cx.entity().downgrade(),
+            fs,
+            tools: tool_entries,
+            profile_id,
+            profile,
+            matches: Vec::new(),
+            selected_index: 0,
+        }
+    }
+}
+
+impl PickerDelegate for ToolPickerDelegate {
+    type ListItem = ListItem;
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) {
+        self.selected_index = ix;
+    }
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        "Search tools…".into()
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Task<()> {
+        let background = cx.background_executor().clone();
+        let candidates = self
+            .tools
+            .iter()
+            .enumerate()
+            .map(|(id, profile)| StringMatchCandidate::new(id, profile.name.as_ref()))
+            .collect::<Vec<_>>();
+
+        cx.spawn_in(window, async move |this, cx| {
+            let matches = if query.is_empty() {
+                candidates
+                    .into_iter()
+                    .enumerate()
+                    .map(|(index, candidate)| StringMatch {
+                        candidate_id: index,
+                        string: candidate.string,
+                        positions: Vec::new(),
+                        score: 0.,
+                    })
+                    .collect()
+            } else {
+                match_strings(
+                    &candidates,
+                    &query,
+                    false,
+                    100,
+                    &Default::default(),
+                    background,
+                )
+                .await
+            };
+
+            this.update(cx, |this, _cx| {
+                this.delegate.matches = matches;
+                this.delegate.selected_index = this
+                    .delegate
+                    .selected_index
+                    .min(this.delegate.matches.len().saturating_sub(1));
+            })
+            .log_err();
+        })
+    }
+
+    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        if self.matches.is_empty() {
+            self.dismissed(window, cx);
+            return;
+        }
+
+        let candidate_id = self.matches[self.selected_index].candidate_id;
+        let tool = &self.tools[candidate_id];
+
+        let is_enabled = match &tool.source {
+            ToolSource::Native => {
+                let is_enabled = self.profile.tools.entry(tool.name.clone()).or_default();
+                *is_enabled = !*is_enabled;
+                *is_enabled
+            }
+            ToolSource::ContextServer { id } => {
+                let preset = self
+                    .profile
+                    .context_servers
+                    .entry(id.clone().into())
+                    .or_default();
+                let is_enabled = preset.tools.entry(tool.name.clone()).or_default();
+                *is_enabled = !*is_enabled;
+                *is_enabled
+            }
+        };
+
+        update_settings_file::<AssistantSettings>(self.fs.clone(), cx, {
+            let profile_id = self.profile_id.clone();
+            let tool = tool.clone();
+            move |settings, _cx| match settings {
+                AssistantSettingsContent::Versioned(VersionedAssistantSettingsContent::V2(
+                    settings,
+                )) => {
+                    if let Some(profiles) = &mut settings.profiles {
+                        if let Some(profile) = profiles.get_mut(&profile_id) {
+                            match tool.source {
+                                ToolSource::Native => {
+                                    *profile.tools.entry(tool.name).or_default() = is_enabled;
+                                }
+                                ToolSource::ContextServer { id } => {
+                                    let preset = profile
+                                        .context_servers
+                                        .entry(id.clone().into())
+                                        .or_default();
+                                    *preset.tools.entry(tool.name.clone()).or_default() =
+                                        is_enabled;
+                                }
+                            }
+                        }
+                    }
+                }
+                _ => {}
+            }
+        });
+    }
+
+    fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        self.tool_picker
+            .update(cx, |_this, cx| cx.emit(DismissEvent))
+            .log_err();
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let tool_match = &self.matches[ix];
+        let tool = &self.tools[tool_match.candidate_id];
+
+        let is_enabled = match &tool.source {
+            ToolSource::Native => self.profile.tools.get(&tool.name).copied().unwrap_or(false),
+            ToolSource::ContextServer { id } => self
+                .profile
+                .context_servers
+                .get(id.as_ref())
+                .and_then(|preset| preset.tools.get(&tool.name))
+                .copied()
+                .unwrap_or(false),
+        };
+
+        Some(
+            ListItem::new(ix)
+                .inset(true)
+                .spacing(ListItemSpacing::Sparse)
+                .toggle_state(selected)
+                .child(
+                    h_flex()
+                        .gap_2()
+                        .child(HighlightedLabel::new(
+                            tool_match.string.clone(),
+                            tool_match.positions.clone(),
+                        ))
+                        .map(|parent| match &tool.source {
+                            ToolSource::Native => parent,
+                            ToolSource::ContextServer { id } => parent
+                                .child(Label::new(id).size(LabelSize::XSmall).color(Color::Muted)),
+                        }),
+                )
+                .end_slot::<Icon>(is_enabled.then(|| {
+                    Icon::new(IconName::Check)
+                        .size(IconSize::Small)
+                        .color(Color::Success)
+                })),
+        )
+    }
+}

crates/assistant2/src/profile_selector.rs πŸ”—

@@ -1,19 +1,14 @@
-use std::sync::{Arc, LazyLock};
+use std::sync::Arc;
 
-use anyhow::Result;
 use assistant_settings::{AgentProfile, AssistantSettings};
-use editor::scroll::Autoscroll;
-use editor::Editor;
 use fs::Fs;
-use gpui::{prelude::*, AsyncWindowContext, Entity, Subscription, WeakEntity};
+use gpui::{prelude::*, Action, Entity, Subscription, WeakEntity};
 use indexmap::IndexMap;
-use regex::Regex;
 use settings::{update_settings_file, Settings as _, SettingsStore};
 use ui::{prelude::*, ContextMenu, ContextMenuEntry, PopoverMenu, Tooltip};
 use util::ResultExt as _;
-use workspace::{create_and_open_local_file, Workspace};
 
-use crate::ThreadStore;
+use crate::{ManageProfiles, ThreadStore};
 
 pub struct ProfileSelector {
     profiles: IndexMap<Arc<str>, AgentProfile>,
@@ -92,89 +87,13 @@ impl ProfileSelector {
                     .icon(IconName::Pencil)
                     .icon_color(Color::Muted)
                     .handler(move |window, cx| {
-                        if let Some(workspace) = window.root().flatten() {
-                            let workspace = workspace.downgrade();
-                            window
-                                .spawn(cx, async |cx| {
-                                    Self::open_profiles_setting_in_editor(workspace, cx).await
-                                })
-                                .detach_and_log_err(cx);
-                        }
+                        window.dispatch_action(ManageProfiles.boxed_clone(), cx);
                     }),
             );
 
             menu
         })
     }
-
-    async fn open_profiles_setting_in_editor(
-        workspace: WeakEntity<Workspace>,
-        cx: &mut AsyncWindowContext,
-    ) -> Result<()> {
-        let settings_editor = workspace
-            .update_in(cx, |_, window, cx| {
-                create_and_open_local_file(paths::settings_file(), window, cx, || {
-                    settings::initial_user_settings_content().as_ref().into()
-                })
-            })?
-            .await?
-            .downcast::<Editor>()
-            .unwrap();
-
-        settings_editor
-            .downgrade()
-            .update_in(cx, |editor, window, cx| {
-                let text = editor.buffer().read(cx).snapshot(cx).text();
-
-                let settings = cx.global::<SettingsStore>();
-
-                let edits =
-                    settings.edits_for_update::<AssistantSettings>(
-                        &text,
-                        |settings| match settings {
-                            assistant_settings::AssistantSettingsContent::Versioned(settings) => {
-                                match settings {
-                                    assistant_settings::VersionedAssistantSettingsContent::V2(
-                                        settings,
-                                    ) => {
-                                        settings.profiles.get_or_insert_with(IndexMap::default);
-                                    }
-                                    assistant_settings::VersionedAssistantSettingsContent::V1(
-                                        _,
-                                    ) => {}
-                                }
-                            }
-                            assistant_settings::AssistantSettingsContent::Legacy(_) => {}
-                        },
-                    );
-
-                if !edits.is_empty() {
-                    editor.edit(edits.iter().cloned(), cx);
-                }
-
-                let text = editor.buffer().read(cx).snapshot(cx).text();
-
-                static PROFILES_REGEX: LazyLock<Regex> =
-                    LazyLock::new(|| Regex::new(r#"(?P<key>"profiles":)\s*\{"#).unwrap());
-                let range = PROFILES_REGEX.captures(&text).and_then(|captures| {
-                    captures
-                        .name("key")
-                        .map(|inner_match| inner_match.start()..inner_match.end())
-                });
-                if let Some(range) = range {
-                    editor.change_selections(
-                        Some(Autoscroll::newest()),
-                        window,
-                        cx,
-                        |selections| {
-                            selections.select_ranges(vec![range]);
-                        },
-                    );
-                }
-            })?;
-
-        anyhow::Ok(())
-    }
 }
 
 impl Render for ProfileSelector {

crates/assistant_settings/src/agent_profile.rs πŸ”—

@@ -12,7 +12,7 @@ pub struct AgentProfile {
     pub context_servers: IndexMap<Arc<str>, ContextServerPreset>,
 }
 
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Default)]
 pub struct ContextServerPreset {
     pub tools: IndexMap<Arc<str>, bool>,
 }

crates/assistant_settings/src/assistant_settings.rs πŸ”—

@@ -442,7 +442,7 @@ pub struct AgentProfileContent {
     pub context_servers: IndexMap<Arc<str>, ContextServerPresetContent>,
 }
 
-#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize, JsonSchema)]
 pub struct ContextServerPresetContent {
     pub tools: IndexMap<Arc<str>, bool>,
 }

crates/ui/src/components/navigable.rs πŸ”—

@@ -2,6 +2,7 @@ use crate::prelude::*;
 use gpui::{AnyElement, FocusHandle, ScrollAnchor, ScrollHandle};
 
 /// An element that can be navigated through via keyboard. Intended for use with scrollable views that want to use
+#[derive(IntoElement)]
 pub struct Navigable {
     child: AnyElement,
     selectable_children: Vec<NavigableEntry>,