diff --git a/Cargo.lock b/Cargo.lock index 70d7a433ae..6a7e05d563 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,6 @@ dependencies = [ "heed", "html_to_markdown", "http_client", - "indexmap", "indoc", "itertools 0.14.0", "jsonschema", diff --git a/assets/icons/message_bubble_dashed.svg b/assets/icons/message_bubble_dashed.svg new file mode 100644 index 0000000000..02d44c7362 --- /dev/null +++ b/assets/icons/message_bubble_dashed.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/scissors.svg b/assets/icons/scissors.svg new file mode 100644 index 0000000000..e7fb6005f4 --- /dev/null +++ b/assets/icons/scissors.svg @@ -0,0 +1 @@ + diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 8594551e80..36c4f3f042 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -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 diff --git a/crates/agent/src/assistant_configuration/manage_profiles_modal.rs b/crates/agent/src/assistant_configuration/manage_profiles_modal.rs index 6f5172a8d4..6e4bafc154 100644 --- a/crates/agent/src/assistant_configuration/manage_profiles_modal.rs +++ b/crates/agent/src/assistant_configuration/manage_profiles_modal.rs @@ -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, _subscription: Subscription, }, + ConfigureMcps { + profile_id: AgentProfileId, + tool_picker: Entity, + _subscription: Subscription, + }, } impl Mode { pub fn choose_profile(_window: &mut Window, cx: &mut Context) -> 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::>(); + }; + 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, + builtin_profiles: Vec, + custom_profiles: Vec, 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,10 +184,50 @@ 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, + ) { + 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); + } + fn configure_tools( &mut self, profile_id: AgentProfileId, @@ -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 for ManageProfilesModal {} impl ManageProfilesModal { + fn render_profile( + &self, + profile: &ProfileEntry, + window: &mut Window, + cx: &mut Context, + ) -> 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) -> 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() } }) diff --git a/crates/agent/src/assistant_configuration/manage_profiles_modal/profile_modal_header.rs b/crates/agent/src/assistant_configuration/manage_profiles_modal/profile_modal_header.rs index 401c4a9bff..4eca9d721c 100644 --- a/crates/agent/src/assistant_configuration/manage_profiles_modal/profile_modal_header.rs +++ b/crates/agent/src/assistant_configuration/manage_profiles_modal/profile_modal_header.rs @@ -3,11 +3,11 @@ use ui::prelude::*; #[derive(IntoElement)] pub struct ProfileModalHeader { label: SharedString, - icon: IconName, + icon: Option, } impl ProfileModalHeader { - pub fn new(label: impl Into, icon: IconName) -> Self { + pub fn new(label: impl Into, icon: Option) -> 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)), + ), + ) } } diff --git a/crates/agent/src/assistant_configuration/tool_picker.rs b/crates/agent/src/assistant_configuration/tool_picker.rs index 2c48792446..bc6fd80019 100644 --- a/crates/agent/src/assistant_configuration/tool_picker.rs +++ b/crates/agent/src/assistant_configuration/tool_picker.rs @@ -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>, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ToolPickerMode { + BuiltinTools, + McpTools, +} + impl ToolPicker { - pub fn new(delegate: ToolPickerDelegate, window: &mut Window, cx: &mut Context) -> Self { + pub fn builtin_tools( + delegate: ToolPickerDelegate, + window: &mut Window, + cx: &mut Context, + ) -> 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 { + let picker = cx.new(|cx| Picker::list(delegate, window, cx).modal(false)); + Self { picker } + } } impl EventEmitter for ToolPicker {} @@ -41,24 +59,31 @@ impl Render for ToolPicker { } #[derive(Debug, Clone)] -pub struct ToolEntry { - pub name: Arc, - pub source: ToolSource, +pub enum PickerItem { + Tool { + server_id: Option>, + name: Arc, + }, + ContextServer { + server_id: Arc, + }, } pub struct ToolPickerDelegate { tool_picker: WeakEntity, thread_store: WeakEntity, fs: Arc, - tools: Vec, + items: Arc>, profile_id: AgentProfileId, profile: AgentProfile, - matches: Vec, + filtered_items: Vec, selected_index: usize, + mode: ToolPickerMode, } impl ToolPickerDelegate { pub fn new( + mode: ToolPickerMode, fs: Arc, tool_set: Entity, thread_store: WeakEntity, @@ -66,33 +91,60 @@ impl ToolPickerDelegate { profile: AgentProfile, cx: &mut Context, ) -> 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, + cx: &mut App, + ) -> Vec { + 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 = 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>, + ) -> 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 { - "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>, ) -> Task<()> { - let background = cx.background_executor().clone(); - let candidates = self - .tools - .iter() - .enumerate() - .map(|(id, profile)| StringMatchCandidate::new(id, profile.name.as_ref())) - .collect::>(); + 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>, Vec>> = + 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>) { - 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::(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>, + cx: &mut Context>, ) -> Option { - let tool_match = &self.matches[ix]; - let tool = &self.tools[tool_match.candidate_id]; + 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) + }; - 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), - }; - - 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::(is_enabled.then(|| { + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Success) + })) + .into_any_element(), ) - .end_slot::(is_enabled.then(|| { - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success) - })), - ) + } + } } } diff --git a/crates/agent/src/profile_selector.rs b/crates/agent/src/profile_selector.rs index ee31a576d8..fa4032d28c 100644 --- a/crates/agent/src/profile_selector.rs +++ b/crates/agent/src/profile_selector.rs @@ -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, + profiles: GroupedAgentProfiles, fs: Arc, thread_store: WeakEntity, 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 { @@ -52,9 +50,7 @@ impl ProfileSelector { } fn refresh_profiles(&mut self, cx: &mut Context) { - 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::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::(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::(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, }; diff --git a/crates/assistant_settings/src/agent_profile.rs b/crates/assistant_settings/src/agent_profile.rs index 579e7f0f97..f5f42397a6 100644 --- a/crates/assistant_settings/src/agent_profile.rs +++ b/crates/assistant_settings/src/agent_profile.rs @@ -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, + pub custom: IndexMap, +} + +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); diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index c370eb7adf..fc0d9fed3c 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -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, diff --git a/crates/ui/src/components/navigable.rs b/crates/ui/src/components/navigable.rs index dd1da2cc4a..a592bcc36f 100644 --- a/crates/ui/src/components/navigable.rs +++ b/crates/ui/src/components/navigable.rs @@ -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,