diff --git a/Cargo.lock b/Cargo.lock index 83e228e0f6..eafcdacb7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -348,6 +348,7 @@ dependencies = [ "file_icons", "fs", "futures 0.3.28", + "fuzzy", "gpui", "http 0.1.0", "indoc", diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index 4a3b131a52..513ef765b8 100644 --- a/crates/assistant/Cargo.toml +++ b/crates/assistant/Cargo.toml @@ -21,6 +21,7 @@ editor.workspace = true file_icons.workspace = true fs.workspace = true futures.workspace = true +fuzzy.workspace = true gpui.workspace = true http.workspace = true indoc.workspace = true diff --git a/crates/assistant/src/ambient_context/current_project.rs b/crates/assistant/src/ambient_context/current_project.rs index 0f41c45988..f89a2a8856 100644 --- a/crates/assistant/src/ambient_context/current_project.rs +++ b/crates/assistant/src/ambient_context/current_project.rs @@ -34,10 +34,12 @@ impl Default for CurrentProjectContext { 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(), - }) + self.enabled + .then(|| LanguageModelRequestMessage { + role: Role::System, + content: self.message.clone(), + }) + .filter(|message| !message.content.is_empty()) } /// Updates the [`CurrentProjectContext`] for the given [`Project`]. diff --git a/crates/assistant/src/ambient_context/recent_buffers.rs b/crates/assistant/src/ambient_context/recent_buffers.rs index 95ff074316..056fbd1183 100644 --- a/crates/assistant/src/ambient_context/recent_buffers.rs +++ b/crates/assistant/src/ambient_context/recent_buffers.rs @@ -87,10 +87,12 @@ impl RecentBuffersContext { /// 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(), - }) + self.enabled + .then(|| LanguageModelRequestMessage { + role: Role::System, + content: self.snapshot.message.to_string(), + }) + .filter(|message| !message.content.is_empty()) } } diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index 63994e60a1..f5272915f4 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -7,6 +7,7 @@ mod prompt_library; mod prompts; mod saved_conversation; mod search; +mod slash_command; mod streaming_diff; use ambient_context::AmbientContextSnapshot; @@ -16,6 +17,7 @@ use client::{proto, Client}; use command_palette_hooks::CommandPaletteFilter; pub(crate) use completion_provider::*; use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal}; +pub(crate) use prompt_library::*; pub(crate) use saved_conversation::*; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 603c5aa439..bcf1bbe731 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -5,6 +5,9 @@ use crate::{ prompt_library::{PromptLibrary, PromptManager}, prompts::generate_content_prompt, search::*, + slash_command::{ + SlashCommandCleanup, SlashCommandCompletionProvider, SlashCommandLine, SlashCommandRegistry, + }, ApplyEdit, Assist, CompletionProvider, CycleMessageRole, InlineAssist, InsertActivePrompt, LanguageModel, LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, MessageStatus, QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, @@ -14,9 +17,10 @@ use anyhow::{anyhow, Result}; use client::telemetry::Telemetry; use collections::{hash_map, HashMap, HashSet, VecDeque}; use editor::{ - actions::{MoveDown, MoveUp}, + actions::{FoldAt, MoveDown, MoveUp}, display_map::{ - BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint, + BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, Flap, FlapId, + ToDisplayPoint, }, scroll::{Autoscroll, AutoscrollStrategy}, Anchor, Editor, EditorElement, EditorEvent, EditorStyle, MultiBufferSnapshot, RowExt, @@ -26,16 +30,17 @@ use file_icons::FileIcons; use fs::Fs; use futures::StreamExt; use gpui::{ - canvas, div, point, relative, rems, uniform_list, Action, AnyView, AppContext, AsyncAppContext, - AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Entity, 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, + canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext, + AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Entity, + 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::{ language_settings::SoftWrap, AutoindentMode, Buffer, BufferSnapshot, LanguageRegistry, - OffsetRangeExt as _, Point, ToOffset as _, + OffsetRangeExt as _, Point, ToOffset as _, ToPoint as _, }; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; @@ -45,7 +50,7 @@ use settings::Settings; use std::{ cmp::{self, Ordering}, fmt::Write, - iter, + iter, mem, ops::Range, path::PathBuf, sync::Arc, @@ -64,6 +69,7 @@ use 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( @@ -98,6 +104,7 @@ pub struct AssistantPanel { focus_handle: FocusHandle, toolbar: View, languages: Arc, + slash_commands: Arc, prompt_library: Arc, fs: Arc, telemetry: Arc, @@ -190,6 +197,12 @@ impl AssistantPanel { }) .detach(); + let slash_command_registry = SlashCommandRegistry::new( + workspace.project().clone(), + prompt_library.clone(), + cx.window_handle().downcast::(), + ); + Self { workspace: workspace_handle, active_conversation_editor: None, @@ -200,6 +213,7 @@ impl AssistantPanel { focus_handle, toolbar, languages: workspace.app_state().languages.clone(), + slash_commands: slash_command_registry, prompt_library, fs: workspace.app_state().fs.clone(), telemetry: workspace.client().telemetry().clone(), @@ -785,6 +799,7 @@ impl AssistantPanel { ConversationEditor::new( self.model.clone(), self.languages.clone(), + self.slash_commands.clone(), self.fs.clone(), workspace, cx, @@ -1083,6 +1098,7 @@ impl AssistantPanel { let fs = self.fs.clone(); let workspace = self.workspace.clone(); + let slash_commands = self.slash_commands.clone(); let languages = self.languages.clone(); let telemetry = self.telemetry.clone(); cx.spawn(|this, mut cx| async move { @@ -1093,6 +1109,7 @@ impl AssistantPanel { model, path.clone(), languages, + slash_commands, Some(telemetry), &mut cx, ) @@ -1380,11 +1397,15 @@ impl FocusableView for AssistantPanel { } } +#[derive(Clone)] enum ConversationEvent { MessagesEdited, SummaryChanged, EditSuggestionsChanged, StreamedCompletion, + SlashCommandsChanged, + SlashCommandOutputAdded(Range), + SlashCommandOutputRemoved(Range), } #[derive(Default)] @@ -1398,6 +1419,7 @@ pub struct Conversation { buffer: Model, pub(crate) ambient_context: AmbientContext, edit_suggestions: Vec, + slash_command_calls: Vec, message_anchors: Vec, messages_metadata: HashMap, next_message_id: MessageId, @@ -1409,10 +1431,12 @@ 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, _subscriptions: Vec, telemetry: Option>, + slash_command_registry: Arc, language_registry: Arc, } @@ -1422,6 +1446,7 @@ impl Conversation { fn new( model: LanguageModel, language_registry: Arc, + slash_command_registry: Arc, telemetry: Option>, cx: &mut ModelContext, ) -> Self { @@ -1438,6 +1463,7 @@ impl Conversation { next_message_id: Default::default(), ambient_context: AmbientContext::default(), edit_suggestions: Vec::new(), + slash_command_calls: Vec::new(), summary: None, pending_summary: Task::ready(None), completion_count: Default::default(), @@ -1445,12 +1471,14 @@ impl Conversation { token_count: None, pending_token_count: Task::ready(None), pending_edit_suggestion_parse: None, + pending_command_invocation_parse: None, model, _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)], pending_save: Task::ready(Ok(())), path: None, buffer, telemetry, + slash_command_registry, language_registry, }; @@ -1500,6 +1528,7 @@ impl Conversation { model: LanguageModel, path: PathBuf, language_registry: Arc, + slash_command_registry: Arc, telemetry: Option>, cx: &mut AsyncAppContext, ) -> Result> { @@ -1540,6 +1569,7 @@ impl Conversation { next_message_id, ambient_context: AmbientContext::default(), edit_suggestions: Vec::new(), + slash_command_calls: Vec::new(), summary: Some(Summary { text: saved_conversation.summary, done: true, @@ -1549,6 +1579,7 @@ 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), model, _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)], @@ -1557,6 +1588,7 @@ impl Conversation { buffer, telemetry, language_registry, + slash_command_registry, }; this.set_language(cx); this.reparse_edit_suggestions(cx); @@ -1640,6 +1672,7 @@ impl Conversation { if *event == language::Event::Edited { self.count_remaining_tokens(cx); self.reparse_edit_suggestions(cx); + self.reparse_slash_command_calls(cx); cx.emit(ConversationEvent::MessagesEdited); } } @@ -1725,6 +1758,220 @@ 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) = this.slash_command_registry.command(name) { + 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, 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); + } + + this.slash_command_calls = new_calls; + }) + .ok(); + })); + } + + 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; + } + + if old_call.argument.as_deref() != new_call.argument.clone().map(|range| &new_text[range]) { + return false; + } + + if old_call.should_rerun { + return false; + } + + 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; + } + } + } + true + } + + 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, + )) + } + } + fn remaining_tokens(&self) -> Option { Some(self.model.max_token_count() as isize - self.token_count? as isize) } @@ -2183,6 +2430,17 @@ 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() { @@ -2202,6 +2460,16 @@ impl Conversation { let message_end = message_end .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, @@ -2209,6 +2477,7 @@ impl Conversation { anchor: message_anchor.start, role: metadata.role, status: metadata.status.clone(), + slash_command_ranges, ambient_context: metadata.ambient_context.clone(), }); } @@ -2367,6 +2636,16 @@ fn parse_next_edit_suggestion(lines: &mut rope::Lines) -> Option, + output_range: Option>, + name: String, + argument: Option, + should_rerun: bool, + _invalidate: Task<()>, + _command_cleanup: SlashCommandCleanup, +} + struct PendingCompletion { id: usize, _task: Task<()>, @@ -2387,6 +2666,7 @@ struct ConversationEditor { fs: Arc, workspace: WeakView, editor: View, + flap_ids: HashMap, FlapId>, blocks: HashSet, scroll_position: Option, _subscriptions: Vec, @@ -2396,13 +2676,21 @@ impl ConversationEditor { fn new( model: LanguageModel, language_registry: Arc, + slash_command_registry: Arc, fs: Arc, workspace: View, cx: &mut ViewContext, ) -> Self { let telemetry = workspace.read(cx).client().telemetry().clone(); - let conversation = - cx.new_model(|cx| Conversation::new(model, language_registry, Some(telemetry), cx)); + let conversation = cx.new_model(|cx| { + Conversation::new( + model, + language_registry, + slash_command_registry, + Some(telemetry), + cx, + ) + }); Self::for_conversation(conversation, fs, workspace, cx) } @@ -2412,11 +2700,17 @@ impl ConversationEditor { workspace: View, cx: &mut ViewContext, ) -> Self { + let command_registry = conversation.read(cx).slash_command_registry.clone(); + let completion_provider = SlashCommandCompletionProvider::new(command_registry); + let editor = cx.new_view(|cx| { let mut editor = Editor::for_buffer(conversation.read(cx).buffer.clone(), None, cx); editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); - editor.set_show_gutter(false, cx); + editor.set_show_line_numbers(false, cx); + editor.set_show_git_diff_gutter(false, cx); + editor.set_show_code_actions(false, cx); editor.set_show_wrap_guides(false, cx); + editor.set_completion_provider(Box::new(completion_provider)); editor }); @@ -2432,6 +2726,7 @@ impl ConversationEditor { editor, blocks: Default::default(), scroll_position: None, + flap_ids: Default::default(), fs, workspace: workspace.downgrade(), _subscriptions, @@ -2570,6 +2865,68 @@ impl ConversationEditor { } }); } + ConversationEvent::SlashCommandsChanged => { + 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 + }; + 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); + } + }); + } + ConversationEvent::SlashCommandOutputAdded(range) => { + 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 buffer_row = MultiBufferRow(start.to_point(&buffer).row); + + let flap_id = editor + .insert_flaps( + [Flap::new( + start..end, + render_slash_command_output_toggle, + render_slash_command_output_trailer, + )], + cx, + ) + .into_iter() + .next() + .unwrap(); + self.flap_ids.insert(range.clone(), flap_id); + 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); + }); + } + } } } @@ -2732,6 +3089,7 @@ impl ConversationEditor { h_flex() .id(("message_header", message_id.0)) + .pl(cx.gutter_dimensions.width) .h_11() .w_full() .relative() @@ -3157,7 +3515,6 @@ impl Render for ConversationEditor { .child( div() .flex_grow() - .pl_4() .bg(cx.theme().colors().editor_background) .child(self.editor.clone()), ) @@ -3184,14 +3541,41 @@ 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 content = buffer - .text_for_range(self.offset_range.clone()) - .collect::(); + let mut slash_command_ranges = self.slash_command_ranges.iter().peekable(); + let mut content = String::with_capacity(self.offset_range.len()); + let mut offset = self.offset_range.start; + let mut chunks = buffer.text_for_range(self.offset_range.clone()); + while let Some(chunk) = chunks.next() { + if let Some(slash_command_range) = slash_command_ranges.peek() { + match offset.cmp(&slash_command_range.start) { + Ordering::Less => { + let max_len = slash_command_range.start - offset; + if chunk.len() < max_len { + content.push_str(chunk); + offset += chunk.len(); + } else { + content.push_str(&chunk[..max_len]); + offset += max_len; + chunks.seek(slash_command_range.end); + slash_command_ranges.next(); + } + } + Ordering::Equal | Ordering::Greater => { + chunks.seek(slash_command_range.end); + offset = slash_command_range.end; + slash_command_ranges.next(); + } + } + } else { + content.push_str(chunk); + } + } LanguageModelRequestMessage { role: self.role, content: content.trim_end().into(), @@ -3470,6 +3854,35 @@ struct PendingInlineAssist { project: WeakModel, } +type ToggleFold = Arc; + +fn render_slash_command_output_toggle( + row: MultiBufferRow, + is_folded: bool, + fold: ToggleFold, + _cx: &mut WindowContext, +) -> AnyElement { + IconButton::new( + ("slash-command-output-fold-indicator", row.0), + ui::IconName::ChevronDown, + ) + .on_click(move |_e, cx| fold(!is_folded, cx)) + .icon_color(ui::Color::Muted) + .icon_size(ui::IconSize::Small) + .selected(is_folded) + .selected_icon(ui::IconName::ChevronRight) + .size(ui::ButtonSize::None) + .into_any_element() +} + +fn render_slash_command_output_trailer( + _row: MultiBufferRow, + _is_folded: bool, + _cx: &mut WindowContext, +) -> AnyElement { + div().into_any_element() +} + fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { ranges.sort_unstable_by(|a, b| { a.start @@ -3494,14 +3907,17 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { #[cfg(test)] mod tests { - use std::path::Path; + use std::{cell::RefCell, path::Path, rc::Rc}; use super::*; use crate::{FakeCompletionProvider, MessageId}; + use fs::FakeFs; use gpui::{AppContext, TestAppContext}; use rope::Rope; + use serde_json::json; use settings::SettingsStore; use unindent::Unindent; + use util::test::marked_text_ranges; #[gpui::test] fn test_inserting_and_removing_messages(cx: &mut AppContext) { @@ -3511,8 +3927,15 @@ mod tests { init(cx); let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let conversation = - cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, None, cx)); + let conversation = cx.new_model(|cx| { + Conversation::new( + LanguageModel::default(), + registry, + Default::default(), + None, + cx, + ) + }); let buffer = conversation.read(cx).buffer.clone(); let message_1 = conversation.read(cx).message_anchors[0].clone(); @@ -3643,8 +4066,15 @@ mod tests { init(cx); let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let conversation = - cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, None, cx)); + let conversation = cx.new_model(|cx| { + Conversation::new( + LanguageModel::default(), + registry, + Default::default(), + None, + cx, + ) + }); let buffer = conversation.read(cx).buffer.clone(); let message_1 = conversation.read(cx).message_anchors[0].clone(); @@ -3742,8 +4172,15 @@ mod tests { cx.set_global(settings_store); init(cx); let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let conversation = - cx.new_model(|cx| Conversation::new(LanguageModel::default(), registry, None, cx)); + let conversation = cx.new_model(|cx| { + Conversation::new( + LanguageModel::default(), + registry, + Default::default(), + None, + cx, + ) + }); let buffer = conversation.read(cx).buffer.clone(); let message_1 = conversation.read(cx).message_anchors[0].clone(); @@ -3820,6 +4257,258 @@ mod tests { } } + #[gpui::test] + async fn test_slash_commands(cx: &mut TestAppContext) { + let settings_store = cx.update(SettingsStore::test); + cx.set_global(settings_store); + cx.set_global(CompletionProvider::Fake(FakeCompletionProvider::default())); + cx.update(Project::init_settings); + cx.update(init); + let fs = FakeFs::new(cx.background_executor.clone()); + + fs.insert_tree( + "/test", + json!({ + "src": { + "lib.rs": "fn one() -> usize { 1 }", + "main.rs": " + use crate::one; + fn main() { one(); } + ".unindent(), + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let prompt_library = Arc::new(PromptLibrary::default()); + let slash_command_registry = + SlashCommandRegistry::new(project.clone(), prompt_library, None); + + let registry = Arc::new(LanguageRegistry::test(cx.executor())); + let conversation = cx.new_model(|cx| { + Conversation::new( + LanguageModel::default(), + registry.clone(), + slash_command_registry, + None, + cx, + ) + }); + + let output_ranges = Rc::new(RefCell::new(HashSet::default())); + conversation.update(cx, |_, cx| { + 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); + } + _ => {} + } + }) + .detach(); + }); + + let buffer = conversation.read_with(cx, |conversation, _| conversation.buffer.clone()); + + // Insert a slash command + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "/file src/lib.rs")], None, cx); + }); + assert_text_and_output_ranges( + &buffer, + &output_ranges.borrow(), + " + /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 + 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); + }); + 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(), + 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); + }); + buffer.update(cx, |buffer, cx| { + buffer.edit( + [(buffer.len()..buffer.len(), "\ngoodbye\nfarewell\n")], + 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() + }] + ); + + #[track_caller] + fn assert_text_and_output_ranges( + buffer: &Model, + ranges: &HashSet>, + expected_marked_text: &str, + cx: &mut TestAppContext, + ) { + let (expected_text, expected_ranges) = marked_text_ranges(expected_marked_text, false); + let (actual_text, actual_ranges) = buffer.update(cx, |buffer, _| { + let mut ranges = ranges + .iter() + .map(|range| range.to_offset(buffer)) + .collect::>(); + ranges.sort_by_key(|a| a.start); + (buffer.text(), ranges) + }); + + assert_eq!(actual_text, expected_text); + assert_eq!(actual_ranges, expected_ranges); + } + } + #[test] fn test_parse_next_edit_suggestion() { let text = " @@ -3898,7 +4587,13 @@ mod tests { cx.update(init); let registry = Arc::new(LanguageRegistry::test(cx.executor())); let conversation = cx.new_model(|cx| { - Conversation::new(LanguageModel::default(), registry.clone(), None, cx) + Conversation::new( + LanguageModel::default(), + registry.clone(), + Default::default(), + None, + cx, + ) }); let buffer = conversation.read_with(cx, |conversation, _| conversation.buffer.clone()); let message_0 = @@ -3938,6 +4633,7 @@ mod tests { LanguageModel::default(), Default::default(), registry.clone(), + Default::default(), None, &mut cx.to_async(), ) diff --git a/crates/assistant/src/completion_provider.rs b/crates/assistant/src/completion_provider.rs index a2c60d708d..666dab5dfc 100644 --- a/crates/assistant/src/completion_provider.rs +++ b/crates/assistant/src/completion_provider.rs @@ -233,7 +233,7 @@ impl CompletionProvider { CompletionProvider::Anthropic(provider) => provider.count_tokens(request, cx), CompletionProvider::ZedDotDev(provider) => provider.count_tokens(request, cx), #[cfg(test)] - CompletionProvider::Fake(_) => unimplemented!(), + CompletionProvider::Fake(_) => futures::FutureExt::boxed(futures::future::ready(Ok(0))), } } diff --git a/crates/assistant/src/prompt_library.rs b/crates/assistant/src/prompt_library.rs index 66bd548ff9..01bce68a5d 100644 --- a/crates/assistant/src/prompt_library.rs +++ b/crates/assistant/src/prompt_library.rs @@ -156,10 +156,10 @@ impl PromptLibrary { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct UserPrompt { version: String, - title: String, + pub title: String, author: String, languages: Vec, - prompt: String, + pub prompt: String, } impl UserPrompt { diff --git a/crates/assistant/src/slash_command.rs b/crates/assistant/src/slash_command.rs new file mode 100644 index 0000000000..ed4acecb7d --- /dev/null +++ b/crates/assistant/src/slash_command.rs @@ -0,0 +1,319 @@ +use anyhow::Result; +use collections::HashMap; +use editor::{CompletionProvider, Editor}; +use futures::channel::oneshot; +use fuzzy::{match_strings, StringMatchCandidate}; +use gpui::{AppContext, Model, Task, ViewContext, WindowHandle}; +use language::{Anchor, Buffer, CodeLabel, Documentation, LanguageServerId, ToPoint}; +use parking_lot::{Mutex, RwLock}; +use project::Project; +use rope::Point; +use std::{ + ops::Range, + sync::{ + atomic::{AtomicBool, Ordering::SeqCst}, + Arc, + }, +}; +use workspace::Workspace; + +use crate::PromptLibrary; + +mod current_file_command; +mod file_command; +mod prompt_command; + +pub(crate) struct SlashCommandCompletionProvider { + commands: Arc, + cancel_flag: Mutex>, +} + +#[derive(Default)] +pub(crate) struct SlashCommandRegistry { + commands: HashMap>, +} + +pub(crate) trait SlashCommand: 'static + Send + Sync { + fn name(&self) -> String; + fn description(&self) -> String; + fn complete_argument( + &self, + query: String, + cancel: Arc, + cx: &mut AppContext, + ) -> Task>>; + fn requires_argument(&self) -> bool; + fn run(&self, argument: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation; +} + +pub(crate) struct SlashCommandInvocation { + pub output: Task>, + pub invalidated: oneshot::Receiver<()>, + pub cleanup: SlashCommandCleanup, +} + +#[derive(Default)] +pub(crate) struct SlashCommandCleanup(Option>); + +impl SlashCommandCleanup { + pub fn new(cleanup: impl FnOnce() + 'static) -> Self { + Self(Some(Box::new(cleanup))) + } +} + +impl Drop for SlashCommandCleanup { + fn drop(&mut self) { + if let Some(cleanup) = self.0.take() { + cleanup(); + } + } +} + +pub(crate) struct SlashCommandLine { + /// The range within the line containing the command name. + pub name: Range, + /// The range within the line containing the command argument. + pub argument: Option>, +} + +impl SlashCommandRegistry { + pub fn new( + project: Model, + prompt_library: Arc, + window: Option>, + ) -> Arc { + let mut this = Self { + commands: HashMap::default(), + }; + + this.register_command(file_command::FileSlashCommand::new(project)); + this.register_command(prompt_command::PromptSlashCommand::new(prompt_library)); + if let Some(window) = window { + this.register_command(current_file_command::CurrentFileSlashCommand::new(window)); + } + + Arc::new(this) + } + + fn register_command(&mut self, command: impl SlashCommand) { + self.commands.insert(command.name(), Box::new(command)); + } + + fn command_names(&self) -> impl Iterator { + self.commands.keys() + } + + pub(crate) fn command(&self, name: &str) -> Option<&dyn SlashCommand> { + self.commands.get(name).map(|b| &**b) + } +} + +impl SlashCommandCompletionProvider { + pub fn new(commands: Arc) -> Self { + Self { + cancel_flag: Mutex::new(Arc::new(AtomicBool::new(false))), + commands, + } + } + + fn complete_command_name( + &self, + command_name: &str, + range: Range, + cx: &mut AppContext, + ) -> Task>> { + let candidates = self + .commands + .command_names() + .enumerate() + .map(|(ix, def)| StringMatchCandidate { + id: ix, + string: def.clone(), + char_bag: def.as_str().into(), + }) + .collect::>(); + let commands = self.commands.clone(); + let command_name = command_name.to_string(); + let executor = cx.background_executor().clone(); + executor.clone().spawn(async move { + let matches = match_strings( + &candidates, + &command_name, + true, + usize::MAX, + &Default::default(), + executor, + ) + .await; + + Ok(matches + .into_iter() + .filter_map(|mat| { + let command = commands.command(&mat.string)?; + let mut new_text = mat.string.clone(); + if command.requires_argument() { + new_text.push(' '); + } + + Some(project::Completion { + old_range: range.clone(), + documentation: Some(Documentation::SingleLine(command.description())), + new_text, + label: CodeLabel::plain(mat.string, None), + server_id: LanguageServerId(0), + lsp_completion: Default::default(), + }) + }) + .collect()) + }) + } + + fn complete_command_argument( + &self, + command_name: &str, + argument: String, + range: Range, + cx: &mut AppContext, + ) -> Task>> { + let new_cancel_flag = Arc::new(AtomicBool::new(false)); + let mut flag = self.cancel_flag.lock(); + flag.store(true, SeqCst); + *flag = new_cancel_flag.clone(); + + if let Some(command) = self.commands.command(command_name) { + let completions = command.complete_argument(argument, new_cancel_flag.clone(), cx); + cx.background_executor().spawn(async move { + Ok(completions + .await? + .into_iter() + .map(|arg| project::Completion { + old_range: range.clone(), + label: CodeLabel::plain(arg.clone(), None), + new_text: arg.clone(), + documentation: None, + server_id: LanguageServerId(0), + lsp_completion: Default::default(), + }) + .collect()) + }) + } else { + cx.background_executor() + .spawn(async move { Ok(Vec::new()) }) + } + } +} + +impl CompletionProvider for SlashCommandCompletionProvider { + fn completions( + &self, + buffer: &Model, + 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)) + } + }); + + task.unwrap_or_else(|| Task::ready(Ok(Vec::new()))) + } + + fn resolve_completions( + &self, + _: Model, + _: Vec, + _: Arc>>, + _: &mut ViewContext, + ) -> Task> { + Task::ready(Ok(true)) + } + + fn apply_additional_edits_for_completion( + &self, + _: Model, + _: project::Completion, + _: bool, + _: &mut ViewContext, + ) -> Task>> { + Task::ready(Ok(None)) + } + + fn is_completion_trigger( + &self, + buffer: &Model, + position: language::Anchor, + _text: &str, + _trigger_in_words: bool, + cx: &mut ViewContext, + ) -> bool { + let buffer = buffer.read(cx); + let position = position.to_point(buffer); + let line_start = Point::new(position.row, 0); + let mut lines = buffer.text_for_range(line_start..position).lines(); + if let Some(line) = lines.next() { + SlashCommandLine::parse(line).is_some() + } else { + false + } + } +} + +impl SlashCommandLine { + pub(crate) fn parse(line: &str) -> Option { + let mut call: Option = None; + let mut ix = 0; + for c in line.chars() { + let next_ix = ix + c.len_utf8(); + if let Some(call) = &mut call { + // The command arguments start at the first non-whitespace character + // after the command name, and continue until the end of the line. + if let Some(argument) = &mut call.argument { + if (*argument).is_empty() && c.is_whitespace() { + argument.start = next_ix; + } + argument.end = next_ix; + } + // The command name ends at the first whitespace character. + else if !call.name.is_empty() { + if c.is_whitespace() { + call.argument = Some(next_ix..next_ix); + } else { + call.name.end = next_ix; + } + } + // The command name must begin with a letter. + else if c.is_alphabetic() { + call.name.end = next_ix; + } else { + return None; + } + } + // Commands start with a slash. + else if c == '/' { + call = Some(SlashCommandLine { + name: next_ix..next_ix, + argument: None, + }); + } + // The line can't contain anything before the slash except for whitespace. + else if !c.is_whitespace() { + return None; + } + ix = next_ix; + } + call + } +} diff --git a/crates/assistant/src/slash_command/current_file_command.rs b/crates/assistant/src/slash_command/current_file_command.rs new file mode 100644 index 0000000000..752e599e92 --- /dev/null +++ b/crates/assistant/src/slash_command/current_file_command.rs @@ -0,0 +1,135 @@ +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 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, _argument: Option<&str>, 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 new file mode 100644 index 0000000000..c8d1aac6e3 --- /dev/null +++ b/crates/assistant/src/slash_command/file_command.rs @@ -0,0 +1,145 @@ +use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation}; +use anyhow::Result; +use futures::channel::oneshot; +use fuzzy::PathMatch; +use gpui::{AppContext, Model, Task}; +use project::{PathMatchCandidateSet, Project}; +use std::{ + path::Path, + sync::{atomic::AtomicBool, Arc}, +}; + +pub(crate) struct FileSlashCommand { + project: Model, +} + +impl FileSlashCommand { + pub fn new(project: Model) -> Self { + Self { project } + } + + fn search_paths( + &self, + query: String, + cancellation_flag: Arc, + cx: &mut AppContext, + ) -> Task> { + let worktrees = self + .project + .read(cx) + .visible_worktrees(cx) + .collect::>(); + let include_root_name = worktrees.len() > 1; + let candidate_sets = worktrees + .into_iter() + .map(|worktree| { + let worktree = worktree.read(cx); + PathMatchCandidateSet { + snapshot: worktree.snapshot(), + include_ignored: worktree + .root_entry() + .map_or(false, |entry| entry.is_ignored), + include_root_name, + directories_only: false, + } + }) + .collect::>(); + + let executor = cx.background_executor().clone(); + cx.foreground_executor().spawn(async move { + fuzzy::match_path_sets( + candidate_sets.as_slice(), + query.as_str(), + None, + false, + 100, + &cancellation_flag, + executor, + ) + .await + }) + } +} + +impl SlashCommand for FileSlashCommand { + fn name(&self) -> String { + "file".into() + } + + fn description(&self) -> String { + "insert an entire file".into() + } + + fn requires_argument(&self) -> bool { + true + } + + fn complete_argument( + &self, + query: String, + cancellation_flag: Arc, + cx: &mut AppContext, + ) -> gpui::Task>> { + let paths = self.search_paths(query, cancellation_flag, cx); + cx.background_executor().spawn(async move { + Ok(paths + .await + .into_iter() + .map(|path_match| { + format!( + "{}{}", + path_match.path_prefix, + path_match.path.to_string_lossy() + ) + }) + .collect()) + }) + } + + fn run(&self, argument: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation { + 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(), + }; + }; + + let path = Path::new(argument); + let abs_path = project.worktrees().find_map(|worktree| { + let worktree = worktree.read(cx); + worktree.entry_for_path(path)?; + worktree.absolutize(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(), + }; + }; + + let fs = project.fs().clone(); + let argument = argument.to_string(); + let output = 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("```"); + output.push_str(&argument); + output.push('\n'); + output.push_str(&content); + if !output.ends_with('\n') { + output.push('\n'); + } + output.push_str("```"); + Ok(output) + }); + SlashCommandInvocation { + output, + invalidated: oneshot::channel().1, + cleanup: SlashCommandCleanup::default(), + } + } +} diff --git a/crates/assistant/src/slash_command/prompt_command.rs b/crates/assistant/src/slash_command/prompt_command.rs new file mode 100644 index 0000000000..ee862d1d86 --- /dev/null +++ b/crates/assistant/src/slash_command/prompt_command.rs @@ -0,0 +1,88 @@ +use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation}; +use crate::PromptLibrary; +use anyhow::{anyhow, Context, Result}; +use futures::channel::oneshot; +use fuzzy::StringMatchCandidate; +use gpui::{AppContext, Task}; +use std::sync::{atomic::AtomicBool, Arc}; + +pub(crate) struct PromptSlashCommand { + library: Arc, +} + +impl PromptSlashCommand { + pub fn new(library: Arc) -> Self { + Self { library } + } +} + +impl SlashCommand for PromptSlashCommand { + fn name(&self) -> String { + "prompt".into() + } + + fn description(&self) -> String { + "insert a prompt from the library".into() + } + + fn requires_argument(&self) -> bool { + true + } + + fn complete_argument( + &self, + query: String, + cancellation_flag: Arc, + cx: &mut AppContext, + ) -> Task>> { + let library = self.library.clone(); + let executor = cx.background_executor().clone(); + cx.background_executor().spawn(async move { + let candidates = library + .prompts() + .into_iter() + .enumerate() + .map(|(ix, prompt)| StringMatchCandidate::new(ix, prompt.title)) + .collect::>(); + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + 100, + &cancellation_flag, + executor, + ) + .await; + Ok(matches + .into_iter() + .map(|mat| candidates[mat.candidate_id].string.clone()) + .collect()) + }) + } + + fn run(&self, title: Option<&str>, cx: &mut AppContext) -> SlashCommandInvocation { + let Some(title) = title else { + return SlashCommandInvocation { + output: Task::ready(Err(anyhow!("missing prompt name"))), + invalidated: oneshot::channel().1, + cleanup: SlashCommandCleanup::default(), + }; + }; + + 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.title == title) + .with_context(|| format!("no prompt found with title {:?}", title))?; + Ok(prompt.prompt) + }); + SlashCommandInvocation { + output, + invalidated: oneshot::channel().1, + cleanup: SlashCommandCleanup::default(), + } + } +} diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs index 35439a4108..a4e71cc312 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/crates/collab_ui/src/chat_panel/message_editor.rs @@ -75,6 +75,17 @@ impl CompletionProvider for MessageEditorCompletionProvider { ) -> Task>> { Task::ready(Ok(None)) } + + fn is_completion_trigger( + &self, + _buffer: &Model, + _position: language::Anchor, + text: &str, + _trigger_in_words: bool, + _cx: &mut ViewContext, + ) -> bool { + text == "@" + } } impl MessageEditor { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index cf81a2c538..26ee9e5fce 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -449,6 +449,9 @@ pub struct Editor { mode: EditorMode, show_breadcrumbs: bool, show_gutter: bool, + show_line_numbers: Option, + show_git_diff_gutter: Option, + show_code_actions: Option, show_wrap_guides: Option, placeholder_text: Option>, highlight_order: usize, @@ -517,6 +520,9 @@ pub struct Editor { pub struct EditorSnapshot { pub mode: EditorMode, show_gutter: bool, + show_line_numbers: Option, + show_git_diff_gutter: Option, + show_code_actions: Option, render_git_blame_gutter: bool, pub display_snapshot: DisplaySnapshot, pub placeholder_text: Option>, @@ -1646,6 +1652,9 @@ impl Editor { mode, show_breadcrumbs: EditorSettings::get_global(cx).toolbar.breadcrumbs, show_gutter: mode == EditorMode::Full, + show_line_numbers: None, + show_git_diff_gutter: None, + show_code_actions: None, show_wrap_guides: None, placeholder_text: None, highlight_order: 0, @@ -1881,6 +1890,9 @@ impl Editor { EditorSnapshot { mode: self.mode, show_gutter: self.show_gutter, + show_line_numbers: self.show_line_numbers, + show_git_diff_gutter: self.show_git_diff_gutter, + show_code_actions: self.show_code_actions, render_git_blame_gutter: self.render_git_blame_gutter(cx), display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)), scroll_anchor: self.scroll_manager.anchor(), @@ -1933,8 +1945,8 @@ impl Editor { self.custom_context_menu = Some(Box::new(f)) } - pub fn set_completion_provider(&mut self, hub: Box) { - self.completion_provider = Some(hub); + pub fn set_completion_provider(&mut self, provider: Box) { + self.completion_provider = Some(provider); } pub fn set_inline_completion_provider( @@ -3280,22 +3292,41 @@ impl Editor { trigger_in_words: bool, cx: &mut ViewContext, ) { - if !EditorSettings::get_global(cx).show_completions_on_input { - return; - } - - let selection = self.selections.newest_anchor(); - if self - .buffer - .read(cx) - .is_completion_trigger(selection.head(), text, trigger_in_words, cx) - { + if self.is_completion_trigger(text, trigger_in_words, cx) { self.show_completions(&ShowCompletions, cx); } else { self.hide_context_menu(cx); } } + fn is_completion_trigger( + &self, + text: &str, + trigger_in_words: bool, + cx: &mut ViewContext, + ) -> bool { + let position = self.selections.newest_anchor().head(); + let multibuffer = self.buffer.read(cx); + let Some(buffer) = position + .buffer_id + .and_then(|buffer_id| multibuffer.buffer(buffer_id).clone()) + else { + return false; + }; + + if let Some(completion_provider) = &self.completion_provider { + completion_provider.is_completion_trigger( + &buffer, + position.text_anchor, + text, + trigger_in_words, + cx, + ) + } else { + false + } + } + /// If any empty selections is touching the start of its innermost containing autoclose /// region, expand it to select the brackets. fn select_autoclose_pair(&mut self, cx: &mut ViewContext) { @@ -9613,8 +9644,27 @@ impl Editor { cx.notify(); } - pub fn set_show_wrap_guides(&mut self, show_gutter: bool, cx: &mut ViewContext) { - self.show_wrap_guides = Some(show_gutter); + pub fn set_show_line_numbers(&mut self, show_line_numbers: bool, cx: &mut ViewContext) { + self.show_line_numbers = Some(show_line_numbers); + cx.notify(); + } + + pub fn set_show_git_diff_gutter( + &mut self, + show_git_diff_gutter: bool, + cx: &mut ViewContext, + ) { + self.show_git_diff_gutter = Some(show_git_diff_gutter); + cx.notify(); + } + + pub fn set_show_code_actions(&mut self, show_code_actions: bool, cx: &mut ViewContext) { + self.show_code_actions = Some(show_code_actions); + cx.notify(); + } + + pub fn set_show_wrap_guides(&mut self, show_wrap_guides: bool, cx: &mut ViewContext) { + self.show_wrap_guides = Some(show_wrap_guides); cx.notify(); } @@ -10888,6 +10938,15 @@ pub trait CompletionProvider { push_to_history: bool, cx: &mut ViewContext, ) -> Task>>; + + fn is_completion_trigger( + &self, + buffer: &Model, + position: language::Anchor, + text: &str, + trigger_in_words: bool, + cx: &mut ViewContext, + ) -> bool; } impl CompletionProvider for Model { @@ -10925,6 +10984,40 @@ impl CompletionProvider for Model { project.apply_additional_edits_for_completion(buffer, completion, push_to_history, cx) }) } + + fn is_completion_trigger( + &self, + buffer: &Model, + position: language::Anchor, + text: &str, + trigger_in_words: bool, + cx: &mut ViewContext, + ) -> bool { + if !EditorSettings::get_global(cx).show_completions_on_input { + return false; + } + + let mut chars = text.chars(); + let char = if let Some(char) = chars.next() { + char + } else { + return false; + }; + if chars.next().is_some() { + return false; + } + + let buffer = buffer.read(cx); + let scope = buffer.snapshot().language_scope_at(position); + if trigger_in_words && char_kind(&scope, char) == CharKind::Word { + return true; + } + + buffer + .completion_triggers() + .iter() + .any(|string| string == text) + } } fn inlay_hint_settings( @@ -11030,13 +11123,17 @@ impl EditorSnapshot { } let descent = cx.text_system().descent(font_id, font_size); - let show_git_gutter = matches!( - ProjectSettings::get_global(cx).git.git_gutter, - Some(GitGutterSetting::TrackedFiles) - ); + let show_git_gutter = self.show_git_diff_gutter.unwrap_or_else(|| { + matches!( + ProjectSettings::get_global(cx).git.git_gutter, + Some(GitGutterSetting::TrackedFiles) + ) + }); let gutter_settings = EditorSettings::get_global(cx).gutter; - let gutter_lines_enabled = gutter_settings.line_numbers; - let line_gutter_width = if gutter_lines_enabled { + let show_line_numbers = self + .show_line_numbers + .unwrap_or_else(|| gutter_settings.line_numbers); + let line_gutter_width = if show_line_numbers { // Avoid flicker-like gutter resizes when the line number gains another digit and only resize the gutter on files with N*10^5 lines. let min_width_for_number_on_gutter = em_width * 4.0; max_line_number_width.max(min_width_for_number_on_gutter) @@ -11044,26 +11141,30 @@ impl EditorSnapshot { 0.0.into() }; + let show_code_actions = self + .show_code_actions + .unwrap_or_else(|| gutter_settings.code_actions); + let git_blame_entries_width = self .render_git_blame_gutter .then_some(em_width * GIT_BLAME_GUTTER_WIDTH_CHARS); let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); - left_padding += if gutter_settings.code_actions { + left_padding += if show_code_actions { em_width * 3.0 - } else if show_git_gutter && gutter_lines_enabled { + } else if show_git_gutter && show_line_numbers { em_width * 2.0 - } else if show_git_gutter || gutter_lines_enabled { + } else if show_git_gutter || show_line_numbers { em_width } else { px(0.) }; - let right_padding = if gutter_settings.folds && gutter_lines_enabled { + let right_padding = if gutter_settings.folds && show_line_numbers { em_width * 4.0 } else if gutter_settings.folds { em_width * 3.0 - } else if gutter_lines_enabled { + } else if show_line_numbers { em_width } else { px(0.) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 66ddd12bdc..2b69874a75 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1623,6 +1623,13 @@ impl EditorElement { snapshot: &EditorSnapshot, cx: &mut WindowContext, ) -> Vec> { + let include_line_numbers = snapshot.show_line_numbers.unwrap_or_else(|| { + EditorSettings::get_global(cx).gutter.line_numbers && snapshot.mode == EditorMode::Full + }); + if !include_line_numbers { + return Vec::new(); + } + let editor = self.editor.read(cx); let newest_selection_head = newest_selection_head.unwrap_or_else(|| { let newest = editor.selections.newest::(cx); @@ -1638,54 +1645,47 @@ impl EditorElement { .head }); let font_size = self.style.text.font_size.to_pixels(cx.rem_size()); - let include_line_numbers = - EditorSettings::get_global(cx).gutter.line_numbers && snapshot.mode == EditorMode::Full; - let mut shaped_line_numbers = Vec::with_capacity(rows.len()); - let mut line_number = String::new(); + let is_relative = EditorSettings::get_global(cx).relative_line_numbers; let relative_to = if is_relative { Some(newest_selection_head.row()) } else { None }; - let relative_rows = self.calculate_relative_line_numbers(snapshot, &rows, relative_to); - - for (ix, row) in buffer_rows.into_iter().enumerate() { - let display_row = DisplayRow(rows.start.0 + ix as u32); - let color = if active_rows.contains_key(&display_row) { - cx.theme().colors().editor_active_line_number - } else { - cx.theme().colors().editor_line_number - }; - if let Some(multibuffer_row) = row { - if include_line_numbers { - line_number.clear(); - let default_number = multibuffer_row.0 + 1; - let number = relative_rows - .get(&DisplayRow(ix as u32 + rows.start.0)) - .unwrap_or(&default_number); - write!(&mut line_number, "{number}").unwrap(); - let run = TextRun { - len: line_number.len(), - font: self.style.text.font(), - color, - background_color: None, - underline: None, - strikethrough: None, - }; - let shaped_line = cx - .text_system() - .shape_line(line_number.clone().into(), font_size, &[run]) - .unwrap(); - shaped_line_numbers.push(Some(shaped_line)); - } - } else { - shaped_line_numbers.push(None); - } - } - - shaped_line_numbers + let mut line_number = String::new(); + buffer_rows + .into_iter() + .enumerate() + .map(|(ix, multibuffer_row)| { + let multibuffer_row = multibuffer_row?; + let display_row = DisplayRow(rows.start.0 + ix as u32); + let color = if active_rows.contains_key(&display_row) { + cx.theme().colors().editor_active_line_number + } else { + cx.theme().colors().editor_line_number + }; + line_number.clear(); + let default_number = multibuffer_row.0 + 1; + let number = relative_rows + .get(&DisplayRow(ix as u32 + rows.start.0)) + .unwrap_or(&default_number); + write!(&mut line_number, "{number}").unwrap(); + let run = TextRun { + len: line_number.len(), + font: self.style.text.font(), + color, + background_color: None, + underline: None, + strikethrough: None, + }; + let shaped_line = cx + .text_system() + .shape_line(line_number.clone().into(), font_size, &[run]) + .unwrap(); + Some(shaped_line) + }) + .collect() } fn layout_gutter_fold_toggles( @@ -2513,10 +2513,16 @@ impl EditorElement { } } - let show_git_gutter = matches!( - ProjectSettings::get_global(cx).git.git_gutter, - Some(GitGutterSetting::TrackedFiles) - ); + let show_git_gutter = layout + .position_map + .snapshot + .show_git_diff_gutter + .unwrap_or_else(|| { + matches!( + ProjectSettings::get_global(cx).git.git_gutter, + Some(GitGutterSetting::TrackedFiles) + ) + }); if show_git_gutter { Self::paint_diff_hunks(layout.gutter_hitbox.bounds, layout, cx) } @@ -4281,7 +4287,11 @@ impl Element for EditorElement { gutter_dimensions.width - gutter_dimensions.left_padding, cx, ); - if gutter_settings.code_actions { + + let show_code_actions = snapshot + .show_code_actions + .unwrap_or_else(|| gutter_settings.code_actions); + if show_code_actions { let newest_selection_point = newest_selection_head.to_point(&snapshot.display_snapshot); let buffer = snapshot.buffer_snapshot.buffer_line_for_row( diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 2c6b2da264..fea24410d1 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -4443,6 +4443,9 @@ impl From> for AnyWindowHandle { } } +unsafe impl Send for WindowHandle {} +unsafe impl Sync for WindowHandle {} + /// A handle to a window with any root view type, which can be downcast to a window with a specific root view type. #[derive(Copy, Clone, PartialEq, Eq, Hash)] pub struct AnyWindowHandle { diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 20bfc37537..a2ffa246d8 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -110,6 +110,7 @@ impl MultiBufferRow { pub const MIN: Self = Self(0); pub const MAX: Self = Self(u32::MAX); } + #[derive(Clone)] struct History { next_transaction_id: TransactionId, @@ -1531,46 +1532,6 @@ impl MultiBuffer { .map(|state| state.buffer.clone()) } - pub fn is_completion_trigger( - &self, - position: Anchor, - text: &str, - trigger_in_words: bool, - cx: &AppContext, - ) -> bool { - let mut chars = text.chars(); - let char = if let Some(char) = chars.next() { - char - } else { - return false; - }; - if chars.next().is_some() { - return false; - } - - let snapshot = self.snapshot(cx); - let position = position.to_offset(&snapshot); - let scope = snapshot.language_scope_at(position); - if trigger_in_words && char_kind(&scope, char) == CharKind::Word { - return true; - } - - let anchor = snapshot.anchor_before(position); - anchor - .buffer_id - .and_then(|buffer_id| { - let buffer = self.buffers.borrow().get(&buffer_id)?.buffer.clone(); - Some( - buffer - .read(cx) - .completion_triggers() - .iter() - .any(|string| string == text), - ) - }) - .unwrap_or(false) - } - pub fn language_at(&self, point: T, cx: &AppContext) -> Option> { self.point_to_buffer_offset(point, cx) .and_then(|(buffer, offset, _)| buffer.read(cx).language_at(offset)) diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 75244f84a1..b3fc0c9197 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -2166,6 +2166,31 @@ impl BufferSnapshot { } } + pub fn has_edits_since_in_range(&self, since: &clock::Global, range: Range) -> bool { + if *since != self.version { + let start_fragment_id = self.fragment_id_for_anchor(&range.start); + let end_fragment_id = self.fragment_id_for_anchor(&range.end); + let mut cursor = self + .fragments + .filter::<_, usize>(move |summary| !since.observed_all(&summary.max_version)); + cursor.next(&None); + while let Some(fragment) = cursor.item() { + if fragment.id > *end_fragment_id { + break; + } + if fragment.id > *start_fragment_id { + let was_visible = fragment.was_visible(since, &self.undo_map); + let is_visible = fragment.visible; + if was_visible != is_visible { + return true; + } + } + cursor.next(&None); + } + } + false + } + pub fn has_edits_since(&self, since: &clock::Global) -> bool { if *since != self.version { let mut cursor = self