agent: Refresh the profile selector and modal design (#29816)

- [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>
This commit is contained in:
Danilo Leal 2025-05-02 20:34:36 -03:00 committed by GitHub
parent 1877fce609
commit 5053562e28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 635 additions and 273 deletions

1
Cargo.lock generated
View file

@ -79,7 +79,6 @@ dependencies = [
"heed", "heed",
"html_to_markdown", "html_to_markdown",
"http_client", "http_client",
"indexmap",
"indoc", "indoc",
"itertools 0.14.0", "itertools 0.14.0",
"jsonschema", "jsonschema",

View file

@ -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>

After

Width:  |  Height:  |  Size: 644 B

View file

@ -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>

After

Width:  |  Height:  |  Size: 383 B

View file

@ -46,7 +46,6 @@ gpui.workspace = true
heed.workspace = true heed.workspace = true
html_to_markdown.workspace = true html_to_markdown.workspace = true
http_client.workspace = true http_client.workspace = true
indexmap.workspace = true
itertools.workspace = true itertools.workspace = true
jsonschema.workspace = true jsonschema.workspace = true
language.workspace = true language.workspace = true

View file

@ -2,7 +2,7 @@ mod profile_modal_header;
use std::sync::Arc; use std::sync::Arc;
use assistant_settings::{AgentProfile, AgentProfileId, AssistantSettings}; use assistant_settings::{AgentProfile, AgentProfileId, AssistantSettings, builtin_profiles};
use assistant_tool::ToolWorkingSet; use assistant_tool::ToolWorkingSet;
use convert_case::{Case, Casing as _}; use convert_case::{Case, Casing as _};
use editor::Editor; 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::assistant_configuration::tool_picker::{ToolPicker, ToolPickerDelegate};
use crate::{AssistantPanel, ManageProfiles, ThreadStore}; use crate::{AssistantPanel, ManageProfiles, ThreadStore};
use super::tool_picker::ToolPickerMode;
enum Mode { enum Mode {
ChooseProfile(ChooseProfileMode), ChooseProfile(ChooseProfileMode),
NewProfile(NewProfileMode), NewProfile(NewProfileMode),
@ -31,26 +33,39 @@ enum Mode {
tool_picker: Entity<ToolPicker>, tool_picker: Entity<ToolPicker>,
_subscription: Subscription, _subscription: Subscription,
}, },
ConfigureMcps {
profile_id: AgentProfileId,
tool_picker: Entity<ToolPicker>,
_subscription: Subscription,
},
} }
impl Mode { impl Mode {
pub fn choose_profile(_window: &mut Window, cx: &mut Context<ManageProfilesModal>) -> Self { pub fn choose_profile(_window: &mut Window, cx: &mut Context<ManageProfilesModal>) -> Self {
let settings = AssistantSettings::get_global(cx); let settings = AssistantSettings::get_global(cx);
let mut profiles = settings.profiles.clone(); let mut builtin_profiles = Vec::new();
profiles.sort_unstable_by(|_, a, _, b| a.name.cmp(&b.name)); let mut custom_profiles = Vec::new();
let profiles = profiles for (profile_id, profile) in settings.profiles.iter() {
.into_iter() let entry = ProfileEntry {
.map(|(id, profile)| ProfileEntry { id: profile_id.clone(),
id, name: profile.name.clone(),
name: profile.name,
navigation: NavigableEntry::focusable(cx), 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 { Self::ChooseProfile(ChooseProfileMode {
profiles, builtin_profiles,
custom_profiles,
add_new_profile: NavigableEntry::focusable(cx), add_new_profile: NavigableEntry::focusable(cx),
}) })
} }
@ -65,7 +80,8 @@ struct ProfileEntry {
#[derive(Clone)] #[derive(Clone)]
pub struct ChooseProfileMode { pub struct ChooseProfileMode {
profiles: Vec<ProfileEntry>, builtin_profiles: Vec<ProfileEntry>,
custom_profiles: Vec<ProfileEntry>,
add_new_profile: NavigableEntry, add_new_profile: NavigableEntry,
} }
@ -74,6 +90,8 @@ pub struct ViewProfileMode {
profile_id: AgentProfileId, profile_id: AgentProfileId,
fork_profile: NavigableEntry, fork_profile: NavigableEntry,
configure_tools: NavigableEntry, configure_tools: NavigableEntry,
configure_mcps: NavigableEntry,
cancel_item: NavigableEntry,
} }
#[derive(Clone)] #[derive(Clone)]
@ -166,10 +184,50 @@ impl ManageProfilesModal {
profile_id, profile_id,
fork_profile: NavigableEntry::focusable(cx), fork_profile: NavigableEntry::focusable(cx),
configure_tools: NavigableEntry::focusable(cx), configure_tools: NavigableEntry::focusable(cx),
configure_mcps: NavigableEntry::focusable(cx),
cancel_item: NavigableEntry::focusable(cx),
}); });
self.focus_handle(cx).focus(window); 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);
}
fn configure_tools( fn configure_tools(
&mut self, &mut self,
profile_id: AgentProfileId, profile_id: AgentProfileId,
@ -183,6 +241,7 @@ impl ManageProfilesModal {
let tool_picker = cx.new(|cx| { let tool_picker = cx.new(|cx| {
let delegate = ToolPickerDelegate::new( let delegate = ToolPickerDelegate::new(
ToolPickerMode::BuiltinTools,
self.fs.clone(), self.fs.clone(),
self.tools.clone(), self.tools.clone(),
self.thread_store.clone(), self.thread_store.clone(),
@ -190,7 +249,7 @@ impl ManageProfilesModal {
profile, profile,
cx, cx,
); );
ToolPicker::new(delegate, window, cx) ToolPicker::builtin_tools(delegate, window, cx)
}); });
let dismiss_subscription = cx.subscribe_in(&tool_picker, window, { let dismiss_subscription = cx.subscribe_in(&tool_picker, window, {
let profile_id = profile_id.clone(); let profile_id = profile_id.clone();
@ -241,6 +300,7 @@ impl ManageProfilesModal {
} }
Mode::ViewProfile(_) => {} Mode::ViewProfile(_) => {}
Mode::ConfigureTools { .. } => {} Mode::ConfigureTools { .. } => {}
Mode::ConfigureMcps { .. } => {}
} }
} }
@ -257,7 +317,12 @@ impl ManageProfilesModal {
} }
} }
Mode::ViewProfile(_) => self.choose_profile(window, cx), 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::NewProfile(mode) => mode.name_editor.focus_handle(cx),
Mode::ViewProfile(_) => self.focus_handle.clone(), Mode::ViewProfile(_) => self.focus_handle.clone(),
Mode::ConfigureTools { tool_picker, .. } => tool_picker.focus_handle(cx), 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 EventEmitter<DismissEvent> for ManageProfilesModal {}
impl 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( fn render_choose_profile(
&mut self, &mut self,
mode: ChooseProfileMode, mode: ChooseProfileMode,
@ -301,57 +412,31 @@ impl ManageProfilesModal {
div() div()
.track_focus(&self.focus_handle(cx)) .track_focus(&self.focus_handle(cx))
.size_full() .size_full()
.child(ProfileModalHeader::new( .child(ProfileModalHeader::new("Agent Profiles", None))
"Agent Profiles",
IconName::ZedAssistant,
))
.child( .child(
v_flex() v_flex()
.pb_1() .pb_1()
.child(ListSeparator) .child(ListSeparator)
.children(mode.profiles.iter().map(|profile| { .children(
div() mode.builtin_profiles
.id(SharedString::from(format!("profile-{}", profile.id))) .iter()
.track_focus(&profile.navigation.focus_handle) .map(|profile| self.render_profile(profile, window, cx)),
.on_action({ )
let profile_id = profile.id.clone(); .when(!mode.custom_profiles.is_empty(), |this| {
cx.listener(move |this, _: &menu::Confirm, window, cx| { this.child(ListSeparator)
this.view_profile(profile_id.clone(), window, cx);
})
})
.child( .child(
ListItem::new(SharedString::from(format!( div().pl_2().pb_1().child(
"profile-{}", Label::new("Custom Profiles")
profile.id .size(LabelSize::Small)
))) .color(Color::Muted),
.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);
})
}),
) )
})) .children(
mode.custom_profiles
.iter()
.map(|profile| self.render_profile(profile, window, cx)),
)
})
.child(ListSeparator) .child(ListSeparator)
.child( .child(
div() div()
@ -382,7 +467,10 @@ impl ManageProfilesModal {
.into_any_element(), .into_any_element(),
) )
.map(|mut navigable| { .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); navigable = navigable.entry(profile.navigation);
} }
@ -411,11 +499,14 @@ impl ManageProfilesModal {
.id("new-profile") .id("new-profile")
.track_focus(&self.focus_handle(cx)) .track_focus(&self.focus_handle(cx))
.child(ProfileModalHeader::new( .child(ProfileModalHeader::new(
match base_profile_name { match &base_profile_name {
Some(base_profile) => format!("Fork {base_profile}"), Some(base_profile) => format!("Fork {base_profile}"),
None => "New Profile".into(), None => "New Profile".into(),
}, },
IconName::Plus, match base_profile_name {
Some(_) => Some(IconName::Scissors),
None => Some(IconName::Plus),
},
)) ))
.child(ListSeparator) .child(ListSeparator)
.child(h_flex().p_2().child(mode.name_editor.clone())) .child(h_flex().p_2().child(mode.name_editor.clone()))
@ -429,20 +520,24 @@ impl ManageProfilesModal {
) -> impl IntoElement { ) -> impl IntoElement {
let settings = AssistantSettings::get_global(cx); let settings = AssistantSettings::get_global(cx);
let profile_id = &settings.default_profile;
let profile_name = settings let profile_name = settings
.profiles .profiles
.get(&mode.profile_id) .get(&mode.profile_id)
.map(|profile| profile.name.clone()) .map(|profile| profile.name.clone())
.unwrap_or_else(|| "Unknown".into()); .unwrap_or_else(|| "Unknown".into());
let icon = match profile_id.as_str() {
"write" => IconName::Pencil,
"ask" => IconName::MessageBubbles,
_ => IconName::UserRoundPen,
};
Navigable::new( Navigable::new(
div() div()
.track_focus(&self.focus_handle(cx)) .track_focus(&self.focus_handle(cx))
.size_full() .size_full()
.child(ProfileModalHeader::new( .child(ProfileModalHeader::new(profile_name, Some(icon)))
profile_name,
IconName::ZedAssistant,
))
.child( .child(
v_flex() v_flex()
.pb_1() .pb_1()
@ -466,7 +561,11 @@ impl ManageProfilesModal {
) )
.inset(true) .inset(true)
.spacing(ListItemSpacing::Sparse) .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")) .child(Label::new("Fork Profile"))
.on_click({ .on_click({
let profile_id = mode.profile_id.clone(); let profile_id = mode.profile_id.clone();
@ -499,7 +598,11 @@ impl ManageProfilesModal {
) )
.inset(true) .inset(true)
.spacing(ListItemSpacing::Sparse) .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")) .child(Label::new("Configure Tools"))
.on_click({ .on_click({
let profile_id = mode.profile_id.clone(); 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(), .into_any_element(),
) )
.entry(mode.fork_profile) .entry(mode.fork_profile)
.entry(mode.configure_tools) .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 { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let settings = AssistantSettings::get_global(cx); 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() div()
.elevation_3(cx) .elevation_3(cx)
.w(rems(34.)) .w(rems(34.))
@ -556,13 +774,39 @@ impl Render for ManageProfilesModal {
.map(|profile| profile.name.clone()) .map(|profile| profile.name.clone())
.unwrap_or_else(|| "Unknown".into()); .unwrap_or_else(|| "Unknown".into());
div() v_flex()
.pb_1()
.child(ProfileModalHeader::new( .child(ProfileModalHeader::new(
format!("{profile_name}: Configure Tools"), format!("{profile_name} Configure Tools"),
IconName::Cog, Some(IconName::Cog),
)) ))
.child(ListSeparator) .child(ListSeparator)
.child(tool_picker.clone()) .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() .into_any_element()
} }
}) })

View file

@ -3,11 +3,11 @@ use ui::prelude::*;
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct ProfileModalHeader { pub struct ProfileModalHeader {
label: SharedString, label: SharedString,
icon: IconName, icon: Option<IconName>,
} }
impl ProfileModalHeader { impl ProfileModalHeader {
pub fn new(label: impl Into<SharedString>, icon: IconName) -> Self { pub fn new(label: impl Into<SharedString>, icon: Option<IconName>) -> Self {
Self { Self {
label: label.into(), label: label.into(),
icon, icon,
@ -17,22 +17,26 @@ impl ProfileModalHeader {
impl RenderOnce for ProfileModalHeader { impl RenderOnce for ProfileModalHeader {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex() let mut container = h_flex()
.w_full() .w_full()
.px(DynamicSpacing::Base12.rems(cx)) .px(DynamicSpacing::Base12.rems(cx))
.pt(DynamicSpacing::Base08.rems(cx)) .pt(DynamicSpacing::Base08.rems(cx))
.pb(DynamicSpacing::Base04.rems(cx)) .pb(DynamicSpacing::Base04.rems(cx))
.rounded_t_sm() .rounded_t_sm()
.gap_1p5() .gap_1p5();
.child(Icon::new(self.icon).size(IconSize::XSmall))
.child( if let Some(icon) = self.icon {
h_flex().gap_1().overflow_x_hidden().child( container = container.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted));
div() }
.max_w_96()
.overflow_x_hidden() container.child(
.text_ellipsis() h_flex().gap_1().overflow_x_hidden().child(
.child(Headline::new(self.label).size(HeadlineSize::XSmall)), div()
), .max_w_96()
) .overflow_x_hidden()
.text_ellipsis()
.child(Headline::new(self.label).size(HeadlineSize::XSmall)),
),
)
} }
} }

View file

@ -1,4 +1,4 @@
use std::sync::Arc; use std::{collections::BTreeMap, sync::Arc};
use assistant_settings::{ use assistant_settings::{
AgentProfile, AgentProfileContent, AgentProfileId, AssistantSettings, AssistantSettingsContent, AgentProfile, AgentProfileContent, AgentProfileId, AssistantSettings, AssistantSettingsContent,
@ -6,11 +6,10 @@ use assistant_settings::{
}; };
use assistant_tool::{ToolSource, ToolWorkingSet}; use assistant_tool::{ToolSource, ToolWorkingSet};
use fs::Fs; use fs::Fs;
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{App, Context, DismissEvent, Entity, EventEmitter, Focusable, Task, WeakEntity, Window}; use gpui::{App, Context, DismissEvent, Entity, EventEmitter, Focusable, Task, WeakEntity, Window};
use picker::{Picker, PickerDelegate}; use picker::{Picker, PickerDelegate};
use settings::{Settings as _, update_settings_file}; use settings::{Settings as _, update_settings_file};
use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*}; use ui::{ListItem, ListItemSpacing, prelude::*};
use util::ResultExt as _; use util::ResultExt as _;
use crate::ThreadStore; use crate::ThreadStore;
@ -19,11 +18,30 @@ pub struct ToolPicker {
picker: Entity<Picker<ToolPickerDelegate>>, picker: Entity<Picker<ToolPickerDelegate>>,
} }
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ToolPickerMode {
BuiltinTools,
McpTools,
}
impl ToolPicker { 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)); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
Self { picker } 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 {} impl EventEmitter<DismissEvent> for ToolPicker {}
@ -41,24 +59,31 @@ impl Render for ToolPicker {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ToolEntry { pub enum PickerItem {
pub name: Arc<str>, Tool {
pub source: ToolSource, server_id: Option<Arc<str>>,
name: Arc<str>,
},
ContextServer {
server_id: Arc<str>,
},
} }
pub struct ToolPickerDelegate { pub struct ToolPickerDelegate {
tool_picker: WeakEntity<ToolPicker>, tool_picker: WeakEntity<ToolPicker>,
thread_store: WeakEntity<ThreadStore>, thread_store: WeakEntity<ThreadStore>,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
tools: Vec<ToolEntry>, items: Arc<Vec<PickerItem>>,
profile_id: AgentProfileId, profile_id: AgentProfileId,
profile: AgentProfile, profile: AgentProfile,
matches: Vec<StringMatch>, filtered_items: Vec<PickerItem>,
selected_index: usize, selected_index: usize,
mode: ToolPickerMode,
} }
impl ToolPickerDelegate { impl ToolPickerDelegate {
pub fn new( pub fn new(
mode: ToolPickerMode,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
tool_set: Entity<ToolWorkingSet>, tool_set: Entity<ToolWorkingSet>,
thread_store: WeakEntity<ThreadStore>, thread_store: WeakEntity<ThreadStore>,
@ -66,33 +91,60 @@ impl ToolPickerDelegate {
profile: AgentProfile, profile: AgentProfile,
cx: &mut Context<ToolPicker>, cx: &mut Context<ToolPicker>,
) -> Self { ) -> Self {
let mut tool_entries = Vec::new(); let items = Arc::new(Self::resolve_items(mode, &tool_set, cx));
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(),
}));
}
Self { Self {
tool_picker: cx.entity().downgrade(), tool_picker: cx.entity().downgrade(),
thread_store, thread_store,
fs, fs,
tools: tool_entries, items,
profile_id, profile_id,
profile, profile,
matches: Vec::new(), filtered_items: Vec::new(),
selected_index: 0, 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 { impl PickerDelegate for ToolPickerDelegate {
type ListItem = ListItem; type ListItem = AnyElement;
fn match_count(&self) -> usize { fn match_count(&self) -> usize {
self.matches.len() self.filtered_items.len()
} }
fn selected_index(&self) -> usize { fn selected_index(&self) -> usize {
@ -108,8 +160,25 @@ impl PickerDelegate for ToolPickerDelegate {
self.selected_index = ix; 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> { 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( fn update_matches(
@ -118,74 +187,76 @@ impl PickerDelegate for ToolPickerDelegate {
window: &mut Window, window: &mut Window,
cx: &mut Context<Picker<Self>>, cx: &mut Context<Picker<Self>>,
) -> Task<()> { ) -> Task<()> {
let background = cx.background_executor().clone(); let all_items = self.items.clone();
let candidates = self
.tools
.iter()
.enumerate()
.map(|(id, profile)| StringMatchCandidate::new(id, profile.name.as_ref()))
.collect::<Vec<_>>();
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
let matches = if query.is_empty() { let filtered_items = cx
candidates .background_spawn(async move {
.into_iter() let mut tools_by_provider: BTreeMap<Option<Arc<str>>, Vec<Arc<str>>> =
.enumerate() BTreeMap::default();
.map(|(index, candidate)| StringMatch {
candidate_id: index, for item in all_items.iter() {
string: candidate.string, if let PickerItem::Tool { server_id, name } = item.clone() {
positions: Vec::new(), if name.contains(&query) {
score: 0., tools_by_provider.entry(server_id).or_default().push(name);
}) }
.collect() }
} else { }
match_strings(
&candidates, let mut items = Vec::new();
&query,
false, for (server_id, names) in tools_by_provider {
100, if let Some(server_id) = server_id.clone() {
&Default::default(), items.push(PickerItem::ContextServer { server_id });
background, }
) for name in names {
.await items.push(PickerItem::Tool {
}; server_id: server_id.clone(),
name,
});
}
}
items
})
.await;
this.update(cx, |this, _cx| { this.update(cx, |this, _cx| {
this.delegate.matches = matches; this.delegate.filtered_items = filtered_items;
this.delegate.selected_index = this this.delegate.selected_index = this
.delegate .delegate
.selected_index .selected_index
.min(this.delegate.matches.len().saturating_sub(1)); .min(this.delegate.filtered_items.len().saturating_sub(1));
}) })
.log_err(); .log_err();
}) })
} }
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) { fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if self.matches.is_empty() { if self.filtered_items.is_empty() {
self.dismissed(window, cx); self.dismissed(window, cx);
return; return;
} }
let candidate_id = self.matches[self.selected_index].candidate_id; let item = &self.filtered_items[self.selected_index];
let tool = &self.tools[candidate_id];
let is_enabled = match &tool.source { let PickerItem::Tool {
ToolSource::Native => { name: tool_name,
let is_enabled = self.profile.tools.entry(tool.name.clone()).or_default(); server_id,
*is_enabled = !*is_enabled; } = item
*is_enabled else {
} return;
ToolSource::ContextServer { id } => { };
let preset = self
.profile let is_currently_enabled = if let Some(server_id) = server_id.clone() {
.context_servers let preset = self.profile.context_servers.entry(server_id).or_default();
.entry(id.clone().into()) let is_enabled = *preset.tools.entry(tool_name.clone()).or_default();
.or_default(); *preset.tools.entry(tool_name.clone()).or_default() = !is_enabled;
let is_enabled = preset.tools.entry(tool.name.clone()).or_default(); is_enabled
*is_enabled = !*is_enabled; } else {
*is_enabled 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; 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, { update_settings_file::<AssistantSettings>(self.fs.clone(), cx, {
let profile_id = self.profile_id.clone(); let profile_id = self.profile_id.clone();
let default_profile = self.profile.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| { move |settings: &mut AssistantSettingsContent, _cx| {
settings settings
.v2_setting(|v2_settings| { .v2_setting(|v2_settings| {
@ -228,17 +300,11 @@ impl PickerDelegate for ToolPickerDelegate {
.collect(), .collect(),
}); });
match tool.source { if let Some(server_id) = server_id {
ToolSource::Native => { let preset = profile.context_servers.entry(server_id).or_default();
*profile.tools.entry(tool.name).or_default() = is_enabled; *preset.tools.entry(tool_name).or_default() = !is_currently_enabled;
} } else {
ToolSource::ContextServer { id } => { *profile.tools.entry(tool_name).or_default() = !is_currently_enabled;
let preset = profile
.context_servers
.entry(id.clone().into())
.or_default();
*preset.tools.entry(tool.name.clone()).or_default() = is_enabled;
}
} }
Ok(()) Ok(())
@ -259,45 +325,53 @@ impl PickerDelegate for ToolPickerDelegate {
ix: usize, ix: usize,
selected: bool, selected: bool,
_window: &mut Window, _window: &mut Window,
_cx: &mut Context<Picker<Self>>, cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> { ) -> Option<Self::ListItem> {
let tool_match = &self.matches[ix]; let item = &self.filtered_items[ix];
let tool = &self.tools[tool_match.candidate_id]; 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 { Some(
ToolSource::Native => self.profile.tools.get(&tool.name).copied().unwrap_or(false), ListItem::new(ix)
ToolSource::ContextServer { id } => self .inset(true)
.profile .spacing(ListItemSpacing::Sparse)
.context_servers .toggle_state(selected)
.get(id.as_ref()) .child(Label::new(name.clone()))
.and_then(|preset| preset.tools.get(&tool.name)) .end_slot::<Icon>(is_enabled.then(|| {
.copied() Icon::new(IconName::Check)
.unwrap_or(self.profile.enable_all_context_servers), .size(IconSize::Small)
}; .color(Color::Success)
}))
Some( .into_any_element(),
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(
h_flex()
.gap_2()
.child(HighlightedLabel::new(
tool_match.string.clone(),
tool_match.positions.clone(),
))
.map(|parent| match &tool.source {
ToolSource::Native => parent,
ToolSource::ContextServer { id } => parent
.child(Label::new(id).size(LabelSize::XSmall).color(Color::Muted)),
}),
) )
.end_slot::<Icon>(is_enabled.then(|| { }
Icon::new(IconName::Check) }
.size(IconSize::Small)
.color(Color::Success)
})),
)
} }
} }

View file

@ -1,9 +1,10 @@
use std::sync::Arc; use std::sync::Arc;
use assistant_settings::{AgentProfile, AgentProfileId, AssistantSettings}; use assistant_settings::{
AgentProfile, AgentProfileId, AssistantSettings, GroupedAgentProfiles, builtin_profiles,
};
use fs::Fs; use fs::Fs;
use gpui::{Action, Entity, FocusHandle, Subscription, WeakEntity, prelude::*}; use gpui::{Action, Entity, FocusHandle, Subscription, WeakEntity, prelude::*};
use indexmap::IndexMap;
use language_model::LanguageModelRegistry; use language_model::LanguageModelRegistry;
use settings::{Settings as _, SettingsStore, update_settings_file}; use settings::{Settings as _, SettingsStore, update_settings_file};
use ui::{ use ui::{
@ -15,7 +16,7 @@ use util::ResultExt as _;
use crate::{ManageProfiles, ThreadStore, ToggleProfileSelector}; use crate::{ManageProfiles, ThreadStore, ToggleProfileSelector};
pub struct ProfileSelector { pub struct ProfileSelector {
profiles: IndexMap<AgentProfileId, AgentProfile>, profiles: GroupedAgentProfiles,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
thread_store: WeakEntity<ThreadStore>, thread_store: WeakEntity<ThreadStore>,
focus_handle: FocusHandle, focus_handle: FocusHandle,
@ -34,17 +35,14 @@ impl ProfileSelector {
this.refresh_profiles(cx); this.refresh_profiles(cx);
}); });
let mut this = Self { Self {
profiles: IndexMap::default(), profiles: GroupedAgentProfiles::from_settings(AssistantSettings::get_global(cx)),
fs, fs,
thread_store, thread_store,
focus_handle, focus_handle,
menu_handle: PopoverMenuHandle::default(), menu_handle: PopoverMenuHandle::default(),
_subscriptions: vec![settings_subscription], _subscriptions: vec![settings_subscription],
}; }
this.refresh_profiles(cx);
this
} }
pub fn menu_handle(&self) -> PopoverMenuHandle<ContextMenu> { pub fn menu_handle(&self) -> PopoverMenuHandle<ContextMenu> {
@ -52,9 +50,7 @@ impl ProfileSelector {
} }
fn refresh_profiles(&mut self, cx: &mut Context<Self>) { fn refresh_profiles(&mut self, cx: &mut Context<Self>) {
let settings = AssistantSettings::get_global(cx); self.profiles = GroupedAgentProfiles::from_settings(AssistantSettings::get_global(cx));
self.profiles = settings.profiles.clone();
} }
fn build_context_menu( fn build_context_menu(
@ -64,58 +60,21 @@ impl ProfileSelector {
) -> Entity<ContextMenu> { ) -> Entity<ContextMenu> {
ContextMenu::build(window, cx, |mut menu, _window, cx| { ContextMenu::build(window, cx, |mut menu, _window, cx| {
let settings = AssistantSettings::get_global(cx); let settings = AssistantSettings::get_global(cx);
let icon_position = IconPosition::End; for (profile_id, profile) in self.profiles.builtin.iter() {
menu =
menu = menu.header("Profiles"); menu.item(self.menu_entry_for_profile(profile_id.clone(), profile, settings));
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();
}
}));
} }
menu = menu.separator(); if !self.profiles.custom.is_empty() {
menu = menu.header("Customize Current Profile"); menu = menu.separator().header("Custom Profiles");
menu = menu.item(ContextMenuEntry::new("Tools…").handler({ for (profile_id, profile) in self.profiles.custom.iter() {
let profile_id = settings.default_profile.clone(); menu = menu.item(self.menu_entry_for_profile(
move |window, cx| { profile_id.clone(),
window.dispatch_action( profile,
ManageProfiles::customize_tools(profile_id.clone()).boxed_clone(), settings,
cx, ));
);
} }
})); }
menu = menu.separator(); menu = menu.separator();
menu = menu.item(ContextMenuEntry::new("Configure Profiles…").handler( menu = menu.item(ContextMenuEntry::new("Configure Profiles…").handler(
@ -127,6 +86,49 @@ impl ProfileSelector {
menu 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 { impl Render for ProfileSelector {
@ -145,8 +147,9 @@ impl Render for ProfileSelector {
.map_or(false, |default| default.model.supports_tools()); .map_or(false, |default| default.model.supports_tools());
let icon = match profile_id.as_str() { let icon = match profile_id.as_str() {
"write" => IconName::Pencil, builtin_profiles::WRITE => IconName::Pencil,
"ask" => IconName::MessageBubbles, builtin_profiles::ASK => IconName::MessageBubbles,
builtin_profiles::MANUAL => IconName::MessageBubbleDashed,
_ => IconName::UserRoundPen, _ => IconName::UserRoundPen,
}; };

View file

@ -5,6 +5,41 @@ use indexmap::IndexMap;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AgentProfileId(pub Arc<str>); pub struct AgentProfileId(pub Arc<str>);

View file

@ -158,6 +158,7 @@ pub enum IconName {
Maximize, Maximize,
Menu, Menu,
MenuAlt, MenuAlt,
MessageBubbleDashed,
MessageBubbles, MessageBubbles,
Mic, Mic,
MicMute, MicMute,
@ -194,6 +195,7 @@ pub enum IconName {
RotateCw, RotateCw,
Route, Route,
Save, Save,
Scissors,
Screen, Screen,
SearchCode, SearchCode,
SearchSelection, SearchSelection,

View file

@ -19,14 +19,14 @@ pub struct NavigableEntry {
impl NavigableEntry { impl NavigableEntry {
/// Creates a new [NavigableEntry] for a given scroll handle. /// 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 { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
scroll_anchor: Some(ScrollAnchor::for_handle(scroll_handle.clone())), scroll_anchor: Some(ScrollAnchor::for_handle(scroll_handle.clone())),
} }
} }
/// Create a new [NavigableEntry] that cannot be scrolled to. /// Create a new [NavigableEntry] that cannot be scrolled to.
pub fn focusable(cx: &mut App) -> Self { pub fn focusable(cx: &App) -> Self {
Self { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
scroll_anchor: None, scroll_anchor: None,