edit predictions: Preview while holding modifier mode (#24316)

Agus Zubiaga and Danilo created

This PR adds a new `inline_completions.inline_preview` config which can
be set to `auto` (current behavior) or to `when_holding_modifier`.
When set to the latter, instead of showing edit prediction previews
inline in the buffer, we'll show it in a popover (even when there's no
LSP completion) so your isn't constantly moving as completions arrive.


https://github.com/user-attachments/assets/3615d151-3633-4ee4-98b9-66ee0aa735b8

Release Notes:

- N/A

---------

Co-authored-by: Danilo <danilo@zed.dev>

Change summary

assets/settings/default.json                          |  9 +
crates/assistant_context_editor/src/context_editor.rs |  2 
crates/editor/src/code_context_menus.rs               | 13 -
crates/editor/src/editor.rs                           | 91 +++++++-----
crates/editor/src/element.rs                          | 14 +
crates/editor/src/signature_help.rs                   |  2 
crates/language/src/language.rs                       |  1 
crates/language/src/language_settings.rs              | 41 +++++
8 files changed, 113 insertions(+), 60 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -783,7 +783,14 @@
       "**/*.cert",
       "**/*.crt",
       "**/secrets.yml"
-    ]
+    ],
+    // When to show edit predictions previews in buffer.
+    // This setting takes two possible values:
+    // 1. Display inline when there are no language server completions available.
+    //     "inline_preview": "auto"
+    // 2. Display inline when holding modifier key (alt by default).
+    //     "inline_preview": "when_holding_modifier"
+    "inline_preview": "auto"
   },
   // Settings specific to journaling
   "journal": {

crates/editor/src/code_context_menus.rs 🔗

@@ -169,7 +169,6 @@ pub struct CompletionsMenu {
     resolve_completions: bool,
     show_completion_documentation: bool,
     last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
-    pub previewing_inline_completion: bool,
 }
 
 impl CompletionsMenu {
@@ -200,7 +199,6 @@ impl CompletionsMenu {
             scroll_handle: UniformListScrollHandle::new(),
             resolve_completions: true,
             last_rendered_range: RefCell::new(None).into(),
-            previewing_inline_completion: false,
         }
     }
 
@@ -257,7 +255,6 @@ impl CompletionsMenu {
             resolve_completions: false,
             show_completion_documentation: false,
             last_rendered_range: RefCell::new(None).into(),
-            previewing_inline_completion: false,
         }
     }
 
@@ -410,12 +407,8 @@ impl CompletionsMenu {
         .detach();
     }
 
-    pub fn is_empty(&self) -> bool {
-        self.entries.borrow().is_empty()
-    }
-
     pub fn visible(&self) -> bool {
-        !self.is_empty() && !self.previewing_inline_completion
+        !self.entries.borrow().is_empty()
     }
 
     fn origin(&self) -> ContextMenuOrigin {
@@ -709,10 +702,6 @@ impl CompletionsMenu {
         // This keeps the display consistent when y_flipped.
         self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
     }
-
-    pub fn set_previewing_inline_completion(&mut self, value: bool) {
-        self.previewing_inline_completion = value;
-    }
 }
 
 #[derive(Clone)]

crates/editor/src/editor.rs 🔗

@@ -97,8 +97,8 @@ use language::{
     language_settings::{self, all_language_settings, language_settings, InlayHintSettings},
     markdown, point_from_lsp, AutoindentMode, BracketPair, Buffer, Capability, CharKind, CodeLabel,
     CompletionDocumentation, CursorShape, Diagnostic, EditPreview, HighlightedText, IndentKind,
-    IndentSize, Language, OffsetRangeExt, Point, Selection, SelectionGoal, TextObject,
-    TransactionId, TreeSitterOptions,
+    IndentSize, InlineCompletionPreviewMode, Language, OffsetRangeExt, Point, Selection,
+    SelectionGoal, TextObject, TransactionId, TreeSitterOptions,
 };
 use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange};
 use linked_editing_ranges::refresh_linked_ranges;
@@ -693,6 +693,7 @@ pub struct Editor {
     show_inline_completions: bool,
     show_inline_completions_override: Option<bool>,
     menu_inline_completions_policy: MenuInlineCompletionsPolicy,
+    previewing_inline_completion: bool,
     inlay_hint_cache: InlayHintCache,
     next_inlay_id: usize,
     _subscriptions: Vec<Subscription>,
@@ -1384,6 +1385,7 @@ impl Editor {
             inline_completion_provider: None,
             active_inline_completion: None,
             stale_inline_completion_in_menu: None,
+            previewing_inline_completion: false,
             inlay_hint_cache: InlayHintCache::new(inlay_hint_settings),
 
             gutter_hovered: false,
@@ -4662,6 +4664,18 @@ impl Editor {
         }
     }
 
+    fn inline_completion_preview_mode(&self, cx: &App) -> language::InlineCompletionPreviewMode {
+        let cursor = self.selections.newest_anchor().head();
+
+        self.buffer
+            .read(cx)
+            .text_anchor_for_position(cursor, cx)
+            .map(|(buffer, _)| {
+                all_language_settings(buffer.read(cx).file(), cx).inline_completions_preview_mode()
+            })
+            .unwrap_or_default()
+    }
+
     fn should_show_inline_completions_in_buffer(
         &self,
         buffer: &Entity<Buffer>,
@@ -5009,11 +5023,28 @@ impl Editor {
         true
     }
 
-    pub fn is_previewing_inline_completion(&self) -> bool {
-        matches!(
-            self.context_menu.borrow().as_ref(),
-            Some(CodeContextMenu::Completions(menu)) if !menu.is_empty() && menu.previewing_inline_completion
-        )
+    /// Returns true when we're displaying the inline completion popover below the cursor
+    /// like we are not previewing and the LSP autocomplete menu is visible
+    /// or we are in `when_holding_modifier` mode.
+    pub fn inline_completion_visible_in_cursor_popover(
+        &self,
+        has_completion: bool,
+        cx: &App,
+    ) -> bool {
+        if self.previewing_inline_completion
+            || !self.show_inline_completions_in_menu(cx)
+            || !self.should_show_inline_completions(cx)
+        {
+            return false;
+        }
+
+        if self.has_visible_completions_menu() {
+            return true;
+        }
+
+        has_completion
+            && self.inline_completion_preview_mode(cx)
+                == InlineCompletionPreviewMode::WhenHoldingModifier
     }
 
     fn update_inline_completion_preview(
@@ -5022,13 +5053,13 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        // Moves jump directly with a preview step
-
+        // Moves jump directly without a preview step
         if self
             .active_inline_completion
             .as_ref()
             .map_or(true, |c| c.is_move())
         {
+            self.previewing_inline_completion = false;
             cx.notify();
             return;
         }
@@ -5037,20 +5068,7 @@ impl Editor {
             return;
         }
 
-        let mut menu_borrow = self.context_menu.borrow_mut();
-
-        let Some(CodeContextMenu::Completions(completions_menu)) = menu_borrow.as_mut() else {
-            return;
-        };
-
-        if completions_menu.is_empty()
-            || completions_menu.previewing_inline_completion == modifiers.alt
-        {
-            return;
-        }
-
-        completions_menu.set_previewing_inline_completion(modifiers.alt);
-        drop(menu_borrow);
+        self.previewing_inline_completion = modifiers.alt;
         self.update_visible_inline_completion(window, cx);
     }
 
@@ -5146,7 +5164,7 @@ impl Editor {
                 snapshot,
             }
         } else {
-            if !show_in_menu || !self.has_active_completions_menu() {
+            if !self.inline_completion_visible_in_cursor_popover(true, cx) {
                 if edits
                     .iter()
                     .all(|(range, _)| range.to_offset(&multibuffer).is_empty())
@@ -5180,7 +5198,7 @@ impl Editor {
 
             let display_mode = if all_edits_insertions_or_deletions(&edits, &multibuffer) {
                 if provider.show_tab_accept_marker() {
-                    EditDisplayMode::TabAccept(self.is_previewing_inline_completion())
+                    EditDisplayMode::TabAccept(self.previewing_inline_completion)
                 } else {
                     EditDisplayMode::Inline
                 }
@@ -5443,10 +5461,12 @@ impl Editor {
     }
 
     pub fn context_menu_visible(&self) -> bool {
-        self.context_menu
-            .borrow()
-            .as_ref()
-            .map_or(false, |menu| menu.visible())
+        !self.previewing_inline_completion
+            && self
+                .context_menu
+                .borrow()
+                .as_ref()
+                .map_or(false, |menu| menu.visible())
     }
 
     fn context_menu_origin(&self) -> Option<ContextMenuOrigin> {
@@ -5848,9 +5868,7 @@ impl Editor {
         self.completion_tasks.clear();
         let context_menu = self.context_menu.borrow_mut().take();
         self.stale_inline_completion_in_menu.take();
-        if context_menu.is_some() {
-            self.update_visible_inline_completion(window, cx);
-        }
+        self.update_visible_inline_completion(window, cx);
         context_menu
     }
 
@@ -14438,10 +14456,11 @@ impl Editor {
         Some(gpui::Point::new(source_x, source_y))
     }
 
-    pub fn has_active_completions_menu(&self) -> bool {
-        self.context_menu.borrow().as_ref().map_or(false, |menu| {
-            menu.visible() && matches!(menu, CodeContextMenu::Completions(_))
-        })
+    pub fn has_visible_completions_menu(&self) -> bool {
+        !self.previewing_inline_completion
+            && self.context_menu.borrow().as_ref().map_or(false, |menu| {
+                menu.visible() && matches!(menu, CodeContextMenu::Completions(_))
+            })
     }
 
     pub fn register_addon<T: Addon>(&mut self, instance: T) {

crates/editor/src/element.rs 🔗

@@ -3109,7 +3109,10 @@ impl EditorElement {
 
         {
             let editor = self.editor.read(cx);
-            if editor.has_active_completions_menu() && editor.show_inline_completions_in_menu(cx) {
+            if editor.inline_completion_visible_in_cursor_popover(
+                editor.has_active_inline_completion(),
+                cx,
+            ) {
                 height_above_menu +=
                     editor.edit_prediction_cursor_popover_height() + POPOVER_Y_PADDING;
                 edit_prediction_popover_visible = true;
@@ -3615,7 +3618,12 @@ impl EditorElement {
         const PADDING_X: Pixels = Pixels(24.);
         const PADDING_Y: Pixels = Pixels(2.);
 
-        let active_inline_completion = self.editor.read(cx).active_inline_completion.as_ref()?;
+        let editor = self.editor.read(cx);
+        let active_inline_completion = editor.active_inline_completion.as_ref()?;
+
+        if editor.inline_completion_visible_in_cursor_popover(true, cx) {
+            return None;
+        }
 
         match &active_inline_completion.completion {
             InlineCompletion::Move { target, .. } => {
@@ -3682,7 +3690,7 @@ impl EditorElement {
                 display_mode,
                 snapshot,
             } => {
-                if self.editor.read(cx).has_active_completions_menu() {
+                if self.editor.read(cx).has_visible_completions_menu() {
                     return None;
                 }
 

crates/editor/src/signature_help.rs 🔗

@@ -158,7 +158,7 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if self.pending_rename.is_some() || self.has_active_completions_menu() {
+        if self.pending_rename.is_some() || self.has_visible_completions_menu() {
             return;
         }
 

crates/language/src/language.rs 🔗

@@ -21,6 +21,7 @@ mod toolchain;
 pub mod buffer_tests;
 pub mod markdown;
 
+pub use crate::language_settings::InlineCompletionPreviewMode;
 use crate::language_settings::SoftWrap;
 use anyhow::{anyhow, Context as _, Result};
 use async_trait::async_trait;

crates/language/src/language_settings.rs 🔗

@@ -214,6 +214,19 @@ pub struct InlineCompletionSettings {
     pub provider: InlineCompletionProvider,
     /// A list of globs representing files that edit predictions should be disabled for.
     pub disabled_globs: Vec<GlobMatcher>,
+    /// When to show edit predictions previews in buffer.
+    pub inline_preview: InlineCompletionPreviewMode,
+}
+
+/// The mode in which edit predictions should be displayed.
+#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum InlineCompletionPreviewMode {
+    /// Display inline when there are no language server completions available.
+    #[default]
+    Auto,
+    /// Display inline when holding modifier key (alt by default).
+    WhenHoldingModifier,
 }
 
 /// The settings for all languages.
@@ -406,6 +419,9 @@ pub struct InlineCompletionSettingsContent {
     /// A list of globs representing files that edit predictions should be disabled for.
     #[serde(default)]
     pub disabled_globs: Option<Vec<String>>,
+    /// When to show edit predictions previews in buffer.
+    #[serde(default)]
+    pub inline_preview: InlineCompletionPreviewMode,
 }
 
 /// The settings for enabling/disabling features.
@@ -890,6 +906,11 @@ impl AllLanguageSettings {
         self.language(None, language.map(|l| l.name()).as_ref(), cx)
             .show_inline_completions
     }
+
+    /// Returns the edit predictions preview mode for the given language and path.
+    pub fn inline_completions_preview_mode(&self) -> InlineCompletionPreviewMode {
+        self.inline_completions.inline_preview
+    }
 }
 
 fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigProperties) {
@@ -987,6 +1008,12 @@ impl settings::Settings for AllLanguageSettings {
             .features
             .as_ref()
             .and_then(|f| f.inline_completion_provider);
+        let mut inline_completions_preview = default_value
+            .inline_completions
+            .as_ref()
+            .map(|inline_completions| inline_completions.inline_preview)
+            .ok_or_else(Self::missing_default)?;
+
         let mut completion_globs: HashSet<&String> = default_value
             .inline_completions
             .as_ref()
@@ -1017,12 +1044,13 @@ impl settings::Settings for AllLanguageSettings {
             {
                 inline_completion_provider = Some(provider);
             }
-            if let Some(globs) = user_settings
-                .inline_completions
-                .as_ref()
-                .and_then(|f| f.disabled_globs.as_ref())
-            {
-                completion_globs.extend(globs.iter());
+
+            if let Some(inline_completions) = user_settings.inline_completions.as_ref() {
+                inline_completions_preview = inline_completions.inline_preview;
+
+                if let Some(disabled_globs) = inline_completions.disabled_globs.as_ref() {
+                    completion_globs.extend(disabled_globs.iter());
+                }
             }
 
             // A user's global settings override the default global settings and
@@ -1075,6 +1103,7 @@ impl settings::Settings for AllLanguageSettings {
                     .iter()
                     .filter_map(|g| Some(globset::Glob::new(g).ok()?.compile_matcher()))
                     .collect(),
+                inline_preview: inline_completions_preview,
             },
             defaults,
             languages,