From 0ec29d6866d79a2e4855ba27c0002175b34b984f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 5 Aug 2024 09:18:06 +0200 Subject: [PATCH] Restructure workflow step resolution and fix inserting newlines (#15720) Release Notes: - N/A --------- Co-authored-by: Nathan --- Cargo.lock | 3 +- crates/assistant/Cargo.toml | 2 +- crates/assistant/src/assistant_panel.rs | 391 +++--- crates/assistant/src/context.rs | 1240 ++++++++++++------- crates/assistant/src/context_store.rs | 21 +- crates/assistant/src/inline_assistant.rs | 101 +- crates/language/Cargo.toml | 1 + crates/language/src/buffer.rs | 57 + crates/language/src/buffer_tests.rs | 86 ++ crates/language/src/outline.rs | 32 + crates/language_model/Cargo.toml | 1 + crates/language_model/src/language_model.rs | 5 + crates/language_model/src/provider/fake.rs | 126 +- crates/language_model/src/registry.rs | 4 +- crates/language_model/src/request.rs | 4 +- crates/language_model/src/role.rs | 2 +- crates/multi_buffer/src/multi_buffer.rs | 50 + crates/text/src/anchor.rs | 5 - 18 files changed, 1316 insertions(+), 815 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51f65f51b2..0dd8f1eda7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -422,7 +422,6 @@ dependencies = [ "settings", "similar", "smol", - "strsim 0.11.1", "telemetry_events", "terminal", "terminal_view", @@ -5952,6 +5951,7 @@ dependencies = [ "similar", "smallvec", "smol", + "strsim 0.11.1", "sum_tree", "task", "text", @@ -5994,6 +5994,7 @@ dependencies = [ "menu", "ollama", "open_ai", + "parking_lot", "project", "proto", "rand 0.8.5", diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index 605867820f..ca6b4085fc 100644 --- a/crates/assistant/Cargo.toml +++ b/crates/assistant/Cargo.toml @@ -67,7 +67,6 @@ serde_json.workspace = true settings.workspace = true similar.workspace = true smol.workspace = true -strsim.workspace = true telemetry_events.workspace = true terminal.workspace = true terminal_view.workspace = true @@ -86,6 +85,7 @@ ctor.workspace = true editor = { workspace = true, features = ["test-support"] } env_logger.workspace = true language = { workspace = true, features = ["test-support"] } +language_model = { workspace = true, features = ["test-support"] } log.workspace = true project = { workspace = true, features = ["test-support"] } rand.workspace = true diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 33081b0aee..a866dba6d1 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -9,10 +9,11 @@ use crate::{ }, terminal_inline_assistant::TerminalInlineAssistant, Assist, ConfirmCommand, Context, ContextEvent, ContextId, ContextStore, CycleMessageRole, - DebugEditSteps, DeployHistory, DeployPromptLibrary, EditStep, EditStepState, - EditStepSuggestions, InlineAssist, InlineAssistId, InlineAssistant, InsertIntoEditor, - MessageStatus, ModelSelector, PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection, - RemoteContextMetadata, SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector, + DebugEditSteps, DeployHistory, DeployPromptLibrary, EditSuggestionGroup, InlineAssist, + InlineAssistId, InlineAssistant, InsertIntoEditor, MessageStatus, ModelSelector, + PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata, + SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector, WorkflowStep, + WorkflowStepEditSuggestions, }; use crate::{ContextStoreEvent, ShowConfiguration}; use anyhow::{anyhow, Result}; @@ -39,7 +40,8 @@ use gpui::{ }; use indexed_docs::IndexedDocsStore; use language::{ - language_settings::SoftWrap, Capability, LanguageRegistry, LspAdapterDelegate, Point, ToOffset, + language_settings::SoftWrap, Buffer, Capability, LanguageRegistry, LspAdapterDelegate, Point, + ToOffset, }; use language_model::{ provider::cloud::PROVIDER_ID, LanguageModelProvider, LanguageModelProviderId, @@ -1284,7 +1286,6 @@ struct ActiveEditStep { start: language::Anchor, assist_ids: Vec, editor: Option>, - _open_editor: Task>, } pub struct ContextEditor { @@ -1452,23 +1453,21 @@ impl ContextEditor { .read(cx) .buffer() .read(cx) - .text_for_range(step.source_range.clone()) + .text_for_range(step.tagged_range.clone()) .collect::() )); - match &step.state { - Some(EditStepState::Resolved(resolution)) => { + match &step.edit_suggestions { + WorkflowStepEditSuggestions::Resolved { + title, + edit_suggestions, + } => { output.push_str("Resolution:\n"); - output.push_str(&format!(" {:?}\n", resolution.step_title)); - for op in &resolution.operations { - output.push_str(&format!(" {:?}\n", op)); - } + output.push_str(&format!(" {:?}\n", title)); + output.push_str(&format!(" {:?}\n", edit_suggestions)); } - Some(EditStepState::Pending(_)) => { + WorkflowStepEditSuggestions::Pending(_) => { output.push_str("Resolution: Pending\n"); } - None => { - output.push_str("Resolution: None\n"); - } } output.push('\n'); } @@ -1875,222 +1874,165 @@ impl ContextEditor { } EditorEvent::SelectionsChanged { .. } => { self.scroll_position = self.cursor_scroll_position(cx); - if self - .edit_step_for_cursor(cx) - .map(|step| step.source_range.start) - != self.active_edit_step.as_ref().map(|step| step.start) - { - if let Some(old_active_edit_step) = self.active_edit_step.take() { - if let Some(editor) = old_active_edit_step - .editor - .and_then(|editor| editor.upgrade()) - { - self.workspace - .update(cx, |workspace, cx| { - if let Some(pane) = workspace.pane_for(&editor) { - pane.update(cx, |pane, cx| { - let item_id = editor.entity_id(); - if pane.is_active_preview_item(item_id) { - pane.close_item_by_id( - item_id, - SaveIntent::Skip, - cx, - ) - .detach_and_log_err(cx); - } - }); - } - }) - .ok(); - } - } - - if let Some(new_active_step) = self.edit_step_for_cursor(cx) { - let start = new_active_step.source_range.start; - let open_editor = new_active_step - .edit_suggestions(&self.project, cx) - .map(|suggestions| { - self.open_editor_for_edit_suggestions(suggestions, cx) - }) - .unwrap_or_else(|| Task::ready(Ok(()))); - self.active_edit_step = Some(ActiveEditStep { - start, - assist_ids: Vec::new(), - editor: None, - _open_editor: open_editor, - }); - } - } + self.update_active_workflow_step(cx); } _ => {} } cx.emit(event.clone()); } - fn open_editor_for_edit_suggestions( - &mut self, - edit_step_suggestions: Task, - cx: &mut ViewContext, - ) -> Task> { - let workspace = self.workspace.clone(); - let project = self.project.clone(); - let assistant_panel = self.assistant_panel.clone(); - cx.spawn(|this, mut cx| async move { - let edit_step_suggestions = edit_step_suggestions.await; + fn update_active_workflow_step(&mut self, cx: &mut ViewContext) { + if self + .workflow_step_for_cursor(cx) + .map(|step| step.tagged_range.start) + != self.active_edit_step.as_ref().map(|step| step.start) + { + if let Some(old_active_edit_step) = self.active_edit_step.take() { + if let Some(editor) = old_active_edit_step + .editor + .and_then(|editor| editor.upgrade()) + { + self.workspace + .update(cx, |workspace, cx| { + if let Some(pane) = workspace.pane_for(&editor) { + pane.update(cx, |pane, cx| { + let item_id = editor.entity_id(); + if pane.is_active_preview_item(item_id) { + pane.close_item_by_id(item_id, SaveIntent::Skip, cx) + .detach_and_log_err(cx); + } + }); + } + }) + .ok(); + } + } - let mut assist_ids = Vec::new(); - let editor = if edit_step_suggestions.suggestions.is_empty() { - return Ok(()); - } else if edit_step_suggestions.suggestions.len() == 1 - && edit_step_suggestions - .suggestions - .values() - .next() - .unwrap() - .len() - == 1 - { - // If there's only one buffer and one suggestion group, open it directly - let (buffer, suggestion_groups) = edit_step_suggestions - .suggestions - .into_iter() - .next() - .unwrap(); - let suggestion_group = suggestion_groups.into_iter().next().unwrap(); - let editor = workspace.update(&mut cx, |workspace, cx| { + if let Some(new_active_step) = self.workflow_step_for_cursor(cx) { + let start = new_active_step.tagged_range.start; + + let mut editor = None; + let mut assist_ids = Vec::new(); + if let WorkflowStepEditSuggestions::Resolved { + title, + edit_suggestions, + } = &new_active_step.edit_suggestions + { + if let Some((opened_editor, inline_assist_ids)) = + self.suggest_edits(title.clone(), edit_suggestions.clone(), cx) + { + editor = Some(opened_editor.downgrade()); + assist_ids = inline_assist_ids; + } + } + + self.active_edit_step = Some(ActiveEditStep { + start, + assist_ids, + editor, + }); + } + } + } + + fn suggest_edits( + &mut self, + title: String, + edit_suggestions: HashMap, Vec>, + cx: &mut ViewContext, + ) -> Option<(View, Vec)> { + let assistant_panel = self.assistant_panel.upgrade()?; + if edit_suggestions.is_empty() { + return None; + } + + let editor; + let mut suggestion_groups = Vec::new(); + if edit_suggestions.len() == 1 && edit_suggestions.values().next().unwrap().len() == 1 { + // If there's only one buffer and one suggestion group, open it directly + let (buffer, groups) = edit_suggestions.into_iter().next().unwrap(); + let group = groups.into_iter().next().unwrap(); + editor = self + .workspace + .update(cx, |workspace, cx| { let active_pane = workspace.active_pane().clone(); workspace.open_project_item::(active_pane, buffer, false, false, cx) - })?; + }) + .log_err()?; - cx.update(|cx| { - for suggestion in suggestion_group.suggestions { - let description = suggestion.description.unwrap_or_else(|| "Delete".into()); + let (&excerpt_id, _, _) = editor + .read(cx) + .buffer() + .read(cx) + .read(cx) + .as_singleton() + .unwrap(); - let range = { - let multibuffer = editor.read(cx).buffer().read(cx).read(cx); - let (&excerpt_id, _, _) = multibuffer.as_singleton().unwrap(); - multibuffer - .anchor_in_excerpt(excerpt_id, suggestion.range.start) - .unwrap() - ..multibuffer - .anchor_in_excerpt(excerpt_id, suggestion.range.end) - .unwrap() - }; - - InlineAssistant::update_global(cx, |assistant, cx| { - let suggestion_id = assistant.suggest_assist( - &editor, - range, - description, - suggestion.initial_insertion, - Some(workspace.clone()), - assistant_panel.upgrade().as_ref(), - cx, - ); - assist_ids.push(suggestion_id); - }); - } - - // Scroll the editor to the suggested assist - editor.update(cx, |editor, cx| { - let multibuffer = editor.buffer().read(cx).snapshot(cx); - let (&excerpt_id, _, buffer) = multibuffer.as_singleton().unwrap(); - let anchor = if suggestion_group.context_range.start.to_offset(buffer) == 0 - { - Anchor::min() - } else { - multibuffer - .anchor_in_excerpt(excerpt_id, suggestion_group.context_range.start) - .unwrap() - }; - - editor.set_scroll_anchor( - ScrollAnchor { - offset: gpui::Point::default(), - anchor, - }, - cx, - ); - }); - })?; - - editor - } else { - // If there are multiple buffers or suggestion groups, create a multibuffer - let mut inline_assist_suggestions = Vec::new(); - let multibuffer = cx.new_model(|cx| { - let replica_id = project.read(cx).replica_id(); - let mut multibuffer = MultiBuffer::new(replica_id, Capability::ReadWrite) - .with_title(edit_step_suggestions.title); - for (buffer, suggestion_groups) in edit_step_suggestions.suggestions { - let excerpt_ids = multibuffer.push_excerpts( - buffer, - suggestion_groups - .iter() - .map(|suggestion_group| ExcerptRange { - context: suggestion_group.context_range.clone(), - primary: None, - }), - cx, - ); - - for (excerpt_id, suggestion_group) in - excerpt_ids.into_iter().zip(suggestion_groups) - { - for suggestion in suggestion_group.suggestions { - let description = - suggestion.description.unwrap_or_else(|| "Delete".into()); - let range = { - let multibuffer = multibuffer.read(cx); - multibuffer - .anchor_in_excerpt(excerpt_id, suggestion.range.start) - .unwrap() - ..multibuffer - .anchor_in_excerpt(excerpt_id, suggestion.range.end) - .unwrap() - }; - inline_assist_suggestions.push(( - range, - description, - suggestion.initial_insertion, - )); - } - } - } + // Scroll the editor to the suggested assist + editor.update(cx, |editor, cx| { + let multibuffer = editor.buffer().read(cx).snapshot(cx); + let (&excerpt_id, _, buffer) = multibuffer.as_singleton().unwrap(); + let anchor = if group.context_range.start.to_offset(buffer) == 0 { + Anchor::min() + } else { multibuffer - })?; + .anchor_in_excerpt(excerpt_id, group.context_range.start) + .unwrap() + }; - let editor = cx - .new_view(|cx| Editor::for_multibuffer(multibuffer, Some(project), true, cx))?; - cx.update(|cx| { - InlineAssistant::update_global(cx, |assistant, cx| { - for (range, description, initial_insertion) in inline_assist_suggestions { - assist_ids.push(assistant.suggest_assist( - &editor, - range, - description, - initial_insertion, - Some(workspace.clone()), - assistant_panel.upgrade().as_ref(), - cx, - )); - } - }) - })?; - workspace.update(&mut cx, |workspace, cx| { - workspace.add_item_to_active_pane(Box::new(editor.clone()), None, false, cx) - })?; + editor.set_scroll_anchor( + ScrollAnchor { + offset: gpui::Point::default(), + anchor, + }, + cx, + ); + }); - editor - }; - - this.update(&mut cx, |this, _cx| { - if let Some(step) = this.active_edit_step.as_mut() { - step.assist_ids = assist_ids; - step.editor = Some(editor.downgrade()); + suggestion_groups.push((excerpt_id, group)); + } else { + // If there are multiple buffers or suggestion groups, create a multibuffer + let multibuffer = cx.new_model(|cx| { + let replica_id = self.project.read(cx).replica_id(); + let mut multibuffer = + MultiBuffer::new(replica_id, Capability::ReadWrite).with_title(title); + for (buffer, groups) in edit_suggestions { + let excerpt_ids = multibuffer.push_excerpts( + buffer, + groups.iter().map(|suggestion_group| ExcerptRange { + context: suggestion_group.context_range.clone(), + primary: None, + }), + cx, + ); + suggestion_groups.extend(excerpt_ids.into_iter().zip(groups)); } - }) - }) + multibuffer + }); + + editor = cx.new_view(|cx| { + Editor::for_multibuffer(multibuffer, Some(self.project.clone()), true, cx) + }); + self.workspace + .update(cx, |workspace, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, false, cx) + }) + .log_err()?; + } + + let mut assist_ids = Vec::new(); + for (excerpt_id, suggestion_group) in suggestion_groups { + for suggestion in suggestion_group.suggestions { + assist_ids.extend(suggestion.show( + &editor, + excerpt_id, + &self.workspace, + &assistant_panel, + cx, + )); + } + } + Some((editor, assist_ids)) } fn handle_editor_search_event( @@ -2374,11 +2316,10 @@ impl ContextEditor { fn render_send_button(&self, cx: &mut ViewContext) -> impl IntoElement { let focus_handle = self.focus_handle(cx).clone(); - let button_text = match self.edit_step_for_cursor(cx) { - Some(edit_step) => match &edit_step.state { - Some(EditStepState::Pending(_)) => "Computing Changes...", - Some(EditStepState::Resolved(_)) => "Apply Changes", - None => "Send", + let button_text = match self.workflow_step_for_cursor(cx) { + Some(edit_step) => match &edit_step.edit_suggestions { + WorkflowStepEditSuggestions::Pending(_) => "Computing Changes...", + WorkflowStepEditSuggestions::Resolved { .. } => "Apply Changes", }, None => "Send", }; @@ -2421,7 +2362,7 @@ impl ContextEditor { }) } - fn edit_step_for_cursor<'a>(&'a self, cx: &'a AppContext) -> Option<&'a EditStep> { + fn workflow_step_for_cursor<'a>(&'a self, cx: &'a AppContext) -> Option<&'a WorkflowStep> { let newest_cursor = self .editor .read(cx) @@ -2435,7 +2376,7 @@ impl ContextEditor { let edit_steps = context.edit_steps(); edit_steps .binary_search_by(|step| { - let step_range = step.source_range.clone(); + let step_range = step.tagged_range.clone(); if newest_cursor.cmp(&step_range.start, buffer).is_lt() { Ordering::Greater } else if newest_cursor.cmp(&step_range.end, buffer).is_gt() { diff --git a/crates/assistant/src/context.rs b/crates/assistant/src/context.rs index 0cc7e6661f..80178a8342 100644 --- a/crates/assistant/src/context.rs +++ b/crates/assistant/src/context.rs @@ -1,6 +1,6 @@ use crate::{ - prompt_library::PromptStore, slash_command::SlashCommandLine, InitialInsertion, MessageId, - MessageStatus, + prompt_library::PromptStore, slash_command::SlashCommandLine, AssistantPanel, InitialInsertion, + InlineAssistId, InlineAssistant, MessageId, MessageStatus, }; use anyhow::{anyhow, Context as _, Result}; use assistant_slash_command::{ @@ -9,14 +9,19 @@ use assistant_slash_command::{ use client::{self, proto, telemetry::Telemetry}; use clock::ReplicaId; use collections::{HashMap, HashSet}; +use editor::Editor; use fs::{Fs, RemoveOptions}; use futures::{ future::{self, Shared}, FutureExt, StreamExt, }; -use gpui::{AppContext, Context as _, EventEmitter, Model, ModelContext, Subscription, Task}; +use gpui::{ + AppContext, Context as _, EventEmitter, Model, ModelContext, Subscription, Task, UpdateGlobal, + View, WeakView, +}; use language::{ - AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, ParseStatus, Point, ToOffset, + AnchorRangeExt, Bias, Buffer, BufferSnapshot, LanguageRegistry, OffsetRangeExt, ParseStatus, + Point, ToOffset, }; use language_model::{ LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelTool, @@ -37,9 +42,10 @@ use std::{ time::{Duration, Instant}, }; use telemetry_events::AssistantKind; -use ui::SharedString; +use ui::{SharedString, WindowContext}; use util::{post_inc, ResultExt, TryFutureExt}; use uuid::Uuid; +use workspace::Workspace; #[derive(Clone, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct ContextId(String); @@ -339,427 +345,229 @@ struct PendingCompletion { pub struct SlashCommandId(clock::Lamport); #[derive(Debug)] -pub struct EditStep { - pub source_range: Range, - pub state: Option, +pub struct WorkflowStep { + pub tagged_range: Range, + pub edit_suggestions: WorkflowStepEditSuggestions, } -#[derive(Debug)] +pub enum WorkflowStepEditSuggestions { + Pending(Task>), + Resolved { + title: String, + edit_suggestions: HashMap, Vec>, + }, +} + +#[derive(Clone, Debug)] pub struct EditSuggestionGroup { pub context_range: Range, pub suggestions: Vec, } -#[derive(Debug)] -pub struct EditSuggestion { - pub range: Range, - /// If None, assume this is a suggestion to delete the range rather than transform it. - pub description: Option, - pub initial_insertion: Option, -} - -pub struct EditStepSuggestions { - pub title: String, - pub suggestions: HashMap, Vec>, -} - -impl EditStep { - pub fn edit_suggestions( - &self, - project: &Model, - cx: &AppContext, - ) -> Option> { - let Some(EditStepState::Resolved(resolution)) = &self.state else { - return None; - }; - - let title = resolution.step_title.clone(); - let suggestion_tasks: Vec<_> = resolution - .operations - .iter() - .map(|operation| operation.edit_suggestion(project.clone(), cx)) - .collect(); - - Some(cx.spawn(|mut cx| async move { - let suggestions = future::join_all(suggestion_tasks) - .await - .into_iter() - .filter_map(|task| task.log_err()) - .collect::>(); - - let mut suggestions_by_buffer = HashMap::default(); - for (buffer, suggestion) in suggestions { - suggestions_by_buffer - .entry(buffer) - .or_insert_with(Vec::new) - .push(suggestion); - } - - let mut suggestion_groups_by_buffer = HashMap::default(); - for (buffer, mut suggestions) in suggestions_by_buffer { - let mut suggestion_groups = Vec::::new(); - buffer - .update(&mut cx, |buffer, _cx| { - // Sort suggestions by their range - suggestions.sort_by(|a, b| a.range.cmp(&b.range, buffer)); - - // Dedup overlapping suggestions - suggestions.dedup_by(|a, b| { - let a_range = a.range.to_offset(buffer); - let b_range = b.range.to_offset(buffer); - if a_range.start <= b_range.end && b_range.start <= a_range.end { - if b_range.start < a_range.start { - a.range.start = b.range.start; - } - if b_range.end > a_range.end { - a.range.end = b.range.end; - } - - if let (Some(a_desc), Some(b_desc)) = - (a.description.as_mut(), b.description.as_mut()) - { - b_desc.push('\n'); - b_desc.push_str(a_desc); - } else if a.description.is_some() { - b.description = a.description.take(); - } - - true - } else { - false - } - }); - - // Create context ranges for each suggestion - for suggestion in suggestions { - let context_range = { - let suggestion_point_range = suggestion.range.to_point(buffer); - let start_row = suggestion_point_range.start.row.saturating_sub(5); - let end_row = cmp::min( - suggestion_point_range.end.row + 5, - buffer.max_point().row, - ); - let start = buffer.anchor_before(Point::new(start_row, 0)); - let end = buffer - .anchor_after(Point::new(end_row, buffer.line_len(end_row))); - start..end - }; - - if let Some(last_group) = suggestion_groups.last_mut() { - if last_group - .context_range - .end - .cmp(&context_range.start, buffer) - .is_ge() - { - // Merge with the previous group if context ranges overlap - last_group.context_range.end = context_range.end; - last_group.suggestions.push(suggestion); - } else { - // Create a new group - suggestion_groups.push(EditSuggestionGroup { - context_range, - suggestions: vec![suggestion], - }); - } - } else { - // Create the first group - suggestion_groups.push(EditSuggestionGroup { - context_range, - suggestions: vec![suggestion], - }); - } - } - }) - .ok(); - suggestion_groups_by_buffer.insert(buffer, suggestion_groups); - } - - EditStepSuggestions { - title, - suggestions: suggestion_groups_by_buffer, - } - })) - } -} - -pub enum EditStepState { - Pending(Task>), - Resolved(EditStepResolution), -} - -impl Debug for EditStepState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - EditStepState::Pending(_) => write!(f, "EditStepOperations::Pending"), - EditStepState::Resolved(operations) => f - .debug_struct("EditStepOperations::Parsed") - .field("operations", operations) - .finish(), - } - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct EditStepResolution { - /// An extremely short title for the edit step represented by these operations. - pub step_title: String, - /// A sequence of operations to apply to the codebase. - /// When multiple operations are required for a step, be sure to include multiple operations in this list. - pub operations: Vec, -} - -impl LanguageModelTool for EditStepResolution { - fn name() -> String { - "edit".into() - } - - fn description() -> String { - "suggest edits to one or more locations in the codebase".into() - } -} - -/// A description of an operation to apply to one location in the codebase. -/// -/// This object represents a single edit operation that can be performed on a specific file -/// in the codebase. It encapsulates both the location (file path) and the nature of the -/// edit to be made. -/// -/// # Fields -/// -/// * `path`: A string representing the file path where the edit operation should be applied. -/// This path is relative to the root of the project or repository. -/// -/// * `kind`: An enum representing the specific type of edit operation to be performed. -/// -/// # Usage -/// -/// `EditOperation` is used within a code editor to represent and apply -/// programmatic changes to source code. It provides a structured way to describe -/// edits for features like refactoring tools or AI-assisted coding suggestions. -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)] -pub struct EditOperation { - /// The path to the file containing the relevant operation - pub path: String, - #[serde(flatten)] - pub kind: EditOperationKind, -} - -impl EditOperation { - fn edit_suggestion( - &self, - project: Model, - cx: &AppContext, - ) -> Task, EditSuggestion)>> { - let path = self.path.clone(); - let kind = self.kind.clone(); - cx.spawn(move |mut cx| async move { - let buffer = project - .update(&mut cx, |project, cx| { - let project_path = project - .find_project_path(Path::new(&path), cx) - .with_context(|| format!("worktree not found for {:?}", path))?; - anyhow::Ok(project.open_buffer(project_path, cx)) - })?? - .await?; - - let mut parse_status = buffer.read_with(&cx, |buffer, _cx| buffer.parse_status())?; - while *parse_status.borrow() != ParseStatus::Idle { - parse_status.changed().await?; - } - - let initial_insertion = kind.initial_insertion(); - let suggestion_range = if let Some(symbol) = kind.symbol() { - let outline = buffer - .update(&mut cx, |buffer, _| buffer.snapshot().outline(None))? - .context("no outline for buffer")?; - let candidate = outline - .path_candidates - .iter() - .max_by(|a, b| { - strsim::jaro_winkler(&a.string, symbol) - .total_cmp(&strsim::jaro_winkler(&b.string, symbol)) - }) - .with_context(|| { - format!( - "symbol {:?} not found in path {:?}.\ncandidates: {:?}.\nparse status: {:?}. text:\n{}", - symbol, - path, - outline - .path_candidates - .iter() - .map(|candidate| &candidate.string) - .collect::>(), - *parse_status.borrow(), - buffer.read_with(&cx, |buffer, _| buffer.text()).unwrap_or_else(|_| "error".to_string()) - ) - })?; - - buffer.update(&mut cx, |buffer, _| { - let outline_item = &outline.items[candidate.id]; - let symbol_range = outline_item.range.to_point(buffer); - let annotation_range = outline_item - .annotation_range - .as_ref() - .map(|range| range.to_point(buffer)); - let body_range = outline_item - .body_range - .as_ref() - .map(|range| range.to_point(buffer)) - .unwrap_or(symbol_range.clone()); - - match kind { - EditOperationKind::PrependChild { .. } => { - let anchor = buffer.anchor_after(body_range.start); - anchor..anchor - } - EditOperationKind::AppendChild { .. } => { - let anchor = buffer.anchor_before(body_range.end); - anchor..anchor - } - EditOperationKind::InsertSiblingBefore { .. } => { - let anchor = buffer.anchor_before( - annotation_range.map_or(symbol_range.start, |annotation_range| { - annotation_range.start - }), - ); - anchor..anchor - } - EditOperationKind::InsertSiblingAfter { .. } => { - let anchor = buffer.anchor_after(symbol_range.end); - anchor..anchor - } - EditOperationKind::Update { .. } | EditOperationKind::Delete { .. } => { - let start = annotation_range.map_or(symbol_range.start, |range| range.start); - let start = Point::new(start.row, 0); - let end = Point::new( - symbol_range.end.row, - buffer.line_len(symbol_range.end.row), - ); - buffer.anchor_before(start)..buffer.anchor_after(end) - } - EditOperationKind::Create { .. } => unreachable!(), - } - })? - } else { - match kind { - EditOperationKind::PrependChild { .. } => { - language::Anchor::MIN..language::Anchor::MIN - } - EditOperationKind::AppendChild { .. } | EditOperationKind::Create { .. } => { - language::Anchor::MAX..language::Anchor::MAX - } - _ => unreachable!("All other operations should have a symbol"), - } - }; - - Ok(( - buffer, - EditSuggestion { - range: suggestion_range, - description: kind.description().map(ToString::to_string), - initial_insertion, - }, - )) - }) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)] -#[serde(tag = "kind")] -pub enum EditOperationKind { - /// Rewrites the specified symbol entirely based on the given description. - /// This operation completely replaces the existing symbol with new content. +#[derive(Clone, Debug)] +pub enum EditSuggestion { Update { - /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`. - /// The path should uniquely identify the symbol within the containing file. - symbol: String, - /// A brief description of the transformation to apply to the symbol. + range: Range, description: String, }, - /// Creates a new file with the given path based on the provided description. - /// This operation adds a new file to the codebase. - Create { - /// A brief description of the file to be created. + CreateFile { description: String, }, - /// Inserts a new symbol based on the given description before the specified symbol. - /// This operation adds new content immediately preceding an existing symbol. InsertSiblingBefore { - /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`. - /// The new content will be inserted immediately before this symbol. - symbol: String, - /// A brief description of the new symbol to be inserted. + position: language::Anchor, description: String, }, - /// Inserts a new symbol based on the given description after the specified symbol. - /// This operation adds new content immediately following an existing symbol. InsertSiblingAfter { - /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`. - /// The new content will be inserted immediately after this symbol. - symbol: String, - /// A brief description of the new symbol to be inserted. + position: language::Anchor, description: String, }, - /// Inserts a new symbol as a child of the specified symbol at the start. - /// This operation adds new content as the first child of an existing symbol (or file if no symbol is provided). PrependChild { - /// An optional fully-qualified reference to the symbol after the code you want to insert, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`. - /// If provided, the new content will be inserted as the first child of this symbol. - /// If not provided, the new content will be inserted at the top of the file. - symbol: Option, - /// A brief description of the new symbol to be inserted. + position: language::Anchor, description: String, }, - /// Inserts a new symbol as a child of the specified symbol at the end. - /// This operation adds new content as the last child of an existing symbol (or file if no symbol is provided). AppendChild { - /// An optional fully-qualified reference to the symbol before the code you want to insert, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`. - /// If provided, the new content will be inserted as the last child of this symbol. - /// If not provided, the new content will be applied at the bottom of the file. - symbol: Option, - /// A brief description of the new symbol to be inserted. + position: language::Anchor, description: String, }, - /// Deletes the specified symbol from the containing file. Delete { - /// An fully-qualified reference to the symbol to be deleted, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`. - symbol: String, + range: Range, }, } -impl EditOperationKind { - pub fn symbol(&self) -> Option<&str> { +impl EditSuggestion { + pub fn range(&self) -> Range { match self { - Self::Update { symbol, .. } => Some(symbol), - Self::InsertSiblingBefore { symbol, .. } => Some(symbol), - Self::InsertSiblingAfter { symbol, .. } => Some(symbol), - Self::PrependChild { symbol, .. } => symbol.as_deref(), - Self::AppendChild { symbol, .. } => symbol.as_deref(), - Self::Delete { symbol } => Some(symbol), - Self::Create { .. } => None, + EditSuggestion::Update { range, .. } => range.clone(), + EditSuggestion::CreateFile { .. } => language::Anchor::MIN..language::Anchor::MAX, + EditSuggestion::InsertSiblingBefore { position, .. } + | EditSuggestion::InsertSiblingAfter { position, .. } + | EditSuggestion::PrependChild { position, .. } + | EditSuggestion::AppendChild { position, .. } => *position..*position, + EditSuggestion::Delete { range } => range.clone(), } } pub fn description(&self) -> Option<&str> { match self { - Self::Update { description, .. } => Some(description), - Self::Create { description } => Some(description), - Self::InsertSiblingBefore { description, .. } => Some(description), - Self::InsertSiblingAfter { description, .. } => Some(description), - Self::PrependChild { description, .. } => Some(description), - Self::AppendChild { description, .. } => Some(description), - Self::Delete { .. } => None, + EditSuggestion::Update { description, .. } + | EditSuggestion::CreateFile { description } + | EditSuggestion::InsertSiblingBefore { description, .. } + | EditSuggestion::InsertSiblingAfter { description, .. } + | EditSuggestion::PrependChild { description, .. } + | EditSuggestion::AppendChild { description, .. } => Some(description), + EditSuggestion::Delete { .. } => None, } } - pub fn initial_insertion(&self) -> Option { + fn description_mut(&mut self) -> Option<&mut String> { match self { - EditOperationKind::InsertSiblingBefore { .. } => Some(InitialInsertion::NewlineAfter), - EditOperationKind::InsertSiblingAfter { .. } => Some(InitialInsertion::NewlineBefore), - EditOperationKind::PrependChild { .. } => Some(InitialInsertion::NewlineAfter), - EditOperationKind::AppendChild { .. } => Some(InitialInsertion::NewlineBefore), - _ => None, + EditSuggestion::Update { description, .. } + | EditSuggestion::CreateFile { description } + | EditSuggestion::InsertSiblingBefore { description, .. } + | EditSuggestion::InsertSiblingAfter { description, .. } + | EditSuggestion::PrependChild { description, .. } + | EditSuggestion::AppendChild { description, .. } => Some(description), + EditSuggestion::Delete { .. } => None, + } + } + + fn try_merge(&mut self, other: &Self, buffer: &BufferSnapshot) -> bool { + let range = self.range(); + let other_range = other.range(); + + // Don't merge if we don't contain the other suggestion. + if range.start.cmp(&other_range.start, buffer).is_gt() + || range.end.cmp(&other_range.end, buffer).is_lt() + { + return false; + } + + if let Some(description) = self.description_mut() { + if let Some(other_description) = other.description() { + description.push('\n'); + description.push_str(other_description); + } + } + true + } + + pub fn show( + &self, + editor: &View, + excerpt_id: editor::ExcerptId, + workspace: &WeakView, + assistant_panel: &View, + cx: &mut WindowContext, + ) -> Option { + let mut initial_transaction_id = None; + let initial_prompt; + let suggestion_range; + let buffer = editor.read(cx).buffer().clone(); + let snapshot = buffer.read(cx).snapshot(cx); + + match self { + EditSuggestion::Update { range, description } => { + initial_prompt = description.clone(); + suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)? + ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?; + } + EditSuggestion::CreateFile { description } => { + initial_prompt = description.clone(); + suggestion_range = editor::Anchor::min()..editor::Anchor::min(); + } + EditSuggestion::InsertSiblingBefore { + position, + description, + } => { + let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?; + initial_prompt = description.clone(); + suggestion_range = buffer.update(cx, |buffer, cx| { + buffer.start_transaction(cx); + let line_start = buffer.insert_empty_line(position, true, true, cx); + initial_transaction_id = buffer.end_transaction(cx); + + let line_start = buffer.read(cx).anchor_before(line_start); + line_start..line_start + }); + } + EditSuggestion::InsertSiblingAfter { + position, + description, + } => { + let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?; + initial_prompt = description.clone(); + suggestion_range = buffer.update(cx, |buffer, cx| { + buffer.start_transaction(cx); + let line_start = buffer.insert_empty_line(position, true, true, cx); + initial_transaction_id = buffer.end_transaction(cx); + + let line_start = buffer.read(cx).anchor_before(line_start); + line_start..line_start + }); + } + EditSuggestion::PrependChild { + position, + description, + } => { + let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?; + initial_prompt = description.clone(); + suggestion_range = buffer.update(cx, |buffer, cx| { + buffer.start_transaction(cx); + let line_start = buffer.insert_empty_line(position, false, true, cx); + initial_transaction_id = buffer.end_transaction(cx); + + let line_start = buffer.read(cx).anchor_before(line_start); + line_start..line_start + }); + } + EditSuggestion::AppendChild { + position, + description, + } => { + let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?; + initial_prompt = description.clone(); + suggestion_range = buffer.update(cx, |buffer, cx| { + buffer.start_transaction(cx); + let line_start = buffer.insert_empty_line(position, true, false, cx); + initial_transaction_id = buffer.end_transaction(cx); + + let line_start = buffer.read(cx).anchor_before(line_start); + line_start..line_start + }); + } + EditSuggestion::Delete { range } => { + initial_prompt = "Delete".to_string(); + suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)? + ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?; + } + } + + InlineAssistant::update_global(cx, |inline_assistant, cx| { + Some(inline_assistant.suggest_assist( + editor, + suggestion_range, + initial_prompt, + initial_transaction_id, + Some(workspace.clone()), + Some(assistant_panel), + cx, + )) + }) + } +} + +impl Debug for WorkflowStepEditSuggestions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WorkflowStepEditSuggestions::Pending(_) => write!(f, "EditStepOperations::Pending"), + WorkflowStepEditSuggestions::Resolved { + title, + edit_suggestions, + } => f + .debug_struct("EditStepOperations::Parsed") + .field("title", title) + .field("edit_suggestions", edit_suggestions) + .finish(), } } } @@ -788,7 +596,8 @@ pub struct Context { _subscriptions: Vec, telemetry: Option>, language_registry: Arc, - edit_steps: Vec, + edit_steps: Vec, + project: Option>, } impl EventEmitter for Context {} @@ -796,6 +605,7 @@ impl EventEmitter for Context {} impl Context { pub fn local( language_registry: Arc, + project: Option>, telemetry: Option>, cx: &mut ModelContext, ) -> Self { @@ -804,6 +614,7 @@ impl Context { ReplicaId::default(), language::Capability::ReadWrite, language_registry, + project, telemetry, cx, ) @@ -814,6 +625,7 @@ impl Context { replica_id: ReplicaId, capability: language::Capability, language_registry: Arc, + project: Option>, telemetry: Option>, cx: &mut ModelContext, ) -> Self { @@ -852,6 +664,7 @@ impl Context { path: None, buffer, telemetry, + project, language_registry, edit_steps: Vec::new(), }; @@ -923,6 +736,7 @@ impl Context { saved_context: SavedContext, path: PathBuf, language_registry: Arc, + project: Option>, telemetry: Option>, cx: &mut ModelContext, ) -> Self { @@ -932,6 +746,7 @@ impl Context { ReplicaId::default(), language::Capability::ReadWrite, language_registry, + project, telemetry, cx, ); @@ -1171,7 +986,7 @@ impl Context { self.summary.as_ref() } - pub fn edit_steps(&self) -> &[EditStep] { + pub fn edit_steps(&self) -> &[WorkflowStep] { &self.edit_steps } @@ -1319,7 +1134,7 @@ impl Context { let buffer = self.buffer.read(cx); let prev_len = self.edit_steps.len(); self.edit_steps.retain(|step| { - step.source_range.start.is_valid(buffer) && step.source_range.end.is_valid(buffer) + step.tagged_range.start.is_valid(buffer) && step.tagged_range.end.is_valid(buffer) }); if self.edit_steps.len() != prev_len { cx.emit(ContextEvent::EditStepsChanged); @@ -1327,58 +1142,65 @@ impl Context { } } - fn parse_edit_steps_in_range(&mut self, range: Range, cx: &mut ModelContext) { + fn parse_edit_steps_in_range( + &mut self, + range: Range, + project: Model, + cx: &mut ModelContext, + ) { let mut new_edit_steps = Vec::new(); - self.buffer.update(cx, |buffer, _cx| { - let mut message_lines = buffer.as_rope().chunks_in_range(range).lines(); - let mut in_step = false; - let mut step_start = 0; - let mut line_start_offset = message_lines.offset(); + let buffer = self.buffer.read(cx).snapshot(); + let mut message_lines = buffer.as_rope().chunks_in_range(range).lines(); + let mut in_step = false; + let mut step_start = 0; + let mut line_start_offset = message_lines.offset(); - while let Some(line) = message_lines.next() { - if let Some(step_start_index) = line.find("") { - if !in_step { - in_step = true; - step_start = line_start_offset + step_start_index; - } + while let Some(line) = message_lines.next() { + if let Some(step_start_index) = line.find("") { + if !in_step { + in_step = true; + step_start = line_start_offset + step_start_index; } - - if let Some(step_end_index) = line.find("") { - if in_step { - let start_anchor = buffer.anchor_after(step_start); - let end_anchor = buffer - .anchor_before(line_start_offset + step_end_index + "".len()); - let source_range = start_anchor..end_anchor; - - // Check if a step with the same range already exists - let existing_step_index = self.edit_steps.binary_search_by(|probe| { - probe.source_range.cmp(&source_range, buffer) - }); - - if let Err(ix) = existing_step_index { - // Step doesn't exist, so add it - new_edit_steps.push(( - ix, - EditStep { - source_range, - state: None, - }, - )); - } - - in_step = false; - } - } - - line_start_offset = message_lines.offset(); } - }); + + if let Some(step_end_index) = line.find("") { + if in_step { + let start_anchor = buffer.anchor_after(step_start); + let end_anchor = + buffer.anchor_before(line_start_offset + step_end_index + "".len()); + let tagged_range = start_anchor..end_anchor; + + // Check if a step with the same range already exists + let existing_step_index = self + .edit_steps + .binary_search_by(|probe| probe.tagged_range.cmp(&tagged_range, &buffer)); + + if let Err(ix) = existing_step_index { + // Step doesn't exist, so add it + let task = self.compute_workflow_step_edit_suggestions( + tagged_range.clone(), + project.clone(), + cx, + ); + new_edit_steps.push(( + ix, + WorkflowStep { + tagged_range, + edit_suggestions: WorkflowStepEditSuggestions::Pending(task), + }, + )); + } + + in_step = false; + } + } + + line_start_offset = message_lines.offset(); + } // Insert new steps and generate their corresponding tasks - for (index, mut step) in new_edit_steps.into_iter().rev() { - let task = self.generate_edit_step_operations(&step, cx); - step.state = Some(EditStepState::Pending(task)); + for (index, step) in new_edit_steps.into_iter().rev() { self.edit_steps.insert(index, step); } @@ -1386,9 +1208,10 @@ impl Context { cx.notify(); } - fn generate_edit_step_operations( + fn compute_workflow_step_edit_suggestions( &self, - edit_step: &EditStep, + tagged_range: Range, + project: Model, cx: &mut ModelContext, ) -> Task> { let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else { @@ -1396,11 +1219,10 @@ impl Context { }; let mut request = self.to_completion_request(cx); - let edit_step_range = edit_step.source_range.clone(); let step_text = self .buffer .read(cx) - .text_for_range(edit_step_range.clone()) + .text_for_range(tagged_range.clone()) .collect::(); cx.spawn(|this, mut cx| { @@ -1415,18 +1237,99 @@ impl Context { content: prompt, }); - let resolution = model.use_tool::(request, &cx).await?; + // Invoke the model to get its edit suggestions for this workflow step. + let step_suggestions = model + .use_tool::(request, &cx) + .await?; + + // Translate the parsed suggestions to our internal types, which anchor the suggestions to locations in the code. + let suggestion_tasks: Vec<_> = step_suggestions + .edit_suggestions + .iter() + .map(|suggestion| suggestion.resolve(project.clone(), cx.clone())) + .collect(); + + // Expand the context ranges of each suggestion and group suggestions with overlapping context ranges. + let suggestions = future::join_all(suggestion_tasks) + .await + .into_iter() + .filter_map(|task| task.log_err()) + .collect::>(); + + let mut suggestions_by_buffer = HashMap::default(); + for (buffer, suggestion) in suggestions { + suggestions_by_buffer + .entry(buffer) + .or_insert_with(Vec::new) + .push(suggestion); + } + + let mut suggestion_groups_by_buffer = HashMap::default(); + for (buffer, mut suggestions) in suggestions_by_buffer { + let mut suggestion_groups = Vec::::new(); + let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?; + // Sort suggestions by their range so that earlier, larger ranges come first + suggestions.sort_by(|a, b| a.range().cmp(&b.range(), &snapshot)); + + // Merge overlapping suggestions + suggestions.dedup_by(|a, b| b.try_merge(&a, &snapshot)); + + // Create context ranges for each suggestion + for suggestion in suggestions { + let context_range = { + let suggestion_point_range = suggestion.range().to_point(&snapshot); + let start_row = suggestion_point_range.start.row.saturating_sub(5); + let end_row = cmp::min( + suggestion_point_range.end.row + 5, + snapshot.max_point().row, + ); + let start = snapshot.anchor_before(Point::new(start_row, 0)); + let end = snapshot + .anchor_after(Point::new(end_row, snapshot.line_len(end_row))); + start..end + }; + + if let Some(last_group) = suggestion_groups.last_mut() { + if last_group + .context_range + .end + .cmp(&context_range.start, &snapshot) + .is_ge() + { + // Merge with the previous group if context ranges overlap + last_group.context_range.end = context_range.end; + last_group.suggestions.push(suggestion); + } else { + // Create a new group + suggestion_groups.push(EditSuggestionGroup { + context_range, + suggestions: vec![suggestion], + }); + } + } else { + // Create the first group + suggestion_groups.push(EditSuggestionGroup { + context_range, + suggestions: vec![suggestion], + }); + } + } + + suggestion_groups_by_buffer.insert(buffer, suggestion_groups); + } this.update(&mut cx, |this, cx| { let step_index = this .edit_steps .binary_search_by(|step| { - step.source_range - .cmp(&edit_step_range, this.buffer.read(cx)) + step.tagged_range.cmp(&tagged_range, this.buffer.read(cx)) }) .map_err(|_| anyhow!("edit step not found"))?; if let Some(edit_step) = this.edit_steps.get_mut(step_index) { - edit_step.state = Some(EditStepState::Resolved(resolution)); + edit_step.edit_suggestions = WorkflowStepEditSuggestions::Resolved { + title: step_suggestions.step_title, + edit_suggestions: suggestion_groups_by_buffer, + }; cx.emit(ContextEvent::EditStepsChanged); } anyhow::Ok(()) @@ -1651,7 +1554,9 @@ impl Context { ); message_start_offset..message_new_end_offset }); - this.parse_edit_steps_in_range(message_range, cx); + if let Some(project) = this.project.clone() { + this.parse_edit_steps_in_range(message_range, project, cx); + } cx.emit(ContextEvent::StreamedCompletion); Some(()) @@ -2514,12 +2419,12 @@ mod tests { #[gpui::test] fn test_inserting_and_removing_messages(cx: &mut AppContext) { let settings_store = SettingsStore::test(cx); - language_model::LanguageModelRegistry::test(cx); + LanguageModelRegistry::test(cx); cx.set_global(settings_store); assistant_panel::init(cx); let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let context = cx.new_model(|cx| Context::local(registry, None, cx)); + let context = cx.new_model(|cx| Context::local(registry, None, None, cx)); let buffer = context.read(cx).buffer.clone(); let message_1 = context.read(cx).message_anchors[0].clone(); @@ -2646,11 +2551,11 @@ mod tests { fn test_message_splitting(cx: &mut AppContext) { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - language_model::LanguageModelRegistry::test(cx); + LanguageModelRegistry::test(cx); assistant_panel::init(cx); let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let context = cx.new_model(|cx| Context::local(registry, None, cx)); + let context = cx.new_model(|cx| Context::local(registry, None, None, cx)); let buffer = context.read(cx).buffer.clone(); let message_1 = context.read(cx).message_anchors[0].clone(); @@ -2739,11 +2644,11 @@ mod tests { #[gpui::test] fn test_messages_for_offsets(cx: &mut AppContext) { let settings_store = SettingsStore::test(cx); - language_model::LanguageModelRegistry::test(cx); + LanguageModelRegistry::test(cx); cx.set_global(settings_store); assistant_panel::init(cx); let registry = Arc::new(LanguageRegistry::test(cx.background_executor().clone())); - let context = cx.new_model(|cx| Context::local(registry, None, cx)); + let context = cx.new_model(|cx| Context::local(registry, None, None, cx)); let buffer = context.read(cx).buffer.clone(); let message_1 = context.read(cx).message_anchors[0].clone(); @@ -2824,7 +2729,7 @@ mod tests { async fn test_slash_commands(cx: &mut TestAppContext) { let settings_store = cx.update(SettingsStore::test); cx.set_global(settings_store); - cx.update(language_model::LanguageModelRegistry::test); + cx.update(LanguageModelRegistry::test); cx.update(Project::init_settings); cx.update(assistant_panel::init); let fs = FakeFs::new(cx.background_executor.clone()); @@ -2848,7 +2753,7 @@ mod tests { slash_command_registry.register_command(active_command::ActiveSlashCommand, false); let registry = Arc::new(LanguageRegistry::test(cx.executor())); - let context = cx.new_model(|cx| Context::local(registry.clone(), None, cx)); + let context = cx.new_model(|cx| Context::local(registry.clone(), None, None, cx)); let output_ranges = Rc::new(RefCell::new(HashSet::default())); context.update(cx, |_, cx| { @@ -2949,24 +2854,44 @@ mod tests { cx.update(prompt_library::init); let settings_store = cx.update(SettingsStore::test); cx.set_global(settings_store); + cx.update(Project::init_settings); + let fs = FakeFs::new(cx.executor()); + fs.as_fake() + .insert_tree( + "/root", + json!({ + "hello.rs": r#" + fn hello() { + println!("Hello, World!"); + } + "#.unindent() + }), + ) + .await; + let project = Project::test(fs, [Path::new("/root")], cx).await; + cx.update(LanguageModelRegistry::test); - let fake_provider = cx.update(language_model::LanguageModelRegistry::test); - - let fake_model = fake_provider.test_model(); + let model = cx.read(|cx| { + LanguageModelRegistry::read_global(cx) + .active_model() + .unwrap() + }); cx.update(assistant_panel::init); let registry = Arc::new(LanguageRegistry::test(cx.executor())); // Create a new context - let context = cx.new_model(|cx| Context::local(registry.clone(), None, cx)); + let context = cx.new_model(|cx| Context::local(registry.clone(), Some(project), None, cx)); let buffer = context.read_with(cx, |context, _| context.buffer.clone()); // Simulate user input let user_message = indoc! {r#" - Please refactor this code: + Please add unnecessary complexity to this code: + ```hello.rs fn main() { println!("Hello, World!"); } + ``` "#}; buffer.update(cx, |buffer, cx| { buffer.edit([(0..0, user_message)], None, cx); @@ -2974,7 +2899,7 @@ mod tests { // Simulate LLM response with edit steps let llm_response = indoc! {r#" - Sure, I can help you refactor that code. Here's a step-by-step process: + Sure, I can help you with that. Here's a step-by-step process: First, let's extract the greeting into a separate function: @@ -3018,8 +2943,10 @@ mod tests { }); // Simulate the LLM completion - fake_model.send_last_completion_chunk(llm_response.to_string()); - fake_model.finish_last_completion(); + model + .as_fake() + .stream_last_completion_response(llm_response.to_string()); + model.as_fake().end_last_completion_stream(); // Wait for the completion to be processed cx.run_until_parked(); @@ -3029,19 +2956,83 @@ mod tests { assert_eq!( edit_steps(context, cx), vec![ - Point::new(response_start_row + 2, 0)..Point::new(response_start_row + 14, 7), - Point::new(response_start_row + 16, 0)..Point::new(response_start_row + 28, 7), + ( + Point::new(response_start_row + 2, 0) + ..Point::new(response_start_row + 14, 7), + WorkflowStepEditSuggestionStatus::Pending + ), + ( + Point::new(response_start_row + 16, 0) + ..Point::new(response_start_row + 28, 7), + WorkflowStepEditSuggestionStatus::Pending + ), ] ); }); - fn edit_steps(context: &Context, cx: &AppContext) -> Vec> { + model + .as_fake() + .respond_to_last_tool_use(Ok(serde_json::to_value( + tool::WorkflowStepEditSuggestions { + step_title: "Title".into(), + edit_suggestions: vec![tool::EditSuggestion { + path: "/root/hello.rs".into(), + // Simulate a symbol name that's slightly different than our outline query + kind: tool::EditSuggestionKind::Update { + symbol: "fn main()".into(), + description: "Extract a greeting function".into(), + }, + }], + }, + ) + .unwrap())); + + // Wait for tool use to be processed. + cx.run_until_parked(); + + // Verify that the last edit step is not pending anymore. + context.read_with(cx, |context, cx| { + assert_eq!( + edit_steps(context, cx), + vec![ + ( + Point::new(response_start_row + 2, 0) + ..Point::new(response_start_row + 14, 7), + WorkflowStepEditSuggestionStatus::Pending + ), + ( + Point::new(response_start_row + 16, 0) + ..Point::new(response_start_row + 28, 7), + WorkflowStepEditSuggestionStatus::Resolved + ), + ] + ); + }); + + #[derive(Copy, Clone, Debug, Eq, PartialEq)] + enum WorkflowStepEditSuggestionStatus { + Pending, + Resolved, + } + + fn edit_steps( + context: &Context, + cx: &AppContext, + ) -> Vec<(Range, WorkflowStepEditSuggestionStatus)> { context .edit_steps .iter() .map(|step| { let buffer = context.buffer.read(cx); - step.source_range.to_point(buffer) + let status = match &step.edit_suggestions { + WorkflowStepEditSuggestions::Pending(_) => { + WorkflowStepEditSuggestionStatus::Pending + } + WorkflowStepEditSuggestions::Resolved { .. } => { + WorkflowStepEditSuggestionStatus::Resolved + } + }; + (step.tagged_range.to_point(buffer), status) }) .collect() } @@ -3051,10 +3042,10 @@ mod tests { async fn test_serialization(cx: &mut TestAppContext) { let settings_store = cx.update(SettingsStore::test); cx.set_global(settings_store); - cx.update(language_model::LanguageModelRegistry::test); + cx.update(LanguageModelRegistry::test); cx.update(assistant_panel::init); let registry = Arc::new(LanguageRegistry::test(cx.executor())); - let context = cx.new_model(|cx| Context::local(registry.clone(), None, cx)); + let context = cx.new_model(|cx| Context::local(registry.clone(), None, None, cx)); let buffer = context.read_with(cx, |context, _| context.buffer.clone()); let message_0 = context.read_with(cx, |context, _| context.message_anchors[0].id); let message_1 = context.update(cx, |context, cx| { @@ -3094,6 +3085,7 @@ mod tests { Default::default(), registry.clone(), None, + None, cx, ) }); @@ -3127,7 +3119,7 @@ mod tests { let settings_store = cx.update(SettingsStore::test); cx.set_global(settings_store); - cx.update(language_model::LanguageModelRegistry::test); + cx.update(LanguageModelRegistry::test); cx.update(assistant_panel::init); let slash_commands = cx.update(SlashCommandRegistry::default_global); @@ -3149,6 +3141,7 @@ mod tests { language::Capability::ReadWrite, registry.clone(), None, + None, cx, ) }); @@ -3434,3 +3427,314 @@ mod tests { } } } + +mod tool { + use gpui::AsyncAppContext; + + use super::*; + + #[derive(Debug, Serialize, Deserialize, JsonSchema)] + pub struct WorkflowStepEditSuggestions { + /// An extremely short title for the edit step represented by these operations. + pub step_title: String, + /// A sequence of operations to apply to the codebase. + /// When multiple operations are required for a step, be sure to include multiple operations in this list. + pub edit_suggestions: Vec, + } + + impl LanguageModelTool for WorkflowStepEditSuggestions { + fn name() -> String { + "edit".into() + } + + fn description() -> String { + "suggest edits to one or more locations in the codebase".into() + } + } + + /// A description of an operation to apply to one location in the codebase. + /// + /// This object represents a single edit operation that can be performed on a specific file + /// in the codebase. It encapsulates both the location (file path) and the nature of the + /// edit to be made. + /// + /// # Fields + /// + /// * `path`: A string representing the file path where the edit operation should be applied. + /// This path is relative to the root of the project or repository. + /// + /// * `kind`: An enum representing the specific type of edit operation to be performed. + /// + /// # Usage + /// + /// `EditOperation` is used within a code editor to represent and apply + /// programmatic changes to source code. It provides a structured way to describe + /// edits for features like refactoring tools or AI-assisted coding suggestions. + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + pub struct EditSuggestion { + /// The path to the file containing the relevant operation + pub path: String, + #[serde(flatten)] + pub kind: EditSuggestionKind, + } + + impl EditSuggestion { + pub(super) async fn resolve( + &self, + project: Model, + mut cx: AsyncAppContext, + ) -> Result<(Model, super::EditSuggestion)> { + let path = self.path.clone(); + let kind = self.kind.clone(); + let buffer = project + .update(&mut cx, |project, cx| { + let project_path = project + .find_project_path(Path::new(&path), cx) + .with_context(|| format!("worktree not found for {:?}", path))?; + anyhow::Ok(project.open_buffer(project_path, cx)) + })?? + .await?; + + let mut parse_status = buffer.read_with(&cx, |buffer, _cx| buffer.parse_status())?; + while *parse_status.borrow() != ParseStatus::Idle { + parse_status.changed().await?; + } + + let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?; + let outline = snapshot.outline(None).context("no outline for buffer")?; + + let suggestion; + match kind { + EditSuggestionKind::Update { + symbol, + description, + } => { + let symbol = outline + .find_most_similar(&symbol) + .with_context(|| format!("symbol not found: {:?}", symbol))? + .to_point(&snapshot); + let start = symbol + .annotation_range + .map_or(symbol.range.start, |range| range.start); + let start = Point::new(start.row, 0); + let end = Point::new( + symbol.range.end.row, + snapshot.line_len(symbol.range.end.row), + ); + let range = snapshot.anchor_before(start)..snapshot.anchor_after(end); + suggestion = super::EditSuggestion::Update { range, description }; + } + EditSuggestionKind::Create { description } => { + suggestion = super::EditSuggestion::CreateFile { description }; + } + EditSuggestionKind::InsertSiblingBefore { + symbol, + description, + } => { + let symbol = outline + .find_most_similar(&symbol) + .with_context(|| format!("symbol not found: {:?}", symbol))? + .to_point(&snapshot); + let position = snapshot.anchor_before( + symbol + .annotation_range + .map_or(symbol.range.start, |annotation_range| { + annotation_range.start + }), + ); + suggestion = super::EditSuggestion::InsertSiblingBefore { + position, + description, + }; + } + EditSuggestionKind::InsertSiblingAfter { + symbol, + description, + } => { + let symbol = outline + .find_most_similar(&symbol) + .with_context(|| format!("symbol not found: {:?}", symbol))? + .to_point(&snapshot); + let position = snapshot.anchor_after(symbol.range.end); + suggestion = super::EditSuggestion::InsertSiblingAfter { + position, + description, + }; + } + EditSuggestionKind::PrependChild { + symbol, + description, + } => { + if let Some(symbol) = symbol { + let symbol = outline + .find_most_similar(&symbol) + .with_context(|| format!("symbol not found: {:?}", symbol))? + .to_point(&snapshot); + + let position = snapshot.anchor_after( + symbol + .body_range + .map_or(symbol.range.start, |body_range| body_range.start), + ); + suggestion = super::EditSuggestion::PrependChild { + position, + description, + }; + } else { + suggestion = super::EditSuggestion::PrependChild { + position: language::Anchor::MIN, + description, + }; + } + } + EditSuggestionKind::AppendChild { + symbol, + description, + } => { + if let Some(symbol) = symbol { + let symbol = outline + .find_most_similar(&symbol) + .with_context(|| format!("symbol not found: {:?}", symbol))? + .to_point(&snapshot); + + let position = snapshot.anchor_before( + symbol + .body_range + .map_or(symbol.range.end, |body_range| body_range.end), + ); + suggestion = super::EditSuggestion::AppendChild { + position, + description, + }; + } else { + suggestion = super::EditSuggestion::PrependChild { + position: language::Anchor::MAX, + description, + }; + } + } + EditSuggestionKind::Delete { symbol } => { + let symbol = outline + .find_most_similar(&symbol) + .with_context(|| format!("symbol not found: {:?}", symbol))? + .to_point(&snapshot); + let start = symbol + .annotation_range + .map_or(symbol.range.start, |range| range.start); + let start = Point::new(start.row, 0); + let end = Point::new( + symbol.range.end.row, + snapshot.line_len(symbol.range.end.row), + ); + let range = snapshot.anchor_before(start)..snapshot.anchor_after(end); + suggestion = super::EditSuggestion::Delete { range }; + } + } + + Ok((buffer, suggestion)) + } + } + + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + #[serde(tag = "kind")] + pub enum EditSuggestionKind { + /// Rewrites the specified symbol entirely based on the given description. + /// This operation completely replaces the existing symbol with new content. + Update { + /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`. + /// The path should uniquely identify the symbol within the containing file. + symbol: String, + /// A brief description of the transformation to apply to the symbol. + description: String, + }, + /// Creates a new file with the given path based on the provided description. + /// This operation adds a new file to the codebase. + Create { + /// A brief description of the file to be created. + description: String, + }, + /// Inserts a new symbol based on the given description before the specified symbol. + /// This operation adds new content immediately preceding an existing symbol. + InsertSiblingBefore { + /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`. + /// The new content will be inserted immediately before this symbol. + symbol: String, + /// A brief description of the new symbol to be inserted. + description: String, + }, + /// Inserts a new symbol based on the given description after the specified symbol. + /// This operation adds new content immediately following an existing symbol. + InsertSiblingAfter { + /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`. + /// The new content will be inserted immediately after this symbol. + symbol: String, + /// A brief description of the new symbol to be inserted. + description: String, + }, + /// Inserts a new symbol as a child of the specified symbol at the start. + /// This operation adds new content as the first child of an existing symbol (or file if no symbol is provided). + PrependChild { + /// An optional fully-qualified reference to the symbol after the code you want to insert, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`. + /// If provided, the new content will be inserted as the first child of this symbol. + /// If not provided, the new content will be inserted at the top of the file. + symbol: Option, + /// A brief description of the new symbol to be inserted. + description: String, + }, + /// Inserts a new symbol as a child of the specified symbol at the end. + /// This operation adds new content as the last child of an existing symbol (or file if no symbol is provided). + AppendChild { + /// An optional fully-qualified reference to the symbol before the code you want to insert, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`. + /// If provided, the new content will be inserted as the last child of this symbol. + /// If not provided, the new content will be applied at the bottom of the file. + symbol: Option, + /// A brief description of the new symbol to be inserted. + description: String, + }, + /// Deletes the specified symbol from the containing file. + Delete { + /// An fully-qualified reference to the symbol to be deleted, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`. + symbol: String, + }, + } + + impl EditSuggestionKind { + pub fn symbol(&self) -> Option<&str> { + match self { + Self::Update { symbol, .. } => Some(symbol), + Self::InsertSiblingBefore { symbol, .. } => Some(symbol), + Self::InsertSiblingAfter { symbol, .. } => Some(symbol), + Self::PrependChild { symbol, .. } => symbol.as_deref(), + Self::AppendChild { symbol, .. } => symbol.as_deref(), + Self::Delete { symbol } => Some(symbol), + Self::Create { .. } => None, + } + } + + pub fn description(&self) -> Option<&str> { + match self { + Self::Update { description, .. } => Some(description), + Self::Create { description } => Some(description), + Self::InsertSiblingBefore { description, .. } => Some(description), + Self::InsertSiblingAfter { description, .. } => Some(description), + Self::PrependChild { description, .. } => Some(description), + Self::AppendChild { description, .. } => Some(description), + Self::Delete { .. } => None, + } + } + + pub fn initial_insertion(&self) -> Option { + match self { + EditSuggestionKind::InsertSiblingBefore { .. } => { + Some(InitialInsertion::NewlineAfter) + } + EditSuggestionKind::InsertSiblingAfter { .. } => { + Some(InitialInsertion::NewlineBefore) + } + EditSuggestionKind::PrependChild { .. } => Some(InitialInsertion::NewlineAfter), + EditSuggestionKind::AppendChild { .. } => Some(InitialInsertion::NewlineBefore), + _ => None, + } + } + } +} diff --git a/crates/assistant/src/context_store.rs b/crates/assistant/src/context_store.rs index a17646a408..dab709bd20 100644 --- a/crates/assistant/src/context_store.rs +++ b/crates/assistant/src/context_store.rs @@ -330,7 +330,12 @@ impl ContextStore { pub fn create(&mut self, cx: &mut ModelContext) -> Model { let context = cx.new_model(|cx| { - Context::local(self.languages.clone(), Some(self.telemetry.clone()), cx) + Context::local( + self.languages.clone(), + Some(self.project.clone()), + Some(self.telemetry.clone()), + cx, + ) }); self.register_context(&context, cx); context @@ -351,6 +356,7 @@ impl ContextStore { let replica_id = project.replica_id(); let capability = project.capability(); let language_registry = self.languages.clone(); + let project = self.project.clone(); let telemetry = self.telemetry.clone(); let request = self.client.request(proto::CreateContext { project_id }); cx.spawn(|this, mut cx| async move { @@ -363,6 +369,7 @@ impl ContextStore { replica_id, capability, language_registry, + Some(project), Some(telemetry), cx, ) @@ -401,6 +408,7 @@ impl ContextStore { let fs = self.fs.clone(); let languages = self.languages.clone(); + let project = self.project.clone(); let telemetry = self.telemetry.clone(); let load = cx.background_executor().spawn({ let path = path.clone(); @@ -413,7 +421,14 @@ impl ContextStore { cx.spawn(|this, mut cx| async move { let saved_context = load.await?; let context = cx.new_model(|cx| { - Context::deserialize(saved_context, path.clone(), languages, Some(telemetry), cx) + Context::deserialize( + saved_context, + path.clone(), + languages, + Some(project), + Some(telemetry), + cx, + ) })?; this.update(&mut cx, |this, cx| { if let Some(existing_context) = this.loaded_context_for_path(&path, cx) { @@ -472,6 +487,7 @@ impl ContextStore { let replica_id = project.replica_id(); let capability = project.capability(); let language_registry = self.languages.clone(); + let project = self.project.clone(); let telemetry = self.telemetry.clone(); let request = self.client.request(proto::OpenContext { project_id, @@ -486,6 +502,7 @@ impl ContextStore { replica_id, capability, language_registry, + Some(project), Some(telemetry), cx, ) diff --git a/crates/assistant/src/inline_assistant.rs b/crates/assistant/src/inline_assistant.rs index caf7a12ef0..0d22ebf3d1 100644 --- a/crates/assistant/src/inline_assistant.rs +++ b/crates/assistant/src/inline_assistant.rs @@ -237,7 +237,7 @@ impl InlineAssistant { editor: &View, mut range: Range, initial_prompt: String, - initial_insertion: Option, + initial_transaction_id: Option, workspace: Option>, assistant_panel: Option<&View>, cx: &mut WindowContext, @@ -251,28 +251,15 @@ impl InlineAssistant { let buffer = editor.read(cx).buffer().clone(); { let snapshot = buffer.read(cx).read(cx); - - let mut point_range = range.to_point(&snapshot); - if point_range.is_empty() { - point_range.start.column = 0; - point_range.end.column = 0; - } else { - point_range.start.column = 0; - if point_range.end.row > point_range.start.row && point_range.end.column == 0 { - point_range.end.row -= 1; - } - point_range.end.column = snapshot.line_len(MultiBufferRow(point_range.end.row)); - } - - range.start = snapshot.anchor_before(point_range.start); - range.end = snapshot.anchor_after(point_range.end); + range.start = range.start.bias_left(&snapshot); + range.end = range.end.bias_right(&snapshot); } let codegen = cx.new_model(|cx| { Codegen::new( editor.read(cx).buffer().clone(), range.clone(), - initial_insertion, + initial_transaction_id, self.telemetry.clone(), cx, ) @@ -873,13 +860,20 @@ impl InlineAssistant { for assist_id in assist_ids { if let Some(assist) = self.assists.get(assist_id) { let codegen = assist.codegen.read(cx); + let buffer = codegen.buffer.read(cx).read(cx); foreground_ranges.extend(codegen.last_equal_ranges().iter().cloned()); - gutter_pending_ranges - .push(codegen.edit_position.unwrap_or(assist.range.start)..assist.range.end); + let pending_range = + codegen.edit_position.unwrap_or(assist.range.start)..assist.range.end; + if pending_range.end.to_offset(&buffer) > pending_range.start.to_offset(&buffer) { + gutter_pending_ranges.push(pending_range); + } if let Some(edit_position) = codegen.edit_position { - gutter_transformed_ranges.push(assist.range.start..edit_position); + let edited_range = assist.range.start..edit_position; + if edited_range.end.to_offset(&buffer) > edited_range.start.to_offset(&buffer) { + gutter_transformed_ranges.push(edited_range); + } } if assist.decorations.is_some() { @@ -1997,13 +1991,13 @@ pub struct Codegen { snapshot: MultiBufferSnapshot, edit_position: Option, last_equal_ranges: Vec>, - transaction_id: Option, + initial_transaction_id: Option, + transformation_transaction_id: Option, status: CodegenStatus, generation: Task<()>, diff: Diff, telemetry: Option>, _subscription: gpui::Subscription, - initial_insertion: Option, } enum CodegenStatus { @@ -2027,7 +2021,7 @@ impl Codegen { pub fn new( buffer: Model, range: Range, - initial_insertion: Option, + initial_transaction_id: Option, telemetry: Option>, cx: &mut ModelContext, ) -> Self { @@ -2059,13 +2053,13 @@ impl Codegen { edit_position: None, snapshot, last_equal_ranges: Default::default(), - transaction_id: None, + transformation_transaction_id: None, status: CodegenStatus::Idle, generation: Task::ready(()), diff: Diff::default(), telemetry, _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), - initial_insertion, + initial_transaction_id, } } @@ -2076,8 +2070,8 @@ impl Codegen { cx: &mut ModelContext, ) { if let multi_buffer::Event::TransactionUndone { transaction_id } = event { - if self.transaction_id == Some(*transaction_id) { - self.transaction_id = None; + if self.transformation_transaction_id == Some(*transaction_id) { + self.transformation_transaction_id = None; self.generation = Task::ready(()); cx.emit(CodegenEvent::Undone); } @@ -2105,7 +2099,7 @@ impl Codegen { pub fn start( &mut self, - mut edit_range: Range, + edit_range: Range, user_prompt: String, assistant_panel_context: Option, cx: &mut ModelContext, @@ -2114,34 +2108,13 @@ impl Codegen { .active_model() .context("no active model")?; - self.undo(cx); - - // Handle initial insertion - self.transaction_id = if let Some(initial_insertion) = self.initial_insertion { + if let Some(transformation_transaction_id) = self.transformation_transaction_id.take() { self.buffer.update(cx, |buffer, cx| { - buffer.start_transaction(cx); - let offset = edit_range.start.to_offset(&self.snapshot); - let edit_position; - match initial_insertion { - InitialInsertion::NewlineBefore => { - buffer.edit([(offset..offset, "\n\n")], None, cx); - self.snapshot = buffer.snapshot(cx); - edit_position = self.snapshot.anchor_after(offset + 1); - } - InitialInsertion::NewlineAfter => { - buffer.edit([(offset..offset, "\n")], None, cx); - self.snapshot = buffer.snapshot(cx); - edit_position = self.snapshot.anchor_after(offset); - } - } - self.edit_position = Some(edit_position); - edit_range = edit_position.bias_left(&self.snapshot)..edit_position; - buffer.end_transaction(cx) - }) - } else { - self.edit_position = Some(edit_range.start.bias_right(&self.snapshot)); - None - }; + buffer.undo_transaction(transformation_transaction_id, cx) + }); + } + + self.edit_position = Some(edit_range.start.bias_right(&self.snapshot)); let telemetry_id = model.telemetry_id(); let chunks: LocalBoxFuture>>> = if user_prompt @@ -2406,7 +2379,8 @@ impl Codegen { }); if let Some(transaction) = transaction { - if let Some(first_transaction) = this.transaction_id { + if let Some(first_transaction) = this.transformation_transaction_id + { // Group all assistant edits into the first transaction. this.buffer.update(cx, |buffer, cx| { buffer.merge_transactions( @@ -2416,7 +2390,7 @@ impl Codegen { ) }); } else { - this.transaction_id = Some(transaction); + this.transformation_transaction_id = Some(transaction); this.buffer.update(cx, |buffer, cx| { buffer.finalize_last_transaction(cx) }); @@ -2459,10 +2433,15 @@ impl Codegen { } pub fn undo(&mut self, cx: &mut ModelContext) { - if let Some(transaction_id) = self.transaction_id.take() { - self.buffer - .update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); - } + self.buffer.update(cx, |buffer, cx| { + if let Some(transaction_id) = self.transformation_transaction_id.take() { + buffer.undo_transaction(transaction_id, cx); + } + + if let Some(transaction_id) = self.initial_transaction_id.take() { + buffer.undo_transaction(transaction_id, cx); + } + }); } fn update_diff(&mut self, edit_range: Range, cx: &mut ModelContext) { diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index aedd15bfe2..378a5ca537 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -53,6 +53,7 @@ settings.workspace = true similar.workspace = true smallvec.workspace = true smol.workspace = true +strsim.workspace = true sum_tree.workspace = true task.workspace = true text.workspace = true diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index d3f5e6a71d..2eda498127 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1876,6 +1876,63 @@ impl Buffer { cx.notify(); } + // Inserts newlines at the given position to create an empty line, returning the start of the new line. + // You can also request the insertion of empty lines above and below the line starting at the returned point. + pub fn insert_empty_line( + &mut self, + position: impl ToPoint, + space_above: bool, + space_below: bool, + cx: &mut ModelContext, + ) -> Point { + let mut position = position.to_point(self); + + self.start_transaction(); + + self.edit( + [(position..position, "\n")], + Some(AutoindentMode::EachLine), + cx, + ); + + if position.column > 0 { + position += Point::new(1, 0); + } + + if !self.is_line_blank(position.row) { + self.edit( + [(position..position, "\n")], + Some(AutoindentMode::EachLine), + cx, + ); + } + + if space_above { + if position.row > 0 && !self.is_line_blank(position.row - 1) { + self.edit( + [(position..position, "\n")], + Some(AutoindentMode::EachLine), + cx, + ); + position.row += 1; + } + } + + if space_below { + if position.row == self.max_point().row || !self.is_line_blank(position.row + 1) { + self.edit( + [(position..position, "\n")], + Some(AutoindentMode::EachLine), + cx, + ); + } + } + + self.end_transaction(cx); + + position + } + /// Applies the given remote operations to the buffer. pub fn apply_ops>( &mut self, diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 3e644c6e56..2e342e52f3 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -1822,6 +1822,92 @@ fn test_autoindent_query_with_outdent_captures(cx: &mut AppContext) { }); } +#[gpui::test] +fn test_insert_empty_line(cx: &mut AppContext) { + init_settings(cx, |_| {}); + + // Insert empty line at the beginning, requesting an empty line above + cx.new_model(|cx| { + let mut buffer = Buffer::local("abc\ndef\nghi", cx); + let point = buffer.insert_empty_line(Point::new(0, 0), true, false, cx); + assert_eq!(buffer.text(), "\nabc\ndef\nghi"); + assert_eq!(point, Point::new(0, 0)); + buffer + }); + + // Insert empty line at the beginning, requesting an empty line above and below + cx.new_model(|cx| { + let mut buffer = Buffer::local("abc\ndef\nghi", cx); + let point = buffer.insert_empty_line(Point::new(0, 0), true, true, cx); + assert_eq!(buffer.text(), "\n\nabc\ndef\nghi"); + assert_eq!(point, Point::new(0, 0)); + buffer + }); + + // Insert empty line at the start of a line, requesting empty lines above and below + cx.new_model(|cx| { + let mut buffer = Buffer::local("abc\ndef\nghi", cx); + let point = buffer.insert_empty_line(Point::new(2, 0), true, true, cx); + assert_eq!(buffer.text(), "abc\ndef\n\n\n\nghi"); + assert_eq!(point, Point::new(3, 0)); + buffer + }); + + // Insert empty line in the middle of a line, requesting empty lines above and below + cx.new_model(|cx| { + let mut buffer = Buffer::local("abc\ndefghi\njkl", cx); + let point = buffer.insert_empty_line(Point::new(1, 3), true, true, cx); + assert_eq!(buffer.text(), "abc\ndef\n\n\n\nghi\njkl"); + assert_eq!(point, Point::new(3, 0)); + buffer + }); + + // Insert empty line in the middle of a line, requesting empty line above only + cx.new_model(|cx| { + let mut buffer = Buffer::local("abc\ndefghi\njkl", cx); + let point = buffer.insert_empty_line(Point::new(1, 3), true, false, cx); + assert_eq!(buffer.text(), "abc\ndef\n\n\nghi\njkl"); + assert_eq!(point, Point::new(3, 0)); + buffer + }); + + // Insert empty line in the middle of a line, requesting empty line below only + cx.new_model(|cx| { + let mut buffer = Buffer::local("abc\ndefghi\njkl", cx); + let point = buffer.insert_empty_line(Point::new(1, 3), false, true, cx); + assert_eq!(buffer.text(), "abc\ndef\n\n\nghi\njkl"); + assert_eq!(point, Point::new(2, 0)); + buffer + }); + + // Insert empty line at the end, requesting empty lines above and below + cx.new_model(|cx| { + let mut buffer = Buffer::local("abc\ndef\nghi", cx); + let point = buffer.insert_empty_line(Point::new(2, 3), true, true, cx); + assert_eq!(buffer.text(), "abc\ndef\nghi\n\n\n"); + assert_eq!(point, Point::new(4, 0)); + buffer + }); + + // Insert empty line at the end, requesting empty line above only + cx.new_model(|cx| { + let mut buffer = Buffer::local("abc\ndef\nghi", cx); + let point = buffer.insert_empty_line(Point::new(2, 3), true, false, cx); + assert_eq!(buffer.text(), "abc\ndef\nghi\n\n"); + assert_eq!(point, Point::new(4, 0)); + buffer + }); + + // Insert empty line at the end, requesting empty line below only + cx.new_model(|cx| { + let mut buffer = Buffer::local("abc\ndef\nghi", cx); + let point = buffer.insert_empty_line(Point::new(2, 3), false, true, cx); + assert_eq!(buffer.text(), "abc\ndef\nghi\n\n"); + assert_eq!(point, Point::new(3, 0)); + buffer + }); +} + #[gpui::test] fn test_language_scope_at_with_javascript(cx: &mut AppContext) { init_settings(cx, |_| {}); diff --git a/crates/language/src/outline.rs b/crates/language/src/outline.rs index 89f58672b8..b7e4a7e14d 100644 --- a/crates/language/src/outline.rs +++ b/crates/language/src/outline.rs @@ -1,3 +1,4 @@ +use crate::{BufferSnapshot, Point, ToPoint}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{relative, AppContext, BackgroundExecutor, HighlightStyle, StyledText, TextStyle}; use settings::Settings; @@ -24,6 +25,27 @@ pub struct OutlineItem { pub annotation_range: Option>, } +impl OutlineItem { + /// Converts to an equivalent outline item, but with parameterized over Points. + pub fn to_point(&self, buffer: &BufferSnapshot) -> OutlineItem { + OutlineItem { + depth: self.depth, + range: self.range.start.to_point(buffer)..self.range.end.to_point(buffer), + text: self.text.clone(), + highlight_ranges: self.highlight_ranges.clone(), + name_ranges: self.name_ranges.clone(), + body_range: self + .body_range + .as_ref() + .map(|r| r.start.to_point(buffer)..r.end.to_point(buffer)), + annotation_range: self + .annotation_range + .as_ref() + .map(|r| r.start.to_point(buffer)..r.end.to_point(buffer)), + } + } +} + impl Outline { pub fn new(items: Vec>) -> Self { let mut candidates = Vec::new(); @@ -62,6 +84,16 @@ impl Outline { } } + /// Find the most similar symbol to the provided query according to the Jaro-Winkler distance measure. + pub fn find_most_similar(&self, query: &str) -> Option<&OutlineItem> { + let candidate = self.path_candidates.iter().max_by(|a, b| { + strsim::jaro_winkler(&a.string, query) + .total_cmp(&strsim::jaro_winkler(&b.string, query)) + })?; + Some(&self.items[candidate.id]) + } + + /// Find all outline symbols according to a longest subsequence match with the query, ordered descending by match score. pub async fn search(&self, query: &str, executor: BackgroundExecutor) -> Vec { let query = query.trim_start(); let is_path_query = query.contains(' '); diff --git a/crates/language_model/Cargo.toml b/crates/language_model/Cargo.toml index eb90143847..90f9943f17 100644 --- a/crates/language_model/Cargo.toml +++ b/crates/language_model/Cargo.toml @@ -37,6 +37,7 @@ log.workspace = true menu.workspace = true ollama = { workspace = true, features = ["schemars"] } open_ai = { workspace = true, features = ["schemars"] } +parking_lot.workspace = true proto = { workspace = true, features = ["test-support"] } project.workspace = true schemars.workspace = true diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 0dbe1aeecd..90ced4d9bc 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -75,6 +75,11 @@ pub trait LanguageModel: Send + Sync { schema: serde_json::Value, cx: &AsyncAppContext, ) -> BoxFuture<'static, Result>; + + #[cfg(any(test, feature = "test-support"))] + fn as_fake(&self) -> &provider::fake::FakeLanguageModel { + unimplemented!() + } } impl dyn LanguageModel { diff --git a/crates/language_model/src/provider/fake.rs b/crates/language_model/src/provider/fake.rs index dd3ead3511..9939cf2791 100644 --- a/crates/language_model/src/provider/fake.rs +++ b/crates/language_model/src/provider/fake.rs @@ -3,15 +3,17 @@ use crate::{ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, }; -use anyhow::anyhow; -use collections::HashMap; -use futures::{channel::mpsc, future::BoxFuture, stream::BoxStream, FutureExt, StreamExt}; +use anyhow::Context as _; +use futures::{ + channel::{mpsc, oneshot}, + future::BoxFuture, + stream::BoxStream, + FutureExt, StreamExt, +}; use gpui::{AnyView, AppContext, AsyncAppContext, Task}; use http_client::Result; -use std::{ - future, - sync::{Arc, Mutex}, -}; +use parking_lot::Mutex; +use std::sync::Arc; use ui::WindowContext; pub fn language_model_id() -> LanguageModelId { @@ -31,9 +33,7 @@ pub fn provider_name() -> LanguageModelProviderName { } #[derive(Clone, Default)] -pub struct FakeLanguageModelProvider { - current_completion_txs: Arc>>>, -} +pub struct FakeLanguageModelProvider; impl LanguageModelProviderState for FakeLanguageModelProvider { type ObservableEntity = (); @@ -53,9 +53,7 @@ impl LanguageModelProvider for FakeLanguageModelProvider { } fn provided_models(&self, _: &AppContext) -> Vec> { - vec![Arc::new(FakeLanguageModel { - current_completion_txs: self.current_completion_txs.clone(), - })] + vec![Arc::new(FakeLanguageModel::default())] } fn is_authenticated(&self, _: &AppContext) -> bool { @@ -77,55 +75,80 @@ impl LanguageModelProvider for FakeLanguageModelProvider { impl FakeLanguageModelProvider { pub fn test_model(&self) -> FakeLanguageModel { - FakeLanguageModel { - current_completion_txs: self.current_completion_txs.clone(), - } + FakeLanguageModel::default() } } +#[derive(Debug, PartialEq)] +pub struct ToolUseRequest { + pub request: LanguageModelRequest, + pub name: String, + pub description: String, + pub schema: serde_json::Value, +} + +#[derive(Default)] pub struct FakeLanguageModel { - current_completion_txs: Arc>>>, + current_completion_txs: Mutex)>>, + current_tool_use_txs: Mutex>)>>, } impl FakeLanguageModel { pub fn pending_completions(&self) -> Vec { self.current_completion_txs .lock() - .unwrap() - .keys() - .map(|k| serde_json::from_str(k).unwrap()) + .iter() + .map(|(request, _)| request.clone()) .collect() } pub fn completion_count(&self) -> usize { - self.current_completion_txs.lock().unwrap().len() + self.current_completion_txs.lock().len() } - pub fn send_completion_chunk(&self, request: &LanguageModelRequest, chunk: String) { - let json = serde_json::to_string(request).unwrap(); + pub fn stream_completion_response(&self, request: &LanguageModelRequest, chunk: String) { + let current_completion_txs = self.current_completion_txs.lock(); + let tx = current_completion_txs + .iter() + .find(|(req, _)| req == request) + .map(|(_, tx)| tx) + .unwrap(); + tx.unbounded_send(chunk).unwrap(); + } + + pub fn end_completion_stream(&self, request: &LanguageModelRequest) { self.current_completion_txs .lock() - .unwrap() - .get(&json) - .unwrap() - .unbounded_send(chunk) - .unwrap(); + .retain(|(req, _)| req != request); } - pub fn send_last_completion_chunk(&self, chunk: String) { - self.send_completion_chunk(self.pending_completions().last().unwrap(), chunk); + pub fn stream_last_completion_response(&self, chunk: String) { + self.stream_completion_response(self.pending_completions().last().unwrap(), chunk); } - pub fn finish_completion(&self, request: &LanguageModelRequest) { - self.current_completion_txs - .lock() - .unwrap() - .remove(&serde_json::to_string(request).unwrap()) - .unwrap(); + pub fn end_last_completion_stream(&self) { + self.end_completion_stream(self.pending_completions().last().unwrap()); } - pub fn finish_last_completion(&self) { - self.finish_completion(self.pending_completions().last().unwrap()); + pub fn respond_to_tool_use( + &self, + tool_call: &ToolUseRequest, + response: Result, + ) { + let mut current_tool_call_txs = self.current_tool_use_txs.lock(); + if let Some(index) = current_tool_call_txs + .iter() + .position(|(call, _)| call == tool_call) + { + let (_, tx) = current_tool_call_txs.remove(index); + tx.send(response).unwrap(); + } + } + + pub fn respond_to_last_tool_use(&self, response: Result) { + let mut current_tool_call_txs = self.current_tool_use_txs.lock(); + let (_, tx) = current_tool_call_txs.pop().unwrap(); + tx.send(response).unwrap(); } } @@ -168,21 +191,30 @@ impl LanguageModel for FakeLanguageModel { _: &AsyncAppContext, ) -> BoxFuture<'static, Result>>> { let (tx, rx) = mpsc::unbounded(); - self.current_completion_txs - .lock() - .unwrap() - .insert(serde_json::to_string(&request).unwrap(), tx); + self.current_completion_txs.lock().push((request, tx)); async move { Ok(rx.map(Ok).boxed()) }.boxed() } fn use_any_tool( &self, - _request: LanguageModelRequest, - _name: String, - _description: String, - _schema: serde_json::Value, + request: LanguageModelRequest, + name: String, + description: String, + schema: serde_json::Value, _cx: &AsyncAppContext, ) -> BoxFuture<'static, Result> { - future::ready(Err(anyhow!("not implemented"))).boxed() + let (tx, rx) = oneshot::channel(); + let tool_call = ToolUseRequest { + request, + name, + description, + schema, + }; + self.current_tool_use_txs.lock().push((tool_call, tx)); + async move { rx.await.context("FakeLanguageModel was dropped")? }.boxed() + } + + fn as_fake(&self) -> &Self { + self } } diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index f59c614e0d..f7539df044 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -103,7 +103,7 @@ impl LanguageModelRegistry { #[cfg(any(test, feature = "test-support"))] pub fn test(cx: &mut AppContext) -> crate::provider::fake::FakeLanguageModelProvider { - let fake_provider = crate::provider::fake::FakeLanguageModelProvider::default(); + let fake_provider = crate::provider::fake::FakeLanguageModelProvider; let registry = cx.new_model(|cx| { let mut registry = Self::default(); registry.register_provider(fake_provider.clone(), cx); @@ -239,7 +239,7 @@ mod tests { let registry = cx.new_model(|_| LanguageModelRegistry::default()); registry.update(cx, |registry, cx| { - registry.register_provider(FakeLanguageModelProvider::default(), cx); + registry.register_provider(FakeLanguageModelProvider, cx); }); let providers = registry.read(cx).providers(); diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index 243dcf906b..6cc8db3a88 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -1,13 +1,13 @@ use crate::role::Role; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Hash)] pub struct LanguageModelRequestMessage { pub role: Role, pub content: String, } -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct LanguageModelRequest { pub messages: Vec, pub stop: Vec, diff --git a/crates/language_model/src/role.rs b/crates/language_model/src/role.rs index 82184038f6..2205ac52dc 100644 --- a/crates/language_model/src/role.rs +++ b/crates/language_model/src/role.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::fmt::{self, Display}; -#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq, Hash)] #[serde(rename_all = "lowercase")] pub enum Role { User, diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 5d9c62fdb3..d2334517fd 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -742,6 +742,33 @@ impl MultiBuffer { tail(self, buffer_edits, autoindent_mode, edited_excerpt_ids, cx); } + // Inserts newlines at the given position to create an empty line, returning the start of the new line. + // You can also request the insertion of empty lines above and below the line starting at the returned point. + // Panics if the given position is invalid. + pub fn insert_empty_line( + &mut self, + position: impl ToPoint, + space_above: bool, + space_below: bool, + cx: &mut ModelContext, + ) -> Point { + let multibuffer_point = position.to_point(&self.read(cx)); + if let Some(buffer) = self.as_singleton() { + buffer.update(cx, |buffer, cx| { + buffer.insert_empty_line(multibuffer_point, space_above, space_below, cx) + }) + } else { + let (buffer, buffer_point, _) = + self.point_to_buffer_point(multibuffer_point, cx).unwrap(); + self.start_transaction(cx); + let empty_line_start = buffer.update(cx, |buffer, cx| { + buffer.insert_empty_line(buffer_point, space_above, space_below, cx) + }); + self.end_transaction(cx); + multibuffer_point + (empty_line_start - buffer_point) + } + } + pub fn start_transaction(&mut self, cx: &mut ModelContext) -> Option { self.start_transaction_at(Instant::now(), cx) } @@ -1448,6 +1475,29 @@ impl MultiBuffer { }) } + // If point is at the end of the buffer, the last excerpt is returned + pub fn point_to_buffer_point( + &self, + point: T, + cx: &AppContext, + ) -> Option<(Model, Point, ExcerptId)> { + let snapshot = self.read(cx); + let point = point.to_point(&snapshot); + let mut cursor = snapshot.excerpts.cursor::(); + cursor.seek(&point, Bias::Right, &()); + if cursor.item().is_none() { + cursor.prev(&()); + } + + cursor.item().map(|excerpt| { + let excerpt_start = excerpt.range.context.start.to_point(&excerpt.buffer); + let buffer_point = excerpt_start + point - *cursor.start(); + let buffer = self.buffers.borrow()[&excerpt.buffer_id].buffer.clone(); + + (buffer, buffer_point, excerpt.id) + }) + } + pub fn range_to_buffer_ranges( &self, range: Range, diff --git a/crates/text/src/anchor.rs b/crates/text/src/anchor.rs index 1a54844821..ee833326f5 100644 --- a/crates/text/src/anchor.rs +++ b/crates/text/src/anchor.rs @@ -136,7 +136,6 @@ where pub trait AnchorRangeExt { fn cmp(&self, b: &Range, buffer: &BufferSnapshot) -> Ordering; - fn intersects(&self, other: &Range, buffer: &BufferSnapshot) -> bool; } impl AnchorRangeExt for Range { @@ -146,8 +145,4 @@ impl AnchorRangeExt for Range { ord => ord, } } - - fn intersects(&self, other: &Range, buffer: &BufferSnapshot) -> bool { - self.start.cmp(&other.end, buffer).is_lt() && other.start.cmp(&self.end, buffer).is_lt() - } }