Cargo.lock ๐
@@ -79,7 +79,6 @@ dependencies = [
"heed",
"html_to_markdown",
"http_client",
- "indexmap",
"indoc",
"itertools 0.14.0",
"jsonschema",
Danilo Leal , Bennet Bo Fenner , Bennet Bo Fenner , and Bennet Bo Fenner created
- [x] Separate MCP servers from tools in the profile customization modal
view
- [x] Group MCP tools in the MCP picker and add a heading
- [x] Separate bult-in profiles from custom ones in the dropdown
selector
- [x] Separate bult-in profiles from custom ones in the modal
- [ ] Enable looping through items via keybinding without opening the
dropdown (will be done on a follow-up PR)
- [ ] Stretch: Focus on the currently active item upon opening the
dropdown (will be done on a follow-up PR)
Release Notes:
- N/A
---------
Co-authored-by: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com>
Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Bennet Bo Fenner <bennet@zed.dev>
Cargo.lock | 1
assets/icons/message_bubble_dashed.svg | 1
assets/icons/scissors.svg | 1
crates/agent/Cargo.toml | 1
crates/agent/src/assistant_configuration/manage_profiles_modal.rs | 386
crates/agent/src/assistant_configuration/manage_profiles_modal/profile_modal_header.rs | 32
crates/agent/src/assistant_configuration/tool_picker.rs | 316
crates/agent/src/profile_selector.rs | 129
crates/assistant_settings/src/agent_profile.rs | 35
crates/icons/src/icons.rs | 2
crates/ui/src/components/navigable.rs | 4
11 files changed, 635 insertions(+), 273 deletions(-)
@@ -79,7 +79,6 @@ dependencies = [
"heed",
"html_to_markdown",
"http_client",
- "indexmap",
"indoc",
"itertools 0.14.0",
"jsonschema",
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle-dashed-icon lucide-message-circle-dashed"><path d="M13.5 3.1c-.5 0-1-.1-1.5-.1s-1 .1-1.5.1"/><path d="M19.3 6.8a10.45 10.45 0 0 0-2.1-2.1"/><path d="M20.9 13.5c.1-.5.1-1 .1-1.5s-.1-1-.1-1.5"/><path d="M17.2 19.3a10.45 10.45 0 0 0 2.1-2.1"/><path d="M10.5 20.9c.5.1 1 .1 1.5.1s1-.1 1.5-.1"/><path d="M3.5 17.5 2 22l4.5-1.5"/><path d="M3.1 10.5c0 .5-.1 1-.1 1.5s.1 1 .1 1.5"/><path d="M6.8 4.7a10.45 10.45 0 0 0-2.1 2.1"/></svg>
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scissors-icon lucide-scissors"><circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/></svg>
@@ -46,7 +46,6 @@ gpui.workspace = true
heed.workspace = true
html_to_markdown.workspace = true
http_client.workspace = true
-indexmap.workspace = true
itertools.workspace = true
jsonschema.workspace = true
language.workspace = true
@@ -2,7 +2,7 @@ mod profile_modal_header;
use std::sync::Arc;
-use assistant_settings::{AgentProfile, AgentProfileId, AssistantSettings};
+use assistant_settings::{AgentProfile, AgentProfileId, AssistantSettings, builtin_profiles};
use assistant_tool::ToolWorkingSet;
use convert_case::{Case, Casing as _};
use editor::Editor;
@@ -22,6 +22,8 @@ use crate::assistant_configuration::manage_profiles_modal::profile_modal_header:
use crate::assistant_configuration::tool_picker::{ToolPicker, ToolPickerDelegate};
use crate::{AssistantPanel, ManageProfiles, ThreadStore};
+use super::tool_picker::ToolPickerMode;
+
enum Mode {
ChooseProfile(ChooseProfileMode),
NewProfile(NewProfileMode),
@@ -31,26 +33,39 @@ enum Mode {
tool_picker: Entity<ToolPicker>,
_subscription: Subscription,
},
+ ConfigureMcps {
+ profile_id: AgentProfileId,
+ tool_picker: Entity<ToolPicker>,
+ _subscription: Subscription,
+ },
}
impl Mode {
pub fn choose_profile(_window: &mut Window, cx: &mut Context<ManageProfilesModal>) -> Self {
let settings = AssistantSettings::get_global(cx);
- let mut profiles = settings.profiles.clone();
- profiles.sort_unstable_by(|_, a, _, b| a.name.cmp(&b.name));
+ let mut builtin_profiles = Vec::new();
+ let mut custom_profiles = Vec::new();
- let profiles = profiles
- .into_iter()
- .map(|(id, profile)| ProfileEntry {
- id,
- name: profile.name,
+ for (profile_id, profile) in settings.profiles.iter() {
+ let entry = ProfileEntry {
+ id: profile_id.clone(),
+ name: profile.name.clone(),
navigation: NavigableEntry::focusable(cx),
- })
- .collect::<Vec<_>>();
+ };
+ if builtin_profiles::is_builtin(profile_id) {
+ builtin_profiles.push(entry);
+ } else {
+ custom_profiles.push(entry);
+ }
+ }
+
+ builtin_profiles.sort_unstable_by(|a, b| a.name.cmp(&b.name));
+ custom_profiles.sort_unstable_by(|a, b| a.name.cmp(&b.name));
Self::ChooseProfile(ChooseProfileMode {
- profiles,
+ builtin_profiles,
+ custom_profiles,
add_new_profile: NavigableEntry::focusable(cx),
})
}
@@ -65,7 +80,8 @@ struct ProfileEntry {
#[derive(Clone)]
pub struct ChooseProfileMode {
- profiles: Vec<ProfileEntry>,
+ builtin_profiles: Vec<ProfileEntry>,
+ custom_profiles: Vec<ProfileEntry>,
add_new_profile: NavigableEntry,
}
@@ -74,6 +90,8 @@ pub struct ViewProfileMode {
profile_id: AgentProfileId,
fork_profile: NavigableEntry,
configure_tools: NavigableEntry,
+ configure_mcps: NavigableEntry,
+ cancel_item: NavigableEntry,
}
#[derive(Clone)]
@@ -166,7 +184,47 @@ impl ManageProfilesModal {
profile_id,
fork_profile: NavigableEntry::focusable(cx),
configure_tools: NavigableEntry::focusable(cx),
+ configure_mcps: NavigableEntry::focusable(cx),
+ cancel_item: NavigableEntry::focusable(cx),
+ });
+ self.focus_handle(cx).focus(window);
+ }
+
+ fn configure_mcps(
+ &mut self,
+ profile_id: AgentProfileId,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let settings = AssistantSettings::get_global(cx);
+ let Some(profile) = settings.profiles.get(&profile_id).cloned() else {
+ return;
+ };
+
+ let tool_picker = cx.new(|cx| {
+ let delegate = ToolPickerDelegate::new(
+ ToolPickerMode::McpTools,
+ self.fs.clone(),
+ self.tools.clone(),
+ self.thread_store.clone(),
+ profile_id.clone(),
+ profile,
+ cx,
+ );
+ ToolPicker::mcp_tools(delegate, window, cx)
+ });
+ let dismiss_subscription = cx.subscribe_in(&tool_picker, window, {
+ let profile_id = profile_id.clone();
+ move |this, _tool_picker, _: &DismissEvent, window, cx| {
+ this.view_profile(profile_id.clone(), window, cx);
+ }
});
+
+ self.mode = Mode::ConfigureMcps {
+ profile_id,
+ tool_picker,
+ _subscription: dismiss_subscription,
+ };
self.focus_handle(cx).focus(window);
}
@@ -183,6 +241,7 @@ impl ManageProfilesModal {
let tool_picker = cx.new(|cx| {
let delegate = ToolPickerDelegate::new(
+ ToolPickerMode::BuiltinTools,
self.fs.clone(),
self.tools.clone(),
self.thread_store.clone(),
@@ -190,7 +249,7 @@ impl ManageProfilesModal {
profile,
cx,
);
- ToolPicker::new(delegate, window, cx)
+ ToolPicker::builtin_tools(delegate, window, cx)
});
let dismiss_subscription = cx.subscribe_in(&tool_picker, window, {
let profile_id = profile_id.clone();
@@ -241,6 +300,7 @@ impl ManageProfilesModal {
}
Mode::ViewProfile(_) => {}
Mode::ConfigureTools { .. } => {}
+ Mode::ConfigureMcps { .. } => {}
}
}
@@ -257,7 +317,12 @@ impl ManageProfilesModal {
}
}
Mode::ViewProfile(_) => self.choose_profile(window, cx),
- Mode::ConfigureTools { .. } => {}
+ Mode::ConfigureTools { profile_id, .. } => {
+ self.view_profile(profile_id.clone(), window, cx)
+ }
+ Mode::ConfigureMcps { profile_id, .. } => {
+ self.view_profile(profile_id.clone(), window, cx)
+ }
}
}
@@ -284,6 +349,7 @@ impl Focusable for ManageProfilesModal {
Mode::NewProfile(mode) => mode.name_editor.focus_handle(cx),
Mode::ViewProfile(_) => self.focus_handle.clone(),
Mode::ConfigureTools { tool_picker, .. } => tool_picker.focus_handle(cx),
+ Mode::ConfigureMcps { tool_picker, .. } => tool_picker.focus_handle(cx),
}
}
}
@@ -291,6 +357,51 @@ impl Focusable for ManageProfilesModal {
impl EventEmitter<DismissEvent> for ManageProfilesModal {}
impl ManageProfilesModal {
+ fn render_profile(
+ &self,
+ profile: &ProfileEntry,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> impl IntoElement + use<> {
+ div()
+ .id(SharedString::from(format!("profile-{}", profile.id)))
+ .track_focus(&profile.navigation.focus_handle)
+ .on_action({
+ let profile_id = profile.id.clone();
+ cx.listener(move |this, _: &menu::Confirm, window, cx| {
+ this.view_profile(profile_id.clone(), window, cx);
+ })
+ })
+ .child(
+ ListItem::new(SharedString::from(format!("profile-{}", profile.id)))
+ .toggle_state(profile.navigation.focus_handle.contains_focused(window, cx))
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .child(Label::new(profile.name.clone()))
+ .end_slot(
+ h_flex()
+ .gap_1()
+ .child(
+ Label::new("Customize")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .children(KeyBinding::for_action_in(
+ &menu::Confirm,
+ &self.focus_handle,
+ window,
+ cx,
+ )),
+ )
+ .on_click({
+ let profile_id = profile.id.clone();
+ cx.listener(move |this, _, window, cx| {
+ this.view_profile(profile_id.clone(), window, cx);
+ })
+ }),
+ )
+ }
+
fn render_choose_profile(
&mut self,
mode: ChooseProfileMode,
@@ -301,57 +412,31 @@ impl ManageProfilesModal {
div()
.track_focus(&self.focus_handle(cx))
.size_full()
- .child(ProfileModalHeader::new(
- "Agent Profiles",
- IconName::ZedAssistant,
- ))
+ .child(ProfileModalHeader::new("Agent Profiles", None))
.child(
v_flex()
.pb_1()
.child(ListSeparator)
- .children(mode.profiles.iter().map(|profile| {
- div()
- .id(SharedString::from(format!("profile-{}", profile.id)))
- .track_focus(&profile.navigation.focus_handle)
- .on_action({
- let profile_id = profile.id.clone();
- cx.listener(move |this, _: &menu::Confirm, window, cx| {
- this.view_profile(profile_id.clone(), window, cx);
- })
- })
+ .children(
+ mode.builtin_profiles
+ .iter()
+ .map(|profile| self.render_profile(profile, window, cx)),
+ )
+ .when(!mode.custom_profiles.is_empty(), |this| {
+ this.child(ListSeparator)
.child(
- ListItem::new(SharedString::from(format!(
- "profile-{}",
- profile.id
- )))
- .toggle_state(
- profile
- .navigation
- .focus_handle
- .contains_focused(window, cx),
- )
- .inset(true)
- .spacing(ListItemSpacing::Sparse)
- .child(Label::new(profile.name.clone()))
- .end_slot(
- h_flex()
- .gap_1()
- .child(Label::new("Customize").size(LabelSize::Small))
- .children(KeyBinding::for_action_in(
- &menu::Confirm,
- &self.focus_handle,
- window,
- cx,
- )),
- )
- .on_click({
- let profile_id = profile.id.clone();
- cx.listener(move |this, _, window, cx| {
- this.view_profile(profile_id.clone(), window, cx);
- })
- }),
+ div().pl_2().pb_1().child(
+ Label::new("Custom Profiles")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .children(
+ mode.custom_profiles
+ .iter()
+ .map(|profile| self.render_profile(profile, window, cx)),
)
- }))
+ })
.child(ListSeparator)
.child(
div()
@@ -382,7 +467,10 @@ impl ManageProfilesModal {
.into_any_element(),
)
.map(|mut navigable| {
- for profile in mode.profiles {
+ for profile in mode.builtin_profiles {
+ navigable = navigable.entry(profile.navigation);
+ }
+ for profile in mode.custom_profiles {
navigable = navigable.entry(profile.navigation);
}
@@ -411,11 +499,14 @@ impl ManageProfilesModal {
.id("new-profile")
.track_focus(&self.focus_handle(cx))
.child(ProfileModalHeader::new(
- match base_profile_name {
+ match &base_profile_name {
Some(base_profile) => format!("Fork {base_profile}"),
None => "New Profile".into(),
},
- IconName::Plus,
+ match base_profile_name {
+ Some(_) => Some(IconName::Scissors),
+ None => Some(IconName::Plus),
+ },
))
.child(ListSeparator)
.child(h_flex().p_2().child(mode.name_editor.clone()))
@@ -429,20 +520,24 @@ impl ManageProfilesModal {
) -> impl IntoElement {
let settings = AssistantSettings::get_global(cx);
+ let profile_id = &settings.default_profile;
let profile_name = settings
.profiles
.get(&mode.profile_id)
.map(|profile| profile.name.clone())
.unwrap_or_else(|| "Unknown".into());
+ let icon = match profile_id.as_str() {
+ "write" => IconName::Pencil,
+ "ask" => IconName::MessageBubbles,
+ _ => IconName::UserRoundPen,
+ };
+
Navigable::new(
div()
.track_focus(&self.focus_handle(cx))
.size_full()
- .child(ProfileModalHeader::new(
- profile_name,
- IconName::ZedAssistant,
- ))
+ .child(ProfileModalHeader::new(profile_name, Some(icon)))
.child(
v_flex()
.pb_1()
@@ -466,7 +561,11 @@ impl ManageProfilesModal {
)
.inset(true)
.spacing(ListItemSpacing::Sparse)
- .start_slot(Icon::new(IconName::GitBranch))
+ .start_slot(
+ Icon::new(IconName::Scissors)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
.child(Label::new("Fork Profile"))
.on_click({
let profile_id = mode.profile_id.clone();
@@ -499,7 +598,11 @@ impl ManageProfilesModal {
)
.inset(true)
.spacing(ListItemSpacing::Sparse)
- .start_slot(Icon::new(IconName::Cog))
+ .start_slot(
+ Icon::new(IconName::Settings)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
.child(Label::new("Configure Tools"))
.on_click({
let profile_id = mode.profile_id.clone();
@@ -512,12 +615,90 @@ impl ManageProfilesModal {
})
}),
),
+ )
+ .child(
+ div()
+ .id("configure-mcps")
+ .track_focus(&mode.configure_mcps.focus_handle)
+ .on_action({
+ let profile_id = mode.profile_id.clone();
+ cx.listener(move |this, _: &menu::Confirm, window, cx| {
+ this.configure_mcps(profile_id.clone(), window, cx);
+ })
+ })
+ .child(
+ ListItem::new("configure-mcps")
+ .toggle_state(
+ mode.configure_mcps
+ .focus_handle
+ .contains_focused(window, cx),
+ )
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .start_slot(
+ Icon::new(IconName::Hammer)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .child(Label::new("Configure MCP Servers"))
+ .on_click({
+ let profile_id = mode.profile_id.clone();
+ cx.listener(move |this, _, window, cx| {
+ this.configure_mcps(profile_id.clone(), window, cx);
+ })
+ }),
+ ),
+ )
+ .child(ListSeparator)
+ .child(
+ div()
+ .id("cancel-item")
+ .track_focus(&mode.cancel_item.focus_handle)
+ .on_action({
+ cx.listener(move |this, _: &menu::Confirm, window, cx| {
+ this.cancel(window, cx);
+ })
+ })
+ .child(
+ ListItem::new("cancel-item")
+ .toggle_state(
+ mode.cancel_item
+ .focus_handle
+ .contains_focused(window, cx),
+ )
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .start_slot(
+ Icon::new(IconName::ArrowLeft)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .child(Label::new("Go Back"))
+ .end_slot(
+ div().children(
+ KeyBinding::for_action_in(
+ &menu::Cancel,
+ &self.focus_handle,
+ window,
+ cx,
+ )
+ .map(|kb| kb.size(rems_from_px(12.))),
+ ),
+ )
+ .on_click({
+ cx.listener(move |this, _, window, cx| {
+ this.cancel(window, cx);
+ })
+ }),
+ ),
),
)
.into_any_element(),
)
.entry(mode.fork_profile)
.entry(mode.configure_tools)
+ .entry(mode.configure_mcps)
+ .entry(mode.cancel_item)
}
}
@@ -525,6 +706,43 @@ impl Render for ManageProfilesModal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let settings = AssistantSettings::get_global(cx);
+ let go_back_item = div()
+ .id("cancel-item")
+ .track_focus(&self.focus_handle)
+ .on_action({
+ cx.listener(move |this, _: &menu::Confirm, window, cx| {
+ this.cancel(window, cx);
+ })
+ })
+ .child(
+ ListItem::new("cancel-item")
+ .toggle_state(self.focus_handle.contains_focused(window, cx))
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .start_slot(
+ Icon::new(IconName::ArrowLeft)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .child(Label::new("Go Back"))
+ .end_slot(
+ div().children(
+ KeyBinding::for_action_in(
+ &menu::Cancel,
+ &self.focus_handle,
+ window,
+ cx,
+ )
+ .map(|kb| kb.size(rems_from_px(12.))),
+ ),
+ )
+ .on_click({
+ cx.listener(move |this, _, window, cx| {
+ this.cancel(window, cx);
+ })
+ }),
+ );
+
div()
.elevation_3(cx)
.w(rems(34.))
@@ -556,13 +774,39 @@ impl Render for ManageProfilesModal {
.map(|profile| profile.name.clone())
.unwrap_or_else(|| "Unknown".into());
- div()
+ v_flex()
+ .pb_1()
.child(ProfileModalHeader::new(
- format!("{profile_name}: Configure Tools"),
- IconName::Cog,
+ format!("{profile_name} โ Configure Tools"),
+ Some(IconName::Cog),
))
.child(ListSeparator)
.child(tool_picker.clone())
+ .child(ListSeparator)
+ .child(go_back_item)
+ .into_any_element()
+ }
+ Mode::ConfigureMcps {
+ profile_id,
+ tool_picker,
+ ..
+ } => {
+ let profile_name = settings
+ .profiles
+ .get(profile_id)
+ .map(|profile| profile.name.clone())
+ .unwrap_or_else(|| "Unknown".into());
+
+ v_flex()
+ .pb_1()
+ .child(ProfileModalHeader::new(
+ format!("{profile_name} โ Configure MCP Servers"),
+ Some(IconName::Hammer),
+ ))
+ .child(ListSeparator)
+ .child(tool_picker.clone())
+ .child(ListSeparator)
+ .child(go_back_item)
.into_any_element()
}
})
@@ -3,11 +3,11 @@ use ui::prelude::*;
#[derive(IntoElement)]
pub struct ProfileModalHeader {
label: SharedString,
- icon: IconName,
+ icon: Option<IconName>,
}
impl ProfileModalHeader {
- pub fn new(label: impl Into<SharedString>, icon: IconName) -> Self {
+ pub fn new(label: impl Into<SharedString>, icon: Option<IconName>) -> Self {
Self {
label: label.into(),
icon,
@@ -17,22 +17,26 @@ impl ProfileModalHeader {
impl RenderOnce for ProfileModalHeader {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
- h_flex()
+ let mut container = h_flex()
.w_full()
.px(DynamicSpacing::Base12.rems(cx))
.pt(DynamicSpacing::Base08.rems(cx))
.pb(DynamicSpacing::Base04.rems(cx))
.rounded_t_sm()
- .gap_1p5()
- .child(Icon::new(self.icon).size(IconSize::XSmall))
- .child(
- h_flex().gap_1().overflow_x_hidden().child(
- div()
- .max_w_96()
- .overflow_x_hidden()
- .text_ellipsis()
- .child(Headline::new(self.label).size(HeadlineSize::XSmall)),
- ),
- )
+ .gap_1p5();
+
+ if let Some(icon) = self.icon {
+ container = container.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
+ }
+
+ container.child(
+ h_flex().gap_1().overflow_x_hidden().child(
+ div()
+ .max_w_96()
+ .overflow_x_hidden()
+ .text_ellipsis()
+ .child(Headline::new(self.label).size(HeadlineSize::XSmall)),
+ ),
+ )
}
}
@@ -1,4 +1,4 @@
-use std::sync::Arc;
+use std::{collections::BTreeMap, sync::Arc};
use assistant_settings::{
AgentProfile, AgentProfileContent, AgentProfileId, AssistantSettings, AssistantSettingsContent,
@@ -6,11 +6,10 @@ use assistant_settings::{
};
use assistant_tool::{ToolSource, ToolWorkingSet};
use fs::Fs;
-use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{App, Context, DismissEvent, Entity, EventEmitter, Focusable, Task, WeakEntity, Window};
use picker::{Picker, PickerDelegate};
use settings::{Settings as _, update_settings_file};
-use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
+use ui::{ListItem, ListItemSpacing, prelude::*};
use util::ResultExt as _;
use crate::ThreadStore;
@@ -19,11 +18,30 @@ pub struct ToolPicker {
picker: Entity<Picker<ToolPickerDelegate>>,
}
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum ToolPickerMode {
+ BuiltinTools,
+ McpTools,
+}
+
impl ToolPicker {
- pub fn new(delegate: ToolPickerDelegate, window: &mut Window, cx: &mut Context<Self>) -> Self {
+ pub fn builtin_tools(
+ delegate: ToolPickerDelegate,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
Self { picker }
}
+
+ pub fn mcp_tools(
+ delegate: ToolPickerDelegate,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let picker = cx.new(|cx| Picker::list(delegate, window, cx).modal(false));
+ Self { picker }
+ }
}
impl EventEmitter<DismissEvent> for ToolPicker {}
@@ -41,24 +59,31 @@ impl Render for ToolPicker {
}
#[derive(Debug, Clone)]
-pub struct ToolEntry {
- pub name: Arc<str>,
- pub source: ToolSource,
+pub enum PickerItem {
+ Tool {
+ server_id: Option<Arc<str>>,
+ name: Arc<str>,
+ },
+ ContextServer {
+ server_id: Arc<str>,
+ },
}
pub struct ToolPickerDelegate {
tool_picker: WeakEntity<ToolPicker>,
thread_store: WeakEntity<ThreadStore>,
fs: Arc<dyn Fs>,
- tools: Vec<ToolEntry>,
+ items: Arc<Vec<PickerItem>>,
profile_id: AgentProfileId,
profile: AgentProfile,
- matches: Vec<StringMatch>,
+ filtered_items: Vec<PickerItem>,
selected_index: usize,
+ mode: ToolPickerMode,
}
impl ToolPickerDelegate {
pub fn new(
+ mode: ToolPickerMode,
fs: Arc<dyn Fs>,
tool_set: Entity<ToolWorkingSet>,
thread_store: WeakEntity<ThreadStore>,
@@ -66,33 +91,60 @@ impl ToolPickerDelegate {
profile: AgentProfile,
cx: &mut Context<ToolPicker>,
) -> Self {
- let mut tool_entries = Vec::new();
-
- for (source, tools) in tool_set.read(cx).tools_by_source(cx) {
- tool_entries.extend(tools.into_iter().map(|tool| ToolEntry {
- name: tool.name().into(),
- source: source.clone(),
- }));
- }
+ let items = Arc::new(Self::resolve_items(mode, &tool_set, cx));
Self {
tool_picker: cx.entity().downgrade(),
thread_store,
fs,
- tools: tool_entries,
+ items,
profile_id,
profile,
- matches: Vec::new(),
+ filtered_items: Vec::new(),
selected_index: 0,
+ mode,
}
}
+
+ fn resolve_items(
+ mode: ToolPickerMode,
+ tool_set: &Entity<ToolWorkingSet>,
+ cx: &mut App,
+ ) -> Vec<PickerItem> {
+ let mut items = Vec::new();
+ for (source, tools) in tool_set.read(cx).tools_by_source(cx) {
+ match source {
+ ToolSource::Native => {
+ if mode == ToolPickerMode::BuiltinTools {
+ items.extend(tools.into_iter().map(|tool| PickerItem::Tool {
+ name: tool.name().into(),
+ server_id: None,
+ }));
+ }
+ }
+ ToolSource::ContextServer { id } => {
+ if mode == ToolPickerMode::McpTools && !tools.is_empty() {
+ let server_id: Arc<str> = id.clone().into();
+ items.push(PickerItem::ContextServer {
+ server_id: server_id.clone(),
+ });
+ items.extend(tools.into_iter().map(|tool| PickerItem::Tool {
+ name: tool.name().into(),
+ server_id: Some(server_id.clone()),
+ }));
+ }
+ }
+ }
+ }
+ items
+ }
}
impl PickerDelegate for ToolPickerDelegate {
- type ListItem = ListItem;
+ type ListItem = AnyElement;
fn match_count(&self) -> usize {
- self.matches.len()
+ self.filtered_items.len()
}
fn selected_index(&self) -> usize {
@@ -108,8 +160,25 @@ impl PickerDelegate for ToolPickerDelegate {
self.selected_index = ix;
}
+ fn can_select(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context<Picker<Self>>,
+ ) -> bool {
+ let item = &self.filtered_items[ix];
+ match item {
+ PickerItem::Tool { .. } => true,
+ PickerItem::ContextServer { .. } => false,
+ }
+ }
+
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
- "Search toolsโฆ".into()
+ match self.mode {
+ ToolPickerMode::BuiltinTools => "Search built-in toolsโฆ",
+ ToolPickerMode::McpTools => "Search MCP serversโฆ",
+ }
+ .into()
}
fn update_matches(
@@ -118,74 +187,76 @@ impl PickerDelegate for ToolPickerDelegate {
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<_>>();
+ let all_items = self.items.clone();
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
- };
+ let filtered_items = cx
+ .background_spawn(async move {
+ let mut tools_by_provider: BTreeMap<Option<Arc<str>>, Vec<Arc<str>>> =
+ BTreeMap::default();
+
+ for item in all_items.iter() {
+ if let PickerItem::Tool { server_id, name } = item.clone() {
+ if name.contains(&query) {
+ tools_by_provider.entry(server_id).or_default().push(name);
+ }
+ }
+ }
+
+ let mut items = Vec::new();
+
+ for (server_id, names) in tools_by_provider {
+ if let Some(server_id) = server_id.clone() {
+ items.push(PickerItem::ContextServer { server_id });
+ }
+ for name in names {
+ items.push(PickerItem::Tool {
+ server_id: server_id.clone(),
+ name,
+ });
+ }
+ }
+
+ items
+ })
+ .await;
this.update(cx, |this, _cx| {
- this.delegate.matches = matches;
+ this.delegate.filtered_items = filtered_items;
this.delegate.selected_index = this
.delegate
.selected_index
- .min(this.delegate.matches.len().saturating_sub(1));
+ .min(this.delegate.filtered_items.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() {
+ if self.filtered_items.is_empty() {
self.dismissed(window, cx);
return;
}
- let candidate_id = self.matches[self.selected_index].candidate_id;
- let tool = &self.tools[candidate_id];
+ let item = &self.filtered_items[self.selected_index];
- 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
- }
+ let PickerItem::Tool {
+ name: tool_name,
+ server_id,
+ } = item
+ else {
+ return;
+ };
+
+ let is_currently_enabled = if let Some(server_id) = server_id.clone() {
+ let preset = self.profile.context_servers.entry(server_id).or_default();
+ let is_enabled = *preset.tools.entry(tool_name.clone()).or_default();
+ *preset.tools.entry(tool_name.clone()).or_default() = !is_enabled;
+ is_enabled
+ } else {
+ let is_enabled = *self.profile.tools.entry(tool_name.clone()).or_default();
+ *self.profile.tools.entry(tool_name.clone()).or_default() = !is_enabled;
+ is_enabled
};
let active_profile_id = &AssistantSettings::get_global(cx).default_profile;
@@ -200,7 +271,8 @@ impl PickerDelegate for ToolPickerDelegate {
update_settings_file::<AssistantSettings>(self.fs.clone(), cx, {
let profile_id = self.profile_id.clone();
let default_profile = self.profile.clone();
- let tool = tool.clone();
+ let server_id = server_id.clone();
+ let tool_name = tool_name.clone();
move |settings: &mut AssistantSettingsContent, _cx| {
settings
.v2_setting(|v2_settings| {
@@ -228,17 +300,11 @@ impl PickerDelegate for ToolPickerDelegate {
.collect(),
});
- 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;
- }
+ if let Some(server_id) = server_id {
+ let preset = profile.context_servers.entry(server_id).or_default();
+ *preset.tools.entry(tool_name).or_default() = !is_currently_enabled;
+ } else {
+ *profile.tools.entry(tool_name).or_default() = !is_currently_enabled;
}
Ok(())
@@ -259,45 +325,53 @@ impl PickerDelegate for ToolPickerDelegate {
ix: usize,
selected: bool,
_window: &mut Window,
- _cx: &mut Context<Picker<Self>>,
+ 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(self.profile.enable_all_context_servers),
- };
+ let item = &self.filtered_items[ix];
+ match item {
+ PickerItem::ContextServer { server_id, .. } => Some(
+ div()
+ .px_2()
+ .pb_1()
+ .when(ix > 1, |this| {
+ this.mt_1()
+ .pt_2()
+ .border_t_1()
+ .border_color(cx.theme().colors().border_variant)
+ })
+ .child(
+ Label::new(server_id)
+ .size(LabelSize::XSmall)
+ .color(Color::Muted),
+ )
+ .into_any_element(),
+ ),
+ PickerItem::Tool { name, server_id } => {
+ let is_enabled = if let Some(server_id) = server_id {
+ self.profile
+ .context_servers
+ .get(server_id.as_ref())
+ .and_then(|preset| preset.tools.get(name))
+ .copied()
+ .unwrap_or(self.profile.enable_all_context_servers)
+ } else {
+ self.profile.tools.get(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)),
- }),
+ Some(
+ ListItem::new(ix)
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .child(Label::new(name.clone()))
+ .end_slot::<Icon>(is_enabled.then(|| {
+ Icon::new(IconName::Check)
+ .size(IconSize::Small)
+ .color(Color::Success)
+ }))
+ .into_any_element(),
)
- .end_slot::<Icon>(is_enabled.then(|| {
- Icon::new(IconName::Check)
- .size(IconSize::Small)
- .color(Color::Success)
- })),
- )
+ }
+ }
}
}
@@ -1,9 +1,10 @@
use std::sync::Arc;
-use assistant_settings::{AgentProfile, AgentProfileId, AssistantSettings};
+use assistant_settings::{
+ AgentProfile, AgentProfileId, AssistantSettings, GroupedAgentProfiles, builtin_profiles,
+};
use fs::Fs;
use gpui::{Action, Entity, FocusHandle, Subscription, WeakEntity, prelude::*};
-use indexmap::IndexMap;
use language_model::LanguageModelRegistry;
use settings::{Settings as _, SettingsStore, update_settings_file};
use ui::{
@@ -15,7 +16,7 @@ use util::ResultExt as _;
use crate::{ManageProfiles, ThreadStore, ToggleProfileSelector};
pub struct ProfileSelector {
- profiles: IndexMap<AgentProfileId, AgentProfile>,
+ profiles: GroupedAgentProfiles,
fs: Arc<dyn Fs>,
thread_store: WeakEntity<ThreadStore>,
focus_handle: FocusHandle,
@@ -34,17 +35,14 @@ impl ProfileSelector {
this.refresh_profiles(cx);
});
- let mut this = Self {
- profiles: IndexMap::default(),
+ Self {
+ profiles: GroupedAgentProfiles::from_settings(AssistantSettings::get_global(cx)),
fs,
thread_store,
focus_handle,
menu_handle: PopoverMenuHandle::default(),
_subscriptions: vec![settings_subscription],
- };
- this.refresh_profiles(cx);
-
- this
+ }
}
pub fn menu_handle(&self) -> PopoverMenuHandle<ContextMenu> {
@@ -52,9 +50,7 @@ impl ProfileSelector {
}
fn refresh_profiles(&mut self, cx: &mut Context<Self>) {
- let settings = AssistantSettings::get_global(cx);
-
- self.profiles = settings.profiles.clone();
+ self.profiles = GroupedAgentProfiles::from_settings(AssistantSettings::get_global(cx));
}
fn build_context_menu(
@@ -64,58 +60,21 @@ impl ProfileSelector {
) -> Entity<ContextMenu> {
ContextMenu::build(window, cx, |mut menu, _window, cx| {
let settings = AssistantSettings::get_global(cx);
- let icon_position = IconPosition::End;
-
- menu = menu.header("Profiles");
- for (profile_id, profile) in self.profiles.clone() {
- let documentation = match profile.name.to_lowercase().as_str() {
- "write" => Some("Get help to write anything."),
- "ask" => Some("Chat about your codebase."),
- "manual" => Some("Chat about anything; no tools."),
- _ => None,
- };
-
- let entry = ContextMenuEntry::new(profile.name.clone())
- .toggleable(icon_position, profile_id == settings.default_profile);
-
- let entry = if let Some(doc_text) = documentation {
- entry.documentation_aside(move |_| Label::new(doc_text).into_any_element())
- } else {
- entry
- };
-
- menu = menu.item(entry.handler({
- let fs = self.fs.clone();
- let thread_store = self.thread_store.clone();
- let profile_id = profile_id.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_profile_by_id(profile_id.clone(), cx);
- })
- .log_err();
- }
- }));
+ for (profile_id, profile) in self.profiles.builtin.iter() {
+ menu =
+ menu.item(self.menu_entry_for_profile(profile_id.clone(), profile, settings));
}
- menu = menu.separator();
- menu = menu.header("Customize Current Profile");
- menu = menu.item(ContextMenuEntry::new("Toolsโฆ").handler({
- let profile_id = settings.default_profile.clone();
- move |window, cx| {
- window.dispatch_action(
- ManageProfiles::customize_tools(profile_id.clone()).boxed_clone(),
- cx,
- );
+ if !self.profiles.custom.is_empty() {
+ menu = menu.separator().header("Custom Profiles");
+ for (profile_id, profile) in self.profiles.custom.iter() {
+ menu = menu.item(self.menu_entry_for_profile(
+ profile_id.clone(),
+ profile,
+ settings,
+ ));
}
- }));
+ }
menu = menu.separator();
menu = menu.item(ContextMenuEntry::new("Configure Profilesโฆ").handler(
@@ -127,6 +86,49 @@ impl ProfileSelector {
menu
})
}
+
+ fn menu_entry_for_profile(
+ &self,
+ profile_id: AgentProfileId,
+ profile: &AgentProfile,
+ settings: &AssistantSettings,
+ ) -> ContextMenuEntry {
+ let documentation = match profile.name.to_lowercase().as_str() {
+ builtin_profiles::WRITE => Some("Get help to write anything."),
+ builtin_profiles::ASK => Some("Chat about your codebase."),
+ builtin_profiles::MANUAL => Some("Chat about anything with no tools."),
+ _ => None,
+ };
+
+ let entry = ContextMenuEntry::new(profile.name.clone())
+ .toggleable(IconPosition::End, profile_id == settings.default_profile);
+
+ let entry = if let Some(doc_text) = documentation {
+ entry.documentation_aside(move |_| Label::new(doc_text).into_any_element())
+ } else {
+ entry
+ };
+
+ entry.handler({
+ let fs = self.fs.clone();
+ let thread_store = self.thread_store.clone();
+ let profile_id = profile_id.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_profile_by_id(profile_id.clone(), cx);
+ })
+ .log_err();
+ }
+ })
+ }
}
impl Render for ProfileSelector {
@@ -145,8 +147,9 @@ impl Render for ProfileSelector {
.map_or(false, |default| default.model.supports_tools());
let icon = match profile_id.as_str() {
- "write" => IconName::Pencil,
- "ask" => IconName::MessageBubbles,
+ builtin_profiles::WRITE => IconName::Pencil,
+ builtin_profiles::ASK => IconName::MessageBubbles,
+ builtin_profiles::MANUAL => IconName::MessageBubbleDashed,
_ => IconName::UserRoundPen,
};
@@ -5,6 +5,41 @@ use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
+pub mod builtin_profiles {
+ use super::AgentProfileId;
+
+ pub const WRITE: &str = "write";
+ pub const ASK: &str = "ask";
+ pub const MANUAL: &str = "manual";
+
+ pub fn is_builtin(profile_id: &AgentProfileId) -> bool {
+ profile_id.as_str() == WRITE || profile_id.as_str() == ASK || profile_id.as_str() == MANUAL
+ }
+}
+
+#[derive(Default)]
+pub struct GroupedAgentProfiles {
+ pub builtin: IndexMap<AgentProfileId, AgentProfile>,
+ pub custom: IndexMap<AgentProfileId, AgentProfile>,
+}
+
+impl GroupedAgentProfiles {
+ pub fn from_settings(settings: &crate::AssistantSettings) -> Self {
+ let mut builtin = IndexMap::default();
+ let mut custom = IndexMap::default();
+
+ for (profile_id, profile) in settings.profiles.clone() {
+ if builtin_profiles::is_builtin(&profile_id) {
+ builtin.insert(profile_id, profile);
+ } else {
+ custom.insert(profile_id, profile);
+ }
+ }
+
+ Self { builtin, custom }
+ }
+}
+
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AgentProfileId(pub Arc<str>);
@@ -158,6 +158,7 @@ pub enum IconName {
Maximize,
Menu,
MenuAlt,
+ MessageBubbleDashed,
MessageBubbles,
Mic,
MicMute,
@@ -194,6 +195,7 @@ pub enum IconName {
RotateCw,
Route,
Save,
+ Scissors,
Screen,
SearchCode,
SearchSelection,
@@ -19,14 +19,14 @@ pub struct NavigableEntry {
impl NavigableEntry {
/// Creates a new [NavigableEntry] for a given scroll handle.
- pub fn new(scroll_handle: &ScrollHandle, cx: &mut App) -> Self {
+ pub fn new(scroll_handle: &ScrollHandle, cx: &App) -> Self {
Self {
focus_handle: cx.focus_handle(),
scroll_anchor: Some(ScrollAnchor::for_handle(scroll_handle.clone())),
}
}
/// Create a new [NavigableEntry] that cannot be scrolled to.
- pub fn focusable(cx: &mut App) -> Self {
+ pub fn focusable(cx: &App) -> Self {
Self {
focus_handle: cx.focus_handle(),
scroll_anchor: None,