Restructure assistant edits to show all changes in a proposed-change editor (#18240)

Max Brunsfeld , Marshall Bowers , Antonio , Richard , Marshall , Nate Butler , Antonio Scandurra , and Richard Feldman created

This changes the `/workflow` command so that instead of emitting edits
in separate steps, the user is presented with a single tab, with an
editable diff that they can apply to the buffer.

Todo

* Assistant panel
* [x] Show a patch title and a list of changed files in a block
decoration
* [x] Don't store resolved patches as state on Context. Resolve on
demand.
    * [ ] Better presentation of patches in the panel
    * [ ] Show a spinner while patch is streaming in
* Patches
* [x] Preserve leading whitespace in new text, auto-indent insertions
    * [x] Ensure patch title is very short, to fit better in tab
* [x] Improve patch location resolution, prefer skipping whitespace over
skipping `}`
    * [x] Ensure patch edits are auto-indented properly
* [ ] Apply `Update` edits via a diff between the old and new text, to
get fine-grained edits.
* Proposed changes editor
    * [x] Show patch title in the tab
    * [x] Add a toolbar with an "Apply all" button
* [x] Make `open excerpts` open the corresponding location in the base
buffer (https://github.com/zed-industries/zed/pull/18591)
* [x] Add an apply button above every hunk
(https://github.com/zed-industries/zed/pull/18592)
* [x] Expand all diff hunks by default
(https://github.com/zed-industries/zed/pull/18598)
    * [x] Fix https://github.com/zed-industries/zed/issues/18589
* [x] Syntax highlighting doesn't work until the buffer is edited
(https://github.com/zed-industries/zed/pull/18648)
* [x] Disable LSP interaction in Proposed Changes editor
(https://github.com/zed-industries/zed/pull/18945)
* [x] No auto-indent? (https://github.com/zed-industries/zed/pull/18984)
* Prompt
    * [ ] make sure old_text is unique

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <elliott.codes@gmail.com>
Co-authored-by: Antonio <antonio@zed.dev>
Co-authored-by: Richard <richard@zed.dev>
Co-authored-by: Marshall <marshall@zed.dev>
Co-authored-by: Nate Butler <iamnbutler@gmail.com>
Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>

Change summary

Cargo.lock                                             |   1 
assets/icons/diff.svg                                  |   1 
assets/prompts/edit_workflow.hbs                       | 324 +--
assets/prompts/step_resolution.hbs                     | 496 ------
crates/assistant/Cargo.toml                            |   1 
crates/assistant/src/assistant.rs                      |  26 
crates/assistant/src/assistant_panel.rs                | 874 +++--------
crates/assistant/src/assistant_settings.rs             |  21 
crates/assistant/src/context.rs                        | 431 ++---
crates/assistant/src/context/context_tests.rs          | 285 +--
crates/assistant/src/inline_assistant.rs               |  93 -
crates/assistant/src/patch.rs                          | 746 ++++++++++
crates/assistant/src/prompts.rs                        |   9 
crates/assistant/src/slash_command/workflow_command.rs |   4 
crates/assistant/src/workflow.rs                       | 507 ------
crates/editor/src/editor.rs                            |  11 
crates/editor/src/proposed_changes_editor.rs           | 138 +
crates/gpui/src/app/entity_map.rs                      |  15 
crates/language/src/buffer.rs                          |  19 
crates/languages/Cargo.toml                            |   5 
crates/text/src/text.rs                                |   2 
crates/ui/src/components/icon.rs                       |   1 
22 files changed, 1,617 insertions(+), 2,393 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -412,6 +412,7 @@ dependencies = [
  "parking_lot",
  "paths",
  "picker",
+ "pretty_assertions",
  "project",
  "proto",
  "rand 0.8.5",

assets/icons/diff.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-diff"><path d="M12 3v14"/><path d="M5 10h14"/><path d="M5 21h14"/></svg>

assets/prompts/edit_workflow.hbs 🔗

@@ -1,85 +1,33 @@
 <task_description>
 
-# Code Change Workflow
-
-Your task is to guide the user through code changes using a series of steps. Each step should describe a high-level change, which can consist of multiple edits to distinct locations in the codebase.
-
-## Output Example
-
-Provide output as XML, with the following format:
-
-<step>
-Update the Person struct to store an age
-
-```rust
-struct Person {
-    // existing fields...
-    age: u8,
-    height: f32,
-    // existing fields...
-}
-
-impl Person {
-    fn age(&self) -> u8 {
-        self.age
-    }
-}
-```
-
-<edit>
-<path>src/person.rs</path>
-<operation>insert_before</operation>
-<search>height: f32,</search>
-<description>Add the age field</description>
-</edit>
-
-<edit>
-<path>src/person.rs</path>
-<operation>insert_after</operation>
-<search>impl Person {</search>
-<description>Add the age getter</description>
-</edit>
-</step>
-
-## Output Format
-
-First, each `<step>` must contain a written description of the change that should be made. The description should begin with a high-level overview, and can contain markdown code blocks as well. The description should be self-contained and actionable.
-
-After the description, each `<step>` must contain one or more `<edit>` tags, each of which refer to a specific range in a source file. Each `<edit>` tag must contain the following child tags:
-
-### `<path>` (required)
-
-This tag contains the path to the file that will be changed. It can be an existing path, or a path that should be created.
-
-### `<search>` (optional)
-
-This tag contains a search string to locate in the source file, e.g. `pub fn baz() {`. If not provided, the new content will be inserted at the top of the file. Make sure to produce a string that exists in the source file and that isn't ambiguous. When there's ambiguity, add more lines to the search to eliminate it.
-
-### `<description>` (required)
-
-This tag contains a single-line description of the edit that should be made at the given location.
-
-### `<operation>` (required)
-
-This tag indicates what type of change should be made, relative to the given location. It can be one of the following:
-- `update`: Rewrites the specified string entirely based on the given description.
-- `create`: Creates a new file with the given path based on the provided description.
-- `insert_before`: Inserts new text based on the given description before the specified search string.
-- `insert_after`: Inserts new text based on the given description after the specified search string.
-- `delete`: Deletes the specified string from the containing file.
+The user of a code editor wants to make a change to their codebase.
+You must describe the change using the following XML structure:
+
+- <patch> - A group of related code changes.
+  Child tags:
+    - <title> (required) - A high-level description of the changes. This should be as short
+      as possible, possibly using common abbreviations.
+    - <edit> (1 or more) - An edit to make at a particular range within a file.
+      Includes the following child tags:
+        - <path> (required) - The path to the file that will be changed.
+        - <description> (optional) - An arbitrarily-long comment that describes the purpose
+          of this edit.
+        - <old_text> (optional) - An excerpt from the file's current contents that uniquely
+          identifies a range within the file where the edit should occur. If this tag is not
+          specified, then the entire file will be used as the range.
+        - <new_text> (required) - The new text to insert into the file.
+        - <operation> (required) - The type of change that should occur at the given range
+          of the file. Must be one of the following values:
+            - `update`: Replaces the entire range with the new text.
+            - `insert_before`: Inserts the new text before the range.
+            - `insert_after`: Inserts new text after the range.
+            - `create`: Creates a new file with the given path and the new text.
+            - `delete`: Deletes the specified range from the file.
 
 <guidelines>
-- There's no need to describe *what* to do, just *where* to do it.
-- Only reference locations that actually exist (unless you're creating a file).
-- If creating a file, assume any subsequent updates are included at the time of creation.
-- Don't create and then update a file. Always create new files in one hot.
-- Prefer multiple edits to smaller regions, as opposed to one big edit to a larger region.
-- Don't produce edits that intersect each other. In that case, merge them into a bigger edit.
-- Never nest an edit with another edit. Never include CDATA. All edits are leaf nodes.
-- Descriptions are required for all edits except delete.
-- When generating multiple edits, ensure the descriptions are specific to each individual operation.
-- Avoid referring to the search string in the description. Focus on the change to be made, not the location where it's made. That's implicit with the `search` string you provide.
-- Don't generate multiple edits at the same location. Instead, combine them together in a single edit with a succinct combined description.
+- Never provide multiple edits whose ranges intersect each other. Instead, merge them into one edit.
+- Prefer multiple edits to smaller, disjoint ranges, rather than one edit to a larger range.
+- There's no need to escape angle brackets within XML tags.
 - Always ensure imports are added if you're referencing symbols that are not in scope.
 </guidelines>
 
@@ -124,189 +72,137 @@ Update all shapes to store their origin as an (x, y) tuple and implement Display
 <message role="assistant">
 We'll need to update both the rectangle and circle modules.
 
-<step>
-Add origin fields to both shape types.
-
-```rust
-struct Rectangle {
-    // existing fields ...
-    origin: (f64, f64),
-}
-```
-
-```rust
-struct Circle {
-    // existing fields ...
-    origin: (f64, f64),
-}
-```
-
+<patch>
+<title>Add origins and display impls to shapes</title>
 <edit>
 <path>src/shapes/rectangle.rs</path>
-<operation>insert_before</operation>
-<search>
-    width: f64,
-    height: f64,
-</search>
-<description>Add the origin field to Rectangle</description>
+<description>Add the origin field to Rectangle struct</description>
+<operation>insert_after</operation>
+<old_text>
+pub struct Rectangle {
+</old_text>
+<new_text>
+origin: (f64, f64),
+</new_text>
 </edit>
 
 <edit>
-<path>src/shapes/circle.rs</path>
-<operation>insert_before</operation>
-<search>
-    radius: f64,
-</search>
-<description>Add the origin field to Circle</description>
+<path>src/shapes/rectangle.rs</path>
+<description>Update the Rectangle's new function to take an origin parameter</description>
+<operation>update</operation>
+<old_text>
+fn new(width: f64, height: f64) -> Self {
+    Rectangle { width, height }
+}
+</old_text>
+<new_text>
+fn new(origin: (f64, f64), width: f64, height: f64) -> Self {
+    Rectangle { origin, width, height }
+}
+</new_text>
 </edit>
 
-<step>
-Update both shape's constructors to take an origin.
-
 <edit>
-<path>src/shapes/rectangle.rs</path>
-<operation>update</operation>
-<search>
-    fn new(width: f64, height: f64) -> Self {
-        Rectangle { width, height }
-    }
-</search>
-<description>Update the Rectangle new function to take an origin</description>
+<path>src/shapes/circle.rs</path>
+<description>Add the origin field to Circle struct</description>
+<operation>insert_after</operation>
+<old_text>
+pub struct Circle {
+    radius: f64,
+</old_text>
+<new_text>
+    origin: (f64, f64),
+</new_text>
 </edit>
 
 <edit>
 <path>src/shapes/circle.rs</path>
+<description>Update the Circle's new function to take an origin parameter</description>
 <operation>update</operation>
-<search>
-    fn new(radius: f64) -> Self {
-        Circle { radius }
-    }
-</search>
-<description>Update the Circle new function to take an origin</description>
+<old_text>
+fn new(radius: f64) -> Self {
+    Circle { radius }
+}
+</old_text>
+<new_text>
+fn new(origin: (f64, f64), radius: f64) -> Self {
+    Circle { origin, radius }
+}
+</new_text>
 </edit>
 </step>
 
-<step>
-Implement Display for both shapes
-
 <edit>
 <path>src/shapes/rectangle.rs</path>
+<description>Add an import for the std::fmt module</description>
 <operation>insert_before</operation>
-<search>
+<old_text>
 struct Rectangle {
-</search>
-<description>Add an import for the `std::fmt` module</description>
+</old_text>
+<new_text>
+use std::fmt;
+
+</new_text>
 </edit>
 
 <edit>
 <path>src/shapes/rectangle.rs</path>
+<description>Add a Display implementation for Rectangle</description>
 <operation>insert_after</operation>
-<search>
+<old_text>
         Rectangle { width, height }
     }
 }
-</search>
-<description>Add a Display implementation for Rectangle</description>
+</old_text>
+<new_text>
+impl fmt::Display for Rectangle {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.format_struct(f, "Rectangle")
+            .field("origin", &self.origin)
+            .field("width", &self.width)
+            .field("height", &self.height)
+            .finish()
+    }
+}
+</new_text>
 </edit>
 
 <edit>
 <path>src/shapes/circle.rs</path>
+<description>Add an import for the `std::fmt` module</description>
 <operation>insert_before</operation>
-<search>
+<old_text>
 struct Circle {
-</search>
-<description>Add an import for the `std::fmt` module</description>
+</old_text>
+<new_text>
+use std::fmt;
+</new_text>
 </edit>
 
 <edit>
 <path>src/shapes/circle.rs</path>
+<description>Add a Display implementation for Circle</description>
 <operation>insert_after</operation>
-<search>
+<old_text>
         Circle { radius }
     }
 }
-</search>
-<description>Add a Display implementation for Circle</description>
-</edit>
-</step>
-</message>
-</example>
-
-<example number=2>
-<message role="user">
-
-```rs src/user.rs
-struct User {
-    pub name: String,
-    age: u32,
-    email: String,
-}
-
-impl User {
-    fn new(name: String, age: u32, email: String) -> Self {
-        User { name, age, email }
-    }
-
-    pub fn print_info(&self) {
-        todo!()
-    }
-}
-```
-
-Let's print all the user information and delete the email field.
-</message>
-
-<message role="assistant">
-<step>
-Update the 'print_info' method to print user information
-
-```rust
-impl User {
-    // ... other methods ...
-
-    pub fn print_info(&self) {
-        println!("Name: {name}, Age: {age}", name = self.name, age = self.age);
+</old_text>
+<new_text>
+impl fmt::Display for Rectangle {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.format_struct(f, "Rectangle")
+            .field("origin", &self.origin)
+            .field("width", &self.width)
+            .field("height", &self.height)
+            .finish()
     }
 }
-```
-
-<edit>
-<path>src/user.rs</path>
-<operation>update</operation>
-<search>
-    pub fn print_info(&self) {
-        todo!()
-    }
-</search>
-<description>Print all the user information</description>
-</edit>
-</step>
-
-<step>
-Remove the 'email' field from the User struct
-
-<edit>
-<path>src/user.rs</path>
-<operation>delete</operation>
-<search>
-email: String,
-</search>
+</new_text>
 </edit>
+</patch>
 
-<edit>
-<path>src/user.rs</path>
-<operation>update</operation>
-<symbol>
-fn new(name: String, age: u32, email: String) -> Self {
-    User { name, age, email }
-}
-</symbol>
-<description>Remove email parameter from new method</description>
-</edit>
-</step>
 </message>
 </example>
 
-You should think step by step. When possible, produce smaller, coherent logical steps as opposed to one big step that combines lots of heterogeneous edits.
-
 </task_description>

assets/prompts/step_resolution.hbs 🔗

@@ -1,496 +0,0 @@
-<overview>
-Your task is to map a step from a workflow to locations in source code where code needs to be changed to fulfill that step.
-Given a workflow containing background context plus a series of <step> tags, you will resolve *one* of these step tags to resolve to one or more locations in the code.
-With each location, you will produce a brief, one-line description of the changes to be made.
-
-<guidelines>
-- There's no need to describe *what* to do, just *where* to do it.
-- Only reference locations that actually exist (unless you're creating a file).
-- If creating a file, assume any subsequent updates are included at the time of creation.
-- Don't create and then update a file. Always create new files in shot.
-- Prefer updating symbols lower in the syntax tree if possible.
-- Never include suggestions on a parent symbol and one of its children in the same suggestions block.
-- Never nest an operation with another operation or include CDATA or other content. All suggestions are leaf nodes.
-- Descriptions are required for all suggestions except delete.
-- When generating multiple suggestions, ensure the descriptions are specific to each individual operation.
-- Avoid referring to the location in the description. Focus on the change to be made, not the location where it's made. That's implicit with the symbol you provide.
-- Don't generate multiple suggestions at the same location. Instead, combine them together in a single operation with a succinct combined description.
-- To add imports respond with a suggestion where the `"symbol"` key is set to `"#imports"`
-</guidelines>
-</overview>
-
-<examples>
-<example>
-<workflow_context>
-<message role="user">
-```rs src/rectangle.rs
-struct Rectangle {
-    width: f64,
-    height: f64,
-}
-
-impl Rectangle {
-    fn new(width: f64, height: f64) -> Self {
-        Rectangle { width, height }
-    }
-}
-```
-
-We need to add methods to calculate the area and perimeter of the rectangle. Can you help with that?
-</message>
-<message role="assistant">
-Sure, I can help with that!
-
-<step>Add new methods 'calculate_area' and 'calculate_perimeter' to the Rectangle struct</step>
-<step>Implement the 'Display' trait for the Rectangle struct</step>
-</message>
-</workflow_context>
-
-<step_to_resolve>
-Add new methods 'calculate_area' and 'calculate_perimeter' to the Rectangle struct
-</step_to_resolve>
-
-<incorrect_output reason="NEVER append multiple children at the same location.">
-{
-  "title": "Add Rectangle methods",
-  "suggestions": [
-    {
-      "kind": "AppendChild",
-      "path": "src/shapes.rs",
-      "symbol": "impl Rectangle",
-      "description": "Add calculate_area method"
-    },
-    {
-      "kind": "AppendChild",
-      "path": "src/shapes.rs",
-      "symbol": "impl Rectangle",
-      "description": "Add calculate_perimeter method"
-    }
-  ]
-}
-</incorrect_output>
-
-<correct_output>
-{
-  "title": "Add Rectangle methods",
-  "suggestions": [
-    {
-      "kind": "AppendChild",
-      "path": "src/shapes.rs",
-      "symbol": "impl Rectangle",
-      "description": "Add calculate area and perimeter methods"
-    }
-  ]
-}
-</correct_output>
-
-<step_to_resolve>
-Implement the 'Display' trait for the Rectangle struct
-</step_to_resolve>
-
-<output>
-{
-  "title": "Implement Display for Rectangle",
-  "suggestions": [
-    {
-      "kind": "InsertSiblingAfter",
-      "path": "src/shapes.rs",
-      "symbol": "impl Rectangle",
-      "description": "Implement Display trait for Rectangle"
-    }
-  ]
-}
-</output>
-
-<example>
-<workflow_context>
-<message role="user">
-```rs src/user.rs
-struct User {
-    pub name: String,
-    age: u32,
-    email: String,
-}
-
-impl User {
-    fn new(name: String, age: u32, email: String) -> Self {
-        User { name, age, email }
-    }
-
-    pub fn print_info(&self) {
-        println!("Name: {}, Age: {}, Email: {}", self.name, self.age, self.email);
-    }
-}
-```
-</message>
-<message role="assistant">
-Certainly!
-<step>Update the 'print_info' method to use formatted output</step>
-<step>Remove the 'email' field from the User struct</step>
-</message>
-</workflow_context>
-
-<step_to_resolve>
-Update the 'print_info' method to use formatted output
-</step_to_resolve>
-
-<output>
-{
-  "title": "Use formatted output",
-  "suggestions": [
-    {
-      "kind": "Update",
-      "path": "src/user.rs",
-      "symbol": "impl User pub fn print_info",
-      "description": "Use formatted output"
-    }
-  ]
-}
-</output>
-
-<step_to_resolve>
-Remove the 'email' field from the User struct
-</step_to_resolve>
-
-<output>
-{
-  "title": "Remove email field",
-  "suggestions": [
-      {
-        "kind": "Delete",
-        "path": "src/user.rs",
-        "symbol": "struct User email"
-      }
-    ]
-}
-</output>
-</example>
-
-<example>
-<workflow_context>
-<message role="user">
-```rs src/vehicle.rs
-struct Vehicle {
-    make: String,
-    model: String,
-    year: u32,
-}
-
-impl Vehicle {
-    fn new(make: String, model: String, year: u32) -> Self {
-        Vehicle { make, model, year }
-    }
-
-    fn print_year(&self) {
-        println!("Year: {}", self.year);
-    }
-}
-```
-</message>
-<message role="assistant">
-<step>Add a 'use std::fmt;' statement at the beginning of the file</step>
-<step>Add a new method 'start_engine' in the Vehicle impl block</step>
-</message>
-</workflow_context>
-
-<step_to_resolve>
-Add a 'use std::fmt;' statement at the beginning of the file
-</step_to_resolve>
-
-<output>
-{
-  "title": "Add use std::fmt statement",
-  "suggestions": [
-    {
-      "kind": "PrependChild",
-      "path": "src/vehicle.rs",
-      "symbol": "#imports",
-      "description": "Add 'use std::fmt' statement"
-    }
-  ]
-}
-</output>
-
-<step_to_resolve>
-Add a new method 'start_engine' in the Vehicle impl block
-</step_to_resolve>
-
-<output>
-{
-  "title": "Add start_engine method",
-  "suggestions": [
-    {
-      "kind": "InsertSiblingAfter",
-      "path": "src/vehicle.rs",
-      "symbol": "impl Vehicle fn new",
-      "description": "Add start_engine method"
-    }
-  ]
-}
-</output>
-</example>
-
-<example>
-<workflow_context>
-<message role="user">
-```rs src/employee.rs
-struct Employee {
-    name: String,
-    position: String,
-    salary: u32,
-    department: String,
-}
-
-impl Employee {
-    fn new(name: String, position: String, salary: u32, department: String) -> Self {
-        Employee { name, position, salary, department }
-    }
-
-    fn print_details(&self) {
-        println!("Name: {}, Position: {}, Salary: {}, Department: {}",
-                  self.name, self.position, self.salary, self.department);
-    }
-
-    fn give_raise(&mut self, amount: u32) {
-        self.salary += amount;
-    }
-}
-```
-</message>
-<message role="assistant">
-<step>Make salary an f32</step>
-<step>Remove the 'department' field and update the 'print_details' method</step>
-</message>
-</workflow_context>
-
-<step_to_resolve>
-Make salary an f32
-</step_to_resolve>
-
-<incorrect_output reason="NEVER include suggestions on a parent symbol and one of its children in the same suggestions block.">
-{
-  "title": "Change salary to f32",
-  "suggestions": [
-    {
-      "kind": "Update",
-      "path": "src/employee.rs",
-      "symbol": "struct Employee",
-      "description": "Change the type of salary to an f32"
-    },
-    {
-      "kind": "Update",
-      "path": "src/employee.rs",
-      "symbol": "struct Employee salary",
-      "description": "Change the type to an f32"
-    }
-  ]
-}
-</incorrect_output>
-
-<correct_output>
-{
-  "title": "Change salary to f32",
-  "suggestions": [
-    {
-      "kind": "Update",
-      "path": "src/employee.rs",
-      "symbol": "struct Employee salary",
-      "description": "Change the type to an f32"
-    }
-  ]
-}
-</correct_output>
-
-<step_to_resolve>
-Remove the 'department' field and update the 'print_details' method
-</step_to_resolve>
-
-<output>
-{
-  "title": "Remove department",
-  "suggestions": [
-    {
-      "kind": "Delete",
-      "path": "src/employee.rs",
-      "symbol": "struct Employee department"
-    },
-    {
-      "kind": "Update",
-      "path": "src/employee.rs",
-      "symbol": "impl Employee fn print_details",
-      "description": "Don't print the 'department' field"
-    }
-  ]
-}
-</output>
-</example>
-
-<example>
-<workflow_context>
-<message role="user">
-```rs src/game.rs
-struct Player {
-    name: String,
-    health: i32,
-    pub score: u32,
-}
-
-impl Player {
-    pub fn new(name: String) -> Self {
-        Player { name, health: 100, score: 0 }
-    }
-}
-
-struct Game {
-    players: Vec<Player>,
-}
-
-impl Game {
-    fn new() -> Self {
-        Game { players: Vec::new() }
-    }
-}
-```
-</message>
-<message role="assistant">
-<step>Add a 'level' field to Player and update the 'new' method</step>
-</message>
-</workflow_context>
-
-<step_to_resolve>
-Add a 'level' field to Player and update the 'new' method
-</step_to_resolve>
-
-<output>
-{
-  "title": "Add level field to Player",
-  "suggestions": [
-    {
-      "kind": "InsertSiblingAfter",
-      "path": "src/game.rs",
-      "symbol": "struct Player pub score",
-      "description": "Add level field to Player"
-    },
-    {
-      "kind": "Update",
-      "path": "src/game.rs",
-      "symbol": "impl Player pub fn new",
-      "description": "Initialize level in new method"
-    }
-  ]
-}
-</output>
-</example>
-
-<example>
-<workflow_context>
-<message role="user">
-```rs src/config.rs
-use std::collections::HashMap;
-
-struct Config {
-    settings: HashMap<String, String>,
-}
-
-impl Config {
-    fn new() -> Self {
-        Config { settings: HashMap::new() }
-    }
-}
-```
-</message>
-<message role="assistant">
-<step>Add a 'load_from_file' method to Config and import necessary modules</step>
-</message>
-</workflow_context>
-
-<step_to_resolve>
-Add a 'load_from_file' method to Config and import necessary modules
-</step_to_resolve>
-
-<output>
-{
-  "title": "Add load_from_file method",
-  "suggestions": [
-    {
-      "kind": "PrependChild",
-      "path": "src/config.rs",
-      "symbol": "#imports",
-      "description": "Import std::fs and std::io modules"
-    },
-    {
-      "kind": "AppendChild",
-      "path": "src/config.rs",
-      "symbol": "impl Config",
-      "description": "Add load_from_file method"
-    }
-  ]
-}
-</output>
-</example>
-
-<example>
-<workflow_context>
-<message role="user">
-```rs src/database.rs
-pub(crate) struct Database {
-    connection: Connection,
-}
-
-impl Database {
-    fn new(url: &str) -> Result<Self, Error> {
-        let connection = Connection::connect(url)?;
-        Ok(Database { connection })
-    }
-
-    async fn query(&self, sql: &str) -> Result<Vec<Row>, Error> {
-        self.connection.query(sql, &[])
-    }
-}
-```
-</message>
-<message role="assistant">
-<step>Add error handling to the 'query' method and create a custom error type</step>
-</message>
-</workflow_context>
-
-<step_to_resolve>
-Add error handling to the 'query' method and create a custom error type
-</step_to_resolve>
-
-<output>
-{
-  "title": "Add error handling to query",
-  "suggestions": [
-    {
-      "kind": "PrependChild",
-      "path": "src/database.rs",
-      "description": "Import necessary error handling modules"
-    },
-    {
-      "kind": "InsertSiblingBefore",
-      "path": "src/database.rs",
-      "symbol": "pub(crate) struct Database",
-      "description": "Define custom DatabaseError enum"
-    },
-    {
-      "kind": "Update",
-      "path": "src/database.rs",
-      "symbol": "impl Database async fn query",
-      "description": "Implement error handling in query method"
-    }
-  ]
-}
-</output>
-</example>
-</examples>
-
-Now generate the suggestions for the following step:
-
-<workflow_context>
-{{{workflow_context}}}
-</workflow_context>
-
-<step_to_resolve>
-{{{step_to_resolve}}}
-</step_to_resolve>

crates/assistant/Cargo.toml 🔗

@@ -97,6 +97,7 @@ language = { workspace = true, features = ["test-support"] }
 language_model = { workspace = true, features = ["test-support"] }
 languages = { workspace = true, features = ["test-support"] }
 log.workspace = true
+pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
 rand.workspace = true
 serde_json_lenient.workspace = true

crates/assistant/src/assistant.rs 🔗

@@ -6,6 +6,7 @@ mod context;
 pub mod context_store;
 mod inline_assistant;
 mod model_selector;
+mod patch;
 mod prompt_library;
 mod prompts;
 mod slash_command;
@@ -14,7 +15,6 @@ pub mod slash_command_settings;
 mod streaming_diff;
 mod terminal_inline_assistant;
 mod tools;
-mod workflow;
 
 pub use assistant_panel::{AssistantPanel, AssistantPanelEvent};
 use assistant_settings::AssistantSettings;
@@ -35,11 +35,13 @@ use language_model::{
     LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, LanguageModelResponseMessage,
 };
 pub(crate) use model_selector::*;
+pub use patch::*;
 pub use prompts::PromptBuilder;
 use prompts::PromptLoadingParams;
 use semantic_index::{CloudEmbeddingProvider, SemanticDb};
 use serde::{Deserialize, Serialize};
 use settings::{update_settings_file, Settings, SettingsStore};
+use slash_command::workflow_command::WorkflowSlashCommand;
 use slash_command::{
     auto_command, cargo_workspace_command, context_server_command, default_command, delta_command,
     diagnostics_command, docs_command, fetch_command, file_command, now_command, project_command,
@@ -50,7 +52,6 @@ use std::path::PathBuf;
 use std::sync::Arc;
 pub(crate) use streaming_diff::*;
 use util::ResultExt;
-pub use workflow::*;
 
 use crate::slash_command_settings::SlashCommandSettings;
 
@@ -393,12 +394,25 @@ fn register_slash_commands(prompt_builder: Option<Arc<PromptBuilder>>, cx: &mut
     slash_command_registry.register_command(now_command::NowSlashCommand, false);
     slash_command_registry.register_command(diagnostics_command::DiagnosticsSlashCommand, true);
     slash_command_registry.register_command(fetch_command::FetchSlashCommand, false);
+    slash_command_registry.register_command(fetch_command::FetchSlashCommand, false);
 
     if let Some(prompt_builder) = prompt_builder {
-        slash_command_registry.register_command(
-            workflow_command::WorkflowSlashCommand::new(prompt_builder.clone()),
-            true,
-        );
+        cx.observe_global::<SettingsStore>({
+            let slash_command_registry = slash_command_registry.clone();
+            let prompt_builder = prompt_builder.clone();
+            move |cx| {
+                if AssistantSettings::get_global(cx).are_live_diffs_enabled(cx) {
+                    slash_command_registry.register_command(
+                        workflow_command::WorkflowSlashCommand::new(prompt_builder.clone()),
+                        true,
+                    );
+                } else {
+                    slash_command_registry.unregister_command_by_name(WorkflowSlashCommand::NAME);
+                }
+            }
+        })
+        .detach();
+
         cx.observe_flag::<project_command::ProjectSlashCommandFeatureFlag, _>({
             let slash_command_registry = slash_command_registry.clone();
             move |is_enabled, _cx| {

crates/assistant/src/assistant_panel.rs 🔗

@@ -11,12 +11,12 @@ use crate::{
     },
     slash_command_picker,
     terminal_inline_assistant::TerminalInlineAssistant,
-    Assist, CacheStatus, ConfirmCommand, Content, Context, ContextEvent, ContextId, ContextStore,
-    ContextStoreEvent, CopyCode, CycleMessageRole, DeployHistory, DeployPromptLibrary,
-    InlineAssistId, InlineAssistant, InsertDraggedFiles, InsertIntoEditor, Message, MessageId,
-    MessageMetadata, MessageStatus, ModelPickerDelegate, ModelSelector, NewContext,
-    PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection, RemoteContextMetadata,
-    SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector, WorkflowStepResolution,
+    Assist, AssistantPatch, AssistantPatchStatus, CacheStatus, ConfirmCommand, Content, Context,
+    ContextEvent, ContextId, ContextStore, ContextStoreEvent, CopyCode, CycleMessageRole,
+    DeployHistory, DeployPromptLibrary, InlineAssistant, InsertDraggedFiles, InsertIntoEditor,
+    Message, MessageId, MessageMetadata, MessageStatus, ModelPickerDelegate, ModelSelector,
+    NewContext, PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection,
+    RemoteContextMetadata, SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector,
 };
 use anyhow::Result;
 use assistant_slash_command::{SlashCommand, SlashCommandOutputSection};
@@ -26,11 +26,12 @@ use collections::{BTreeSet, HashMap, HashSet};
 use editor::{
     actions::{FoldAt, MoveToEndOfLine, Newline, ShowCompletions, UnfoldAt},
     display_map::{
-        BlockDisposition, BlockId, BlockProperties, BlockStyle, Crease, CreaseMetadata,
-        CustomBlockId, FoldId, RenderBlock, ToDisplayPoint,
+        BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, Crease,
+        CreaseMetadata, CustomBlockId, FoldId, RenderBlock, ToDisplayPoint,
     },
-    scroll::{Autoscroll, AutoscrollStrategy, ScrollAnchor},
-    Anchor, Editor, EditorEvent, ExcerptRange, MultiBuffer, RowExt, ToOffset as _, ToPoint,
+    scroll::{Autoscroll, AutoscrollStrategy},
+    Anchor, Editor, EditorEvent, ProposedChangeLocation, ProposedChangesEditor, RowExt,
+    ToOffset as _, ToPoint,
 };
 use editor::{display_map::CreaseId, FoldPlaceholder};
 use fs::Fs;
@@ -38,15 +39,14 @@ use futures::FutureExt;
 use gpui::{
     canvas, div, img, percentage, point, pulsating_between, size, Action, Animation, AnimationExt,
     AnyElement, AnyView, AppContext, AsyncWindowContext, ClipboardEntry, ClipboardItem,
-    Context as _, Empty, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, FocusableView,
-    FontWeight, InteractiveElement, IntoElement, Model, ParentElement, Pixels, ReadGlobal, Render,
-    RenderImage, SharedString, Size, StatefulInteractiveElement, Styled, Subscription, Task,
-    Transformation, UpdateGlobal, View, VisualContext, WeakView, WindowContext,
+    CursorStyle, Empty, Entity, EventEmitter, ExternalPaths, FocusHandle, FocusableView,
+    FontWeight, InteractiveElement, IntoElement, Model, ParentElement, Pixels, Render, RenderImage,
+    SharedString, Size, StatefulInteractiveElement, Styled, Subscription, Task, Transformation,
+    UpdateGlobal, View, VisualContext, WeakView, WindowContext,
 };
 use indexed_docs::IndexedDocsStore;
 use language::{
-    language_settings::SoftWrap, BufferSnapshot, Capability, LanguageRegistry, LspAdapterDelegate,
-    ToOffset,
+    language_settings::SoftWrap, BufferSnapshot, LanguageRegistry, LspAdapterDelegate, ToOffset,
 };
 use language_model::{
     provider::cloud::PROVIDER_ID, LanguageModelProvider, LanguageModelProviderId,
@@ -65,7 +65,6 @@ use smol::stream::StreamExt;
 use std::{
     borrow::Cow,
     cmp,
-    collections::hash_map,
     ops::{ControlFlow, Range},
     path::PathBuf,
     sync::Arc,
@@ -1444,61 +1443,16 @@ struct ScrollPosition {
     cursor: Anchor,
 }
 
-struct WorkflowStepViewState {
-    header_block_id: CustomBlockId,
-    header_crease_id: CreaseId,
-    footer_block_id: Option<CustomBlockId>,
-    footer_crease_id: Option<CreaseId>,
-    assist: Option<WorkflowAssist>,
-    resolution: Option<Arc<Result<WorkflowStepResolution>>>,
+struct PatchViewState {
+    footer_block_id: CustomBlockId,
+    crease_id: CreaseId,
+    editor: Option<PatchEditorState>,
+    update_task: Option<Task<()>>,
 }
 
-impl WorkflowStepViewState {
-    fn status(&self, cx: &AppContext) -> WorkflowStepStatus {
-        if let Some(assist) = &self.assist {
-            match assist.status(cx) {
-                WorkflowAssistStatus::Idle => WorkflowStepStatus::Idle,
-                WorkflowAssistStatus::Pending => WorkflowStepStatus::Pending,
-                WorkflowAssistStatus::Done => WorkflowStepStatus::Done,
-                WorkflowAssistStatus::Confirmed => WorkflowStepStatus::Confirmed,
-            }
-        } else if let Some(resolution) = self.resolution.as_deref() {
-            match resolution {
-                Err(err) => WorkflowStepStatus::Error(err),
-                Ok(_) => WorkflowStepStatus::Idle,
-            }
-        } else {
-            WorkflowStepStatus::Resolving
-        }
-    }
-}
-
-#[derive(Clone, Copy)]
-enum WorkflowStepStatus<'a> {
-    Resolving,
-    Error(&'a anyhow::Error),
-    Idle,
-    Pending,
-    Done,
-    Confirmed,
-}
-
-impl<'a> WorkflowStepStatus<'a> {
-    pub(crate) fn is_confirmed(&self) -> bool {
-        matches!(self, Self::Confirmed)
-    }
-}
-
-#[derive(Debug, Eq, PartialEq)]
-struct ActiveWorkflowStep {
-    range: Range<language::Anchor>,
-    resolved: bool,
-}
-
-struct WorkflowAssist {
-    editor: WeakView<Editor>,
-    editor_was_open: bool,
-    assist_ids: Vec<InlineAssistId>,
+struct PatchEditorState {
+    editor: WeakView<ProposedChangesEditor>,
+    opened_patch: AssistantPatch,
 }
 
 type MessageHeader = MessageMetadata;
@@ -1525,8 +1479,8 @@ pub struct ContextEditor {
     pending_slash_command_blocks: HashMap<Range<language::Anchor>, CustomBlockId>,
     pending_tool_use_creases: HashMap<Range<language::Anchor>, CreaseId>,
     _subscriptions: Vec<Subscription>,
-    workflow_steps: HashMap<Range<language::Anchor>, WorkflowStepViewState>,
-    active_workflow_step: Option<ActiveWorkflowStep>,
+    patches: HashMap<Range<language::Anchor>, PatchViewState>,
+    active_patch: Option<Range<language::Anchor>>,
     assistant_panel: WeakView<AssistantPanel>,
     last_error: Option<AssistError>,
     show_accept_terms: bool,
@@ -1580,7 +1534,7 @@ impl ContextEditor {
         ];
 
         let sections = context.read(cx).slash_command_output_sections().to_vec();
-        let edit_step_ranges = context.read(cx).workflow_step_ranges().collect::<Vec<_>>();
+        let patch_ranges = context.read(cx).patch_ranges().collect::<Vec<_>>();
         let mut this = Self {
             context,
             editor,
@@ -1596,8 +1550,8 @@ impl ContextEditor {
             pending_slash_command_blocks: HashMap::default(),
             pending_tool_use_creases: HashMap::default(),
             _subscriptions,
-            workflow_steps: HashMap::default(),
-            active_workflow_step: None,
+            patches: HashMap::default(),
+            active_patch: None,
             assistant_panel,
             last_error: None,
             show_accept_terms: false,
@@ -1607,7 +1561,7 @@ impl ContextEditor {
         this.update_message_headers(cx);
         this.update_image_blocks(cx);
         this.insert_slash_command_output_sections(sections, false, cx);
-        this.workflow_steps_updated(&Vec::new(), &edit_step_ranges, cx);
+        this.patches_updated(&Vec::new(), &patch_ranges, cx);
         this
     }
 
@@ -1642,134 +1596,28 @@ impl ContextEditor {
             return;
         }
 
-        if !self.apply_active_workflow_step(cx) {
-            self.last_error = None;
-            self.send_to_model(cx);
-            cx.notify();
-        }
-    }
-
-    fn apply_workflow_step(&mut self, range: Range<language::Anchor>, cx: &mut ViewContext<Self>) {
-        self.show_workflow_step(range.clone(), cx);
-
-        if let Some(workflow_step) = self.workflow_steps.get(&range) {
-            if let Some(assist) = workflow_step.assist.as_ref() {
-                let assist_ids = assist.assist_ids.clone();
-                cx.spawn(|this, mut cx| async move {
-                    for assist_id in assist_ids {
-                        let mut receiver = this.update(&mut cx, |_, cx| {
-                            cx.window_context().defer(move |cx| {
-                                InlineAssistant::update_global(cx, |assistant, cx| {
-                                    assistant.start_assist(assist_id, cx);
-                                })
-                            });
-                            InlineAssistant::update_global(cx, |assistant, _| {
-                                assistant.observe_assist(assist_id)
-                            })
-                        })?;
-                        while !receiver.borrow().is_done() {
-                            let _ = receiver.changed().await;
-                        }
-                    }
-                    anyhow::Ok(())
-                })
-                .detach_and_log_err(cx);
-            }
-        }
-    }
-
-    fn apply_active_workflow_step(&mut self, cx: &mut ViewContext<Self>) -> bool {
-        let Some((range, step)) = self.active_workflow_step() else {
-            return false;
-        };
-
-        if let Some(assist) = step.assist.as_ref() {
-            match assist.status(cx) {
-                WorkflowAssistStatus::Pending => {}
-                WorkflowAssistStatus::Confirmed => return false,
-                WorkflowAssistStatus::Done => self.confirm_workflow_step(range, cx),
-                WorkflowAssistStatus::Idle => self.apply_workflow_step(range, cx),
-            }
-        } else {
-            match step.resolution.as_deref() {
-                Some(Ok(_)) => self.apply_workflow_step(range, cx),
-                Some(Err(_)) => self.resolve_workflow_step(range, cx),
-                None => {}
-            }
-        }
-
-        true
-    }
-
-    fn resolve_workflow_step(
-        &mut self,
-        range: Range<language::Anchor>,
-        cx: &mut ViewContext<Self>,
-    ) {
-        self.context
-            .update(cx, |context, cx| context.resolve_workflow_step(range, cx));
-    }
-
-    fn stop_workflow_step(&mut self, range: Range<language::Anchor>, cx: &mut ViewContext<Self>) {
-        if let Some(workflow_step) = self.workflow_steps.get(&range) {
-            if let Some(assist) = workflow_step.assist.as_ref() {
-                let assist_ids = assist.assist_ids.clone();
-                cx.window_context().defer(|cx| {
-                    InlineAssistant::update_global(cx, |assistant, cx| {
-                        for assist_id in assist_ids {
-                            assistant.stop_assist(assist_id, cx);
-                        }
-                    })
-                });
-            }
+        if self.focus_active_patch(cx) {
+            return;
         }
-    }
 
-    fn undo_workflow_step(&mut self, range: Range<language::Anchor>, cx: &mut ViewContext<Self>) {
-        if let Some(workflow_step) = self.workflow_steps.get_mut(&range) {
-            if let Some(assist) = workflow_step.assist.take() {
-                cx.window_context().defer(|cx| {
-                    InlineAssistant::update_global(cx, |assistant, cx| {
-                        for assist_id in assist.assist_ids {
-                            assistant.undo_assist(assist_id, cx);
-                        }
-                    })
-                });
-            }
-        }
+        self.last_error = None;
+        self.send_to_model(cx);
+        cx.notify();
     }
 
-    fn confirm_workflow_step(
-        &mut self,
-        range: Range<language::Anchor>,
-        cx: &mut ViewContext<Self>,
-    ) {
-        if let Some(workflow_step) = self.workflow_steps.get(&range) {
-            if let Some(assist) = workflow_step.assist.as_ref() {
-                let assist_ids = assist.assist_ids.clone();
-                cx.window_context().defer(move |cx| {
-                    InlineAssistant::update_global(cx, |assistant, cx| {
-                        for assist_id in assist_ids {
-                            assistant.finish_assist(assist_id, false, cx);
-                        }
-                    })
-                });
+    fn focus_active_patch(&mut self, cx: &mut ViewContext<Self>) -> bool {
+        if let Some((_range, patch)) = self.active_patch() {
+            if let Some(editor) = patch
+                .editor
+                .as_ref()
+                .and_then(|state| state.editor.upgrade())
+            {
+                cx.focus_view(&editor);
+                return true;
             }
         }
-    }
 
-    fn reject_workflow_step(&mut self, range: Range<language::Anchor>, cx: &mut ViewContext<Self>) {
-        if let Some(workflow_step) = self.workflow_steps.get_mut(&range) {
-            if let Some(assist) = workflow_step.assist.take() {
-                cx.window_context().defer(move |cx| {
-                    InlineAssistant::update_global(cx, |assistant, cx| {
-                        for assist_id in assist.assist_ids {
-                            assistant.finish_assist(assist_id, true, cx);
-                        }
-                    })
-                });
-            }
-        }
+        false
     }
 
     fn send_to_model(&mut self, cx: &mut ViewContext<Self>) {
@@ -1802,19 +1650,6 @@ impl ContextEditor {
             return;
         }
 
-        if let Some((range, active_step)) = self.active_workflow_step() {
-            match active_step.status(cx) {
-                WorkflowStepStatus::Pending => {
-                    self.stop_workflow_step(range, cx);
-                    return;
-                }
-                WorkflowStepStatus::Done => {
-                    self.reject_workflow_step(range, cx);
-                    return;
-                }
-                _ => {}
-            }
-        }
         cx.propagate();
     }
 
@@ -2068,8 +1903,8 @@ impl ContextEditor {
                     );
                 });
             }
-            ContextEvent::WorkflowStepsUpdated { removed, updated } => {
-                self.workflow_steps_updated(removed, updated, cx);
+            ContextEvent::PatchesUpdated { removed, updated } => {
+                self.patches_updated(removed, updated, cx);
             }
             ContextEvent::PendingSlashCommandsUpdated { removed, updated } => {
                 self.editor.update(cx, |editor, cx| {
@@ -2309,7 +2144,7 @@ impl ContextEditor {
         }
     }
 
-    fn workflow_steps_updated(
+    fn patches_updated(
         &mut self,
         removed: &Vec<Range<text::Anchor>>,
         updated: &Vec<Range<text::Anchor>>,
@@ -2320,218 +2155,133 @@ impl ContextEditor {
         let mut removed_block_ids = HashSet::default();
         let mut editors_to_close = Vec::new();
         for range in removed {
-            if let Some(state) = self.workflow_steps.remove(range) {
-                editors_to_close.extend(self.hide_workflow_step(range.clone(), cx));
-                removed_block_ids.insert(state.header_block_id);
-                removed_crease_ids.push(state.header_crease_id);
-                removed_block_ids.extend(state.footer_block_id);
-                removed_crease_ids.extend(state.footer_crease_id);
+            if let Some(state) = self.patches.remove(range) {
+                editors_to_close.extend(state.editor.and_then(|state| state.editor.upgrade()));
+                removed_block_ids.insert(state.footer_block_id);
+                removed_crease_ids.push(state.crease_id);
             }
         }
 
-        for range in updated {
-            editors_to_close.extend(self.hide_workflow_step(range.clone(), cx));
-        }
-
         self.editor.update(cx, |editor, cx| {
             let snapshot = editor.snapshot(cx);
             let multibuffer = &snapshot.buffer_snapshot;
-            let (&excerpt_id, _, buffer) = multibuffer.as_singleton().unwrap();
+            let (&excerpt_id, _, _) = multibuffer.as_singleton().unwrap();
 
+            let mut replaced_blocks = HashMap::default();
             for range in updated {
-                let Some(step) = self.context.read(cx).workflow_step_for_range(&range, cx) else {
+                let Some(patch) = self.context.read(cx).patch_for_range(&range, cx).cloned() else {
                     continue;
                 };
 
-                let resolution = step.resolution.clone();
-                let header_start = step.range.start;
-                let header_end = if buffer.contains_str_at(step.leading_tags_end, "\n") {
-                    buffer.anchor_before(step.leading_tags_end.to_offset(&buffer) + 1)
-                } else {
-                    step.leading_tags_end
-                };
-                let header_range = multibuffer
-                    .anchor_in_excerpt(excerpt_id, header_start)
-                    .unwrap()
-                    ..multibuffer
-                        .anchor_in_excerpt(excerpt_id, header_end)
-                        .unwrap();
-                let footer_range = step.trailing_tag_start.map(|start| {
-                    let mut step_range_end = step.range.end.to_offset(&buffer);
-                    if buffer.contains_str_at(step_range_end, "\n") {
-                        // Only include the newline if it belongs to the same message.
-                        let messages = self
-                            .context
-                            .read(cx)
-                            .messages_for_offsets([step_range_end, step_range_end + 1], cx);
-                        if messages.len() == 1 {
-                            step_range_end += 1;
-                        }
+                let path_count = patch.path_count();
+                let patch_start = multibuffer
+                    .anchor_in_excerpt(excerpt_id, patch.range.start)
+                    .unwrap();
+                let patch_end = multibuffer
+                    .anchor_in_excerpt(excerpt_id, patch.range.end)
+                    .unwrap();
+                let render_block: RenderBlock = Box::new({
+                    let this = this.clone();
+                    let patch_range = range.clone();
+                    move |cx: &mut BlockContext<'_, '_>| {
+                        let max_width = cx.max_width;
+                        let gutter_width = cx.gutter_dimensions.full_width();
+                        let block_id = cx.block_id;
+                        this.update(&mut **cx, |this, cx| {
+                            this.render_patch_footer(
+                                patch_range.clone(),
+                                max_width,
+                                gutter_width,
+                                block_id,
+                                cx,
+                            )
+                        })
+                        .ok()
+                        .flatten()
+                        .unwrap_or_else(|| Empty.into_any())
                     }
-
-                    let end = buffer.anchor_before(step_range_end);
-                    multibuffer.anchor_in_excerpt(excerpt_id, start).unwrap()
-                        ..multibuffer.anchor_in_excerpt(excerpt_id, end).unwrap()
                 });
 
-                let block_ids = editor.insert_blocks(
-                    [BlockProperties {
-                        position: header_range.start,
-                        height: 1,
-                        style: BlockStyle::Flex,
-                        render: Box::new({
-                            let this = this.clone();
-                            let range = step.range.clone();
-                            move |cx| {
-                                let block_id = cx.block_id;
-                                let max_width = cx.max_width;
-                                let gutter_width = cx.gutter_dimensions.full_width();
-                                this.update(&mut **cx, |this, cx| {
-                                    this.render_workflow_step_header(
-                                        range.clone(),
-                                        max_width,
-                                        gutter_width,
-                                        block_id,
-                                        cx,
-                                    )
-                                })
-                                .ok()
-                                .flatten()
-                                .unwrap_or_else(|| Empty.into_any())
-                            }
-                        }),
-                        disposition: BlockDisposition::Above,
-                        priority: 0,
-                    }]
-                    .into_iter()
-                    .chain(footer_range.as_ref().map(|footer_range| {
-                        return BlockProperties {
-                            position: footer_range.end,
-                            height: 1,
-                            style: BlockStyle::Flex,
-                            render: Box::new({
-                                let this = this.clone();
-                                let range = step.range.clone();
-                                move |cx| {
-                                    let max_width = cx.max_width;
-                                    let gutter_width = cx.gutter_dimensions.full_width();
-                                    this.update(&mut **cx, |this, cx| {
-                                        this.render_workflow_step_footer(
-                                            range.clone(),
-                                            max_width,
-                                            gutter_width,
-                                            cx,
-                                        )
-                                    })
-                                    .ok()
-                                    .flatten()
-                                    .unwrap_or_else(|| Empty.into_any())
-                                }
-                            }),
-                            disposition: BlockDisposition::Below,
-                            priority: 0,
-                        };
-                    })),
-                    None,
-                    cx,
-                );
-
                 let header_placeholder = FoldPlaceholder {
-                    render: Arc::new(move |_, _crease_range, _cx| Empty.into_any()),
-                    constrain_width: false,
-                    merge_adjacent: false,
-                };
-                let footer_placeholder = FoldPlaceholder {
-                    render: render_fold_icon_button(
-                        cx.view().downgrade(),
-                        IconName::Code,
-                        "Edits".into(),
-                    ),
+                    render: {
+                        let this = this.clone();
+                        let patch_range = range.clone();
+                        Arc::new(move |fold_id, _range, cx| {
+                            this.update(cx, |this, cx| {
+                                this.render_patch_header(patch_range.clone(), fold_id, cx)
+                            })
+                            .ok()
+                            .flatten()
+                            .unwrap_or_else(|| Empty.into_any())
+                        })
+                    },
                     constrain_width: false,
                     merge_adjacent: false,
                 };
 
-                let new_crease_ids = editor.insert_creases(
-                    [Crease::new(
-                        header_range.clone(),
-                        header_placeholder.clone(),
-                        fold_toggle("step-header"),
-                        |_, _, _| Empty.into_any_element(),
-                    )]
-                    .into_iter()
-                    .chain(footer_range.clone().map(|footer_range| {
-                        Crease::new(
-                            footer_range,
-                            footer_placeholder.clone(),
-                            |row, is_folded, fold, cx| {
-                                if is_folded {
-                                    Empty.into_any_element()
-                                } else {
-                                    fold_toggle("step-footer")(row, is_folded, fold, cx)
-                                }
-                            },
-                            |_, _, _| Empty.into_any_element(),
-                        )
-                    })),
-                    cx,
-                );
-
-                let state = WorkflowStepViewState {
-                    header_block_id: block_ids[0],
-                    header_crease_id: new_crease_ids[0],
-                    footer_block_id: block_ids.get(1).copied(),
-                    footer_crease_id: new_crease_ids.get(1).copied(),
-                    resolution,
-                    assist: None,
-                };
-
-                let mut folds_to_insert = [(header_range.clone(), header_placeholder)]
-                    .into_iter()
-                    .chain(
-                        footer_range
-                            .clone()
-                            .map(|range| (range, footer_placeholder)),
-                    )
-                    .collect::<Vec<_>>();
-
-                match self.workflow_steps.entry(range.clone()) {
-                    hash_map::Entry::Vacant(entry) => {
-                        entry.insert(state);
-                    }
-                    hash_map::Entry::Occupied(mut entry) => {
-                        let entry = entry.get_mut();
-                        removed_block_ids.insert(entry.header_block_id);
-                        removed_crease_ids.push(entry.header_crease_id);
-                        removed_block_ids.extend(entry.footer_block_id);
-                        removed_crease_ids.extend(entry.footer_crease_id);
-                        folds_to_insert.retain(|(range, _)| snapshot.intersects_fold(range.start));
-                        *entry = state;
+                if let Some(state) = self.patches.get_mut(&range) {
+                    replaced_blocks.insert(state.footer_block_id, render_block);
+                    if let Some(editor_state) = &state.editor {
+                        if editor_state.opened_patch != patch {
+                            state.update_task = Some({
+                                let this = this.clone();
+                                cx.spawn(|_, cx| async move {
+                                    Self::update_patch_editor(this.clone(), patch, cx)
+                                        .await
+                                        .log_err();
+                                })
+                            });
+                        }
                     }
-                }
+                } else {
+                    let block_ids = editor.insert_blocks(
+                        [BlockProperties {
+                            position: patch_start,
+                            height: path_count as u32 + 1,
+                            style: BlockStyle::Flex,
+                            render: render_block,
+                            disposition: BlockDisposition::Below,
+                            priority: 0,
+                        }],
+                        None,
+                        cx,
+                    );
 
-                editor.unfold_ranges(
-                    [header_range.clone()]
-                        .into_iter()
-                        .chain(footer_range.clone()),
-                    true,
-                    false,
-                    cx,
-                );
+                    let new_crease_ids = editor.insert_creases(
+                        [Crease::new(
+                            patch_start..patch_end,
+                            header_placeholder.clone(),
+                            fold_toggle("patch-header"),
+                            |_, _, _| Empty.into_any_element(),
+                        )],
+                        cx,
+                    );
 
-                if !folds_to_insert.is_empty() {
-                    editor.fold_ranges(folds_to_insert, false, cx);
+                    self.patches.insert(
+                        range.clone(),
+                        PatchViewState {
+                            footer_block_id: block_ids[0],
+                            crease_id: new_crease_ids[0],
+                            editor: None,
+                            update_task: None,
+                        },
+                    );
                 }
+
+                editor.unfold_ranges([patch_start..patch_end], true, false, cx);
+                editor.fold_ranges([(patch_start..patch_end, header_placeholder)], false, cx);
             }
 
             editor.remove_creases(removed_crease_ids, cx);
             editor.remove_blocks(removed_block_ids, None, cx);
+            editor.replace_blocks(replaced_blocks, None, cx);
         });
 
-        for (editor, editor_was_open) in editors_to_close {
-            self.close_workflow_editor(cx, editor, editor_was_open);
+        for editor in editors_to_close {
+            self.close_patch_editor(editor, cx);
         }
 
-        self.update_active_workflow_step(cx);
+        self.update_active_patch(cx);
     }
 
     fn insert_slash_command_output_sections(
@@ -2604,87 +2354,75 @@ impl ContextEditor {
             }
             EditorEvent::SelectionsChanged { .. } => {
                 self.scroll_position = self.cursor_scroll_position(cx);
-                self.update_active_workflow_step(cx);
+                self.update_active_patch(cx);
             }
             _ => {}
         }
         cx.emit(event.clone());
     }
 
-    fn active_workflow_step(&self) -> Option<(Range<text::Anchor>, &WorkflowStepViewState)> {
-        let step = self.active_workflow_step.as_ref()?;
-        Some((step.range.clone(), self.workflow_steps.get(&step.range)?))
+    fn active_patch(&self) -> Option<(Range<text::Anchor>, &PatchViewState)> {
+        let patch = self.active_patch.as_ref()?;
+        Some((patch.clone(), self.patches.get(&patch)?))
     }
 
-    fn update_active_workflow_step(&mut self, cx: &mut ViewContext<Self>) {
-        let newest_cursor = self.editor.read(cx).selections.newest::<usize>(cx).head();
+    fn update_active_patch(&mut self, cx: &mut ViewContext<Self>) {
+        let newest_cursor = self.editor.read(cx).selections.newest::<Point>(cx).head();
         let context = self.context.read(cx);
 
-        let new_step = context
-            .workflow_step_containing(newest_cursor, cx)
-            .map(|step| ActiveWorkflowStep {
-                resolved: step.resolution.is_some(),
-                range: step.range.clone(),
-            });
-
-        if new_step.as_ref() != self.active_workflow_step.as_ref() {
-            let mut old_editor = None;
-            let mut old_editor_was_open = None;
-            if let Some(old_step) = self.active_workflow_step.take() {
-                (old_editor, old_editor_was_open) =
-                    self.hide_workflow_step(old_step.range, cx).unzip();
-            }
+        let new_patch = context.patch_containing(newest_cursor, cx).cloned();
 
-            let mut new_editor = None;
-            if let Some(new_step) = new_step {
-                new_editor = self.show_workflow_step(new_step.range.clone(), cx);
-                self.active_workflow_step = Some(new_step);
-            }
+        if new_patch.as_ref().map(|p| &p.range) == self.active_patch.as_ref() {
+            return;
+        }
 
-            if new_editor != old_editor {
-                if let Some((old_editor, old_editor_was_open)) = old_editor.zip(old_editor_was_open)
-                {
-                    self.close_workflow_editor(cx, old_editor, old_editor_was_open)
+        if let Some(old_patch_range) = self.active_patch.take() {
+            if let Some(patch_state) = self.patches.get_mut(&old_patch_range) {
+                if let Some(state) = patch_state.editor.take() {
+                    if let Some(editor) = state.editor.upgrade() {
+                        self.close_patch_editor(editor, cx);
+                    }
                 }
             }
         }
-    }
 
-    fn hide_workflow_step(
-        &mut self,
-        step_range: Range<language::Anchor>,
-        cx: &mut ViewContext<Self>,
-    ) -> Option<(View<Editor>, bool)> {
-        if let Some(step) = self.workflow_steps.get_mut(&step_range) {
-            let assist = step.assist.as_ref()?;
-            let editor = assist.editor.upgrade()?;
-
-            if matches!(step.status(cx), WorkflowStepStatus::Idle) {
-                let assist = step.assist.take().unwrap();
-                InlineAssistant::update_global(cx, |assistant, cx| {
-                    for assist_id in assist.assist_ids {
-                        assistant.finish_assist(assist_id, true, cx)
+        if let Some(new_patch) = new_patch {
+            self.active_patch = Some(new_patch.range.clone());
+
+            if let Some(patch_state) = self.patches.get_mut(&new_patch.range) {
+                let mut editor = None;
+                if let Some(state) = &patch_state.editor {
+                    if let Some(opened_editor) = state.editor.upgrade() {
+                        editor = Some(opened_editor);
                     }
-                });
-                return Some((editor, assist.editor_was_open));
+                }
+
+                if let Some(editor) = editor {
+                    self.workspace
+                        .update(cx, |workspace, cx| {
+                            workspace.activate_item(&editor, true, false, cx);
+                        })
+                        .ok();
+                } else {
+                    patch_state.update_task = Some(cx.spawn(move |this, cx| async move {
+                        Self::open_patch_editor(this, new_patch, cx).await.log_err();
+                    }));
+                }
             }
         }
-
-        None
     }
 
-    fn close_workflow_editor(
+    fn close_patch_editor(
         &mut self,
+        editor: View<ProposedChangesEditor>,
         cx: &mut ViewContext<ContextEditor>,
-        editor: View<Editor>,
-        editor_was_open: bool,
     ) {
         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 !editor_was_open && !editor.read(cx).is_focused(cx) {
+                        if !editor.read(cx).focus_handle(cx).is_focused(cx) {
                             pane.close_item_by_id(item_id, SaveIntent::Skip, cx)
                                 .detach_and_log_err(cx);
                         }
@@ -2694,190 +2432,94 @@ impl ContextEditor {
             .ok();
     }
 
-    fn show_workflow_step(
-        &mut self,
-        step_range: Range<language::Anchor>,
-        cx: &mut ViewContext<Self>,
-    ) -> Option<View<Editor>> {
-        let step = self.workflow_steps.get_mut(&step_range)?;
-
-        let mut editor_to_return = None;
-        let mut scroll_to_assist_id = None;
-        match step.status(cx) {
-            WorkflowStepStatus::Idle => {
-                if let Some(assist) = step.assist.as_ref() {
-                    scroll_to_assist_id = assist.assist_ids.first().copied();
-                } else if let Some(Ok(resolved)) = step.resolution.clone().as_deref() {
-                    step.assist = Self::open_assists_for_step(
-                        &resolved,
-                        &self.project,
-                        &self.assistant_panel,
-                        &self.workspace,
-                        cx,
-                    );
-                    editor_to_return = step
-                        .assist
-                        .as_ref()
-                        .and_then(|assist| assist.editor.upgrade());
-                }
-            }
-            WorkflowStepStatus::Pending => {
-                if let Some(assist) = step.assist.as_ref() {
-                    let assistant = InlineAssistant::global(cx);
-                    scroll_to_assist_id = assist
-                        .assist_ids
-                        .iter()
-                        .copied()
-                        .find(|assist_id| assistant.assist_status(*assist_id, cx).is_pending());
-                }
-            }
-            WorkflowStepStatus::Done => {
-                if let Some(assist) = step.assist.as_ref() {
-                    scroll_to_assist_id = assist.assist_ids.first().copied();
-                }
-            }
-            _ => {}
-        }
+    async fn open_patch_editor(
+        this: WeakView<Self>,
+        patch: AssistantPatch,
+        mut cx: AsyncWindowContext,
+    ) -> Result<()> {
+        let project = this.update(&mut cx, |this, _| this.project.clone())?;
+        let resolved_patch = patch.resolve(project.clone(), &mut cx).await;
 
-        if let Some(assist_id) = scroll_to_assist_id {
-            if let Some(assist_editor) = step
-                .assist
-                .as_ref()
-                .and_then(|assists| assists.editor.upgrade())
-            {
-                editor_to_return = Some(assist_editor.clone());
-                self.workspace
-                    .update(cx, |workspace, cx| {
-                        workspace.activate_item(&assist_editor, false, false, cx);
+        let editor = cx.new_view(|cx| {
+            let editor = ProposedChangesEditor::new(
+                patch.title.clone(),
+                resolved_patch
+                    .edit_groups
+                    .iter()
+                    .map(|(buffer, groups)| ProposedChangeLocation {
+                        buffer: buffer.clone(),
+                        ranges: groups
+                            .iter()
+                            .map(|group| group.context_range.clone())
+                            .collect(),
                     })
-                    .ok();
-                InlineAssistant::update_global(cx, |assistant, cx| {
-                    assistant.scroll_to_assist(assist_id, cx)
+                    .collect(),
+                Some(project.clone()),
+                cx,
+            );
+            resolved_patch.apply(&editor, cx);
+            editor
+        })?;
+
+        this.update(&mut cx, |this, cx| {
+            if let Some(patch_state) = this.patches.get_mut(&patch.range) {
+                patch_state.editor = Some(PatchEditorState {
+                    editor: editor.downgrade(),
+                    opened_patch: patch,
                 });
+                patch_state.update_task.take();
             }
-        }
-
-        editor_to_return
-    }
-
-    fn open_assists_for_step(
-        resolved_step: &WorkflowStepResolution,
-        project: &Model<Project>,
-        assistant_panel: &WeakView<AssistantPanel>,
-        workspace: &WeakView<Workspace>,
-        cx: &mut ViewContext<Self>,
-    ) -> Option<WorkflowAssist> {
-        let assistant_panel = assistant_panel.upgrade()?;
-        if resolved_step.suggestion_groups.is_empty() {
-            return None;
-        }
 
-        let editor;
-        let mut editor_was_open = false;
-        let mut suggestion_groups = Vec::new();
-        if resolved_step.suggestion_groups.len() == 1
-            && resolved_step
-                .suggestion_groups
-                .values()
-                .next()
-                .unwrap()
-                .len()
-                == 1
-        {
-            // If there's only one buffer and one suggestion group, open it directly
-            let (buffer, groups) = resolved_step.suggestion_groups.iter().next().unwrap();
-            let group = groups.into_iter().next().unwrap();
-            editor = workspace
+            this.workspace
                 .update(cx, |workspace, cx| {
-                    let active_pane = workspace.active_pane().clone();
-                    editor_was_open =
-                        workspace.is_project_item_open::<Editor>(&active_pane, buffer, cx);
-                    workspace.open_project_item::<Editor>(
-                        active_pane,
-                        buffer.clone(),
-                        false,
-                        false,
-                        cx,
-                    )
+                    workspace.add_item_to_active_pane(Box::new(editor.clone()), None, false, cx)
                 })
-                .log_err()?;
-            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()
-                };
+                .log_err();
+        })?;
 
-                editor.set_scroll_anchor(
-                    ScrollAnchor {
-                        offset: gpui::Point::default(),
-                        anchor,
-                    },
-                    cx,
-                );
-            });
+        Ok(())
+    }
 
-            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 mut multibuffer =
-                    MultiBuffer::new(Capability::ReadWrite).with_title(resolved_step.title.clone());
-                for (buffer, groups) in &resolved_step.suggestion_groups {
-                    let excerpt_ids = multibuffer.push_excerpts(
-                        buffer.clone(),
-                        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
-            });
+    async fn update_patch_editor(
+        this: WeakView<Self>,
+        patch: AssistantPatch,
+        mut cx: AsyncWindowContext,
+    ) -> Result<()> {
+        let project = this.update(&mut cx, |this, _| this.project.clone())?;
+        let resolved_patch = patch.resolve(project.clone(), &mut cx).await;
+        this.update(&mut cx, |this, cx| {
+            let patch_state = this.patches.get_mut(&patch.range)?;
 
-            editor = cx.new_view(|cx| {
-                Editor::for_multibuffer(multibuffer, Some(project.clone()), true, cx)
-            });
-            workspace
-                .update(cx, |workspace, cx| {
-                    workspace.add_item_to_active_pane(Box::new(editor.clone()), None, false, cx)
+            let locations = resolved_patch
+                .edit_groups
+                .iter()
+                .map(|(buffer, groups)| ProposedChangeLocation {
+                    buffer: buffer.clone(),
+                    ranges: groups
+                        .iter()
+                        .map(|group| group.context_range.clone())
+                        .collect(),
                 })
-                .log_err()?;
-        }
+                .collect();
 
-        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,
-                    workspace,
-                    &assistant_panel,
-                    cx,
-                ));
+            if let Some(state) = &mut patch_state.editor {
+                if let Some(editor) = state.editor.upgrade() {
+                    editor.update(cx, |editor, cx| {
+                        editor.set_title(patch.title.clone(), cx);
+                        editor.reset_locations(locations, cx);
+                        resolved_patch.apply(editor, cx);
+                    });
+
+                    state.opened_patch = patch;
+                } else {
+                    patch_state.editor.take();
+                }
             }
-        }
+            patch_state.update_task.take();
 
-        Some(WorkflowAssist {
-            assist_ids,
-            editor: editor.downgrade(),
-            editor_was_open,
-        })
+            Some(())
+        })?;
+        Ok(())
     }
 
     fn handle_editor_search_event(

crates/assistant/src/assistant_settings.rs 🔗

@@ -2,6 +2,7 @@ use std::sync::Arc;
 
 use ::open_ai::Model as OpenAiModel;
 use anthropic::Model as AnthropicModel;
+use feature_flags::FeatureFlagAppExt;
 use fs::Fs;
 use gpui::{AppContext, Pixels};
 use language_model::provider::open_ai;
@@ -61,6 +62,13 @@ pub struct AssistantSettings {
     pub default_model: LanguageModelSelection,
     pub inline_alternatives: Vec<LanguageModelSelection>,
     pub using_outdated_settings_version: bool,
+    pub enable_experimental_live_diffs: bool,
+}
+
+impl AssistantSettings {
+    pub fn are_live_diffs_enabled(&self, cx: &AppContext) -> bool {
+        cx.is_staff() || self.enable_experimental_live_diffs
+    }
 }
 
 /// Assistant panel settings
@@ -238,6 +246,7 @@ impl AssistantSettingsContent {
                             }
                         }),
                     inline_alternatives: None,
+                    enable_experimental_live_diffs: None,
                 },
                 VersionedAssistantSettingsContent::V2(settings) => settings.clone(),
             },
@@ -257,6 +266,7 @@ impl AssistantSettingsContent {
                         .to_string(),
                 }),
                 inline_alternatives: None,
+                enable_experimental_live_diffs: None,
             },
         }
     }
@@ -373,6 +383,7 @@ impl Default for VersionedAssistantSettingsContent {
             default_height: None,
             default_model: None,
             inline_alternatives: None,
+            enable_experimental_live_diffs: None,
         })
     }
 }
@@ -403,6 +414,10 @@ pub struct AssistantSettingsContentV2 {
     default_model: Option<LanguageModelSelection>,
     /// Additional models with which to generate alternatives when performing inline assists.
     inline_alternatives: Option<Vec<LanguageModelSelection>>,
+    /// Enable experimental live diffs in the assistant panel.
+    ///
+    /// Default: false
+    enable_experimental_live_diffs: Option<bool>,
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
@@ -525,7 +540,10 @@ impl Settings for AssistantSettings {
             );
             merge(&mut settings.default_model, value.default_model);
             merge(&mut settings.inline_alternatives, value.inline_alternatives);
-            // merge(&mut settings.infer_context, value.infer_context); TODO re-enable this once we ship context inference
+            merge(
+                &mut settings.enable_experimental_live_diffs,
+                value.enable_experimental_live_diffs,
+            );
         }
 
         Ok(settings)
@@ -584,6 +602,7 @@ mod tests {
                             dock: None,
                             default_width: None,
                             default_height: None,
+                            enable_experimental_live_diffs: None,
                         }),
                     )
                 },

crates/assistant/src/context.rs 🔗

@@ -2,8 +2,8 @@
 mod context_tests;
 
 use crate::{
-    prompts::PromptBuilder, slash_command::SlashCommandLine, MessageId, MessageStatus,
-    WorkflowStep, WorkflowStepEdit, WorkflowStepResolution, WorkflowSuggestionGroup,
+    prompts::PromptBuilder, slash_command::SlashCommandLine, AssistantEdit, AssistantPatch,
+    AssistantPatchStatus, MessageId, MessageStatus,
 };
 use anyhow::{anyhow, Context as _, Result};
 use assistant_slash_command::{
@@ -15,13 +15,10 @@ use clock::ReplicaId;
 use collections::{HashMap, HashSet};
 use feature_flags::{FeatureFlag, FeatureFlagAppExt};
 use fs::{Fs, RemoveOptions};
-use futures::{
-    future::{self, Shared},
-    FutureExt, StreamExt,
-};
+use futures::{future::Shared, FutureExt, StreamExt};
 use gpui::{
-    AppContext, AsyncAppContext, Context as _, EventEmitter, Model, ModelContext, RenderImage,
-    SharedString, Subscription, Task,
+    AppContext, Context as _, EventEmitter, Model, ModelContext, RenderImage, SharedString,
+    Subscription, Task,
 };
 
 use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, Point, ToOffset};
@@ -38,7 +35,7 @@ use project::Project;
 use serde::{Deserialize, Serialize};
 use smallvec::SmallVec;
 use std::{
-    cmp::{self, max, Ordering},
+    cmp::{max, Ordering},
     fmt::Debug,
     iter, mem,
     ops::Range,
@@ -300,7 +297,7 @@ pub enum ContextEvent {
     MessagesEdited,
     SummaryChanged,
     StreamedCompletion,
-    WorkflowStepsUpdated {
+    PatchesUpdated {
         removed: Vec<Range<language::Anchor>>,
         updated: Vec<Range<language::Anchor>>,
     },
@@ -454,13 +451,14 @@ pub struct XmlTag {
 #[derive(Copy, Clone, Debug, strum::EnumString, PartialEq, Eq, strum::AsRefStr)]
 #[strum(serialize_all = "snake_case")]
 pub enum XmlTagKind {
-    Step,
+    Patch,
+    Title,
     Edit,
     Path,
-    Search,
-    Within,
-    Operation,
     Description,
+    OldText,
+    NewText,
+    Operation,
 }
 
 pub struct Context {
@@ -490,7 +488,7 @@ pub struct Context {
     _subscriptions: Vec<Subscription>,
     telemetry: Option<Arc<Telemetry>>,
     language_registry: Arc<LanguageRegistry>,
-    workflow_steps: Vec<WorkflowStep>,
+    patches: Vec<AssistantPatch>,
     xml_tags: Vec<XmlTag>,
     project: Option<Model<Project>>,
     prompt_builder: Arc<PromptBuilder>,
@@ -506,7 +504,7 @@ impl ContextAnnotation for PendingSlashCommand {
     }
 }
 
-impl ContextAnnotation for WorkflowStep {
+impl ContextAnnotation for AssistantPatch {
     fn range(&self) -> &Range<language::Anchor> {
         &self.range
     }
@@ -591,7 +589,7 @@ impl Context {
             telemetry,
             project,
             language_registry,
-            workflow_steps: Vec::new(),
+            patches: Vec::new(),
             xml_tags: Vec::new(),
             prompt_builder,
         };
@@ -929,48 +927,49 @@ impl Context {
         self.summary.as_ref()
     }
 
-    pub(crate) fn workflow_step_containing(
+    pub(crate) fn patch_containing(
         &self,
-        offset: usize,
+        position: Point,
         cx: &AppContext,
-    ) -> Option<&WorkflowStep> {
+    ) -> Option<&AssistantPatch> {
         let buffer = self.buffer.read(cx);
-        let index = self
-            .workflow_steps
-            .binary_search_by(|step| {
-                let step_range = step.range.to_offset(&buffer);
-                if offset < step_range.start {
-                    Ordering::Greater
-                } else if offset > step_range.end {
-                    Ordering::Less
-                } else {
-                    Ordering::Equal
-                }
-            })
-            .ok()?;
-        Some(&self.workflow_steps[index])
+        let index = self.patches.binary_search_by(|patch| {
+            let patch_range = patch.range.to_point(&buffer);
+            if position < patch_range.start {
+                Ordering::Greater
+            } else if position > patch_range.end {
+                Ordering::Less
+            } else {
+                Ordering::Equal
+            }
+        });
+        if let Ok(ix) = index {
+            Some(&self.patches[ix])
+        } else {
+            None
+        }
     }
 
-    pub fn workflow_step_ranges(&self) -> impl Iterator<Item = Range<language::Anchor>> + '_ {
-        self.workflow_steps.iter().map(|step| step.range.clone())
+    pub fn patch_ranges(&self) -> impl Iterator<Item = Range<language::Anchor>> + '_ {
+        self.patches.iter().map(|patch| patch.range.clone())
     }
 
-    pub(crate) fn workflow_step_for_range(
+    pub(crate) fn patch_for_range(
         &self,
         range: &Range<language::Anchor>,
         cx: &AppContext,
-    ) -> Option<&WorkflowStep> {
+    ) -> Option<&AssistantPatch> {
         let buffer = self.buffer.read(cx);
-        let index = self.workflow_step_index_for_range(range, buffer).ok()?;
-        Some(&self.workflow_steps[index])
+        let index = self.patch_index_for_range(range, buffer).ok()?;
+        Some(&self.patches[index])
     }
 
-    fn workflow_step_index_for_range(
+    fn patch_index_for_range(
         &self,
         tagged_range: &Range<text::Anchor>,
         buffer: &text::BufferSnapshot,
     ) -> Result<usize, usize> {
-        self.workflow_steps
+        self.patches
             .binary_search_by(|probe| probe.range.cmp(&tagged_range, buffer))
     }
 
@@ -1018,8 +1017,6 @@ impl Context {
             language::BufferEvent::Edited => {
                 self.count_remaining_tokens(cx);
                 self.reparse(cx);
-                // Use `inclusive = true` to invalidate a step when an edit occurs
-                // at the start/end of a parsed step.
                 cx.emit(ContextEvent::MessagesEdited);
             }
             _ => {}
@@ -1248,8 +1245,8 @@ impl Context {
 
         let mut removed_slash_command_ranges = Vec::new();
         let mut updated_slash_commands = Vec::new();
-        let mut removed_steps = Vec::new();
-        let mut updated_steps = Vec::new();
+        let mut removed_patches = Vec::new();
+        let mut updated_patches = Vec::new();
         while let Some(mut row_range) = row_ranges.next() {
             while let Some(next_row_range) = row_ranges.peek() {
                 if row_range.end >= next_row_range.start {
@@ -1273,11 +1270,11 @@ impl Context {
                 &mut removed_slash_command_ranges,
                 cx,
             );
-            self.reparse_workflow_steps_in_range(
+            self.reparse_patches_in_range(
                 start..end,
                 &buffer,
-                &mut updated_steps,
-                &mut removed_steps,
+                &mut updated_patches,
+                &mut removed_patches,
                 cx,
             );
         }
@@ -1289,10 +1286,10 @@ impl Context {
             });
         }
 
-        if !updated_steps.is_empty() || !removed_steps.is_empty() {
-            cx.emit(ContextEvent::WorkflowStepsUpdated {
-                removed: removed_steps,
-                updated: updated_steps,
+        if !updated_patches.is_empty() || !removed_patches.is_empty() {
+            cx.emit(ContextEvent::PatchesUpdated {
+                removed: removed_patches,
+                updated: updated_patches,
             });
         }
     }
@@ -1354,7 +1351,7 @@ impl Context {
         removed.extend(removed_commands.map(|command| command.source_range));
     }
 
-    fn reparse_workflow_steps_in_range(
+    fn reparse_patches_in_range(
         &mut self,
         range: Range<text::Anchor>,
         buffer: &BufferSnapshot,
@@ -1369,41 +1366,32 @@ impl Context {
         self.xml_tags
             .splice(intersecting_tags_range.clone(), new_tags);
 
-        // Find which steps intersect the changed range.
-        let intersecting_steps_range =
-            self.indices_intersecting_buffer_range(&self.workflow_steps, range.clone(), cx);
+        // Find which patches intersect the changed range.
+        let intersecting_patches_range =
+            self.indices_intersecting_buffer_range(&self.patches, range.clone(), cx);
 
-        // Reparse all tags after the last unchanged step before the change.
+        // Reparse all tags after the last unchanged patch before the change.
         let mut tags_start_ix = 0;
-        if let Some(preceding_unchanged_step) =
-            self.workflow_steps[..intersecting_steps_range.start].last()
+        if let Some(preceding_unchanged_patch) =
+            self.patches[..intersecting_patches_range.start].last()
         {
             tags_start_ix = match self.xml_tags.binary_search_by(|tag| {
                 tag.range
                     .start
-                    .cmp(&preceding_unchanged_step.range.end, buffer)
+                    .cmp(&preceding_unchanged_patch.range.end, buffer)
                     .then(Ordering::Less)
             }) {
                 Ok(ix) | Err(ix) => ix,
             };
         }
 
-        // Rebuild the edit suggestions in the range.
-        let mut new_steps = self.parse_steps(tags_start_ix, range.end, buffer);
-
-        if let Some(project) = self.project() {
-            for step in &mut new_steps {
-                Self::resolve_workflow_step_internal(step, &project, cx);
-            }
-        }
-
-        updated.extend(new_steps.iter().map(|step| step.range.clone()));
-        let removed_steps = self
-            .workflow_steps
-            .splice(intersecting_steps_range, new_steps);
+        // Rebuild the patches in the range.
+        let new_patches = self.parse_patches(tags_start_ix, range.end, buffer, cx);
+        updated.extend(new_patches.iter().map(|patch| patch.range.clone()));
+        let removed_patches = self.patches.splice(intersecting_patches_range, new_patches);
         removed.extend(
-            removed_steps
-                .map(|step| step.range)
+            removed_patches
+                .map(|patch| patch.range)
                 .filter(|range| !updated.contains(&range)),
         );
     }
@@ -1464,60 +1452,95 @@ impl Context {
         tags
     }
 
-    fn parse_steps(
+    fn parse_patches(
         &mut self,
         tags_start_ix: usize,
         buffer_end: text::Anchor,
         buffer: &BufferSnapshot,
-    ) -> Vec<WorkflowStep> {
-        let mut new_steps = Vec::new();
-        let mut pending_step = None;
-        let mut edit_step_depth = 0;
+        cx: &AppContext,
+    ) -> Vec<AssistantPatch> {
+        let mut new_patches = Vec::new();
+        let mut pending_patch = None;
+        let mut patch_tag_depth = 0;
         let mut tags = self.xml_tags[tags_start_ix..].iter().peekable();
         'tags: while let Some(tag) = tags.next() {
-            if tag.range.start.cmp(&buffer_end, buffer).is_gt() && edit_step_depth == 0 {
+            if tag.range.start.cmp(&buffer_end, buffer).is_gt() && patch_tag_depth == 0 {
                 break;
             }
 
-            if tag.kind == XmlTagKind::Step && tag.is_open_tag {
-                edit_step_depth += 1;
-                let edit_start = tag.range.start;
-                let mut edits = Vec::new();
-                let mut step = WorkflowStep {
-                    range: edit_start..edit_start,
-                    leading_tags_end: tag.range.end,
-                    trailing_tag_start: None,
+            if tag.kind == XmlTagKind::Patch && tag.is_open_tag {
+                patch_tag_depth += 1;
+                let patch_start = tag.range.start;
+                let mut edits = Vec::<Result<AssistantEdit>>::new();
+                let mut patch = AssistantPatch {
+                    range: patch_start..patch_start,
+                    title: String::new().into(),
                     edits: Default::default(),
-                    resolution: None,
-                    resolution_task: None,
+                    status: crate::AssistantPatchStatus::Pending,
                 };
 
                 while let Some(tag) = tags.next() {
-                    step.trailing_tag_start.get_or_insert(tag.range.start);
-
-                    if tag.kind == XmlTagKind::Step && !tag.is_open_tag {
-                        // step.trailing_tag_start = Some(tag.range.start);
-                        edit_step_depth -= 1;
-                        if edit_step_depth == 0 {
-                            step.range.end = tag.range.end;
-                            step.edits = edits.into();
-                            new_steps.push(step);
+                    if tag.kind == XmlTagKind::Patch && !tag.is_open_tag {
+                        patch_tag_depth -= 1;
+                        if patch_tag_depth == 0 {
+                            patch.range.end = tag.range.end;
+
+                            // Include the line immediately after this <patch> tag if it's empty.
+                            let patch_end_offset = patch.range.end.to_offset(buffer);
+                            let mut patch_end_chars = buffer.chars_at(patch_end_offset);
+                            if patch_end_chars.next() == Some('\n')
+                                && patch_end_chars.next().map_or(true, |ch| ch == '\n')
+                            {
+                                let messages = self.messages_for_offsets(
+                                    [patch_end_offset, patch_end_offset + 1],
+                                    cx,
+                                );
+                                if messages.len() == 1 {
+                                    patch.range.end = buffer.anchor_before(patch_end_offset + 1);
+                                }
+                            }
+
+                            edits.sort_unstable_by(|a, b| {
+                                if let (Ok(a), Ok(b)) = (a, b) {
+                                    a.path.cmp(&b.path)
+                                } else {
+                                    Ordering::Equal
+                                }
+                            });
+                            patch.edits = edits.into();
+                            patch.status = AssistantPatchStatus::Ready;
+                            new_patches.push(patch);
                             continue 'tags;
                         }
                     }
 
+                    if tag.kind == XmlTagKind::Title && tag.is_open_tag {
+                        let content_start = tag.range.end;
+                        while let Some(tag) = tags.next() {
+                            if tag.kind == XmlTagKind::Title && !tag.is_open_tag {
+                                let content_end = tag.range.start;
+                                patch.title =
+                                    trimmed_text_in_range(buffer, content_start..content_end)
+                                        .into();
+                                break;
+                            }
+                        }
+                    }
+
                     if tag.kind == XmlTagKind::Edit && tag.is_open_tag {
                         let mut path = None;
-                        let mut search = None;
+                        let mut old_text = None;
+                        let mut new_text = None;
                         let mut operation = None;
                         let mut description = None;
 
                         while let Some(tag) = tags.next() {
                             if tag.kind == XmlTagKind::Edit && !tag.is_open_tag {
-                                edits.push(WorkflowStepEdit::new(
+                                edits.push(AssistantEdit::new(
                                     path,
                                     operation,
-                                    search,
+                                    old_text,
+                                    new_text,
                                     description,
                                 ));
                                 break;
@@ -1526,7 +1549,8 @@ impl Context {
                             if tag.is_open_tag
                                 && [
                                     XmlTagKind::Path,
-                                    XmlTagKind::Search,
+                                    XmlTagKind::OldText,
+                                    XmlTagKind::NewText,
                                     XmlTagKind::Operation,
                                     XmlTagKind::Description,
                                 ]
@@ -1538,15 +1562,18 @@ impl Context {
                                     if tag.kind == kind && !tag.is_open_tag {
                                         let tag = tags.next().unwrap();
                                         let content_end = tag.range.start;
-                                        let mut content = buffer
-                                            .text_for_range(content_start..content_end)
-                                            .collect::<String>();
-                                        content.truncate(content.trim_end().len());
+                                        let content = trimmed_text_in_range(
+                                            buffer,
+                                            content_start..content_end,
+                                        );
                                         match kind {
                                             XmlTagKind::Path => path = Some(content),
                                             XmlTagKind::Operation => operation = Some(content),
-                                            XmlTagKind::Search => {
-                                                search = Some(content).filter(|s| !s.is_empty())
+                                            XmlTagKind::OldText => {
+                                                old_text = Some(content).filter(|s| !s.is_empty())
+                                            }
+                                            XmlTagKind::NewText => {
+                                                new_text = Some(content).filter(|s| !s.is_empty())
                                             }
                                             XmlTagKind::Description => {
                                                 description =
@@ -1561,162 +1588,28 @@ impl Context {
                     }
                 }
 
-                pending_step = Some(step);
+                patch.edits = edits.into();
+                pending_patch = Some(patch);
             }
         }
 
-        if let Some(mut pending_step) = pending_step {
-            pending_step.range.end = text::Anchor::MAX;
-            new_steps.push(pending_step);
-        }
-
-        new_steps
-    }
-
-    pub fn resolve_workflow_step(
-        &mut self,
-        tagged_range: Range<text::Anchor>,
-        cx: &mut ModelContext<Self>,
-    ) -> Option<()> {
-        let index = self
-            .workflow_step_index_for_range(&tagged_range, self.buffer.read(cx))
-            .ok()?;
-        let step = &mut self.workflow_steps[index];
-        let project = self.project.as_ref()?;
-        step.resolution.take();
-        Self::resolve_workflow_step_internal(step, project, cx);
-        None
-    }
-
-    fn resolve_workflow_step_internal(
-        step: &mut WorkflowStep,
-        project: &Model<Project>,
-        cx: &mut ModelContext<'_, Context>,
-    ) {
-        step.resolution_task = Some(cx.spawn({
-            let range = step.range.clone();
-            let edits = step.edits.clone();
-            let project = project.clone();
-            |this, mut cx| async move {
-                let suggestion_groups =
-                    Self::compute_step_resolution(project, edits, &mut cx).await;
-
-                this.update(&mut cx, |this, cx| {
-                    let buffer = this.buffer.read(cx).text_snapshot();
-                    let ix = this.workflow_step_index_for_range(&range, &buffer).ok();
-                    if let Some(ix) = ix {
-                        let step = &mut this.workflow_steps[ix];
-
-                        let resolution = suggestion_groups.map(|suggestion_groups| {
-                            let mut title = String::new();
-                            for mut chunk in buffer.text_for_range(
-                                step.leading_tags_end
-                                    ..step.trailing_tag_start.unwrap_or(step.range.end),
-                            ) {
-                                if title.is_empty() {
-                                    chunk = chunk.trim_start();
-                                }
-                                if let Some((prefix, _)) = chunk.split_once('\n') {
-                                    title.push_str(prefix);
-                                    break;
-                                } else {
-                                    title.push_str(chunk);
-                                }
-                            }
-
-                            WorkflowStepResolution {
-                                title,
-                                suggestion_groups,
-                            }
-                        });
-
-                        step.resolution = Some(Arc::new(resolution));
-                        cx.emit(ContextEvent::WorkflowStepsUpdated {
-                            removed: vec![],
-                            updated: vec![range],
-                        })
-                    }
-                })
-                .ok();
-            }
-        }));
-    }
-
-    async fn compute_step_resolution(
-        project: Model<Project>,
-        edits: Arc<[Result<WorkflowStepEdit>]>,
-        cx: &mut AsyncAppContext,
-    ) -> Result<HashMap<Model<Buffer>, Vec<WorkflowSuggestionGroup>>> {
-        let mut suggestion_tasks = Vec::new();
-        for edit in edits.iter() {
-            let edit = edit.as_ref().map_err(|e| anyhow!("{e}"))?;
-            suggestion_tasks.push(edit.resolve(project.clone(), cx.clone()));
-        }
-
-        // Expand the context ranges of each suggestion and group suggestions with overlapping context ranges.
-        let suggestions = future::try_join_all(suggestion_tasks).await?;
-
-        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::<WorkflowSuggestionGroup>::new();
-            let snapshot = buffer.update(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(WorkflowSuggestionGroup {
-                            context_range,
-                            suggestions: vec![suggestion],
-                        });
-                    }
+        if let Some(mut pending_patch) = pending_patch {
+            let patch_start = pending_patch.range.start.to_offset(buffer);
+            if let Some(message) = self.message_for_offset(patch_start, cx) {
+                if message.anchor_range.end == text::Anchor::MAX {
+                    pending_patch.range.end = text::Anchor::MAX;
                 } else {
-                    // Create the first group
-                    suggestion_groups.push(WorkflowSuggestionGroup {
-                        context_range,
-                        suggestions: vec![suggestion],
-                    });
+                    let message_end = buffer.anchor_after(message.offset_range.end - 1);
+                    pending_patch.range.end = message_end;
                 }
+            } else {
+                pending_patch.range.end = text::Anchor::MAX;
             }
 
-            suggestion_groups_by_buffer.insert(buffer, suggestion_groups);
+            new_patches.push(pending_patch);
         }
 
-        Ok(suggestion_groups_by_buffer)
+        new_patches
     }
 
     pub fn pending_command_for_position(
@@ -2315,11 +2208,11 @@ impl Context {
         let mut updated = Vec::new();
         let mut removed = Vec::new();
         for range in ranges {
-            self.reparse_workflow_steps_in_range(range, &buffer, &mut updated, &mut removed, cx);
+            self.reparse_patches_in_range(range, &buffer, &mut updated, &mut removed, cx);
         }
 
         if !updated.is_empty() || !removed.is_empty() {
-            cx.emit(ContextEvent::WorkflowStepsUpdated { removed, updated })
+            cx.emit(ContextEvent::PatchesUpdated { removed, updated })
         }
     }
 
@@ -2825,6 +2718,24 @@ impl Context {
     }
 }
 
+fn trimmed_text_in_range(buffer: &BufferSnapshot, range: Range<text::Anchor>) -> String {
+    let mut is_start = true;
+    let mut content = buffer
+        .text_for_range(range)
+        .map(|mut chunk| {
+            if is_start {
+                chunk = chunk.trim_start_matches('\n');
+                if !chunk.is_empty() {
+                    is_start = false;
+                }
+            }
+            chunk
+        })
+        .collect::<String>();
+    content.truncate(content.trim_end().len());
+    content
+}
+
 #[derive(Debug, Default)]
 pub struct ContextVersion {
     context: clock::Global,

crates/assistant/src/context/context_tests.rs 🔗

@@ -1,8 +1,7 @@
-use super::{MessageCacheMetadata, WorkflowStepEdit};
+use super::{AssistantEdit, MessageCacheMetadata};
 use crate::{
-    assistant_panel, prompt_library, slash_command::file_command, CacheStatus, Context,
-    ContextEvent, ContextId, ContextOperation, MessageId, MessageStatus, PromptBuilder,
-    WorkflowStepEditKind,
+    assistant_panel, prompt_library, slash_command::file_command, AssistantEditKind, CacheStatus,
+    Context, ContextEvent, ContextId, ContextOperation, MessageId, MessageStatus, PromptBuilder,
 };
 use anyhow::Result;
 use assistant_slash_command::{
@@ -15,6 +14,7 @@ use gpui::{AppContext, Model, SharedString, Task, TestAppContext, WeakView};
 use language::{Buffer, BufferSnapshot, LanguageRegistry, LspAdapterDelegate};
 use language_model::{LanguageModelCacheConfiguration, LanguageModelRegistry, Role};
 use parking_lot::Mutex;
+use pretty_assertions::assert_eq;
 use project::Project;
 use rand::prelude::*;
 use serde_json::json;
@@ -478,7 +478,15 @@ async fn test_slash_commands(cx: &mut TestAppContext) {
 #[gpui::test]
 async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
     cx.update(prompt_library::init);
-    let settings_store = cx.update(SettingsStore::test);
+    let mut settings_store = cx.update(SettingsStore::test);
+    cx.update(|cx| {
+        settings_store
+            .set_user_settings(
+                r#"{ "assistant": { "enable_experimental_live_diffs": true } }"#,
+                cx,
+            )
+            .unwrap()
+    });
     cx.set_global(settings_store);
     cx.update(language::init);
     cx.update(Project::init_settings);
@@ -520,7 +528,7 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
         »",
         cx,
     );
-    expect_steps(
+    expect_patches(
         &context,
         "
 
@@ -539,17 +547,17 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
         one
         two
         «
-        <step»",
+        <patch»",
         cx,
     );
-    expect_steps(
+    expect_patches(
         &context,
         "
 
         one
         two
 
-        <step",
+        <patch",
         &[],
         cx,
     );
@@ -563,36 +571,24 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
         one
         two
 
-        <step«>
-        Add a second function
-
-        ```rust
-        fn two() {}
-        ```
-
+        <patch«>
         <edit>»",
         cx,
     );
-    expect_steps(
+    expect_patches(
         &context,
         "
 
         one
         two
 
-        «<step>
-        Add a second function
-
-        ```rust
-        fn two() {}
-        ```
-
+        «<patch>
         <edit>»",
         &[&[]],
         cx,
     );
 
-    // The full suggestion is added
+    // The full patch is added
     edit(
         &context,
         "
@@ -600,51 +596,46 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
         one
         two
 
-        <step>
-        Add a second function
-
-        ```rust
-        fn two() {}
-        ```
-
+        <patch>
         <edit>«
+        <description>add a `two` function</description>
         <path>src/lib.rs</path>
         <operation>insert_after</operation>
-        <search>fn one</search>
-        <description>add a `two` function</description>
+        <old_text>fn one</old_text>
+        <new_text>
+        fn two() {}
+        </new_text>
         </edit>
-        </step>
+        </patch>
 
         also,»",
         cx,
     );
-    expect_steps(
+    expect_patches(
         &context,
         "
 
         one
         two
 
-        «<step>
-        Add a second function
-
-        ```rust
-        fn two() {}
-        ```
-
+        «<patch>
         <edit>
+        <description>add a `two` function</description>
         <path>src/lib.rs</path>
         <operation>insert_after</operation>
-        <search>fn one</search>
-        <description>add a `two` function</description>
+        <old_text>fn one</old_text>
+        <new_text>
+        fn two() {}
+        </new_text>
         </edit>
-        </step>»
-
+        </patch>
+        »
         also,",
-        &[&[WorkflowStepEdit {
+        &[&[AssistantEdit {
             path: "src/lib.rs".into(),
-            kind: WorkflowStepEditKind::InsertAfter {
-                search: "fn one".into(),
+            kind: AssistantEditKind::InsertAfter {
+                old_text: "fn one".into(),
+                new_text: "fn two() {}".into(),
                 description: "add a `two` function".into(),
             },
         }]],
@@ -659,51 +650,46 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
         one
         two
 
-        <step>
-        Add a second function
-
-        ```rust
-        fn two() {}
-        ```
-
+        <patch>
         <edit>
+        <description>add a `two` function</description>
         <path>src/lib.rs</path>
         <operation>insert_after</operation>
-        <search>«fn zero»</search>
-        <description>add a `two` function</description>
+        <old_text>«fn zero»</old_text>
+        <new_text>
+        fn two() {}
+        </new_text>
         </edit>
-        </step>
+        </patch>
 
         also,",
         cx,
     );
-    expect_steps(
+    expect_patches(
         &context,
         "
 
         one
         two
 
-        «<step>
-        Add a second function
-
-        ```rust
-        fn two() {}
-        ```
-
+        «<patch>
         <edit>
+        <description>add a `two` function</description>
         <path>src/lib.rs</path>
         <operation>insert_after</operation>
-        <search>fn zero</search>
-        <description>add a `two` function</description>
+        <old_text>fn zero</old_text>
+        <new_text>
+        fn two() {}
+        </new_text>
         </edit>
-        </step>»
-
+        </patch>
+        »
         also,",
-        &[&[WorkflowStepEdit {
+        &[&[AssistantEdit {
             path: "src/lib.rs".into(),
-            kind: WorkflowStepEditKind::InsertAfter {
-                search: "fn zero".into(),
+            kind: AssistantEditKind::InsertAfter {
+                old_text: "fn zero".into(),
+                new_text: "fn two() {}".into(),
                 description: "add a `two` function".into(),
             },
         }]],
@@ -715,27 +701,24 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
         context.cycle_message_roles(HashSet::from_iter([assistant_message_id]), cx);
         context.cycle_message_roles(HashSet::from_iter([assistant_message_id]), cx);
     });
-    expect_steps(
+    expect_patches(
         &context,
         "
 
         one
         two
 
-        <step>
-        Add a second function
-
-        ```rust
-        fn two() {}
-        ```
-
+        <patch>
         <edit>
+        <description>add a `two` function</description>
         <path>src/lib.rs</path>
         <operation>insert_after</operation>
-        <search>fn zero</search>
-        <description>add a `two` function</description>
+        <old_text>fn zero</old_text>
+        <new_text>
+        fn two() {}
+        </new_text>
         </edit>
-        </step>
+        </patch>
 
         also,",
         &[],
@@ -746,33 +729,31 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
     context.update(cx, |context, cx| {
         context.cycle_message_roles(HashSet::from_iter([assistant_message_id]), cx);
     });
-    expect_steps(
+    expect_patches(
         &context,
         "
 
         one
         two
 
-        «<step>
-        Add a second function
-
-        ```rust
-        fn two() {}
-        ```
-
+        «<patch>
         <edit>
+        <description>add a `two` function</description>
         <path>src/lib.rs</path>
         <operation>insert_after</operation>
-        <search>fn zero</search>
-        <description>add a `two` function</description>
+        <old_text>fn zero</old_text>
+        <new_text>
+        fn two() {}
+        </new_text>
         </edit>
-        </step>»
-
+        </patch>
+        »
         also,",
-        &[&[WorkflowStepEdit {
+        &[&[AssistantEdit {
             path: "src/lib.rs".into(),
-            kind: WorkflowStepEditKind::InsertAfter {
-                search: "fn zero".into(),
+            kind: AssistantEditKind::InsertAfter {
+                old_text: "fn zero".into(),
+                new_text: "fn two() {}".into(),
                 description: "add a `two` function".into(),
             },
         }]],
@@ -792,33 +773,31 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
             cx,
         )
     });
-    expect_steps(
+    expect_patches(
         &deserialized_context,
         "
 
         one
         two
 
-        «<step>
-        Add a second function
-
-        ```rust
-        fn two() {}
-        ```
-
+        «<patch>
         <edit>
+        <description>add a `two` function</description>
         <path>src/lib.rs</path>
         <operation>insert_after</operation>
-        <search>fn zero</search>
-        <description>add a `two` function</description>
+        <old_text>fn zero</old_text>
+        <new_text>
+        fn two() {}
+        </new_text>
         </edit>
-        </step>»
-
+        </patch>
+        »
         also,",
-        &[&[WorkflowStepEdit {
+        &[&[AssistantEdit {
             path: "src/lib.rs".into(),
-            kind: WorkflowStepEditKind::InsertAfter {
-                search: "fn zero".into(),
+            kind: AssistantEditKind::InsertAfter {
+                old_text: "fn zero".into(),
+                new_text: "fn two() {}".into(),
                 description: "add a `two` function".into(),
             },
         }]],
@@ -834,48 +813,58 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
         cx.executor().run_until_parked();
     }
 
-    fn expect_steps(
+    #[track_caller]
+    fn expect_patches(
         context: &Model<Context>,
         expected_marked_text: &str,
-        expected_suggestions: &[&[WorkflowStepEdit]],
+        expected_suggestions: &[&[AssistantEdit]],
         cx: &mut TestAppContext,
     ) {
-        context.update(cx, |context, cx| {
-            let expected_marked_text = expected_marked_text.unindent();
-            let (expected_text, expected_ranges) = marked_text_ranges(&expected_marked_text, false);
+        let expected_marked_text = expected_marked_text.unindent();
+        let (expected_text, _) = marked_text_ranges(&expected_marked_text, false);
+
+        let (buffer_text, ranges, patches) = context.update(cx, |context, cx| {
             context.buffer.read_with(cx, |buffer, _| {
-                assert_eq!(buffer.text(), expected_text);
                 let ranges = context
-                    .workflow_steps
+                    .patches
                     .iter()
                     .map(|entry| entry.range.to_offset(buffer))
                     .collect::<Vec<_>>();
-                let marked = generate_marked_text(&expected_text, &ranges, false);
-                assert_eq!(
-                    marked,
-                    expected_marked_text,
-                    "unexpected suggestion ranges. actual: {ranges:?}, expected: {expected_ranges:?}"
-                );
-                let suggestions = context
-                    .workflow_steps
-                    .iter()
-                    .map(|step| {
-                        step.edits
-                            .iter()
-                            .map(|edit| {
-                                let edit = edit.as_ref().unwrap();
-                                WorkflowStepEdit {
-                                    path: edit.path.clone(),
-                                    kind: edit.kind.clone(),
-                                }
-                            })
-                            .collect::<Vec<_>>()
-                    })
-                    .collect::<Vec<_>>();
-
-                assert_eq!(suggestions, expected_suggestions);
-            });
+                (
+                    buffer.text(),
+                    ranges,
+                    context
+                        .patches
+                        .iter()
+                        .map(|step| step.edits.clone())
+                        .collect::<Vec<_>>(),
+                )
+            })
         });
+
+        assert_eq!(buffer_text, expected_text);
+
+        let actual_marked_text = generate_marked_text(&expected_text, &ranges, false);
+        assert_eq!(actual_marked_text, expected_marked_text);
+
+        assert_eq!(
+            patches
+                .iter()
+                .map(|patch| {
+                    patch
+                        .iter()
+                        .map(|edit| {
+                            let edit = edit.as_ref().unwrap();
+                            AssistantEdit {
+                                path: edit.path.clone(),
+                                kind: edit.kind.clone(),
+                            }
+                        })
+                        .collect::<Vec<_>>()
+                })
+                .collect::<Vec<_>>(),
+            expected_suggestions
+        );
     }
 }
 

crates/assistant/src/inline_assistant.rs 🔗

@@ -82,13 +82,6 @@ pub struct InlineAssistant {
     assists: HashMap<InlineAssistId, InlineAssist>,
     assists_by_editor: HashMap<WeakView<Editor>, EditorInlineAssists>,
     assist_groups: HashMap<InlineAssistGroupId, InlineAssistGroup>,
-    assist_observations: HashMap<
-        InlineAssistId,
-        (
-            async_watch::Sender<AssistStatus>,
-            async_watch::Receiver<AssistStatus>,
-        ),
-    >,
     confirmed_assists: HashMap<InlineAssistId, Model<CodegenAlternative>>,
     prompt_history: VecDeque<String>,
     prompt_builder: Arc<PromptBuilder>,
@@ -96,19 +89,6 @@ pub struct InlineAssistant {
     fs: Arc<dyn Fs>,
 }
 
-pub enum AssistStatus {
-    Idle,
-    Started,
-    Stopped,
-    Finished,
-}
-
-impl AssistStatus {
-    pub fn is_done(&self) -> bool {
-        matches!(self, Self::Stopped | Self::Finished)
-    }
-}
-
 impl Global for InlineAssistant {}
 
 impl InlineAssistant {
@@ -123,7 +103,6 @@ impl InlineAssistant {
             assists: HashMap::default(),
             assists_by_editor: HashMap::default(),
             assist_groups: HashMap::default(),
-            assist_observations: HashMap::default(),
             confirmed_assists: HashMap::default(),
             prompt_history: VecDeque::default(),
             prompt_builder,
@@ -835,17 +814,6 @@ impl InlineAssistant {
                     .insert(assist_id, confirmed_alternative);
             }
         }
-
-        // Remove the assist from the status updates map
-        self.assist_observations.remove(&assist_id);
-    }
-
-    pub fn undo_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool {
-        let Some(codegen) = self.confirmed_assists.remove(&assist_id) else {
-            return false;
-        };
-        codegen.update(cx, |this, cx| this.undo(cx));
-        true
     }
 
     fn dismiss_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool {
@@ -1039,10 +1007,6 @@ impl InlineAssistant {
                 codegen.start(user_prompt, assistant_panel_context, cx)
             })
             .log_err();
-
-        if let Some((tx, _)) = self.assist_observations.get(&assist_id) {
-            tx.send(AssistStatus::Started).ok();
-        }
     }
 
     pub fn stop_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) {
@@ -1053,25 +1017,6 @@ impl InlineAssistant {
         };
 
         assist.codegen.update(cx, |codegen, cx| codegen.stop(cx));
-
-        if let Some((tx, _)) = self.assist_observations.get(&assist_id) {
-            tx.send(AssistStatus::Stopped).ok();
-        }
-    }
-
-    pub fn assist_status(&self, assist_id: InlineAssistId, cx: &AppContext) -> InlineAssistStatus {
-        if let Some(assist) = self.assists.get(&assist_id) {
-            match assist.codegen.read(cx).status(cx) {
-                CodegenStatus::Idle => InlineAssistStatus::Idle,
-                CodegenStatus::Pending => InlineAssistStatus::Pending,
-                CodegenStatus::Done => InlineAssistStatus::Done,
-                CodegenStatus::Error(_) => InlineAssistStatus::Error,
-            }
-        } else if self.confirmed_assists.contains_key(&assist_id) {
-            InlineAssistStatus::Confirmed
-        } else {
-            InlineAssistStatus::Canceled
-        }
     }
 
     fn update_editor_highlights(&self, editor: &View<Editor>, cx: &mut WindowContext) {
@@ -1257,42 +1202,6 @@ impl InlineAssistant {
                 .collect();
         })
     }
-
-    pub fn observe_assist(
-        &mut self,
-        assist_id: InlineAssistId,
-    ) -> async_watch::Receiver<AssistStatus> {
-        if let Some((_, rx)) = self.assist_observations.get(&assist_id) {
-            rx.clone()
-        } else {
-            let (tx, rx) = async_watch::channel(AssistStatus::Idle);
-            self.assist_observations.insert(assist_id, (tx, rx.clone()));
-            rx
-        }
-    }
-}
-
-pub enum InlineAssistStatus {
-    Idle,
-    Pending,
-    Done,
-    Error,
-    Confirmed,
-    Canceled,
-}
-
-impl InlineAssistStatus {
-    pub(crate) fn is_pending(&self) -> bool {
-        matches!(self, Self::Pending)
-    }
-
-    pub(crate) fn is_confirmed(&self) -> bool {
-        matches!(self, Self::Confirmed)
-    }
-
-    pub(crate) fn is_done(&self) -> bool {
-        matches!(self, Self::Done)
-    }
 }
 
 struct EditorInlineAssists {
@@ -2290,8 +2199,6 @@ impl InlineAssist {
 
                             if assist.decorations.is_none() {
                                 this.finish_assist(assist_id, false, cx);
-                            } else if let Some(tx) = this.assist_observations.get(&assist_id) {
-                                tx.0.send(AssistStatus::Finished).ok();
                             }
                         }
                     })

crates/assistant/src/patch.rs 🔗

@@ -0,0 +1,746 @@
+use anyhow::{anyhow, Context as _, Result};
+use collections::HashMap;
+use editor::ProposedChangesEditor;
+use futures::{future, TryFutureExt as _};
+use gpui::{AppContext, AsyncAppContext, Model, SharedString};
+use language::{AutoindentMode, Buffer, BufferSnapshot};
+use project::{Project, ProjectPath};
+use std::{cmp, ops::Range, path::Path, sync::Arc};
+use text::{AnchorRangeExt as _, Bias, OffsetRangeExt as _, Point};
+
+#[derive(Clone, Debug)]
+pub(crate) struct AssistantPatch {
+    pub range: Range<language::Anchor>,
+    pub title: SharedString,
+    pub edits: Arc<[Result<AssistantEdit>]>,
+    pub status: AssistantPatchStatus,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub(crate) enum AssistantPatchStatus {
+    Pending,
+    Ready,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct AssistantEdit {
+    pub path: String,
+    pub kind: AssistantEditKind,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum AssistantEditKind {
+    Update {
+        old_text: String,
+        new_text: String,
+        description: String,
+    },
+    Create {
+        new_text: String,
+        description: String,
+    },
+    InsertBefore {
+        old_text: String,
+        new_text: String,
+        description: String,
+    },
+    InsertAfter {
+        old_text: String,
+        new_text: String,
+        description: String,
+    },
+    Delete {
+        old_text: String,
+    },
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct ResolvedPatch {
+    pub edit_groups: HashMap<Model<Buffer>, Vec<ResolvedEditGroup>>,
+    pub errors: Vec<AssistantPatchResolutionError>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ResolvedEditGroup {
+    pub context_range: Range<language::Anchor>,
+    pub edits: Vec<ResolvedEdit>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ResolvedEdit {
+    range: Range<language::Anchor>,
+    new_text: String,
+    description: Option<String>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct AssistantPatchResolutionError {
+    pub edit_ix: usize,
+    pub message: String,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+enum SearchDirection {
+    Up,
+    Left,
+    Diagonal,
+}
+
+// A measure of the currently quality of an in-progress fuzzy search.
+//
+// Uses 60 bits to store a numeric cost, and 4 bits to store the preceding
+// operation in the search.
+#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
+struct SearchState {
+    score: u32,
+    direction: SearchDirection,
+}
+
+impl SearchState {
+    fn new(score: u32, direction: SearchDirection) -> Self {
+        Self { score, direction }
+    }
+}
+
+impl ResolvedPatch {
+    pub fn apply(&self, editor: &ProposedChangesEditor, cx: &mut AppContext) {
+        for (buffer, groups) in &self.edit_groups {
+            let branch = editor.branch_buffer_for_base(buffer).unwrap();
+            Self::apply_edit_groups(groups, &branch, cx);
+        }
+        editor.recalculate_all_buffer_diffs();
+    }
+
+    fn apply_edit_groups(
+        groups: &Vec<ResolvedEditGroup>,
+        buffer: &Model<Buffer>,
+        cx: &mut AppContext,
+    ) {
+        let mut edits = Vec::new();
+        for group in groups {
+            for suggestion in &group.edits {
+                edits.push((suggestion.range.clone(), suggestion.new_text.clone()));
+            }
+        }
+        buffer.update(cx, |buffer, cx| {
+            buffer.edit(
+                edits,
+                Some(AutoindentMode::Block {
+                    original_indent_columns: Vec::new(),
+                }),
+                cx,
+            );
+        });
+    }
+}
+
+impl ResolvedEdit {
+    pub fn try_merge(&mut self, other: &Self, buffer: &text::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) = &mut self.description {
+            if let Some(other_description) = &other.description {
+                description.push('\n');
+                description.push_str(other_description);
+            }
+        }
+        true
+    }
+}
+
+impl AssistantEdit {
+    pub fn new(
+        path: Option<String>,
+        operation: Option<String>,
+        old_text: Option<String>,
+        new_text: Option<String>,
+        description: Option<String>,
+    ) -> Result<Self> {
+        let path = path.ok_or_else(|| anyhow!("missing path"))?;
+        let operation = operation.ok_or_else(|| anyhow!("missing operation"))?;
+
+        let kind = match operation.as_str() {
+            "update" => AssistantEditKind::Update {
+                old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?,
+                new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?,
+                description: description.ok_or_else(|| anyhow!("missing description"))?,
+            },
+            "insert_before" => AssistantEditKind::InsertBefore {
+                old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?,
+                new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?,
+                description: description.ok_or_else(|| anyhow!("missing description"))?,
+            },
+            "insert_after" => AssistantEditKind::InsertAfter {
+                old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?,
+                new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?,
+                description: description.ok_or_else(|| anyhow!("missing description"))?,
+            },
+            "delete" => AssistantEditKind::Delete {
+                old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?,
+            },
+            "create" => AssistantEditKind::Create {
+                description: description.ok_or_else(|| anyhow!("missing description"))?,
+                new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?,
+            },
+            _ => Err(anyhow!("unknown operation {operation:?}"))?,
+        };
+
+        Ok(Self { path, kind })
+    }
+
+    pub async fn resolve(
+        &self,
+        project: Model<Project>,
+        mut cx: AsyncAppContext,
+    ) -> Result<(Model<Buffer>, ResolvedEdit)> {
+        let path = self.path.clone();
+        let kind = self.kind.clone();
+        let buffer = project
+            .update(&mut cx, |project, cx| {
+                let project_path = project
+                    .find_project_path(Path::new(&path), cx)
+                    .or_else(|| {
+                        // If we couldn't find a project path for it, put it in the active worktree
+                        // so that when we create the buffer, it can be saved.
+                        let worktree = project
+                            .active_entry()
+                            .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
+                            .or_else(|| project.worktrees(cx).next())?;
+                        let worktree = worktree.read(cx);
+
+                        Some(ProjectPath {
+                            worktree_id: worktree.id(),
+                            path: Arc::from(Path::new(&path)),
+                        })
+                    })
+                    .with_context(|| format!("worktree not found for {:?}", path))?;
+                anyhow::Ok(project.open_buffer(project_path, cx))
+            })??
+            .await?;
+
+        let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
+        let suggestion = cx
+            .background_executor()
+            .spawn(async move { kind.resolve(&snapshot) })
+            .await;
+
+        Ok((buffer, suggestion))
+    }
+}
+
+impl AssistantEditKind {
+    fn resolve(self, snapshot: &BufferSnapshot) -> ResolvedEdit {
+        match self {
+            Self::Update {
+                old_text,
+                new_text,
+                description,
+            } => {
+                let range = Self::resolve_location(&snapshot, &old_text);
+                ResolvedEdit {
+                    range,
+                    new_text,
+                    description: Some(description),
+                }
+            }
+            Self::Create {
+                new_text,
+                description,
+            } => ResolvedEdit {
+                range: text::Anchor::MIN..text::Anchor::MAX,
+                description: Some(description),
+                new_text,
+            },
+            Self::InsertBefore {
+                old_text,
+                mut new_text,
+                description,
+            } => {
+                let range = Self::resolve_location(&snapshot, &old_text);
+                new_text.push('\n');
+                ResolvedEdit {
+                    range: range.start..range.start,
+                    new_text,
+                    description: Some(description),
+                }
+            }
+            Self::InsertAfter {
+                old_text,
+                mut new_text,
+                description,
+            } => {
+                let range = Self::resolve_location(&snapshot, &old_text);
+                new_text.insert(0, '\n');
+                ResolvedEdit {
+                    range: range.end..range.end,
+                    new_text,
+                    description: Some(description),
+                }
+            }
+            Self::Delete { old_text } => {
+                let range = Self::resolve_location(&snapshot, &old_text);
+                ResolvedEdit {
+                    range,
+                    new_text: String::new(),
+                    description: None,
+                }
+            }
+        }
+    }
+
+    fn resolve_location(buffer: &text::BufferSnapshot, search_query: &str) -> Range<text::Anchor> {
+        const INSERTION_COST: u32 = 3;
+        const WHITESPACE_INSERTION_COST: u32 = 1;
+        const DELETION_COST: u32 = 3;
+        const WHITESPACE_DELETION_COST: u32 = 1;
+        const EQUALITY_BONUS: u32 = 5;
+
+        struct Matrix {
+            cols: usize,
+            data: Vec<SearchState>,
+        }
+
+        impl Matrix {
+            fn new(rows: usize, cols: usize) -> Self {
+                Matrix {
+                    cols,
+                    data: vec![SearchState::new(0, SearchDirection::Diagonal); rows * cols],
+                }
+            }
+
+            fn get(&self, row: usize, col: usize) -> SearchState {
+                self.data[row * self.cols + col]
+            }
+
+            fn set(&mut self, row: usize, col: usize, cost: SearchState) {
+                self.data[row * self.cols + col] = cost;
+            }
+        }
+
+        let buffer_len = buffer.len();
+        let query_len = search_query.len();
+        let mut matrix = Matrix::new(query_len + 1, buffer_len + 1);
+
+        for (row, query_byte) in search_query.bytes().enumerate() {
+            for (col, buffer_byte) in buffer.bytes_in_range(0..buffer.len()).flatten().enumerate() {
+                let deletion_cost = if query_byte.is_ascii_whitespace() {
+                    WHITESPACE_DELETION_COST
+                } else {
+                    DELETION_COST
+                };
+                let insertion_cost = if buffer_byte.is_ascii_whitespace() {
+                    WHITESPACE_INSERTION_COST
+                } else {
+                    INSERTION_COST
+                };
+
+                let up = SearchState::new(
+                    matrix.get(row, col + 1).score.saturating_sub(deletion_cost),
+                    SearchDirection::Up,
+                );
+                let left = SearchState::new(
+                    matrix
+                        .get(row + 1, col)
+                        .score
+                        .saturating_sub(insertion_cost),
+                    SearchDirection::Left,
+                );
+                let diagonal = SearchState::new(
+                    if query_byte == *buffer_byte {
+                        matrix.get(row, col).score.saturating_add(EQUALITY_BONUS)
+                    } else {
+                        matrix
+                            .get(row, col)
+                            .score
+                            .saturating_sub(deletion_cost + insertion_cost)
+                    },
+                    SearchDirection::Diagonal,
+                );
+                matrix.set(row + 1, col + 1, up.max(left).max(diagonal));
+            }
+        }
+
+        // Traceback to find the best match
+        let mut best_buffer_end = buffer_len;
+        let mut best_score = 0;
+        for col in 1..=buffer_len {
+            let score = matrix.get(query_len, col).score;
+            if score > best_score {
+                best_score = score;
+                best_buffer_end = col;
+            }
+        }
+
+        let mut query_ix = query_len;
+        let mut buffer_ix = best_buffer_end;
+        while query_ix > 0 && buffer_ix > 0 {
+            let current = matrix.get(query_ix, buffer_ix);
+            match current.direction {
+                SearchDirection::Diagonal => {
+                    query_ix -= 1;
+                    buffer_ix -= 1;
+                }
+                SearchDirection::Up => {
+                    query_ix -= 1;
+                }
+                SearchDirection::Left => {
+                    buffer_ix -= 1;
+                }
+            }
+        }
+
+        let mut start = buffer.offset_to_point(buffer.clip_offset(buffer_ix, Bias::Left));
+        start.column = 0;
+        let mut end = buffer.offset_to_point(buffer.clip_offset(best_buffer_end, Bias::Right));
+        if end.column > 0 {
+            end.column = buffer.line_len(end.row);
+        }
+
+        buffer.anchor_after(start)..buffer.anchor_before(end)
+    }
+}
+
+impl AssistantPatch {
+    pub(crate) async fn resolve(
+        &self,
+        project: Model<Project>,
+        cx: &mut AsyncAppContext,
+    ) -> ResolvedPatch {
+        let mut resolve_tasks = Vec::new();
+        for (ix, edit) in self.edits.iter().enumerate() {
+            if let Ok(edit) = edit.as_ref() {
+                resolve_tasks.push(
+                    edit.resolve(project.clone(), cx.clone())
+                        .map_err(move |error| (ix, error)),
+                );
+            }
+        }
+
+        let edits = future::join_all(resolve_tasks).await;
+        let mut errors = Vec::new();
+        let mut edits_by_buffer = HashMap::default();
+        for entry in edits {
+            match entry {
+                Ok((buffer, edit)) => {
+                    edits_by_buffer
+                        .entry(buffer)
+                        .or_insert_with(Vec::new)
+                        .push(edit);
+                }
+                Err((edit_ix, error)) => errors.push(AssistantPatchResolutionError {
+                    edit_ix,
+                    message: error.to_string(),
+                }),
+            }
+        }
+
+        // Expand the context ranges of each edit and group edits with overlapping context ranges.
+        let mut edit_groups_by_buffer = HashMap::default();
+        for (buffer, edits) in edits_by_buffer {
+            if let Ok(snapshot) = buffer.update(cx, |buffer, _| buffer.text_snapshot()) {
+                edit_groups_by_buffer.insert(buffer, Self::group_edits(edits, &snapshot));
+            }
+        }
+
+        ResolvedPatch {
+            edit_groups: edit_groups_by_buffer,
+            errors,
+        }
+    }
+
+    fn group_edits(
+        mut edits: Vec<ResolvedEdit>,
+        snapshot: &text::BufferSnapshot,
+    ) -> Vec<ResolvedEditGroup> {
+        let mut edit_groups = Vec::<ResolvedEditGroup>::new();
+        // Sort edits by their range so that earlier, larger ranges come first
+        edits.sort_by(|a, b| a.range.cmp(&b.range, &snapshot));
+
+        // Merge overlapping edits
+        edits.dedup_by(|a, b| b.try_merge(a, &snapshot));
+
+        // Create context ranges for each edit
+        for edit in edits {
+            let context_range = {
+                let edit_point_range = edit.range.to_point(&snapshot);
+                let start_row = edit_point_range.start.row.saturating_sub(5);
+                let end_row = cmp::min(edit_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) = edit_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.edits.push(edit);
+                } else {
+                    // Create a new group
+                    edit_groups.push(ResolvedEditGroup {
+                        context_range,
+                        edits: vec![edit],
+                    });
+                }
+            } else {
+                // Create the first group
+                edit_groups.push(ResolvedEditGroup {
+                    context_range,
+                    edits: vec![edit],
+                });
+            }
+        }
+
+        edit_groups
+    }
+
+    pub fn path_count(&self) -> usize {
+        self.paths().count()
+    }
+
+    pub fn paths(&self) -> impl '_ + Iterator<Item = &str> {
+        let mut prev_path = None;
+        self.edits.iter().filter_map(move |edit| {
+            if let Ok(edit) = edit {
+                let path = Some(edit.path.as_str());
+                if path != prev_path {
+                    prev_path = path;
+                    return path;
+                }
+            }
+            None
+        })
+    }
+}
+
+impl PartialEq for AssistantPatch {
+    fn eq(&self, other: &Self) -> bool {
+        self.range == other.range
+            && self.title == other.title
+            && Arc::ptr_eq(&self.edits, &other.edits)
+    }
+}
+
+impl Eq for AssistantPatch {}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::{AppContext, Context};
+    use language::{
+        language_settings::AllLanguageSettings, Language, LanguageConfig, LanguageMatcher,
+    };
+    use settings::SettingsStore;
+    use text::{OffsetRangeExt, Point};
+    use ui::BorrowAppContext;
+    use unindent::Unindent as _;
+
+    #[gpui::test]
+    fn test_resolve_location(cx: &mut AppContext) {
+        {
+            let buffer = cx.new_model(|cx| {
+                Buffer::local(
+                    concat!(
+                        "    Lorem\n",
+                        "    ipsum\n",
+                        "    dolor sit amet\n",
+                        "    consecteur",
+                    ),
+                    cx,
+                )
+            });
+            let snapshot = buffer.read(cx).snapshot();
+            assert_eq!(
+                AssistantEditKind::resolve_location(&snapshot, "ipsum\ndolor").to_point(&snapshot),
+                Point::new(1, 0)..Point::new(2, 18)
+            );
+        }
+
+        {
+            let buffer = cx.new_model(|cx| {
+                Buffer::local(
+                    concat!(
+                        "fn foo1(a: usize) -> usize {\n",
+                        "    40\n",
+                        "}\n",
+                        "\n",
+                        "fn foo2(b: usize) -> usize {\n",
+                        "    42\n",
+                        "}\n",
+                    ),
+                    cx,
+                )
+            });
+            let snapshot = buffer.read(cx).snapshot();
+            assert_eq!(
+                AssistantEditKind::resolve_location(&snapshot, "fn foo1(b: usize) {\n40\n}")
+                    .to_point(&snapshot),
+                Point::new(0, 0)..Point::new(2, 1)
+            );
+        }
+
+        {
+            let buffer = cx.new_model(|cx| {
+                Buffer::local(
+                    concat!(
+                        "fn main() {\n",
+                        "    Foo\n",
+                        "        .bar()\n",
+                        "        .baz()\n",
+                        "        .qux()\n",
+                        "}\n",
+                        "\n",
+                        "fn foo2(b: usize) -> usize {\n",
+                        "    42\n",
+                        "}\n",
+                    ),
+                    cx,
+                )
+            });
+            let snapshot = buffer.read(cx).snapshot();
+            assert_eq!(
+                AssistantEditKind::resolve_location(&snapshot, "Foo.bar.baz.qux()")
+                    .to_point(&snapshot),
+                Point::new(1, 0)..Point::new(4, 14)
+            );
+        }
+    }
+
+    #[gpui::test]
+    fn test_resolve_edits(cx: &mut AppContext) {
+        let settings_store = SettingsStore::test(cx);
+        cx.set_global(settings_store);
+        language::init(cx);
+        cx.update_global::<SettingsStore, _>(|settings, cx| {
+            settings.update_user_settings::<AllLanguageSettings>(cx, |_| {});
+        });
+
+        assert_edits(
+            "
+                /// A person
+                struct Person {
+                    name: String,
+                    age: usize,
+                }
+
+                /// A dog
+                struct Dog {
+                    weight: f32,
+                }
+
+                impl Person {
+                    fn name(&self) -> &str {
+                        &self.name
+                    }
+                }
+            "
+            .unindent(),
+            vec![
+                AssistantEditKind::Update {
+                    old_text: "
+                        name: String,
+                    "
+                    .unindent(),
+                    new_text: "
+                        first_name: String,
+                        last_name: String,
+                    "
+                    .unindent(),
+                    description: "".into(),
+                },
+                AssistantEditKind::Update {
+                    old_text: "
+                        fn name(&self) -> &str {
+                            &self.name
+                        }
+                    "
+                    .unindent(),
+                    new_text: "
+                        fn name(&self) -> String {
+                            format!(\"{} {}\", self.first_name, self.last_name)
+                        }
+                    "
+                    .unindent(),
+                    description: "".into(),
+                },
+            ],
+            "
+                /// A person
+                struct Person {
+                    first_name: String,
+                    last_name: String,
+                    age: usize,
+                }
+
+                /// A dog
+                struct Dog {
+                    weight: f32,
+                }
+
+                impl Person {
+                    fn name(&self) -> String {
+                        format!(\"{} {}\", self.first_name, self.last_name)
+                    }
+                }
+            "
+            .unindent(),
+            cx,
+        );
+    }
+
+    #[track_caller]
+    fn assert_edits(
+        old_text: String,
+        edits: Vec<AssistantEditKind>,
+        new_text: String,
+        cx: &mut AppContext,
+    ) {
+        let buffer =
+            cx.new_model(|cx| Buffer::local(old_text, cx).with_language(Arc::new(rust_lang()), cx));
+        let snapshot = buffer.read(cx).snapshot();
+        let resolved_edits = edits
+            .into_iter()
+            .map(|kind| kind.resolve(&snapshot))
+            .collect();
+        let edit_groups = AssistantPatch::group_edits(resolved_edits, &snapshot);
+        ResolvedPatch::apply_edit_groups(&edit_groups, &buffer, cx);
+        let actual_new_text = buffer.read(cx).text();
+        pretty_assertions::assert_eq!(actual_new_text, new_text);
+    }
+
+    fn rust_lang() -> Language {
+        Language::new(
+            LanguageConfig {
+                name: "Rust".into(),
+                matcher: LanguageMatcher {
+                    path_suffixes: vec!["rs".to_string()],
+                    ..Default::default()
+                },
+                ..Default::default()
+            },
+            Some(language::tree_sitter_rust::LANGUAGE.into()),
+        )
+        .with_indents_query(
+            r#"
+            (call_expression) @indent
+            (field_expression) @indent
+            (_ "(" ")" @end) @indent
+            (_ "{" "}" @end) @indent
+            "#,
+        )
+        .unwrap()
+    }
+}

crates/assistant/src/prompts.rs 🔗

@@ -45,15 +45,6 @@ pub struct ProjectSlashCommandPromptContext {
     pub context_buffer: String,
 }
 
-/// Context required to generate a workflow step resolution prompt.
-#[derive(Debug, Serialize)]
-pub struct StepResolutionContext {
-    /// The full context, including <step>...</step> tags
-    pub workflow_context: String,
-    /// The text of the specific step from the context to resolve
-    pub step_to_resolve: String,
-}
-
 pub struct PromptLoadingParams<'a> {
     pub fs: Arc<dyn Fs>,
     pub repo_path: Option<PathBuf>,

crates/assistant/src/slash_command/workflow_command.rs 🔗

@@ -18,6 +18,8 @@ pub(crate) struct WorkflowSlashCommand {
 }
 
 impl WorkflowSlashCommand {
+    pub const NAME: &'static str = "workflow";
+
     pub fn new(prompt_builder: Arc<PromptBuilder>) -> Self {
         Self { prompt_builder }
     }
@@ -25,7 +27,7 @@ impl WorkflowSlashCommand {
 
 impl SlashCommand for WorkflowSlashCommand {
     fn name(&self) -> String {
-        "workflow".into()
+        Self::NAME.into()
     }
 
     fn description(&self) -> String {

crates/assistant/src/workflow.rs 🔗

@@ -1,507 +0,0 @@
-use crate::{AssistantPanel, InlineAssistId, InlineAssistant};
-use anyhow::{anyhow, Context as _, Result};
-use collections::HashMap;
-use editor::Editor;
-use gpui::AsyncAppContext;
-use gpui::{Model, Task, UpdateGlobal as _, View, WeakView, WindowContext};
-use language::{Buffer, BufferSnapshot};
-use project::{Project, ProjectPath};
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use std::{ops::Range, path::Path, sync::Arc};
-use text::Bias;
-use workspace::Workspace;
-
-#[derive(Debug)]
-pub(crate) struct WorkflowStep {
-    pub range: Range<language::Anchor>,
-    pub leading_tags_end: text::Anchor,
-    pub trailing_tag_start: Option<text::Anchor>,
-    pub edits: Arc<[Result<WorkflowStepEdit>]>,
-    pub resolution_task: Option<Task<()>>,
-    pub resolution: Option<Arc<Result<WorkflowStepResolution>>>,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub(crate) struct WorkflowStepEdit {
-    pub path: String,
-    pub kind: WorkflowStepEditKind,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub(crate) struct WorkflowStepResolution {
-    pub title: String,
-    pub suggestion_groups: HashMap<Model<Buffer>, Vec<WorkflowSuggestionGroup>>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct WorkflowSuggestionGroup {
-    pub context_range: Range<language::Anchor>,
-    pub suggestions: Vec<WorkflowSuggestion>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum WorkflowSuggestion {
-    Update {
-        range: Range<language::Anchor>,
-        description: String,
-    },
-    CreateFile {
-        description: String,
-    },
-    InsertBefore {
-        position: language::Anchor,
-        description: String,
-    },
-    InsertAfter {
-        position: language::Anchor,
-        description: String,
-    },
-    Delete {
-        range: Range<language::Anchor>,
-    },
-}
-
-impl WorkflowSuggestion {
-    pub fn range(&self) -> Range<language::Anchor> {
-        match self {
-            Self::Update { range, .. } => range.clone(),
-            Self::CreateFile { .. } => language::Anchor::MIN..language::Anchor::MAX,
-            Self::InsertBefore { position, .. } | Self::InsertAfter { position, .. } => {
-                *position..*position
-            }
-            Self::Delete { range, .. } => range.clone(),
-        }
-    }
-
-    pub fn description(&self) -> Option<&str> {
-        match self {
-            Self::Update { description, .. }
-            | Self::CreateFile { description }
-            | Self::InsertBefore { description, .. }
-            | Self::InsertAfter { description, .. } => Some(description),
-            Self::Delete { .. } => None,
-        }
-    }
-
-    fn description_mut(&mut self) -> Option<&mut String> {
-        match self {
-            Self::Update { description, .. }
-            | Self::CreateFile { description }
-            | Self::InsertBefore { description, .. }
-            | Self::InsertAfter { description, .. } => Some(description),
-            Self::Delete { .. } => None,
-        }
-    }
-
-    pub 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 {
-            Self::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)?;
-            }
-            Self::CreateFile { description } => {
-                initial_prompt = description.clone();
-                suggestion_range = editor::Anchor::min()..editor::Anchor::min();
-            }
-            Self::InsertBefore {
-                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);
-                    buffer.refresh_preview(cx);
-
-                    let line_start = buffer.read(cx).anchor_before(line_start);
-                    line_start..line_start
-                });
-            }
-            Self::InsertAfter {
-                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);
-                    buffer.refresh_preview(cx);
-
-                    let line_start = buffer.read(cx).anchor_before(line_start);
-                    line_start..line_start
-                });
-            }
-            Self::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,
-                false,
-                Some(workspace.clone()),
-                Some(assistant_panel),
-                cx,
-            ))
-        })
-    }
-}
-
-impl WorkflowStepEdit {
-    pub fn new(
-        path: Option<String>,
-        operation: Option<String>,
-        search: Option<String>,
-        description: Option<String>,
-    ) -> Result<Self> {
-        let path = path.ok_or_else(|| anyhow!("missing path"))?;
-        let operation = operation.ok_or_else(|| anyhow!("missing operation"))?;
-
-        let kind = match operation.as_str() {
-            "update" => WorkflowStepEditKind::Update {
-                search: search.ok_or_else(|| anyhow!("missing search"))?,
-                description: description.ok_or_else(|| anyhow!("missing description"))?,
-            },
-            "insert_before" => WorkflowStepEditKind::InsertBefore {
-                search: search.ok_or_else(|| anyhow!("missing search"))?,
-                description: description.ok_or_else(|| anyhow!("missing description"))?,
-            },
-            "insert_after" => WorkflowStepEditKind::InsertAfter {
-                search: search.ok_or_else(|| anyhow!("missing search"))?,
-                description: description.ok_or_else(|| anyhow!("missing description"))?,
-            },
-            "delete" => WorkflowStepEditKind::Delete {
-                search: search.ok_or_else(|| anyhow!("missing search"))?,
-            },
-            "create" => WorkflowStepEditKind::Create {
-                description: description.ok_or_else(|| anyhow!("missing description"))?,
-            },
-            _ => Err(anyhow!("unknown operation {operation:?}"))?,
-        };
-
-        Ok(Self { path, kind })
-    }
-
-    pub async fn resolve(
-        &self,
-        project: Model<Project>,
-        mut cx: AsyncAppContext,
-    ) -> Result<(Model<Buffer>, super::WorkflowSuggestion)> {
-        let path = self.path.clone();
-        let kind = self.kind.clone();
-        let buffer = project
-            .update(&mut cx, |project, cx| {
-                let project_path = project
-                    .find_project_path(Path::new(&path), cx)
-                    .or_else(|| {
-                        // If we couldn't find a project path for it, put it in the active worktree
-                        // so that when we create the buffer, it can be saved.
-                        let worktree = project
-                            .active_entry()
-                            .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
-                            .or_else(|| project.worktrees(cx).next())?;
-                        let worktree = worktree.read(cx);
-
-                        Some(ProjectPath {
-                            worktree_id: worktree.id(),
-                            path: Arc::from(Path::new(&path)),
-                        })
-                    })
-                    .with_context(|| format!("worktree not found for {:?}", path))?;
-                anyhow::Ok(project.open_buffer(project_path, cx))
-            })??
-            .await?;
-
-        let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
-        let suggestion = cx
-            .background_executor()
-            .spawn(async move {
-                match kind {
-                    WorkflowStepEditKind::Update {
-                        search,
-                        description,
-                    } => {
-                        let range = Self::resolve_location(&snapshot, &search);
-                        WorkflowSuggestion::Update { range, description }
-                    }
-                    WorkflowStepEditKind::Create { description } => {
-                        WorkflowSuggestion::CreateFile { description }
-                    }
-                    WorkflowStepEditKind::InsertBefore {
-                        search,
-                        description,
-                    } => {
-                        let range = Self::resolve_location(&snapshot, &search);
-                        WorkflowSuggestion::InsertBefore {
-                            position: range.start,
-                            description,
-                        }
-                    }
-                    WorkflowStepEditKind::InsertAfter {
-                        search,
-                        description,
-                    } => {
-                        let range = Self::resolve_location(&snapshot, &search);
-                        WorkflowSuggestion::InsertAfter {
-                            position: range.end,
-                            description,
-                        }
-                    }
-                    WorkflowStepEditKind::Delete { search } => {
-                        let range = Self::resolve_location(&snapshot, &search);
-                        WorkflowSuggestion::Delete { range }
-                    }
-                }
-            })
-            .await;
-
-        Ok((buffer, suggestion))
-    }
-
-    fn resolve_location(buffer: &text::BufferSnapshot, search_query: &str) -> Range<text::Anchor> {
-        const INSERTION_SCORE: f64 = -1.0;
-        const DELETION_SCORE: f64 = -1.0;
-        const REPLACEMENT_SCORE: f64 = -1.0;
-        const EQUALITY_SCORE: f64 = 5.0;
-
-        struct Matrix {
-            cols: usize,
-            data: Vec<f64>,
-        }
-
-        impl Matrix {
-            fn new(rows: usize, cols: usize) -> Self {
-                Matrix {
-                    cols,
-                    data: vec![0.0; rows * cols],
-                }
-            }
-
-            fn get(&self, row: usize, col: usize) -> f64 {
-                self.data[row * self.cols + col]
-            }
-
-            fn set(&mut self, row: usize, col: usize, value: f64) {
-                self.data[row * self.cols + col] = value;
-            }
-        }
-
-        let buffer_len = buffer.len();
-        let query_len = search_query.len();
-        let mut matrix = Matrix::new(query_len + 1, buffer_len + 1);
-
-        for (i, query_byte) in search_query.bytes().enumerate() {
-            for (j, buffer_byte) in buffer.bytes_in_range(0..buffer.len()).flatten().enumerate() {
-                let match_score = if query_byte == *buffer_byte {
-                    EQUALITY_SCORE
-                } else {
-                    REPLACEMENT_SCORE
-                };
-                let up = matrix.get(i + 1, j) + DELETION_SCORE;
-                let left = matrix.get(i, j + 1) + INSERTION_SCORE;
-                let diagonal = matrix.get(i, j) + match_score;
-                let score = up.max(left.max(diagonal)).max(0.);
-                matrix.set(i + 1, j + 1, score);
-            }
-        }
-
-        // Traceback to find the best match
-        let mut best_buffer_end = buffer_len;
-        let mut best_score = 0.0;
-        for col in 1..=buffer_len {
-            let score = matrix.get(query_len, col);
-            if score > best_score {
-                best_score = score;
-                best_buffer_end = col;
-            }
-        }
-
-        let mut query_ix = query_len;
-        let mut buffer_ix = best_buffer_end;
-        while query_ix > 0 && buffer_ix > 0 {
-            let current = matrix.get(query_ix, buffer_ix);
-            let up = matrix.get(query_ix - 1, buffer_ix);
-            let left = matrix.get(query_ix, buffer_ix - 1);
-            if current == left + INSERTION_SCORE {
-                buffer_ix -= 1;
-            } else if current == up + DELETION_SCORE {
-                query_ix -= 1;
-            } else {
-                query_ix -= 1;
-                buffer_ix -= 1;
-            }
-        }
-
-        let mut start = buffer.offset_to_point(buffer.clip_offset(buffer_ix, Bias::Left));
-        start.column = 0;
-        let mut end = buffer.offset_to_point(buffer.clip_offset(best_buffer_end, Bias::Right));
-        end.column = buffer.line_len(end.row);
-
-        buffer.anchor_after(start)..buffer.anchor_before(end)
-    }
-}
-
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
-#[serde(tag = "operation")]
-pub enum WorkflowStepEditKind {
-    /// Rewrites the specified text entirely based on the given description.
-    /// This operation completely replaces the given text.
-    Update {
-        /// A string in the source text to apply the update to.
-        search: String,
-        /// A brief description of the transformation to apply to the symbol.
-        description: String,
-    },
-    /// Creates a new file with the given path based on the provided description.
-    /// This operation adds a new file to the codebase.
-    Create {
-        /// A brief description of the file to be created.
-        description: String,
-    },
-    /// Inserts text before the specified text in the source file.
-    InsertBefore {
-        /// A string in the source text to insert text before.
-        search: String,
-        /// A brief description of how the new text should be generated.
-        description: String,
-    },
-    /// Inserts text after the specified text in the source file.
-    InsertAfter {
-        /// A string in the source text to insert text after.
-        search: String,
-        /// A brief description of how the new text should be generated.
-        description: String,
-    },
-    /// Deletes the specified symbol from the containing file.
-    Delete {
-        /// A string in the source text to delete.
-        search: String,
-    },
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use gpui::{AppContext, Context};
-    use text::{OffsetRangeExt, Point};
-
-    #[gpui::test]
-    fn test_resolve_location(cx: &mut AppContext) {
-        {
-            let buffer = cx.new_model(|cx| {
-                Buffer::local(
-                    concat!(
-                        "    Lorem\n",
-                        "    ipsum\n",
-                        "    dolor sit amet\n",
-                        "    consecteur",
-                    ),
-                    cx,
-                )
-            });
-            let snapshot = buffer.read(cx).snapshot();
-            assert_eq!(
-                WorkflowStepEdit::resolve_location(&snapshot, "ipsum\ndolor").to_point(&snapshot),
-                Point::new(1, 0)..Point::new(2, 18)
-            );
-        }
-
-        {
-            let buffer = cx.new_model(|cx| {
-                Buffer::local(
-                    concat!(
-                        "fn foo1(a: usize) -> usize {\n",
-                        "    42\n",
-                        "}\n",
-                        "\n",
-                        "fn foo2(b: usize) -> usize {\n",
-                        "    42\n",
-                        "}\n",
-                    ),
-                    cx,
-                )
-            });
-            let snapshot = buffer.read(cx).snapshot();
-            assert_eq!(
-                WorkflowStepEdit::resolve_location(&snapshot, "fn foo1(b: usize) {\n42\n}")
-                    .to_point(&snapshot),
-                Point::new(0, 0)..Point::new(2, 1)
-            );
-        }
-
-        {
-            let buffer = cx.new_model(|cx| {
-                Buffer::local(
-                    concat!(
-                        "fn main() {\n",
-                        "    Foo\n",
-                        "        .bar()\n",
-                        "        .baz()\n",
-                        "        .qux()\n",
-                        "}\n",
-                        "\n",
-                        "fn foo2(b: usize) -> usize {\n",
-                        "    42\n",
-                        "}\n",
-                    ),
-                    cx,
-                )
-            });
-            let snapshot = buffer.read(cx).snapshot();
-            assert_eq!(
-                WorkflowStepEdit::resolve_location(&snapshot, "Foo.bar.baz.qux()")
-                    .to_point(&snapshot),
-                Point::new(1, 0)..Point::new(4, 14)
-            );
-        }
-    }
-}

crates/editor/src/editor.rs 🔗

@@ -100,7 +100,7 @@ use language::{
 };
 use linked_editing_ranges::refresh_linked_ranges;
 pub use proposed_changes_editor::{
-    ProposedChangesBuffer, ProposedChangesEditor, ProposedChangesEditorToolbar,
+    ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
 };
 use similar::{ChangeTag, TextDiff};
 use task::{ResolvedTask, TaskTemplate, TaskVariables};
@@ -12363,10 +12363,15 @@ impl Editor {
 
         let proposed_changes_buffers = new_selections_by_buffer
             .into_iter()
-            .map(|(buffer, ranges)| ProposedChangesBuffer { buffer, ranges })
+            .map(|(buffer, ranges)| ProposedChangeLocation { buffer, ranges })
             .collect::<Vec<_>>();
         let proposed_changes_editor = cx.new_view(|cx| {
-            ProposedChangesEditor::new(proposed_changes_buffers, self.project.clone(), cx)
+            ProposedChangesEditor::new(
+                "Proposed changes",
+                proposed_changes_buffers,
+                self.project.clone(),
+                cx,
+            )
         });
 
         cx.window_context().defer(move |cx| {

crates/editor/src/proposed_changes_editor.rs 🔗

@@ -16,16 +16,24 @@ use workspace::{
 
 pub struct ProposedChangesEditor {
     editor: View<Editor>,
-    _subscriptions: Vec<Subscription>,
+    multibuffer: Model<MultiBuffer>,
+    title: SharedString,
+    buffer_entries: Vec<BufferEntry>,
     _recalculate_diffs_task: Task<Option<()>>,
     recalculate_diffs_tx: mpsc::UnboundedSender<RecalculateDiff>,
 }
 
-pub struct ProposedChangesBuffer<T> {
+pub struct ProposedChangeLocation<T> {
     pub buffer: Model<Buffer>,
     pub ranges: Vec<Range<T>>,
 }
 
+struct BufferEntry {
+    base: Model<Buffer>,
+    branch: Model<Buffer>,
+    _subscription: Subscription,
+}
+
 pub struct ProposedChangesEditorToolbar {
     current_editor: Option<View<ProposedChangesEditor>>,
 }
@@ -43,32 +51,14 @@ struct BranchBufferSemanticsProvider(Rc<dyn SemanticsProvider>);
 
 impl ProposedChangesEditor {
     pub fn new<T: ToOffset>(
-        buffers: Vec<ProposedChangesBuffer<T>>,
+        title: impl Into<SharedString>,
+        locations: Vec<ProposedChangeLocation<T>>,
         project: Option<Model<Project>>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
-        let mut subscriptions = Vec::new();
         let multibuffer = cx.new_model(|_| MultiBuffer::new(Capability::ReadWrite));
-
-        for buffer in buffers {
-            let branch_buffer = buffer.buffer.update(cx, |buffer, cx| buffer.branch(cx));
-            subscriptions.push(cx.subscribe(&branch_buffer, Self::on_buffer_event));
-
-            multibuffer.update(cx, |multibuffer, cx| {
-                multibuffer.push_excerpts(
-                    branch_buffer,
-                    buffer.ranges.into_iter().map(|range| ExcerptRange {
-                        context: range,
-                        primary: None,
-                    }),
-                    cx,
-                );
-            });
-        }
-
         let (recalculate_diffs_tx, mut recalculate_diffs_rx) = mpsc::unbounded();
-
-        Self {
+        let mut this = Self {
             editor: cx.new_view(|cx| {
                 let mut editor = Editor::for_multibuffer(multibuffer.clone(), project, true, cx);
                 editor.set_expand_all_diff_hunks();
@@ -81,6 +71,9 @@ impl ProposedChangesEditor {
                 );
                 editor
             }),
+            multibuffer,
+            title: title.into(),
+            buffer_entries: Vec::new(),
             recalculate_diffs_tx,
             _recalculate_diffs_task: cx.spawn(|_, mut cx| async move {
                 let mut buffers_to_diff = HashSet::default();
@@ -112,7 +105,100 @@ impl ProposedChangesEditor {
                 }
                 None
             }),
-            _subscriptions: subscriptions,
+        };
+        this.reset_locations(locations, cx);
+        this
+    }
+
+    pub fn branch_buffer_for_base(&self, base_buffer: &Model<Buffer>) -> Option<Model<Buffer>> {
+        self.buffer_entries.iter().find_map(|entry| {
+            if &entry.base == base_buffer {
+                Some(entry.branch.clone())
+            } else {
+                None
+            }
+        })
+    }
+
+    pub fn set_title(&mut self, title: SharedString, cx: &mut ViewContext<Self>) {
+        self.title = title;
+        cx.notify();
+    }
+
+    pub fn reset_locations<T: ToOffset>(
+        &mut self,
+        locations: Vec<ProposedChangeLocation<T>>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        // Undo all branch changes
+        for entry in &self.buffer_entries {
+            let base_version = entry.base.read(cx).version();
+            entry.branch.update(cx, |buffer, cx| {
+                let undo_counts = buffer
+                    .operations()
+                    .iter()
+                    .filter_map(|(timestamp, _)| {
+                        if !base_version.observed(*timestamp) {
+                            Some((*timestamp, u32::MAX))
+                        } else {
+                            None
+                        }
+                    })
+                    .collect();
+                buffer.undo_operations(undo_counts, cx);
+            });
+        }
+
+        self.multibuffer.update(cx, |multibuffer, cx| {
+            multibuffer.clear(cx);
+        });
+
+        let mut buffer_entries = Vec::new();
+        for location in locations {
+            let branch_buffer;
+            if let Some(ix) = self
+                .buffer_entries
+                .iter()
+                .position(|entry| entry.base == location.buffer)
+            {
+                let entry = self.buffer_entries.remove(ix);
+                branch_buffer = entry.branch.clone();
+                buffer_entries.push(entry);
+            } else {
+                branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx));
+                buffer_entries.push(BufferEntry {
+                    branch: branch_buffer.clone(),
+                    base: location.buffer.clone(),
+                    _subscription: cx.subscribe(&branch_buffer, Self::on_buffer_event),
+                });
+            }
+
+            self.multibuffer.update(cx, |multibuffer, cx| {
+                multibuffer.push_excerpts(
+                    branch_buffer,
+                    location.ranges.into_iter().map(|range| ExcerptRange {
+                        context: range,
+                        primary: None,
+                    }),
+                    cx,
+                );
+            });
+        }
+
+        self.buffer_entries = buffer_entries;
+        self.editor.update(cx, |editor, cx| {
+            editor.change_selections(None, cx, |selections| selections.refresh())
+        });
+    }
+
+    pub fn recalculate_all_buffer_diffs(&self) {
+        for (ix, entry) in self.buffer_entries.iter().enumerate().rev() {
+            self.recalculate_diffs_tx
+                .unbounded_send(RecalculateDiff {
+                    buffer: entry.branch.clone(),
+                    debounce: ix > 0,
+                })
+                .ok();
         }
     }
 
@@ -162,11 +248,11 @@ impl Item for ProposedChangesEditor {
     type Event = EditorEvent;
 
     fn tab_icon(&self, _cx: &ui::WindowContext) -> Option<Icon> {
-        Some(Icon::new(IconName::Pencil))
+        Some(Icon::new(IconName::Diff))
     }
 
     fn tab_content_text(&self, _cx: &WindowContext) -> Option<SharedString> {
-        Some("Proposed changes".into())
+        Some(self.title.clone())
     }
 
     fn as_searchable(&self, _: &View<Self>) -> Option<Box<dyn SearchableItemHandle>> {

crates/gpui/src/app/entity_map.rs 🔗

@@ -434,12 +434,10 @@ impl<T> Clone for Model<T> {
 
 impl<T> std::fmt::Debug for Model<T> {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(
-            f,
-            "Model {{ entity_id: {:?}, entity_type: {:?} }}",
-            self.any_model.entity_id,
-            type_name::<T>()
-        )
+        f.debug_struct("Model")
+            .field("entity_id", &self.any_model.entity_id)
+            .field("entity_type", &type_name::<T>())
+            .finish()
     }
 }
 
@@ -569,7 +567,10 @@ pub struct WeakModel<T> {
 
 impl<T> std::fmt::Debug for WeakModel<T> {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        f.debug_struct(type_name::<WeakModel<T>>()).finish()
+        f.debug_struct(&type_name::<Self>())
+            .field("entity_id", &self.any_model.entity_id)
+            .field("entity_type", &type_name::<T>())
+            .finish()
     }
 }
 

crates/language/src/buffer.rs 🔗

@@ -20,6 +20,7 @@ use anyhow::{anyhow, Context, Result};
 use async_watch as watch;
 use clock::Lamport;
 pub use clock::ReplicaId;
+use collections::HashMap;
 use futures::channel::oneshot;
 use gpui::{
     AnyElement, AppContext, Context as _, EventEmitter, HighlightStyle, Model, ModelContext,
@@ -910,10 +911,8 @@ impl Buffer {
         self.apply_ops([operation.clone()], cx);
 
         if let Some(timestamp) = operation_to_undo {
-            let operation = self
-                .text
-                .undo_operations([(timestamp, u32::MAX)].into_iter().collect());
-            self.send_operation(Operation::Buffer(operation), true, cx);
+            let counts = [(timestamp, u32::MAX)].into_iter().collect();
+            self.undo_operations(counts, cx);
         }
 
         self.diff_base_version += 1;
@@ -2331,6 +2330,18 @@ impl Buffer {
         undone
     }
 
+    pub fn undo_operations(
+        &mut self,
+        counts: HashMap<Lamport, u32>,
+        cx: &mut ModelContext<Buffer>,
+    ) {
+        let was_dirty = self.is_dirty();
+        let operation = self.text.undo_operations(counts);
+        let old_version = self.version.clone();
+        self.send_operation(Operation::Buffer(operation), true, cx);
+        self.did_edit(&old_version, was_dirty, cx);
+    }
+
     /// Manually redoes a specific transaction in the buffer's redo history.
     pub fn redo(&mut self, cx: &mut ModelContext<Self>) -> Option<TransactionId> {
         let was_dirty = self.is_dirty();

crates/languages/Cargo.toml 🔗

@@ -9,7 +9,9 @@ license = "GPL-3.0-or-later"
 workspace = true
 
 [features]
-test-support = []
+test-support = [
+    "tree-sitter"
+]
 load-grammars = [
     "tree-sitter-bash",
     "tree-sitter-c",
@@ -75,6 +77,7 @@ tree-sitter-yaml = { workspace = true, optional = true }
 util.workspace = true
 
 [dev-dependencies]
+tree-sitter.workspace = true
 text.workspace = true
 theme = { workspace = true, features = ["test-support"] }
 unindent.workspace = true

crates/text/src/text.rs 🔗

@@ -1427,7 +1427,7 @@ impl Buffer {
     fn undo_or_redo(&mut self, transaction: Transaction) -> Operation {
         let mut counts = HashMap::default();
         for edit_id in transaction.edit_ids {
-            counts.insert(edit_id, self.undo_map.undo_count(edit_id) + 1);
+            counts.insert(edit_id, self.undo_map.undo_count(edit_id).saturating_add(1));
         }
 
         let operation = self.undo_operations(counts);