Return completion proposals from inline completion providers (#17578)

Kevin Wang created

Updates the inline completion provider to return a completion proposal
which is then converted to a completion state. This completion proposal
includes more detailed information about which inlays specifically
should be rendered.

Release Notes:

- Added support for fill-in-the-middle style inline completions


![image](https://github.com/user-attachments/assets/1830700f-5a76-4d1f-ac6d-246cc69b64c5)

Change summary

crates/copilot/src/copilot_completion_provider.rs       | 15 +
crates/editor/src/editor.rs                             | 88 +++++++---
crates/editor/src/inline_completion_provider.rs         | 18 +
crates/supermaven/src/supermaven_completion_provider.rs | 85 +++++++++
4 files changed, 167 insertions(+), 39 deletions(-)

Detailed changes

crates/copilot/src/copilot_completion_provider.rs 🔗

@@ -1,14 +1,14 @@
 use crate::{Completion, Copilot};
 use anyhow::Result;
 use client::telemetry::Telemetry;
-use editor::{Direction, InlineCompletionProvider};
+use editor::{CompletionProposal, Direction, InlayProposal, InlineCompletionProvider};
 use gpui::{AppContext, EntityId, Model, ModelContext, Task};
 use language::{
     language_settings::{all_language_settings, AllLanguageSettings},
     Buffer, OffsetRangeExt, ToOffset,
 };
 use settings::Settings;
-use std::{ops::Range, path::Path, sync::Arc, time::Duration};
+use std::{path::Path, sync::Arc, time::Duration};
 
 pub const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75);
 
@@ -237,7 +237,7 @@ impl InlineCompletionProvider for CopilotCompletionProvider {
         buffer: &Model<Buffer>,
         cursor_position: language::Anchor,
         cx: &'a AppContext,
-    ) -> Option<(&'a str, Option<Range<language::Anchor>>)> {
+    ) -> Option<CompletionProposal> {
         let buffer_id = buffer.entity_id();
         let buffer = buffer.read(cx);
         let completion = self.active_completion()?;
@@ -267,7 +267,14 @@ impl InlineCompletionProvider for CopilotCompletionProvider {
             if completion_text.trim().is_empty() {
                 None
             } else {
-                Some((completion_text, None))
+                Some(CompletionProposal {
+                    inlays: vec![InlayProposal::Suggestion(
+                        cursor_position.bias_right(buffer),
+                        completion_text.into(),
+                    )],
+                    text: completion_text.into(),
+                    delete_range: None,
+                })
             }
         } else {
             None

crates/editor/src/editor.rs 🔗

@@ -414,6 +414,22 @@ impl Default for EditorStyle {
 
 type CompletionId = usize;
 
+#[derive(Clone, Debug)]
+struct CompletionState {
+    // render_inlay_ids represents the inlay hints that are inserted
+    // for rendering the inline completions. They may be discontinuous
+    // in the event that the completion provider returns some intersection
+    // with the existing content.
+    render_inlay_ids: Vec<InlayId>,
+    // text is the resulting rope that is inserted when the user accepts a completion.
+    text: Rope,
+    // position is the position of the cursor when the completion was triggered.
+    position: multi_buffer::Anchor,
+    // delete_range is the range of text that this completion state covers.
+    // if the completion is accepted, this range should be deleted.
+    delete_range: Option<Range<multi_buffer::Anchor>>,
+}
+
 #[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Default)]
 struct EditorActionId(usize);
 
@@ -557,7 +573,7 @@ pub struct Editor {
     gutter_hovered: bool,
     hovered_link_state: Option<HoveredLinkState>,
     inline_completion_provider: Option<RegisteredInlineCompletionProvider>,
-    active_inline_completion: Option<(Inlay, Option<Range<Anchor>>)>,
+    active_inline_completion: Option<CompletionState>,
     // enable_inline_completions is a switch that Vim can use to disable
     // inline completions based on its mode.
     enable_inline_completions: bool,
@@ -5069,7 +5085,7 @@ impl Editor {
         _: &AcceptInlineCompletion,
         cx: &mut ViewContext<Self>,
     ) {
-        let Some((completion, delete_range)) = self.take_active_inline_completion(cx) else {
+        let Some(completion) = self.take_active_inline_completion(cx) else {
             return;
         };
         if let Some(provider) = self.inline_completion_provider() {
@@ -5081,7 +5097,7 @@ impl Editor {
             text: completion.text.to_string().into(),
         });
 
-        if let Some(range) = delete_range {
+        if let Some(range) = completion.delete_range {
             self.change_selections(None, cx, |s| s.select_ranges([range]))
         }
         self.insert_with_autoindent_mode(&completion.text.to_string(), None, cx);
@@ -5095,7 +5111,7 @@ impl Editor {
         cx: &mut ViewContext<Self>,
     ) {
         if self.selections.count() == 1 && self.has_active_inline_completion(cx) {
-            if let Some((completion, delete_range)) = self.take_active_inline_completion(cx) {
+            if let Some(completion) = self.take_active_inline_completion(cx) {
                 let mut partial_completion = completion
                     .text
                     .chars()
@@ -5116,7 +5132,7 @@ impl Editor {
                     text: partial_completion.clone().into(),
                 });
 
-                if let Some(range) = delete_range {
+                if let Some(range) = completion.delete_range {
                     self.change_selections(None, cx, |s| s.select_ranges([range]))
                 }
                 self.insert_with_autoindent_mode(&partial_completion, None, cx);
@@ -5142,7 +5158,7 @@ impl Editor {
     pub fn has_active_inline_completion(&self, cx: &AppContext) -> bool {
         if let Some(completion) = self.active_inline_completion.as_ref() {
             let buffer = self.buffer.read(cx).read(cx);
-            completion.0.position.is_valid(&buffer)
+            completion.position.is_valid(&buffer)
         } else {
             false
         }
@@ -5151,14 +5167,15 @@ impl Editor {
     fn take_active_inline_completion(
         &mut self,
         cx: &mut ViewContext<Self>,
-    ) -> Option<(Inlay, Option<Range<Anchor>>)> {
+    ) -> Option<CompletionState> {
         let completion = self.active_inline_completion.take()?;
+        let render_inlay_ids = completion.render_inlay_ids.clone();
         self.display_map.update(cx, |map, cx| {
-            map.splice_inlays(vec![completion.0.id], Default::default(), cx);
+            map.splice_inlays(render_inlay_ids, Default::default(), cx);
         });
         let buffer = self.buffer.read(cx).read(cx);
 
-        if completion.0.position.is_valid(&buffer) {
+        if completion.position.is_valid(&buffer) {
             Some(completion)
         } else {
             None
@@ -5179,31 +5196,50 @@ impl Editor {
                 if let Some((buffer, cursor_buffer_position)) =
                     self.buffer.read(cx).text_anchor_for_position(cursor, cx)
                 {
-                    if let Some((text, text_anchor_range)) =
+                    if let Some(proposal) =
                         provider.active_completion_text(&buffer, cursor_buffer_position, cx)
                     {
-                        let text = Rope::from(text);
                         let mut to_remove = Vec::new();
                         if let Some(completion) = self.active_inline_completion.take() {
-                            to_remove.push(completion.0.id);
+                            to_remove.extend(completion.render_inlay_ids.iter());
                         }
 
-                        let completion_inlay =
-                            Inlay::suggestion(post_inc(&mut self.next_inlay_id), cursor, text);
-
-                        let multibuffer_anchor_range = text_anchor_range.and_then(|range| {
-                            let snapshot = self.buffer.read(cx).snapshot(cx);
-                            Some(
-                                snapshot.anchor_in_excerpt(excerpt_id, range.start)?
-                                    ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?,
-                            )
+                        let to_add = proposal
+                            .inlays
+                            .iter()
+                            .filter_map(|inlay| {
+                                let snapshot = self.buffer.read(cx).snapshot(cx);
+                                let id = post_inc(&mut self.next_inlay_id);
+                                match inlay {
+                                    InlayProposal::Hint(position, hint) => {
+                                        let position =
+                                            snapshot.anchor_in_excerpt(excerpt_id, *position)?;
+                                        Some(Inlay::hint(id, position, hint))
+                                    }
+                                    InlayProposal::Suggestion(position, text) => {
+                                        let position =
+                                            snapshot.anchor_in_excerpt(excerpt_id, *position)?;
+                                        Some(Inlay::suggestion(id, position, text.clone()))
+                                    }
+                                }
+                            })
+                            .collect_vec();
+
+                        self.active_inline_completion = Some(CompletionState {
+                            position: cursor,
+                            text: proposal.text,
+                            delete_range: proposal.delete_range.and_then(|range| {
+                                let snapshot = self.buffer.read(cx).snapshot(cx);
+                                let start = snapshot.anchor_in_excerpt(excerpt_id, range.start);
+                                let end = snapshot.anchor_in_excerpt(excerpt_id, range.end);
+                                Some(start?..end?)
+                            }),
+                            render_inlay_ids: to_add.iter().map(|i| i.id).collect(),
                         });
-                        self.active_inline_completion =
-                            Some((completion_inlay.clone(), multibuffer_anchor_range));
 
-                        self.display_map.update(cx, move |map, cx| {
-                            map.splice_inlays(to_remove, vec![completion_inlay], cx)
-                        });
+                        self.display_map
+                            .update(cx, move |map, cx| map.splice_inlays(to_remove, to_add, cx));
+
                         cx.notify();
                         return;
                     }

crates/editor/src/inline_completion_provider.rs 🔗

@@ -2,6 +2,18 @@ use crate::Direction;
 use gpui::{AppContext, Model, ModelContext};
 use language::Buffer;
 use std::ops::Range;
+use text::{Anchor, Rope};
+
+pub enum InlayProposal {
+    Hint(Anchor, project::InlayHint),
+    Suggestion(Anchor, Rope),
+}
+
+pub struct CompletionProposal {
+    pub inlays: Vec<InlayProposal>,
+    pub text: Rope,
+    pub delete_range: Option<Range<Anchor>>,
+}
 
 pub trait InlineCompletionProvider: 'static + Sized {
     fn name() -> &'static str;
@@ -32,7 +44,7 @@ pub trait InlineCompletionProvider: 'static + Sized {
         buffer: &Model<Buffer>,
         cursor_position: language::Anchor,
         cx: &'a AppContext,
-    ) -> Option<(&'a str, Option<Range<language::Anchor>>)>;
+    ) -> Option<CompletionProposal>;
 }
 
 pub trait InlineCompletionProviderHandle {
@@ -63,7 +75,7 @@ pub trait InlineCompletionProviderHandle {
         buffer: &Model<Buffer>,
         cursor_position: language::Anchor,
         cx: &'a AppContext,
-    ) -> Option<(&'a str, Option<Range<language::Anchor>>)>;
+    ) -> Option<CompletionProposal>;
 }
 
 impl<T> InlineCompletionProviderHandle for Model<T>
@@ -118,7 +130,7 @@ where
         buffer: &Model<Buffer>,
         cursor_position: language::Anchor,
         cx: &'a AppContext,
-    ) -> Option<(&'a str, Option<Range<language::Anchor>>)> {
+    ) -> Option<CompletionProposal> {
         self.read(cx)
             .active_completion_text(buffer, cursor_position, cx)
     }

crates/supermaven/src/supermaven_completion_provider.rs 🔗

@@ -1,12 +1,17 @@
 use crate::{Supermaven, SupermavenCompletionStateId};
 use anyhow::Result;
 use client::telemetry::Telemetry;
-use editor::{Direction, InlineCompletionProvider};
+use editor::{CompletionProposal, Direction, InlayProposal, InlineCompletionProvider};
 use futures::StreamExt as _;
 use gpui::{AppContext, EntityId, Model, ModelContext, Task};
-use language::{language_settings::all_language_settings, Anchor, Buffer};
-use std::{ops::Range, path::Path, sync::Arc, time::Duration};
-use text::ToPoint;
+use language::{language_settings::all_language_settings, Anchor, Buffer, BufferSnapshot};
+use std::{
+    ops::{AddAssign, Range},
+    path::Path,
+    sync::Arc,
+    time::Duration,
+};
+use text::{ToOffset, ToPoint};
 
 pub const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75);
 
@@ -37,6 +42,69 @@ impl SupermavenCompletionProvider {
     }
 }
 
+// Computes the completion state from the difference between the completion text.
+// this is defined by greedily matching the buffer text against the completion text, with any leftover buffer placed at the end.
+// for example, given the completion text "moo cows are cool" and the buffer text "cowsre pool", the completion state would be
+// the inlays "moo ", " a", and "cool" which will render as "[moo ]cows[ a]re [cool]pool" in the editor.
+fn completion_state_from_diff(
+    snapshot: BufferSnapshot,
+    completion_text: &str,
+    position: Anchor,
+    delete_range: Range<Anchor>,
+) -> CompletionProposal {
+    let buffer_text = snapshot
+        .text_for_range(delete_range.clone())
+        .collect::<String>()
+        .chars()
+        .collect::<Vec<char>>();
+
+    let mut inlays: Vec<InlayProposal> = Vec::new();
+
+    let completion = completion_text.chars().collect::<Vec<char>>();
+
+    let mut offset = position.to_offset(&snapshot);
+
+    let mut i = 0;
+    let mut j = 0;
+    while i < completion.len() && j < buffer_text.len() {
+        // find the next instance of the buffer text in the completion text.
+        let k = completion[i..].iter().position(|c| *c == buffer_text[j]);
+        match k {
+            Some(k) => {
+                if k != 0 {
+                    // the range from the current position to item is an inlay.
+                    inlays.push(InlayProposal::Suggestion(
+                        snapshot.anchor_after(offset),
+                        completion_text[i..i + k].into(),
+                    ));
+                    offset.add_assign(j);
+                }
+                i += k + 1;
+                j += 1;
+            }
+            None => {
+                // there are no more matching completions, so drop the remaining
+                // completion text as an inlay.
+                break;
+            }
+        }
+    }
+
+    if j == buffer_text.len() && i < completion.len() {
+        // there is leftover completion text, so drop it as an inlay.
+        inlays.push(InlayProposal::Suggestion(
+            snapshot.anchor_after(offset),
+            completion_text[i..completion_text.len()].into(),
+        ));
+    }
+
+    CompletionProposal {
+        inlays,
+        text: completion_text.into(),
+        delete_range: Some(delete_range),
+    }
+}
+
 impl InlineCompletionProvider for SupermavenCompletionProvider {
     fn name() -> &'static str {
         "supermaven"
@@ -138,7 +206,7 @@ impl InlineCompletionProvider for SupermavenCompletionProvider {
         buffer: &Model<Buffer>,
         cursor_position: Anchor,
         cx: &'a AppContext,
-    ) -> Option<(&'a str, Option<Range<Anchor>>)> {
+    ) -> Option<CompletionProposal> {
         let completion_text = self
             .supermaven
             .read(cx)
@@ -153,7 +221,12 @@ impl InlineCompletionProvider for SupermavenCompletionProvider {
             let mut point = cursor_position.to_point(&snapshot);
             point.column = snapshot.line_len(point.row);
             let range = cursor_position..snapshot.anchor_after(point);
-            Some((completion_text, Some(range)))
+            Some(completion_state_from_diff(
+                snapshot,
+                completion_text,
+                cursor_position,
+                range,
+            ))
         } else {
             None
         }