Move UI code from assistant_context_editor -> agent_ui (#33289)

This breaks a transitive dependency of `agent` on UI crates. I've also
found and eliminated some dead code in assistant_context_editor.

Release Notes:

- N/A
This commit is contained in:
Max Brunsfeld 2025-06-23 22:22:01 -07:00 committed by GitHub
parent 786e724684
commit 4cd4d28531
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 144 additions and 455 deletions

View file

@ -22,7 +22,7 @@ test-support = [
agent.workspace = true
agent_settings.workspace = true
anyhow.workspace = true
assistant_context_editor.workspace = true
assistant_context.workspace = true
assistant_slash_command.workspace = true
assistant_slash_commands.workspace = true
assistant_tool.workspace = true
@ -101,7 +101,10 @@ editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, "features" = ["test-support"] }
indoc.workspace = true
language = { workspace = true, "features" = ["test-support"] }
languages = { workspace = true, features = ["test-support"] }
language_model = { workspace = true, "features" = ["test-support"] }
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
rand.workspace = true
tree-sitter-md.workspace = true
unindent.workspace = true

View file

@ -1,13 +1,14 @@
use crate::{
ModelUsageContext,
language_model_selector::{
LanguageModelSelector, ToggleModelSelector, language_model_selector,
},
};
use agent_settings::AgentSettings;
use fs::Fs;
use gpui::{Entity, FocusHandle, SharedString};
use picker::popover_menu::PickerPopoverMenu;
use crate::ModelUsageContext;
use assistant_context_editor::language_model_selector::{
LanguageModelSelector, ToggleModelSelector, language_model_selector,
};
use language_model::{ConfiguredModel, LanguageModelRegistry};
use picker::popover_menu::PickerPopoverMenu;
use settings::update_settings_file;
use std::sync::Arc;
use ui::{PopoverMenuHandle, Tooltip, prelude::*};

View file

@ -7,17 +7,35 @@ use std::time::Duration;
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize};
use crate::language_model_selector::ToggleModelSelector;
use crate::{
AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell,
ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu,
active_thread::{self, ActiveThread, ActiveThreadEvent},
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
agent_diff::AgentDiff,
message_editor::{MessageEditor, MessageEditorEvent},
slash_command::SlashCommandCompletionProvider,
text_thread_editor::{
AgentPanelDelegate, TextThreadEditor, humanize_token_count, make_lsp_adapter_delegate,
render_remaining_tokens,
},
thread_history::{HistoryEntryElement, ThreadHistory},
ui::AgentOnboardingModal,
};
use agent::{
Thread, ThreadError, ThreadEvent, ThreadId, ThreadSummary, TokenUsageRatio,
context_store::ContextStore,
history_store::{HistoryEntryId, HistoryStore},
thread_store::{TextThreadStore, ThreadStore},
};
use agent_settings::{AgentDockPosition, AgentSettings, CompletionMode, DefaultView};
use anyhow::{Result, anyhow};
use assistant_context_editor::{
AgentPanelDelegate, AssistantContext, ContextEditor, ContextEvent, ContextSummary,
SlashCommandCompletionProvider, humanize_token_count, make_lsp_adapter_delegate,
render_remaining_tokens,
};
use assistant_context::{AssistantContext, ContextEvent, ContextSummary};
use assistant_slash_command::SlashCommandWorkingSet;
use assistant_tool::ToolWorkingSet;
use assistant_context_editor::language_model_selector::ToggleModelSelector;
use client::{UserStore, zed_urls};
use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
use fs::Fs;
@ -56,25 +74,6 @@ use zed_actions::{
};
use zed_llm_client::{CompletionIntent, UsageLimit};
use crate::{
AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell,
ResetTrialUpsell, ToggleBurnMode, ToggleContextPicker, ToggleNavigationMenu, ToggleOptionsMenu,
active_thread::{self, ActiveThread, ActiveThreadEvent},
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
agent_diff::AgentDiff,
message_editor::{MessageEditor, MessageEditorEvent},
thread_history::{HistoryEntryElement, ThreadHistory},
ui::AgentOnboardingModal,
};
use agent::{
Thread, ThreadError, ThreadEvent, ThreadId, ThreadSummary, TokenUsageRatio,
context_store::ContextStore,
history_store::{HistoryEntryId, HistoryStore},
thread_store::{TextThreadStore, ThreadStore},
};
const AGENT_PANEL_KEY: &str = "agent_panel";
#[derive(Serialize, Deserialize)]
@ -179,7 +178,7 @@ enum ActiveView {
_subscriptions: Vec<gpui::Subscription>,
},
TextThread {
context_editor: Entity<ContextEditor>,
context_editor: Entity<TextThreadEditor>,
title_editor: Entity<Editor>,
buffer_search_bar: Entity<BufferSearchBar>,
_subscriptions: Vec<gpui::Subscription>,
@ -260,7 +259,7 @@ impl ActiveView {
}
pub fn prompt_editor(
context_editor: Entity<ContextEditor>,
context_editor: Entity<TextThreadEditor>,
history_store: Entity<HistoryStore>,
language_registry: Arc<LanguageRegistry>,
window: &mut Window,
@ -434,7 +433,7 @@ impl AgentPanel {
let context_store = workspace
.update(cx, |workspace, cx| {
let project = workspace.project().clone();
assistant_context_editor::ContextStore::new(
assistant_context::ContextStore::new(
project,
prompt_builder.clone(),
slash_commands,
@ -546,7 +545,7 @@ impl AgentPanel {
context_store.update(cx, |context_store, cx| context_store.create(cx));
let lsp_adapter_delegate = make_lsp_adapter_delegate(&project.clone(), cx).unwrap();
let context_editor = cx.new(|cx| {
let mut editor = ContextEditor::for_context(
let mut editor = TextThreadEditor::for_context(
context,
fs.clone(),
workspace.clone(),
@ -841,7 +840,7 @@ impl AgentPanel {
.flatten();
let context_editor = cx.new(|cx| {
let mut editor = ContextEditor::for_context(
let mut editor = TextThreadEditor::for_context(
context,
self.fs.clone(),
self.workspace.clone(),
@ -933,7 +932,7 @@ impl AgentPanel {
.log_err()
.flatten();
let editor = cx.new(|cx| {
ContextEditor::for_context(
TextThreadEditor::for_context(
context,
self.fs.clone(),
self.workspace.clone(),
@ -1321,7 +1320,7 @@ impl AgentPanel {
});
}
pub(crate) fn active_context_editor(&self) -> Option<Entity<ContextEditor>> {
pub(crate) fn active_context_editor(&self) -> Option<Entity<TextThreadEditor>> {
match &self.active_view {
ActiveView::TextThread { context_editor, .. } => Some(context_editor.clone()),
_ => None,
@ -2899,7 +2898,7 @@ impl AgentPanel {
fn render_prompt_editor(
&self,
context_editor: &Entity<ContextEditor>,
context_editor: &Entity<TextThreadEditor>,
buffer_search_bar: &Entity<BufferSearchBar>,
window: &mut Window,
cx: &mut Context<Self>,
@ -3026,7 +3025,7 @@ impl AgentPanel {
}
ActiveView::TextThread { context_editor, .. } => {
context_editor.update(cx, |context_editor, cx| {
ContextEditor::insert_dragged_files(
TextThreadEditor::insert_dragged_files(
context_editor,
paths,
added_worktrees,
@ -3205,7 +3204,7 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate {
workspace: &mut Workspace,
_window: &mut Window,
cx: &mut Context<Workspace>,
) -> Option<Entity<ContextEditor>> {
) -> Option<Entity<TextThreadEditor>> {
let panel = workspace.panel::<AgentPanel>(cx)?;
panel.read(cx).active_context_editor()
}
@ -3229,10 +3228,10 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate {
fn open_remote_context(
&self,
_workspace: &mut Workspace,
_context_id: assistant_context_editor::ContextId,
_context_id: assistant_context::ContextId,
_window: &mut Window,
_cx: &mut Context<Workspace>,
) -> Task<Result<Entity<ContextEditor>>> {
) -> Task<Result<Entity<TextThreadEditor>>> {
Task::ready(Err(anyhow!("opening remote context not implemented")))
}

View file

@ -10,11 +10,16 @@ mod context_strip;
mod debug;
mod inline_assistant;
mod inline_prompt_editor;
mod language_model_selector;
mod max_mode_tooltip;
mod message_editor;
mod profile_selector;
mod slash_command;
mod slash_command_picker;
mod slash_command_settings;
mod terminal_codegen;
mod terminal_inline_assistant;
mod text_thread_editor;
mod thread_history;
mod tool_compatibility;
mod ui;
@ -43,6 +48,7 @@ pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate};
pub use crate::inline_assistant::InlineAssistant;
use crate::slash_command_settings::SlashCommandSettings;
pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
pub use text_thread_editor::AgentPanelDelegate;
pub use ui::preview::{all_agent_previews, get_agent_preview};
actions!(
@ -140,7 +146,7 @@ pub fn init(
AgentSettings::register(cx);
SlashCommandSettings::register(cx);
assistant_context_editor::init(client.clone(), cx);
assistant_context::init(client.clone(), cx);
rules_library::init(cx);
if !is_eval {
// Initializing the language model from the user settings messes with the eval, so we only initialize them when

View file

@ -73,7 +73,7 @@ fn search(
recent_entries: Vec<RecentEntry>,
prompt_store: Option<Entity<PromptStore>>,
thread_store: Option<WeakEntity<ThreadStore>>,
text_thread_context_store: Option<WeakEntity<assistant_context_editor::ContextStore>>,
text_thread_context_store: Option<WeakEntity<assistant_context::ContextStore>>,
workspace: Entity<Workspace>,
cx: &mut App,
) -> Task<Vec<Match>> {

View file

@ -2,6 +2,7 @@ use crate::agent_model_selector::AgentModelSelector;
use crate::buffer_codegen::BufferCodegen;
use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider};
use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind};
use crate::language_model_selector::ToggleModelSelector;
use crate::message_editor::{ContextCreasesAddon, extract_message_creases, insert_message_creases};
use crate::terminal_codegen::TerminalCodegen;
use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext};
@ -10,7 +11,6 @@ use agent::{
context_store::ContextStore,
thread_store::{TextThreadStore, ThreadStore},
};
use assistant_context_editor::language_model_selector::ToggleModelSelector;
use client::ErrorExt;
use collections::VecDeque;
use db::kvp::Dismissable;

View file

@ -0,0 +1,833 @@
use std::{cmp::Reverse, sync::Arc};
use collections::{HashSet, IndexMap};
use feature_flags::ZedProFeatureFlag;
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{
Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task, actions,
};
use language_model::{
AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
LanguageModelRegistry,
};
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
use proto::Plan;
use ui::{ListItem, ListItemSpacing, prelude::*};
actions!(
agent,
[
#[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])]
ToggleModelSelector
]
);
const TRY_ZED_PRO_URL: &str = "https://zed.dev/pro";
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
pub type LanguageModelSelector = Picker<LanguageModelPickerDelegate>;
pub fn language_model_selector(
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
window: &mut Window,
cx: &mut Context<LanguageModelSelector>,
) -> LanguageModelSelector {
let delegate = LanguageModelPickerDelegate::new(get_active_model, on_model_changed, window, cx);
Picker::list(delegate, window, cx)
.show_scrollbar(true)
.width(rems(20.))
.max_height(Some(rems(20.).into()))
}
fn all_models(cx: &App) -> GroupedModels {
let providers = LanguageModelRegistry::global(cx).read(cx).providers();
let recommended = providers
.iter()
.flat_map(|provider| {
provider
.recommended_models(cx)
.into_iter()
.map(|model| ModelInfo {
model,
icon: provider.icon(),
})
})
.collect();
let other = providers
.iter()
.flat_map(|provider| {
provider
.provided_models(cx)
.into_iter()
.map(|model| ModelInfo {
model,
icon: provider.icon(),
})
})
.collect();
GroupedModels::new(other, recommended)
}
#[derive(Clone)]
struct ModelInfo {
model: Arc<dyn LanguageModel>,
icon: IconName,
}
pub struct LanguageModelPickerDelegate {
on_model_changed: OnModelChanged,
get_active_model: GetActiveModel,
all_models: Arc<GroupedModels>,
filtered_entries: Vec<LanguageModelPickerEntry>,
selected_index: usize,
_authenticate_all_providers_task: Task<()>,
_subscriptions: Vec<Subscription>,
}
impl LanguageModelPickerDelegate {
fn new(
get_active_model: impl Fn(&App) -> Option<ConfiguredModel> + 'static,
on_model_changed: impl Fn(Arc<dyn LanguageModel>, &mut App) + 'static,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Self {
let on_model_changed = Arc::new(on_model_changed);
let models = all_models(cx);
let entries = models.entries();
Self {
on_model_changed: on_model_changed.clone(),
all_models: Arc::new(models),
selected_index: Self::get_active_model_index(&entries, get_active_model(cx)),
filtered_entries: entries,
get_active_model: Arc::new(get_active_model),
_authenticate_all_providers_task: Self::authenticate_all_providers(cx),
_subscriptions: vec![cx.subscribe_in(
&LanguageModelRegistry::global(cx),
window,
|picker, _, event, window, cx| {
match event {
language_model::Event::ProviderStateChanged
| language_model::Event::AddedProvider(_)
| language_model::Event::RemovedProvider(_) => {
let query = picker.query(cx);
picker.delegate.all_models = Arc::new(all_models(cx));
// Update matches will automatically drop the previous task
// if we get a provider event again
picker.update_matches(query, window, cx)
}
_ => {}
}
},
)],
}
}
fn get_active_model_index(
entries: &[LanguageModelPickerEntry],
active_model: Option<ConfiguredModel>,
) -> usize {
entries
.iter()
.position(|entry| {
if let LanguageModelPickerEntry::Model(model) = entry {
active_model
.as_ref()
.map(|active_model| {
active_model.model.id() == model.model.id()
&& active_model.provider.id() == model.model.provider_id()
})
.unwrap_or_default()
} else {
false
}
})
.unwrap_or(0)
}
/// Authenticates all providers in the [`LanguageModelRegistry`].
///
/// We do this so that we can populate the language selector with all of the
/// models from the configured providers.
fn authenticate_all_providers(cx: &mut App) -> Task<()> {
let authenticate_all_providers = LanguageModelRegistry::global(cx)
.read(cx)
.providers()
.iter()
.map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
.collect::<Vec<_>>();
cx.spawn(async move |_cx| {
for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
if let Err(err) = authenticate_task.await {
if matches!(err, AuthenticateError::CredentialsNotFound) {
// Since we're authenticating these providers in the
// background for the purposes of populating the
// language selector, we don't care about providers
// where the credentials are not found.
} else {
// Some providers have noisy failure states that we
// don't want to spam the logs with every time the
// language model selector is initialized.
//
// Ideally these should have more clear failure modes
// that we know are safe to ignore here, like what we do
// with `CredentialsNotFound` above.
match provider_id.0.as_ref() {
"lmstudio" | "ollama" => {
// LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated".
//
// These fail noisily, so we don't log them.
}
"copilot_chat" => {
// Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors.
}
_ => {
log::error!(
"Failed to authenticate provider: {}: {err}",
provider_name.0
);
}
}
}
}
}
})
}
pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
(self.get_active_model)(cx)
}
}
struct GroupedModels {
recommended: Vec<ModelInfo>,
other: IndexMap<LanguageModelProviderId, Vec<ModelInfo>>,
}
impl GroupedModels {
pub fn new(other: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
let recommended_ids = recommended
.iter()
.map(|info| (info.model.provider_id(), info.model.id()))
.collect::<HashSet<_>>();
let mut other_by_provider: IndexMap<_, Vec<ModelInfo>> = IndexMap::default();
for model in other {
if recommended_ids.contains(&(model.model.provider_id(), model.model.id())) {
continue;
}
let provider = model.model.provider_id();
if let Some(models) = other_by_provider.get_mut(&provider) {
models.push(model);
} else {
other_by_provider.insert(provider, vec![model]);
}
}
Self {
recommended,
other: other_by_provider,
}
}
fn entries(&self) -> Vec<LanguageModelPickerEntry> {
let mut entries = Vec::new();
if !self.recommended.is_empty() {
entries.push(LanguageModelPickerEntry::Separator("Recommended".into()));
entries.extend(
self.recommended
.iter()
.map(|info| LanguageModelPickerEntry::Model(info.clone())),
);
}
for models in self.other.values() {
if models.is_empty() {
continue;
}
entries.push(LanguageModelPickerEntry::Separator(
models[0].model.provider_name().0,
));
entries.extend(
models
.iter()
.map(|info| LanguageModelPickerEntry::Model(info.clone())),
);
}
entries
}
fn model_infos(&self) -> Vec<ModelInfo> {
let other = self
.other
.values()
.flat_map(|model| model.iter())
.cloned()
.collect::<Vec<_>>();
self.recommended
.iter()
.chain(&other)
.cloned()
.collect::<Vec<_>>()
}
}
enum LanguageModelPickerEntry {
Model(ModelInfo),
Separator(SharedString),
}
struct ModelMatcher {
models: Vec<ModelInfo>,
bg_executor: BackgroundExecutor,
candidates: Vec<StringMatchCandidate>,
}
impl ModelMatcher {
fn new(models: Vec<ModelInfo>, bg_executor: BackgroundExecutor) -> ModelMatcher {
let candidates = Self::make_match_candidates(&models);
Self {
models,
bg_executor,
candidates,
}
}
pub fn fuzzy_search(&self, query: &str) -> Vec<ModelInfo> {
let mut matches = self.bg_executor.block(match_strings(
&self.candidates,
&query,
false,
true,
100,
&Default::default(),
self.bg_executor.clone(),
));
let sorting_key = |mat: &StringMatch| {
let candidate = &self.candidates[mat.candidate_id];
(Reverse(OrderedFloat(mat.score)), candidate.id)
};
matches.sort_unstable_by_key(sorting_key);
let matched_models: Vec<_> = matches
.into_iter()
.map(|mat| self.models[mat.candidate_id].clone())
.collect();
matched_models
}
pub fn exact_search(&self, query: &str) -> Vec<ModelInfo> {
self.models
.iter()
.filter(|m| {
m.model
.name()
.0
.to_lowercase()
.contains(&query.to_lowercase())
})
.cloned()
.collect::<Vec<_>>()
}
fn make_match_candidates(model_infos: &Vec<ModelInfo>) -> Vec<StringMatchCandidate> {
model_infos
.iter()
.enumerate()
.map(|(index, model)| {
StringMatchCandidate::new(
index,
&format!(
"{}/{}",
&model.model.provider_name().0,
&model.model.name().0
),
)
})
.collect::<Vec<_>>()
}
}
impl PickerDelegate for LanguageModelPickerDelegate {
type ListItem = AnyElement;
fn match_count(&self) -> usize {
self.filtered_entries.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
cx.notify();
}
fn can_select(
&mut self,
ix: usize,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> bool {
match self.filtered_entries.get(ix) {
Some(LanguageModelPickerEntry::Model(_)) => true,
Some(LanguageModelPickerEntry::Separator(_)) | None => false,
}
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Select a model…".into()
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let all_models = self.all_models.clone();
let current_index = self.selected_index;
let bg_executor = cx.background_executor();
let language_model_registry = LanguageModelRegistry::global(cx);
let configured_providers = language_model_registry
.read(cx)
.providers()
.into_iter()
.filter(|provider| provider.is_authenticated(cx))
.collect::<Vec<_>>();
let configured_provider_ids = configured_providers
.iter()
.map(|provider| provider.id())
.collect::<Vec<_>>();
let recommended_models = all_models
.recommended
.iter()
.filter(|m| configured_provider_ids.contains(&m.model.provider_id()))
.cloned()
.collect::<Vec<_>>();
let available_models = all_models
.model_infos()
.iter()
.filter(|m| configured_provider_ids.contains(&m.model.provider_id()))
.cloned()
.collect::<Vec<_>>();
let matcher_rec = ModelMatcher::new(recommended_models, bg_executor.clone());
let matcher_all = ModelMatcher::new(available_models, bg_executor.clone());
let recommended = matcher_rec.exact_search(&query);
let all = matcher_all.fuzzy_search(&query);
let filtered_models = GroupedModels::new(all, recommended);
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| {
this.delegate.filtered_entries = filtered_models.entries();
// Preserve selection focus
let new_index = if current_index >= this.delegate.filtered_entries.len() {
0
} else {
current_index
};
this.set_selected_index(new_index, Some(picker::Direction::Down), true, window, cx);
cx.notify();
})
.ok();
})
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if let Some(LanguageModelPickerEntry::Model(model_info)) =
self.filtered_entries.get(self.selected_index)
{
let model = model_info.model.clone();
(self.on_model_changed)(model.clone(), cx);
let current_index = self.selected_index;
self.set_selected_index(current_index, window, cx);
cx.emit(DismissEvent);
}
}
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
cx.emit(DismissEvent);
}
fn render_match(
&self,
ix: usize,
selected: bool,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
match self.filtered_entries.get(ix)? {
LanguageModelPickerEntry::Separator(title) => 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(title)
.size(LabelSize::XSmall)
.color(Color::Muted),
)
.into_any_element(),
),
LanguageModelPickerEntry::Model(model_info) => {
let active_model = (self.get_active_model)(cx);
let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
let active_model_id = active_model.map(|m| m.model.id());
let is_selected = Some(model_info.model.provider_id()) == active_provider_id
&& Some(model_info.model.id()) == active_model_id;
let model_icon_color = if is_selected {
Color::Accent
} else {
Color::Muted
};
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.start_slot(
Icon::new(model_info.icon)
.color(model_icon_color)
.size(IconSize::Small),
)
.child(
h_flex()
.w_full()
.pl_0p5()
.gap_1p5()
.w(px(240.))
.child(Label::new(model_info.model.name().0.clone()).truncate()),
)
.end_slot(div().pr_3().when(is_selected, |this| {
this.child(
Icon::new(IconName::Check)
.color(Color::Accent)
.size(IconSize::Small),
)
}))
.into_any_element(),
)
}
}
}
fn render_footer(
&self,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<gpui::AnyElement> {
use feature_flags::FeatureFlagAppExt;
let plan = proto::Plan::ZedPro;
Some(
h_flex()
.w_full()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.p_1()
.gap_4()
.justify_between()
.when(cx.has_flag::<ZedProFeatureFlag>(), |this| {
this.child(match plan {
Plan::ZedPro => Button::new("zed-pro", "Zed Pro")
.icon(IconName::ZedAssistant)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.icon_position(IconPosition::Start)
.on_click(|_, window, cx| {
window
.dispatch_action(Box::new(zed_actions::OpenAccountSettings), cx)
}),
Plan::Free | Plan::ZedProTrial => Button::new(
"try-pro",
if plan == Plan::ZedProTrial {
"Upgrade to Pro"
} else {
"Try Pro"
},
)
.on_click(|_, _, cx| cx.open_url(TRY_ZED_PRO_URL)),
})
})
.child(
Button::new("configure", "Configure")
.icon(IconName::Settings)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.icon_position(IconPosition::Start)
.on_click(|_, window, cx| {
window.dispatch_action(
zed_actions::agent::OpenConfiguration.boxed_clone(),
cx,
);
}),
)
.into_any(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::{future::BoxFuture, stream::BoxStream};
use gpui::{AsyncApp, TestAppContext, http_client};
use language_model::{
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId,
LanguageModelName, LanguageModelProviderId, LanguageModelProviderName,
LanguageModelRequest, LanguageModelToolChoice,
};
use ui::IconName;
#[derive(Clone)]
struct TestLanguageModel {
name: LanguageModelName,
id: LanguageModelId,
provider_id: LanguageModelProviderId,
provider_name: LanguageModelProviderName,
}
impl TestLanguageModel {
fn new(name: &str, provider: &str) -> Self {
Self {
name: LanguageModelName::from(name.to_string()),
id: LanguageModelId::from(name.to_string()),
provider_id: LanguageModelProviderId::from(provider.to_string()),
provider_name: LanguageModelProviderName::from(provider.to_string()),
}
}
}
impl LanguageModel for TestLanguageModel {
fn id(&self) -> LanguageModelId {
self.id.clone()
}
fn name(&self) -> LanguageModelName {
self.name.clone()
}
fn provider_id(&self) -> LanguageModelProviderId {
self.provider_id.clone()
}
fn provider_name(&self) -> LanguageModelProviderName {
self.provider_name.clone()
}
fn supports_tools(&self) -> bool {
false
}
fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool {
false
}
fn supports_images(&self) -> bool {
false
}
fn telemetry_id(&self) -> String {
format!("{}/{}", self.provider_id.0, self.name.0)
}
fn max_token_count(&self) -> u64 {
1000
}
fn count_tokens(
&self,
_: LanguageModelRequest,
_: &App,
) -> BoxFuture<'static, http_client::Result<u64>> {
unimplemented!()
}
fn stream_completion(
&self,
_: LanguageModelRequest,
_: &AsyncApp,
) -> BoxFuture<
'static,
Result<
BoxStream<
'static,
Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
>,
LanguageModelCompletionError,
>,
> {
unimplemented!()
}
}
fn create_models(model_specs: Vec<(&str, &str)>) -> Vec<ModelInfo> {
model_specs
.into_iter()
.map(|(provider, name)| ModelInfo {
model: Arc::new(TestLanguageModel::new(name, provider)),
icon: IconName::Ai,
})
.collect()
}
fn assert_models_eq(result: Vec<ModelInfo>, expected: Vec<&str>) {
assert_eq!(
result.len(),
expected.len(),
"Number of models doesn't match"
);
for (i, expected_name) in expected.iter().enumerate() {
assert_eq!(
result[i].model.telemetry_id(),
*expected_name,
"Model at position {} doesn't match expected model",
i
);
}
}
#[gpui::test]
fn test_exact_match(cx: &mut TestAppContext) {
let models = create_models(vec![
("zed", "Claude 3.7 Sonnet"),
("zed", "Claude 3.7 Sonnet Thinking"),
("zed", "gpt-4.1"),
("zed", "gpt-4.1-nano"),
("openai", "gpt-3.5-turbo"),
("openai", "gpt-4.1"),
("openai", "gpt-4.1-nano"),
("ollama", "mistral"),
("ollama", "deepseek"),
]);
let matcher = ModelMatcher::new(models, cx.background_executor.clone());
// The order of models should be maintained, case doesn't matter
let results = matcher.exact_search("GPT-4.1");
assert_models_eq(
results,
vec![
"zed/gpt-4.1",
"zed/gpt-4.1-nano",
"openai/gpt-4.1",
"openai/gpt-4.1-nano",
],
);
}
#[gpui::test]
fn test_fuzzy_match(cx: &mut TestAppContext) {
let models = create_models(vec![
("zed", "Claude 3.7 Sonnet"),
("zed", "Claude 3.7 Sonnet Thinking"),
("zed", "gpt-4.1"),
("zed", "gpt-4.1-nano"),
("openai", "gpt-3.5-turbo"),
("openai", "gpt-4.1"),
("openai", "gpt-4.1-nano"),
("ollama", "mistral"),
("ollama", "deepseek"),
]);
let matcher = ModelMatcher::new(models, cx.background_executor.clone());
// Results should preserve models order whenever possible.
// In the case below, `zed/gpt-4.1` and `openai/gpt-4.1` have identical
// similarity scores, but `zed/gpt-4.1` was higher in the models list,
// so it should appear first in the results.
let results = matcher.fuzzy_search("41");
assert_models_eq(
results,
vec![
"zed/gpt-4.1",
"openai/gpt-4.1",
"zed/gpt-4.1-nano",
"openai/gpt-4.1-nano",
],
);
// Model provider should be searchable as well
let results = matcher.fuzzy_search("ol"); // meaning "ollama"
assert_models_eq(results, vec!["ollama/mistral", "ollama/deepseek"]);
// Fuzzy search
let results = matcher.fuzzy_search("z4n");
assert_models_eq(results, vec!["zed/gpt-4.1-nano"]);
}
#[gpui::test]
fn test_exclude_recommended_models(_cx: &mut TestAppContext) {
let recommended_models = create_models(vec![("zed", "claude")]);
let all_models = create_models(vec![
("zed", "claude"), // Should be filtered out from "other"
("zed", "gemini"),
("copilot", "o3"),
]);
let grouped_models = GroupedModels::new(all_models, recommended_models);
let actual_other_models = grouped_models
.other
.values()
.flatten()
.cloned()
.collect::<Vec<_>>();
// Recommended models should not appear in "other"
assert_models_eq(actual_other_models, vec!["zed/gemini", "copilot/o3"]);
}
#[gpui::test]
fn test_dont_exclude_models_from_other_providers(_cx: &mut TestAppContext) {
let recommended_models = create_models(vec![("zed", "claude")]);
let all_models = create_models(vec![
("zed", "claude"), // Should be filtered out from "other"
("zed", "gemini"),
("copilot", "claude"), // Should not be filtered out from "other"
]);
let grouped_models = GroupedModels::new(all_models, recommended_models);
let actual_other_models = grouped_models
.other
.values()
.flatten()
.cloned()
.collect::<Vec<_>>();
// Recommended models should not appear in "other"
assert_models_eq(actual_other_models, vec!["zed/gemini", "copilot/claude"]);
}
}

View file

@ -0,0 +1,61 @@
use gpui::{Context, FontWeight, IntoElement, Render, Window};
use ui::{prelude::*, tooltip_container};
pub struct MaxModeTooltip {
selected: bool,
}
impl MaxModeTooltip {
pub fn new() -> Self {
Self { selected: false }
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl Render for MaxModeTooltip {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (icon, color) = if self.selected {
(IconName::ZedBurnModeOn, Color::Error)
} else {
(IconName::ZedBurnMode, Color::Default)
};
let turned_on = h_flex()
.h_4()
.px_1()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().text_accent.opacity(0.1))
.rounded_sm()
.child(
Label::new("ON")
.size(LabelSize::XSmall)
.weight(FontWeight::SEMIBOLD)
.color(Color::Accent),
);
let title = h_flex()
.gap_1p5()
.child(Icon::new(icon).size(IconSize::Small).color(color))
.child(Label::new("Burn Mode"))
.when(self.selected, |title| title.child(turned_on));
tooltip_container(window, cx, |this, _, _| {
this
.child(title)
.child(
div()
.max_w_64()
.child(
Label::new("Enables models to use large context windows, unlimited tool calls, and other capabilities for expanded reasoning.")
.size(LabelSize::Small)
.color(Color::Muted)
)
)
})
}
}

View file

@ -3,6 +3,7 @@ use std::rc::Rc;
use std::sync::Arc;
use crate::agent_model_selector::AgentModelSelector;
use crate::language_model_selector::ToggleModelSelector;
use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip};
use crate::ui::{
MaxModeTooltip,
@ -13,7 +14,6 @@ use agent::{
context_store::ContextStoreEvent,
};
use agent_settings::{AgentSettings, CompletionMode};
use assistant_context_editor::language_model_selector::ToggleModelSelector;
use buffer_diff::BufferDiff;
use client::UserStore;
use collections::{HashMap, HashSet};

View file

@ -0,0 +1,352 @@
use crate::text_thread_editor::TextThreadEditor;
use anyhow::Result;
pub use assistant_slash_command::SlashCommand;
use assistant_slash_command::{AfterCompletion, SlashCommandLine, SlashCommandWorkingSet};
use editor::{CompletionProvider, Editor, ExcerptId};
use fuzzy::{StringMatchCandidate, match_strings};
use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity, Window};
use language::{Anchor, Buffer, ToPoint};
use parking_lot::Mutex;
use project::{CompletionIntent, CompletionSource, lsp_store::CompletionDocumentation};
use rope::Point;
use std::{
ops::Range,
sync::{
Arc,
atomic::{AtomicBool, Ordering::SeqCst},
},
};
use workspace::Workspace;
pub struct SlashCommandCompletionProvider {
cancel_flag: Mutex<Arc<AtomicBool>>,
slash_commands: Arc<SlashCommandWorkingSet>,
editor: Option<WeakEntity<TextThreadEditor>>,
workspace: Option<WeakEntity<Workspace>>,
}
impl SlashCommandCompletionProvider {
pub fn new(
slash_commands: Arc<SlashCommandWorkingSet>,
editor: Option<WeakEntity<TextThreadEditor>>,
workspace: Option<WeakEntity<Workspace>>,
) -> Self {
Self {
cancel_flag: Mutex::new(Arc::new(AtomicBool::new(false))),
slash_commands,
editor,
workspace,
}
}
fn complete_command_name(
&self,
command_name: &str,
command_range: Range<Anchor>,
name_range: Range<Anchor>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Vec<project::CompletionResponse>>> {
let slash_commands = self.slash_commands.clone();
let candidates = slash_commands
.command_names(cx)
.into_iter()
.enumerate()
.map(|(ix, def)| StringMatchCandidate::new(ix, &def))
.collect::<Vec<_>>();
let command_name = command_name.to_string();
let editor = self.editor.clone();
let workspace = self.workspace.clone();
window.spawn(cx, async move |cx| {
let matches = match_strings(
&candidates,
&command_name,
true,
true,
usize::MAX,
&Default::default(),
cx.background_executor().clone(),
)
.await;
cx.update(|_, cx| {
let completions = matches
.into_iter()
.filter_map(|mat| {
let command = slash_commands.command(&mat.string, cx)?;
let mut new_text = mat.string.clone();
let requires_argument = command.requires_argument();
let accepts_arguments = command.accepts_arguments();
if requires_argument || accepts_arguments {
new_text.push(' ');
}
let confirm =
editor
.clone()
.zip(workspace.clone())
.map(|(editor, workspace)| {
let command_name = mat.string.clone();
let command_range = command_range.clone();
let editor = editor.clone();
let workspace = workspace.clone();
Arc::new(
move |intent: CompletionIntent,
window: &mut Window,
cx: &mut App| {
if !requires_argument
&& (!accepts_arguments || intent.is_complete())
{
editor
.update(cx, |editor, cx| {
editor.run_command(
command_range.clone(),
&command_name,
&[],
true,
workspace.clone(),
window,
cx,
);
})
.ok();
false
} else {
requires_argument || accepts_arguments
}
},
) as Arc<_>
});
Some(project::Completion {
replace_range: name_range.clone(),
documentation: Some(CompletionDocumentation::SingleLine(
command.description().into(),
)),
new_text,
label: command.label(cx),
icon_path: None,
insert_text_mode: None,
confirm,
source: CompletionSource::Custom,
})
})
.collect();
vec![project::CompletionResponse {
completions,
is_incomplete: false,
}]
})
})
}
fn complete_command_argument(
&self,
command_name: &str,
arguments: &[String],
command_range: Range<Anchor>,
argument_range: Range<Anchor>,
last_argument_range: Range<Anchor>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Vec<project::CompletionResponse>>> {
let new_cancel_flag = Arc::new(AtomicBool::new(false));
let mut flag = self.cancel_flag.lock();
flag.store(true, SeqCst);
*flag = new_cancel_flag.clone();
if let Some(command) = self.slash_commands.command(command_name, cx) {
let completions = command.complete_argument(
arguments,
new_cancel_flag.clone(),
self.workspace.clone(),
window,
cx,
);
let command_name: Arc<str> = command_name.into();
let editor = self.editor.clone();
let workspace = self.workspace.clone();
let arguments = arguments.to_vec();
cx.background_spawn(async move {
let completions = completions
.await?
.into_iter()
.map(|new_argument| {
let confirm =
editor
.clone()
.zip(workspace.clone())
.map(|(editor, workspace)| {
Arc::new({
let mut completed_arguments = arguments.clone();
if new_argument.replace_previous_arguments {
completed_arguments.clear();
} else {
completed_arguments.pop();
}
completed_arguments.push(new_argument.new_text.clone());
let command_range = command_range.clone();
let command_name = command_name.clone();
move |intent: CompletionIntent,
window: &mut Window,
cx: &mut App| {
if new_argument.after_completion.run()
|| intent.is_complete()
{
editor
.update(cx, |editor, cx| {
editor.run_command(
command_range.clone(),
&command_name,
&completed_arguments,
true,
workspace.clone(),
window,
cx,
);
})
.ok();
false
} else {
!new_argument.after_completion.run()
}
}
}) as Arc<_>
});
let mut new_text = new_argument.new_text.clone();
if new_argument.after_completion == AfterCompletion::Continue {
new_text.push(' ');
}
project::Completion {
replace_range: if new_argument.replace_previous_arguments {
argument_range.clone()
} else {
last_argument_range.clone()
},
label: new_argument.label,
icon_path: None,
new_text,
documentation: None,
confirm,
insert_text_mode: None,
source: CompletionSource::Custom,
}
})
.collect();
Ok(vec![project::CompletionResponse {
completions,
// TODO: Could have slash commands indicate whether their completions are incomplete.
is_incomplete: true,
}])
})
} else {
Task::ready(Ok(vec![project::CompletionResponse {
completions: Vec::new(),
is_incomplete: true,
}]))
}
}
}
impl CompletionProvider for SlashCommandCompletionProvider {
fn completions(
&self,
_excerpt_id: ExcerptId,
buffer: &Entity<Buffer>,
buffer_position: Anchor,
_: editor::CompletionContext,
window: &mut Window,
cx: &mut Context<Editor>,
) -> Task<Result<Vec<project::CompletionResponse>>> {
let Some((name, arguments, command_range, last_argument_range)) =
buffer.update(cx, |buffer, _cx| {
let position = buffer_position.to_point(buffer);
let line_start = Point::new(position.row, 0);
let mut lines = buffer.text_for_range(line_start..position).lines();
let line = lines.next()?;
let call = SlashCommandLine::parse(line)?;
let command_range_start = Point::new(position.row, call.name.start as u32 - 1);
let command_range_end = Point::new(
position.row,
call.arguments.last().map_or(call.name.end, |arg| arg.end) as u32,
);
let command_range = buffer.anchor_before(command_range_start)
..buffer.anchor_after(command_range_end);
let name = line[call.name.clone()].to_string();
let (arguments, last_argument_range) = if let Some(argument) = call.arguments.last()
{
let last_arg_start =
buffer.anchor_before(Point::new(position.row, argument.start as u32));
let first_arg_start = call.arguments.first().expect("we have the last element");
let first_arg_start = buffer
.anchor_before(Point::new(position.row, first_arg_start.start as u32));
let arguments = call
.arguments
.into_iter()
.filter_map(|argument| Some(line.get(argument)?.to_string()))
.collect::<Vec<_>>();
let argument_range = first_arg_start..buffer_position;
(
Some((arguments, argument_range)),
last_arg_start..buffer_position,
)
} else {
let start =
buffer.anchor_before(Point::new(position.row, call.name.start as u32));
(None, start..buffer_position)
};
Some((name, arguments, command_range, last_argument_range))
})
else {
return Task::ready(Ok(vec![project::CompletionResponse {
completions: Vec::new(),
is_incomplete: false,
}]));
};
if let Some((arguments, argument_range)) = arguments {
self.complete_command_argument(
&name,
&arguments,
command_range,
argument_range,
last_argument_range,
window,
cx,
)
} else {
self.complete_command_name(&name, command_range, last_argument_range, window, cx)
}
}
fn is_completion_trigger(
&self,
buffer: &Entity<Buffer>,
position: language::Anchor,
_text: &str,
_trigger_in_words: bool,
_menu_is_open: bool,
cx: &mut Context<Editor>,
) -> bool {
let buffer = buffer.read(cx);
let position = position.to_point(buffer);
let line_start = Point::new(position.row, 0);
let mut lines = buffer.text_for_range(line_start..position).lines();
if let Some(line) = lines.next() {
SlashCommandLine::parse(line).is_some()
} else {
false
}
}
fn sort_completions(&self) -> bool {
false
}
}

View file

@ -0,0 +1,352 @@
use crate::text_thread_editor::TextThreadEditor;
use assistant_slash_command::SlashCommandWorkingSet;
use gpui::{AnyElement, AnyView, DismissEvent, SharedString, Task, WeakEntity};
use picker::{Picker, PickerDelegate, PickerEditorPosition};
use std::sync::Arc;
use ui::{ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger, Tooltip, prelude::*};
#[derive(IntoElement)]
pub(super) struct SlashCommandSelector<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
working_set: Arc<SlashCommandWorkingSet>,
active_context_editor: WeakEntity<TextThreadEditor>,
trigger: T,
tooltip: TT,
}
#[derive(Clone)]
struct SlashCommandInfo {
name: SharedString,
description: SharedString,
args: Option<SharedString>,
icon: IconName,
}
#[derive(Clone)]
enum SlashCommandEntry {
Info(SlashCommandInfo),
Advert {
name: SharedString,
renderer: fn(&mut Window, &mut App) -> AnyElement,
on_confirm: fn(&mut Window, &mut App),
},
}
impl AsRef<str> for SlashCommandEntry {
fn as_ref(&self) -> &str {
match self {
SlashCommandEntry::Info(SlashCommandInfo { name, .. })
| SlashCommandEntry::Advert { name, .. } => name,
}
}
}
pub(crate) struct SlashCommandDelegate {
all_commands: Vec<SlashCommandEntry>,
filtered_commands: Vec<SlashCommandEntry>,
active_context_editor: WeakEntity<TextThreadEditor>,
selected_index: usize,
}
impl<T, TT> SlashCommandSelector<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
pub(crate) fn new(
working_set: Arc<SlashCommandWorkingSet>,
active_context_editor: WeakEntity<TextThreadEditor>,
trigger: T,
tooltip: TT,
) -> Self {
SlashCommandSelector {
working_set,
active_context_editor,
trigger,
tooltip,
}
}
}
impl PickerDelegate for SlashCommandDelegate {
type ListItem = ListItem;
fn match_count(&self) -> usize {
self.filtered_commands.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
self.selected_index = ix.min(self.filtered_commands.len().saturating_sub(1));
cx.notify();
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Select a command...".into()
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let all_commands = self.all_commands.clone();
cx.spawn_in(window, async move |this, cx| {
let filtered_commands = cx
.background_spawn(async move {
if query.is_empty() {
all_commands
} else {
all_commands
.into_iter()
.filter(|model_info| {
model_info
.as_ref()
.to_lowercase()
.contains(&query.to_lowercase())
})
.collect()
}
})
.await;
this.update_in(cx, |this, window, cx| {
this.delegate.filtered_commands = filtered_commands;
this.delegate.set_selected_index(0, window, cx);
cx.notify();
})
.ok();
})
}
fn separators_after_indices(&self) -> Vec<usize> {
let mut ret = vec![];
let mut previous_is_advert = false;
for (index, command) in self.filtered_commands.iter().enumerate() {
if previous_is_advert {
if let SlashCommandEntry::Info(_) = command {
previous_is_advert = false;
debug_assert_ne!(
index, 0,
"index cannot be zero, as we can never have a separator at 0th position"
);
ret.push(index - 1);
}
} else {
if let SlashCommandEntry::Advert { .. } = command {
previous_is_advert = true;
if index != 0 {
ret.push(index - 1);
}
}
}
}
ret
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if let Some(command) = self.filtered_commands.get(self.selected_index) {
match command {
SlashCommandEntry::Info(info) => {
self.active_context_editor
.update(cx, |context_editor, cx| {
context_editor.insert_command(&info.name, window, cx)
})
.ok();
}
SlashCommandEntry::Advert { on_confirm, .. } => {
on_confirm(window, cx);
}
}
cx.emit(DismissEvent);
}
}
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
fn editor_position(&self) -> PickerEditorPosition {
PickerEditorPosition::End
}
fn render_match(
&self,
ix: usize,
selected: bool,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let command_info = self.filtered_commands.get(ix)?;
match command_info {
SlashCommandEntry::Info(info) => Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Dense)
.toggle_state(selected)
.tooltip({
let description = info.description.clone();
move |_, cx| cx.new(|_| Tooltip::new(description.clone())).into()
})
.child(
v_flex()
.group(format!("command-entry-label-{ix}"))
.w_full()
.py_0p5()
.min_w(px(250.))
.max_w(px(400.))
.child(
h_flex()
.gap_1p5()
.child(
Icon::new(info.icon)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.child({
let mut label = format!("{}", info.name);
if let Some(args) = info.args.as_ref().filter(|_| selected)
{
label.push_str(&args);
}
Label::new(label)
.single_line()
.size(LabelSize::Small)
.buffer_font(cx)
})
.children(info.args.clone().filter(|_| !selected).map(
|args| {
div()
.child(
Label::new(args)
.single_line()
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx),
)
.visible_on_hover(format!(
"command-entry-label-{ix}"
))
},
)),
)
.child(
Label::new(info.description.clone())
.size(LabelSize::Small)
.color(Color::Muted)
.truncate(),
),
),
),
SlashCommandEntry::Advert { renderer, .. } => Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Dense)
.toggle_state(selected)
.child(renderer(window, cx)),
),
}
}
}
impl<T, TT> RenderOnce for SlashCommandSelector<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let all_models = self
.working_set
.featured_command_names(cx)
.into_iter()
.filter_map(|command_name| {
let command = self.working_set.command(&command_name, cx)?;
let menu_text = SharedString::from(Arc::from(command.menu_text()));
let label = command.label(cx);
let args = label.filter_range.end.ne(&label.text.len()).then(|| {
SharedString::from(
label.text[label.filter_range.end..label.text.len()].to_owned(),
)
});
Some(SlashCommandEntry::Info(SlashCommandInfo {
name: command_name.into(),
description: menu_text,
args,
icon: command.icon(),
}))
})
.chain([SlashCommandEntry::Advert {
name: "create-your-command".into(),
renderer: |_, cx| {
v_flex()
.w_full()
.child(
h_flex()
.w_full()
.font_buffer(cx)
.items_center()
.justify_between()
.child(
h_flex()
.items_center()
.gap_1p5()
.child(Icon::new(IconName::Plus).size(IconSize::XSmall))
.child(
Label::new("create-your-command")
.size(LabelSize::Small)
.buffer_font(cx),
),
)
.child(
Icon::new(IconName::ArrowUpRight)
.size(IconSize::XSmall)
.color(Color::Muted),
),
)
.child(
Label::new("Create your custom command")
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any_element()
},
on_confirm: |_, cx| cx.open_url("https://zed.dev/docs/extensions/slash-commands"),
}])
.collect::<Vec<_>>();
let delegate = SlashCommandDelegate {
all_commands: all_models.clone(),
active_context_editor: self.active_context_editor.clone(),
filtered_commands: all_models,
selected_index: 0,
};
let picker_view = cx.new(|cx| {
let picker =
Picker::uniform_list(delegate, window, cx).max_height(Some(rems(20.).into()));
picker
});
let handle = self
.active_context_editor
.read_with(cx, |this, _| this.slash_menu_handle.clone())
.ok();
PopoverMenu::new("model-switcher")
.menu(move |_window, _cx| Some(picker_view.clone()))
.trigger_with_tooltip(self.trigger, self.tooltip)
.attach(gpui::Corner::TopLeft)
.anchor(gpui::Corner::BottomLeft)
.offset(gpui::Point {
x: px(0.0),
y: px(-2.0),
})
.when_some(handle, |this, handle| this.with_handle(handle))
}
}

File diff suppressed because it is too large Load diff