From 7e3ab9acc93d29c12aee4ab381dd723ca303caf4 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 28 May 2024 01:44:54 +0200 Subject: [PATCH] Rework context insertion UX (#12360) - Confirming a completion now runs the command immediately - Hitting `enter` on a line with a command now runs it - The output of commands gets folded away and replaced with a custom placeholder - Eliminated ambient context image Release Notes: - N/A --------- Co-authored-by: Nathan Sobo --- Cargo.lock | 3 +- assets/icons/triangle_right.svg | 1 + assets/keymaps/default-linux.json | 4 +- assets/keymaps/default-macos.json | 4 +- crates/assistant/src/ambient_context.rs | 30 - .../src/ambient_context/current_project.rs | 180 --- .../src/ambient_context/recent_buffers.rs | 147 -- crates/assistant/src/assistant.rs | 10 +- crates/assistant/src/assistant_panel.rs | 1289 +++++++---------- crates/assistant/src/omit_ranges.rs | 101 -- crates/assistant/src/slash_command.rs | 139 +- .../src/slash_command/active_command.rs | 117 ++ .../src/slash_command/current_file_command.rs | 142 -- .../src/slash_command/file_command.rs | 90 +- .../src/slash_command/project_command.rs | 151 ++ .../src/slash_command/prompt_command.rs | 64 +- crates/assistant_slash_command/Cargo.toml | 2 +- .../src/assistant_slash_command.rs | 43 +- .../src/chat_panel/message_editor.rs | 1 + crates/editor/src/display_map/flap_map.rs | 8 +- crates/editor/src/display_map/fold_map.rs | 10 +- crates/editor/src/editor.rs | 6 + crates/editor/src/element.rs | 31 +- crates/extension/Cargo.toml | 2 + crates/extension/src/extension_manifest.rs | 1 + .../extension/src/extension_slash_command.rs | 39 +- crates/extension/src/extension_store.rs | 1 + .../wit/since_v0.0.7/slash-command.wit | 2 + crates/gpui/src/elements/div.rs | 2 + crates/project/src/project.rs | 45 +- crates/ui/src/components/icon.rs | 2 + extensions/gleam/extension.toml | 1 + 32 files changed, 1141 insertions(+), 1527 deletions(-) create mode 100644 assets/icons/triangle_right.svg delete mode 100644 crates/assistant/src/ambient_context.rs delete mode 100644 crates/assistant/src/ambient_context/current_project.rs delete mode 100644 crates/assistant/src/ambient_context/recent_buffers.rs delete mode 100644 crates/assistant/src/omit_ranges.rs create mode 100644 crates/assistant/src/slash_command/active_command.rs delete mode 100644 crates/assistant/src/slash_command/current_file_command.rs create mode 100644 crates/assistant/src/slash_command/project_command.rs diff --git a/Cargo.lock b/Cargo.lock index 6e546ca96639b..91d78e0b7e7ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -434,10 +434,10 @@ dependencies = [ "anyhow", "collections", "derive_more", - "futures 0.3.28", "gpui", "language", "parking_lot", + "workspace", ] [[package]] @@ -3823,6 +3823,7 @@ dependencies = [ "wasmtime", "wasmtime-wasi", "wit-component", + "workspace", ] [[package]] diff --git a/assets/icons/triangle_right.svg b/assets/icons/triangle_right.svg new file mode 100644 index 0000000000000..2c78a316f7cd8 --- /dev/null +++ b/assets/icons/triangle_right.svg @@ -0,0 +1 @@ + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2d7e693b3c75b..67e12b0235582 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -211,7 +211,9 @@ "ctrl-s": "workspace::Save", "ctrl->": "assistant::QuoteSelection", "shift-enter": "assistant::Split", - "ctrl-r": "assistant::CycleMessageRole" + "ctrl-r": "assistant::CycleMessageRole", + "enter": "assistant::ConfirmCommand", + "alt-enter": "editor::Newline" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 09464c3d604ce..94e8998e5a37c 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -227,7 +227,9 @@ "cmd-s": "workspace::Save", "cmd->": "assistant::QuoteSelection", "shift-enter": "assistant::Split", - "ctrl-r": "assistant::CycleMessageRole" + "ctrl-r": "assistant::CycleMessageRole", + "enter": "assistant::ConfirmCommand", + "alt-enter": "editor::Newline" } }, { diff --git a/crates/assistant/src/ambient_context.rs b/crates/assistant/src/ambient_context.rs deleted file mode 100644 index cbb63b6044d48..0000000000000 --- a/crates/assistant/src/ambient_context.rs +++ /dev/null @@ -1,30 +0,0 @@ -mod current_project; -mod recent_buffers; - -pub use current_project::*; -pub use recent_buffers::*; - -#[derive(Default)] -pub struct AmbientContext { - pub recent_buffers: RecentBuffersContext, - pub current_project: CurrentProjectContext, -} - -impl AmbientContext { - pub fn snapshot(&self) -> AmbientContextSnapshot { - AmbientContextSnapshot { - recent_buffers: self.recent_buffers.snapshot.clone(), - } - } -} - -#[derive(Clone, Default, Debug)] -pub struct AmbientContextSnapshot { - pub recent_buffers: RecentBuffersSnapshot, -} - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -pub enum ContextUpdated { - Updating, - Disabled, -} diff --git a/crates/assistant/src/ambient_context/current_project.rs b/crates/assistant/src/ambient_context/current_project.rs deleted file mode 100644 index f89a2a88562f5..0000000000000 --- a/crates/assistant/src/ambient_context/current_project.rs +++ /dev/null @@ -1,180 +0,0 @@ -use std::fmt::Write; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{anyhow, Result}; -use fs::Fs; -use gpui::{AsyncAppContext, ModelContext, Task, WeakModel}; -use project::{Project, ProjectPath}; -use util::ResultExt; - -use crate::ambient_context::ContextUpdated; -use crate::assistant_panel::Conversation; -use crate::{LanguageModelRequestMessage, Role}; - -/// Ambient context about the current project. -pub struct CurrentProjectContext { - pub enabled: bool, - pub message: String, - pub pending_message: Option>, -} - -#[allow(clippy::derivable_impls)] -impl Default for CurrentProjectContext { - fn default() -> Self { - Self { - enabled: false, - message: String::new(), - pending_message: None, - } - } -} - -impl CurrentProjectContext { - /// Returns the [`CurrentProjectContext`] as a message to the language model. - pub fn to_message(&self) -> Option { - self.enabled - .then(|| LanguageModelRequestMessage { - role: Role::System, - content: self.message.clone(), - }) - .filter(|message| !message.content.is_empty()) - } - - /// Updates the [`CurrentProjectContext`] for the given [`Project`]. - pub fn update( - &mut self, - fs: Arc, - project: WeakModel, - cx: &mut ModelContext, - ) -> ContextUpdated { - if !self.enabled { - self.message.clear(); - self.pending_message = None; - cx.notify(); - return ContextUpdated::Disabled; - } - - self.pending_message = Some(cx.spawn(|conversation, mut cx| async move { - const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100); - cx.background_executor().timer(DEBOUNCE_TIMEOUT).await; - - let Some(path_to_cargo_toml) = Self::path_to_cargo_toml(project, &mut cx).log_err() - else { - return; - }; - - let Some(path_to_cargo_toml) = path_to_cargo_toml - .ok_or_else(|| anyhow!("no Cargo.toml")) - .log_err() - else { - return; - }; - - let message_task = cx - .background_executor() - .spawn(async move { Self::build_message(fs, &path_to_cargo_toml).await }); - - if let Some(message) = message_task.await.log_err() { - conversation - .update(&mut cx, |conversation, cx| { - conversation.ambient_context.current_project.message = message; - conversation.count_remaining_tokens(cx); - cx.notify(); - }) - .log_err(); - } - })); - - ContextUpdated::Updating - } - - async fn build_message(fs: Arc, path_to_cargo_toml: &Path) -> Result { - let buffer = fs.load(path_to_cargo_toml).await?; - let cargo_toml: cargo_toml::Manifest = toml::from_str(&buffer)?; - - let mut message = String::new(); - writeln!(message, "You are in a Rust project.")?; - - if let Some(workspace) = cargo_toml.workspace { - writeln!( - message, - "The project is a Cargo workspace with the following members:" - )?; - for member in workspace.members { - writeln!(message, "- {member}")?; - } - - if !workspace.default_members.is_empty() { - writeln!(message, "The default members are:")?; - for member in workspace.default_members { - writeln!(message, "- {member}")?; - } - } - - if !workspace.dependencies.is_empty() { - writeln!( - message, - "The following workspace dependencies are installed:" - )?; - for dependency in workspace.dependencies.keys() { - writeln!(message, "- {dependency}")?; - } - } - } else if let Some(package) = cargo_toml.package { - writeln!( - message, - "The project name is \"{name}\".", - name = package.name - )?; - - let description = package - .description - .as_ref() - .and_then(|description| description.get().ok().cloned()); - if let Some(description) = description.as_ref() { - writeln!(message, "It describes itself as \"{description}\".")?; - } - - if !cargo_toml.dependencies.is_empty() { - writeln!(message, "The following dependencies are installed:")?; - for dependency in cargo_toml.dependencies.keys() { - writeln!(message, "- {dependency}")?; - } - } - } - - Ok(message) - } - - fn path_to_cargo_toml( - project: WeakModel, - cx: &mut AsyncAppContext, - ) -> Result> { - cx.update(|cx| { - let worktree = project.update(cx, |project, _cx| { - project - .worktrees() - .next() - .ok_or_else(|| anyhow!("no worktree")) - })??; - - let path_to_cargo_toml = worktree.update(cx, |worktree, _cx| { - let cargo_toml = worktree.entry_for_path("Cargo.toml")?; - Some(ProjectPath { - worktree_id: worktree.id(), - path: cargo_toml.path.clone(), - }) - }); - let path_to_cargo_toml = path_to_cargo_toml.and_then(|path| { - project - .update(cx, |project, cx| project.absolute_path(&path, cx)) - .ok() - .flatten() - }); - - Ok(path_to_cargo_toml) - })? - } -} diff --git a/crates/assistant/src/ambient_context/recent_buffers.rs b/crates/assistant/src/ambient_context/recent_buffers.rs deleted file mode 100644 index 056fbd11834d2..0000000000000 --- a/crates/assistant/src/ambient_context/recent_buffers.rs +++ /dev/null @@ -1,147 +0,0 @@ -use crate::{assistant_panel::Conversation, LanguageModelRequestMessage, Role}; -use gpui::{ModelContext, Subscription, Task, WeakModel}; -use language::{Buffer, BufferSnapshot, Rope}; -use std::{fmt::Write, path::PathBuf, time::Duration}; - -use super::ContextUpdated; - -pub struct RecentBuffersContext { - pub enabled: bool, - pub buffers: Vec, - pub snapshot: RecentBuffersSnapshot, - pub pending_message: Option>, -} - -pub struct RecentBuffer { - pub buffer: WeakModel, - pub _subscription: Subscription, -} - -impl Default for RecentBuffersContext { - fn default() -> Self { - Self { - enabled: true, - buffers: Vec::new(), - snapshot: RecentBuffersSnapshot::default(), - pending_message: None, - } - } -} - -impl RecentBuffersContext { - pub fn update(&mut self, cx: &mut ModelContext) -> ContextUpdated { - let source_buffers = self - .buffers - .iter() - .filter_map(|recent| { - let (full_path, snapshot) = recent - .buffer - .read_with(cx, |buffer, cx| { - ( - buffer.file().map(|file| file.full_path(cx)), - buffer.snapshot(), - ) - }) - .ok()?; - Some(SourceBufferSnapshot { - full_path, - model: recent.buffer.clone(), - snapshot, - }) - }) - .collect::>(); - - if !self.enabled || source_buffers.is_empty() { - self.snapshot.message = Default::default(); - self.snapshot.source_buffers.clear(); - self.pending_message = None; - cx.notify(); - ContextUpdated::Disabled - } else { - self.pending_message = Some(cx.spawn(|this, mut cx| async move { - const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100); - cx.background_executor().timer(DEBOUNCE_TIMEOUT).await; - - let message = if source_buffers.is_empty() { - Rope::new() - } else { - cx.background_executor() - .spawn({ - let source_buffers = source_buffers.clone(); - async move { message_for_recent_buffers(source_buffers) } - }) - .await - }; - this.update(&mut cx, |this, cx| { - this.ambient_context.recent_buffers.snapshot.source_buffers = source_buffers; - this.ambient_context.recent_buffers.snapshot.message = message; - this.count_remaining_tokens(cx); - cx.notify(); - }) - .ok(); - })); - - ContextUpdated::Updating - } - } - - /// Returns the [`RecentBuffersContext`] as a message to the language model. - pub fn to_message(&self) -> Option { - self.enabled - .then(|| LanguageModelRequestMessage { - role: Role::System, - content: self.snapshot.message.to_string(), - }) - .filter(|message| !message.content.is_empty()) - } -} - -#[derive(Clone, Default, Debug)] -pub struct RecentBuffersSnapshot { - pub message: Rope, - pub source_buffers: Vec, -} - -#[derive(Clone)] -pub struct SourceBufferSnapshot { - pub full_path: Option, - pub model: WeakModel, - pub snapshot: BufferSnapshot, -} - -impl std::fmt::Debug for SourceBufferSnapshot { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SourceBufferSnapshot") - .field("full_path", &self.full_path) - .field("model (entity id)", &self.model.entity_id()) - .field("snapshot (text)", &self.snapshot.text()) - .finish() - } -} - -fn message_for_recent_buffers(buffers: Vec) -> Rope { - let mut message = String::new(); - writeln!( - message, - "The following is a list of recent buffers that the user has opened." - ) - .unwrap(); - - for buffer in buffers { - if let Some(path) = buffer.full_path { - writeln!(message, "```{}", path.display()).unwrap(); - } else { - writeln!(message, "```untitled").unwrap(); - } - - for chunk in buffer.snapshot.chunks(0..buffer.snapshot.len(), false) { - message.push_str(chunk.text); - } - if !message.ends_with('\n') { - message.push('\n'); - } - message.push_str("```\n"); - } - - Rope::from(message.as_str()) -} diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index da1be612c5455..6497da9b8ab44 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -1,17 +1,15 @@ -mod ambient_context; pub mod assistant_panel; pub mod assistant_settings; mod codegen; mod completion_provider; -mod omit_ranges; mod prompts; mod saved_conversation; mod search; mod slash_command; mod streaming_diff; -use ambient_context::AmbientContextSnapshot; pub use assistant_panel::AssistantPanel; + use assistant_settings::{AnthropicModel, AssistantSettings, OpenAiModel, ZedDotDevModel}; use client::{proto, Client}; use command_palette_hooks::CommandPaletteFilter; @@ -38,7 +36,8 @@ actions!( InsertActivePrompt, ToggleIncludeConversation, ToggleHistory, - ApplyEdit + ApplyEdit, + ConfirmCommand ] ); @@ -188,9 +187,6 @@ pub struct LanguageModelChoiceDelta { struct MessageMetadata { role: Role, status: MessageStatus, - // TODO: Delete this - #[serde(skip)] - ambient_context: AmbientContextSnapshot, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index d125b0fc0feaa..dc604df07b132 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1,51 +1,48 @@ -use crate::ambient_context::{AmbientContext, ContextUpdated, RecentBuffer}; use crate::prompts::{generate_content_prompt, PromptLibrary, PromptManager}; use crate::{ - ambient_context::*, assistant_settings::{AssistantDockPosition, AssistantSettings, ZedDotDevModel}, codegen::{self, Codegen, CodegenKind}, - omit_ranges::text_in_range_omitting_ranges, search::*, slash_command::{ - current_file_command, file_command, prompt_command, SlashCommandCleanup, + active_command, file_command, project_command, prompt_command, SlashCommandCompletionProvider, SlashCommandLine, SlashCommandRegistry, }, - ApplyEdit, Assist, CompletionProvider, CycleMessageRole, InlineAssist, LanguageModel, - LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, MessageStatus, - QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, SavedMessage, - Split, ToggleFocus, ToggleHistory, ToggleIncludeConversation, + ApplyEdit, Assist, CompletionProvider, ConfirmCommand, CycleMessageRole, InlineAssist, + LanguageModel, LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, + MessageStatus, QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, + SavedMessage, Split, ToggleFocus, ToggleHistory, ToggleIncludeConversation, }; use anyhow::{anyhow, Result}; +use assistant_slash_command::{RenderFoldPlaceholder, SlashCommandOutput}; use client::telemetry::Telemetry; use collections::{hash_map, HashMap, HashSet, VecDeque}; -use editor::FoldPlaceholder; use editor::{ actions::{FoldAt, MoveDown, MoveUp}, display_map::{ - BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, Flap, FlapId, - ToDisplayPoint, + BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, Flap, ToDisplayPoint, }, scroll::{Autoscroll, AutoscrollStrategy}, Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBufferSnapshot, RowExt, ToOffset as _, ToPoint, }; +use editor::{display_map::FlapId, FoldPlaceholder}; use file_icons::FileIcons; use fs::Fs; use futures::StreamExt; use gpui::{ canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext, - AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Empty, Entity, + AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Empty, EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement, IntoElement, Model, ModelContext, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace, WindowContext, }; -use language::LspAdapterDelegate; use language::{ - language_settings::SoftWrap, AutoindentMode, Buffer, BufferSnapshot, LanguageRegistry, - OffsetRangeExt as _, Point, ToOffset as _, ToPoint as _, + language_settings::SoftWrap, AutoindentMode, Buffer, LanguageRegistry, OffsetRangeExt as _, + Point, ToOffset as _, }; +use language::{LineEnding, LspAdapterDelegate}; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; use project::{Project, ProjectLspAdapterDelegate, ProjectTransaction}; @@ -54,7 +51,7 @@ use settings::Settings; use std::{ cmp::{self, Ordering}, fmt::Write, - iter, mem, + iter, ops::Range, path::PathBuf, sync::Arc, @@ -71,13 +68,10 @@ use uuid::Uuid; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, searchable::Direction, - Event as WorkspaceEvent, Save, Toast, ToggleZoom, Toolbar, Workspace, + Save, Toast, ToggleZoom, Toolbar, Workspace, }; use workspace::{notifications::NotificationId, NewFile}; -const MAX_RECENT_BUFFERS: usize = 3; -const SLASH_COMMAND_DEBOUNCE: Duration = Duration::from_millis(200); - pub fn init(cx: &mut AppContext) { cx.observe_new_views( |workspace: &mut Workspace, _cx: &mut ViewContext| { @@ -205,7 +199,6 @@ impl AssistantPanel { .detach(); let slash_command_registry = SlashCommandRegistry::global(cx); - let window = cx.window_handle().downcast::(); slash_command_registry.register_command(file_command::FileSlashCommand::new( workspace.project().clone(), @@ -213,11 +206,8 @@ impl AssistantPanel { slash_command_registry.register_command( prompt_command::PromptSlashCommand::new(prompt_library.clone()), ); - if let Some(window) = window { - slash_command_registry.register_command( - current_file_command::CurrentFileSlashCommand::new(window), - ); - } + slash_command_registry.register_command(active_command::ActiveSlashCommand); + slash_command_registry.register_command(project_command::ProjectSlashCommand); Self { workspace: workspace_handle, @@ -1145,7 +1135,6 @@ impl AssistantPanel { languages, slash_commands, Some(telemetry), - lsp_adapter_delegate, &mut cx, ) .await?; @@ -1155,7 +1144,13 @@ impl AssistantPanel { .upgrade() .ok_or_else(|| anyhow!("workspace dropped"))?; let editor = cx.new_view(|cx| { - ConversationEditor::for_conversation(conversation, fs, workspace, cx) + ConversationEditor::for_conversation( + conversation, + fs, + workspace, + lsp_adapter_delegate, + cx, + ) }); this.show_conversation(editor, cx); anyhow::Ok(()) @@ -1456,9 +1451,14 @@ enum ConversationEvent { SummaryChanged, EditSuggestionsChanged, StreamedCompletion, - SlashCommandsChanged, - SlashCommandOutputAdded(Range), - SlashCommandOutputRemoved(Range), + PendingSlashCommandsUpdated { + removed: Vec>, + updated: Vec, + }, + SlashCommandFinished { + output_range: Range, + render_placeholder: RenderFoldPlaceholder, + }, } #[derive(Default)] @@ -1467,12 +1467,27 @@ struct Summary { done: bool, } +#[derive(Copy, Clone, Default, Eq, PartialEq, Hash)] +pub struct SlashCommandInvocationId(usize); + +impl SlashCommandInvocationId { + fn post_inc(&mut self) -> Self { + let id = *self; + self.0 += 1; + id + } +} + +struct SlashCommandInvocation { + _pending_output: Task>, +} + pub struct Conversation { id: Option, buffer: Model, - pub(crate) ambient_context: AmbientContext, edit_suggestions: Vec, - slash_command_calls: Vec, + pending_slash_commands: Vec, + edits_since_last_slash_command_parse: language::Subscription, message_anchors: Vec, messages_metadata: HashMap, next_message_id: MessageId, @@ -1484,14 +1499,14 @@ pub struct Conversation { token_count: Option, pending_token_count: Task>, pending_edit_suggestion_parse: Option>, - pending_command_invocation_parse: Option>, pending_save: Task>, path: Option, + invocations: HashMap, + next_invocation_id: SlashCommandInvocationId, _subscriptions: Vec, telemetry: Option>, slash_command_registry: Arc, language_registry: Arc, - lsp_adapter_delegate: Option>, } impl EventEmitter for Conversation {} @@ -1502,7 +1517,6 @@ impl Conversation { language_registry: Arc, slash_command_registry: Arc, telemetry: Option>, - lsp_adapter_delegate: Option>, cx: &mut ModelContext, ) -> Self { let buffer = cx.new_model(|cx| { @@ -1510,15 +1524,16 @@ impl Conversation { buffer.set_language_registry(language_registry.clone()); buffer }); - + let edits_since_last_slash_command_parse = + buffer.update(cx, |buffer, _| buffer.subscribe()); let mut this = Self { id: Some(Uuid::new_v4().to_string()), message_anchors: Default::default(), messages_metadata: Default::default(), next_message_id: Default::default(), - ambient_context: AmbientContext::default(), edit_suggestions: Vec::new(), - slash_command_calls: Vec::new(), + pending_slash_commands: Vec::new(), + edits_since_last_slash_command_parse, summary: None, pending_summary: Task::ready(None), completion_count: Default::default(), @@ -1526,16 +1541,16 @@ impl Conversation { token_count: None, pending_token_count: Task::ready(None), pending_edit_suggestion_parse: None, - pending_command_invocation_parse: None, + next_invocation_id: SlashCommandInvocationId::default(), + invocations: HashMap::default(), model, _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)], pending_save: Task::ready(Ok(())), path: None, buffer, telemetry, - slash_command_registry, language_registry, - lsp_adapter_delegate, + slash_command_registry, }; let message = MessageAnchor { @@ -1548,7 +1563,6 @@ impl Conversation { MessageMetadata { role: Role::User, status: MessageStatus::Done, - ambient_context: AmbientContextSnapshot::default(), }, ); @@ -1587,7 +1601,6 @@ impl Conversation { language_registry: Arc, slash_command_registry: Arc, telemetry: Option>, - lsp_adapter_delegate: Option>, cx: &mut AsyncAppContext, ) -> Result> { let id = match saved_conversation.id { @@ -1620,14 +1633,16 @@ impl Conversation { })?; cx.new_model(move |cx| { + let edits_since_last_slash_command_parse = + buffer.update(cx, |buffer, _| buffer.subscribe()); let mut this = Self { id, message_anchors, messages_metadata: saved_conversation.message_metadata, next_message_id, - ambient_context: AmbientContext::default(), edit_suggestions: Vec::new(), - slash_command_calls: Vec::new(), + pending_slash_commands: Vec::new(), + edits_since_last_slash_command_parse, summary: Some(Summary { text: saved_conversation.summary, done: true, @@ -1637,8 +1652,9 @@ impl Conversation { pending_completions: Default::default(), token_count: None, pending_edit_suggestion_parse: None, - pending_command_invocation_parse: None, pending_token_count: Task::ready(None), + next_invocation_id: SlashCommandInvocationId::default(), + invocations: HashMap::default(), model, _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)], pending_save: Task::ready(Ok(())), @@ -1647,7 +1663,6 @@ impl Conversation { telemetry, language_registry, slash_command_registry, - lsp_adapter_delegate, }; this.set_language(cx); this.reparse_edit_suggestions(cx); @@ -1668,60 +1683,6 @@ impl Conversation { .detach_and_log_err(cx); } - fn toggle_recent_buffers(&mut self, cx: &mut ModelContext) { - self.ambient_context.recent_buffers.enabled = !self.ambient_context.recent_buffers.enabled; - match self.ambient_context.recent_buffers.update(cx) { - ContextUpdated::Updating => {} - ContextUpdated::Disabled => { - self.count_remaining_tokens(cx); - } - } - } - - fn toggle_current_project_context( - &mut self, - fs: Arc, - project: WeakModel, - cx: &mut ModelContext, - ) { - self.ambient_context.current_project.enabled = - !self.ambient_context.current_project.enabled; - match self.ambient_context.current_project.update(fs, project, cx) { - ContextUpdated::Updating => {} - ContextUpdated::Disabled => { - self.count_remaining_tokens(cx); - } - } - } - - fn set_recent_buffers( - &mut self, - buffers: impl IntoIterator>, - cx: &mut ModelContext, - ) { - self.ambient_context.recent_buffers.buffers.clear(); - self.ambient_context - .recent_buffers - .buffers - .extend(buffers.into_iter().map(|buffer| RecentBuffer { - buffer: buffer.downgrade(), - _subscription: cx.observe(&buffer, |this, _, cx| { - match this.ambient_context.recent_buffers.update(cx) { - ContextUpdated::Updating => {} - ContextUpdated::Disabled => { - this.count_remaining_tokens(cx); - } - } - }), - })); - match self.ambient_context.recent_buffers.update(cx) { - ContextUpdated::Updating => {} - ContextUpdated::Disabled => { - self.count_remaining_tokens(cx); - } - } - } - fn handle_buffer_event( &mut self, _: Model, @@ -1731,7 +1692,7 @@ impl Conversation { if *event == language::Event::Edited { self.count_remaining_tokens(cx); self.reparse_edit_suggestions(cx); - self.reparse_slash_command_calls(cx); + self.reparse_slash_commands(cx); cx.emit(ConversationEvent::MessagesEdited); } } @@ -1758,6 +1719,94 @@ impl Conversation { }); } + fn reparse_slash_commands(&mut self, cx: &mut ModelContext) { + let buffer = self.buffer.read(cx); + let mut row_ranges = self + .edits_since_last_slash_command_parse + .consume() + .into_iter() + .map(|edit| { + let start_row = buffer.offset_to_point(edit.new.start).row; + let end_row = buffer.offset_to_point(edit.new.end).row + 1; + start_row..end_row + }) + .peekable(); + + let mut removed = Vec::new(); + let mut updated = Vec::new(); + while let Some(mut row_range) = row_ranges.next() { + while let Some(next_row_range) = row_ranges.peek() { + if row_range.end >= next_row_range.start { + row_range.end = next_row_range.end; + row_ranges.next(); + } else { + break; + } + } + + let start = buffer.anchor_before(Point::new(row_range.start, 0)); + let end = buffer.anchor_after(Point::new( + row_range.end - 1, + 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 mut new_commands = Vec::new(); + let mut lines = buffer.text_for_range(start..end).lines(); + let mut offset = lines.offset(); + while let Some(line) = lines.next() { + if let Some(command_line) = SlashCommandLine::parse(line) { + let name = &line[command_line.name.clone()]; + let argument = command_line.argument.as_ref().and_then(|argument| { + (!argument.is_empty()).then_some(&line[argument.clone()]) + }); + if let Some(command) = self.slash_command_registry.command(name) { + if !command.requires_argument() || argument.is_some() { + let start_ix = offset + command_line.name.start - 1; + let end_ix = offset + + command_line + .argument + .map_or(command_line.name.end, |argument| argument.end); + let source_range = + buffer.anchor_after(start_ix)..buffer.anchor_after(end_ix); + let pending_command = PendingSlashCommand { + name: name.to_string(), + argument: argument.map(ToString::to_string), + tooltip_text: command.tooltip_text().into(), + source_range, + }; + updated.push(pending_command.clone()); + new_commands.push(pending_command); + } + } + } + + offset = lines.offset(); + } + + let removed_commands = self + .pending_slash_commands + .splice(start_ix..end_ix, new_commands); + removed.extend(removed_commands.map(|command| command.source_range)); + } + + if !updated.is_empty() || !removed.is_empty() { + cx.emit(ConversationEvent::PendingSlashCommandsUpdated { removed, updated }); + } + } + fn reparse_edit_suggestions(&mut self, cx: &mut ModelContext) { self.pending_edit_suggestion_parse = Some(cx.spawn(|this, mut cx| async move { cx.background_executor() @@ -1817,222 +1866,72 @@ impl Conversation { cx.notify(); } - fn reparse_slash_command_calls(&mut self, cx: &mut ModelContext) { - self.pending_command_invocation_parse = Some(cx.spawn(|this, mut cx| async move { - cx.background_executor().timer(SLASH_COMMAND_DEBOUNCE).await; - - this.update(&mut cx, |this, cx| { - let buffer = this.buffer.read(cx).snapshot(); - - let mut changed = false; - let mut new_calls = Vec::new(); - let mut old_calls = mem::take(&mut this.slash_command_calls) - .into_iter() - .peekable(); - let mut lines = buffer.as_rope().chunks().lines(); - let mut offset = 0; - while let Some(line) = lines.next() { - let line_end_offset = offset + line.len(); - if let Some(call) = SlashCommandLine::parse(line) { - let mut unchanged_call = None; - while let Some(old_call) = old_calls.peek() { - match old_call.source_range.start.to_offset(&buffer).cmp(&offset) { - Ordering::Greater => break, - Ordering::Equal - if this.slash_command_is_unchanged( - old_call, &call, line, &buffer, - ) => - { - unchanged_call = old_calls.next(); - } - _ => { - changed = true; - let old_call = old_calls.next().unwrap(); - this.slash_command_call_removed(old_call, cx); - } - } - } - - let name = &line[call.name]; - if let Some(call) = unchanged_call { - new_calls.push(call); - } else if let Some((command, lsp_adapter_delegate)) = this - .slash_command_registry - .command(name) - .zip(this.lsp_adapter_delegate.clone()) - { - changed = true; - let name = name.to_string(); - let source_range = - buffer.anchor_after(offset)..buffer.anchor_before(line_end_offset); - - let argument = call.argument.map(|range| &line[range]); - let invocation = command.run(argument, lsp_adapter_delegate, cx); - - new_calls.push(SlashCommandCall { - name, - argument: argument.map(|s| s.to_string()), - source_range: source_range.clone(), - output_range: None, - should_rerun: false, - _invalidate: cx.spawn(|this, mut cx| { - let source_range = source_range.clone(); - let invalidated = invocation.invalidated; - async move { - if invalidated.await.is_ok() { - _ = this.update(&mut cx, |this, cx| { - let buffer = this.buffer.read(cx); - let call_ix = this - .slash_command_calls - .binary_search_by(|probe| { - probe - .source_range - .start - .cmp(&source_range.start, buffer) - }); - if let Ok(call_ix) = call_ix { - this.slash_command_calls[call_ix] - .should_rerun = true; - this.reparse_slash_command_calls(cx); - } - }); - } - } - }), - _command_cleanup: invocation.cleanup, - }); - - cx.spawn(|this, mut cx| async move { - let output = invocation.output.await; - this.update(&mut cx, |this, cx| { - let output_range = this.buffer.update(cx, |buffer, cx| { - let call_ix = this - .slash_command_calls - .binary_search_by(|probe| { - probe - .source_range - .start - .cmp(&source_range.start, buffer) - }) - .ok()?; - - let mut output = output.log_err()?; - output.truncate(output.trim_end().len()); - - let source_end = source_range.end.to_offset(buffer); - let output_start = source_end + '\n'.len_utf8(); - let output_end = output_start + output.len(); - - if buffer - .chars_at(source_end) - .next() - .map_or(false, |c| c != '\n') - { - output.push('\n'); - } - - buffer.edit( - [ - (source_end..source_end, "\n".to_string()), - (source_end..source_end, output), - ], - None, - cx, - ); - - let output_start = buffer.anchor_after(output_start); - let output_end = buffer.anchor_before(output_end); - this.slash_command_calls[call_ix].output_range = - Some(output_start..output_end); - Some(source_range.end..output_end) - }); - if let Some(output_range) = output_range { - cx.emit(ConversationEvent::SlashCommandOutputAdded( - output_range, - )); - cx.emit(ConversationEvent::SlashCommandsChanged); - } - }) - .ok(); - }) - .detach(); - } - } - offset = lines.offset(); - } - - for old_call in old_calls { - changed = true; - this.slash_command_call_removed(old_call, cx); - } - - if changed { - cx.emit(ConversationEvent::SlashCommandsChanged); + fn pending_command_for_position( + &self, + position: language::Anchor, + cx: &AppContext, + ) -> Option<&PendingSlashCommand> { + let buffer = self.buffer.read(cx); + let ix = 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 + } else { + Ordering::Equal } - - this.slash_command_calls = new_calls; }) - .ok(); - })); + .ok()?; + self.pending_slash_commands.get(ix) } - fn slash_command_is_unchanged( - &self, - old_call: &SlashCommandCall, - new_call: &SlashCommandLine, - new_text: &str, - buffer: &BufferSnapshot, - ) -> bool { - if old_call.name != new_text[new_call.name.clone()] { - return false; - } + fn insert_command_output( + &mut self, + invocation_id: SlashCommandInvocationId, + command_range: Range, + output: Task>, + cx: &mut ModelContext, + ) { + let insert_output_task = cx.spawn(|this, mut cx| { + async move { + let output = output.await?; - if old_call.argument.as_deref() != new_call.argument.clone().map(|range| &new_text[range]) { - return false; - } + let mut text = output.text; + LineEnding::normalize(&mut text); + if !text.ends_with('\n') { + text.push('\n'); + } - if old_call.should_rerun { - return false; - } + this.update(&mut cx, |this, cx| { + let output_range = 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 + text.len(); + buffer.edit([(start..old_end, text)], None, cx); + if buffer.chars_at(new_end).next() != Some('\n') { + buffer.edit([(new_end..new_end, "\n")], None, cx); + } + buffer.anchor_after(start)..buffer.anchor_before(new_end) + }); + cx.emit(ConversationEvent::SlashCommandFinished { + output_range, + render_placeholder: output.render_placeholder, + }); + })?; - if let Some(output_range) = &old_call.output_range { - let source_range = old_call.source_range.to_point(buffer); - let output_start = output_range.start.to_point(buffer); - if source_range.start.column != 0 { - return false; - } - if source_range.end.column != new_text.len() as u32 { - return false; - } - if output_start != Point::new(source_range.end.row + 1, 0) { - return false; - } - if let Some(next_char) = buffer.chars_at(output_range.end).next() { - if next_char != '\n' { - return false; - } + anyhow::Ok(()) } - } - true - } + .log_err() + }); - fn slash_command_call_removed( - &self, - old_call: SlashCommandCall, - cx: &mut ModelContext, - ) { - if let Some(output_range) = old_call.output_range { - self.buffer.update(cx, |buffer, cx| { - buffer.edit( - [(old_call.source_range.end..output_range.end, "")], - None, - cx, - ); - }); - cx.emit(ConversationEvent::SlashCommandOutputRemoved( - old_call.source_range.end..output_range.end, - )) - } + self.invocations.insert( + invocation_id, + SlashCommandInvocation { + _pending_output: insert_output_task, + }, + ); } fn remaining_tokens(&self) -> Option { @@ -2207,18 +2106,11 @@ impl Conversation { content: include_str!("./system_prompts/edits.md").to_string(), }; - let recent_buffers_context = self.ambient_context.recent_buffers.to_message(); - let current_project_context = self.ambient_context.current_project.to_message(); - - let messages = Some(edits_system_prompt) - .into_iter() - .chain(recent_buffers_context) - .chain(current_project_context) - .chain( - self.messages(cx) - .filter(|message| matches!(message.status, MessageStatus::Done)) - .map(|message| message.to_request_message(self.buffer.read(cx))), - ); + let messages = Some(edits_system_prompt).into_iter().chain( + self.messages(cx) + .filter(|message| matches!(message.status, MessageStatus::Done)) + .map(|message| message.to_request_message(self.buffer.read(cx))), + ); LanguageModelRequest { model: self.model.clone(), @@ -2277,14 +2169,8 @@ impl Conversation { }; self.message_anchors .insert(next_message_ix, message.clone()); - self.messages_metadata.insert( - message.id, - MessageMetadata { - role, - status, - ambient_context: self.ambient_context.snapshot(), - }, - ); + self.messages_metadata + .insert(message.id, MessageMetadata { role, status }); cx.emit(ConversationEvent::MessagesEdited); Some(message) } else { @@ -2342,7 +2228,6 @@ impl Conversation { MessageMetadata { role, status: MessageStatus::Done, - ambient_context: message.ambient_context.clone(), }, ); @@ -2387,7 +2272,6 @@ impl Conversation { MessageMetadata { role, status: MessageStatus::Done, - ambient_context: message.ambient_context, }, ); (Some(selection), Some(suffix)) @@ -2493,17 +2377,6 @@ impl Conversation { fn messages<'a>(&'a self, cx: &'a AppContext) -> impl 'a + Iterator { let buffer = self.buffer.read(cx); - let mut slash_command_calls = self - .slash_command_calls - .iter() - .map(|call| { - if let Some(output) = &call.output_range { - call.source_range.start.to_offset(buffer)..output.start.to_offset(buffer) - } else { - call.source_range.to_offset(buffer) - } - }) - .peekable(); let mut message_anchors = self.message_anchors.iter().enumerate().peekable(); iter::from_fn(move || { if let Some((start_ix, message_anchor)) = message_anchors.next() { @@ -2524,15 +2397,6 @@ impl Conversation { .unwrap_or(language::Anchor::MAX) .to_offset(buffer); - let mut slash_command_ranges = Vec::new(); - while let Some(call_range) = slash_command_calls.peek() { - if call_range.end <= message_end { - slash_command_ranges.push(slash_command_calls.next().unwrap()); - } else { - break; - } - } - return Some(Message { index_range: start_ix..end_ix, offset_range: message_start..message_end, @@ -2540,8 +2404,6 @@ impl Conversation { anchor: message_anchor.start, role: metadata.role, status: metadata.status.clone(), - slash_command_ranges, - ambient_context: metadata.ambient_context.clone(), }); } None @@ -2699,14 +2561,12 @@ fn parse_next_edit_suggestion(lines: &mut rope::Lines) -> Option, - output_range: Option>, +#[derive(Clone)] +struct PendingSlashCommand { name: String, argument: Option, - should_rerun: bool, - _invalidate: Task<()>, - _command_cleanup: SlashCommandCleanup, + source_range: Range, + tooltip_text: SharedString, } struct PendingCompletion { @@ -2724,14 +2584,16 @@ struct ScrollPosition { cursor: Anchor, } -struct ConversationEditor { +pub struct ConversationEditor { conversation: Model, fs: Arc, workspace: WeakView, + slash_command_registry: Arc, + lsp_adapter_delegate: Option>, editor: View, - flap_ids: HashMap, FlapId>, blocks: HashSet, scroll_position: Option, + pending_slash_command_flaps: HashMap, FlapId>, _subscriptions: Vec, } @@ -2754,21 +2616,27 @@ impl ConversationEditor { language_registry, slash_command_registry, Some(telemetry), - lsp_adapter_delegate, cx, ) }); - Self::for_conversation(conversation, fs, workspace, cx) + + Self::for_conversation(conversation, fs, workspace, lsp_adapter_delegate, cx) } fn for_conversation( conversation: Model, fs: Arc, workspace: View, + lsp_adapter_delegate: Option>, cx: &mut ViewContext, ) -> Self { - let command_registry = conversation.read(cx).slash_command_registry.clone(); - let completion_provider = SlashCommandCompletionProvider::new(command_registry); + let slash_command_registry = conversation.read(cx).slash_command_registry.clone(); + + let completion_provider = SlashCommandCompletionProvider::new( + cx.view().downgrade(), + slash_command_registry.clone(), + workspace.downgrade(), + ); let editor = cx.new_view(|cx| { let mut editor = Editor::for_buffer(conversation.read(cx).buffer.clone(), None, cx); @@ -2786,20 +2654,20 @@ impl ConversationEditor { cx.observe(&conversation, |_, _, cx| cx.notify()), cx.subscribe(&conversation, Self::handle_conversation_event), cx.subscribe(&editor, Self::handle_editor_event), - cx.subscribe(&workspace, Self::handle_workspace_event), ]; let mut this = Self { conversation, editor, + slash_command_registry, + lsp_adapter_delegate, blocks: Default::default(), scroll_position: None, - flap_ids: Default::default(), fs, workspace: workspace.downgrade(), + pending_slash_command_flaps: HashMap::default(), _subscriptions, }; - this.update_recent_editors(cx); this.update_message_headers(cx); this } @@ -2866,12 +2734,68 @@ impl ConversationEditor { .collect() } + pub fn confirm_command(&mut self, _: &ConfirmCommand, cx: &mut ViewContext) { + let selections = self.editor.read(cx).selections.disjoint_anchors(); + let mut commands_by_range = HashMap::default(); + let workspace = self.workspace.clone(); + self.conversation.update(cx, |conversation, cx| { + for selection in selections.iter() { + if let Some(command) = + conversation.pending_command_for_position(selection.head().text_anchor, cx) + { + commands_by_range + .entry(command.source_range.clone()) + .or_insert_with(|| command.clone()); + } + } + }); + + if commands_by_range.is_empty() { + cx.propagate(); + } else { + for command in commands_by_range.into_values() { + self.run_command( + command.source_range, + &command.name, + command.argument.as_deref(), + workspace.clone(), + cx, + ); + } + cx.stop_propagation(); + } + } + + pub fn run_command( + &mut self, + command_range: Range, + name: &str, + argument: Option<&str>, + workspace: WeakView, + cx: &mut ViewContext, + ) -> Option { + let command = self.slash_command_registry.command(name)?; + let lsp_adapter_delegate = self.lsp_adapter_delegate.clone()?; + let argument = argument.map(ToString::to_string); + let id = self.conversation.update(cx, |conversation, _| { + conversation.next_invocation_id.post_inc() + }); + let output = command.run(argument.as_deref(), workspace, lsp_adapter_delegate, cx); + self.conversation.update(cx, |conversation, cx| { + conversation.insert_command_output(id, command_range, output, cx) + }); + + Some(id) + } + fn handle_conversation_event( &mut self, _: Model, event: &ConversationEvent, cx: &mut ViewContext, ) { + let conversation_editor = cx.view().downgrade(); + match event { ConversationEvent::MessagesEdited => { self.update_message_headers(cx); @@ -2933,72 +2857,134 @@ impl ConversationEditor { } }); } - ConversationEvent::SlashCommandsChanged => { + ConversationEvent::PendingSlashCommandsUpdated { removed, updated } => { self.editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); let excerpt_id = *buffer.as_singleton().unwrap().0; - let conversation = self.conversation.read(cx); - let colors = cx.theme().colors(); - let highlighted_rows = conversation - .slash_command_calls - .iter() - .map(|call| { - let start = call.source_range.start; - let end = if let Some(output) = &call.output_range { - output.end - } else { - call.source_range.end + + editor.remove_flaps( + removed + .iter() + .filter_map(|range| self.pending_slash_command_flaps.remove(range)), + cx, + ); + + let flap_ids = editor.insert_flaps( + updated.iter().map(|command| { + let workspace = self.workspace.clone(); + let confirm_command = Arc::new({ + let conversation_editor = conversation_editor.clone(); + let command = command.clone(); + move |cx: &mut WindowContext| { + conversation_editor + .update(cx, |conversation_editor, cx| { + conversation_editor.run_command( + command.source_range.clone(), + &command.name, + command.argument.as_deref(), + workspace.clone(), + cx, + ); + }) + .ok(); + } + }); + let placeholder = FoldPlaceholder { + render: Arc::new(move |_, _, _| Empty.into_any()), + constrain_width: false, + merge_adjacent: false, + }; + let render_toggle = { + let confirm_command = confirm_command.clone(); + let command = command.clone(); + move |row, _, _, _cx: &mut WindowContext| { + render_pending_slash_command_toggle( + row, + command.tooltip_text.clone(), + confirm_command.clone(), + ) + } + }; + let render_trailer = { + let confirm_command = confirm_command.clone(); + move |row, _, cx: &mut WindowContext| { + render_pending_slash_command_trailer( + row, + confirm_command.clone(), + cx, + ) + } }; - let start = buffer.anchor_in_excerpt(excerpt_id, start).unwrap(); - let end = buffer.anchor_in_excerpt(excerpt_id, end).unwrap(); - ( - start..=end, - Some(colors.editor_document_highlight_read_background), - ) - }) - .collect::>(); - editor.clear_row_highlights::(); - for (range, color) in highlighted_rows { - editor.highlight_rows::(range, color, false, cx); - } - }); + let start = buffer + .anchor_in_excerpt(excerpt_id, command.source_range.start) + .unwrap(); + let end = buffer + .anchor_in_excerpt(excerpt_id, command.source_range.end) + .unwrap(); + Flap::new(start..end, placeholder, render_toggle, render_trailer) + }), + cx, + ); + + self.pending_slash_command_flaps.extend( + updated + .iter() + .map(|command| command.source_range.clone()) + .zip(flap_ids), + ); + }) } - ConversationEvent::SlashCommandOutputAdded(range) => { + ConversationEvent::SlashCommandFinished { + output_range, + render_placeholder, + } => { self.editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); let excerpt_id = *buffer.as_singleton().unwrap().0; - let start = buffer.anchor_in_excerpt(excerpt_id, range.start).unwrap(); - let end = buffer.anchor_in_excerpt(excerpt_id, range.end).unwrap(); + let start = buffer + .anchor_in_excerpt(excerpt_id, output_range.start) + .unwrap(); + let end = buffer + .anchor_in_excerpt(excerpt_id, output_range.end) + .unwrap(); let buffer_row = MultiBufferRow(start.to_point(&buffer).row); - let flap_id = editor - .insert_flaps( - [Flap::new( - start..end, - FoldPlaceholder { - render: Arc::new(|_, _, _| Empty.into_any()), - constrain_width: false, - }, - render_slash_command_output_toggle, - render_slash_command_output_trailer, - )], - cx, - ) - .into_iter() - .next() - .unwrap(); - self.flap_ids.insert(range.clone(), flap_id); + editor.insert_flaps( + [Flap::new( + start..end, + FoldPlaceholder { + render: Arc::new({ + let editor = cx.view().downgrade(); + let render_placeholder = render_placeholder.clone(); + move |fold_id, fold_range, cx| { + let editor = editor.clone(); + let unfold = Arc::new(move |cx: &mut WindowContext| { + editor + .update(cx, |editor, cx| { + editor.unfold_ranges( + [fold_range.start..fold_range.end], + true, + false, + cx, + ); + }) + .ok(); + }); + render_placeholder(fold_id.into(), unfold, cx) + } + }), + constrain_width: false, + merge_adjacent: false, + }, + render_slash_command_output_toggle, + |_, _, _| Empty.into_any_element(), + )], + cx, + ); editor.fold_at(&FoldAt { buffer_row }, cx); }); } - ConversationEvent::SlashCommandOutputRemoved(range) => { - if let Some(flap_id) = self.flap_ids.remove(range) { - self.editor.update(cx, |editor, cx| { - editor.remove_flaps([flap_id], cx); - }); - } - } } } @@ -3024,62 +3010,6 @@ impl ConversationEditor { } } - fn handle_workspace_event( - &mut self, - _: View, - event: &WorkspaceEvent, - cx: &mut ViewContext, - ) { - match event { - WorkspaceEvent::ActiveItemChanged - | WorkspaceEvent::ItemAdded - | WorkspaceEvent::ItemRemoved - | WorkspaceEvent::PaneAdded(_) - | WorkspaceEvent::PaneRemoved => self.update_recent_editors(cx), - _ => {} - } - } - - fn update_recent_editors(&mut self, cx: &mut ViewContext) { - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - - let mut timestamps_by_entity_id = HashMap::default(); - for pane in workspace.read(cx).panes() { - let pane = pane.read(cx); - for entry in pane.activation_history() { - timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp); - } - } - - let mut timestamps_by_buffer = HashMap::default(); - for editor in workspace.read(cx).items_of_type::(cx) { - let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else { - continue; - }; - - let new_timestamp = timestamps_by_entity_id - .get(&editor.entity_id()) - .copied() - .unwrap_or_default(); - let timestamp = timestamps_by_buffer.entry(buffer).or_insert(new_timestamp); - *timestamp = cmp::max(*timestamp, new_timestamp); - } - - let mut recent_buffers = timestamps_by_buffer.into_iter().collect::>(); - recent_buffers.sort_unstable_by_key(|(_, timestamp)| *timestamp); - if recent_buffers.len() > MAX_RECENT_BUFFERS { - let excess = recent_buffers.len() - MAX_RECENT_BUFFERS; - recent_buffers.drain(..excess); - } - - self.conversation.update(cx, |conversation, cx| { - conversation - .set_recent_buffers(recent_buffers.into_iter().map(|(buffer, _)| buffer), cx); - }); - } - fn cursor_scroll_position(&self, cx: &mut ViewContext) -> Option { self.editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(cx); @@ -3106,11 +3036,6 @@ impl ConversationEditor { } fn update_message_headers(&mut self, cx: &mut ViewContext) { - let project = self - .workspace - .update(cx, |workspace, _cx| workspace.project().downgrade()) - .unwrap(); - self.editor.update(cx, |editor, cx| { let buffer = editor.buffer().read(cx).snapshot(cx); let excerpt_id = *buffer.as_singleton().unwrap().0; @@ -3119,16 +3044,13 @@ impl ConversationEditor { .conversation .read(cx) .messages(cx) - .enumerate() - .map(|(ix, message)| BlockProperties { + .map(|message| BlockProperties { position: buffer .anchor_in_excerpt(excerpt_id, message.anchor) .unwrap(), height: 2, style: BlockStyle::Sticky, render: Box::new({ - let fs = self.fs.clone(); - let project = project.clone(); let conversation = self.conversation.clone(); move |cx| { let message_id = message.id; @@ -3179,74 +3101,6 @@ impl ConversationEditor { None }, ) - .children((ix == 0).then(|| { - div() - .h_flex() - .flex_1() - .justify_end() - .pr_4() - .gap_1() - .child( - IconButton::new("include_file", IconName::File) - .icon_size(IconSize::Small) - .selected( - conversation - .read(cx) - .ambient_context - .recent_buffers - .enabled, - ) - .on_click({ - let conversation = conversation.downgrade(); - move |_, cx| { - conversation - .update(cx, |conversation, cx| { - conversation - .toggle_recent_buffers(cx); - }) - .ok(); - } - }) - .tooltip(|cx| { - Tooltip::text("Include Open Files", cx) - }), - ) - .child( - IconButton::new( - "include_current_project", - IconName::FileTree, - ) - .icon_size(IconSize::Small) - .selected( - conversation - .read(cx) - .ambient_context - .current_project - .enabled, - ) - .on_click({ - let fs = fs.clone(); - let project = project.clone(); - let conversation = conversation.downgrade(); - move |_, cx| { - let fs = fs.clone(); - let project = project.clone(); - conversation - .update(cx, |conversation, cx| { - conversation - .toggle_current_project_context( - fs, project, cx, - ); - }) - .ok(); - } - }) - .tooltip( - |cx| Tooltip::text("Include Current Project", cx), - ), - ) - .into_any() - })) .into_any_element() } }), @@ -3375,6 +3229,11 @@ impl ConversationEditor { } fn apply_edit(&mut self, _: &ApplyEdit, cx: &mut ViewContext) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let project = workspace.read(cx).project().clone(); + struct Edit { old_text: String, new_text: String, @@ -3386,45 +3245,58 @@ impl ConversationEditor { let selections = self.editor.read(cx).selections.disjoint_anchors(); let mut selections = selections.iter().peekable(); - let selected_suggestions = conversation.edit_suggestions.iter().filter(|suggestion| { - while let Some(selection) = selections.peek() { - if selection - .end - .text_anchor - .cmp(&suggestion.source_range.start, conversation_buffer) - .is_lt() - { - selections.next(); - continue; - } - if selection - .start - .text_anchor - .cmp(&suggestion.source_range.end, conversation_buffer) - .is_gt() - { - break; + let selected_suggestions = conversation + .edit_suggestions + .iter() + .filter(|suggestion| { + while let Some(selection) = selections.peek() { + if selection + .end + .text_anchor + .cmp(&suggestion.source_range.start, conversation_buffer) + .is_lt() + { + selections.next(); + continue; + } + if selection + .start + .text_anchor + .cmp(&suggestion.source_range.end, conversation_buffer) + .is_gt() + { + break; + } + return true; } - return true; + false + }) + .cloned() + .collect::>(); + + let mut opened_buffers: HashMap>>> = HashMap::default(); + project.update(cx, |project, cx| { + for suggestion in &selected_suggestions { + opened_buffers + .entry(suggestion.full_path.clone()) + .or_insert_with(|| { + project.open_buffer_for_full_path(&suggestion.full_path, cx) + }); } - false }); - let mut suggestions_by_buffer = - HashMap::, (BufferSnapshot, Vec)>::default(); - for suggestion in selected_suggestions { - let offset = suggestion.source_range.start.to_offset(conversation_buffer); - if let Some(message) = conversation.message_for_offset(offset, cx) { - if let Some(buffer) = message - .ambient_context - .recent_buffers - .source_buffers - .iter() - .find(|source_buffer| { - source_buffer.full_path.as_ref() == Some(&suggestion.full_path) - }) - { - if let Some(buffer) = buffer.model.upgrade() { + cx.spawn(|this, mut cx| async move { + let mut buffers_by_full_path = HashMap::default(); + for (full_path, buffer) in opened_buffers { + if let Some(buffer) = buffer.await.log_err() { + buffers_by_full_path.insert(full_path, buffer); + } + } + + let mut suggestions_by_buffer = HashMap::default(); + cx.update(|cx| { + for suggestion in selected_suggestions { + if let Some(buffer) = buffers_by_full_path.get(&suggestion.full_path) { let (_, edits) = suggestions_by_buffer .entry(buffer.clone()) .or_insert_with(|| (buffer.read(cx).snapshot(), Vec::new())); @@ -3448,10 +3320,8 @@ impl ConversationEditor { } } } - } - } + })?; - cx.spawn(|this, mut cx| async move { let edits_by_buffer = cx .background_executor() .spawn(async move { @@ -3553,6 +3423,7 @@ impl Render for ConversationEditor { .capture_action(cx.listener(ConversationEditor::save)) .capture_action(cx.listener(ConversationEditor::copy)) .capture_action(cx.listener(ConversationEditor::cycle_message_role)) + .capture_action(cx.listener(ConversationEditor::confirm_command)) .on_action(cx.listener(ConversationEditor::assist)) .on_action(cx.listener(ConversationEditor::split)) .on_action(cx.listener(ConversationEditor::apply_edit)) @@ -3587,21 +3458,13 @@ pub struct Message { anchor: language::Anchor, role: Role, status: MessageStatus, - slash_command_ranges: Vec>, - ambient_context: AmbientContextSnapshot, } impl Message { fn to_request_message(&self, buffer: &Buffer) -> LanguageModelRequestMessage { - let mut content = text_in_range_omitting_ranges( - buffer.as_rope(), - self.offset_range.clone(), - &self.slash_command_ranges, - ); - content.truncate(content.trim_end().len()); LanguageModelRequestMessage { role: self.role, - content, + content: buffer.text_for_range(self.offset_range.clone()).collect(), } } } @@ -3898,12 +3761,41 @@ fn render_slash_command_output_toggle( .into_any_element() } -fn render_slash_command_output_trailer( +fn render_pending_slash_command_toggle( + row: MultiBufferRow, + tooltip_text: SharedString, + confirm_command: Arc, +) -> AnyElement { + IconButton::new( + ("slash-command-output-fold-indicator", row.0), + ui::IconName::TriangleRight, + ) + .on_click(move |_e, cx| confirm_command(cx)) + .icon_color(ui::Color::Success) + .icon_size(ui::IconSize::Small) + .selected(true) + .size(ui::ButtonSize::None) + .tooltip(move |cx| Tooltip::text(tooltip_text.clone(), cx)) + .into_any_element() +} + +fn render_pending_slash_command_trailer( _row: MultiBufferRow, - _is_folded: bool, + _confirm_command: Arc, _cx: &mut WindowContext, ) -> AnyElement { - div().into_any_element() + Empty.into_any() + // ButtonLike::new(("run_button", row.0)) + // .style(ButtonStyle::Filled) + // .size(ButtonSize::Compact) + // .layer(ElevationIndex::ModalSurface) + // .children( + // KeyBinding::for_action(&Confirm, cx) + // .map(|binding| binding.icon_size(IconSize::XSmall).into_any_element()), + // ) + // .child(Label::new("Run").size(LabelSize::XSmall)) + // .on_click(move |_, cx| confirm_command(cx)) + // .into_any_element() } fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { @@ -3944,8 +3836,6 @@ fn make_lsp_adapter_delegate( #[cfg(test)] mod tests { - use std::{cell::RefCell, path::Path, rc::Rc}; - use super::*; use crate::{FakeCompletionProvider, MessageId}; use fs::FakeFs; @@ -3953,6 +3843,7 @@ mod tests { use rope::Rope; use serde_json::json; use settings::SettingsStore; + use std::{cell::RefCell, path::Path, rc::Rc}; use unindent::Unindent; use util::test::marked_text_ranges; @@ -3970,7 +3861,6 @@ mod tests { registry, Default::default(), None, - None, cx, ) }); @@ -4110,7 +4000,6 @@ mod tests { registry, Default::default(), None, - None, cx, ) }); @@ -4217,7 +4106,6 @@ mod tests { registry, Default::default(), None, - None, cx, ) }); @@ -4330,15 +4218,6 @@ mod tests { prompt_library.clone(), )); - let lsp_adapter_delegate = project.update(cx, |project, cx| { - // TODO: Find the right worktree. - let worktree = project - .worktrees() - .next() - .expect("expected at least one worktree"); - ProjectLspAdapterDelegate::new(project, &worktree, cx) - }); - let registry = Arc::new(LanguageRegistry::test(cx.executor())); let conversation = cx.new_model(|cx| { Conversation::new( @@ -4346,7 +4225,6 @@ mod tests { registry.clone(), slash_command_registry, None, - Some(lsp_adapter_delegate), cx, ) }); @@ -4356,11 +4234,13 @@ mod tests { cx.subscribe(&conversation, { let ranges = output_ranges.clone(); move |_, _, event, _| match event { - ConversationEvent::SlashCommandOutputAdded(range) => { - ranges.borrow_mut().insert(range.clone()); - } - ConversationEvent::SlashCommandOutputRemoved(range) => { - ranges.borrow_mut().remove(range); + ConversationEvent::PendingSlashCommandsUpdated { removed, updated } => { + for range in removed { + ranges.borrow_mut().remove(range); + } + for command in updated { + ranges.borrow_mut().insert(command.source_range.clone()); + } } _ => {} } @@ -4378,28 +4258,14 @@ mod tests { &buffer, &output_ranges.borrow(), " - /file src/lib.rs + «/file src/lib.rs» " .unindent() .trim_end(), cx, ); - // The slash command runs - cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/lib.rs« - ```src/lib.rs - fn one() -> usize { 1 } - ```»" - .unindent(), - cx, - ); - - // Edit the slash command + // Edit the argument of the slash command. buffer.update(cx, |buffer, cx| { let edit_offset = buffer.text().find("lib.rs").unwrap(); buffer.edit([(edit_offset..edit_offset + "lib".len(), "main")], None, cx); @@ -4407,139 +4273,32 @@ mod tests { assert_text_and_output_ranges( &buffer, &output_ranges.borrow(), - &" - /file src/main.rs« - ```src/lib.rs - fn one() -> usize { 1 } - ```»" - .unindent(), - cx, - ); - - cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/main.rs« - ```src/main.rs - use crate::one; - fn main() { one(); } - ```»" - .unindent(), - cx, - ); - - // Insert newlines between the slash command and its output - buffer.update(cx, |buffer, cx| { - let edit_offset = buffer.text().find("\n```src/main.rs").unwrap(); - buffer.edit([(edit_offset..edit_offset, "\n")], None, cx); - }); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/main.rs« - - ```src/main.rs - use crate::one; - fn main() { one(); } - ```»" - .unindent(), - cx, - ); - - cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/main.rs« - ```src/main.rs - use crate::one; - fn main() { one(); } - ```»" - .unindent(), - cx, - ); - - // Insert text at the beginning of the output - buffer.update(cx, |buffer, cx| { - let edit_offset = buffer.text().find("```src/main.rs").unwrap(); - buffer.edit([(edit_offset..edit_offset, "!")], None, cx); - }); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/main.rs« - !```src/main.rs - use crate::one; - fn main() { one(); } - ```»" - .unindent(), - cx, - ); - - cx.executor().advance_clock(SLASH_COMMAND_DEBOUNCE); - assert_text_and_output_ranges( - &buffer, - &output_ranges.borrow(), - &" - /file src/main.rs« - ```src/main.rs - use crate::one; - fn main() { one(); } - ```»" - .unindent(), + " + «/file src/main.rs» + " + .unindent() + .trim_end(), cx, ); - // Slash commands are omitted from completion requests. Only their - // output is included. - let request = conversation.update(cx, |conversation, cx| { - conversation.to_completion_request(cx) - }); - assert_eq!( - &request.messages[1..], - &[LanguageModelRequestMessage { - role: Role::User, - content: " - ```src/main.rs - use crate::one; - fn main() { one(); } - ```" - .unindent() - }] - ); - - buffer.update(cx, |buffer, cx| { - buffer.edit([(0..0, "hello\n")], None, cx); - }); + // Edit the name of the slash command, using one that doesn't exist. buffer.update(cx, |buffer, cx| { + let edit_offset = buffer.text().find("/file").unwrap(); buffer.edit( - [(buffer.len()..buffer.len(), "\ngoodbye\nfarewell\n")], + [(edit_offset..edit_offset + "/file".len(), "/unknown")], None, cx, ); }); - let request = conversation.update(cx, |conversation, cx| { - conversation.to_completion_request(cx) - }); - assert_eq!( - &request.messages[1..], - &[LanguageModelRequestMessage { - role: Role::User, - content: " - hello - ```src/main.rs - use crate::one; - fn main() { one(); } - ``` - goodbye - farewell" - .unindent() - }] + assert_text_and_output_ranges( + &buffer, + &output_ranges.borrow(), + " + /unknown src/main.rs + " + .unindent() + .trim_end(), + cx, ); #[track_caller] @@ -4647,7 +4406,6 @@ mod tests { registry.clone(), Default::default(), None, - None, cx, ) }); @@ -4691,7 +4449,6 @@ mod tests { registry.clone(), Default::default(), None, - None, &mut cx.to_async(), ) .await diff --git a/crates/assistant/src/omit_ranges.rs b/crates/assistant/src/omit_ranges.rs deleted file mode 100644 index f4a6988e95628..0000000000000 --- a/crates/assistant/src/omit_ranges.rs +++ /dev/null @@ -1,101 +0,0 @@ -use rope::Rope; -use std::{cmp::Ordering, ops::Range}; - -pub(crate) fn text_in_range_omitting_ranges( - rope: &Rope, - range: Range, - omit_ranges: &[Range], -) -> String { - let mut content = String::with_capacity(range.len()); - let mut omit_ranges = omit_ranges - .iter() - .skip_while(|omit_range| omit_range.end <= range.start) - .peekable(); - let mut offset = range.start; - let mut chunks = rope.chunks_in_range(range.clone()); - while let Some(chunk) = chunks.next() { - if let Some(omit_range) = omit_ranges.peek() { - match offset.cmp(&omit_range.start) { - Ordering::Less => { - let max_len = omit_range.start - offset; - if chunk.len() < max_len { - content.push_str(chunk); - offset += chunk.len(); - } else { - content.push_str(&chunk[..max_len]); - chunks.seek(omit_range.end.min(range.end)); - offset = omit_range.end; - omit_ranges.next(); - } - } - Ordering::Equal | Ordering::Greater => { - chunks.seek(omit_range.end.min(range.end)); - offset = omit_range.end; - omit_ranges.next(); - } - } - } else { - content.push_str(chunk); - offset += chunk.len(); - } - } - - content -} - -#[cfg(test)] -mod tests { - use super::*; - use rand::{rngs::StdRng, Rng as _}; - use util::RandomCharIter; - - #[gpui::test(iterations = 100)] - fn test_text_in_range_omitting_ranges(mut rng: StdRng) { - let text = RandomCharIter::new(&mut rng).take(1024).collect::(); - let rope = Rope::from(text.as_str()); - - let mut start = rng.gen_range(0..=text.len() / 2); - let mut end = rng.gen_range(text.len() / 2..=text.len()); - while !text.is_char_boundary(start) { - start -= 1; - } - while !text.is_char_boundary(end) { - end += 1; - } - let range = start..end; - - let mut ix = 0; - let mut omit_ranges = Vec::new(); - for _ in 0..rng.gen_range(0..10) { - let mut start = rng.gen_range(ix..=text.len()); - while !text.is_char_boundary(start) { - start += 1; - } - let mut end = rng.gen_range(start..=text.len()); - while !text.is_char_boundary(end) { - end += 1; - } - omit_ranges.push(start..end); - ix = end; - if ix == text.len() { - break; - } - } - - let mut expected_text = text[range.clone()].to_string(); - for omit_range in omit_ranges.iter().rev() { - let start = omit_range - .start - .saturating_sub(range.start) - .min(range.len()); - let end = omit_range.end.saturating_sub(range.start).min(range.len()); - expected_text.replace_range(start..end, ""); - } - - assert_eq!( - text_in_range_omitting_ranges(&rope, range.clone(), &omit_ranges), - expected_text, - "text: {text:?}\nrange: {range:?}\nomit_ranges: {omit_ranges:?}" - ); - } -} diff --git a/crates/assistant/src/slash_command.rs b/crates/assistant/src/slash_command.rs index a9d5f61d02695..58bcf900f687d 100644 --- a/crates/assistant/src/slash_command.rs +++ b/crates/assistant/src/slash_command.rs @@ -1,7 +1,9 @@ +use crate::assistant_panel::ConversationEditor; use anyhow::Result; +pub use assistant_slash_command::{SlashCommand, SlashCommandOutput, SlashCommandRegistry}; use editor::{CompletionProvider, Editor}; use fuzzy::{match_strings, StringMatchCandidate}; -use gpui::{AppContext, Model, Task, ViewContext}; +use gpui::{Model, Task, ViewContext, WeakView, WindowContext}; use language::{Anchor, Buffer, CodeLabel, Documentation, LanguageServerId, ToPoint}; use parking_lot::{Mutex, RwLock}; use rope::Point; @@ -12,18 +14,18 @@ use std::{ Arc, }, }; +use workspace::Workspace; -pub use assistant_slash_command::{ - SlashCommand, SlashCommandCleanup, SlashCommandInvocation, SlashCommandRegistry, -}; - -pub mod current_file_command; +pub mod active_command; pub mod file_command; +pub mod project_command; pub mod prompt_command; pub(crate) struct SlashCommandCompletionProvider { + editor: WeakView, commands: Arc, cancel_flag: Mutex>, + workspace: WeakView, } pub(crate) struct SlashCommandLine { @@ -34,18 +36,25 @@ pub(crate) struct SlashCommandLine { } impl SlashCommandCompletionProvider { - pub fn new(commands: Arc) -> Self { + pub fn new( + editor: WeakView, + commands: Arc, + workspace: WeakView, + ) -> Self { Self { cancel_flag: Mutex::new(Arc::new(AtomicBool::new(false))), + editor, commands, + workspace, } } fn complete_command_name( &self, command_name: &str, - range: Range, - cx: &mut AppContext, + command_range: Range, + name_range: Range, + cx: &mut WindowContext, ) -> Task>> { let candidates = self .commands @@ -60,6 +69,8 @@ impl SlashCommandCompletionProvider { .collect::>(); let commands = self.commands.clone(); let command_name = command_name.to_string(); + let editor = self.editor.clone(); + let workspace = self.workspace.clone(); let executor = cx.background_executor().clone(); executor.clone().spawn(async move { let matches = match_strings( @@ -77,17 +88,37 @@ impl SlashCommandCompletionProvider { .filter_map(|mat| { let command = commands.command(&mat.string)?; let mut new_text = mat.string.clone(); - if command.requires_argument() { + let requires_argument = command.requires_argument(); + if requires_argument { new_text.push(' '); } Some(project::Completion { - old_range: range.clone(), + old_range: name_range.clone(), documentation: Some(Documentation::SingleLine(command.description())), new_text, - label: CodeLabel::plain(mat.string, None), + label: CodeLabel::plain(mat.string.clone(), None), server_id: LanguageServerId(0), lsp_completion: Default::default(), + confirm: (!requires_argument).then(|| { + let command_name = mat.string.clone(); + let command_range = command_range.clone(); + let editor = editor.clone(); + let workspace = workspace.clone(); + Arc::new(move |cx: &mut WindowContext| { + editor + .update(cx, |editor, cx| { + editor.run_command( + command_range.clone(), + &command_name, + None, + workspace.clone(), + cx, + ); + }) + .ok(); + }) as Arc<_> + }), }) }) .collect()) @@ -98,8 +129,9 @@ impl SlashCommandCompletionProvider { &self, command_name: &str, argument: String, - range: Range, - cx: &mut AppContext, + command_range: Range, + argument_range: Range, + cx: &mut WindowContext, ) -> Task>> { let new_cancel_flag = Arc::new(AtomicBool::new(false)); let mut flag = self.cancel_flag.lock(); @@ -108,17 +140,39 @@ impl SlashCommandCompletionProvider { if let Some(command) = self.commands.command(command_name) { let completions = command.complete_argument(argument, new_cancel_flag.clone(), cx); + let command_name: Arc = command_name.into(); + let editor = self.editor.clone(); + let workspace = self.workspace.clone(); cx.background_executor().spawn(async move { Ok(completions .await? .into_iter() .map(|arg| project::Completion { - old_range: range.clone(), + old_range: argument_range.clone(), label: CodeLabel::plain(arg.clone(), None), new_text: arg.clone(), documentation: None, server_id: LanguageServerId(0), lsp_completion: Default::default(), + confirm: Some(Arc::new({ + let command_name = command_name.clone(); + let command_range = command_range.clone(); + let editor = editor.clone(); + let workspace = workspace.clone(); + move |cx| { + editor + .update(cx, |editor, cx| { + editor.run_command( + command_range.clone(), + &command_name, + Some(&arg), + workspace.clone(), + cx, + ); + }) + .ok(); + } + })), }) .collect()) }) @@ -136,25 +190,44 @@ impl CompletionProvider for SlashCommandCompletionProvider { buffer_position: Anchor, cx: &mut ViewContext, ) -> Task>> { - let task = 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 name = &line[call.name.clone()]; - if let Some(argument) = call.argument { - let start = buffer.anchor_after(Point::new(position.row, argument.start as u32)); - let argument = line[argument.clone()].to_string(); - Some(self.complete_command_argument(name, argument, start..buffer_position, cx)) - } else { - let start = buffer.anchor_after(Point::new(position.row, call.name.start as u32)); - Some(self.complete_command_name(name, start..buffer_position, cx)) - } - }); + let Some((name, argument, command_range, 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)?; - task.unwrap_or_else(|| Task::ready(Ok(Vec::new()))) + let command_range_start = Point::new(position.row, call.name.start as u32 - 1); + let command_range_end = Point::new( + position.row, + call.argument.as_ref().map_or(call.name.end, |arg| arg.end) as u32, + ); + let command_range = buffer.anchor_after(command_range_start) + ..buffer.anchor_after(command_range_end); + + let name = line[call.name.clone()].to_string(); + + Some(if let Some(argument) = call.argument { + let start = + buffer.anchor_after(Point::new(position.row, argument.start as u32)); + let argument = line[argument.clone()].to_string(); + (name, Some(argument), command_range, start..buffer_position) + } else { + let start = + buffer.anchor_after(Point::new(position.row, call.name.start as u32)); + (name, None, command_range, start..buffer_position) + }) + }) + else { + return Task::ready(Ok(Vec::new())); + }; + + if let Some(argument) = argument { + self.complete_command_argument(&name, argument, command_range, argument_range, cx) + } else { + self.complete_command_name(&name, command_range, argument_range, cx) + } } fn resolve_completions( diff --git a/crates/assistant/src/slash_command/active_command.rs b/crates/assistant/src/slash_command/active_command.rs new file mode 100644 index 0000000000000..47ff4f72e4c73 --- /dev/null +++ b/crates/assistant/src/slash_command/active_command.rs @@ -0,0 +1,117 @@ +use super::{file_command::FilePlaceholder, SlashCommand, SlashCommandOutput}; +use anyhow::{anyhow, Result}; +use collections::HashMap; +use editor::Editor; +use gpui::{AppContext, Entity, Task, WeakView}; +use language::LspAdapterDelegate; +use std::{borrow::Cow, sync::Arc}; +use ui::{IntoElement, WindowContext}; +use workspace::Workspace; + +pub(crate) struct ActiveSlashCommand; + +impl SlashCommand for ActiveSlashCommand { + fn name(&self) -> String { + "active".into() + } + + fn description(&self) -> String { + "insert active tab".into() + } + + fn tooltip_text(&self) -> String { + "insert active tab".into() + } + + fn complete_argument( + &self, + _query: String, + _cancel: std::sync::Arc, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Err(anyhow!("this command does not require argument"))) + } + + fn requires_argument(&self) -> bool { + false + } + + fn run( + self: Arc, + _argument: Option<&str>, + workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let output = workspace.update(cx, |workspace, cx| { + let mut timestamps_by_entity_id = HashMap::default(); + for pane in workspace.panes() { + let pane = pane.read(cx); + for entry in pane.activation_history() { + timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp); + } + } + + let mut most_recent_buffer = None; + for editor in workspace.items_of_type::(cx) { + let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else { + continue; + }; + + let timestamp = timestamps_by_entity_id + .get(&editor.entity_id()) + .copied() + .unwrap_or_default(); + if most_recent_buffer + .as_ref() + .map_or(true, |(_, prev_timestamp)| timestamp > *prev_timestamp) + { + most_recent_buffer = Some((buffer, timestamp)); + } + } + + if let Some((buffer, _)) = most_recent_buffer { + let snapshot = buffer.read(cx).snapshot(); + let path = snapshot.resolve_file_path(cx, true); + let text = cx.background_executor().spawn({ + let path = path.clone(); + async move { + let path = path + .as_ref() + .map(|path| path.to_string_lossy()) + .unwrap_or_else(|| Cow::Borrowed("untitled")); + + let mut output = String::with_capacity(path.len() + snapshot.len() + 9); + output.push_str("```"); + output.push_str(&path); + output.push('\n'); + for chunk in snapshot.as_rope().chunks() { + output.push_str(chunk); + } + if !output.ends_with('\n') { + output.push('\n'); + } + output.push_str("```"); + output + } + }); + cx.foreground_executor().spawn(async move { + Ok(SlashCommandOutput { + text: text.await, + render_placeholder: Arc::new(move |id, unfold, _| { + FilePlaceholder { + id, + path: path.clone(), + unfold, + } + .into_any_element() + }), + }) + }) + } else { + Task::ready(Err(anyhow!("no recent buffer found"))) + } + }); + output.unwrap_or_else(|error| Task::ready(Err(error))) + } +} diff --git a/crates/assistant/src/slash_command/current_file_command.rs b/crates/assistant/src/slash_command/current_file_command.rs deleted file mode 100644 index 5f55253557a30..0000000000000 --- a/crates/assistant/src/slash_command/current_file_command.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::sync::Arc; -use std::{borrow::Cow, cell::Cell, rc::Rc}; - -use anyhow::{anyhow, Result}; -use collections::HashMap; -use editor::Editor; -use futures::channel::oneshot; -use gpui::{AppContext, Entity, Subscription, Task, WindowHandle}; -use language::LspAdapterDelegate; -use workspace::{Event as WorkspaceEvent, Workspace}; - -use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation}; - -pub(crate) struct CurrentFileSlashCommand { - workspace: WindowHandle, -} - -impl CurrentFileSlashCommand { - pub fn new(workspace: WindowHandle) -> Self { - Self { workspace } - } -} - -impl SlashCommand for CurrentFileSlashCommand { - fn name(&self) -> String { - "current_file".into() - } - - fn description(&self) -> String { - "insert the current file".into() - } - - fn complete_argument( - &self, - _query: String, - _cancel: std::sync::Arc, - _cx: &mut AppContext, - ) -> Task>> { - Task::ready(Err(anyhow!("this command does not require argument"))) - } - - fn requires_argument(&self) -> bool { - false - } - - fn run( - self: Arc, - _argument: Option<&str>, - _delegate: Arc, - cx: &mut AppContext, - ) -> SlashCommandInvocation { - let (invalidate_tx, invalidate_rx) = oneshot::channel(); - let invalidate_tx = Rc::new(Cell::new(Some(invalidate_tx))); - let mut subscriptions: Vec = Vec::new(); - let output = self.workspace.update(cx, |workspace, cx| { - let mut timestamps_by_entity_id = HashMap::default(); - for pane in workspace.panes() { - let pane = pane.read(cx); - for entry in pane.activation_history() { - timestamps_by_entity_id.insert(entry.entity_id, entry.timestamp); - } - } - - let mut most_recent_buffer = None; - for editor in workspace.items_of_type::(cx) { - let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else { - continue; - }; - - let timestamp = timestamps_by_entity_id - .get(&editor.entity_id()) - .copied() - .unwrap_or_default(); - if most_recent_buffer - .as_ref() - .map_or(true, |(_, prev_timestamp)| timestamp > *prev_timestamp) - { - most_recent_buffer = Some((buffer, timestamp)); - } - } - - subscriptions.push({ - let workspace_view = cx.view().clone(); - let invalidate_tx = invalidate_tx.clone(); - cx.window_context() - .subscribe(&workspace_view, move |_workspace, event, _cx| match event { - WorkspaceEvent::ActiveItemChanged - | WorkspaceEvent::ItemAdded - | WorkspaceEvent::ItemRemoved - | WorkspaceEvent::PaneAdded(_) - | WorkspaceEvent::PaneRemoved => { - if let Some(invalidate_tx) = invalidate_tx.take() { - _ = invalidate_tx.send(()); - } - } - _ => {} - }) - }); - - if let Some((buffer, _)) = most_recent_buffer { - subscriptions.push({ - let invalidate_tx = invalidate_tx.clone(); - cx.window_context().observe(&buffer, move |_buffer, _cx| { - if let Some(invalidate_tx) = invalidate_tx.take() { - _ = invalidate_tx.send(()); - } - }) - }); - - let snapshot = buffer.read(cx).snapshot(); - let path = snapshot.resolve_file_path(cx, true); - cx.background_executor().spawn(async move { - let path = path - .as_ref() - .map(|path| path.to_string_lossy()) - .unwrap_or_else(|| Cow::Borrowed("untitled")); - - let mut output = String::with_capacity(path.len() + snapshot.len() + 9); - output.push_str("```"); - output.push_str(&path); - output.push('\n'); - for chunk in snapshot.as_rope().chunks() { - output.push_str(chunk); - } - if !output.ends_with('\n') { - output.push('\n'); - } - output.push_str("```"); - Ok(output) - }) - } else { - Task::ready(Err(anyhow!("no recent buffer found"))) - } - }); - - SlashCommandInvocation { - output: output.unwrap_or_else(|error| Task::ready(Err(error))), - invalidated: invalidate_rx, - cleanup: SlashCommandCleanup::new(move || drop(subscriptions)), - } - } -} diff --git a/crates/assistant/src/slash_command/file_command.rs b/crates/assistant/src/slash_command/file_command.rs index e9b9b4060bf72..a44f97f70172c 100644 --- a/crates/assistant/src/slash_command/file_command.rs +++ b/crates/assistant/src/slash_command/file_command.rs @@ -1,14 +1,15 @@ -use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation}; +use super::{SlashCommand, SlashCommandOutput}; use anyhow::Result; -use futures::channel::oneshot; use fuzzy::PathMatch; -use gpui::{AppContext, Model, Task}; +use gpui::{AppContext, Model, RenderOnce, SharedString, Task, WeakView}; use language::LspAdapterDelegate; use project::{PathMatchCandidateSet, Project}; use std::{ - path::Path, + path::{Path, PathBuf}, sync::{atomic::AtomicBool, Arc}, }; +use ui::{prelude::*, ButtonLike, ElevationIndex}; +use workspace::Workspace; pub(crate) struct FileSlashCommand { project: Model, @@ -30,7 +31,6 @@ impl FileSlashCommand { .read(cx) .visible_worktrees(cx) .collect::>(); - let include_root_name = worktrees.len() > 1; let candidate_sets = worktrees .into_iter() .map(|worktree| { @@ -40,7 +40,7 @@ impl FileSlashCommand { include_ignored: worktree .root_entry() .map_or(false, |entry| entry.is_ignored), - include_root_name, + include_root_name: true, directories_only: false, } }) @@ -68,7 +68,11 @@ impl SlashCommand for FileSlashCommand { } fn description(&self) -> String { - "insert an entire file".into() + "insert a file".into() + } + + fn tooltip_text(&self) -> String { + "insert file".into() } fn requires_argument(&self) -> bool { @@ -100,36 +104,30 @@ impl SlashCommand for FileSlashCommand { fn run( self: Arc, argument: Option<&str>, + _workspace: WeakView, _delegate: Arc, - cx: &mut AppContext, - ) -> SlashCommandInvocation { + cx: &mut WindowContext, + ) -> Task> { let project = self.project.read(cx); let Some(argument) = argument else { - return SlashCommandInvocation { - output: Task::ready(Err(anyhow::anyhow!("missing path"))), - invalidated: oneshot::channel().1, - cleanup: SlashCommandCleanup::default(), - }; + return Task::ready(Err(anyhow::anyhow!("missing path"))); }; - let path = Path::new(argument); + let path = PathBuf::from(argument); let abs_path = project.worktrees().find_map(|worktree| { let worktree = worktree.read(cx); - worktree.entry_for_path(path)?; - worktree.absolutize(path).ok() + let worktree_root_path = Path::new(worktree.root_name()); + let relative_path = path.strip_prefix(worktree_root_path).ok()?; + worktree.absolutize(&relative_path).ok() }); let Some(abs_path) = abs_path else { - return SlashCommandInvocation { - output: Task::ready(Err(anyhow::anyhow!("missing path"))), - invalidated: oneshot::channel().1, - cleanup: SlashCommandCleanup::default(), - }; + return Task::ready(Err(anyhow::anyhow!("missing path"))); }; let fs = project.fs().clone(); let argument = argument.to_string(); - let output = cx.background_executor().spawn(async move { + let text = cx.background_executor().spawn(async move { let content = fs.load(&abs_path).await?; let mut output = String::with_capacity(argument.len() + content.len() + 9); output.push_str("```"); @@ -140,12 +138,46 @@ impl SlashCommand for FileSlashCommand { output.push('\n'); } output.push_str("```"); - Ok(output) + anyhow::Ok(output) }); - SlashCommandInvocation { - output, - invalidated: oneshot::channel().1, - cleanup: SlashCommandCleanup::default(), - } + cx.foreground_executor().spawn(async move { + let text = text.await?; + Ok(SlashCommandOutput { + text, + render_placeholder: Arc::new(move |id, unfold, _cx| { + FilePlaceholder { + path: Some(path.clone()), + id, + unfold, + } + .into_any_element() + }), + }) + }) + } +} + +#[derive(IntoElement)] +pub struct FilePlaceholder { + pub path: Option, + pub id: ElementId, + pub unfold: Arc, +} + +impl RenderOnce for FilePlaceholder { + fn render(self, _cx: &mut WindowContext) -> impl IntoElement { + let unfold = self.unfold; + let title = if let Some(path) = self.path.as_ref() { + SharedString::from(path.to_string_lossy().to_string()) + } else { + SharedString::from("untitled") + }; + + ButtonLike::new(self.id) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ElevatedSurface) + .child(Icon::new(IconName::File)) + .child(Label::new(title)) + .on_click(move |_, cx| unfold(cx)) } } diff --git a/crates/assistant/src/slash_command/project_command.rs b/crates/assistant/src/slash_command/project_command.rs new file mode 100644 index 0000000000000..5aa5ebd607f1c --- /dev/null +++ b/crates/assistant/src/slash_command/project_command.rs @@ -0,0 +1,151 @@ +use super::{SlashCommand, SlashCommandOutput}; +use anyhow::{anyhow, Context, Result}; +use fs::Fs; +use gpui::{AppContext, Model, Task, WeakView}; +use language::LspAdapterDelegate; +use project::{Project, ProjectPath}; +use std::{ + fmt::Write, + path::Path, + sync::{atomic::AtomicBool, Arc}, +}; +use ui::{prelude::*, ButtonLike, ElevationIndex}; +use workspace::Workspace; + +pub(crate) struct ProjectSlashCommand; + +impl ProjectSlashCommand { + async fn build_message(fs: Arc, path_to_cargo_toml: &Path) -> Result { + let buffer = fs.load(path_to_cargo_toml).await?; + let cargo_toml: cargo_toml::Manifest = toml::from_str(&buffer)?; + + let mut message = String::new(); + writeln!(message, "You are in a Rust project.")?; + + if let Some(workspace) = cargo_toml.workspace { + writeln!( + message, + "The project is a Cargo workspace with the following members:" + )?; + for member in workspace.members { + writeln!(message, "- {member}")?; + } + + if !workspace.default_members.is_empty() { + writeln!(message, "The default members are:")?; + for member in workspace.default_members { + writeln!(message, "- {member}")?; + } + } + + if !workspace.dependencies.is_empty() { + writeln!( + message, + "The following workspace dependencies are installed:" + )?; + for dependency in workspace.dependencies.keys() { + writeln!(message, "- {dependency}")?; + } + } + } else if let Some(package) = cargo_toml.package { + writeln!( + message, + "The project name is \"{name}\".", + name = package.name + )?; + + let description = package + .description + .as_ref() + .and_then(|description| description.get().ok().cloned()); + if let Some(description) = description.as_ref() { + writeln!(message, "It describes itself as \"{description}\".")?; + } + + if !cargo_toml.dependencies.is_empty() { + writeln!(message, "The following dependencies are installed:")?; + for dependency in cargo_toml.dependencies.keys() { + writeln!(message, "- {dependency}")?; + } + } + } + + Ok(message) + } + + fn path_to_cargo_toml(project: Model, cx: &mut AppContext) -> Option> { + let worktree = project.read(cx).worktrees().next()?; + let worktree = worktree.read(cx); + let entry = worktree.entry_for_path("Cargo.toml")?; + let path = ProjectPath { + worktree_id: worktree.id(), + path: entry.path.clone(), + }; + Some(Arc::from( + project.read(cx).absolute_path(&path, cx)?.as_path(), + )) + } +} + +impl SlashCommand for ProjectSlashCommand { + fn name(&self) -> String { + "project".into() + } + + fn description(&self) -> String { + "insert current project context".into() + } + + fn tooltip_text(&self) -> String { + "insert current project context".into() + } + + fn complete_argument( + &self, + _query: String, + _cancel: Arc, + _cx: &mut AppContext, + ) -> Task>> { + Task::ready(Err(anyhow!("this command does not require argument"))) + } + + fn requires_argument(&self) -> bool { + false + } + + fn run( + self: Arc, + _argument: Option<&str>, + workspace: WeakView, + _delegate: Arc, + cx: &mut WindowContext, + ) -> Task> { + let output = workspace.update(cx, |workspace, cx| { + let project = workspace.project().clone(); + let fs = workspace.project().read(cx).fs().clone(); + let path = Self::path_to_cargo_toml(project, cx); + let output = cx.background_executor().spawn(async move { + let path = path.with_context(|| "Cargo.toml not found")?; + Self::build_message(fs, &path).await + }); + + cx.foreground_executor().spawn(async move { + let text = output.await?; + + Ok(SlashCommandOutput { + text, + render_placeholder: Arc::new(move |id, unfold, _cx| { + ButtonLike::new(id) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ElevatedSurface) + .child(Icon::new(IconName::FileTree)) + .child(Label::new("Project")) + .on_click(move |_, cx| unfold(cx)) + .into_any_element() + }), + }) + }) + }); + output.unwrap_or_else(|error| Task::ready(Err(error))) + } +} diff --git a/crates/assistant/src/slash_command/prompt_command.rs b/crates/assistant/src/slash_command/prompt_command.rs index 21ce0cd706352..e4a0f1137106b 100644 --- a/crates/assistant/src/slash_command/prompt_command.rs +++ b/crates/assistant/src/slash_command/prompt_command.rs @@ -1,11 +1,12 @@ -use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation}; +use super::{SlashCommand, SlashCommandOutput}; use crate::prompts::PromptLibrary; use anyhow::{anyhow, Context, Result}; -use futures::channel::oneshot; use fuzzy::StringMatchCandidate; -use gpui::{AppContext, Task}; +use gpui::{AppContext, Task, WeakView}; use language::LspAdapterDelegate; use std::sync::{atomic::AtomicBool, Arc}; +use ui::{prelude::*, ButtonLike, ElevationIndex}; +use workspace::Workspace; pub(crate) struct PromptSlashCommand { library: Arc, @@ -26,6 +27,10 @@ impl SlashCommand for PromptSlashCommand { "insert a prompt from the library".into() } + fn tooltip_text(&self) -> String { + "insert prompt".into() + } + fn requires_argument(&self) -> bool { true } @@ -64,32 +69,43 @@ impl SlashCommand for PromptSlashCommand { fn run( self: Arc, title: Option<&str>, + _workspace: WeakView, _delegate: Arc, - cx: &mut AppContext, - ) -> SlashCommandInvocation { + cx: &mut WindowContext, + ) -> Task> { let Some(title) = title else { - return SlashCommandInvocation { - output: Task::ready(Err(anyhow!("missing prompt name"))), - invalidated: oneshot::channel().1, - cleanup: SlashCommandCleanup::default(), - }; + return Task::ready(Err(anyhow!("missing prompt name"))); }; let library = self.library.clone(); - let title = title.to_string(); - let output = cx.background_executor().spawn(async move { - let prompt = library - .prompts() - .into_iter() - .find(|prompt| &prompt.1.title().to_string() == &title) - .with_context(|| format!("no prompt found with title {:?}", title))? - .1; - Ok(prompt.body()) + let title = SharedString::from(title.to_string()); + let prompt = cx.background_executor().spawn({ + let title = title.clone(); + async move { + let prompt = library + .prompts() + .into_iter() + .map(|prompt| (prompt.1.title(), prompt)) + .find(|(t, _)| t == &title) + .with_context(|| format!("no prompt found with title {:?}", title))? + .1; + anyhow::Ok(prompt.1.body()) + } }); - SlashCommandInvocation { - output, - invalidated: oneshot::channel().1, - cleanup: SlashCommandCleanup::default(), - } + cx.foreground_executor().spawn(async move { + let prompt = prompt.await?; + Ok(SlashCommandOutput { + text: prompt, + render_placeholder: Arc::new(move |id, unfold, _cx| { + ButtonLike::new(id) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ElevatedSurface) + .child(Icon::new(IconName::Library)) + .child(Label::new(title.clone())) + .on_click(move |_, cx| unfold(cx)) + .into_any_element() + }), + }) + }) } } diff --git a/crates/assistant_slash_command/Cargo.toml b/crates/assistant_slash_command/Cargo.toml index a30e9ba0a0c10..a2a14593da570 100644 --- a/crates/assistant_slash_command/Cargo.toml +++ b/crates/assistant_slash_command/Cargo.toml @@ -15,7 +15,7 @@ path = "src/assistant_slash_command.rs" anyhow.workspace = true collections.workspace = true derive_more.workspace = true -futures.workspace = true gpui.workspace = true language.workspace = true parking_lot.workspace = true +workspace.workspace = true diff --git a/crates/assistant_slash_command/src/assistant_slash_command.rs b/crates/assistant_slash_command/src/assistant_slash_command.rs index dcdf446673475..7f3a8df1891ef 100644 --- a/crates/assistant_slash_command/src/assistant_slash_command.rs +++ b/crates/assistant_slash_command/src/assistant_slash_command.rs @@ -1,14 +1,11 @@ mod slash_command_registry; -use std::sync::atomic::AtomicBool; -use std::sync::Arc; - use anyhow::Result; -use futures::channel::oneshot; -use gpui::{AppContext, Task}; +use gpui::{AnyElement, AppContext, ElementId, Task, WeakView, WindowContext}; use language::LspAdapterDelegate; - pub use slash_command_registry::*; +use std::sync::{atomic::AtomicBool, Arc}; +use workspace::Workspace; pub fn init(cx: &mut AppContext) { SlashCommandRegistry::default_global(cx); @@ -17,6 +14,7 @@ pub fn init(cx: &mut AppContext) { pub trait SlashCommand: 'static + Send + Sync { fn name(&self) -> String; fn description(&self) -> String; + fn tooltip_text(&self) -> String; fn complete_argument( &self, query: String, @@ -27,35 +25,24 @@ pub trait SlashCommand: 'static + Send + Sync { fn run( self: Arc, argument: Option<&str>, + workspace: WeakView, // TODO: We're just using the `LspAdapterDelegate` here because that is // what the extension API is already expecting. // // It may be that `LspAdapterDelegate` needs a more general name, or // perhaps another kind of delegate is needed here. delegate: Arc, - cx: &mut AppContext, - ) -> SlashCommandInvocation; + cx: &mut WindowContext, + ) -> Task>; } -pub struct SlashCommandInvocation { - pub output: Task>, - pub invalidated: oneshot::Receiver<()>, - pub cleanup: SlashCommandCleanup, -} - -#[derive(Default)] -pub struct SlashCommandCleanup(Option>); - -impl SlashCommandCleanup { - pub fn new(cleanup: impl FnOnce() + 'static) -> Self { - Self(Some(Box::new(cleanup))) - } -} +pub type RenderFoldPlaceholder = Arc< + dyn Send + + Sync + + Fn(ElementId, Arc, &mut WindowContext) -> AnyElement, +>; -impl Drop for SlashCommandCleanup { - fn drop(&mut self) { - if let Some(cleanup) = self.0.take() { - cleanup(); - } - } +pub struct SlashCommandOutput { + pub text: String, + pub render_placeholder: RenderFoldPlaceholder, } diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs index 995ca3ab44727..8b616880dba23 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/crates/collab_ui/src/chat_panel/message_editor.rs @@ -305,6 +305,7 @@ impl MessageEditor { documentation: None, server_id: LanguageServerId(0), // TODO: Make this optional or something? lsp_completion: Default::default(), // TODO: Make this optional or something? + confirm: None, } }) .collect() diff --git a/crates/editor/src/display_map/flap_map.rs b/crates/editor/src/display_map/flap_map.rs index 9c33f766c9541..de4b8245dff1e 100644 --- a/crates/editor/src/display_map/flap_map.rs +++ b/crates/editor/src/display_map/flap_map.rs @@ -36,7 +36,13 @@ impl FlapSnapshot { while let Some(item) = cursor.item() { match Ord::cmp(&item.flap.range.start.to_point(snapshot).row, &row.0) { Ordering::Less => cursor.next(snapshot), - Ordering::Equal => return Some(&item.flap), + Ordering::Equal => { + if item.flap.range.start.is_valid(snapshot) { + return Some(&item.flap); + } else { + cursor.next(snapshot); + } + } Ordering::Greater => break, } } diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index a117f2058bf23..25f080aeaf628 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -20,6 +20,8 @@ pub struct FoldPlaceholder { pub render: Arc, &mut WindowContext) -> AnyElement>, /// If true, the element is constrained to the shaped width of an ellipsis. pub constrain_width: bool, + /// If true, merges the fold with an adjacent one. + pub merge_adjacent: bool, } impl FoldPlaceholder { @@ -30,6 +32,7 @@ impl FoldPlaceholder { Self { render: Arc::new(|_id, _range, _cx| gpui::Empty.into_any_element()), constrain_width: true, + merge_adjacent: true, } } } @@ -374,8 +377,11 @@ impl FoldMap { assert!(fold_range.start.0 >= sum.input.len); - while folds.peek().map_or(false, |(_, next_fold_range)| { - next_fold_range.start <= fold_range.end + while folds.peek().map_or(false, |(next_fold, next_fold_range)| { + next_fold_range.start < fold_range.end + || (next_fold_range.start == fold_range.end + && fold.placeholder.merge_adjacent + && next_fold.placeholder.merge_adjacent) }) { let (_, next_fold_range) = folds.next().unwrap(); if next_fold_range.end > fold_range.end { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a1f16e8d86442..1e7a3fa2814dd 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1628,6 +1628,7 @@ impl Editor { }) .into_any() }), + merge_adjacent: true, }; let display_map = cx.new_model(|cx| { let file_header_size = if show_excerpt_controls { 3 } else { 2 }; @@ -3905,6 +3906,7 @@ impl Editor { let snippet; let text; + if completion.is_snippet() { snippet = Some(Snippet::parse(&completion.new_text).log_err()?); text = snippet.as_ref().unwrap().text.clone(); @@ -3998,6 +4000,10 @@ impl Editor { this.refresh_inline_completion(true, cx); }); + if let Some(confirm) = completion.confirm.as_ref() { + (confirm)(cx); + } + let provider = self.completion_provider.as_ref()?; let apply_edits = provider.apply_additional_edits_for_completion( buffer_handle, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index ecdf3c7623f1e..8580fcc056c3d 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -3908,7 +3908,7 @@ enum LineFragment { Text(ShapedLine), Element { element: Option, - width: Pixels, + size: Size, len: usize, }, } @@ -3917,9 +3917,9 @@ impl fmt::Debug for LineFragment { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { LineFragment::Text(shaped_line) => f.debug_tuple("Text").field(shaped_line).finish(), - LineFragment::Element { width, len, .. } => f + LineFragment::Element { size, len, .. } => f .debug_struct("Element") - .field("width", width) + .field("size", size) .field("len", len) .finish(), } @@ -3999,7 +3999,7 @@ impl LineWithInvisibles { len += highlighted_chunk.text.len(); fragments.push(LineFragment::Element { element: Some(element), - width: size.width, + size, len: highlighted_chunk.text.len(), }); } else { @@ -4112,13 +4112,18 @@ impl LineWithInvisibles { LineFragment::Text(line) => { fragment_origin.x += line.width; } - LineFragment::Element { element, width, .. } => { + LineFragment::Element { element, size, .. } => { let mut element = element .take() .expect("you can't prepaint LineWithInvisibles twice"); - element.prepaint_at(fragment_origin, cx); + + // Center the element vertically within the line. + let mut element_origin = fragment_origin; + element_origin.y += (line_height - size.height) / 2.; + element.prepaint_at(element_origin, cx); line_elements.push(element); - fragment_origin.x += *width; + + fragment_origin.x += size.width; } } } @@ -4146,8 +4151,8 @@ impl LineWithInvisibles { line.paint(fragment_origin, line_height, cx).log_err(); fragment_origin.x += line.width; } - LineFragment::Element { width, .. } => { - fragment_origin.x += *width; + LineFragment::Element { size, .. } => { + fragment_origin.x += size.width; } } } @@ -4225,12 +4230,12 @@ impl LineWithInvisibles { fragment_start_x += shaped_line.width; fragment_start_index = fragment_end_index; } - LineFragment::Element { len, width, .. } => { + LineFragment::Element { len, size, .. } => { let fragment_end_index = fragment_start_index + len; if index < fragment_end_index { return fragment_start_x; } - fragment_start_x += *width; + fragment_start_x += size.width; fragment_start_index = fragment_end_index; } } @@ -4255,8 +4260,8 @@ impl LineWithInvisibles { fragment_start_x = fragment_end_x; fragment_start_index += shaped_line.len; } - LineFragment::Element { len, width, .. } => { - let fragment_end_x = fragment_start_x + *width; + LineFragment::Element { len, size, .. } => { + let fragment_end_x = fragment_start_x + size.width; if x < fragment_end_x { return Some(fragment_start_index); } diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml index 24ce269ea013c..231318cd69619 100644 --- a/crates/extension/Cargo.toml +++ b/crates/extension/Cargo.toml @@ -46,6 +46,7 @@ wasmtime.workspace = true wasmtime-wasi.workspace = true wasmparser.workspace = true wit-component.workspace = true +workspace.workspace = true task.workspace = true serde_json_lenient.workspace = true @@ -58,3 +59,4 @@ fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index 28cc796538c9a..a3f5ca89af23b 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -133,6 +133,7 @@ impl LanguageServerManifestEntry { #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] pub struct SlashCommandManifestEntry { pub description: String, + pub tooltip_text: String, pub requires_argument: bool, } diff --git a/crates/extension/src/extension_slash_command.rs b/crates/extension/src/extension_slash_command.rs index 8f98c414d0f12..e16b18cd31ebf 100644 --- a/crates/extension/src/extension_slash_command.rs +++ b/crates/extension/src/extension_slash_command.rs @@ -1,15 +1,12 @@ -use std::sync::atomic::AtomicBool; -use std::sync::Arc; - +use crate::wasm_host::{WasmExtension, WasmHost}; use anyhow::{anyhow, Result}; -use assistant_slash_command::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation}; -use futures::channel::oneshot; +use assistant_slash_command::{SlashCommand, SlashCommandOutput}; use futures::FutureExt; -use gpui::{AppContext, Task}; +use gpui::{AppContext, IntoElement, Task, WeakView, WindowContext}; use language::LspAdapterDelegate; +use std::sync::{atomic::AtomicBool, Arc}; use wasmtime_wasi::WasiView; - -use crate::wasm_host::{WasmExtension, WasmHost}; +use workspace::Workspace; pub struct ExtensionSlashCommand { pub(crate) extension: WasmExtension, @@ -27,6 +24,10 @@ impl SlashCommand for ExtensionSlashCommand { self.command.description.clone() } + fn tooltip_text(&self) -> String { + self.command.tooltip_text.clone() + } + fn requires_argument(&self) -> bool { self.command.requires_argument } @@ -43,11 +44,11 @@ impl SlashCommand for ExtensionSlashCommand { fn run( self: Arc, argument: Option<&str>, + _workspace: WeakView, delegate: Arc, - cx: &mut AppContext, - ) -> SlashCommandInvocation { + cx: &mut WindowContext, + ) -> Task> { let argument = argument.map(|arg| arg.to_string()); - let output = cx.background_executor().spawn(async move { let output = self .extension @@ -72,14 +73,16 @@ impl SlashCommand for ExtensionSlashCommand { } }) .await?; - output.ok_or_else(|| anyhow!("no output from command: {}", self.command.name)) }); - - SlashCommandInvocation { - output, - invalidated: oneshot::channel().1, - cleanup: SlashCommandCleanup::default(), - } + cx.foreground_executor().spawn(async move { + let output = output.await?; + Ok(SlashCommandOutput { + text: output, + render_placeholder: Arc::new(|_, _, _| { + "TODO: Extension command output".into_any_element() + }), + }) + }) } } diff --git a/crates/extension/src/extension_store.rs b/crates/extension/src/extension_store.rs index 4c18af678145a..e40dbb6722d0b 100644 --- a/crates/extension/src/extension_store.rs +++ b/crates/extension/src/extension_store.rs @@ -1183,6 +1183,7 @@ impl ExtensionStore { command: crate::wit::SlashCommand { name: slash_command_name.to_string(), description: slash_command.description.to_string(), + tooltip_text: slash_command.tooltip_text.to_string(), requires_argument: slash_command.requires_argument, }, extension: wasm_extension.clone(), diff --git a/crates/extension_api/wit/since_v0.0.7/slash-command.wit b/crates/extension_api/wit/since_v0.0.7/slash-command.wit index 4dedfde07ae66..1be04f3319ac8 100644 --- a/crates/extension_api/wit/since_v0.0.7/slash-command.wit +++ b/crates/extension_api/wit/since_v0.0.7/slash-command.wit @@ -5,6 +5,8 @@ interface slash-command { name: string, /// The description of the slash command. description: string, + /// The tooltip text to display for the run button. + tooltip-text: string, /// Whether this slash command requires an argument. requires-argument: bool, } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 00609aef2debe..e269dd6b2035a 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -291,6 +291,8 @@ impl Interactivity { let action = action.downcast_ref().unwrap(); if phase == DispatchPhase::Capture { (listener)(action, cx) + } else { + cx.propagate(); } }), )); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 0ba691472c761..32e7b758de449 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -36,7 +36,7 @@ use git::{blame::Blame, repository::GitRepository}; use globset::{Glob, GlobSet, GlobSetBuilder}; use gpui::{ AnyModel, AppContext, AsyncAppContext, BackgroundExecutor, BorrowAppContext, Context, Entity, - EventEmitter, Model, ModelContext, PromptLevel, SharedString, Task, WeakModel, + EventEmitter, Model, ModelContext, PromptLevel, SharedString, Task, WeakModel, WindowContext, }; use itertools::Itertools; use language::{ @@ -407,7 +407,7 @@ pub struct InlayHint { } /// A completion provided by a language server -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct Completion { /// The range of the buffer that will be replaced. pub old_range: Range, @@ -421,6 +421,21 @@ pub struct Completion { pub documentation: Option, /// The raw completion provided by the language server. pub lsp_completion: lsp::CompletionItem, + /// An optional callback to invoke when this completion is confirmed. + pub confirm: Option>, +} + +impl std::fmt::Debug for Completion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Completion") + .field("old_range", &self.old_range) + .field("new_text", &self.new_text) + .field("label", &self.label) + .field("server_id", &self.server_id) + .field("documentation", &self.documentation) + .field("lsp_completion", &self.lsp_completion) + .finish() + } } /// A completion provided by a language server @@ -2029,6 +2044,30 @@ impl Project { }) } + pub fn open_buffer_for_full_path( + &mut self, + path: &Path, + cx: &mut ModelContext, + ) -> Task>> { + if let Some(worktree_name) = path.components().next() { + let worktree = self.worktrees().find(|worktree| { + OsStr::new(worktree.read(cx).root_name()) == worktree_name.as_os_str() + }); + if let Some(worktree) = worktree { + let worktree = worktree.read(cx); + let worktree_root_path = Path::new(worktree.root_name()); + if let Ok(path) = path.strip_prefix(worktree_root_path) { + let project_path = ProjectPath { + worktree_id: worktree.id(), + path: path.into(), + }; + return self.open_buffer(project_path, cx); + } + } + } + Task::ready(Err(anyhow!("buffer not found for {:?}", path))) + } + pub fn open_local_buffer( &mut self, abs_path: impl AsRef, @@ -9212,6 +9251,7 @@ impl Project { runs: Default::default(), filter_range: Default::default(), }, + confirm: None, }, false, cx, @@ -10883,6 +10923,7 @@ async fn populate_labels_for_completions( server_id: completion.server_id, documentation, lsp_completion, + confirm: None, }) } } diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 05dc40b868ade..a7a83a91aeff8 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -184,6 +184,7 @@ pub enum IconName { Tab, Terminal, Trash, + TriangleRight, Update, WholeWord, XCircle, @@ -303,6 +304,7 @@ impl IconName { IconName::Tab => "icons/tab.svg", IconName::Terminal => "icons/terminal.svg", IconName::Trash => "icons/trash.svg", + IconName::TriangleRight => "icons/triangle_right.svg", IconName::Update => "icons/update.svg", IconName::WholeWord => "icons/word_search.svg", IconName::XCircle => "icons/error.svg", diff --git a/extensions/gleam/extension.toml b/extensions/gleam/extension.toml index 642db17a74546..3a290472b218c 100644 --- a/extensions/gleam/extension.toml +++ b/extensions/gleam/extension.toml @@ -17,3 +17,4 @@ commit = "8432ffe32ccd360534837256747beb5b1c82fca1" [slash_commands.gleam-project] description = "Returns information about the current Gleam project." requires_argument = false +tooltip_text = "Insert Gleam project data"