Accept partial copilot suggestions (#8682)

Jonathan created

Fixes https://github.com/zed-industries/zed/issues/8020
 
This PR adds a new shortcut cmd-right, if a copilot suggestion exists.
The suggestions is accepted word by word.
It emulates the behaviour of VS Code's Github Copilot implementation.


Release Notes:

- Added ability to accept partial copilot suggestions ([8020](https://github.com/zed-industries/zed/issues/8020))

Change summary

assets/keymaps/default-linux.json |  15 +++
assets/keymaps/default-macos.json |  15 +++
crates/editor/src/actions.rs      |   1 
crates/editor/src/editor.rs       |  37 ++++++++++
crates/editor/src/editor_tests.rs | 122 +++++++++++++++++++++++++++++++++
crates/editor/src/element.rs      |   1 
6 files changed, 187 insertions(+), 4 deletions(-)

Detailed changes

assets/keymaps/default-linux.json πŸ”—

@@ -135,10 +135,21 @@
           "focus": true
         }
       ],
-      "alt-\\": "copilot::Suggest",
+      "ctrl->": "assistant::QuoteSelection"
+    }
+  },
+  {
+    "context": "Editor && mode == full && copilot_suggestion",
+    "bindings": {
       "alt-]": "copilot::NextSuggestion",
       "alt-[": "copilot::PreviousSuggestion",
-      "ctrl->": "assistant::QuoteSelection"
+      "alt-right": "editor::AcceptPartialCopilotSuggestion"
+    }
+  },
+  {
+    "context": "Editor && !copilot_suggestion",
+    "bindings": {
+      "alt-\\": "copilot::Suggest"
     }
   },
   {

assets/keymaps/default-macos.json πŸ”—

@@ -176,10 +176,21 @@
           "focus": false
         }
       ],
-      "alt-\\": "copilot::Suggest",
+      "cmd->": "assistant::QuoteSelection"
+    }
+  },
+  {
+    "context": "Editor && mode == full && copilot_suggestion",
+    "bindings": {
       "alt-]": "copilot::NextSuggestion",
       "alt-[": "copilot::PreviousSuggestion",
-      "cmd->": "assistant::QuoteSelection"
+      "alt-right": "editor::AcceptPartialCopilotSuggestion"
+    }
+  },
+  {
+    "context": "Editor && !copilot_suggestion",
+    "bindings": {
+      "alt-\\": "copilot::Suggest"
     }
   },
   {

crates/editor/src/actions.rs πŸ”—

@@ -119,6 +119,7 @@ impl_actions!(
 gpui::actions!(
     editor,
     [
+        AcceptPartialCopilotSuggestion,
         AddSelectionAbove,
         AddSelectionBelow,
         Backspace,

crates/editor/src/editor.rs πŸ”—

@@ -1627,6 +1627,10 @@ impl Editor {
             key_context.set("extension", extension.to_string());
         }
 
+        if self.has_active_copilot_suggestion(cx) {
+            key_context.add("copilot_suggestion");
+        }
+
         key_context
     }
 
@@ -3965,6 +3969,39 @@ impl Editor {
         }
     }
 
+    fn accept_partial_copilot_suggestion(
+        &mut self,
+        _: &AcceptPartialCopilotSuggestion,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if self.selections.count() == 1 && self.has_active_copilot_suggestion(cx) {
+            if let Some(suggestion) = self.take_active_copilot_suggestion(cx) {
+                let mut partial_suggestion = suggestion
+                    .text
+                    .chars()
+                    .by_ref()
+                    .take_while(|c| c.is_alphabetic())
+                    .collect::<String>();
+                if partial_suggestion.is_empty() {
+                    partial_suggestion = suggestion
+                        .text
+                        .chars()
+                        .by_ref()
+                        .take_while(|c| c.is_whitespace() || !c.is_alphabetic())
+                        .collect::<String>();
+                }
+
+                cx.emit(EditorEvent::InputHandled {
+                    utf16_range_to_replace: None,
+                    text: partial_suggestion.clone().into(),
+                });
+                self.insert_with_autoindent_mode(&partial_suggestion, None, cx);
+                self.refresh_copilot_suggestions(true, cx);
+                cx.notify();
+            }
+        }
+    }
+
     fn discard_copilot_suggestion(&mut self, cx: &mut ViewContext<Self>) -> bool {
         if let Some(suggestion) = self.take_active_copilot_suggestion(cx) {
             if let Some(copilot) = Copilot::global(cx) {

crates/editor/src/editor_tests.rs πŸ”—

@@ -7623,6 +7623,128 @@ async fn test_copilot(executor: BackgroundExecutor, cx: &mut gpui::TestAppContex
     });
 }
 
+#[gpui::test(iterations = 10)]
+async fn test_accept_partial_copilot_suggestion(
+    executor: BackgroundExecutor,
+    cx: &mut gpui::TestAppContext,
+) {
+    // flaky
+    init_test(cx, |_| {});
+
+    let (copilot, copilot_lsp) = Copilot::fake(cx);
+    _ = cx.update(|cx| Copilot::set_global(copilot, cx));
+    let mut cx = EditorLspTestContext::new_rust(
+        lsp::ServerCapabilities {
+            completion_provider: Some(lsp::CompletionOptions {
+                trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
+                ..Default::default()
+            }),
+            ..Default::default()
+        },
+        cx,
+    )
+    .await;
+
+    // Setup the editor with a completion request.
+    cx.set_state(indoc! {"
+        oneˇ
+        two
+        three
+    "});
+    cx.simulate_keystroke(".");
+    let _ = handle_completion_request(
+        &mut cx,
+        indoc! {"
+            one.|<>
+            two
+            three
+        "},
+        vec![],
+    );
+    handle_copilot_completion_request(
+        &copilot_lsp,
+        vec![copilot::request::Completion {
+            text: "one.copilot1".into(),
+            range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
+            ..Default::default()
+        }],
+        vec![],
+    );
+    executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
+    cx.update_editor(|editor, cx| {
+        assert!(editor.has_active_copilot_suggestion(cx));
+
+        // Accepting the first word of the suggestion should only accept the first word and still show the rest.
+        editor.accept_partial_copilot_suggestion(&Default::default(), cx);
+        assert!(editor.has_active_copilot_suggestion(cx));
+        assert_eq!(editor.text(cx), "one.copilot\ntwo\nthree\n");
+        assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n");
+
+        // Accepting next word should accept the non-word and copilot suggestion should be gone
+        editor.accept_partial_copilot_suggestion(&Default::default(), cx);
+        assert!(!editor.has_active_copilot_suggestion(cx));
+        assert_eq!(editor.text(cx), "one.copilot1\ntwo\nthree\n");
+        assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n");
+    });
+
+    // Reset the editor and check non-word and whitespace completion
+    cx.set_state(indoc! {"
+        oneˇ
+        two
+        three
+    "});
+    cx.simulate_keystroke(".");
+    let _ = handle_completion_request(
+        &mut cx,
+        indoc! {"
+            one.|<>
+            two
+            three
+        "},
+        vec![],
+    );
+    handle_copilot_completion_request(
+        &copilot_lsp,
+        vec![copilot::request::Completion {
+            text: "one.123. copilot\n 456".into(),
+            range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)),
+            ..Default::default()
+        }],
+        vec![],
+    );
+    executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
+    cx.update_editor(|editor, cx| {
+        assert!(editor.has_active_copilot_suggestion(cx));
+
+        // Accepting the first word (non-word) of the suggestion should only accept the first word and still show the rest.
+        editor.accept_partial_copilot_suggestion(&Default::default(), cx);
+        assert!(editor.has_active_copilot_suggestion(cx));
+        assert_eq!(editor.text(cx), "one.123. \ntwo\nthree\n");
+        assert_eq!(
+            editor.display_text(cx),
+            "one.123. copilot\n 456\ntwo\nthree\n"
+        );
+
+        // Accepting next word should accept the next word and copilot suggestion should still exist
+        editor.accept_partial_copilot_suggestion(&Default::default(), cx);
+        assert!(editor.has_active_copilot_suggestion(cx));
+        assert_eq!(editor.text(cx), "one.123. copilot\ntwo\nthree\n");
+        assert_eq!(
+            editor.display_text(cx),
+            "one.123. copilot\n 456\ntwo\nthree\n"
+        );
+
+        // Accepting the whitespace should accept the non-word/whitespaces with newline and copilot suggestion should be gone
+        editor.accept_partial_copilot_suggestion(&Default::default(), cx);
+        assert!(!editor.has_active_copilot_suggestion(cx));
+        assert_eq!(editor.text(cx), "one.123. copilot\n 456\ntwo\nthree\n");
+        assert_eq!(
+            editor.display_text(cx),
+            "one.123. copilot\n 456\ntwo\nthree\n"
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_copilot_completion_invalidation(
     executor: BackgroundExecutor,

crates/editor/src/element.rs πŸ”—

@@ -338,6 +338,7 @@ impl EditorElement {
         register_action(view, cx, Editor::display_cursor_names);
         register_action(view, cx, Editor::unique_lines_case_insensitive);
         register_action(view, cx, Editor::unique_lines_case_sensitive);
+        register_action(view, cx, Editor::accept_partial_copilot_suggestion);
     }
 
     fn register_key_listeners(