From 51092c4e31ffc706722fb1242af7798ca8b51ea9 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 12 Feb 2025 12:56:31 -0300 Subject: [PATCH] Polish edit predictions (#24732) Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra Co-authored-by: as-cii Co-authored-by: Danilo Leal --- assets/settings/default.json | 8 +- crates/editor/src/editor.rs | 86 +++++++- crates/editor/src/element.rs | 199 +++++++----------- .../src/inline_completion_button.rs | 64 +++--- crates/language/src/language_settings.rs | 2 +- crates/zeta/src/zeta.rs | 155 +++++++++++++- 6 files changed, 353 insertions(+), 161 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index a6b43ea405..8200e22e69 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -792,11 +792,11 @@ ], // When to show edit predictions previews in buffer. // This setting takes two possible values: - // 1. Display inline when holding modifier key (alt by default). - // "mode": "auto" - // 2. Display inline when there are no language server completions available. + // 1. Display inline when there are no language server completions available. // "mode": "eager_preview" - "mode": "auto" + // 2. Display inline when holding modifier key (alt by default). + // "mode": "auto" + "mode": "eager_preview" }, // Settings specific to journaling "journal": { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 54e9533d7e..2968e8e1c4 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -5648,6 +5648,77 @@ impl Editor { } } + fn render_edit_prediction_accept_keybind(&self, window: &mut Window, cx: &App) -> Option
{ + let accept_binding = self.accept_edit_prediction_keybind(window, cx); + let accept_keystroke = accept_binding.keystroke()?; + let colors = cx.theme().colors(); + let accent_color = colors.text_accent; + let editor_bg_color = colors.editor_background; + let bg_color = editor_bg_color.blend(accent_color.opacity(0.1)); + + h_flex() + .px_0p5() + .gap_1() + .bg(bg_color) + .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .text_size(TextSize::XSmall.rems(cx)) + .when(!self.edit_prediction_preview_is_active(), |parent| { + parent.children(ui::render_modifiers( + &accept_keystroke.modifiers, + PlatformStyle::platform(), + Some(if accept_keystroke.modifiers == window.modifiers() { + Color::Accent + } else { + Color::Muted + }), + Some(IconSize::XSmall.rems().into()), + false, + )) + }) + .child(accept_keystroke.key.clone()) + .into() + } + + fn render_edit_prediction_line_popover( + &self, + label: impl Into, + icon: Option, + window: &mut Window, + cx: &App, + ) -> Option
{ + let bg_color = Self::edit_prediction_line_popover_bg_color(cx); + + let padding_right = if icon.is_some() { px(4.) } else { px(8.) }; + + let result = h_flex() + .gap_1() + .border_1() + .rounded_lg() + .shadow_sm() + .bg(bg_color) + .border_color(cx.theme().colors().text_accent.opacity(0.4)) + .py_0p5() + .pl_1() + .pr(padding_right) + .children(self.render_edit_prediction_accept_keybind(window, cx)) + .child(Label::new(label).size(LabelSize::Small)) + .when_some(icon, |element, icon| { + element.child( + div() + .mt(px(1.5)) + .child(Icon::new(icon).size(IconSize::Small)), + ) + }); + + Some(result) + } + + fn edit_prediction_line_popover_bg_color(cx: &App) -> Hsla { + let accent_color = cx.theme().colors().text_accent; + let editor_bg_color = cx.theme().colors().editor_background; + editor_bg_color.blend(accent_color.opacity(0.1)) + } + #[allow(clippy::too_many_arguments)] fn render_edit_prediction_cursor_popover( &self, @@ -5788,18 +5859,26 @@ impl Editor { .min_w(min_width) .max_w(max_width) .flex_1() - .px_2() .elevation_2(cx) .border_color(cx.theme().colors().border) - .child(div().py_1().overflow_hidden().child(completion)) + .child( + div() + .flex_1() + .py_1() + .px_2() + .overflow_hidden() + .child(completion), + ) .child( h_flex() .h_full() .border_l_1() + .rounded_r_lg() .border_color(cx.theme().colors().border) + .bg(Self::edit_prediction_line_popover_bg_color(cx)) .gap_1() .py_1() - .pl_2() + .px_2() .child( h_flex() .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) @@ -14548,6 +14627,7 @@ impl Editor { } self.hide_context_menu(window, cx); + self.discard_inline_completion(false, cx); cx.emit(EditorEvent::Blurred); cx.notify(); } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 78f07c60fa..cb694b9dec 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -3583,14 +3583,14 @@ impl EditorElement { } if target_display_point.row() < visible_row_range.start { - let mut element = inline_completion_accept_indicator( - "Scroll", - Some(IconName::ArrowUp), - editor, - window, - cx, - )? - .into_any(); + let mut element = editor + .render_edit_prediction_line_popover( + "Scroll", + Some(IconName::ArrowUp), + window, + cx, + )? + .into_any(); element.layout_as_root(AvailableSpace::min_size(), window, cx); @@ -3608,14 +3608,14 @@ impl EditorElement { element.prepaint_at(origin, window, cx); return Some(element); } else if target_display_point.row() >= visible_row_range.end { - let mut element = inline_completion_accept_indicator( - "Scroll", - Some(IconName::ArrowDown), - editor, - window, - cx, - )? - .into_any(); + let mut element = editor + .render_edit_prediction_line_popover( + "Scroll", + Some(IconName::ArrowDown), + window, + cx, + )? + .into_any(); let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); @@ -3640,12 +3640,11 @@ impl EditorElement { let mut element = v_flex() .child( - inline_completion_accept_indicator( - "Jump", None, editor, window, cx, - )? - .rounded_br(px(0.)) - .rounded_tr(px(0.)) - .border_r_2(), + editor + .render_edit_prediction_line_popover("Jump", None, window, cx)? + .rounded_br(px(0.)) + .rounded_tr(px(0.)) + .border_r_2(), ) .child( div() @@ -3680,28 +3679,30 @@ impl EditorElement { } if target_display_point.row().as_f32() < scroll_top { - let mut element = inline_completion_accept_indicator( - "Jump to Edit", - Some(IconName::ArrowUp), - editor, - window, - cx, - )? - .into_any(); + let mut element = editor + .render_edit_prediction_line_popover( + "Jump to Edit", + Some(IconName::ArrowUp), + window, + cx, + )? + .into_any(); + let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); let offset = point((text_bounds.size.width - size.width) / 2., PADDING_Y); 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_accept_indicator( - "Jump to Edit", - Some(IconName::ArrowDown), - editor, - window, - cx, - )? - .into_any(); + let mut element = editor + .render_edit_prediction_line_popover( + "Jump to Edit", + Some(IconName::ArrowDown), + window, + cx, + )? + .into_any(); + let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); let offset = point( (text_bounds.size.width - size.width) / 2., @@ -3711,14 +3712,9 @@ impl EditorElement { element.prepaint_at(text_bounds.origin + offset, window, cx); Some(element) } else { - let mut element = inline_completion_accept_indicator( - "Jump to Edit", - None, - editor, - window, - cx, - )? - .into_any(); + let mut element = editor + .render_edit_prediction_line_popover("Jump to Edit", None, window, cx)? + .into_any(); let target_line_end = DisplayPoint::new( target_display_point.row(), editor_snapshot.line_len(target_display_point.row()), @@ -3776,10 +3772,11 @@ impl EditorElement { ); let (mut element, origin) = self.editor.update(cx, |editor, cx| { Some(( - inline_completion_accept_indicator( - "Accept", None, editor, window, cx, - )? - .into_any(), + editor + .render_edit_prediction_line_popover( + "Accept", None, window, cx, + )? + .into_any(), editor.display_to_pixel_point( target_line_end, editor_snapshot, @@ -3808,6 +3805,37 @@ impl EditorElement { cx, ); + let styled_text = highlighted_edits.to_styled_text(&style.text); + + const ACCEPT_INDICATOR_HEIGHT: Pixels = px(24.); + + let mut element = v_flex() + .items_end() + .shadow_sm() + .child( + h_flex() + .h(ACCEPT_INDICATOR_HEIGHT) + .mb(px(-1.)) + .px_1p5() + .gap_1() + .bg(Editor::edit_prediction_line_popover_bg_color(cx)) + .border_1() + .border_b_0() + .border_color(cx.theme().colors().border) + .rounded_t_lg() + .children(editor.render_edit_prediction_accept_keybind(window, cx)), + ) + .child( + div() + .bg(cx.theme().colors().editor_background) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_lg() + .rounded_tr(Pixels::ZERO) + .child(styled_text), + ) + .into_any(); + let line_count = highlighted_edits.text.lines().count(); let longest_row = @@ -3827,16 +3855,6 @@ impl EditorElement { .width }; - let styled_text = highlighted_edits.to_styled_text(&style.text); - - let mut element = div() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(cx.theme().colors().border) - .rounded_md() - .child(styled_text) - .into_any(); - let viewport_bounds = Bounds::new(Default::default(), window.viewport_size()) .extend(Edges { right: -Self::SCROLLBAR_WIDTH, @@ -3853,7 +3871,7 @@ impl EditorElement { let is_fully_visible = x_after_longest < text_bounds.right() && x_after_longest + element_bounds.width < viewport_bounds.right(); - let origin = if is_fully_visible { + let mut origin = if is_fully_visible { point( x_after_longest, text_bounds.origin.y + edit_start.row().as_f32() * line_height @@ -3898,6 +3916,8 @@ impl EditorElement { ) }; + origin.y -= ACCEPT_INDICATOR_HEIGHT; + window.defer_draw(element, origin, 1); // Do not return an element, since it will already be drawn due to defer_draw. @@ -5796,63 +5816,6 @@ fn header_jump_data( } } -fn inline_completion_accept_indicator( - label: impl Into, - icon: Option, - editor: &Editor, - window: &mut Window, - cx: &App, -) -> Option
{ - let accept_binding = editor.accept_edit_prediction_keybind(window, cx); - let accept_keystroke = accept_binding.keystroke()?; - - 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) - .gap_1() - .when(!editor.edit_prediction_preview_is_active(), |parent| { - parent.children(ui::render_modifiers( - &accept_keystroke.modifiers, - PlatformStyle::platform(), - Some(Color::Default), - None, - false, - )) - }) - .child(accept_keystroke.key.clone()); - - let colors = cx.theme().colors(); - - let accent_color = colors.text_accent; - let editor_bg_color = colors.editor_background; - let bg_color = editor_bg_color.blend(accent_color.opacity(0.2)); - let padding_right = if icon.is_some() { px(4.) } else { px(8.) }; - - let result = h_flex() - .gap_1() - .border_1() - .rounded_md() - .shadow_sm() - .bg(bg_color) - .border_color(colors.text_accent.opacity(0.8)) - .py_0p5() - .pl_1() - .pr(padding_right) - .child(accept_key) - .child(Label::new(label).size(LabelSize::Small)) - .when_some(icon, |element, icon| { - element.child( - div() - .mt(px(1.5)) - .child(Icon::new(icon).size(IconSize::Small)), - ) - }); - - Some(result) -} - pub struct AcceptEditPredictionBinding(pub(crate) Option); impl AcceptEditPredictionBinding { diff --git a/crates/inline_completion_button/src/inline_completion_button.rs b/crates/inline_completion_button/src/inline_completion_button.rs index 16f2d07a66..59b2c72ee4 100644 --- a/crates/inline_completion_button/src/inline_completion_button.rs +++ b/crates/inline_completion_button/src/inline_completion_button.rs @@ -573,37 +573,41 @@ impl InlineCompletionButton { language::EditPredictionsMode::Auto => false, language::EditPredictionsMode::EagerPreview => true, }; - menu = menu.separator().toggleable_entry( - "Eager Preview Mode", - is_eager_preview_enabled, - IconPosition::Start, - None, - { - let fs = fs.clone(); - move |_window, cx| { - update_settings_file::( - fs.clone(), - cx, - move |settings, _cx| { - let new_mode = match is_eager_preview_enabled { - true => language::EditPredictionsMode::Auto, - false => language::EditPredictionsMode::EagerPreview, - }; + menu = if cx.is_staff() { + menu.separator().toggleable_entry( + "Eager Preview Mode", + is_eager_preview_enabled, + IconPosition::Start, + None, + { + let fs = fs.clone(); + move |_window, cx| { + update_settings_file::( + fs.clone(), + cx, + move |settings, _cx| { + let new_mode = match is_eager_preview_enabled { + true => language::EditPredictionsMode::Auto, + false => language::EditPredictionsMode::EagerPreview, + }; - if let Some(edit_predictions) = settings.edit_predictions.as_mut() { - edit_predictions.mode = new_mode; - } else { - settings.edit_predictions = - Some(language_settings::EditPredictionSettingsContent { - mode: new_mode, - ..Default::default() - }); - } - }, - ); - } - }, - ); + if let Some(edit_predictions) = settings.edit_predictions.as_mut() { + edit_predictions.mode = new_mode; + } else { + settings.edit_predictions = + Some(language_settings::EditPredictionSettingsContent { + mode: new_mode, + ..Default::default() + }); + } + }, + ); + } + }, + ) + } else { + menu + }; if let Some(editor_focus_handle) = self.editor_focus_handle.clone() { menu = menu diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 856341dbe4..ac6f04f4b6 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -237,9 +237,9 @@ pub struct EditPredictionSettings { pub enum EditPredictionsMode { /// If provider supports it, display inline when holding modifier key (e.g., alt). /// Otherwise, eager preview is used. - #[default] Auto, /// Display inline when there are no language server completions available. + #[default] EagerPreview, } diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index f679bb6827..cc60ad46ec 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -27,7 +27,10 @@ use gpui::{ }; use http_client::{HttpClient, Method}; use input_excerpt::excerpt_for_cursor_position; -use language::{Anchor, Buffer, BufferSnapshot, EditPreview, OffsetRangeExt, ToOffset, ToPoint}; +use language::{ + Anchor, Buffer, BufferSnapshot, CharClassifier, CharKind, EditPreview, OffsetRangeExt, + ToOffset, ToPoint, +}; use language_models::LlmApiToken; use postage::watch; use project::Project; @@ -57,9 +60,9 @@ const EDITABLE_REGION_END_MARKER: &'static str = "<|editable_region_end|>"; const BUFFER_CHANGE_GROUPING_INTERVAL: Duration = Duration::from_secs(1); const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice"; -const MAX_CONTEXT_TOKENS: usize = 100; -const MAX_REWRITE_TOKENS: usize = 300; -const MAX_EVENT_TOKENS: usize = 400; +const MAX_CONTEXT_TOKENS: usize = 150; +const MAX_REWRITE_TOKENS: usize = 350; +const MAX_EVENT_TOKENS: usize = 500; /// Maximum number of events to track. const MAX_EVENT_COUNT: usize = 16; @@ -834,8 +837,34 @@ and then another offset: usize, snapshot: &BufferSnapshot, ) -> Vec<(Range, String)> { - let diff = similar::TextDiff::from_words(old_text.as_str(), new_text); + fn tokenize(text: &str) -> Vec<&str> { + let classifier = CharClassifier::new(None).for_completion(true); + let mut chars = text.chars().peekable(); + let mut prev_ch = chars.peek().copied(); + let mut tokens = Vec::new(); + let mut start = 0; + let mut end = 0; + while let Some(ch) = chars.next() { + let prev_kind = prev_ch.map(|ch| classifier.kind(ch)); + let kind = classifier.kind(ch); + if Some(kind) != prev_kind || (kind == CharKind::Punctuation && Some(ch) != prev_ch) + { + tokens.push(&text[start..end]); + start = end; + } + end += ch.len_utf8(); + prev_ch = Some(ch); + } + tokens.push(&text[start..end]); + tokens + } + let old_tokens = tokenize(&old_text); + let new_tokens = tokenize(new_text); + + let diff = similar::TextDiffConfig::default() + .algorithm(similar::Algorithm::Patience) + .diff_slices(&old_tokens, &new_tokens); let mut edits: Vec<(Range, String)> = Vec::new(); let mut old_start = offset; for change in diff.iter_all_changes() { @@ -1705,6 +1734,70 @@ mod tests { }) } + #[gpui::test] + async fn test_clean_up_diff(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + client::init_settings(cx); + }); + + let edits = edits_for_prediction( + indoc! {" + fn main() { + let word_1 = \"lorem\"; + let range = word.len()..word.len(); + } + "}, + indoc! {" + <|editable_region_start|> + fn main() { + let word_1 = \"lorem\"; + let range = word_1.len()..word_1.len(); + } + + <|editable_region_end|> + "}, + cx, + ) + .await; + assert_eq!( + edits, + [ + (Point::new(2, 20)..Point::new(2, 20), "_1".to_string()), + (Point::new(2, 32)..Point::new(2, 32), "_1".to_string()), + ] + ); + + let edits = edits_for_prediction( + indoc! {" + fn main() { + let story = \"the quick\" + } + "}, + indoc! {" + <|editable_region_start|> + fn main() { + let story = \"the quick brown fox jumps over the lazy dog\"; + } + + <|editable_region_end|> + "}, + cx, + ) + .await; + assert_eq!( + edits, + [ + ( + Point::new(1, 26)..Point::new(1, 26), + " brown fox jumps over the lazy dog".to_string() + ), + (Point::new(1, 27)..Point::new(1, 27), ";".to_string()), + ] + ); + } + #[gpui::test] async fn test_inline_completion_end_of_buffer(cx: &mut TestAppContext) { cx.update(|cx| { @@ -1768,6 +1861,58 @@ mod tests { ); } + async fn edits_for_prediction( + buffer_content: &str, + completion_response: &str, + cx: &mut TestAppContext, + ) -> Vec<(Range, String)> { + let completion_response = completion_response.to_string(); + let http_client = FakeHttpClient::create(move |_| { + let completion = completion_response.clone(); + async move { + Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&PredictEditsResponse { + request_id: Uuid::new_v4(), + output_excerpt: completion, + }) + .unwrap() + .into(), + ) + .unwrap()) + } + }); + + let client = cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); + cx.update(|cx| { + RefreshLlmTokenListener::register(client.clone(), cx); + }); + let server = FakeServer::for_client(42, &client, cx).await; + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + let zeta = cx.new(|cx| Zeta::new(client, user_store, cx)); + + let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0))); + let completion_task = zeta.update(cx, |zeta, cx| { + zeta.request_completion(None, &buffer, cursor, false, cx) + }); + + let token_request = server.receive::().await.unwrap(); + server.respond( + token_request.receipt(), + proto::GetLlmTokenResponse { token: "".into() }, + ); + + let completion = completion_task.await.unwrap().unwrap(); + completion + .edits + .into_iter() + .map(|(old_range, new_text)| (old_range.to_point(&snapshot), new_text.clone())) + .collect::>() + } + fn to_completion_edits( iterator: impl IntoIterator, String)>, buffer: &Entity,