Restructure workflow step resolution and fix inserting newlines (#15720)

Antonio Scandurra and Nathan created

Release Notes:

- N/A

---------

Co-authored-by: Nathan <nathan@zed.dev>

Change summary

Cargo.lock                                  |   3 
crates/assistant/Cargo.toml                 |   2 
crates/assistant/src/assistant_panel.rs     | 383 ++++------
crates/assistant/src/context.rs             | 795 ++++++++++------------
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, 890 insertions(+), 788 deletions(-)

Detailed changes

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",

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

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<InlineAssistId>,
     editor: Option<WeakView<Editor>>,
-    _open_editor: Task<Result<()>>,
 }
 
 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::<String>()
             ));
-            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)
+                self.update_active_workflow_step(cx);
+            }
+            _ => {}
+        }
+        cx.emit(event.clone());
+    }
+
+    fn update_active_workflow_step(&mut self, cx: &mut ViewContext<Self>) {
+        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())
                 {
-                    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);
-                                            }
-                                        });
+                    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();
-                        }
-                    }
+                                });
+                            }
+                        })
+                        .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,
-                        });
+            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,
+                });
             }
-            _ => {}
         }
-        cx.emit(event.clone());
     }
 
-    fn open_editor_for_edit_suggestions(
+    fn suggest_edits(
         &mut self,
-        edit_step_suggestions: Task<EditStepSuggestions>,
+        title: String,
+        edit_suggestions: HashMap<Model<Buffer>, Vec<EditSuggestionGroup>>,
         cx: &mut ViewContext<Self>,
-    ) -> Task<Result<()>> {
-        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;
-
-            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| {
+    ) -> Option<(View<Editor>, Vec<InlineAssistId>)> {
+        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::<Editor>(active_pane, buffer, false, false, cx)
-                })?;
-
-                cx.update(|cx| {
-                    for suggestion in suggestion_group.suggestions {
-                        let description = suggestion.description.unwrap_or_else(|| "Delete".into());
-
-                        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);
-                        });
-                    }
+                })
+                .log_err()?;
 
-                    // 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,
-                        );
-                    });
-                })?;
+            let (&excerpt_id, _, _) = editor
+                .read(cx)
+                .buffer()
+                .read(cx)
+                .read(cx)
+                .as_singleton()
+                .unwrap();
+
+            // 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()
+                };
 
-                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,
-                        );
+                editor.set_scroll_anchor(
+                    ScrollAnchor {
+                        offset: gpui::Point::default(),
+                        anchor,
+                    },
+                    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,
-                                ));
-                            }
-                        }
-                    }
-                    multibuffer
-                })?;
+            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
+            });
 
-                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| {
+            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)
-                })?;
-
-                editor
-            };
+                })
+                .log_err()?;
+        }
 
-            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());
-                }
-            })
-        })
+        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<Self>) -> 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() {

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<language::Anchor>,
-    pub state: Option<EditStepState>,
-}
-
-#[derive(Debug)]
-pub struct EditSuggestionGroup {
-    pub context_range: Range<language::Anchor>,
-    pub suggestions: Vec<EditSuggestion>,
-}
-
-#[derive(Debug)]
-pub struct EditSuggestion {
-    pub range: Range<language::Anchor>,
-    /// If None, assume this is a suggestion to delete the range rather than transform it.
-    pub description: Option<String>,
-    pub initial_insertion: Option<InitialInsertion>,
+pub struct WorkflowStep {
+    pub tagged_range: Range<language::Anchor>,
+    pub edit_suggestions: WorkflowStepEditSuggestions,
 }
 
-pub struct EditStepSuggestions {
-    pub title: String,
-    pub suggestions: HashMap<Model<Buffer>, Vec<EditSuggestionGroup>>,
-}
-
-impl EditStep {
-    pub fn edit_suggestions(
-        &self,
-        project: &Model<Project>,
-        cx: &AppContext,
-    ) -> Option<Task<EditStepSuggestions>> {
-        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::<Vec<_>>();
-
-            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::<EditSuggestionGroup>::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 {
+pub enum WorkflowStepEditSuggestions {
     Pending(Task<Option<()>>),
-    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<EditOperation>,
-}
-
-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,
+    Resolved {
+        title: String,
+        edit_suggestions: HashMap<Model<Buffer>, Vec<EditSuggestionGroup>>,
+    },
 }
 
-impl EditOperation {
-    fn edit_suggestion(
-        &self,
-        project: Model<Project>,
-        cx: &AppContext,
-    ) -> Task<Result<(Model<language::Buffer>, 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::<Vec<_>>(),
-                            *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)]
+pub struct EditSuggestionGroup {
+    pub context_range: Range<language::Anchor>,
+    pub suggestions: Vec<EditSuggestion>,
 }
 
-#[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<language::Anchor>,
         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<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 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<String>,
-        /// 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<language::Anchor>,
     },
 }
 
-impl EditOperationKind {
-    pub fn symbol(&self) -> Option<&str> {
+impl EditSuggestion {
+    pub fn range(&self) -> Range<language::Anchor> {
         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<InitialInsertion> {
+    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<Editor>,
+        excerpt_id: editor::ExcerptId,
+        workspace: &WeakView<Workspace>,
+        assistant_panel: &View<AssistantPanel>,
+        cx: &mut WindowContext,
+    ) -> Option<InlineAssistId> {
+        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<Subscription>,
     telemetry: Option<Arc<Telemetry>>,
     language_registry: Arc<LanguageRegistry>,
-    edit_steps: Vec<EditStep>,
+    edit_steps: Vec<WorkflowStep>,
+    project: Option<Model<Project>>,
 }
 
 impl EventEmitter<ContextEvent> for Context {}
@@ -796,6 +605,7 @@ impl EventEmitter<ContextEvent> for Context {}
 impl Context {
     pub fn local(
         language_registry: Arc<LanguageRegistry>,
+        project: Option<Model<Project>>,
         telemetry: Option<Arc<Telemetry>>,
         cx: &mut ModelContext<Self>,
     ) -> 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<LanguageRegistry>,
+        project: Option<Model<Project>>,
         telemetry: Option<Arc<Telemetry>>,
         cx: &mut ModelContext<Self>,
     ) -> 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<LanguageRegistry>,
+        project: Option<Model<Project>>,
         telemetry: Option<Arc<Telemetry>>,
         cx: &mut ModelContext<Self>,
     ) -> 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<usize>, cx: &mut ModelContext<Self>) {
+    fn parse_edit_steps_in_range(
+        &mut self,
+        range: Range<usize>,
+        project: Model<Project>,
+        cx: &mut ModelContext<Self>,
+    ) {
         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();
-
-            while let Some(line) = message_lines.next() {
-                if let Some(step_start_index) = line.find("<step>") {
-                    if !in_step {
-                        in_step = true;
-                        step_start = line_start_offset + step_start_index;
-                    }
+        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("<step>") {
+                if !in_step {
+                    in_step = true;
+                    step_start = line_start_offset + step_start_index;
                 }
+            }
 
-                if let Some(step_end_index) = line.find("</step>") {
-                    if in_step {
-                        let start_anchor = buffer.anchor_after(step_start);
-                        let end_anchor = buffer
-                            .anchor_before(line_start_offset + step_end_index + "</step>".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 Some(step_end_index) = line.find("</step>") {
+                if in_step {
+                    let start_anchor = buffer.anchor_after(step_start);
+                    let end_anchor =
+                        buffer.anchor_before(line_start_offset + step_end_index + "</step>".len());
+                    let tagged_range = start_anchor..end_anchor;
 
-                        if let Err(ix) = existing_step_index {
-                            // Step doesn't exist, so add it
-                            new_edit_steps.push((
-                                ix,
-                                EditStep {
-                                    source_range,
-                                    state: None,
-                                },
-                            ));
-                        }
+                    // 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));
 
-                        in_step = false;
+                    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),
+                            },
+                        ));
                     }
-                }
 
-                line_start_offset = message_lines.offset();
+                    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<language::Anchor>,
+        project: Model<Project>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Option<()>> {
         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::<String>();
 
         cx.spawn(|this, mut cx| {
@@ -1415,18 +1237,99 @@ impl Context {
                     content: prompt,
                 });
 
-                let resolution = model.use_tool::<EditStepResolution>(request, &cx).await?;
+                // Invoke the model to get its edit suggestions for this workflow step.
+                let step_suggestions = model
+                    .use_tool::<tool::WorkflowStepEditSuggestions>(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::<Vec<_>>();
+
+                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::<EditSuggestionGroup>::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();

crates/assistant/src/context_store.rs 🔗

@@ -330,7 +330,12 @@ impl ContextStore {
 
     pub fn create(&mut self, cx: &mut ModelContext<Self>) -> Model<Context> {
         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,
                 )

crates/assistant/src/inline_assistant.rs 🔗

@@ -237,7 +237,7 @@ impl InlineAssistant {
         editor: &View<Editor>,
         mut range: Range<Anchor>,
         initial_prompt: String,
-        initial_insertion: Option<InitialInsertion>,
+        initial_transaction_id: Option<TransactionId>,
         workspace: Option<WeakView<Workspace>>,
         assistant_panel: Option<&View<AssistantPanel>>,
         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<Anchor>,
     last_equal_ranges: Vec<Range<Anchor>>,
-    transaction_id: Option<TransactionId>,
+    initial_transaction_id: Option<TransactionId>,
+    transformation_transaction_id: Option<TransactionId>,
     status: CodegenStatus,
     generation: Task<()>,
     diff: Diff,
     telemetry: Option<Arc<Telemetry>>,
     _subscription: gpui::Subscription,
-    initial_insertion: Option<InitialInsertion>,
 }
 
 enum CodegenStatus {
@@ -2027,7 +2021,7 @@ impl Codegen {
     pub fn new(
         buffer: Model<MultiBuffer>,
         range: Range<Anchor>,
-        initial_insertion: Option<InitialInsertion>,
+        initial_transaction_id: Option<TransactionId>,
         telemetry: Option<Arc<Telemetry>>,
         cx: &mut ModelContext<Self>,
     ) -> 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<Self>,
     ) {
         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<Anchor>,
+        edit_range: Range<Anchor>,
         user_prompt: String,
         assistant_panel_context: Option<LanguageModelRequest>,
         cx: &mut ModelContext<Self>,
@@ -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<Result<BoxStream<Result<String>>>> = 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<Self>) {
-        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<Anchor>, cx: &mut ModelContext<Self>) {

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

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<Self>,
+    ) -> 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<I: IntoIterator<Item = Operation>>(
         &mut self,

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, |_| {});

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<T> {
     pub annotation_range: Option<Range<T>>,
 }
 
+impl<T: ToPoint> OutlineItem<T> {
+    /// Converts to an equivalent outline item, but with parameterized over Points.
+    pub fn to_point(&self, buffer: &BufferSnapshot) -> OutlineItem<Point> {
+        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<T> Outline<T> {
     pub fn new(items: Vec<OutlineItem<T>>) -> Self {
         let mut candidates = Vec::new();
@@ -62,6 +84,16 @@ impl<T> Outline<T> {
         }
     }
 
+    /// 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<T>> {
+        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<StringMatch> {
         let query = query.trim_start();
         let is_path_query = query.contains(' ');

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

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<serde_json::Value>>;
+
+    #[cfg(any(test, feature = "test-support"))]
+    fn as_fake(&self) -> &provider::fake::FakeLanguageModel {
+        unimplemented!()
+    }
 }
 
 impl dyn LanguageModel {

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<Mutex<HashMap<String, mpsc::UnboundedSender<String>>>>,
-}
+pub struct FakeLanguageModelProvider;
 
 impl LanguageModelProviderState for FakeLanguageModelProvider {
     type ObservableEntity = ();
@@ -53,9 +53,7 @@ impl LanguageModelProvider for FakeLanguageModelProvider {
     }
 
     fn provided_models(&self, _: &AppContext) -> Vec<Arc<dyn LanguageModel>> {
-        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<Mutex<HashMap<String, mpsc::UnboundedSender<String>>>>,
+    current_completion_txs: Mutex<Vec<(LanguageModelRequest, mpsc::UnboundedSender<String>)>>,
+    current_tool_use_txs: Mutex<Vec<(ToolUseRequest, oneshot::Sender<Result<serde_json::Value>>)>>,
 }
 
 impl FakeLanguageModel {
     pub fn pending_completions(&self) -> Vec<LanguageModelRequest> {
         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 respond_to_tool_use(
+        &self,
+        tool_call: &ToolUseRequest,
+        response: Result<serde_json::Value>,
+    ) {
+        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 finish_last_completion(&self) {
-        self.finish_completion(self.pending_completions().last().unwrap());
+    pub fn respond_to_last_tool_use(&self, response: Result<serde_json::Value>) {
+        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<BoxStream<'static, Result<String>>>> {
         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<serde_json::Value>> {
-        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
     }
 }

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();

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<LanguageModelRequestMessage>,
     pub stop: Vec<String>,

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,

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<Self>,
+    ) -> 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<Self>) -> Option<TransactionId> {
         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<T: ToPoint>(
+        &self,
+        point: T,
+        cx: &AppContext,
+    ) -> Option<(Model<Buffer>, Point, ExcerptId)> {
+        let snapshot = self.read(cx);
+        let point = point.to_point(&snapshot);
+        let mut cursor = snapshot.excerpts.cursor::<Point>();
+        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<T: ToOffset>(
         &self,
         range: Range<T>,

crates/text/src/anchor.rs 🔗

@@ -136,7 +136,6 @@ where
 
 pub trait AnchorRangeExt {
     fn cmp(&self, b: &Range<Anchor>, buffer: &BufferSnapshot) -> Ordering;
-    fn intersects(&self, other: &Range<Anchor>, buffer: &BufferSnapshot) -> bool;
 }
 
 impl AnchorRangeExt for Range<Anchor> {
@@ -146,8 +145,4 @@ impl AnchorRangeExt for Range<Anchor> {
             ord => ord,
         }
     }
-
-    fn intersects(&self, other: &Range<Anchor>, buffer: &BufferSnapshot) -> bool {
-        self.start.cmp(&other.end, buffer).is_lt() && other.start.cmp(&self.end, buffer).is_lt()
-    }
 }