From c5b22eee2db31fb59e3a7e052eef0c092821f64f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 4 Jun 2024 18:36:54 +0200 Subject: [PATCH] Polish prompt library UX (#12647) This could still use some improvement UI-wise but the user experience should be a lot better. - [x] Show in "Window" application menu - [x] Load prompt as it's selected in the picker - [x] Refocus picker on `esc` - [x] When creating a new prompt, if a new prompt already exists and is unedited, activate it instead - [x] Add `/default` command - [x] Evaluate /commands on prompt insertion - [x] Autocomplete /commands (but don't evaluate) during prompt editing - [x] Show token count using the settings model, right-aligned in the editor - [x] Picker - [x] Sorted alpha - [x] 2 sublists - Default - Empty state: Star a prompt to add it to your default prompt - Otherwise show prompts with star on hover - All - Move prompts with star on hover Release Notes: - N/A --- Cargo.lock | 10 - assets/icons/zed_assistant_filled.svg | 5 + crates/assistant/Cargo.toml | 1 - crates/assistant/src/assistant.rs | 17 +- crates/assistant/src/assistant_panel.rs | 270 ++++---- crates/assistant/src/prompt_library.rs | 625 +++++++++++++----- crates/assistant/src/slash_command.rs | 3 + .../src/slash_command/active_command.rs | 1 + .../src/slash_command/default_command.rs | 81 +++ .../src/slash_command/fetch_command.rs | 1 + .../src/slash_command/file_command.rs | 1 + .../src/slash_command/project_command.rs | 1 + .../src/slash_command/prompt_command.rs | 18 +- .../src/slash_command/rustdoc_command.rs | 1 + .../src/slash_command/search_command.rs | 6 +- .../src/slash_command/tabs_command.rs | 6 +- .../src/assistant_slash_command.rs | 1 + .../extension/src/extension_slash_command.rs | 1 + crates/gpui/src/platform/mac/window.rs | 2 +- crates/picker/src/picker.rs | 7 +- crates/ui/src/components/icon.rs | 2 + .../ui/src/components/list/list_sub_header.rs | 14 +- 22 files changed, 716 insertions(+), 358 deletions(-) create mode 100644 assets/icons/zed_assistant_filled.svg create mode 100644 crates/assistant/src/slash_command/default_command.rs diff --git a/Cargo.lock b/Cargo.lock index 1c0d673f2b..fb6ad1f833 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,7 +339,6 @@ dependencies = [ "anthropic", "anyhow", "assistant_slash_command", - "async-watch", "cargo_toml", "chrono", "client", @@ -824,15 +823,6 @@ dependencies = [ "tungstenite 0.16.0", ] -[[package]] -name = "async-watch" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a078faf4e27c0c6cc0efb20e5da59dcccc04968ebf2801d8e0b2195124cdcdb2" -dependencies = [ - "event-listener 2.5.3", -] - [[package]] name = "async_zip" version = "0.0.17" diff --git a/assets/icons/zed_assistant_filled.svg b/assets/icons/zed_assistant_filled.svg new file mode 100644 index 0000000000..8d16fd9849 --- /dev/null +++ b/assets/icons/zed_assistant_filled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index 57ca921802..029da5d553 100644 --- a/crates/assistant/Cargo.toml +++ b/crates/assistant/Cargo.toml @@ -16,7 +16,6 @@ doctest = false anyhow.workspace = true anthropic = { workspace = true, features = ["schemars"] } assistant_slash_command.workspace = true -async-watch.workspace = true cargo_toml.workspace = true chrono.workspace = true client.workspace = true diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index 3f3ecbc862..d3a4b66532 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -19,14 +19,13 @@ use command_palette_hooks::CommandPaletteFilter; pub(crate) use completion_provider::*; use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal}; pub(crate) use model_selector::*; -use prompt_library::PromptStore; pub(crate) use saved_conversation::*; use semantic_index::{CloudEmbeddingProvider, SemanticIndex}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; use slash_command::{ - active_command, fetch_command, file_command, project_command, prompt_command, rustdoc_command, - search_command, tabs_command, + active_command, default_command, fetch_command, file_command, project_command, prompt_command, + rustdoc_command, search_command, tabs_command, }; use std::{ fmt::{self, Display}, @@ -303,18 +302,10 @@ fn register_slash_commands(cx: &mut AppContext) { slash_command_registry.register_command(tabs_command::TabsSlashCommand, true); slash_command_registry.register_command(project_command::ProjectSlashCommand, true); slash_command_registry.register_command(search_command::SearchSlashCommand, true); + slash_command_registry.register_command(prompt_command::PromptSlashCommand, true); + slash_command_registry.register_command(default_command::DefaultSlashCommand, true); slash_command_registry.register_command(rustdoc_command::RustdocSlashCommand, false); slash_command_registry.register_command(fetch_command::FetchSlashCommand, false); - - let store = PromptStore::global(cx); - cx.background_executor() - .spawn(async move { - let store = store.await?; - slash_command_registry - .register_command(prompt_command::PromptSlashCommand::new(store), true); - anyhow::Ok(()) - }) - .detach_and_log_err(cx); } #[cfg(test)] diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index b73d2a89c6..ea895f2021 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1,11 +1,11 @@ use crate::{ assistant_settings::{AssistantDockPosition, AssistantSettings}, codegen::{self, Codegen, CodegenKind}, - prompt_library::{open_prompt_library, PromptMetadata, PromptStore}, + prompt_library::open_prompt_library, prompts::generate_content_prompt, search::*, slash_command::{ - prompt_command::PromptPlaceholder, SlashCommandCompletionProvider, SlashCommandLine, + default_command::DefaultSlashCommand, SlashCommandCompletionProvider, SlashCommandLine, SlashCommandRegistry, }, ApplyEdit, Assist, CompletionProvider, ConfirmCommand, CycleMessageRole, InlineAssist, @@ -14,7 +14,7 @@ use crate::{ SavedMessage, Split, ToggleFocus, ToggleHistory, ToggleModelSelector, }; use anyhow::{anyhow, Result}; -use assistant_slash_command::{SlashCommandOutput, SlashCommandOutputSection}; +use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandOutputSection}; use client::telemetry::Telemetry; use collections::{hash_map, BTreeSet, HashMap, HashSet, VecDeque}; use editor::{actions::ShowCompletions, GutterDimensions}; @@ -40,10 +40,9 @@ use gpui::{ Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace, WindowContext, }; -use language::LspAdapterDelegate; use language::{ language_settings::SoftWrap, AnchorRangeExt, AutoindentMode, Buffer, LanguageRegistry, - OffsetRangeExt as _, Point, ToOffset as _, + LspAdapterDelegate, OffsetRangeExt as _, Point, ToOffset as _, }; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; @@ -118,14 +117,6 @@ pub struct AssistantPanel { _watch_saved_conversations: Task>, authentication_prompt: Option, model_menu_handle: PopoverMenuHandle, - default_prompt: DefaultPrompt, - _watch_prompt_store: Task<()>, -} - -#[derive(Default)] -struct DefaultPrompt { - text: String, - sections: Vec>, } struct ActiveConversationEditor { @@ -146,8 +137,6 @@ impl AssistantPanel { .await .log_err() .unwrap_or_default(); - let prompt_store = cx.update(|cx| PromptStore::global(cx))?.await?; - let default_prompts = prompt_store.load_default().await?; // TODO: deserialize state. let workspace_handle = workspace.clone(); @@ -173,22 +162,6 @@ impl AssistantPanel { anyhow::Ok(()) }); - let _watch_prompt_store = cx.spawn(|this, mut cx| async move { - let mut updates = prompt_store.updates(); - while updates.changed().await.is_ok() { - let Some(prompts) = prompt_store.load_default().await.log_err() else { - continue; - }; - - if this - .update(&mut cx, |this, _cx| this.update_default_prompt(prompts)) - .is_err() - { - break; - } - } - }); - let toolbar = cx.new_view(|cx| { let mut toolbar = Toolbar::new(); toolbar.set_can_navigate(false, cx); @@ -216,7 +189,7 @@ impl AssistantPanel { }) .detach(); - let mut this = Self { + Self { workspace: workspace_handle, active_conversation_editor: None, show_saved_conversations: false, @@ -239,11 +212,7 @@ impl AssistantPanel { _watch_saved_conversations, authentication_prompt: None, model_menu_handle: PopoverMenuHandle::default(), - default_prompt: DefaultPrompt::default(), - _watch_prompt_store, - }; - this.update_default_prompt(default_prompts); - this + } }) }) }) @@ -266,55 +235,6 @@ impl AssistantPanel { cx.notify(); } - fn update_default_prompt(&mut self, prompts: Vec<(PromptMetadata, String)>) { - self.default_prompt.text.clear(); - self.default_prompt.sections.clear(); - if !prompts.is_empty() { - self.default_prompt.text.push_str("Default Prompt:\n"); - } - - for (metadata, body) in prompts { - let section_start = self.default_prompt.text.len(); - self.default_prompt.text.push_str(&body); - let section_end = self.default_prompt.text.len(); - self.default_prompt - .sections - .push(SlashCommandOutputSection { - range: section_start..section_end, - render_placeholder: Arc::new(move |id, unfold, _cx| { - PromptPlaceholder { - title: metadata - .title - .clone() - .unwrap_or_else(|| SharedString::from("Untitled")), - id, - unfold, - } - .into_any_element() - }), - }); - self.default_prompt.text.push('\n'); - } - self.default_prompt.text.pop(); - - if !self.default_prompt.text.is_empty() { - self.default_prompt.sections.insert( - 0, - SlashCommandOutputSection { - range: 0..self.default_prompt.text.len(), - render_placeholder: Arc::new(move |id, unfold, _cx| { - PromptPlaceholder { - title: "Default".into(), - id, - unfold, - } - .into_any_element() - }), - }, - ) - } - } - fn completion_provider_changed( &mut self, prev_settings_version: usize, @@ -862,7 +782,6 @@ impl AssistantPanel { let editor = cx.new_view(|cx| { ConversationEditor::new( - &self.default_prompt, self.languages.clone(), self.slash_commands.clone(), self.fs.clone(), @@ -1460,7 +1379,9 @@ enum ConversationEvent { updated: Vec, }, SlashCommandFinished { + output_range: Range, sections: Vec>, + run_commands_in_output: bool, }, } @@ -1727,18 +1648,7 @@ impl Conversation { buffer.line_len(row_range.end - 1), )); - let start_ix = match self - .pending_slash_commands - .binary_search_by(|probe| probe.source_range.start.cmp(&start, buffer)) - { - Ok(ix) | Err(ix) => ix, - }; - let end_ix = match self.pending_slash_commands[start_ix..] - .binary_search_by(|probe| probe.source_range.end.cmp(&end, buffer)) - { - Ok(ix) => start_ix + ix + 1, - Err(ix) => start_ix + ix, - }; + let old_range = self.pending_command_indices_for_range(start..end, cx); let mut new_commands = Vec::new(); let mut lines = buffer.text_for_range(start..end).lines(); @@ -1773,9 +1683,7 @@ impl Conversation { offset = lines.offset(); } - let removed_commands = self - .pending_slash_commands - .splice(start_ix..end_ix, new_commands); + let removed_commands = self.pending_slash_commands.splice(old_range, new_commands); removed.extend(removed_commands.map(|command| command.source_range)); } @@ -1849,25 +1757,60 @@ impl Conversation { cx: &mut ModelContext, ) -> Option<&mut PendingSlashCommand> { let buffer = self.buffer.read(cx); - let ix = self + match self .pending_slash_commands - .binary_search_by(|probe| { - if probe.source_range.start.cmp(&position, buffer).is_gt() { - Ordering::Less - } else if probe.source_range.end.cmp(&position, buffer).is_lt() { - Ordering::Greater + .binary_search_by(|probe| probe.source_range.end.cmp(&position, buffer)) + { + Ok(ix) => Some(&mut self.pending_slash_commands[ix]), + Err(ix) => { + let cmd = self.pending_slash_commands.get_mut(ix)?; + if position.cmp(&cmd.source_range.start, buffer).is_ge() + && position.cmp(&cmd.source_range.end, buffer).is_le() + { + Some(cmd) } else { - Ordering::Equal + None } - }) - .ok()?; - self.pending_slash_commands.get_mut(ix) + } + } + } + + fn pending_commands_for_range( + &self, + range: Range, + cx: &AppContext, + ) -> &[PendingSlashCommand] { + let range = self.pending_command_indices_for_range(range, cx); + &self.pending_slash_commands[range] + } + + fn pending_command_indices_for_range( + &self, + range: Range, + cx: &AppContext, + ) -> Range { + let buffer = self.buffer.read(cx); + let start_ix = match self + .pending_slash_commands + .binary_search_by(|probe| probe.source_range.end.cmp(&range.start, &buffer)) + { + Ok(ix) | Err(ix) => ix, + }; + let end_ix = match self + .pending_slash_commands + .binary_search_by(|probe| probe.source_range.start.cmp(&range.end, &buffer)) + { + Ok(ix) => ix + 1, + Err(ix) => ix, + }; + start_ix..end_ix } fn insert_command_output( &mut self, command_range: Range, output: Task>, + insert_trailing_newline: bool, cx: &mut ModelContext, ) { self.reparse_slash_commands(cx); @@ -1878,13 +1821,14 @@ impl Conversation { let output = output.await; this.update(&mut cx, |this, cx| match output { Ok(mut output) => { - if !output.text.ends_with('\n') { + if insert_trailing_newline { output.text.push('\n'); } - let sections = this.buffer.update(cx, |buffer, cx| { + let event = this.buffer.update(cx, |buffer, cx| { let start = command_range.start.to_offset(buffer); let old_end = command_range.end.to_offset(buffer); + let new_end = start + output.text.len(); buffer.edit([(start..old_end, output.text)], None, cx); let mut sections = output @@ -1897,9 +1841,14 @@ impl Conversation { }) .collect::>(); sections.sort_by(|a, b| a.range.cmp(&b.range, buffer)); - sections + ConversationEvent::SlashCommandFinished { + output_range: buffer.anchor_after(start) + ..buffer.anchor_before(new_end), + sections, + run_commands_in_output: output.run_commands_in_text, + } }); - cx.emit(ConversationEvent::SlashCommandFinished { sections }); + cx.emit(event); } Err(error) => { if let Some(pending_command) = @@ -2596,7 +2545,6 @@ pub struct ConversationEditor { impl ConversationEditor { fn new( - default_prompt: &DefaultPrompt, language_registry: Arc, slash_command_registry: Arc, fs: Arc, @@ -2618,31 +2566,7 @@ impl ConversationEditor { let mut this = Self::for_conversation(conversation, fs, workspace, lsp_adapter_delegate, cx); - - if !default_prompt.text.is_empty() { - this.editor - .update(cx, |editor, cx| editor.insert(&default_prompt.text, cx)); - let snapshot = this.conversation.read(cx).buffer.read(cx).text_snapshot(); - this.insert_slash_command_output_sections( - default_prompt - .sections - .iter() - .map(|section| SlashCommandOutputSection { - range: snapshot.anchor_after(section.range.start) - ..snapshot.anchor_before(section.range.end), - render_placeholder: section.render_placeholder.clone(), - }), - cx, - ); - this.split(&Split, cx); - this.conversation.update(cx, |this, _cx| { - this.messages_metadata - .get_mut(&MessageId::default()) - .unwrap() - .role = Role::System; - }); - } - + this.insert_default_prompt(cx); this } @@ -2695,6 +2619,32 @@ impl ConversationEditor { this } + fn insert_default_prompt(&mut self, cx: &mut ViewContext) { + let command_name = DefaultSlashCommand.name(); + self.editor.update(cx, |editor, cx| { + editor.insert(&format!("/{command_name}"), cx) + }); + self.split(&Split, cx); + let command = self.conversation.update(cx, |conversation, cx| { + conversation + .messages_metadata + .get_mut(&MessageId::default()) + .unwrap() + .role = Role::System; + conversation.reparse_slash_commands(cx); + conversation.pending_slash_commands[0].clone() + }); + + self.run_command( + command.source_range, + &command.name, + command.argument.as_deref(), + false, + self.workspace.clone(), + cx, + ); + } + fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { let cursors = self.cursors(cx); @@ -2817,6 +2767,7 @@ impl ConversationEditor { command.source_range, &command.name, command.argument.as_deref(), + true, workspace.clone(), cx, ); @@ -2830,6 +2781,7 @@ impl ConversationEditor { command_range: Range, name: &str, argument: Option<&str>, + insert_trailing_newline: bool, workspace: WeakView, cx: &mut ViewContext, ) { @@ -2838,7 +2790,12 @@ impl ConversationEditor { let argument = argument.map(ToString::to_string); let output = command.run(argument.as_deref(), workspace, lsp_adapter_delegate, cx); self.conversation.update(cx, |conversation, cx| { - conversation.insert_command_output(command_range, output, cx) + conversation.insert_command_output( + command_range, + output, + insert_trailing_newline, + cx, + ) }); } } @@ -2938,6 +2895,7 @@ impl ConversationEditor { command.source_range.clone(), &command.name, command.argument.as_deref(), + false, workspace.clone(), cx, ); @@ -2991,8 +2949,32 @@ impl ConversationEditor { ); }) } - ConversationEvent::SlashCommandFinished { sections } => { + ConversationEvent::SlashCommandFinished { + output_range, + sections, + run_commands_in_output, + } => { self.insert_slash_command_output_sections(sections.iter().cloned(), cx); + + if *run_commands_in_output { + let commands = self.conversation.update(cx, |conversation, cx| { + conversation.reparse_slash_commands(cx); + conversation + .pending_commands_for_range(output_range.clone(), cx) + .to_vec() + }); + + for command in commands { + self.run_command( + command.source_range, + &command.name, + command.argument.as_deref(), + false, + self.workspace.clone(), + cx, + ); + } + } } } } diff --git a/crates/assistant/src/prompt_library.rs b/crates/assistant/src/prompt_library.rs index 6426a80aa7..fdeeb4ea85 100644 --- a/crates/assistant/src/prompt_library.rs +++ b/crates/assistant/src/prompt_library.rs @@ -1,33 +1,40 @@ +use crate::{ + slash_command::SlashCommandLine, CompletionProvider, LanguageModelRequest, + LanguageModelRequestMessage, Role, +}; use anyhow::{anyhow, Result}; +use assistant_slash_command::SlashCommandRegistry; use chrono::{DateTime, Utc}; use collections::HashMap; -use editor::{Editor, EditorEvent}; +use editor::{actions::Tab, Editor, EditorEvent}; use futures::{ future::{self, BoxFuture, Shared}, FutureExt, }; -use fuzzy::StringMatchCandidate; +use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ - actions, point, size, AppContext, BackgroundExecutor, Bounds, DevicePixels, Empty, - EventEmitter, Global, PromptLevel, ReadGlobal, Subscription, Task, TitlebarOptions, View, - WindowBounds, WindowHandle, WindowOptions, + actions, point, size, AnyElement, AppContext, BackgroundExecutor, Bounds, DevicePixels, + EventEmitter, Global, Model, PromptLevel, ReadGlobal, Subscription, Task, TitlebarOptions, + View, WindowBounds, WindowHandle, WindowOptions, }; use heed::{types::SerdeBincode, Database, RoTxn}; -use language::{language_settings::SoftWrap, Buffer, LanguageRegistry}; +use language::{ + language_settings::SoftWrap, Buffer, Documentation, LanguageRegistry, LanguageServerId, Point, + ToPoint as _, +}; use parking_lot::RwLock; use picker::{Picker, PickerDelegate}; use rope::Rope; use serde::{Deserialize, Serialize}; use std::{ - cmp::Reverse, future::Future, path::PathBuf, sync::{atomic::AtomicBool, Arc}, time::Duration, }; use ui::{ - div, prelude::*, IconButtonShape, ListItem, ListItemSpacing, ParentElement, Render, - SharedString, Styled, TitleBar, Tooltip, ViewContext, VisualContext, + div, prelude::*, IconButtonShape, ListHeader, ListItem, ListItemSpacing, ListSubHeader, + ParentElement, Render, SharedString, Styled, TitleBar, Tooltip, ViewContext, VisualContext, }; use util::{paths::PROMPTS_DIR, ResultExt, TryFutureExt}; use uuid::Uuid; @@ -80,7 +87,7 @@ pub fn open_prompt_library( cx.open_window( WindowOptions { titlebar: Some(TitlebarOptions { - title: None, + title: Some("Prompt Library".into()), appears_transparent: true, traffic_light_position: Some(point(px(9.0), px(9.0))), }), @@ -106,6 +113,8 @@ pub struct PromptLibrary { struct PromptEditor { editor: View, + token_count: Option, + pending_token_count: Task>, next_body_to_save: Option, pending_save: Option>>, _subscription: Subscription, @@ -114,30 +123,54 @@ struct PromptEditor { struct PromptPickerDelegate { store: Arc, selected_index: usize, - matches: Vec, + entries: Vec, } enum PromptPickerEvent { + Selected { prompt_id: PromptId }, Confirmed { prompt_id: PromptId }, Deleted { prompt_id: PromptId }, ToggledDefault { prompt_id: PromptId }, } +#[derive(Debug)] +enum PromptPickerEntry { + DefaultPromptsHeader, + DefaultPromptsEmpty, + AllPromptsHeader, + AllPromptsEmpty, + Prompt(PromptMetadata), +} + +impl PromptPickerEntry { + fn prompt_id(&self) -> Option { + match self { + PromptPickerEntry::Prompt(metadata) => Some(metadata.id), + _ => None, + } + } +} + impl EventEmitter for Picker {} impl PickerDelegate for PromptPickerDelegate { - type ListItem = ListItem; + type ListItem = AnyElement; fn match_count(&self) -> usize { - self.matches.len() + self.entries.len() } fn selected_index(&self) -> usize { self.selected_index } - fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext>) { + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>) { self.selected_index = ix; + if let Some(PromptPickerEntry::Prompt(prompt)) = self.entries.get(self.selected_index) { + cx.emit(PromptPickerEvent::Selected { + prompt_id: prompt.id, + }); + } } fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { @@ -146,11 +179,49 @@ impl PickerDelegate for PromptPickerDelegate { fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { let search = self.store.search(query); + let prev_prompt_id = self + .entries + .get(self.selected_index) + .and_then(|mat| mat.prompt_id()); cx.spawn(|this, mut cx| async move { - let matches = search.await; + let (entries, selected_index) = cx + .background_executor() + .spawn(async move { + let prompts = search.await; + let (default_prompts, prompts) = prompts + .into_iter() + .partition::, _>(|prompt| prompt.default); + + let mut entries = Vec::new(); + entries.push(PromptPickerEntry::DefaultPromptsHeader); + if default_prompts.is_empty() { + entries.push(PromptPickerEntry::DefaultPromptsEmpty); + } else { + entries.extend(default_prompts.into_iter().map(PromptPickerEntry::Prompt)); + } + + entries.push(PromptPickerEntry::AllPromptsHeader); + if prompts.is_empty() { + entries.push(PromptPickerEntry::AllPromptsEmpty); + } else { + entries.extend(prompts.into_iter().map(PromptPickerEntry::Prompt)); + } + + let selected_index = prev_prompt_id + .and_then(|prev_prompt_id| { + entries + .iter() + .position(|entry| entry.prompt_id() == Some(prev_prompt_id)) + }) + .or_else(|| entries.iter().position(|entry| entry.prompt_id().is_some())) + .unwrap_or(0); + (entries, selected_index) + }) + .await; + this.update(&mut cx, |this, cx| { - this.delegate.selected_index = 0; - this.delegate.matches = matches; + this.delegate.entries = entries; + this.delegate.set_selected_index(selected_index, cx); cx.notify(); }) .ok(); @@ -158,7 +229,7 @@ impl PickerDelegate for PromptPickerDelegate { } fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { - if let Some(prompt) = self.matches.get(self.selected_index) { + if let Some(PromptPickerEntry::Prompt(prompt)) = self.entries.get(self.selected_index) { cx.emit(PromptPickerEvent::Confirmed { prompt_id: prompt.id, }); @@ -173,61 +244,80 @@ impl PickerDelegate for PromptPickerDelegate { selected: bool, cx: &mut ViewContext>, ) -> Option { - let prompt = self.matches.get(ix)?; - let default = prompt.default; - let prompt_id = prompt.id; - Some( - ListItem::new(ix) + let prompt = self.entries.get(ix)?; + let element = match prompt { + PromptPickerEntry::DefaultPromptsHeader => ListHeader::new("Default Prompts") .inset(true) - .spacing(ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::ZedAssistant)) .selected(selected) - .child(Label::new( - prompt.title.clone().unwrap_or("Untitled".into()), - )) - .end_slot(if default { - IconButton::new("toggle-default-prompt", IconName::StarFilled) - .shape(IconButtonShape::Square) - .into_any_element() - } else { - Empty.into_any() - }) - .end_hover_slot( - h_flex() - .gap_2() - .child( - IconButton::new("delete-prompt", IconName::Trash) - .shape(IconButtonShape::Square) - .tooltip(move |cx| Tooltip::text("Delete Prompt", cx)) - .on_click(cx.listener(move |_, _, cx| { - cx.emit(PromptPickerEvent::Deleted { prompt_id }) - })), - ) - .child( - IconButton::new( - "toggle-default-prompt", - if default { - IconName::StarFilled - } else { - IconName::Star - }, + .into_any_element(), + PromptPickerEntry::DefaultPromptsEmpty => { + ListSubHeader::new("Star a prompt to add it to the default context") + .inset(true) + .selected(selected) + .into_any_element() + } + PromptPickerEntry::AllPromptsHeader => ListHeader::new("All Prompts") + .inset(true) + .start_slot(Icon::new(IconName::Library)) + .selected(selected) + .into_any_element(), + PromptPickerEntry::AllPromptsEmpty => ListSubHeader::new("No prompts") + .inset(true) + .selected(selected) + .into_any_element(), + PromptPickerEntry::Prompt(prompt) => { + let default = prompt.default; + let prompt_id = prompt.id; + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .selected(selected) + .child(Label::new( + prompt.title.clone().unwrap_or("Untitled".into()), + )) + .end_hover_slot( + h_flex() + .gap_2() + .child( + IconButton::new("delete-prompt", IconName::Trash) + .shape(IconButtonShape::Square) + .tooltip(move |cx| Tooltip::text("Delete Prompt", cx)) + .on_click(cx.listener(move |_, _, cx| { + cx.emit(PromptPickerEvent::Deleted { prompt_id }) + })), ) - .shape(IconButtonShape::Square) - .tooltip(move |cx| { - Tooltip::text( + .child( + IconButton::new( + "toggle-default-prompt", if default { - "Remove from Default Prompt" + IconName::ZedAssistantFilled } else { - "Add to Default Prompt" + IconName::ZedAssistant }, - cx, ) - }) - .on_click(cx.listener(move |_, _, cx| { - cx.emit(PromptPickerEvent::ToggledDefault { prompt_id }) - })), - ), - ), - ) + .shape(IconButtonShape::Square) + .tooltip(move |cx| { + Tooltip::text( + if default { + "Remove from Default Prompt" + } else { + "Add to Default Prompt" + }, + cx, + ) + }) + .on_click(cx.listener( + move |_, _, cx| { + cx.emit(PromptPickerEvent::ToggledDefault { prompt_id }) + }, + )), + ), + ) + .into_any_element() + } + }; + Some(element) } } @@ -240,17 +330,15 @@ impl PromptLibrary { let delegate = PromptPickerDelegate { store: store.clone(), selected_index: 0, - matches: Vec::new(), + entries: Vec::new(), }; let picker = cx.new_view(|cx| { - let picker = Picker::uniform_list(delegate, cx) - .modal(false) - .max_height(None); + let picker = Picker::list(delegate, cx).modal(false).max_height(None); picker.focus(cx); picker }); - let mut this = Self { + Self { store: store.clone(), language_registry, prompt_editors: HashMap::default(), @@ -258,11 +346,7 @@ impl PromptLibrary { pending_load: Task::ready(()), _subscriptions: vec![cx.subscribe(&picker, Self::handle_picker_event)], picker, - }; - if let Some(prompt_id) = store.most_recently_saved() { - this.load_prompt(prompt_id, false, cx); } - this } fn handle_picker_event( @@ -272,6 +356,9 @@ impl PromptLibrary { cx: &mut ViewContext, ) { match event { + PromptPickerEvent::Selected { prompt_id } => { + self.load_prompt(*prompt_id, false, cx); + } PromptPickerEvent::Confirmed { prompt_id } => { self.load_prompt(*prompt_id, true, cx); } @@ -285,6 +372,15 @@ impl PromptLibrary { } pub fn new_prompt(&mut self, cx: &mut ViewContext) { + // If we already have an untitled prompt, use that instead + // of creating a new one. + if let Some(metadata) = self.store.first() { + if metadata.title.is_none() { + self.load_prompt(metadata.id, true, cx); + return; + } + } + let prompt_id = PromptId::new(); let save = self.store.save(prompt_id, None, false, "".into()); self.picker.update(cx, |picker, cx| picker.refresh(cx)); @@ -383,7 +479,7 @@ impl PromptLibrary { .editor .update(cx, |editor, cx| editor.focus(cx)); } - self.active_prompt_id = Some(prompt_id); + self.set_active_prompt(Some(prompt_id), cx); } else { let language_registry = self.language_registry.clone(); let prompt = self.store.load(prompt_id); @@ -404,6 +500,8 @@ impl PromptLibrary { editor.set_show_gutter(false, cx); editor.set_show_wrap_guides(false, cx); editor.set_show_indent_guides(false, cx); + editor + .set_completion_provider(Box::new(SlashCommandCompletionProvider)); if focus { editor.focus(cx); } @@ -419,11 +517,13 @@ impl PromptLibrary { editor, next_body_to_save: None, pending_save: None, + token_count: None, + pending_token_count: Task::ready(None), _subscription, }, ); - this.active_prompt_id = Some(prompt_id); - cx.notify(); + this.set_active_prompt(Some(prompt_id), cx); + this.count_tokens(prompt_id, cx); } Err(error) => { // TODO: we should show the error in the UI. @@ -435,6 +535,32 @@ impl PromptLibrary { } } + fn set_active_prompt(&mut self, prompt_id: Option, cx: &mut ViewContext) { + self.active_prompt_id = prompt_id; + self.picker.update(cx, |picker, cx| { + if let Some(prompt_id) = prompt_id { + if picker + .delegate + .entries + .get(picker.delegate.selected_index()) + .map_or(true, |old_selected_prompt| { + old_selected_prompt.prompt_id() != Some(prompt_id) + }) + { + if let Some(ix) = picker + .delegate + .entries + .iter() + .position(|mat| mat.prompt_id() == Some(prompt_id)) + { + picker.set_selected_index(ix, true, cx); + } + } + } + }); + cx.notify(); + } + pub fn delete_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext) { if let Some(metadata) = self.store.metadata(prompt_id) { let confirmation = cx.prompt( @@ -451,7 +577,7 @@ impl PromptLibrary { if confirmation.await.ok() == Some(0) { this.update(&mut cx, |this, cx| { if this.active_prompt_id == Some(prompt_id) { - this.active_prompt_id = None; + this.set_active_prompt(None, cx); } this.prompt_editors.remove(&prompt_id); this.store.delete(prompt_id).detach_and_log_err(cx); @@ -465,6 +591,19 @@ impl PromptLibrary { } } + fn focus_active_prompt(&mut self, _: &Tab, cx: &mut ViewContext) { + if let Some(active_prompt) = self.active_prompt_id { + self.prompt_editors[&active_prompt] + .editor + .update(cx, |editor, cx| editor.focus(cx)); + cx.stop_propagation(); + } + } + + fn focus_picker(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { + self.picker.update(cx, |picker, cx| picker.focus(cx)); + } + fn handle_prompt_editor_event( &mut self, prompt_id: PromptId, @@ -502,12 +641,53 @@ impl PromptLibrary { }); self.save_prompt(prompt_id, cx); + self.count_tokens(prompt_id, cx); + } + } + + fn count_tokens(&mut self, prompt_id: PromptId, cx: &mut ViewContext) { + if let Some(prompt) = self.prompt_editors.get_mut(&prompt_id) { + let editor = &prompt.editor.read(cx); + let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx); + let body = buffer.as_rope().clone(); + prompt.pending_token_count = cx.spawn(|this, mut cx| { + async move { + const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1); + + cx.background_executor().timer(DEBOUNCE_TIMEOUT).await; + let token_count = cx + .update(|cx| { + let provider = CompletionProvider::global(cx); + let model = provider.model(); + provider.count_tokens( + LanguageModelRequest { + model, + messages: vec![LanguageModelRequestMessage { + role: Role::System, + content: body.to_string(), + }], + stop: Vec::new(), + temperature: 1., + }, + cx, + ) + })? + .await?; + this.update(&mut cx, |this, cx| { + let prompt_editor = this.prompt_editors.get_mut(&prompt_id).unwrap(); + prompt_editor.token_count = Some(token_count); + cx.notify(); + }) + } + .log_err() + }); } } fn render_prompt_list(&mut self, cx: &mut ViewContext) -> impl IntoElement { v_flex() .id("prompt-list") + .capture_action(cx.listener(Self::focus_active_prompt)) .bg(cx.theme().colors().panel_background) .h_full() .w_1_3() @@ -545,64 +725,69 @@ impl PromptLibrary { .min_w_64() .children(self.active_prompt_id.and_then(|prompt_id| { let prompt_metadata = self.store.metadata(prompt_id)?; - let editor = self.prompt_editors[&prompt_id].editor.clone(); + let prompt_editor = &self.prompt_editors[&prompt_id]; Some( - v_flex() + h_flex() .size_full() + .items_start() .child( - h_flex() - .h(TitleBar::height(cx)) - .px(Spacing::Large.rems(cx)) - .justify_end() - .child( - h_flex() - .gap_4() - .child( - IconButton::new( - "toggle-default-prompt", - if prompt_metadata.default { - IconName::StarFilled - } else { - IconName::Star - }, - ) - .shape(IconButtonShape::Square) - .tooltip(move |cx| { - Tooltip::for_action( - if prompt_metadata.default { - "Remove from Default Prompt" - } else { - "Add to Default Prompt" - }, - &ToggleDefaultPrompt, - cx, - ) - }) - .on_click( - |_, cx| { - cx.dispatch_action(Box::new( - ToggleDefaultPrompt, - )); - }, - ), - ) - .child( - IconButton::new("delete-prompt", IconName::Trash) - .shape(IconButtonShape::Square) - .tooltip(move |cx| { - Tooltip::for_action( - "Delete Prompt", - &DeletePrompt, - cx, - ) - }) - .on_click(|_, cx| { - cx.dispatch_action(Box::new(DeletePrompt)); - }), - ), - ), + div() + .on_action(cx.listener(Self::focus_picker)) + .flex_grow() + .h_full() + .pt(Spacing::Large.rems(cx)) + .pl(Spacing::Large.rems(cx)) + .child(prompt_editor.editor.clone()), ) - .child(div().flex_grow().p(Spacing::Large.rems(cx)).child(editor)), + .child( + v_flex() + .w_12() + .py(Spacing::Large.rems(cx)) + .justify_start() + .items_center() + .gap_4() + .child( + IconButton::new( + "toggle-default-prompt", + if prompt_metadata.default { + IconName::ZedAssistantFilled + } else { + IconName::ZedAssistant + }, + ) + .size(ButtonSize::Large) + .shape(IconButtonShape::Square) + .tooltip(move |cx| { + Tooltip::for_action( + if prompt_metadata.default { + "Remove from Default Prompt" + } else { + "Add to Default Prompt" + }, + &ToggleDefaultPrompt, + cx, + ) + }) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(ToggleDefaultPrompt)); + }), + ) + .child( + IconButton::new("delete-prompt", IconName::Trash) + .shape(IconButtonShape::Square) + .tooltip(move |cx| { + Tooltip::for_action("Delete Prompt", &DeletePrompt, cx) + }) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(DeletePrompt)); + }), + ) + .children(prompt_editor.token_count.map(|token_count| { + h_flex() + .justify_center() + .child(Label::new(token_count.to_string())) + })), + ), ) })) } @@ -648,7 +833,6 @@ pub struct PromptStore { bodies: Database, SerdeBincode>, metadata: Database, SerdeBincode>, metadata_cache: RwLock, - updates: (Arc>, async_watch::Receiver<()>), } #[derive(Default)] @@ -668,9 +852,7 @@ impl MetadataCache { cache.metadata.push(metadata.clone()); cache.metadata_by_id.insert(prompt_id, metadata); } - cache - .metadata - .sort_unstable_by_key(|metadata| Reverse(metadata.saved_at)); + cache.sort(); Ok(cache) } @@ -681,13 +863,21 @@ impl MetadataCache { } else { self.metadata.push(metadata); } - self.metadata.sort_by_key(|m| Reverse(m.saved_at)); + self.sort(); } fn remove(&mut self, id: PromptId) { self.metadata.retain(|metadata| metadata.id != id); self.metadata_by_id.remove(&id); } + + fn sort(&mut self) { + self.metadata.sort_unstable_by(|a, b| { + a.title + .cmp(&b.title) + .then_with(|| b.saved_at.cmp(&a.saved_at)) + }); + } } impl PromptStore { @@ -715,23 +905,17 @@ impl PromptStore { let metadata_cache = MetadataCache::from_db(metadata, &txn)?; txn.commit()?; - let (updates_tx, updates_rx) = async_watch::channel(()); Ok(PromptStore { executor, env: db_env, bodies, metadata, metadata_cache: RwLock::new(metadata_cache), - updates: (Arc::new(updates_tx), updates_rx), }) } }) } - pub fn updates(&self) -> async_watch::Receiver<()> { - self.updates.1.clone() - } - pub fn load(&self, id: PromptId) -> Task> { let env = self.env.clone(); let bodies = self.bodies; @@ -743,8 +927,8 @@ impl PromptStore { }) } - pub fn load_default(&self) -> Task>> { - let default_metadatas = self + pub fn default_prompt_metadata(&self) -> Vec { + return self .metadata_cache .read() .metadata @@ -752,23 +936,6 @@ impl PromptStore { .filter(|metadata| metadata.default) .cloned() .collect::>(); - let env = self.env.clone(); - let bodies = self.bodies; - self.executor.spawn(async move { - let txn = env.read_txn()?; - - let mut default_prompts = Vec::new(); - for metadata in default_metadatas { - if let Some(body) = bodies.get(&txn, &metadata.id)? { - if !body.is_empty() { - default_prompts.push((metadata, body)); - } - } - } - - default_prompts.sort_unstable_by_key(|(metadata, _)| metadata.saved_at); - Ok(default_prompts) - }) } pub fn delete(&self, id: PromptId) -> Task> { @@ -854,7 +1021,6 @@ impl PromptStore { let db_connection = self.env.clone(); let bodies = self.bodies; let metadata = self.metadata; - let updates = self.updates.0.clone(); self.executor.spawn(async move { let mut txn = db_connection.write_txn()?; @@ -863,7 +1029,6 @@ impl PromptStore { bodies.put(&mut txn, &id, &body.to_string())?; txn.commit()?; - updates.send(()).ok(); Ok(()) }) @@ -885,24 +1050,18 @@ impl PromptStore { let db_connection = self.env.clone(); let metadata = self.metadata; - let updates = self.updates.0.clone(); self.executor.spawn(async move { let mut txn = db_connection.write_txn()?; metadata.put(&mut txn, &id, &prompt_metadata)?; txn.commit()?; - updates.send(()).ok(); Ok(()) }) } - fn most_recently_saved(&self) -> Option { - self.metadata_cache - .read() - .metadata - .first() - .map(|metadata| metadata.id) + fn first(&self) -> Option { + self.metadata_cache.read().metadata.first().cloned() } } @@ -933,3 +1092,123 @@ fn title_from_body(body: impl IntoIterator) -> Option None } } + +struct SlashCommandCompletionProvider; + +impl editor::CompletionProvider for SlashCommandCompletionProvider { + fn completions( + &self, + buffer: &Model, + buffer_position: language::Anchor, + cx: &mut ViewContext, + ) -> Task>> { + let Some((command_name, name_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)?; + + if call.argument.is_some() { + // Don't autocomplete arguments. + None + } else { + let name = line[call.name.clone()].to_string(); + let name_range_start = Point::new(position.row, call.name.start as u32); + let name_range_end = Point::new(position.row, call.name.end as u32); + let name_range = + buffer.anchor_after(name_range_start)..buffer.anchor_after(name_range_end); + Some((name, name_range)) + } + }) else { + return Task::ready(Ok(Vec::new())); + }; + + let commands = SlashCommandRegistry::global(cx); + let candidates = commands + .command_names() + .into_iter() + .enumerate() + .map(|(ix, def)| StringMatchCandidate { + id: ix, + string: def.to_string(), + char_bag: def.as_ref().into(), + }) + .collect::>(); + let command_name = command_name.to_string(); + cx.spawn(|_, mut cx| async move { + let matches = match_strings( + &candidates, + &command_name, + true, + usize::MAX, + &Default::default(), + cx.background_executor().clone(), + ) + .await; + cx.update(|cx| { + matches + .into_iter() + .filter_map(|mat| { + let command = commands.command(&mat.string)?; + let mut new_text = mat.string.clone(); + let requires_argument = command.requires_argument(); + if requires_argument { + new_text.push(' '); + } + + Some(project::Completion { + old_range: name_range.clone(), + documentation: Some(Documentation::SingleLine(command.description())), + new_text, + label: command.label(cx), + server_id: LanguageServerId(0), + lsp_completion: Default::default(), + show_new_completions_on_confirm: false, + confirm: None, + }) + }) + .collect() + }) + }) + } + + fn resolve_completions( + &self, + _: Model, + _: Vec, + _: Arc>>, + _: &mut ViewContext, + ) -> Task> { + Task::ready(Ok(true)) + } + + fn apply_additional_edits_for_completion( + &self, + _: Model, + _: project::Completion, + _: bool, + _: &mut ViewContext, + ) -> Task>> { + Task::ready(Ok(None)) + } + + fn is_completion_trigger( + &self, + buffer: &Model, + position: language::Anchor, + _text: &str, + _trigger_in_words: bool, + cx: &mut ViewContext, + ) -> 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 + } + } +} diff --git a/crates/assistant/src/slash_command.rs b/crates/assistant/src/slash_command.rs index 98ee1248a0..5506f898c7 100644 --- a/crates/assistant/src/slash_command.rs +++ b/crates/assistant/src/slash_command.rs @@ -17,6 +17,7 @@ use std::{ use workspace::Workspace; pub mod active_command; +pub mod default_command; pub mod fetch_command; pub mod file_command; pub mod project_command; @@ -117,6 +118,7 @@ impl SlashCommandCompletionProvider { command_range.clone(), &command_name, None, + true, workspace.clone(), cx, ); @@ -178,6 +180,7 @@ impl SlashCommandCompletionProvider { command_range.clone(), &command_name, Some(&arg), + true, workspace.clone(), cx, ); diff --git a/crates/assistant/src/slash_command/active_command.rs b/crates/assistant/src/slash_command/active_command.rs index 8ae02d1e6c..14cf86a825 100644 --- a/crates/assistant/src/slash_command/active_command.rs +++ b/crates/assistant/src/slash_command/active_command.rs @@ -96,6 +96,7 @@ impl SlashCommand for ActiveSlashCommand { .into_any_element() }), }], + run_commands_in_text: false, }) }) }); diff --git a/crates/assistant/src/slash_command/default_command.rs b/crates/assistant/src/slash_command/default_command.rs new file mode 100644 index 0000000000..c7656849b1 --- /dev/null +++ b/crates/assistant/src/slash_command/default_command.rs @@ -0,0 +1,81 @@ +use super::{prompt_command::PromptPlaceholder, SlashCommand, SlashCommandOutput}; +use crate::prompt_library::PromptStore; +use anyhow::{anyhow, Result}; +use assistant_slash_command::SlashCommandOutputSection; +use gpui::{AppContext, Task, WeakView}; +use language::LspAdapterDelegate; +use std::{ + fmt::Write, + sync::{atomic::AtomicBool, Arc}, +}; +use ui::prelude::*; +use workspace::Workspace; + +pub(crate) struct DefaultSlashCommand; + +impl SlashCommand for DefaultSlashCommand { + fn name(&self) -> String { + "default".into() + } + + fn description(&self) -> String { + "insert default prompt".into() + } + + fn menu_text(&self) -> String { + "Insert Default Prompt".into() + } + + fn requires_argument(&self) -> bool { + false + } + + fn complete_argument( + &self, + _query: String, + _cancellation_flag: Arc, + _workspace: WeakView, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Err(anyhow!("this command does not require argument"))) + } + + fn run( + self: Arc, + _argument: Option<&str>, + _workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let store = PromptStore::global(cx); + cx.background_executor().spawn(async move { + let store = store.await?; + let prompts = store.default_prompt_metadata(); + + let mut text = String::new(); + writeln!(text, "Default Prompt:").unwrap(); + for prompt in prompts { + if let Some(title) = prompt.title { + writeln!(text, "/prompt {}", title).unwrap(); + } + } + text.pop(); + + Ok(SlashCommandOutput { + sections: vec![SlashCommandOutputSection { + range: 0..text.len(), + render_placeholder: Arc::new(move |id, unfold, _cx| { + PromptPlaceholder { + title: "Default".into(), + id, + unfold, + } + .into_any_element() + }), + }], + text, + run_commands_in_text: true, + }) + }) + } +} diff --git a/crates/assistant/src/slash_command/fetch_command.rs b/crates/assistant/src/slash_command/fetch_command.rs index b28bb77f5d..6bd870c1b6 100644 --- a/crates/assistant/src/slash_command/fetch_command.rs +++ b/crates/assistant/src/slash_command/fetch_command.rs @@ -107,6 +107,7 @@ impl SlashCommand for FetchSlashCommand { .into_any_element() }), }], + run_commands_in_text: false, }) }) } diff --git a/crates/assistant/src/slash_command/file_command.rs b/crates/assistant/src/slash_command/file_command.rs index da4c7f2fdd..f347a87048 100644 --- a/crates/assistant/src/slash_command/file_command.rs +++ b/crates/assistant/src/slash_command/file_command.rs @@ -187,6 +187,7 @@ impl SlashCommand for FileSlashCommand { .into_any_element() }), }], + run_commands_in_text: false, }) }) } diff --git a/crates/assistant/src/slash_command/project_command.rs b/crates/assistant/src/slash_command/project_command.rs index f3c1d18b3a..6d67f6f559 100644 --- a/crates/assistant/src/slash_command/project_command.rs +++ b/crates/assistant/src/slash_command/project_command.rs @@ -148,6 +148,7 @@ impl SlashCommand for ProjectSlashCommand { .into_any_element() }), }], + run_commands_in_text: false, }) }) }); diff --git a/crates/assistant/src/slash_command/prompt_command.rs b/crates/assistant/src/slash_command/prompt_command.rs index 6a1b8e74ad..c837493fb9 100644 --- a/crates/assistant/src/slash_command/prompt_command.rs +++ b/crates/assistant/src/slash_command/prompt_command.rs @@ -8,15 +8,7 @@ use std::sync::{atomic::AtomicBool, Arc}; use ui::{prelude::*, ButtonLike, ElevationIndex}; use workspace::Workspace; -pub(crate) struct PromptSlashCommand { - store: Arc, -} - -impl PromptSlashCommand { - pub fn new(store: Arc) -> Self { - Self { store } - } -} +pub(crate) struct PromptSlashCommand; impl SlashCommand for PromptSlashCommand { fn name(&self) -> String { @@ -42,9 +34,9 @@ impl SlashCommand for PromptSlashCommand { _workspace: WeakView, cx: &mut AppContext, ) -> Task>> { - let store = self.store.clone(); + let store = PromptStore::global(cx); cx.background_executor().spawn(async move { - let prompts = store.search(query).await; + let prompts = store.await?.search(query).await; Ok(prompts .into_iter() .filter_map(|prompt| Some(prompt.title?.to_string())) @@ -63,11 +55,12 @@ impl SlashCommand for PromptSlashCommand { return Task::ready(Err(anyhow!("missing prompt name"))); }; - let store = self.store.clone(); + let store = PromptStore::global(cx); let title = SharedString::from(title.to_string()); let prompt = cx.background_executor().spawn({ let title = title.clone(); async move { + let store = store.await?; let prompt_id = store .id_for_title(&title) .with_context(|| format!("no prompt found with title {:?}", title))?; @@ -91,6 +84,7 @@ impl SlashCommand for PromptSlashCommand { .into_any_element() }), }], + run_commands_in_text: true, }) }) } diff --git a/crates/assistant/src/slash_command/rustdoc_command.rs b/crates/assistant/src/slash_command/rustdoc_command.rs index d9b0fe8ef7..85015798b2 100644 --- a/crates/assistant/src/slash_command/rustdoc_command.rs +++ b/crates/assistant/src/slash_command/rustdoc_command.rs @@ -192,6 +192,7 @@ impl SlashCommand for RustdocSlashCommand { .into_any_element() }), }], + run_commands_in_text: false, }) }) } diff --git a/crates/assistant/src/slash_command/search_command.rs b/crates/assistant/src/slash_command/search_command.rs index fee15bc1c0..54498a35e4 100644 --- a/crates/assistant/src/slash_command/search_command.rs +++ b/crates/assistant/src/slash_command/search_command.rs @@ -181,7 +181,11 @@ impl SlashCommand for SearchSlashCommand { }), }); - SlashCommandOutput { text, sections } + SlashCommandOutput { + text, + sections, + run_commands_in_text: false, + } }) .await; diff --git a/crates/assistant/src/slash_command/tabs_command.rs b/crates/assistant/src/slash_command/tabs_command.rs index 5c6dc0bb2f..2350315a5a 100644 --- a/crates/assistant/src/slash_command/tabs_command.rs +++ b/crates/assistant/src/slash_command/tabs_command.rs @@ -109,7 +109,11 @@ impl SlashCommand for TabsSlashCommand { }); } - Ok(SlashCommandOutput { text, sections }) + Ok(SlashCommandOutput { + text, + sections, + run_commands_in_text: false, + }) }), Err(error) => Task::ready(Err(error)), } diff --git a/crates/assistant_slash_command/src/assistant_slash_command.rs b/crates/assistant_slash_command/src/assistant_slash_command.rs index 554595ca31..c748b4fd96 100644 --- a/crates/assistant_slash_command/src/assistant_slash_command.rs +++ b/crates/assistant_slash_command/src/assistant_slash_command.rs @@ -52,6 +52,7 @@ pub type RenderFoldPlaceholder = Arc< pub struct SlashCommandOutput { pub text: String, pub sections: Vec>, + pub run_commands_in_text: bool, } #[derive(Clone)] diff --git a/crates/extension/src/extension_slash_command.rs b/crates/extension/src/extension_slash_command.rs index 27805c5d21..775c318631 100644 --- a/crates/extension/src/extension_slash_command.rs +++ b/crates/extension/src/extension_slash_command.rs @@ -100,6 +100,7 @@ impl SlashCommand for ExtensionSlashCommand { } }), }], + run_commands_in_text: false, }) }) } diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index e4d5e37dad..758af61cd1 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -656,7 +656,7 @@ impl MacWindow { .as_ref() .and_then(|t| t.title.as_ref().map(AsRef::as_ref)) { - native_window.setTitle_(NSString::alloc(nil).init_str(title)); + window.set_title(title); } native_window.setMovable_(is_movable as BOOL); diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 994cdb74d6..7f87bfda7f 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -236,7 +236,12 @@ impl Picker { /// If `scroll_to_index` is true, the new selected index will be scrolled into view. /// /// If some effect is bound to `selected_index_changed`, it will be executed. - fn set_selected_index(&mut self, ix: usize, scroll_to_index: bool, cx: &mut ViewContext) { + pub fn set_selected_index( + &mut self, + ix: usize, + scroll_to_index: bool, + cx: &mut ViewContext, + ) { let previous_index = self.delegate.selected_index(); self.delegate.set_selected_index(ix, cx); let current_index = self.delegate.selected_index(); diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 571f28f7a0..cb72386b59 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -192,6 +192,7 @@ pub enum IconName { WholeWord, XCircle, ZedAssistant, + ZedAssistantFilled, ZedXCopilot, } @@ -315,6 +316,7 @@ impl IconName { IconName::WholeWord => "icons/word_search.svg", IconName::XCircle => "icons/error.svg", IconName::ZedAssistant => "icons/zed_assistant.svg", + IconName::ZedAssistantFilled => "icons/zed_assistant_filled.svg", IconName::ZedXCopilot => "icons/zed_x_copilot.svg", IconName::ArrowUpFromLine => "icons/arrow_up_from_line.svg", } diff --git a/crates/ui/src/components/list/list_sub_header.rs b/crates/ui/src/components/list/list_sub_header.rs index fddf3e3a89..eab9cbf0f0 100644 --- a/crates/ui/src/components/list/list_sub_header.rs +++ b/crates/ui/src/components/list/list_sub_header.rs @@ -6,6 +6,7 @@ pub struct ListSubHeader { label: SharedString, start_slot: Option, inset: bool, + selected: bool, } impl ListSubHeader { @@ -14,6 +15,7 @@ impl ListSubHeader { label: label.into(), start_slot: None, inset: false, + selected: false, } } @@ -28,12 +30,22 @@ impl ListSubHeader { } } +impl Selectable for ListSubHeader { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } +} + impl RenderOnce for ListSubHeader { - fn render(self, _cx: &mut WindowContext) -> impl IntoElement { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { h_flex().flex_1().w_full().relative().py_1().child( div() .h_6() .when(self.inset, |this| this.px_2()) + .when(self.selected, |this| { + this.bg(cx.theme().colors().ghost_element_selected) + }) .flex() .flex_1() .w_full()