zeta: Improve UX for simultaneous LSP and prediction completions (#24024)

Agus Zubiaga , Michael Sloan , Danilo , and Richard created

Release Notes:

- N/A

---------

Co-authored-by: Michael Sloan <michael@zed.dev>
Co-authored-by: Danilo <danilo@zed.dev>
Co-authored-by: Richard <richard@zed.dev>

Change summary

assets/keymaps/default-linux.json                 |   7 
assets/keymaps/default-macos.json                 |   7 
crates/copilot/src/copilot_completion_provider.rs |   4 
crates/editor/src/code_context_menus.rs           | 443 +++-------
crates/editor/src/display_map.rs                  |   2 
crates/editor/src/display_map/inlay_map.rs        |  16 
crates/editor/src/editor.rs                       | 674 +++++++++++-----
crates/editor/src/editor_tests.rs                 |  16 
crates/editor/src/element.rs                      | 543 ++++++++++---
crates/editor/src/inlay_hint_cache.rs             |   2 
crates/editor/src/inline_completion_tests.rs      |   4 
crates/editor/src/movement.rs                     |   2 
crates/language/src/buffer.rs                     |   7 
crates/ui/src/components/keybinding.rs            | 219 +++-
crates/vim/src/motion.rs                          |   4 
crates/zeta/src/zeta.rs                           |  10 
16 files changed, 1,197 insertions(+), 763 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -509,6 +509,13 @@
       "tab": "editor::AcceptInlineCompletion"
     }
   },
+  {
+    "context": "Editor && inline_completion && showing_completions",
+    "bindings": {
+      // Currently, changing this binding breaks the preview behavior
+      "alt-enter": "editor::AcceptInlineCompletion"
+    }
+  },
   {
     "context": "Editor && showing_code_actions",
     "bindings": {

assets/keymaps/default-macos.json 🔗

@@ -586,6 +586,13 @@
       "tab": "editor::AcceptInlineCompletion"
     }
   },
+  {
+    "context": "Editor && inline_completion && showing_completions",
+    "bindings": {
+      // Currently, changing this binding breaks the preview behavior
+      "alt-tab": "editor::AcceptInlineCompletion"
+    }
+  },
   {
     "context": "Editor && showing_code_actions",
     "use_key_equivalents": true,

crates/copilot/src/copilot_completion_provider.rs 🔗

@@ -341,7 +341,6 @@ mod tests {
         executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
         cx.update_editor(|editor, window, cx| {
             assert!(editor.context_menu_visible());
-            assert!(!editor.context_menu_contains_inline_completion());
             assert!(!editor.has_active_inline_completion());
             // Since we have both, the copilot suggestion is not shown inline
             assert_eq!(editor.text(cx), "one.\ntwo\nthree\n");
@@ -399,7 +398,6 @@ mod tests {
         executor.run_until_parked();
         cx.update_editor(|editor, _, cx| {
             assert!(!editor.context_menu_visible());
-            assert!(!editor.context_menu_contains_inline_completion());
             assert!(editor.has_active_inline_completion());
             assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n");
             assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n");
@@ -419,7 +417,6 @@ mod tests {
         cx.update_editor(|editor, window, cx| {
             assert!(!editor.context_menu_visible());
             assert!(editor.has_active_inline_completion());
-            assert!(!editor.context_menu_contains_inline_completion());
             assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n");
             assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n");
 
@@ -934,7 +931,6 @@ mod tests {
         executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT);
         cx.update_editor(|editor, _, cx| {
             assert!(editor.context_menu_visible());
-            assert!(!editor.context_menu_contains_inline_completion());
             assert!(!editor.has_active_inline_completion(),);
             assert_eq!(editor.text(cx), "one\ntwo.\nthree\n");
         });

crates/editor/src/code_context_menus.rs 🔗

@@ -1,8 +1,8 @@
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    div, pulsating_between, px, uniform_list, Animation, AnimationExt, AnyElement,
-    BackgroundExecutor, Div, Entity, FontWeight, ListSizingBehavior, ScrollStrategy, SharedString,
-    Size, StrikethroughStyle, StyledText, UniformListScrollHandle, WeakEntity,
+    div, px, uniform_list, AnyElement, BackgroundExecutor, Div, Entity, FontWeight,
+    ListSizingBehavior, ScrollStrategy, SharedString, Size, StrikethroughStyle, StyledText,
+    UniformListScrollHandle, WeakEntity,
 };
 use language::Buffer;
 use language::{CodeLabel, CompletionDocumentation};
@@ -10,8 +10,7 @@ use lsp::LanguageServerId;
 use multi_buffer::{Anchor, ExcerptId};
 use ordered_float::OrderedFloat;
 use project::{CodeAction, Completion, TaskSourceKind};
-use settings::Settings;
-use std::time::Duration;
+
 use std::{
     cell::RefCell,
     cmp::{min, Reverse},
@@ -26,11 +25,9 @@ use workspace::Workspace;
 
 use crate::{
     actions::{ConfirmCodeAction, ConfirmCompletion},
-    display_map::DisplayPoint,
     render_parsed_markdown, split_words, styled_runs_for_code_label, CodeActionProvider,
     CompletionId, CompletionProvider, DisplayRow, Editor, EditorStyle, ResolvedTasks,
 };
-use crate::{AcceptInlineCompletion, InlineCompletionMenuHint, InlineCompletionText};
 
 pub const MENU_GAP: Pixels = px(4.);
 pub const MENU_ASIDE_X_PADDING: Pixels = px(16.);
@@ -114,10 +111,10 @@ impl CodeContextMenu {
         }
     }
 
-    pub fn origin(&self, cursor_position: DisplayPoint) -> ContextMenuOrigin {
+    pub fn origin(&self) -> ContextMenuOrigin {
         match self {
-            CodeContextMenu::Completions(menu) => menu.origin(cursor_position),
-            CodeContextMenu::CodeActions(menu) => menu.origin(cursor_position),
+            CodeContextMenu::Completions(menu) => menu.origin(),
+            CodeContextMenu::CodeActions(menu) => menu.origin(),
         }
     }
 
@@ -154,7 +151,7 @@ impl CodeContextMenu {
 }
 
 pub enum ContextMenuOrigin {
-    EditorPoint(DisplayPoint),
+    Cursor,
     GutterIndicator(DisplayRow),
 }
 
@@ -166,18 +163,13 @@ pub struct CompletionsMenu {
     pub buffer: Entity<Buffer>,
     pub completions: Rc<RefCell<Box<[Completion]>>>,
     match_candidates: Rc<[StringMatchCandidate]>,
-    pub entries: Rc<RefCell<Vec<CompletionEntry>>>,
+    pub entries: Rc<RefCell<Vec<StringMatch>>>,
     pub selected_item: usize,
     scroll_handle: UniformListScrollHandle,
     resolve_completions: bool,
     show_completion_documentation: bool,
     last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
-}
-
-#[derive(Clone, Debug)]
-pub(crate) enum CompletionEntry {
-    Match(StringMatch),
-    InlineCompletionHint(InlineCompletionMenuHint),
+    pub previewing_inline_completion: bool,
 }
 
 impl CompletionsMenu {
@@ -208,6 +200,7 @@ impl CompletionsMenu {
             scroll_handle: UniformListScrollHandle::new(),
             resolve_completions: true,
             last_rendered_range: RefCell::new(None).into(),
+            previewing_inline_completion: false,
         }
     }
 
@@ -244,13 +237,11 @@ impl CompletionsMenu {
         let entries = choices
             .iter()
             .enumerate()
-            .map(|(id, completion)| {
-                CompletionEntry::Match(StringMatch {
-                    candidate_id: id,
-                    score: 1.,
-                    positions: vec![],
-                    string: completion.clone(),
-                })
+            .map(|(id, completion)| StringMatch {
+                candidate_id: id,
+                score: 1.,
+                positions: vec![],
+                string: completion.clone(),
             })
             .collect::<Vec<_>>();
         Self {
@@ -266,6 +257,7 @@ impl CompletionsMenu {
             resolve_completions: false,
             show_completion_documentation: false,
             last_rendered_range: RefCell::new(None).into(),
+            previewing_inline_completion: false,
         }
     }
 
@@ -340,24 +332,6 @@ impl CompletionsMenu {
         }
     }
 
-    pub fn show_inline_completion_hint(&mut self, hint: InlineCompletionMenuHint) {
-        let hint = CompletionEntry::InlineCompletionHint(hint);
-        let mut entries = self.entries.borrow_mut();
-        match entries.first() {
-            Some(CompletionEntry::InlineCompletionHint { .. }) => {
-                entries[0] = hint;
-            }
-            _ => {
-                entries.insert(0, hint);
-                // When `y_flipped`, need to scroll to bring it into view.
-                if self.selected_item == 0 {
-                    self.scroll_handle
-                        .scroll_to_item(self.selected_item, ScrollStrategy::Top);
-                }
-            }
-        }
-    }
-
     pub fn resolve_visible_completions(
         &mut self,
         provider: Option<&dyn CompletionProvider>,
@@ -406,17 +380,15 @@ impl CompletionsMenu {
         // This filtering doesn't happen if the completions are currently being updated.
         let completions = self.completions.borrow();
         let candidate_ids = entry_indices
-            .flat_map(|i| Self::entry_candidate_id(&entries[i]))
+            .map(|i| entries[i].candidate_id)
             .filter(|i| completions[*i].documentation.is_none());
 
         // Current selection is always resolved even if it already has documentation, to handle
         // out-of-spec language servers that return more results later.
-        let candidate_ids = match Self::entry_candidate_id(&entries[self.selected_item]) {
-            None => candidate_ids.collect::<Vec<usize>>(),
-            Some(selected_candidate_id) => iter::once(selected_candidate_id)
-                .chain(candidate_ids.filter(|id| *id != selected_candidate_id))
-                .collect::<Vec<usize>>(),
-        };
+        let selected_candidate_id = entries[self.selected_item].candidate_id;
+        let candidate_ids = iter::once(selected_candidate_id)
+            .chain(candidate_ids.filter(|id| *id != selected_candidate_id))
+            .collect::<Vec<usize>>();
         drop(entries);
 
         if candidate_ids.is_empty() {
@@ -438,19 +410,16 @@ impl CompletionsMenu {
         .detach();
     }
 
-    fn entry_candidate_id(entry: &CompletionEntry) -> Option<usize> {
-        match entry {
-            CompletionEntry::Match(entry) => Some(entry.candidate_id),
-            CompletionEntry::InlineCompletionHint { .. } => None,
-        }
+    pub fn is_empty(&self) -> bool {
+        self.entries.borrow().is_empty()
     }
 
     pub fn visible(&self) -> bool {
-        !self.entries.borrow().is_empty()
+        !self.is_empty() && !self.previewing_inline_completion
     }
 
-    fn origin(&self, cursor_position: DisplayPoint) -> ContextMenuOrigin {
-        ContextMenuOrigin::EditorPoint(cursor_position)
+    fn origin(&self) -> ContextMenuOrigin {
+        ContextMenuOrigin::Cursor
     }
 
     fn render(
@@ -468,23 +437,18 @@ impl CompletionsMenu {
             .borrow()
             .iter()
             .enumerate()
-            .max_by_key(|(_, mat)| match mat {
-                CompletionEntry::Match(mat) => {
-                    let completion = &completions[mat.candidate_id];
-                    let documentation = &completion.documentation;
-
-                    let mut len = completion.label.text.chars().count();
-                    if let Some(CompletionDocumentation::SingleLine(text)) = documentation {
-                        if show_completion_documentation {
-                            len += text.chars().count();
-                        }
-                    }
+            .max_by_key(|(_, mat)| {
+                let completion = &completions[mat.candidate_id];
+                let documentation = &completion.documentation;
 
-                    len
-                }
-                CompletionEntry::InlineCompletionHint(hint) => {
-                    "Zed AI / ".chars().count() + hint.label().chars().count()
+                let mut len = completion.label.text.chars().count();
+                if let Some(CompletionDocumentation::SingleLine(text)) = documentation {
+                    if show_completion_documentation {
+                        len += text.chars().count();
+                    }
                 }
+
+                len
             })
             .map(|(ix, _)| ix);
         drop(completions);
@@ -508,179 +472,83 @@ impl CompletionsMenu {
                     .enumerate()
                     .map(|(ix, mat)| {
                         let item_ix = start_ix + ix;
-                        let buffer_font = theme::ThemeSettings::get_global(cx).buffer_font.clone();
-                        let base_label = h_flex()
-                            .gap_1()
-                            .child(div().font(buffer_font.clone()).child("Zed AI"))
-                            .child(div().px_0p5().child("/").opacity(0.2));
-
-                        match mat {
-                            CompletionEntry::Match(mat) => {
-                                let candidate_id = mat.candidate_id;
-                                let completion = &completions_guard[candidate_id];
-
-                                let documentation = if show_completion_documentation {
-                                    &completion.documentation
-                                } else {
-                                    &None
-                                };
-
-                                let filter_start = completion.label.filter_range.start;
-                                let highlights = gpui::combine_highlights(
-                                    mat.ranges().map(|range| {
-                                        (
-                                            filter_start + range.start..filter_start + range.end,
-                                            FontWeight::BOLD.into(),
-                                        )
-                                    }),
-                                    styled_runs_for_code_label(&completion.label, &style.syntax)
-                                        .map(|(range, mut highlight)| {
-                                            // Ignore font weight for syntax highlighting, as we'll use it
-                                            // for fuzzy matches.
-                                            highlight.font_weight = None;
-
-                                            if completion.lsp_completion.deprecated.unwrap_or(false)
-                                            {
-                                                highlight.strikethrough =
-                                                    Some(StrikethroughStyle {
-                                                        thickness: 1.0.into(),
-                                                        ..Default::default()
-                                                    });
-                                                highlight.color =
-                                                    Some(cx.theme().colors().text_muted);
-                                            }
-
-                                            (range, highlight)
-                                        }),
-                                );
-
-                                let completion_label =
-                                    StyledText::new(completion.label.text.clone())
-                                        .with_highlights(&style.text, highlights);
-                                let documentation_label =
-                                    if let Some(CompletionDocumentation::SingleLine(text)) =
-                                        documentation
-                                    {
-                                        if text.trim().is_empty() {
-                                            None
-                                        } else {
-                                            Some(
-                                                Label::new(text.clone())
-                                                    .ml_4()
-                                                    .size(LabelSize::Small)
-                                                    .color(Color::Muted),
-                                            )
-                                        }
-                                    } else {
-                                        None
-                                    };
-
-                                let color_swatch = completion
-                                    .color()
-                                    .map(|color| div().size_4().bg(color).rounded_sm());
-
-                                div().min_w(px(220.)).max_w(px(540.)).child(
-                                    ListItem::new(mat.candidate_id)
-                                        .inset(true)
-                                        .toggle_state(item_ix == selected_item)
-                                        .on_click(cx.listener(move |editor, _event, window, cx| {
-                                            cx.stop_propagation();
-                                            if let Some(task) = editor.confirm_completion(
-                                                &ConfirmCompletion {
-                                                    item_ix: Some(item_ix),
-                                                },
-                                                window,
-                                                cx,
-                                            ) {
-                                                task.detach_and_log_err(cx)
-                                            }
-                                        }))
-                                        .start_slot::<Div>(color_swatch)
-                                        .child(h_flex().overflow_hidden().child(completion_label))
-                                        .end_slot::<Label>(documentation_label),
+                        let completion = &completions_guard[mat.candidate_id];
+                        let documentation = if show_completion_documentation {
+                            &completion.documentation
+                        } else {
+                            &None
+                        };
+
+                        let filter_start = completion.label.filter_range.start;
+                        let highlights = gpui::combine_highlights(
+                            mat.ranges().map(|range| {
+                                (
+                                    filter_start + range.start..filter_start + range.end,
+                                    FontWeight::BOLD.into(),
                                 )
-                            }
-                            CompletionEntry::InlineCompletionHint(
-                                hint @ InlineCompletionMenuHint::None,
-                            ) => div().min_w(px(250.)).max_w(px(500.)).child(
-                                ListItem::new("inline-completion")
-                                    .inset(true)
-                                    .toggle_state(item_ix == selected_item)
-                                    .start_slot(Icon::new(IconName::ZedPredict))
-                                    .child(
-                                        base_label.child(
-                                            StyledText::new(hint.label())
-                                                .with_highlights(&style.text, None),
-                                        ),
-                                    ),
-                            ),
-                            CompletionEntry::InlineCompletionHint(
-                                hint @ InlineCompletionMenuHint::Loading,
-                            ) => div().min_w(px(250.)).max_w(px(500.)).child(
-                                ListItem::new("inline-completion")
-                                    .inset(true)
-                                    .toggle_state(item_ix == selected_item)
-                                    .start_slot(Icon::new(IconName::ZedPredict))
-                                    .child(base_label.child({
-                                        let text_style = style.text.clone();
-                                        StyledText::new(hint.label())
-                                            .with_highlights(&text_style, None)
-                                            .with_animation(
-                                                "pulsating-label",
-                                                Animation::new(Duration::from_secs(1))
-                                                    .repeat()
-                                                    .with_easing(pulsating_between(0.4, 0.8)),
-                                                move |text, delta| {
-                                                    let mut text_style = text_style.clone();
-                                                    text_style.color =
-                                                        text_style.color.opacity(delta);
-                                                    text.with_highlights(&text_style, None)
-                                                },
-                                            )
-                                    })),
-                            ),
-                            CompletionEntry::InlineCompletionHint(
-                                hint @ InlineCompletionMenuHint::PendingTermsAcceptance,
-                            ) => div().min_w(px(250.)).max_w(px(500.)).child(
-                                ListItem::new("inline-completion")
-                                    .inset(true)
-                                    .toggle_state(item_ix == selected_item)
-                                    .start_slot(Icon::new(IconName::ZedPredict))
-                                    .child(
-                                        base_label.child(
-                                            StyledText::new(hint.label())
-                                                .with_highlights(&style.text, None),
-                                        ),
-                                    )
-                                    .on_click(cx.listener(move |editor, _event, window, cx| {
-                                        cx.stop_propagation();
-                                        editor.toggle_zed_predict_onboarding(window, cx);
-                                    })),
+                            }),
+                            styled_runs_for_code_label(&completion.label, &style.syntax).map(
+                                |(range, mut highlight)| {
+                                    // Ignore font weight for syntax highlighting, as we'll use it
+                                    // for fuzzy matches.
+                                    highlight.font_weight = None;
+                                    if completion.lsp_completion.deprecated.unwrap_or(false) {
+                                        highlight.strikethrough = Some(StrikethroughStyle {
+                                            thickness: 1.0.into(),
+                                            ..Default::default()
+                                        });
+                                        highlight.color = Some(cx.theme().colors().text_muted);
+                                    }
+
+                                    (range, highlight)
+                                },
                             ),
+                        );
+
+                        let completion_label = StyledText::new(completion.label.text.clone())
+                            .with_highlights(&style.text, highlights);
+                        let documentation_label = if let Some(
+                            CompletionDocumentation::SingleLine(text),
+                        ) = documentation
+                        {
+                            if text.trim().is_empty() {
+                                None
+                            } else {
+                                Some(
+                                    Label::new(text.clone())
+                                        .ml_4()
+                                        .size(LabelSize::Small)
+                                        .color(Color::Muted),
+                                )
+                            }
+                        } else {
+                            None
+                        };
 
-                            CompletionEntry::InlineCompletionHint(
-                                hint @ InlineCompletionMenuHint::Loaded { .. },
-                            ) => div().min_w(px(250.)).max_w(px(500.)).child(
-                                ListItem::new("inline-completion")
-                                    .inset(true)
-                                    .toggle_state(item_ix == selected_item)
-                                    .start_slot(Icon::new(IconName::ZedPredict))
-                                    .child(
-                                        base_label.child(
-                                            StyledText::new(hint.label())
-                                                .with_highlights(&style.text, None),
-                                        ),
-                                    )
-                                    .on_click(cx.listener(move |editor, _event, window, cx| {
-                                        cx.stop_propagation();
-                                        editor.accept_inline_completion(
-                                            &AcceptInlineCompletion {},
-                                            window,
-                                            cx,
-                                        );
-                                    })),
-                            ),
-                        }
+                        let color_swatch = completion
+                            .color()
+                            .map(|color| div().size_4().bg(color).rounded_sm());
+
+                        div().min_w(px(280.)).max_w(px(540.)).child(
+                            ListItem::new(mat.candidate_id)
+                                .inset(true)
+                                .toggle_state(item_ix == selected_item)
+                                .on_click(cx.listener(move |editor, _event, window, cx| {
+                                    cx.stop_propagation();
+                                    if let Some(task) = editor.confirm_completion(
+                                        &ConfirmCompletion {
+                                            item_ix: Some(item_ix),
+                                        },
+                                        window,
+                                        cx,
+                                    ) {
+                                        task.detach_and_log_err(cx)
+                                    }
+                                }))
+                                .start_slot::<Div>(color_swatch)
+                                .child(h_flex().overflow_hidden().child(completion_label))
+                                .end_slot::<Label>(documentation_label),
+                        )
                     })
                     .collect()
             },
@@ -706,45 +574,25 @@ impl CompletionsMenu {
             return None;
         }
 
-        let multiline_docs = match &self.entries.borrow()[self.selected_item] {
-            CompletionEntry::Match(mat) => {
-                match self.completions.borrow_mut()[mat.candidate_id]
-                    .documentation
-                    .as_ref()?
-                {
-                    CompletionDocumentation::MultiLinePlainText(text) => {
-                        div().child(SharedString::from(text.clone()))
-                    }
-                    CompletionDocumentation::MultiLineMarkdown(parsed)
-                        if !parsed.text.is_empty() =>
-                    {
-                        div().child(render_parsed_markdown(
-                            "completions_markdown",
-                            parsed,
-                            &style,
-                            workspace,
-                            cx,
-                        ))
-                    }
-                    CompletionDocumentation::MultiLineMarkdown(_) => return None,
-                    CompletionDocumentation::SingleLine(_) => return None,
-                    CompletionDocumentation::Undocumented => return None,
-                }
-            }
-            CompletionEntry::InlineCompletionHint(InlineCompletionMenuHint::Loaded { text }) => {
-                match text {
-                    InlineCompletionText::Edit(highlighted_edits) => div()
-                        .mx_1()
-                        .rounded_md()
-                        .bg(cx.theme().colors().editor_background)
-                        .child(
-                            gpui::StyledText::new(highlighted_edits.text.clone())
-                                .with_highlights(&style.text, highlighted_edits.highlights.clone()),
-                        ),
-                    InlineCompletionText::Move(text) => div().child(text.clone()),
-                }
+        let mat = &self.entries.borrow()[self.selected_item];
+        let multiline_docs = match self.completions.borrow_mut()[mat.candidate_id]
+            .documentation
+            .as_ref()?
+        {
+            CompletionDocumentation::MultiLinePlainText(text) => {
+                div().child(SharedString::from(text.clone()))
             }
-            CompletionEntry::InlineCompletionHint(_) => return None,
+            CompletionDocumentation::MultiLineMarkdown(parsed) if !parsed.text.is_empty() => div()
+                .child(render_parsed_markdown(
+                    "completions_markdown",
+                    parsed,
+                    &style,
+                    workspace,
+                    cx,
+                )),
+            CompletionDocumentation::MultiLineMarkdown(_) => return None,
+            CompletionDocumentation::SingleLine(_) => return None,
+            CompletionDocumentation::Undocumented => return None,
         };
 
         Some(
@@ -763,11 +611,6 @@ impl CompletionsMenu {
     }
 
     pub async fn filter(&mut self, query: Option<&str>, executor: BackgroundExecutor) {
-        let inline_completion_was_selected = self.selected_item == 0
-            && self.entries.borrow().first().map_or(false, |entry| {
-                matches!(entry, CompletionEntry::InlineCompletionHint(_))
-            });
-
         let mut matches = if let Some(query) = query {
             fuzzy::match_strings(
                 &self.match_candidates,
@@ -861,25 +704,15 @@ impl CompletionsMenu {
         }
         drop(completions);
 
-        let mut entries = self.entries.borrow_mut();
-        let new_selection = if let Some(CompletionEntry::InlineCompletionHint(_)) = entries.first()
-        {
-            entries.truncate(1);
-            if inline_completion_was_selected || matches.is_empty() {
-                0
-            } else {
-                1
-            }
-        } else {
-            entries.truncate(0);
-            0
-        };
-        entries.extend(matches.into_iter().map(CompletionEntry::Match));
-        self.selected_item = new_selection;
-        // Scroll to 0 even if the LSP completion is the only one selected. This keeps the display
-        // consistent when y_flipped.
+        *self.entries.borrow_mut() = matches;
+        self.selected_item = 0;
+        // 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)]
@@ -1077,11 +910,11 @@ impl CodeActionsMenu {
         !self.actions.is_empty()
     }
 
-    fn origin(&self, cursor_position: DisplayPoint) -> ContextMenuOrigin {
+    fn origin(&self) -> ContextMenuOrigin {
         if let Some(row) = self.deployed_from_indicator {
             ContextMenuOrigin::GutterIndicator(row)
         } else {
-            ContextMenuOrigin::EditorPoint(cursor_position)
+            ContextMenuOrigin::Cursor
         }
     }
 

crates/editor/src/display_map.rs 🔗

@@ -508,7 +508,7 @@ impl DisplayMap {
 
     pub(crate) fn splice_inlays(
         &mut self,
-        to_remove: Vec<InlayId>,
+        to_remove: &[InlayId],
         to_insert: Vec<Inlay>,
         cx: &mut Context<Self>,
     ) {

crates/editor/src/display_map/inlay_map.rs 🔗

@@ -545,7 +545,7 @@ impl InlayMap {
 
     pub fn splice(
         &mut self,
-        to_remove: Vec<InlayId>,
+        to_remove: &[InlayId],
         to_insert: Vec<Inlay>,
     ) -> (InlaySnapshot, Vec<InlayEdit>) {
         let snapshot = &mut self.snapshot;
@@ -653,7 +653,7 @@ impl InlayMap {
         }
         log::info!("removing inlays: {:?}", to_remove);
 
-        let (snapshot, edits) = self.splice(to_remove, to_insert);
+        let (snapshot, edits) = self.splice(&to_remove, to_insert);
         (snapshot, edits)
     }
 }
@@ -1171,7 +1171,7 @@ mod tests {
         let mut next_inlay_id = 0;
 
         let (inlay_snapshot, _) = inlay_map.splice(
-            Vec::new(),
+            &[],
             vec![Inlay {
                 id: InlayId::Hint(post_inc(&mut next_inlay_id)),
                 position: buffer.read(cx).snapshot(cx).anchor_after(3),
@@ -1247,7 +1247,7 @@ mod tests {
         assert_eq!(inlay_snapshot.text(), "abxyDzefghi");
 
         let (inlay_snapshot, _) = inlay_map.splice(
-            Vec::new(),
+            &[],
             vec![
                 Inlay {
                     id: InlayId::Hint(post_inc(&mut next_inlay_id)),
@@ -1444,7 +1444,11 @@ mod tests {
 
         // The inlays can be manually removed.
         let (inlay_snapshot, _) = inlay_map.splice(
-            inlay_map.inlays.iter().map(|inlay| inlay.id).collect(),
+            &inlay_map
+                .inlays
+                .iter()
+                .map(|inlay| inlay.id)
+                .collect::<Vec<InlayId>>(),
             Vec::new(),
         );
         assert_eq!(inlay_snapshot.text(), "abxJKLyDzefghi");
@@ -1458,7 +1462,7 @@ mod tests {
         let mut next_inlay_id = 0;
 
         let (inlay_snapshot, _) = inlay_map.splice(
-            Vec::new(),
+            &[],
             vec![
                 Inlay {
                     id: InlayId::Hint(post_inc(&mut next_inlay_id)),

crates/editor/src/editor.rs 🔗

@@ -73,17 +73,18 @@ use zed_predict_onboarding::ZedPredictModal;
 
 use code_context_menus::{
     AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu,
-    CompletionEntry, CompletionsMenu, ContextMenuOrigin,
+    CompletionsMenu, ContextMenuOrigin,
 };
 use git::blame::GitBlame;
 use gpui::{
-    div, impl_actions, point, prelude::*, px, relative, size, Action, AnyElement, App,
-    AsyncWindowContext, AvailableSpace, Bounds, ClipboardEntry, ClipboardItem, Context,
-    DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent,
-    Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, InteractiveText, KeyContext,
-    MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, SharedString, Size,
-    Styled, StyledText, Subscription, Task, TextStyle, TextStyleRefinement, UTF16Selection,
-    UnderlineStyle, UniformListScrollHandle, WeakEntity, WeakFocusHandle, Window,
+    div, impl_actions, point, prelude::*, pulsating_between, px, relative, size, Action, Animation,
+    AnimationExt, AnyElement, App, AsyncWindowContext, AvailableSpace, Bounds, ClipboardEntry,
+    ClipboardItem, Context, DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter,
+    FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla,
+    InteractiveText, KeyContext, Modifiers, MouseButton, MouseDownEvent, PaintQuad, ParentElement,
+    Pixels, Render, SharedString, Size, Styled, StyledText, Subscription, Task, TextStyle,
+    TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity,
+    WeakFocusHandle, Window,
 };
 use highlight_matching_bracket::refresh_matching_bracket_highlights;
 use hover_popover::{hide_hover, HoverState};
@@ -107,7 +108,7 @@ pub use proposed_changes_editor::{
     ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
 };
 use similar::{ChangeTag, TextDiff};
-use std::iter::Peekable;
+use std::iter::{self, Peekable};
 use task::{ResolvedTask, TaskTemplate, TaskVariables};
 
 use hover_links::{find_file, HoverLink, HoveredLinkState, InlayHighlight};
@@ -163,7 +164,7 @@ use ui::{
     h_flex, prelude::*, ButtonSize, ButtonStyle, Disclosure, IconButton, IconName, IconSize,
     Tooltip,
 };
-use util::{defer, maybe, post_inc, RangeExt, ResultExt, TryFutureExt};
+use util::{defer, maybe, post_inc, RangeExt, ResultExt, TakeUntilExt, TryFutureExt};
 use workspace::item::{ItemHandle, PreviewTabsSettings};
 use workspace::notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt};
 use workspace::{
@@ -465,32 +466,6 @@ pub fn make_suggestion_styles(cx: &mut App) -> InlineCompletionStyles {
 
 type CompletionId = usize;
 
-#[derive(Debug, Clone)]
-enum InlineCompletionMenuHint {
-    Loading,
-    Loaded { text: InlineCompletionText },
-    PendingTermsAcceptance,
-    None,
-}
-
-impl InlineCompletionMenuHint {
-    pub fn label(&self) -> &'static str {
-        match self {
-            InlineCompletionMenuHint::Loading | InlineCompletionMenuHint::Loaded { .. } => {
-                "Edit Prediction"
-            }
-            InlineCompletionMenuHint::PendingTermsAcceptance => "Accept Terms of Service",
-            InlineCompletionMenuHint::None => "No Prediction",
-        }
-    }
-}
-
-#[derive(Clone, Debug)]
-enum InlineCompletionText {
-    Move(SharedString),
-    Edit(HighlightedText),
-}
-
 pub(crate) enum EditDisplayMode {
     TabAccept,
     DiffPopover,
@@ -504,7 +479,11 @@ enum InlineCompletion {
         display_mode: EditDisplayMode,
         snapshot: BufferSnapshot,
     },
-    Move(Anchor),
+    Move {
+        target: Anchor,
+        range_around_target: Range<text::Anchor>,
+        snapshot: BufferSnapshot,
+    },
 }
 
 struct InlineCompletionState {
@@ -513,6 +492,15 @@ struct InlineCompletionState {
     invalidation_range: Range<Anchor>,
 }
 
+impl InlineCompletionState {
+    pub fn is_move(&self) -> bool {
+        match &self.completion {
+            InlineCompletion::Move { .. } => true,
+            _ => false,
+        }
+    }
+}
+
 enum InlineCompletionHighlight {}
 
 pub enum MenuInlineCompletionsPolicy {
@@ -687,6 +675,8 @@ pub struct Editor {
     inline_completion_provider: Option<RegisteredInlineCompletionProvider>,
     code_action_providers: Vec<Rc<dyn CodeActionProvider>>,
     active_inline_completion: Option<InlineCompletionState>,
+    /// Used to prevent flickering as the user types while the menu is open
+    stale_inline_completion_in_menu: Option<InlineCompletionState>,
     // enable_inline_completions is a switch that Vim can use to disable
     // inline completions based on its mode.
     enable_inline_completions: bool,
@@ -1381,6 +1371,7 @@ impl Editor {
             hovered_link_state: Default::default(),
             inline_completion_provider: None,
             active_inline_completion: None,
+            stale_inline_completion_in_menu: None,
             inlay_hint_cache: InlayHintCache::new(inlay_hint_settings),
 
             gutter_hovered: false,
@@ -1496,7 +1487,7 @@ impl Editor {
         match self.context_menu.borrow().as_ref() {
             Some(CodeContextMenu::Completions(_)) => {
                 key_context.add("menu");
-                key_context.add("showing_completions")
+                key_context.add("showing_completions");
             }
             Some(CodeContextMenu::CodeActions(_)) => {
                 key_context.add("menu");
@@ -2611,9 +2602,6 @@ impl Editor {
         }
 
         if self.hide_context_menu(window, cx).is_some() {
-            if self.show_inline_completions_in_menu(cx) && self.has_active_inline_completion() {
-                self.update_visible_inline_completion(window, cx);
-            }
             return true;
         }
 
@@ -3601,10 +3589,11 @@ impl Editor {
                 } else {
                     self.inlay_hint_cache.clear();
                     self.splice_inlays(
-                        self.visible_inlay_hints(cx)
+                        &self
+                            .visible_inlay_hints(cx)
                             .iter()
                             .map(|inlay| inlay.id)
-                            .collect(),
+                            .collect::<Vec<InlayId>>(),
                         Vec::new(),
                         cx,
                     );
@@ -3622,7 +3611,7 @@ impl Editor {
                         to_remove,
                         to_insert,
                     })) => {
-                        self.splice_inlays(to_remove, to_insert, cx);
+                        self.splice_inlays(&to_remove, to_insert, cx);
                         return;
                     }
                     ControlFlow::Break(None) => return,
@@ -3635,7 +3624,7 @@ impl Editor {
                     to_insert,
                 }) = self.inlay_hint_cache.remove_excerpts(excerpts_removed)
                 {
-                    self.splice_inlays(to_remove, to_insert, cx);
+                    self.splice_inlays(&to_remove, to_insert, cx);
                 }
                 return;
             }
@@ -3658,7 +3647,7 @@ impl Editor {
             ignore_debounce,
             cx,
         ) {
-            self.splice_inlays(to_remove, to_insert, cx);
+            self.splice_inlays(&to_remove, to_insert, cx);
         }
     }
 
@@ -3738,7 +3727,7 @@ impl Editor {
 
     pub fn splice_inlays(
         &self,
-        to_remove: Vec<InlayId>,
+        to_remove: &[InlayId],
         to_insert: Vec<Inlay>,
         cx: &mut Context<Self>,
     ) {
@@ -3905,17 +3894,15 @@ impl Editor {
                         let mut menu = menu.unwrap();
                         menu.resolve_visible_completions(editor.completion_provider.as_deref(), cx);
 
+                        *editor.context_menu.borrow_mut() =
+                            Some(CodeContextMenu::Completions(menu));
+
                         if editor.show_inline_completions_in_menu(cx) {
-                            if let Some(hint) = editor.inline_completion_menu_hint(window, cx) {
-                                menu.show_inline_completion_hint(hint);
-                            }
+                            editor.update_visible_inline_completion(window, cx);
                         } else {
                             editor.discard_inline_completion(false, cx);
                         }
 
-                        *editor.context_menu.borrow_mut() =
-                            Some(CodeContextMenu::Completions(menu));
-
                         cx.notify();
                     } else if editor.completion_tasks.len() <= 1 {
                         // If there are no more completion tasks and the last menu was
@@ -3982,34 +3969,6 @@ impl Editor {
     ) -> Option<Task<std::result::Result<(), anyhow::Error>>> {
         use language::ToOffset as _;
 
-        {
-            let context_menu = self.context_menu.borrow();
-            if let CodeContextMenu::Completions(menu) = context_menu.as_ref()? {
-                let entries = menu.entries.borrow();
-                let entry = entries.get(item_ix.unwrap_or(menu.selected_item));
-                match entry {
-                    Some(CompletionEntry::InlineCompletionHint(
-                        InlineCompletionMenuHint::Loading,
-                    )) => return Some(Task::ready(Ok(()))),
-                    Some(CompletionEntry::InlineCompletionHint(InlineCompletionMenuHint::None)) => {
-                        drop(entries);
-                        drop(context_menu);
-                        self.context_menu_next(&Default::default(), window, cx);
-                        return Some(Task::ready(Ok(())));
-                    }
-                    Some(CompletionEntry::InlineCompletionHint(
-                        InlineCompletionMenuHint::PendingTermsAcceptance,
-                    )) => {
-                        drop(entries);
-                        drop(context_menu);
-                        self.toggle_zed_predict_onboarding(window, cx);
-                        return Some(Task::ready(Ok(())));
-                    }
-                    _ => {}
-                }
-            }
-        }
-
         let completions_menu =
             if let CodeContextMenu::Completions(menu) = self.hide_context_menu(window, cx)? {
                 menu
@@ -4019,19 +3978,9 @@ impl Editor {
 
         let entries = completions_menu.entries.borrow();
         let mat = entries.get(item_ix.unwrap_or(completions_menu.selected_item))?;
-        let mat = match mat {
-            CompletionEntry::InlineCompletionHint(_) => {
-                self.accept_inline_completion(&AcceptInlineCompletion, window, cx);
-                cx.stop_propagation();
-                return Some(Task::ready(Ok(())));
-            }
-            CompletionEntry::Match(mat) => {
-                if self.show_inline_completions_in_menu(cx) {
-                    self.discard_inline_completion(true, cx);
-                }
-                mat
-            }
-        };
+        if self.show_inline_completions_in_menu(cx) {
+            self.discard_inline_completion(true, cx);
+        }
         let candidate_id = mat.candidate_id;
         drop(entries);
 
@@ -4863,10 +4812,10 @@ impl Editor {
         self.report_inline_completion_event(true, cx);
 
         match &active_inline_completion.completion {
-            InlineCompletion::Move(position) => {
-                let position = *position;
+            InlineCompletion::Move { target, .. } => {
+                let target = *target;
                 self.change_selections(Some(Autoscroll::newest()), window, cx, |selections| {
-                    selections.select_anchor_ranges([position..position]);
+                    selections.select_anchor_ranges([target..target]);
                 });
             }
             InlineCompletion::Edit { edits, .. } => {
@@ -4911,10 +4860,10 @@ impl Editor {
         self.report_inline_completion_event(true, cx);
 
         match &active_inline_completion.completion {
-            InlineCompletion::Move(position) => {
-                let position = *position;
+            InlineCompletion::Move { target, .. } => {
+                let target = *target;
                 self.change_selections(Some(Autoscroll::newest()), window, cx, |selections| {
-                    selections.select_anchor_ranges([position..position]);
+                    selections.select_anchor_ranges([target..target]);
                 });
             }
             InlineCompletion::Edit { edits, .. } => {
@@ -4973,7 +4922,7 @@ impl Editor {
             provider.discard(cx);
         }
 
-        self.take_active_inline_completion(cx).is_some()
+        self.take_active_inline_completion(cx)
     }
 
     fn report_inline_completion_event(&self, accepted: bool, cx: &App) {
@@ -5010,19 +4959,58 @@ impl Editor {
         self.active_inline_completion.is_some()
     }
 
-    fn take_active_inline_completion(
+    fn take_active_inline_completion(&mut self, cx: &mut Context<Self>) -> bool {
+        let Some(active_inline_completion) = self.active_inline_completion.take() else {
+            return false;
+        };
+
+        self.splice_inlays(&active_inline_completion.inlay_ids, Default::default(), cx);
+        self.clear_highlights::<InlineCompletionHighlight>(cx);
+        self.stale_inline_completion_in_menu = Some(active_inline_completion);
+        true
+    }
+
+    fn update_inline_completion_preview(
         &mut self,
+        modifiers: &Modifiers,
+        window: &mut Window,
         cx: &mut Context<Self>,
-    ) -> Option<InlineCompletion> {
-        let active_inline_completion = self.active_inline_completion.take()?;
-        self.splice_inlays(active_inline_completion.inlay_ids, Default::default(), cx);
-        self.clear_highlights::<InlineCompletionHighlight>(cx);
-        Some(active_inline_completion.completion)
+    ) {
+        // Moves jump directly with a preview step
+
+        if self
+            .active_inline_completion
+            .as_ref()
+            .map_or(true, |c| c.is_move())
+        {
+            cx.notify();
+            return;
+        }
+
+        if !self.show_inline_completions_in_menu(cx) {
+            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.update_visible_inline_completion(window, cx);
     }
 
     fn update_visible_inline_completion(
         &mut self,
-        window: &mut Window,
+        _window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Option<()> {
         let selection = self.selections.newest_anchor();
@@ -5031,7 +5019,8 @@ impl Editor {
         let offset_selection = selection.map(|endpoint| endpoint.to_offset(&multibuffer));
         let excerpt_id = cursor.excerpt_id;
 
-        let completions_menu_has_precedence = !self.show_inline_completions_in_menu(cx)
+        let show_in_menu = self.show_inline_completions_in_menu(cx);
+        let completions_menu_has_precedence = !show_in_menu
             && (self.context_menu.borrow().is_some()
                 || (!self.completion_tasks.is_empty() && !self.has_active_inline_completion()));
         if completions_menu_has_precedence
@@ -5080,50 +5069,73 @@ impl Editor {
 
         let cursor_row = cursor.to_point(&multibuffer).row;
 
+        let snapshot = multibuffer.buffer_for_excerpt(excerpt_id).cloned()?;
+
         let mut inlay_ids = Vec::new();
         let invalidation_row_range;
-        let completion = if cursor_row < edit_start_row {
-            invalidation_row_range = cursor_row..edit_end_row;
-            InlineCompletion::Move(first_edit_start)
+        let move_invalidation_row_range = if cursor_row < edit_start_row {
+            Some(cursor_row..edit_end_row)
         } else if cursor_row > edit_end_row {
-            invalidation_row_range = edit_start_row..cursor_row;
-            InlineCompletion::Move(first_edit_start)
+            Some(edit_start_row..cursor_row)
         } else {
-            if edits
-                .iter()
-                .all(|(range, _)| range.to_offset(&multibuffer).is_empty())
-            {
-                let mut inlays = Vec::new();
-                for (range, new_text) in &edits {
-                    let inlay = Inlay::inline_completion(
-                        post_inc(&mut self.next_inlay_id),
-                        range.start,
-                        new_text.as_str(),
+            None
+        };
+        let completion = if let Some(move_invalidation_row_range) = move_invalidation_row_range {
+            invalidation_row_range = move_invalidation_row_range;
+            let target = first_edit_start;
+            let target_point = text::ToPoint::to_point(&target.text_anchor, &snapshot);
+            // TODO: Base this off of TreeSitter or word boundaries?
+            let target_excerpt_begin = snapshot.anchor_before(snapshot.clip_point(
+                Point::new(target_point.row, target_point.column.saturating_sub(10)),
+                Bias::Left,
+            ));
+            let target_excerpt_end = snapshot.anchor_after(snapshot.clip_point(
+                Point::new(target_point.row, target_point.column + 10),
+                Bias::Right,
+            ));
+            // TODO: Extend this to be before the jump target, and draw a cursor at the jump target
+            // (using Editor::current_user_player_color).
+            let range_around_target = target_excerpt_begin..target_excerpt_end;
+            InlineCompletion::Move {
+                target,
+                range_around_target,
+                snapshot,
+            }
+        } else {
+            if !show_in_menu || !self.has_active_completions_menu() {
+                if edits
+                    .iter()
+                    .all(|(range, _)| range.to_offset(&multibuffer).is_empty())
+                {
+                    let mut inlays = Vec::new();
+                    for (range, new_text) in &edits {
+                        let inlay = Inlay::inline_completion(
+                            post_inc(&mut self.next_inlay_id),
+                            range.start,
+                            new_text.as_str(),
+                        );
+                        inlay_ids.push(inlay.id);
+                        inlays.push(inlay);
+                    }
+
+                    self.splice_inlays(&[], inlays, cx);
+                } else {
+                    let background_color = cx.theme().status().deleted_background;
+                    self.highlight_text::<InlineCompletionHighlight>(
+                        edits.iter().map(|(range, _)| range.clone()).collect(),
+                        HighlightStyle {
+                            background_color: Some(background_color),
+                            ..Default::default()
+                        },
+                        cx,
                     );
-                    inlay_ids.push(inlay.id);
-                    inlays.push(inlay);
                 }
-
-                self.splice_inlays(vec![], inlays, cx);
-            } else {
-                let background_color = cx.theme().status().deleted_background;
-                self.highlight_text::<InlineCompletionHighlight>(
-                    edits.iter().map(|(range, _)| range.clone()).collect(),
-                    HighlightStyle {
-                        background_color: Some(background_color),
-                        ..Default::default()
-                    },
-                    cx,
-                );
             }
 
             invalidation_row_range = edit_start_row..edit_end_row;
 
             let display_mode = if all_edits_insertions_or_deletions(&edits, &multibuffer) {
-                if provider.show_tab_accept_marker()
-                    && first_edit_start_point.row == last_edit_end_point.row
-                    && !edits.iter().any(|(_, edit)| edit.contains('\n'))
-                {
+                if provider.show_tab_accept_marker() {
                     EditDisplayMode::TabAccept
                 } else {
                     EditDisplayMode::Inline
@@ -5132,8 +5144,6 @@ impl Editor {
                 EditDisplayMode::DiffPopover
             };
 
-            let snapshot = multibuffer.buffer_for_excerpt(excerpt_id).cloned()?;
-
             InlineCompletion::Edit {
                 edits,
                 edit_preview: inline_completion.edit_preview,
@@ -5149,69 +5159,18 @@ impl Editor {
                 multibuffer.line_len(MultiBufferRow(invalidation_row_range.end)),
             ));
 
+        self.stale_inline_completion_in_menu = None;
         self.active_inline_completion = Some(InlineCompletionState {
             inlay_ids,
             completion,
             invalidation_range,
         });
 
-        if self.show_inline_completions_in_menu(cx) && self.has_active_completions_menu() {
-            if let Some(hint) = self.inline_completion_menu_hint(window, cx) {
-                match self.context_menu.borrow_mut().as_mut() {
-                    Some(CodeContextMenu::Completions(menu)) => {
-                        menu.show_inline_completion_hint(hint);
-                    }
-                    _ => {}
-                }
-            }
-        }
-
         cx.notify();
 
         Some(())
     }
 
-    fn inline_completion_menu_hint(
-        &self,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Option<InlineCompletionMenuHint> {
-        let provider = self.inline_completion_provider()?;
-        if self.has_active_inline_completion() {
-            let editor_snapshot = self.snapshot(window, cx);
-
-            let text = match &self.active_inline_completion.as_ref()?.completion {
-                InlineCompletion::Edit {
-                    edits,
-                    edit_preview,
-                    display_mode: _,
-                    snapshot,
-                } => edit_preview
-                    .as_ref()
-                    .and_then(|edit_preview| {
-                        inline_completion_edit_text(&snapshot, &edits, edit_preview, true, cx)
-                    })
-                    .map(InlineCompletionText::Edit),
-                InlineCompletion::Move(target) => {
-                    let target_point =
-                        target.to_point(&editor_snapshot.display_snapshot.buffer_snapshot);
-                    let target_line = target_point.row + 1;
-                    Some(InlineCompletionText::Move(
-                        format!("Jump to edit in line {}", target_line).into(),
-                    ))
-                }
-            };
-
-            Some(InlineCompletionMenuHint::Loaded { text: text? })
-        } else if provider.is_refreshing(cx) {
-            Some(InlineCompletionMenuHint::Loading)
-        } else if provider.needs_terms_acceptance(cx) {
-            Some(InlineCompletionMenuHint::PendingTermsAcceptance)
-        } else {
-            Some(InlineCompletionMenuHint::None)
-        }
-    }
-
     pub fn inline_completion_provider(&self) -> Option<Arc<dyn InlineCompletionProviderHandle>> {
         Some(self.inline_completion_provider.as_ref()?.provider.clone())
     }
@@ -5439,7 +5398,6 @@ impl Editor {
             }))
     }
 
-    #[cfg(any(test, feature = "test-support"))]
     pub fn context_menu_visible(&self) -> bool {
         self.context_menu
             .borrow()
@@ -5447,26 +5405,300 @@ impl Editor {
             .map_or(false, |menu| menu.visible())
     }
 
-    #[cfg(feature = "test-support")]
-    pub fn context_menu_contains_inline_completion(&self) -> bool {
+    fn context_menu_origin(&self) -> Option<ContextMenuOrigin> {
         self.context_menu
             .borrow()
             .as_ref()
-            .map_or(false, |menu| match menu {
-                CodeContextMenu::Completions(menu) => {
-                    menu.entries.borrow().first().map_or(false, |entry| {
-                        matches!(entry, CompletionEntry::InlineCompletionHint(_))
-                    })
+            .map(|menu| menu.origin())
+    }
+
+    fn edit_prediction_cursor_popover_height(&self) -> Pixels {
+        px(32.)
+    }
+
+    fn current_user_player_color(&self, cx: &mut App) -> PlayerColor {
+        if self.read_only(cx) {
+            cx.theme().players().read_only()
+        } else {
+            self.style.as_ref().unwrap().local_player
+        }
+    }
+
+    fn render_edit_prediction_cursor_popover(
+        &self,
+        max_width: Pixels,
+        cursor_point: Point,
+        style: &EditorStyle,
+        accept_keystroke: &gpui::Keystroke,
+        window: &Window,
+        cx: &mut Context<Editor>,
+    ) -> Option<AnyElement> {
+        let provider = self.inline_completion_provider.as_ref()?;
+
+        if provider.provider.needs_terms_acceptance(cx) {
+            return Some(
+                h_flex()
+                    .h(self.edit_prediction_cursor_popover_height())
+                    .flex_1()
+                    .px_2()
+                    .gap_3()
+                    .elevation_2(cx)
+                    .hover(|style| style.bg(cx.theme().colors().element_hover))
+                    .id("accept-terms")
+                    .cursor_pointer()
+                    .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
+                    .on_click(cx.listener(|this, _event, window, cx| {
+                        cx.stop_propagation();
+                        this.toggle_zed_predict_onboarding(window, cx)
+                    }))
+                    .child(
+                        h_flex()
+                            .w_full()
+                            .gap_2()
+                            .child(Icon::new(IconName::ZedPredict))
+                            .child(Label::new("Accept Terms of Service"))
+                            .child(div().w_full())
+                            .child(Icon::new(IconName::ArrowUpRight))
+                            .into_any_element(),
+                    )
+                    .into_any(),
+            );
+        }
+
+        let is_refreshing = provider.provider.is_refreshing(cx);
+
+        fn pending_completion_container() -> Div {
+            h_flex().gap_3().child(Icon::new(IconName::ZedPredict))
+        }
+
+        let completion = match &self.active_inline_completion {
+            Some(completion) => self.render_edit_prediction_cursor_popover_preview(
+                completion,
+                cursor_point,
+                style,
+                cx,
+            )?,
+
+            None if is_refreshing => match &self.stale_inline_completion_in_menu {
+                Some(stale_completion) => self.render_edit_prediction_cursor_popover_preview(
+                    stale_completion,
+                    cursor_point,
+                    style,
+                    cx,
+                )?,
+
+                None => {
+                    pending_completion_container().child(Label::new("...").size(LabelSize::Small))
                 }
-                CodeContextMenu::CodeActions(_) => false,
-            })
+            },
+
+            None => pending_completion_container().child(Label::new("No Prediction")),
+        };
+
+        let buffer_font = theme::ThemeSettings::get_global(cx).buffer_font.clone();
+        let completion = completion.font(buffer_font.clone());
+
+        let completion = if is_refreshing {
+            completion
+                .with_animation(
+                    "loading-completion",
+                    Animation::new(Duration::from_secs(2))
+                        .repeat()
+                        .with_easing(pulsating_between(0.4, 0.8)),
+                    |label, delta| label.opacity(delta),
+                )
+                .into_any_element()
+        } else {
+            completion.into_any_element()
+        };
+
+        let has_completion = self.active_inline_completion.is_some();
+
+        Some(
+            h_flex()
+                .h(self.edit_prediction_cursor_popover_height())
+                .max_w(max_width)
+                .flex_1()
+                .px_2()
+                .gap_3()
+                .elevation_2(cx)
+                .child(completion)
+                .child(div().w_full())
+                .child(
+                    h_flex()
+                        .border_l_1()
+                        .border_color(cx.theme().colors().border_variant)
+                        .pl_2()
+                        .child(
+                            h_flex()
+                                .font(buffer_font.clone())
+                                .p_1()
+                                .rounded_sm()
+                                .children(ui::render_modifiers(
+                                    &accept_keystroke.modifiers,
+                                    PlatformStyle::platform(),
+                                    if window.modifiers() == accept_keystroke.modifiers {
+                                        Some(Color::Accent)
+                                    } else {
+                                        None
+                                    },
+                                )),
+                        )
+                        .opacity(if has_completion { 1.0 } else { 0.1 })
+                        .child(
+                            if self
+                                .active_inline_completion
+                                .as_ref()
+                                .map_or(false, |c| c.is_move())
+                            {
+                                div()
+                                    .child(ui::Key::new(&accept_keystroke.key, None))
+                                    .font(buffer_font.clone())
+                                    .into_any()
+                            } else {
+                                Label::new("Preview").color(Color::Muted).into_any_element()
+                            },
+                        ),
+                )
+                .into_any(),
+        )
     }
 
-    fn context_menu_origin(&self, cursor_position: DisplayPoint) -> Option<ContextMenuOrigin> {
-        self.context_menu
-            .borrow()
-            .as_ref()
-            .map(|menu| menu.origin(cursor_position))
+    fn render_edit_prediction_cursor_popover_preview(
+        &self,
+        completion: &InlineCompletionState,
+        cursor_point: Point,
+        style: &EditorStyle,
+        cx: &mut Context<Editor>,
+    ) -> Option<Div> {
+        use text::ToPoint as _;
+
+        fn render_relative_row_jump(
+            prefix: impl Into<String>,
+            current_row: u32,
+            target_row: u32,
+        ) -> Div {
+            let (row_diff, arrow) = if target_row < current_row {
+                (current_row - target_row, IconName::ArrowUp)
+            } else {
+                (target_row - current_row, IconName::ArrowDown)
+            };
+
+            h_flex()
+                .child(
+                    Label::new(format!("{}{}", prefix.into(), row_diff))
+                        .color(Color::Muted)
+                        .size(LabelSize::Small),
+                )
+                .child(Icon::new(arrow).color(Color::Muted).size(IconSize::Small))
+        }
+
+        match &completion.completion {
+            InlineCompletion::Edit {
+                edits,
+                edit_preview,
+                snapshot,
+                display_mode: _,
+            } => {
+                let first_edit_row = edits.first()?.0.start.text_anchor.to_point(&snapshot).row;
+
+                let highlighted_edits = crate::inline_completion_edit_text(
+                    &snapshot,
+                    &edits,
+                    edit_preview.as_ref()?,
+                    true,
+                    cx,
+                );
+
+                let len_total = highlighted_edits.text.len();
+                let first_line = &highlighted_edits.text
+                    [..highlighted_edits.text.find('\n').unwrap_or(len_total)];
+                let first_line_len = first_line.len();
+
+                let first_highlight_start = highlighted_edits
+                    .highlights
+                    .first()
+                    .map_or(0, |(range, _)| range.start);
+                let drop_prefix_len = first_line
+                    .char_indices()
+                    .find(|(_, c)| !c.is_whitespace())
+                    .map_or(first_highlight_start, |(ix, _)| {
+                        ix.min(first_highlight_start)
+                    });
+
+                let preview_text = &first_line[drop_prefix_len..];
+                let preview_len = preview_text.len();
+                let highlights = highlighted_edits
+                    .highlights
+                    .into_iter()
+                    .take_until(|(range, _)| range.start > first_line_len)
+                    .map(|(range, style)| {
+                        (
+                            range.start - drop_prefix_len
+                                ..(range.end - drop_prefix_len).min(preview_len),
+                            style,
+                        )
+                    });
+
+                let styled_text = gpui::StyledText::new(SharedString::new(preview_text))
+                    .with_highlights(&style.text, highlights);
+
+                let preview = h_flex()
+                    .gap_1()
+                    .child(styled_text)
+                    .when(len_total > first_line_len, |parent| parent.child("…"));
+
+                let left = if first_edit_row != cursor_point.row {
+                    render_relative_row_jump("", cursor_point.row, first_edit_row)
+                        .into_any_element()
+                } else {
+                    Icon::new(IconName::ZedPredict).into_any_element()
+                };
+
+                Some(h_flex().gap_3().child(left).child(preview))
+            }
+
+            InlineCompletion::Move {
+                target,
+                range_around_target,
+                snapshot,
+            } => {
+                let mut highlighted_text = snapshot.highlighted_text_for_range(
+                    range_around_target.clone(),
+                    None,
+                    &style.syntax,
+                );
+                let cursor_color = self.current_user_player_color(cx).cursor;
+                let target_offset =
+                    text::ToOffset::to_offset(&target.text_anchor, &snapshot).saturating_sub(
+                        text::ToOffset::to_offset(&range_around_target.start, &snapshot),
+                    );
+                highlighted_text.highlights = gpui::combine_highlights(
+                    highlighted_text.highlights,
+                    iter::once((
+                        target_offset..target_offset + 1,
+                        HighlightStyle {
+                            background_color: Some(cursor_color),
+                            ..Default::default()
+                        },
+                    )),
+                )
+                .collect::<Vec<_>>();
+
+                Some(
+                    h_flex()
+                        .gap_3()
+                        .child(render_relative_row_jump(
+                            "Jump ",
+                            cursor_point.row,
+                            target.text_anchor.to_point(&snapshot).row,
+                        ))
+                        .when(!highlighted_text.text.is_empty(), |parent| {
+                            parent.child(highlighted_text.to_styled_text(&style.text))
+                        }),
+                )
+            }
+        }
     }
 
     fn render_context_menu(
@@ -5477,13 +5709,12 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Editor>,
     ) -> Option<AnyElement> {
-        self.context_menu.borrow().as_ref().and_then(|menu| {
-            if menu.visible() {
-                Some(menu.render(style, max_height_in_lines, y_flipped, window, cx))
-            } else {
-                None
-            }
-        })
+        let menu = self.context_menu.borrow();
+        let menu = menu.as_ref()?;
+        if !menu.visible() {
+            return None;
+        };
+        Some(menu.render(style, max_height_in_lines, y_flipped, window, cx))
     }
 
     fn render_context_menu_aside(
@@ -5514,7 +5745,8 @@ impl Editor {
         cx.notify();
         self.completion_tasks.clear();
         let context_menu = self.context_menu.borrow_mut().take();
-        if context_menu.is_some() && !self.show_inline_completions_in_menu(cx) {
+        self.stale_inline_completion_in_menu.take();
+        if context_menu.is_some() {
             self.update_visible_inline_completion(window, cx);
         }
         context_menu
@@ -15859,7 +16091,7 @@ fn inline_completion_edit_text(
     edit_preview: &EditPreview,
     include_deletions: bool,
     cx: &App,
-) -> Option<HighlightedText> {
+) -> HighlightedText {
     let edits = edits
         .iter()
         .map(|(anchor, text)| {
@@ -15870,7 +16102,7 @@ fn inline_completion_edit_text(
         })
         .collect::<Vec<_>>();
 
-    Some(edit_preview.highlight_edits(current_snapshot, &edits, include_deletions, cx))
+    edit_preview.highlight_edits(current_snapshot, &edits, include_deletions, cx)
 }
 
 pub fn highlight_diagnostic_message(

crates/editor/src/editor_tests.rs 🔗

@@ -11707,10 +11707,7 @@ async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppCo
                         .entries
                         .borrow()
                         .iter()
-                        .flat_map(|c| match c {
-                            CompletionEntry::Match(mat) => Some(mat.string.clone()),
-                            _ => None,
-                        })
+                        .map(|mat| mat.string.clone())
                         .collect::<Vec<String>>(),
                     items_out
                         .iter()
@@ -11852,13 +11849,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui:
 
 fn completion_menu_entries(menu: &CompletionsMenu) -> Vec<String> {
     let entries = menu.entries.borrow();
-    entries
-        .iter()
-        .flat_map(|e| match e {
-            CompletionEntry::Match(mat) => Some(mat.string.clone()),
-            _ => None,
-        })
-        .collect()
+    entries.iter().map(|mat| mat.string.clone()).collect()
 }
 
 #[gpui::test]
@@ -15469,8 +15460,7 @@ async fn assert_highlighted_edits(
             &edit_preview,
             include_deletions,
             cx,
-        )
-        .expect("Missing highlighted edits");
+        );
         assertion_fn(highlighted_edits, cx)
     });
 }

crates/editor/src/element.rs 🔗

@@ -32,11 +32,12 @@ use gpui::{
     anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px, quad,
     relative, size, svg, transparent_black, Action, AnyElement, App, AvailableSpace, Axis, Bounds,
     ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase,
-    Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox,
-    Hsla, InteractiveElement, IntoElement, Length, ModifiersChangedEvent, MouseButton,
-    MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta,
-    ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled,
-    Subscription, TextRun, TextStyleRefinement, WeakEntity, Window,
+    Edges, Element, ElementInputHandler, Entity, FocusHandle, Focusable as _, FontId,
+    GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Keystroke, Length,
+    ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
+    ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size,
+    StatefulInteractiveElement, Style, Styled, Subscription, TextRun, TextStyleRefinement,
+    WeakEntity, Window,
 };
 use itertools::Itertools;
 use language::{
@@ -525,6 +526,8 @@ impl EditorElement {
         window: &mut Window,
         cx: &mut Context<Editor>,
     ) {
+        editor.update_inline_completion_preview(&event.modifiers, window, cx);
+
         let mouse_position = window.mouse_position();
         if !text_hitbox.is_hovered(window) {
             return;
@@ -1010,12 +1013,7 @@ impl EditorElement {
                     layouts.push(layout);
                 }
 
-                let player = if editor.read_only(cx) {
-                    cx.theme().players().read_only()
-                } else {
-                    self.style.local_player
-                };
-
+                let player = editor.current_user_player_color(cx);
                 selections.push((player, layouts));
             }
 
@@ -1077,11 +1075,6 @@ impl EditorElement {
 
                 selections.extend(remote_selections.into_values());
             } else if !editor.is_focused(window) && editor.show_cursor_when_unfocused {
-                let player = if editor.read_only(cx) {
-                    cx.theme().players().read_only()
-                } else {
-                    self.style.local_player
-                };
                 let layouts = snapshot
                     .buffer_snapshot
                     .selections_in_range(&(start_anchor..end_anchor), true)
@@ -1097,6 +1090,7 @@ impl EditorElement {
                         )
                     })
                     .collect::<Vec<_>>();
+                let player = editor.current_user_player_color(cx);
                 selections.push((player, layouts));
             }
         });
@@ -3157,7 +3151,7 @@ impl EditorElement {
     }
 
     #[allow(clippy::too_many_arguments)]
-    fn layout_context_menu(
+    fn layout_cursor_popovers(
         &self,
         line_height: Pixels,
         text_hitbox: &Hitbox,
@@ -3165,63 +3159,296 @@ impl EditorElement {
         start_row: DisplayRow,
         scroll_pixel_position: gpui::Point<Pixels>,
         line_layouts: &[LineWithInvisibles],
-        newest_selection_head: DisplayPoint,
+        cursor: DisplayPoint,
+        cursor_point: Point,
+        style: &EditorStyle,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        let mut min_menu_height = Pixels::ZERO;
+        let mut max_menu_height = Pixels::ZERO;
+        let mut height_above_menu = Pixels::ZERO;
+        let height_below_menu = Pixels::ZERO;
+        let mut edit_prediction_popover_visible = false;
+        let mut context_menu_visible = false;
+
+        {
+            let editor = self.editor.read(cx);
+            if editor.has_active_completions_menu() && editor.show_inline_completions_in_menu(cx) {
+                height_above_menu +=
+                    editor.edit_prediction_cursor_popover_height() + POPOVER_Y_PADDING;
+                edit_prediction_popover_visible = true;
+            }
+
+            if editor.context_menu_visible() {
+                if let Some(crate::ContextMenuOrigin::Cursor) = editor.context_menu_origin() {
+                    min_menu_height += line_height * 3. + POPOVER_Y_PADDING;
+                    max_menu_height += line_height * 12. + POPOVER_Y_PADDING;
+                    context_menu_visible = true;
+                }
+            }
+        }
+
+        let visible = edit_prediction_popover_visible || context_menu_visible;
+        if !visible {
+            return;
+        }
+
+        let cursor_row_layout = &line_layouts[cursor.row().minus(start_row) as usize];
+        let target_position = content_origin
+            + gpui::Point {
+                x: cmp::max(
+                    px(0.),
+                    cursor_row_layout.x_for_index(cursor.column() as usize)
+                        - scroll_pixel_position.x,
+                ),
+                y: cmp::max(
+                    px(0.),
+                    cursor.row().next_row().as_f32() * line_height - scroll_pixel_position.y,
+                ),
+            };
+
+        let viewport_bounds =
+            Bounds::new(Default::default(), window.viewport_size()).extend(Edges {
+                right: -Self::SCROLLBAR_WIDTH - MENU_GAP,
+                ..Default::default()
+            });
+
+        let min_height = height_above_menu + min_menu_height + height_below_menu;
+        let max_height = height_above_menu + max_menu_height + height_below_menu;
+        let Some((laid_out_popovers, y_flipped)) = self.layout_popovers_above_or_below_line(
+            target_position,
+            line_height,
+            min_height,
+            max_height,
+            text_hitbox,
+            viewport_bounds,
+            window,
+            cx,
+            |height, max_width_for_stable_x, y_flipped, window, cx| {
+                // First layout the menu to get its size - others can be at least this wide.
+                let context_menu = if context_menu_visible {
+                    let menu_height = if y_flipped {
+                        height - height_below_menu
+                    } else {
+                        height - height_above_menu
+                    };
+                    let mut element = self
+                        .render_context_menu(line_height, menu_height, y_flipped, window, cx)
+                        .unwrap();
+                    let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
+                    Some((CursorPopoverType::CodeContextMenu, element, size))
+                } else {
+                    None
+                };
+                let max_width = max_width_for_stable_x.max(
+                    context_menu
+                        .as_ref()
+                        .map_or(px(0.), |(_, _, size)| size.width),
+                );
+                let edit_prediction = if edit_prediction_popover_visible {
+                    let accept_keystroke: Option<Keystroke>;
+
+                    // TODO: load modifier from keymap.
+                    // `bindings_for_action_in` returns `None` in Linux, and is intermittent on macOS
+                    #[cfg(target_os = "macos")]
+                    {
+                        // let bindings = window.bindings_for_action_in(
+                        //     &crate::AcceptInlineCompletion,
+                        //     &self.editor.focus_handle(cx),
+                        // );
+
+                        // let last_binding = bindings.last();
+
+                        // accept_keystroke = if let Some(binding) = last_binding {
+                        //     match &binding.keystrokes() {
+                        //         // TODO: no need to clone once this logic works on linux.
+                        //         [keystroke] => Some(keystroke.clone()),
+                        //         _ => None,
+                        //     }
+                        // } else {
+                        //     None
+                        // };
+                        accept_keystroke = Some(Keystroke {
+                            modifiers: gpui::Modifiers {
+                                alt: true,
+                                control: false,
+                                shift: false,
+                                platform: false,
+                                function: false,
+                            },
+                            key: "tab".to_string(),
+                            key_char: None,
+                        });
+                    }
+
+                    #[cfg(not(target_os = "macos"))]
+                    {
+                        accept_keystroke = Some(Keystroke {
+                            modifiers: gpui::Modifiers {
+                                alt: true,
+                                control: false,
+                                shift: false,
+                                platform: false,
+                                function: false,
+                            },
+                            key: "enter".to_string(),
+                            key_char: None,
+                        });
+                    }
+
+                    self.editor.update(cx, move |editor, cx| {
+                        let mut element = editor.render_edit_prediction_cursor_popover(
+                            max_width,
+                            cursor_point,
+                            style,
+                            accept_keystroke.as_ref()?,
+                            window,
+                            cx,
+                        )?;
+                        let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
+                        Some((CursorPopoverType::EditPrediction, element, size))
+                    })
+                } else {
+                    None
+                };
+                vec![edit_prediction, context_menu]
+                    .into_iter()
+                    .flatten()
+                    .collect::<Vec<_>>()
+            },
+        ) else {
+            return;
+        };
+
+        let Some((_, menu_bounds)) = laid_out_popovers
+            .iter()
+            .find(|(x, _)| matches!(x, CursorPopoverType::CodeContextMenu))
+        else {
+            return;
+        };
+        let first_popover_bounds = laid_out_popovers[0].1;
+        let last_popover_bounds = laid_out_popovers[laid_out_popovers.len() - 1].1;
+
+        let mut target_bounds = if y_flipped {
+            Bounds::from_corners(
+                last_popover_bounds.origin,
+                first_popover_bounds.bottom_right(),
+            )
+        } else {
+            Bounds::from_corners(
+                first_popover_bounds.origin,
+                last_popover_bounds.bottom_right(),
+            )
+        };
+        target_bounds.size.width = menu_bounds.size.width;
+
+        let mut max_target_bounds = target_bounds;
+        max_target_bounds.size.height = max_height;
+        if y_flipped {
+            max_target_bounds.origin.y -= max_height - target_bounds.size.height;
+        }
+
+        let mut extend_amount = Edges::all(MENU_GAP);
+        if y_flipped {
+            extend_amount.bottom = line_height;
+        } else {
+            extend_amount.top = line_height;
+        }
+        let target_bounds = target_bounds.extend(extend_amount);
+        let max_target_bounds = max_target_bounds.extend(extend_amount);
+
+        self.layout_context_menu_aside(
+            y_flipped,
+            *menu_bounds,
+            target_bounds,
+            max_target_bounds,
+            max_menu_height,
+            text_hitbox,
+            viewport_bounds,
+            window,
+            cx,
+        );
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn layout_gutter_menu(
+        &self,
+        line_height: Pixels,
+        text_hitbox: &Hitbox,
+        content_origin: gpui::Point<Pixels>,
+        scroll_pixel_position: gpui::Point<Pixels>,
         gutter_overshoot: Pixels,
         window: &mut Window,
         cx: &mut App,
     ) {
-        let Some(context_menu_origin) = self
-            .editor
-            .read(cx)
-            .context_menu_origin(newest_selection_head)
+        let Some(crate::ContextMenuOrigin::GutterIndicator(gutter_row)) =
+            self.editor.read(cx).context_menu_origin()
         else {
             return;
         };
+        // Context menu was spawned via a click on a gutter. Ensure it's a bit closer to the
+        // indicator than just a plain first column of the text field.
         let target_position = content_origin
-            + match context_menu_origin {
-                crate::ContextMenuOrigin::EditorPoint(display_point) => {
-                    let cursor_row_layout =
-                        &line_layouts[display_point.row().minus(start_row) as usize];
-                    gpui::Point {
-                        x: cmp::max(
-                            px(0.),
-                            cursor_row_layout.x_for_index(display_point.column() as usize)
-                                - scroll_pixel_position.x,
-                        ),
-                        y: cmp::max(
-                            px(0.),
-                            display_point.row().next_row().as_f32() * line_height
-                                - scroll_pixel_position.y,
-                        ),
-                    }
-                }
-                crate::ContextMenuOrigin::GutterIndicator(row) => {
-                    // Context menu was spawned via a click on a gutter. Ensure it's a bit closer to the indicator than just a plain first column of the
-                    // text field.
-                    gpui::Point {
-                        x: -gutter_overshoot,
-                        y: row.next_row().as_f32() * line_height - scroll_pixel_position.y,
-                    }
-                }
+            + gpui::Point {
+                x: -gutter_overshoot,
+                y: gutter_row.next_row().as_f32() * line_height - scroll_pixel_position.y,
             };
-
+        let min_height = line_height * 3. + POPOVER_Y_PADDING;
+        let max_height = line_height * 12. + POPOVER_Y_PADDING;
         let viewport_bounds =
             Bounds::new(Default::default(), window.viewport_size()).extend(Edges {
                 right: -Self::SCROLLBAR_WIDTH - MENU_GAP,
                 ..Default::default()
             });
+        self.layout_popovers_above_or_below_line(
+            target_position,
+            line_height,
+            min_height,
+            max_height,
+            text_hitbox,
+            viewport_bounds,
+            window,
+            cx,
+            move |height, _max_width_for_stable_x, y_flipped, window, cx| {
+                let Some(mut element) =
+                    self.render_context_menu(line_height, height, y_flipped, window, cx)
+                else {
+                    return vec![];
+                };
+                let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
+                vec![(CursorPopoverType::CodeContextMenu, element, size)]
+            },
+        );
+    }
 
-        // If the context menu's max height won't fit below, then flip it above the line and display
-        // it in reverse order. If the available space above is less than below.
-        let unconstrained_max_height = line_height * 12. + POPOVER_Y_PADDING;
-        let min_height = line_height * 3. + POPOVER_Y_PADDING;
+    #[allow(clippy::too_many_arguments)]
+    fn layout_popovers_above_or_below_line(
+        &self,
+        target_position: gpui::Point<Pixels>,
+        line_height: Pixels,
+        min_height: Pixels,
+        max_height: Pixels,
+        text_hitbox: &Hitbox,
+        viewport_bounds: Bounds<Pixels>,
+        window: &mut Window,
+        cx: &mut App,
+        make_sized_popovers: impl FnOnce(
+            Pixels,
+            Pixels,
+            bool,
+            &mut Window,
+            &mut App,
+        ) -> Vec<(CursorPopoverType, AnyElement, Size<Pixels>)>,
+    ) -> Option<(Vec<(CursorPopoverType, Bounds<Pixels>)>, bool)> {
+        // If the max height won't fit below and there is more space above, put it above the line.
         let bottom_y_when_flipped = target_position.y - line_height;
         let available_above = bottom_y_when_flipped - text_hitbox.top();
         let available_below = text_hitbox.bottom() - target_position.y;
-        let y_overflows_below = unconstrained_max_height > available_below;
+        let y_overflows_below = max_height > available_below;
         let mut y_flipped = y_overflows_below && available_above > available_below;
         let mut height = cmp::min(
-            unconstrained_max_height,
+            max_height,
             if y_flipped {
                 available_above
             } else {
@@ -3229,14 +3456,14 @@ impl EditorElement {
             },
         );
 
-        // If less than 3 lines fit within the text bounds, instead fit within the window.
+        // If the min height doesn't fit within text bounds, instead fit within the window.
         if height < min_height {
             let available_above = bottom_y_when_flipped;
             let available_below = viewport_bounds.bottom() - target_position.y;
-            if available_below > 3. * line_height {
+            if available_below > min_height {
                 y_flipped = false;
                 height = min_height;
-            } else if available_above > 3. * line_height {
+            } else if available_above > min_height {
                 y_flipped = true;
                 height = min_height;
             } else if available_above > available_below {
@@ -3248,82 +3475,67 @@ impl EditorElement {
             }
         }
 
-        let max_height_in_lines = ((height - POPOVER_Y_PADDING) / line_height).floor() as u32;
+        let max_width_for_stable_x = viewport_bounds.right() - target_position.x;
 
-        // TODO(mgsloan): use viewport_bounds.width as a max width when rendering menu.
-        let Some(mut menu_element) = self.editor.update(cx, |editor, cx| {
-            editor.render_context_menu(&self.style, max_height_in_lines, y_flipped, window, cx)
-        }) else {
-            return;
-        };
+        // TODO: Use viewport_bounds.width as a max width so that it doesn't get clipped on the left
+        // for very narrow windows.
+        let popovers = make_sized_popovers(height, max_width_for_stable_x, y_flipped, window, cx);
+        if popovers.is_empty() {
+            return None;
+        }
+
+        let max_width = popovers
+            .iter()
+            .map(|(_, _, size)| size.width)
+            .max()
+            .unwrap_or_default();
 
-        let menu_size = menu_element.layout_as_root(AvailableSpace::min_size(), window, cx);
-        let menu_position = gpui::Point {
+        let mut current_position = gpui::Point {
             // Snap the right edge of the list to the right edge of the window if its horizontal bounds
             // overflow. Include space for the scrollbar.
             x: target_position
                 .x
-                .min((viewport_bounds.right() - menu_size.width).max(Pixels::ZERO)),
+                .min((viewport_bounds.right() - max_width).max(Pixels::ZERO)),
             y: if y_flipped {
-                bottom_y_when_flipped - menu_size.height
+                bottom_y_when_flipped
             } else {
                 target_position.y
             },
         };
-        window.defer_draw(menu_element, menu_position, 1);
-
-        // Layout documentation aside
-        let menu_bounds = Bounds::new(menu_position, menu_size);
-        let max_menu_size = size(menu_size.width, unconstrained_max_height);
-        let max_menu_bounds = if y_flipped {
-            Bounds::new(
-                point(
-                    menu_position.x,
-                    bottom_y_when_flipped - max_menu_size.height,
-                ),
-                max_menu_size,
-            )
-        } else {
-            Bounds::new(target_position, max_menu_size)
-        };
-        self.layout_context_menu_aside(
-            text_hitbox,
-            y_flipped,
-            menu_position,
-            menu_bounds,
-            max_menu_bounds,
-            unconstrained_max_height,
-            line_height,
-            viewport_bounds,
-            window,
-            cx,
-        );
+
+        let laid_out_popovers = popovers
+            .into_iter()
+            .map(|(popover_type, element, size)| {
+                if y_flipped {
+                    current_position.y -= size.height;
+                }
+                let position = current_position;
+                window.defer_draw(element, current_position, 1);
+                if !y_flipped {
+                    current_position.y += size.height + MENU_GAP;
+                } else {
+                    current_position.y -= MENU_GAP;
+                }
+                (popover_type, Bounds::new(position, size))
+            })
+            .collect::<Vec<_>>();
+
+        Some((laid_out_popovers, y_flipped))
     }
 
     #[allow(clippy::too_many_arguments)]
     fn layout_context_menu_aside(
         &self,
-        text_hitbox: &Hitbox,
         y_flipped: bool,
-        menu_position: gpui::Point<Pixels>,
         menu_bounds: Bounds<Pixels>,
-        max_menu_bounds: Bounds<Pixels>,
+        target_bounds: Bounds<Pixels>,
+        max_target_bounds: Bounds<Pixels>,
         max_height: Pixels,
-        line_height: Pixels,
+        text_hitbox: &Hitbox,
         viewport_bounds: Bounds<Pixels>,
         window: &mut Window,
         cx: &mut App,
     ) {
-        let mut extend_amount = Edges::all(MENU_GAP);
-        // Extend to include the cursored line to avoid overlapping it.
-        if y_flipped {
-            extend_amount.bottom = line_height;
-        } else {
-            extend_amount.top = line_height;
-        }
-        let target_bounds = menu_bounds.extend(extend_amount);
-        let max_target_bounds = max_menu_bounds.extend(extend_amount);
-
         let available_within_viewport = target_bounds.space_within(&viewport_bounds);
         let positioned_aside = if available_within_viewport.right >= MENU_ASIDE_MIN_WIDTH {
             let max_width = cmp::min(
@@ -3336,7 +3548,7 @@ impl EditorElement {
                 return;
             };
             aside.layout_as_root(AvailableSpace::min_size(), window, cx);
-            let right_position = point(target_bounds.right(), menu_position.y);
+            let right_position = point(target_bounds.right(), menu_bounds.origin.y);
             Some((aside, right_position))
         } else {
             let max_size = size(
@@ -3359,8 +3571,11 @@ impl EditorElement {
             };
             let actual_size = aside.layout_as_root(AvailableSpace::min_size(), window, cx);
 
-            let top_position = point(menu_position.x, target_bounds.top() - actual_size.height);
-            let bottom_position = point(menu_position.x, target_bounds.bottom());
+            let top_position = point(
+                menu_bounds.origin.x,
+                target_bounds.top() - actual_size.height,
+            );
+            let bottom_position = point(menu_bounds.origin.x, target_bounds.bottom());
 
             let fit_within = |available: Edges<Pixels>, wanted: Size<Pixels>| {
                 // Prefer to fit on the same side of the line as the menu, then on the other side of
@@ -3396,6 +3611,20 @@ impl EditorElement {
         }
     }
 
+    fn render_context_menu(
+        &self,
+        line_height: Pixels,
+        height: Pixels,
+        y_flipped: bool,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Option<AnyElement> {
+        let max_height_in_lines = ((height - POPOVER_Y_PADDING) / line_height).floor() as u32;
+        self.editor.update(cx, |editor, cx| {
+            editor.render_context_menu(&self.style, max_height_in_lines, y_flipped, window, cx)
+        })
+    }
+
     fn render_context_menu_aside(
         &self,
         max_size: Size<Pixels>,
@@ -3434,12 +3663,14 @@ impl EditorElement {
         let active_inline_completion = self.editor.read(cx).active_inline_completion.as_ref()?;
 
         match &active_inline_completion.completion {
-            InlineCompletion::Move(target_position) => {
-                let target_display_point = target_position.to_display_point(editor_snapshot);
+            InlineCompletion::Move { target, .. } => {
+                let target_display_point = target.to_display_point(editor_snapshot);
                 if target_display_point.row().as_f32() < scroll_top {
-                    let mut element = inline_completion_tab_indicator(
+                    let mut element = inline_completion_accept_indicator(
                         "Jump to Edit",
                         Some(IconName::ArrowUp),
+                        self.editor.focus_handle(cx),
+                        window,
                         cx,
                     );
                     let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
@@ -3447,9 +3678,11 @@ impl EditorElement {
                     element.prepaint_at(text_bounds.origin + offset, window, cx);
                     Some(element)
                 } else if (target_display_point.row().as_f32() + 1.) > scroll_bottom {
-                    let mut element = inline_completion_tab_indicator(
+                    let mut element = inline_completion_accept_indicator(
                         "Jump to Edit",
                         Some(IconName::ArrowDown),
+                        self.editor.focus_handle(cx),
+                        window,
                         cx,
                     );
                     let size = element.layout_as_root(AvailableSpace::min_size(), window, cx);
@@ -3460,7 +3693,13 @@ impl EditorElement {
                     element.prepaint_at(text_bounds.origin + offset, window, cx);
                     Some(element)
                 } else {
-                    let mut element = inline_completion_tab_indicator("Jump to Edit", None, cx);
+                    let mut element = inline_completion_accept_indicator(
+                        "Jump to Edit",
+                        None,
+                        self.editor.focus_handle(cx),
+                        window,
+                        cx,
+                    );
 
                     let target_line_end = DisplayPoint::new(
                         target_display_point.row(),
@@ -3520,7 +3759,13 @@ impl EditorElement {
                             editor.display_to_pixel_point(target_line_end, editor_snapshot, window)
                         })?;
 
-                        let mut element = inline_completion_tab_indicator("Accept", None, cx);
+                        let mut element = inline_completion_accept_indicator(
+                            "Accept",
+                            None,
+                            self.editor.focus_handle(cx),
+                            window,
+                            cx,
+                        );
 
                         element.prepaint_as_root(
                             text_bounds.origin + origin + point(PADDING_X, px(0.)),
@@ -3535,9 +3780,13 @@ impl EditorElement {
                     EditDisplayMode::DiffPopover => {}
                 }
 
-                let highlighted_edits = edit_preview.as_ref().and_then(|edit_preview| {
-                    crate::inline_completion_edit_text(&snapshot, edits, edit_preview, false, cx)
-                })?;
+                let highlighted_edits = crate::inline_completion_edit_text(
+                    &snapshot,
+                    edits,
+                    edit_preview.as_ref()?,
+                    false,
+                    cx,
+                );
 
                 let line_count = highlighted_edits.text.lines().count();
 
@@ -3558,8 +3807,7 @@ impl EditorElement {
                     .width
                 };
 
-                let styled_text = gpui::StyledText::new(highlighted_edits.text.clone())
-                    .with_highlights(&style.text, highlighted_edits.highlights);
+                let styled_text = highlighted_edits.to_styled_text(&style.text);
 
                 let mut element = div()
                     .bg(cx.theme().colors().editor_background)
@@ -5548,17 +5796,33 @@ fn header_jump_data(
     }
 }
 
-fn inline_completion_tab_indicator(
+fn inline_completion_accept_indicator(
     label: impl Into<SharedString>,
     icon: Option<IconName>,
+    focus_handle: FocusHandle,
+    window: &Window,
     cx: &App,
 ) -> AnyElement {
-    let tab_kbd = h_flex()
+    let bindings = window.bindings_for_action_in(&crate::AcceptInlineCompletion, &focus_handle);
+    let Some(accept_keystroke) = bindings
+        .last()
+        .and_then(|binding| binding.keystrokes().first())
+    else {
+        return div().into_any();
+    };
+
+    let accept_key = h_flex()
         .px_0p5()
         .font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
         .text_size(TextSize::XSmall.rems(cx))
         .text_color(cx.theme().colors().text)
-        .child("tab");
+        .gap_1()
+        .children(ui::render_modifiers(
+            &accept_keystroke.modifiers,
+            PlatformStyle::platform(),
+            Some(Color::Default),
+        ))
+        .child(accept_keystroke.key.clone());
 
     let padding_right = if icon.is_some() { px(4.) } else { px(8.) };
 
@@ -5572,7 +5836,7 @@ fn inline_completion_tab_indicator(
         .border_color(cx.theme().colors().text_accent.opacity(0.8))
         .rounded_md()
         .shadow_sm()
-        .child(tab_kbd)
+        .child(accept_key)
         .child(Label::new(label).size(LabelSize::Small))
         .when_some(icon, |element, icon| {
             element.child(
@@ -7059,8 +7323,11 @@ impl Element for EditorElement {
                         );
                     let mut code_actions_indicator = None;
                     if let Some(newest_selection_head) = newest_selection_head {
+                        let newest_selection_point =
+                            newest_selection_head.to_point(&snapshot.display_snapshot);
+
                         if (start_row..end_row).contains(&newest_selection_head.row()) {
-                            self.layout_context_menu(
+                            self.layout_cursor_popovers(
                                 line_height,
                                 &text_hitbox,
                                 content_origin,
@@ -7068,7 +7335,8 @@ impl Element for EditorElement {
                                 scroll_pixel_position,
                                 &line_layouts,
                                 newest_selection_head,
-                                gutter_dimensions.width - gutter_dimensions.left_padding,
+                                newest_selection_point,
+                                &style,
                                 window,
                                 cx,
                             );
@@ -7113,6 +7381,16 @@ impl Element for EditorElement {
                         }
                     }
 
+                    self.layout_gutter_menu(
+                        line_height,
+                        &text_hitbox,
+                        content_origin,
+                        scroll_pixel_position,
+                        gutter_dimensions.width - gutter_dimensions.left_padding,
+                        window,
+                        cx,
+                    );
+
                     let test_indicators = if gutter_settings.runnables {
                         self.layout_run_indicators(
                             line_height,
@@ -7994,6 +8272,11 @@ impl HighlightedRange {
     }
 }
 
+enum CursorPopoverType {
+    CodeContextMenu,
+    EditPrediction,
+}
+
 pub fn scale_vertical_mouse_autoscroll_delta(delta: Pixels) -> f32 {
     (delta.pow(1.5) / 100.0).into()
 }

crates/editor/src/inlay_hint_cache.rs 🔗

@@ -1253,7 +1253,7 @@ fn apply_hint_update(
         editor.inlay_hint_cache.version += 1;
     }
     if displayed_inlays_changed {
-        editor.splice_inlays(to_remove, to_insert, cx)
+        editor.splice_inlays(&to_remove, to_insert, cx)
     }
 }
 

crates/editor/src/inline_completion_tests.rs 🔗

@@ -304,8 +304,8 @@ fn assert_editor_active_move_completion(
             .as_ref()
             .expect("editor has no active completion");
 
-        if let InlineCompletion::Move(anchor) = &completion_state.completion {
-            assert(editor.buffer().read(cx).snapshot(cx), *anchor);
+        if let InlineCompletion::Move { target, .. } = &completion_state.completion {
+            assert(editor.buffer().read(cx).snapshot(cx), *target);
         } else {
             panic!("expected move completion");
         }

crates/editor/src/movement.rs 🔗

@@ -864,7 +864,7 @@ mod tests {
             })
             .collect();
         let snapshot = display_map.update(cx, |map, cx| {
-            map.splice_inlays(Vec::new(), inlays, cx);
+            map.splice_inlays(&[], inlays, cx);
             map.snapshot(cx)
         });
 

crates/language/src/buffer.rs 🔗

@@ -26,7 +26,7 @@ use fs::MTime;
 use futures::channel::oneshot;
 use gpui::{
     AnyElement, App, AppContext as _, Context, Entity, EventEmitter, HighlightStyle, Pixels,
-    SharedString, Task, TaskLabel, Window,
+    SharedString, StyledText, Task, TaskLabel, TextStyle, Window,
 };
 use lsp::LanguageServerId;
 use parking_lot::Mutex;
@@ -617,6 +617,11 @@ impl HighlightedText {
         );
         highlighted_text.build()
     }
+
+    pub fn to_styled_text(&self, default_style: &TextStyle) -> StyledText {
+        gpui::StyledText::new(self.text.clone())
+            .with_highlights(default_style, self.highlights.iter().cloned())
+    }
 }
 
 impl HighlightedTextBuilder {

crates/ui/src/components/keybinding.rs 🔗

@@ -1,7 +1,9 @@
 #![allow(missing_docs)]
 use crate::PlatformStyle;
 use crate::{h_flex, prelude::*, Icon, IconName, IconSize};
-use gpui::{relative, Action, App, FocusHandle, IntoElement, Keystroke, Window};
+use gpui::{
+    relative, Action, AnyElement, App, FocusHandle, IntoElement, Keystroke, Modifiers, Window,
+};
 
 #[derive(Debug, IntoElement, Clone)]
 pub struct KeyBinding {
@@ -41,30 +43,6 @@ impl KeyBinding {
         Some(Self::new(key_binding))
     }
 
-    fn icon_for_key(&self, keystroke: &Keystroke) -> Option<IconName> {
-        match keystroke.key.as_str() {
-            "left" => Some(IconName::ArrowLeft),
-            "right" => Some(IconName::ArrowRight),
-            "up" => Some(IconName::ArrowUp),
-            "down" => Some(IconName::ArrowDown),
-            "backspace" => Some(IconName::Backspace),
-            "delete" => Some(IconName::Delete),
-            "return" => Some(IconName::Return),
-            "enter" => Some(IconName::Return),
-            "tab" => Some(IconName::Tab),
-            "space" => Some(IconName::Space),
-            "escape" => Some(IconName::Escape),
-            "pagedown" => Some(IconName::PageDown),
-            "pageup" => Some(IconName::PageUp),
-            "shift" if self.platform_style == PlatformStyle::Mac => Some(IconName::Shift),
-            "control" if self.platform_style == PlatformStyle::Mac => Some(IconName::Control),
-            "platform" if self.platform_style == PlatformStyle::Mac => Some(IconName::Command),
-            "function" if self.platform_style == PlatformStyle::Mac => Some(IconName::Control),
-            "alt" if self.platform_style == PlatformStyle::Mac => Some(IconName::Option),
-            _ => None,
-        }
-    }
-
     pub fn new(key_binding: gpui::KeyBinding) -> Self {
         Self {
             key_binding,
@@ -96,63 +74,148 @@ impl RenderOnce for KeyBinding {
             .gap(DynamicSpacing::Base04.rems(cx))
             .flex_none()
             .children(self.key_binding.keystrokes().iter().map(|keystroke| {
-                let key_icon = self.icon_for_key(keystroke);
-
                 h_flex()
                     .flex_none()
                     .py_0p5()
                     .rounded_sm()
                     .text_color(cx.theme().colors().text_muted)
-                    .when(keystroke.modifiers.function, |el| {
-                        match self.platform_style {
-                            PlatformStyle::Mac => el.child(Key::new("fn")),
-                            PlatformStyle::Linux | PlatformStyle::Windows => {
-                                el.child(Key::new("Fn")).child(Key::new("+"))
-                            }
-                        }
-                    })
-                    .when(keystroke.modifiers.control, |el| {
-                        match self.platform_style {
-                            PlatformStyle::Mac => el.child(KeyIcon::new(IconName::Control)),
-                            PlatformStyle::Linux | PlatformStyle::Windows => {
-                                el.child(Key::new("Ctrl")).child(Key::new("+"))
-                            }
-                        }
-                    })
-                    .when(keystroke.modifiers.alt, |el| match self.platform_style {
-                        PlatformStyle::Mac => el.child(KeyIcon::new(IconName::Option)),
-                        PlatformStyle::Linux | PlatformStyle::Windows => {
-                            el.child(Key::new("Alt")).child(Key::new("+"))
-                        }
-                    })
-                    .when(keystroke.modifiers.platform, |el| {
-                        match self.platform_style {
-                            PlatformStyle::Mac => el.child(KeyIcon::new(IconName::Command)),
-                            PlatformStyle::Linux => {
-                                el.child(Key::new("Super")).child(Key::new("+"))
-                            }
-                            PlatformStyle::Windows => {
-                                el.child(Key::new("Win")).child(Key::new("+"))
-                            }
-                        }
-                    })
-                    .when(keystroke.modifiers.shift, |el| match self.platform_style {
-                        PlatformStyle::Mac => el.child(KeyIcon::new(IconName::Shift)),
-                        PlatformStyle::Linux | PlatformStyle::Windows => {
-                            el.child(Key::new("Shift")).child(Key::new("+"))
-                        }
-                    })
-                    .map(|el| match key_icon {
-                        Some(icon) => el.child(KeyIcon::new(icon)),
-                        None => el.child(Key::new(keystroke.key.to_uppercase())),
-                    })
+                    .children(render_modifiers(
+                        &keystroke.modifiers,
+                        self.platform_style,
+                        None,
+                    ))
+                    .map(|el| el.child(render_key(&keystroke, self.platform_style, None)))
             }))
     }
 }
 
+pub fn render_key(
+    keystroke: &Keystroke,
+    platform_style: PlatformStyle,
+    color: Option<Color>,
+) -> AnyElement {
+    let key_icon = icon_for_key(keystroke, platform_style);
+    match key_icon {
+        Some(icon) => KeyIcon::new(icon, color).into_any_element(),
+        None => Key::new(
+            if keystroke.key.len() > 1 {
+                keystroke.key.clone()
+            } else {
+                keystroke.key.to_uppercase()
+            },
+            color,
+        )
+        .into_any_element(),
+    }
+}
+
+fn icon_for_key(keystroke: &Keystroke, platform_style: PlatformStyle) -> Option<IconName> {
+    match keystroke.key.as_str() {
+        "left" => Some(IconName::ArrowLeft),
+        "right" => Some(IconName::ArrowRight),
+        "up" => Some(IconName::ArrowUp),
+        "down" => Some(IconName::ArrowDown),
+        "backspace" => Some(IconName::Backspace),
+        "delete" => Some(IconName::Delete),
+        "return" => Some(IconName::Return),
+        "enter" => Some(IconName::Return),
+        // "tab" => Some(IconName::Tab),
+        "space" => Some(IconName::Space),
+        "escape" => Some(IconName::Escape),
+        "pagedown" => Some(IconName::PageDown),
+        "pageup" => Some(IconName::PageUp),
+        "shift" if platform_style == PlatformStyle::Mac => Some(IconName::Shift),
+        "control" if platform_style == PlatformStyle::Mac => Some(IconName::Control),
+        "platform" if platform_style == PlatformStyle::Mac => Some(IconName::Command),
+        "function" if platform_style == PlatformStyle::Mac => Some(IconName::Control),
+        "alt" if platform_style == PlatformStyle::Mac => Some(IconName::Option),
+        _ => None,
+    }
+}
+
+pub fn render_modifiers(
+    modifiers: &Modifiers,
+    platform_style: PlatformStyle,
+    color: Option<Color>,
+) -> impl Iterator<Item = AnyElement> {
+    enum KeyOrIcon {
+        Key(&'static str),
+        Icon(IconName),
+    }
+
+    struct Modifier {
+        enabled: bool,
+        mac: KeyOrIcon,
+        linux: KeyOrIcon,
+        windows: KeyOrIcon,
+    }
+
+    let table = {
+        use KeyOrIcon::*;
+
+        [
+            Modifier {
+                enabled: modifiers.function,
+                mac: Icon(IconName::Control),
+                linux: Key("Fn"),
+                windows: Key("Fn"),
+            },
+            Modifier {
+                enabled: modifiers.control,
+                mac: Icon(IconName::Control),
+                linux: Key("Ctrl"),
+                windows: Key("Ctrl"),
+            },
+            Modifier {
+                enabled: modifiers.alt,
+                mac: Icon(IconName::Option),
+                linux: Key("Alt"),
+                windows: Key("Alt"),
+            },
+            Modifier {
+                enabled: modifiers.platform,
+                mac: Icon(IconName::Command),
+                linux: Key("Super"),
+                windows: Key("Win"),
+            },
+            Modifier {
+                enabled: modifiers.shift,
+                mac: Icon(IconName::Shift),
+                linux: Key("Shift"),
+                windows: Key("Shift"),
+            },
+        ]
+    };
+
+    table
+        .into_iter()
+        .flat_map(move |modifier| {
+            if modifier.enabled {
+                match platform_style {
+                    PlatformStyle::Mac => Some(modifier.mac),
+                    PlatformStyle::Linux => Some(modifier.linux)
+                        .into_iter()
+                        .chain(Some(KeyOrIcon::Key("+")))
+                        .next(),
+                    PlatformStyle::Windows => Some(modifier.windows)
+                        .into_iter()
+                        .chain(Some(KeyOrIcon::Key("+")))
+                        .next(),
+                }
+            } else {
+                None
+            }
+        })
+        .map(move |key_or_icon| match key_or_icon {
+            KeyOrIcon::Key(key) => Key::new(key, color).into_any_element(),
+            KeyOrIcon::Icon(icon) => KeyIcon::new(icon, color).into_any_element(),
+        })
+}
+
 #[derive(IntoElement)]
 pub struct Key {
     key: SharedString,
+    color: Option<Color>,
 }
 
 impl RenderOnce for Key {
@@ -174,33 +237,37 @@ impl RenderOnce for Key {
             .h(rems_from_px(14.))
             .text_ui(cx)
             .line_height(relative(1.))
-            .text_color(cx.theme().colors().text_muted)
+            .text_color(self.color.unwrap_or(Color::Muted).color(cx))
             .child(self.key.clone())
     }
 }
 
 impl Key {
-    pub fn new(key: impl Into<SharedString>) -> Self {
-        Self { key: key.into() }
+    pub fn new(key: impl Into<SharedString>, color: Option<Color>) -> Self {
+        Self {
+            key: key.into(),
+            color,
+        }
     }
 }
 
 #[derive(IntoElement)]
 pub struct KeyIcon {
     icon: IconName,
+    color: Option<Color>,
 }
 
 impl RenderOnce for KeyIcon {
     fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
         Icon::new(self.icon)
             .size(IconSize::XSmall)
-            .color(Color::Muted)
+            .color(self.color.unwrap_or(Color::Muted))
     }
 }
 
 impl KeyIcon {
-    pub fn new(icon: IconName) -> Self {
-        Self { icon }
+    pub fn new(icon: IconName, color: Option<Color>) -> Self {
+        Self { icon, color }
     }
 }
 

crates/vim/src/motion.rs 🔗

@@ -3441,7 +3441,7 @@ mod test {
             let range = editor.selections.newest_anchor().range();
             let inlay_text = "  field: int,\n  field2: string\n  field3: float";
             let inlay = Inlay::inline_completion(1, range.start, inlay_text);
-            editor.splice_inlays(vec![], vec![inlay], cx);
+            editor.splice_inlays(&[], vec![inlay], cx);
         });
 
         cx.simulate_keystrokes("j");
@@ -3473,7 +3473,7 @@ mod test {
                 snapshot.anchor_after(Point::new(0, snapshot.line_len(MultiBufferRow(0))));
             let inlay_text = " hint";
             let inlay = Inlay::inline_completion(1, end_of_line, inlay_text);
-            editor.splice_inlays(vec![], vec![inlay], cx);
+            editor.splice_inlays(&[], vec![inlay], cx);
         });
         cx.simulate_keystrokes("$");
         cx.assert_state(

crates/zeta/src/zeta.rs 🔗

@@ -1530,6 +1530,16 @@ impl inline_completion::InlineCompletionProvider for ZetaInlineCompletionProvide
                 .log_err()
                 .flatten()
             else {
+                this.update(&mut cx, |this, cx| {
+                    if this.pending_completions[0].id == pending_completion_id {
+                        this.pending_completions.remove(0);
+                    } else {
+                        this.pending_completions.clear();
+                    }
+
+                    cx.notify();
+                })
+                .ok();
                 return;
             };