assistant2: Add profile selector (#27520)

Marshall Bowers created

This PR replaces the tool selector with a new profile selector.

<img width="1394" alt="Screenshot 2025-03-26 at 2 35 42 PM"
src="https://github.com/user-attachments/assets/9631c6e9-9c47-411e-b9fc-5d61ed9ca1fe"
/>

<img width="1394" alt="Screenshot 2025-03-26 at 2 35 50 PM"
src="https://github.com/user-attachments/assets/3abe4e08-d044-4d3f-aa95-f472938452a8"
/>

Release Notes:

- N/A

Change summary

Cargo.lock                                          |   1 
assets/settings/default.json                        |   1 
crates/assistant2/Cargo.toml                        |   1 
crates/assistant2/src/assistant.rs                  |   2 
crates/assistant2/src/message_editor.rs             |  11 
crates/assistant2/src/profile_selector.rs           | 202 +++++++++++++++
crates/assistant2/src/thread_store.rs               |  37 ++
crates/assistant2/src/tool_selector.rs              | 172 ------------
crates/assistant_settings/src/assistant_settings.rs |  22 +
9 files changed, 268 insertions(+), 181 deletions(-)

Detailed changes

Cargo.lock 🔗

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

assets/settings/default.json 🔗

@@ -622,6 +622,7 @@
       // The model to use.
       "model": "claude-3-5-sonnet-latest"
     },
+    "default_profile": "code-writer",
     "profiles": {
       "read-only": {
         "name": "Read-only",

crates/assistant2/Cargo.toml 🔗

@@ -62,6 +62,7 @@ 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.rs 🔗

@@ -11,12 +11,12 @@ mod history_store;
 mod inline_assistant;
 mod inline_prompt_editor;
 mod message_editor;
+mod profile_selector;
 mod terminal_codegen;
 mod terminal_inline_assistant;
 mod thread;
 mod thread_history;
 mod thread_store;
-mod tool_selector;
 mod tool_use;
 mod ui;
 

crates/assistant2/src/message_editor.rs 🔗

@@ -26,9 +26,9 @@ use crate::assistant_model_selector::AssistantModelSelector;
 use crate::context_picker::{ConfirmBehavior, ContextPicker, ContextPickerCompletionProvider};
 use crate::context_store::{refresh_context_store_text, ContextStore};
 use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
+use crate::profile_selector::ProfileSelector;
 use crate::thread::{RequestKind, Thread};
 use crate::thread_store::ThreadStore;
-use crate::tool_selector::ToolSelector;
 use crate::{Chat, ChatMode, RemoveAllContext, ThreadEvent, ToggleContextPicker};
 
 pub struct MessageEditor {
@@ -43,7 +43,7 @@ pub struct MessageEditor {
     inline_context_picker: Entity<ContextPicker>,
     inline_context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
     model_selector: Entity<AssistantModelSelector>,
-    tool_selector: Entity<ToolSelector>,
+    profile_selector: Entity<ProfileSelector>,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -57,7 +57,6 @@ impl MessageEditor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
-        let tools = thread.read(cx).tools().clone();
         let context_picker_menu_handle = PopoverMenuHandle::default();
         let inline_context_picker_menu_handle = PopoverMenuHandle::default();
         let model_selector_menu_handle = PopoverMenuHandle::default();
@@ -129,14 +128,14 @@ impl MessageEditor {
             inline_context_picker_menu_handle,
             model_selector: cx.new(|cx| {
                 AssistantModelSelector::new(
-                    fs,
+                    fs.clone(),
                     model_selector_menu_handle,
                     editor.focus_handle(cx),
                     window,
                     cx,
                 )
             }),
-            tool_selector: cx.new(|cx| ToolSelector::new(tools, cx)),
+            profile_selector: cx.new(|cx| ProfileSelector::new(fs, thread_store, cx)),
             _subscriptions: subscriptions,
         }
     }
@@ -624,7 +623,7 @@ impl Render for MessageEditor {
                             .child(
                                 h_flex()
                                     .justify_between()
-                                    .child(h_flex().gap_2().child(self.tool_selector.clone()))
+                                    .child(h_flex().gap_2().child(self.profile_selector.clone()))
                                     .child(
                                         h_flex().gap_1().child(self.model_selector.clone()).child(
                                             ButtonLike::new("submit-message")

crates/assistant2/src/profile_selector.rs 🔗

@@ -0,0 +1,202 @@
+use std::sync::{Arc, LazyLock};
+
+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 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;
+
+pub struct ProfileSelector {
+    profiles: IndexMap<Arc<str>, AgentProfile>,
+    fs: Arc<dyn Fs>,
+    thread_store: WeakEntity<ThreadStore>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl ProfileSelector {
+    pub fn new(
+        fs: Arc<dyn Fs>,
+        thread_store: WeakEntity<ThreadStore>,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let settings_subscription = cx.observe_global::<SettingsStore>(move |this, cx| {
+            this.refresh_profiles(cx);
+        });
+
+        let mut this = Self {
+            profiles: IndexMap::default(),
+            fs,
+            thread_store,
+            _subscriptions: vec![settings_subscription],
+        };
+        this.refresh_profiles(cx);
+
+        this
+    }
+
+    fn refresh_profiles(&mut self, cx: &mut Context<Self>) {
+        let settings = AssistantSettings::get_global(cx);
+
+        self.profiles = settings.profiles.clone();
+    }
+
+    fn build_context_menu(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Entity<ContextMenu> {
+        ContextMenu::build(window, cx, |mut menu, _window, cx| {
+            let settings = AssistantSettings::get_global(cx);
+            let icon_position = IconPosition::Start;
+
+            menu = menu.header("Profiles");
+            for (profile_id, profile) in self.profiles.clone() {
+                menu = menu.toggleable_entry(
+                    profile.name.clone(),
+                    profile_id == settings.default_profile,
+                    icon_position,
+                    None,
+                    {
+                        let fs = self.fs.clone();
+                        let thread_store = self.thread_store.clone();
+                        move |_window, cx| {
+                            update_settings_file::<AssistantSettings>(fs.clone(), cx, {
+                                let profile_id = profile_id.clone();
+                                move |settings, _cx| {
+                                    settings.set_profile(profile_id.clone());
+                                }
+                            });
+
+                            thread_store
+                                .update(cx, |this, cx| {
+                                    this.load_default_profile(cx);
+                                })
+                                .log_err();
+                        }
+                    },
+                );
+            }
+
+            menu = menu.separator();
+            menu = menu.item(
+                ContextMenuEntry::new("Configure Profiles")
+                    .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);
+                        }
+                    }),
+            );
+
+            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 {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let settings = AssistantSettings::get_global(cx);
+        let profile = settings
+            .profiles
+            .get(&settings.default_profile)
+            .map(|profile| profile.name.clone())
+            .unwrap_or_else(|| "Unknown".into());
+
+        let this = cx.entity().clone();
+        PopoverMenu::new("tool-selector")
+            .menu(move |window, cx| {
+                Some(this.update(cx, |this, cx| this.build_context_menu(window, cx)))
+            })
+            .trigger_with_tooltip(
+                Button::new("profile-selector-button", profile)
+                    .style(ButtonStyle::Filled)
+                    .label_size(LabelSize::Small),
+                Tooltip::text("Change Profile"),
+            )
+            .anchor(gpui::Corner::BottomLeft)
+    }
+}

crates/assistant2/src/thread_store.rs 🔗

@@ -3,7 +3,8 @@ use std::path::PathBuf;
 use std::sync::Arc;
 
 use anyhow::{anyhow, Result};
-use assistant_tool::{ToolId, ToolWorkingSet};
+use assistant_settings::AssistantSettings;
+use assistant_tool::{ToolId, ToolSource, ToolWorkingSet};
 use chrono::{DateTime, Utc};
 use collections::HashMap;
 use context_server::manager::ContextServerManager;
@@ -19,6 +20,7 @@ use language_model::{LanguageModelToolUseId, Role};
 use project::Project;
 use prompt_store::PromptBuilder;
 use serde::{Deserialize, Serialize};
+use settings::Settings as _;
 use util::ResultExt as _;
 
 use crate::thread::{MessageId, ProjectSnapshot, Thread, ThreadEvent, ThreadId};
@@ -57,6 +59,7 @@ impl ThreadStore {
                 context_server_tool_ids: HashMap::default(),
                 threads: Vec::new(),
             };
+            this.load_default_profile(cx);
             this.register_context_server_handlers(cx);
             this.reload(cx).detach_and_log_err(cx);
 
@@ -184,6 +187,38 @@ impl ThreadStore {
         })
     }
 
+    pub fn load_default_profile(&self, cx: &mut Context<Self>) {
+        let assistant_settings = AssistantSettings::get_global(cx);
+
+        if let Some(profile) = assistant_settings
+            .profiles
+            .get(&assistant_settings.default_profile)
+        {
+            self.tools.disable_source(ToolSource::Native, cx);
+            self.tools.enable(
+                ToolSource::Native,
+                &profile
+                    .tools
+                    .iter()
+                    .filter_map(|(tool, enabled)| enabled.then(|| tool.clone()))
+                    .collect::<Vec<_>>(),
+            );
+
+            for (context_server_id, preset) in &profile.context_servers {
+                self.tools.enable(
+                    ToolSource::ContextServer {
+                        id: context_server_id.clone().into(),
+                    },
+                    &preset
+                        .tools
+                        .iter()
+                        .filter_map(|(tool, enabled)| enabled.then(|| tool.clone()))
+                        .collect::<Vec<_>>(),
+                )
+            }
+        }
+    }
+
     fn register_context_server_handlers(&self, cx: &mut Context<Self>) {
         cx.subscribe(
             &self.context_server_manager.clone(),

crates/assistant2/src/tool_selector.rs 🔗

@@ -1,172 +0,0 @@
-use std::sync::Arc;
-
-use assistant_settings::{AgentProfile, AssistantSettings};
-use assistant_tool::{ToolSource, ToolWorkingSet};
-use gpui::{Entity, Subscription};
-use indexmap::IndexMap;
-use settings::{Settings as _, SettingsStore};
-use ui::{prelude::*, ContextMenu, PopoverMenu, Tooltip};
-
-pub struct ToolSelector {
-    profiles: IndexMap<Arc<str>, AgentProfile>,
-    tools: Arc<ToolWorkingSet>,
-    _subscriptions: Vec<Subscription>,
-}
-
-impl ToolSelector {
-    pub fn new(tools: Arc<ToolWorkingSet>, cx: &mut Context<Self>) -> Self {
-        let settings_subscription = cx.observe_global::<SettingsStore>(move |this, cx| {
-            this.refresh_profiles(cx);
-        });
-
-        let mut this = Self {
-            profiles: IndexMap::default(),
-            tools,
-            _subscriptions: vec![settings_subscription],
-        };
-        this.refresh_profiles(cx);
-
-        this
-    }
-
-    fn refresh_profiles(&mut self, cx: &mut Context<Self>) {
-        let settings = AssistantSettings::get_global(cx);
-
-        self.profiles = settings.profiles.clone();
-    }
-
-    fn build_context_menu(
-        &self,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Entity<ContextMenu> {
-        let profiles = self.profiles.clone();
-        let tool_set = self.tools.clone();
-        ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| {
-            let icon_position = IconPosition::End;
-
-            menu = menu.header("Profiles");
-            for (_id, profile) in profiles.clone() {
-                menu = menu.toggleable_entry(profile.name.clone(), false, icon_position, None, {
-                    let tools = tool_set.clone();
-                    move |_window, cx| {
-                        tools.disable_all_tools(cx);
-
-                        tools.enable(
-                            ToolSource::Native,
-                            &profile
-                                .tools
-                                .iter()
-                                .filter_map(|(tool, enabled)| enabled.then(|| tool.clone()))
-                                .collect::<Vec<_>>(),
-                        );
-
-                        for (context_server_id, preset) in &profile.context_servers {
-                            tools.enable(
-                                ToolSource::ContextServer {
-                                    id: context_server_id.clone().into(),
-                                },
-                                &preset
-                                    .tools
-                                    .iter()
-                                    .filter_map(|(tool, enabled)| enabled.then(|| tool.clone()))
-                                    .collect::<Vec<_>>(),
-                            )
-                        }
-                    }
-                });
-            }
-
-            menu = menu.separator();
-
-            let tools_by_source = tool_set.tools_by_source(cx);
-
-            let all_tools_enabled = tool_set.are_all_tools_enabled();
-            menu = menu.toggleable_entry("All Tools", all_tools_enabled, icon_position, None, {
-                let tools = tool_set.clone();
-                move |_window, cx| {
-                    if all_tools_enabled {
-                        tools.disable_all_tools(cx);
-                    } else {
-                        tools.enable_all_tools();
-                    }
-                }
-            });
-
-            for (source, tools) in tools_by_source {
-                let mut tools = tools
-                    .into_iter()
-                    .map(|tool| {
-                        let source = tool.source();
-                        let name = tool.name().into();
-                        let is_enabled = tool_set.is_enabled(&source, &name);
-
-                        (source, name, is_enabled)
-                    })
-                    .collect::<Vec<_>>();
-
-                if ToolSource::Native == source {
-                    tools.sort_by(|(_, name_a, _), (_, name_b, _)| name_a.cmp(name_b));
-                }
-
-                menu = match &source {
-                    ToolSource::Native => menu.separator().header("Zed Tools"),
-                    ToolSource::ContextServer { id } => {
-                        let all_tools_from_source_enabled =
-                            tool_set.are_all_tools_from_source_enabled(&source);
-
-                        menu.separator().header(id).toggleable_entry(
-                            "All Tools",
-                            all_tools_from_source_enabled,
-                            icon_position,
-                            None,
-                            {
-                                let tools = tool_set.clone();
-                                let source = source.clone();
-                                move |_window, cx| {
-                                    if all_tools_from_source_enabled {
-                                        tools.disable_source(source.clone(), cx);
-                                    } else {
-                                        tools.enable_source(&source);
-                                    }
-                                }
-                            },
-                        )
-                    }
-                };
-
-                for (source, name, is_enabled) in tools {
-                    menu = menu.toggleable_entry(name.clone(), is_enabled, icon_position, None, {
-                        let tools = tool_set.clone();
-                        move |_window, _cx| {
-                            if is_enabled {
-                                tools.disable(source.clone(), &[name.clone()]);
-                            } else {
-                                tools.enable(source.clone(), &[name.clone()]);
-                            }
-                        }
-                    });
-                }
-            }
-
-            menu
-        })
-    }
-}
-
-impl Render for ToolSelector {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
-        let this = cx.entity().clone();
-        PopoverMenu::new("tool-selector")
-            .menu(move |window, cx| {
-                Some(this.update(cx, |this, cx| this.build_context_menu(window, cx)))
-            })
-            .trigger_with_tooltip(
-                IconButton::new("tool-selector-button", IconName::SettingsAlt)
-                    .icon_size(IconSize::Small)
-                    .icon_color(Color::Muted),
-                Tooltip::text("Customize Tools"),
-            )
-            .anchor(gpui::Corner::BottomLeft)
-    }
-}

crates/assistant_settings/src/assistant_settings.rs 🔗

@@ -71,6 +71,7 @@ pub struct AssistantSettings {
     pub inline_alternatives: Vec<LanguageModelSelection>,
     pub using_outdated_settings_version: bool,
     pub enable_experimental_live_diffs: bool,
+    pub default_profile: Arc<str>,
     pub profiles: IndexMap<Arc<str>, AgentProfile>,
     pub always_allow_tool_actions: bool,
     pub notify_when_agent_waiting: bool,
@@ -174,6 +175,7 @@ impl AssistantSettingsContent {
                     editor_model: None,
                     inline_alternatives: None,
                     enable_experimental_live_diffs: None,
+                    default_profile: None,
                     profiles: None,
                     always_allow_tool_actions: None,
                     notify_when_agent_waiting: None,
@@ -198,6 +200,7 @@ impl AssistantSettingsContent {
                 editor_model: None,
                 inline_alternatives: None,
                 enable_experimental_live_diffs: None,
+                default_profile: None,
                 profiles: None,
                 always_allow_tool_actions: None,
                 notify_when_agent_waiting: None,
@@ -307,6 +310,18 @@ impl AssistantSettingsContent {
             }
         }
     }
+
+    pub fn set_profile(&mut self, profile_id: Arc<str>) {
+        match self {
+            AssistantSettingsContent::Versioned(settings) => match settings {
+                VersionedAssistantSettingsContent::V2(settings) => {
+                    settings.default_profile = Some(profile_id);
+                }
+                VersionedAssistantSettingsContent::V1(_) => {}
+            },
+            AssistantSettingsContent::Legacy(_) => {}
+        }
+    }
 }
 
 #[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)]
@@ -330,6 +345,7 @@ impl Default for VersionedAssistantSettingsContent {
             editor_model: None,
             inline_alternatives: None,
             enable_experimental_live_diffs: None,
+            default_profile: None,
             profiles: None,
             always_allow_tool_actions: None,
             notify_when_agent_waiting: None,
@@ -370,7 +386,9 @@ pub struct AssistantSettingsContentV2 {
     /// Default: false
     enable_experimental_live_diffs: Option<bool>,
     #[schemars(skip)]
-    profiles: Option<IndexMap<Arc<str>, AgentProfileContent>>,
+    default_profile: Option<Arc<str>>,
+    #[schemars(skip)]
+    pub profiles: Option<IndexMap<Arc<str>, AgentProfileContent>>,
     /// Whenever a tool action would normally wait for your confirmation
     /// that you allow it, always choose to allow it.
     ///
@@ -531,6 +549,7 @@ impl Settings for AssistantSettings {
                 &mut settings.notify_when_agent_waiting,
                 value.notify_when_agent_waiting,
             );
+            merge(&mut settings.default_profile, value.default_profile);
 
             if let Some(profiles) = value.profiles {
                 settings
@@ -621,6 +640,7 @@ mod tests {
                             default_width: None,
                             default_height: None,
                             enable_experimental_live_diffs: None,
+                            default_profile: None,
                             profiles: None,
                             always_allow_tool_actions: None,
                             notify_when_agent_waiting: None,