From 4c29e1ff07815fabacda3bf6b9b34edd5c508c5b Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Mon, 3 Feb 2025 21:47:11 -0300 Subject: [PATCH] zeta: Improve UX for simultaneous LSP and prediction completions (#24024) Release Notes: - N/A --------- Co-authored-by: Michael Sloan Co-authored-by: Danilo Co-authored-by: Richard --- assets/keymaps/default-linux.json | 7 + assets/keymaps/default-macos.json | 7 + .../src/copilot_completion_provider.rs | 4 - crates/editor/src/code_context_menus.rs | 435 ++++------- crates/editor/src/display_map.rs | 2 +- crates/editor/src/display_map/inlay_map.rs | 16 +- crates/editor/src/editor.rs | 680 ++++++++++++------ 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, 1196 insertions(+), 762 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 87a2c5132f..90c8faaadf 100644 --- a/assets/keymaps/default-linux.json +++ b/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": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 86a761c0ec..934373b675 100644 --- a/assets/keymaps/default-macos.json +++ b/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, diff --git a/crates/copilot/src/copilot_completion_provider.rs b/crates/copilot/src/copilot_completion_provider.rs index 0dc03e4037..30fd76e37d 100644 --- a/crates/copilot/src/copilot_completion_provider.rs +++ b/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"); }); diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 401ca95e6d..a6f0e5c0f0 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/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, pub completions: Rc>>, match_candidates: Rc<[StringMatchCandidate]>, - pub entries: Rc>>, + pub entries: Rc>>, pub selected_item: usize, scroll_handle: UniformListScrollHandle, resolve_completions: bool, show_completion_documentation: bool, last_rendered_range: Rc>>>, -} - -#[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::>(); 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::>(), - Some(selected_candidate_id) => iter::once(selected_candidate_id) - .chain(candidate_ids.filter(|id| *id != selected_candidate_id)) - .collect::>(), - }; + 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::>(); drop(entries); if candidate_ids.is_empty() { @@ -438,19 +410,16 @@ impl CompletionsMenu { .detach(); } - fn entry_candidate_id(entry: &CompletionEntry) -> Option { - 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; + .max_by_key(|(_, 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(); - } + let mut len = completion.label.text.chars().count(); + if let Some(CompletionDocumentation::SingleLine(text)) = documentation { + if show_completion_documentation { + len += text.chars().count(); } + } - len - } - CompletionEntry::InlineCompletionHint(hint) => { - "Zed AI / ".chars().count() + hint.label().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)); + let completion = &completions_guard[mat.candidate_id]; + let documentation = if show_completion_documentation { + &completion.documentation + } else { + &None + }; - match mat { - CompletionEntry::Match(mat) => { - let candidate_id = mat.candidate_id; - let completion = &completions_guard[candidate_id]; + 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); + } - let documentation = if show_completion_documentation { - &completion.documentation - } else { - &None - }; + (range, highlight) + }, + ), + ); - 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::
(color_swatch) - .child(h_flex().overflow_hidden().child(completion_label)) - .end_slot::