diff --git a/Cargo.lock b/Cargo.lock index 09930893337d77..3804dce05aae89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6271,8 +6271,6 @@ version = "0.1.0" dependencies = [ "gpui", "language", - "project", - "text", ] [[package]] @@ -6282,6 +6280,7 @@ dependencies = [ "anyhow", "copilot", "editor", + "feature_flags", "fs", "futures 0.3.31", "gpui", @@ -6297,6 +6296,7 @@ dependencies = [ "ui", "workspace", "zed_actions", + "zeta", ] [[package]] @@ -16146,6 +16146,7 @@ dependencies = [ "winresource", "workspace", "zed_actions", + "zeta", ] [[package]] @@ -16456,6 +16457,43 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "zeta" +version = "0.1.0" +dependencies = [ + "anyhow", + "call", + "client", + "clock", + "collections", + "ctor", + "editor", + "env_logger 0.11.5", + "futures 0.3.31", + "gpui", + "http_client", + "indoc", + "inline_completion", + "language", + "language_models", + "log", + "menu", + "reqwest_client", + "rpc", + "serde_json", + "settings", + "similar", + "telemetry_events", + "theme", + "tree-sitter-go", + "tree-sitter-rust", + "ui", + "util", + "uuid", + "workspace", + "worktree", +] + [[package]] name = "zip" version = "0.6.6" diff --git a/Cargo.toml b/Cargo.toml index 7ff0ad6ce3534d..77642558055685 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,6 +141,7 @@ members = [ "crates/worktree", "crates/zed", "crates/zed_actions", + "crates/zeta", # # Extensions @@ -325,6 +326,7 @@ workspace = { path = "crates/workspace" } worktree = { path = "crates/worktree" } zed = { path = "crates/zed" } zed_actions = { path = "crates/zed_actions" } +zeta = { path = "crates/zeta" } # # External crates diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index eef2a8215fe910..3e97b0164b4ee4 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -18,7 +18,8 @@ use std::time::Instant; use std::{env, mem, path::PathBuf, sync::Arc, time::Duration}; use telemetry_events::{ ActionEvent, AppEvent, AssistantEvent, CallEvent, EditEvent, EditorEvent, Event, - EventRequestBody, EventWrapper, ExtensionEvent, InlineCompletionEvent, ReplEvent, SettingEvent, + EventRequestBody, EventWrapper, ExtensionEvent, InlineCompletionEvent, InlineCompletionRating, + InlineCompletionRatingEvent, ReplEvent, SettingEvent, }; use util::{ResultExt, TryFutureExt}; use worktree::{UpdatedEntriesSet, WorktreeId}; @@ -355,6 +356,24 @@ impl Telemetry { self.report_event(event) } + pub fn report_inline_completion_rating_event( + self: &Arc, + rating: InlineCompletionRating, + input_events: Arc, + input_excerpt: Arc, + output_excerpt: Arc, + feedback: String, + ) { + let event = Event::InlineCompletionRating(InlineCompletionRatingEvent { + rating, + input_events, + input_excerpt, + output_excerpt, + feedback, + }); + self.report_event(event); + } + pub fn report_assistant_event(self: &Arc, event: AssistantEvent) { self.report_event(Event::Assistant(event)); } diff --git a/crates/collab/k8s/collab.template.yml b/crates/collab/k8s/collab.template.yml index a2f89e56462a09..89921f242425f6 100644 --- a/crates/collab/k8s/collab.template.yml +++ b/crates/collab/k8s/collab.template.yml @@ -149,6 +149,21 @@ spec: secretKeyRef: name: google-ai key: api_key + - name: PREDICTION_API_URL + valueFrom: + secretKeyRef: + name: prediction + key: api_url + - name: PREDICTION_API_KEY + valueFrom: + secretKeyRef: + name: prediction + key: api_key + - name: PREDICTION_MODEL + valueFrom: + secretKeyRef: + name: prediction + key: model - name: BLOB_STORE_ACCESS_KEY valueFrom: secretKeyRef: diff --git a/crates/collab/src/api/events.rs b/crates/collab/src/api/events.rs index b5cd920fb30d4c..1dc036ca862643 100644 --- a/crates/collab/src/api/events.rs +++ b/crates/collab/src/api/events.rs @@ -483,7 +483,7 @@ pub async fn post_events( checksum_matched, )) } - Event::Cpu(_) | Event::Memory(_) => continue, + Event::Cpu(_) | Event::Memory(_) | Event::InlineCompletionRating(_) => continue, Event::App(event) => to_upload.app_events.push(AppEventRow::from_event( event.clone(), wrapper, @@ -1406,6 +1406,10 @@ fn for_snowflake( ), serde_json::to_value(e).unwrap(), ), + Event::InlineCompletionRating(e) => ( + "Inline Completion Feedback".to_string(), + serde_json::to_value(e).unwrap(), + ), Event::Call(e) => { let event_type = match e.operation.trim() { "unshare project" => "Project Unshared".to_string(), diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index cfa0e1631ebca3..9c87b69826e932 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -180,6 +180,9 @@ pub struct Config { pub anthropic_api_key: Option>, pub anthropic_staff_api_key: Option>, pub llm_closed_beta_model_name: Option>, + pub prediction_api_url: Option>, + pub prediction_api_key: Option>, + pub prediction_model: Option>, pub zed_client_checksum_seed: Option, pub slack_panics_webhook: Option, pub auto_join_channel_id: Option, @@ -230,6 +233,9 @@ impl Config { anthropic_api_key: None, anthropic_staff_api_key: None, llm_closed_beta_model_name: None, + prediction_api_url: None, + prediction_api_key: None, + prediction_model: None, clickhouse_url: None, clickhouse_user: None, clickhouse_password: None, diff --git a/crates/collab/src/llm.rs b/crates/collab/src/llm.rs index 603b76db739e19..94329c0b6f5467 100644 --- a/crates/collab/src/llm.rs +++ b/crates/collab/src/llm.rs @@ -29,7 +29,10 @@ use reqwest_client::ReqwestClient; use rpc::{ proto::Plan, LanguageModelProvider, PerformCompletionParams, EXPIRED_LLM_TOKEN_HEADER_NAME, }; -use rpc::{ListModelsResponse, MAX_LLM_MONTHLY_SPEND_REACHED_HEADER_NAME}; +use rpc::{ + ListModelsResponse, PredictEditsParams, PredictEditsResponse, + MAX_LLM_MONTHLY_SPEND_REACHED_HEADER_NAME, +}; use serde_json::json; use std::{ pin::Pin, @@ -126,6 +129,7 @@ pub fn routes() -> Router<(), Body> { Router::new() .route("/models", get(list_models)) .route("/completion", post(perform_completion)) + .route("/predict_edits", post(predict_edits)) .layer(middleware::from_fn(validate_api_token)) } @@ -439,6 +443,59 @@ fn normalize_model_name(known_models: Vec, name: String) -> String { } } +async fn predict_edits( + Extension(state): Extension>, + Extension(claims): Extension, + _country_code_header: Option>, + Json(params): Json, +) -> Result { + if !claims.is_staff { + return Err(anyhow!("not found"))?; + } + + let api_url = state + .config + .prediction_api_url + .as_ref() + .context("no PREDICTION_API_URL configured on the server")?; + let api_key = state + .config + .prediction_api_key + .as_ref() + .context("no PREDICTION_API_KEY configured on the server")?; + let model = state + .config + .prediction_model + .as_ref() + .context("no PREDICTION_MODEL configured on the server")?; + let prompt = include_str!("./llm/prediction_prompt.md") + .replace("", ¶ms.input_events) + .replace("", ¶ms.input_excerpt); + let mut response = open_ai::complete_text( + &state.http_client, + api_url, + api_key, + open_ai::CompletionRequest { + model: model.to_string(), + prompt: prompt.clone(), + max_tokens: 1024, + temperature: 0., + prediction: Some(open_ai::Prediction::Content { + content: params.input_excerpt, + }), + rewrite_speculation: Some(true), + }, + ) + .await?; + let choice = response + .choices + .pop() + .context("no output from completion response")?; + Ok(Json(PredictEditsResponse { + output_excerpt: choice.text, + })) +} + /// The maximum monthly spending an individual user can reach on the free tier /// before they have to pay. pub const FREE_TIER_MONTHLY_SPENDING_LIMIT: Cents = Cents::from_dollars(10); diff --git a/crates/collab/src/llm/prediction_prompt.md b/crates/collab/src/llm/prediction_prompt.md new file mode 100644 index 00000000000000..81de06d2804afc --- /dev/null +++ b/crates/collab/src/llm/prediction_prompt.md @@ -0,0 +1,12 @@ +Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request. + +### Instruction: +You are a code completion assistant and your task is to analyze user edits and then rewrite an excerpt that the user provides, suggesting the appropriate edits within the excerpt, taking into account the cursor location. + +### Events: + + +### Input: + + +### Response: diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index e66a828a77f213..91e103510c4edf 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -546,6 +546,9 @@ impl TestServer { anthropic_api_key: None, anthropic_staff_api_key: None, llm_closed_beta_model_name: None, + prediction_api_url: None, + prediction_api_key: None, + prediction_model: None, clickhouse_url: None, clickhouse_user: None, clickhouse_password: None, diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 2cbe76c16ec4b3..5905d2d46ef520 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -59,18 +59,21 @@ workspace.workspace = true async-std = { version = "1.12.0", features = ["unstable"] } [dev-dependencies] -clock.workspace = true indoc.workspace = true serde_json.workspace = true +clock = { workspace = true, features = ["test-support"] } +client = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } +http_client = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } +node_runtime = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } rpc = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } -http_client = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/copilot/src/copilot_completion_provider.rs b/crates/copilot/src/copilot_completion_provider.rs index 85fe20f1ae54f4..8d664e22899c49 100644 --- a/crates/copilot/src/copilot_completion_provider.rs +++ b/crates/copilot/src/copilot_completion_provider.rs @@ -1,14 +1,13 @@ use crate::{Completion, Copilot}; use anyhow::Result; -use client::telemetry::Telemetry; use gpui::{AppContext, EntityId, Model, ModelContext, Task}; -use inline_completion::{CompletionProposal, Direction, InlayProposal, InlineCompletionProvider}; +use inline_completion::{Direction, InlineCompletion, InlineCompletionProvider}; use language::{ language_settings::{all_language_settings, AllLanguageSettings}, Buffer, OffsetRangeExt, ToOffset, }; use settings::Settings; -use std::{path::Path, sync::Arc, time::Duration}; +use std::{path::Path, time::Duration}; pub const COPILOT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75); @@ -21,7 +20,6 @@ pub struct CopilotCompletionProvider { pending_refresh: Task>, pending_cycling_refresh: Task>, copilot: Model, - telemetry: Option>, } impl CopilotCompletionProvider { @@ -35,15 +33,9 @@ impl CopilotCompletionProvider { pending_refresh: Task::ready(Ok(())), pending_cycling_refresh: Task::ready(Ok(())), copilot, - telemetry: None, } } - pub fn with_telemetry(mut self, telemetry: Arc) -> Self { - self.telemetry = Some(telemetry); - self - } - fn active_completion(&self) -> Option<&Completion> { self.completions.get(self.active_completion_index) } @@ -190,23 +182,10 @@ impl InlineCompletionProvider for CopilotCompletionProvider { self.copilot .update(cx, |copilot, cx| copilot.accept_completion(completion, cx)) .detach_and_log_err(cx); - if self.active_completion().is_some() { - if let Some(telemetry) = self.telemetry.as_ref() { - telemetry.report_inline_completion_event( - Self::name().to_string(), - true, - self.file_extension.clone(), - ); - } - } } } - fn discard( - &mut self, - should_report_inline_completion_event: bool, - cx: &mut ModelContext, - ) { + fn discard(&mut self, cx: &mut ModelContext) { let settings = AllLanguageSettings::get_global(cx); let copilot_enabled = settings.inline_completions_enabled(None, None, cx); @@ -220,24 +199,14 @@ impl InlineCompletionProvider for CopilotCompletionProvider { copilot.discard_completions(&self.completions, cx) }) .detach_and_log_err(cx); - - if should_report_inline_completion_event && self.active_completion().is_some() { - if let Some(telemetry) = self.telemetry.as_ref() { - telemetry.report_inline_completion_event( - Self::name().to_string(), - false, - self.file_extension.clone(), - ); - } - } } - fn active_completion_text<'a>( - &'a self, + fn suggest( + &mut self, buffer: &Model, cursor_position: language::Anchor, - cx: &'a AppContext, - ) -> Option { + cx: &mut ModelContext, + ) -> Option { let buffer_id = buffer.entity_id(); let buffer = buffer.read(cx); let completion = self.active_completion()?; @@ -267,13 +236,9 @@ impl InlineCompletionProvider for CopilotCompletionProvider { if completion_text.trim().is_empty() { None } else { - Some(CompletionProposal { - inlays: vec![InlayProposal::Suggestion( - cursor_position.bias_right(buffer), - completion_text.into(), - )], - text: completion_text.into(), - delete_range: None, + let position = cursor_position.bias_right(buffer); + Some(InlineCompletion { + edits: vec![(position..position, completion_text.into())], }) } } else { @@ -359,7 +324,7 @@ mod tests { executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, cx| { assert!(editor.context_menu_visible()); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); // Confirming a completion inserts it and hides the context menu, without showing // the copilot suggestion afterwards. @@ -368,7 +333,7 @@ mod tests { .unwrap() .detach(); assert!(!editor.context_menu_visible()); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); assert_eq!(editor.text(cx), "one.completion_a\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.completion_a\ntwo\nthree\n"); }); @@ -401,7 +366,7 @@ mod tests { executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, cx| { assert!(!editor.context_menu_visible()); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.\ntwo\nthree\n"); }); @@ -434,12 +399,12 @@ mod tests { executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, cx| { assert!(editor.context_menu_visible()); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); // When hiding the context menu, the Copilot suggestion becomes visible. editor.cancel(&Default::default(), cx); assert!(!editor.context_menu_visible()); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.\ntwo\nthree\n"); }); @@ -449,7 +414,7 @@ mod tests { executor.run_until_parked(); cx.update_editor(|editor, cx| { assert!(!editor.context_menu_visible()); - assert!(editor.has_active_inline_completion(cx)); + 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"); }); @@ -467,25 +432,25 @@ mod tests { executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, cx| { assert!(!editor.context_menu_visible()); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); // Canceling should remove the active Copilot suggestion. editor.cancel(&Default::default(), cx); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one.c\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); // After canceling, tabbing shouldn't insert the previously shown suggestion. editor.tab(&Default::default(), cx); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one.c \ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.c \ntwo\nthree\n"); // When undoing the previously active suggestion is shown again. editor.undo(&Default::default(), cx); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); }); @@ -493,25 +458,25 @@ mod tests { // If an edit occurs outside of this editor, the suggestion is still correctly interpolated. cx.update_buffer(|buffer, cx| buffer.edit([(5..5, "o")], None, cx)); cx.update_editor(|editor, cx| { - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); // AcceptInlineCompletion when there is an active suggestion inserts it. editor.accept_inline_completion(&Default::default(), cx); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.copilot2\ntwo\nthree\n"); // When undoing the previously active suggestion is shown again. editor.undo(&Default::default(), cx); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); // Hide suggestion. editor.cancel(&Default::default(), cx); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one.co\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); }); @@ -520,7 +485,7 @@ mod tests { // we won't make it visible. cx.update_buffer(|buffer, cx| buffer.edit([(6..6, "p")], None, cx)); cx.update_editor(|editor, cx| { - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one.cop\ntwo\nthree\n"); assert_eq!(editor.text(cx), "one.cop\ntwo\nthree\n"); }); @@ -545,19 +510,19 @@ mod tests { cx.update_editor(|editor, cx| editor.next_inline_completion(&Default::default(), cx)); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, cx| { - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); assert_eq!(editor.text(cx), "fn foo() {\n \n}"); // Tabbing inside of leading whitespace inserts indentation without accepting the suggestion. editor.tab(&Default::default(), cx); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.text(cx), "fn foo() {\n \n}"); assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); // Using AcceptInlineCompletion again accepts the suggestion. editor.accept_inline_completion(&Default::default(), cx); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); assert_eq!(editor.text(cx), "fn foo() {\n let x = 4;\n}"); assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); }); @@ -615,17 +580,17 @@ mod tests { ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, cx| { - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); // Accepting the first word of the suggestion should only accept the first word and still show the rest. editor.accept_partial_inline_completion(&Default::default(), cx); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.text(cx), "one.copilot\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); // Accepting next word should accept the non-word and copilot suggestion should be gone editor.accept_partial_inline_completion(&Default::default(), cx); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); assert_eq!(editor.text(cx), "one.copilot1\ntwo\nthree\n"); assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); }); @@ -657,11 +622,11 @@ mod tests { ); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, cx| { - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); // Accepting the first word (non-word) of the suggestion should only accept the first word and still show the rest. editor.accept_partial_inline_completion(&Default::default(), cx); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.text(cx), "one.123. \ntwo\nthree\n"); assert_eq!( editor.display_text(cx), @@ -670,7 +635,7 @@ mod tests { // Accepting next word should accept the next word and copilot suggestion should still exist editor.accept_partial_inline_completion(&Default::default(), cx); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.text(cx), "one.123. copilot\ntwo\nthree\n"); assert_eq!( editor.display_text(cx), @@ -679,7 +644,7 @@ mod tests { // Accepting the whitespace should accept the non-word/whitespaces with newline and copilot suggestion should be gone editor.accept_partial_inline_completion(&Default::default(), cx); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); assert_eq!(editor.text(cx), "one.123. copilot\n 456\ntwo\nthree\n"); assert_eq!( editor.display_text(cx), @@ -730,29 +695,29 @@ mod tests { cx.update_editor(|editor, cx| editor.next_inline_completion(&Default::default(), cx)); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, cx| { - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); assert_eq!(editor.text(cx), "one\ntw\nthree\n"); editor.backspace(&Default::default(), cx); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); assert_eq!(editor.text(cx), "one\nt\nthree\n"); editor.backspace(&Default::default(), cx); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); assert_eq!(editor.text(cx), "one\n\nthree\n"); // Deleting across the original suggestion range invalidates it. editor.backspace(&Default::default(), cx); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one\nthree\n"); assert_eq!(editor.text(cx), "one\nthree\n"); // Undoing the deletion restores the suggestion. editor.undo(&Default::default(), cx); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); assert_eq!(editor.text(cx), "one\n\nthree\n"); }); @@ -813,7 +778,7 @@ mod tests { }); executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); _ = editor.update(cx, |editor, cx| { - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!( editor.display_text(cx), "\n\n\na = 1\nb = 2 + a\n\n\n\n\n\nc = 3\nd = 4\n\n" @@ -835,7 +800,7 @@ mod tests { editor.change_selections(None, cx, |s| { s.select_ranges([Point::new(4, 5)..Point::new(4, 5)]) }); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); assert_eq!( editor.display_text(cx), "\n\n\na = 1\nb = 2\n\n\n\n\n\nc = 3\nd = 4\n\n" @@ -844,7 +809,7 @@ mod tests { // Type a character, ensuring we don't even try to interpolate the previous suggestion. editor.handle_input(" ", cx); - assert!(!editor.has_active_inline_completion(cx)); + assert!(!editor.has_active_inline_completion()); assert_eq!( editor.display_text(cx), "\n\n\na = 1\nb = 2\n\n\n\n\n\nc = 3\nd = 4 \n\n" @@ -855,7 +820,7 @@ mod tests { // Ensure the new suggestion is displayed when the debounce timeout expires. executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); _ = editor.update(cx, |editor, cx| { - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!( editor.display_text(cx), "\n\n\na = 1\nb = 2\n\n\n\n\n\nc = 3\nd = 4 + c\n\n" @@ -916,7 +881,7 @@ mod tests { executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, cx| { assert!(!editor.context_menu_visible(), "Even there are some completions available, those are not triggered when active copilot suggestion is present"); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); assert_eq!(editor.text(cx), "one\ntw\nthree\n"); }); @@ -943,7 +908,7 @@ mod tests { executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); cx.update_editor(|editor, cx| { assert!(!editor.context_menu_visible()); - assert!(editor.has_active_inline_completion(cx)); + assert!(editor.has_active_inline_completion()); assert_eq!(editor.display_text(cx), "one\ntwo.foo()\nthree\n"); assert_eq!(editor.text(cx), "one\ntwo\nthree\n"); }); @@ -974,7 +939,7 @@ mod tests { "On completion trigger input, the completions should be fetched and visible" ); assert!( - !editor.has_active_inline_completion(cx), + !editor.has_active_inline_completion(), "On completion trigger input, copilot suggestion should be dismissed" ); assert_eq!(editor.display_text(cx), "one\ntwo.\nthree\n"); @@ -998,7 +963,7 @@ mod tests { "/test", json!({ ".env": "SECRET=something\n", - "README.md": "hello\n" + "README.md": "hello\nworld\nhow\nare\nyou\ntoday" }), ) .await; @@ -1030,7 +995,7 @@ mod tests { multibuffer.push_excerpts( public_buffer.clone(), [ExcerptRange { - context: Point::new(0, 0)..Point::new(1, 0), + context: Point::new(0, 0)..Point::new(6, 0), primary: None, }], cx, @@ -1038,6 +1003,7 @@ mod tests { multibuffer }); let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, true, cx)); + editor.update(cx, |editor, cx| editor.focus(cx)).unwrap(); let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot)); editor .update(cx, |editor, cx| { @@ -1073,7 +1039,7 @@ mod tests { _ = editor.update(cx, |editor, cx| { editor.change_selections(None, cx, |s| { - s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) + s.select_ranges([Point::new(5, 0)..Point::new(5, 0)]) }); editor.refresh_inline_completion(true, false, cx); }); diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 2c62295a290dc6..a02b925456672a 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -1125,6 +1125,12 @@ impl DisplaySnapshot { DisplayRow(self.block_snapshot.longest_row()) } + pub fn longest_row_in_range(&self, range: Range) -> DisplayRow { + let block_range = BlockRow(range.start.0)..BlockRow(range.end.0); + let longest_row = self.block_snapshot.longest_row_in_range(block_range); + DisplayRow(longest_row.0) + } + pub fn starts_indent(&self, buffer_row: MultiBufferRow) -> bool { let max_row = self.buffer_snapshot.max_row(); if buffer_row >= max_row { diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 1300537a2a216f..b495669ef8eb85 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -1339,6 +1339,57 @@ impl BlockSnapshot { self.transforms.summary().longest_row } + pub fn longest_row_in_range(&self, range: Range) -> BlockRow { + let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&()); + cursor.seek(&range.start, Bias::Right, &()); + + let mut longest_row = range.start; + let mut longest_row_chars = 0; + if let Some(transform) = cursor.item() { + if transform.block.is_none() { + let (output_start, input_start) = cursor.start(); + let overshoot = range.start.0 - output_start.0; + let wrap_start_row = input_start.0 + overshoot; + let wrap_end_row = cmp::min( + input_start.0 + (range.end.0 - output_start.0), + cursor.end(&()).1 .0, + ); + let summary = self + .wrap_snapshot + .text_summary_for_range(wrap_start_row..wrap_end_row); + longest_row = BlockRow(range.start.0 + summary.longest_row); + longest_row_chars = summary.longest_row_chars; + } + cursor.next(&()); + } + + let cursor_start_row = cursor.start().0; + if range.end > cursor_start_row { + let summary = cursor.summary::<_, TransformSummary>(&range.end, Bias::Right, &()); + if summary.longest_row_chars > longest_row_chars { + longest_row = BlockRow(cursor_start_row.0 + summary.longest_row); + longest_row_chars = summary.longest_row_chars; + } + + if let Some(transform) = cursor.item() { + if transform.block.is_none() { + let (output_start, input_start) = cursor.start(); + let overshoot = range.end.0 - output_start.0; + let wrap_start_row = input_start.0; + let wrap_end_row = input_start.0 + overshoot; + let summary = self + .wrap_snapshot + .text_summary_for_range(wrap_start_row..wrap_end_row); + if summary.longest_row_chars > longest_row_chars { + longest_row = BlockRow(output_start.0 + summary.longest_row); + } + } + } + } + + longest_row + } + pub(super) fn line_len(&self, row: BlockRow) -> u32 { let mut cursor = self.transforms.cursor::<(BlockRow, WrapRow)>(&()); cursor.seek(&BlockRow(row.0), Bias::Right, &()); @@ -2705,6 +2756,40 @@ mod tests { longest_line_len, ); + for _ in 0..10 { + let end_row = rng.gen_range(1..=expected_lines.len()); + let start_row = rng.gen_range(0..end_row); + + let mut expected_longest_rows_in_range = vec![]; + let mut longest_line_len_in_range = 0; + + let mut row = start_row as u32; + for line in &expected_lines[start_row..end_row] { + let line_char_count = line.chars().count() as isize; + match line_char_count.cmp(&longest_line_len_in_range) { + Ordering::Less => {} + Ordering::Equal => expected_longest_rows_in_range.push(row), + Ordering::Greater => { + longest_line_len_in_range = line_char_count; + expected_longest_rows_in_range.clear(); + expected_longest_rows_in_range.push(row); + } + } + row += 1; + } + + let longest_row_in_range = blocks_snapshot + .longest_row_in_range(BlockRow(start_row as u32)..BlockRow(end_row as u32)); + assert!( + expected_longest_rows_in_range.contains(&longest_row_in_range.0), + "incorrect longest row {} in range {:?}. expected {:?} with length {}", + longest_row, + start_row..end_row, + expected_longest_rows_in_range, + longest_line_len_in_range, + ); + } + // Ensure that conversion between block points and wrap points is stable. for row in 0..=blocks_snapshot.wrap_snapshot.max_point().row() { let wrap_point = WrapPoint::new(row, 0); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b2abe8db80c373..98e91f97512727 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -42,6 +42,8 @@ pub mod tasks; #[cfg(test)] mod editor_tests; +#[cfg(test)] +mod inline_completion_tests; mod signature_help; #[cfg(any(test, feature = "test-support"))] pub mod test; @@ -87,7 +89,7 @@ use hunk_diff::{diff_hunk_to_display, DiffMap, DiffMapSnapshot}; use indent_guides::ActiveIndentGuidesState; use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; pub use inline_completion::Direction; -use inline_completion::{InlayProposal, InlineCompletionProvider, InlineCompletionProviderHandle}; +use inline_completion::{InlineCompletionProvider, InlineCompletionProviderHandle}; pub use items::MAX_TAB_TITLE_LEN; use itertools::Itertools; use language::{ @@ -438,22 +440,19 @@ pub fn make_inlay_hints_style(cx: &WindowContext) -> HighlightStyle { type CompletionId = usize; -#[derive(Clone, Debug)] -struct CompletionState { - // render_inlay_ids represents the inlay hints that are inserted - // for rendering the inline completions. They may be discontinuous - // in the event that the completion provider returns some intersection - // with the existing content. - render_inlay_ids: Vec, - // text is the resulting rope that is inserted when the user accepts a completion. - text: Rope, - // position is the position of the cursor when the completion was triggered. - position: multi_buffer::Anchor, - // delete_range is the range of text that this completion state covers. - // if the completion is accepted, this range should be deleted. - delete_range: Option>, +enum InlineCompletion { + Edit(Vec<(Range, String)>), + Move(Anchor), } +struct InlineCompletionState { + inlay_ids: Vec, + completion: InlineCompletion, + invalidation_range: Range, +} + +enum InlineCompletionHighlight {} + #[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Default)] struct EditorActionId(usize); @@ -619,7 +618,7 @@ pub struct Editor { hovered_link_state: Option, inline_completion_provider: Option, code_action_providers: Vec>, - active_inline_completion: Option, + active_inline_completion: Option, // enable_inline_completions is a switch that Vim can use to disable // inline completions based on its mode. enable_inline_completions: bool, @@ -2250,7 +2249,7 @@ impl Editor { key_context.set("extension", extension.to_string()); } - if self.has_active_inline_completion(cx) { + if self.has_active_inline_completion() { key_context.add("copilot_suggestion"); key_context.add("inline_completion"); } @@ -2760,7 +2759,7 @@ impl Editor { self.refresh_code_actions(cx); self.refresh_document_highlights(cx); refresh_matching_bracket_highlights(self, cx); - self.discard_inline_completion(false, cx); + self.update_visible_inline_completion(cx); linked_editing_ranges::refresh_linked_ranges(self, cx); if self.git_blame_inline_enabled { self.start_inline_blame_timer(cx); @@ -3651,7 +3650,7 @@ impl Editor { ); } - let had_active_inline_completion = this.has_active_inline_completion(cx); + let had_active_inline_completion = this.has_active_inline_completion(); this.change_selections_inner(Some(Autoscroll::fit()), false, cx, |s| { s.select(new_selections) }); @@ -4386,7 +4385,7 @@ impl Editor { cx: &mut ViewContext, ) { self.display_map.update(cx, |display_map, cx| { - display_map.splice_inlays(to_remove, to_insert, cx); + display_map.splice_inlays(to_remove, to_insert, cx) }); cx.notify(); } @@ -5243,7 +5242,8 @@ impl Editor { if !user_requested && (!self.enable_inline_completions - || !self.should_show_inline_completions(&buffer, cursor_buffer_position, cx)) + || !self.should_show_inline_completions(&buffer, cursor_buffer_position, cx) + || !self.is_focused(cx)) { self.discard_inline_completion(false, cx); return None; @@ -5276,7 +5276,7 @@ impl Editor { } pub fn show_inline_completion(&mut self, _: &ShowInlineCompletion, cx: &mut ViewContext) { - if !self.has_active_inline_completion(cx) { + if !self.has_active_inline_completion() { self.refresh_inline_completion(false, true, cx); return; } @@ -5303,7 +5303,7 @@ impl Editor { } pub fn next_inline_completion(&mut self, _: &NextInlineCompletion, cx: &mut ViewContext) { - if self.has_active_inline_completion(cx) { + if self.has_active_inline_completion() { self.cycle_inline_completion(Direction::Next, cx); } else { let is_copilot_disabled = self.refresh_inline_completion(false, true, cx).is_none(); @@ -5318,7 +5318,7 @@ impl Editor { _: &PreviousInlineCompletion, cx: &mut ViewContext, ) { - if self.has_active_inline_completion(cx) { + if self.has_active_inline_completion() { self.cycle_inline_completion(Direction::Prev, cx); } else { let is_copilot_disabled = self.refresh_inline_completion(false, true, cx).is_none(); @@ -5333,24 +5333,43 @@ impl Editor { _: &AcceptInlineCompletion, cx: &mut ViewContext, ) { - let Some(completion) = self.take_active_inline_completion(cx) else { + let Some(active_inline_completion) = self.active_inline_completion.as_ref() else { return; }; - if let Some(provider) = self.inline_completion_provider() { - provider.accept(cx); - } - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: None, - text: completion.text.to_string().into(), - }); + self.report_inline_completion_event(true, cx); + + match &active_inline_completion.completion { + InlineCompletion::Move(position) => { + let position = *position; + self.change_selections(Some(Autoscroll::newest()), cx, |selections| { + selections.select_anchor_ranges([position..position]); + }); + } + InlineCompletion::Edit(edits) => { + if let Some(provider) = self.inline_completion_provider() { + provider.accept(cx); + } + + let snapshot = self.buffer.read(cx).snapshot(cx); + let last_edit_end = edits.last().unwrap().0.end.bias_right(&snapshot); - if let Some(range) = completion.delete_range { - self.change_selections(None, cx, |s| s.select_ranges([range])) + self.buffer.update(cx, |buffer, cx| { + buffer.edit(edits.iter().cloned(), None, cx) + }); + + self.change_selections(None, cx, |s| { + s.select_anchor_ranges([last_edit_end..last_edit_end]) + }); + + self.update_visible_inline_completion(cx); + if self.active_inline_completion.is_none() { + self.refresh_inline_completion(true, true, cx); + } + + cx.notify(); + } } - self.insert_with_autoindent_mode(&completion.text.to_string(), None, cx); - self.refresh_inline_completion(true, true, cx); - cx.notify(); } pub fn accept_partial_inline_completion( @@ -5358,35 +5377,48 @@ impl Editor { _: &AcceptPartialInlineCompletion, cx: &mut ViewContext, ) { - if self.selections.count() == 1 && self.has_active_inline_completion(cx) { - if let Some(completion) = self.take_active_inline_completion(cx) { - let mut partial_completion = completion - .text - .chars() - .by_ref() - .take_while(|c| c.is_alphabetic()) - .collect::(); - if partial_completion.is_empty() { - partial_completion = completion - .text + let Some(active_inline_completion) = self.active_inline_completion.as_ref() else { + return; + }; + if self.selections.count() != 1 { + return; + } + + self.report_inline_completion_event(true, cx); + + match &active_inline_completion.completion { + InlineCompletion::Move(position) => { + let position = *position; + self.change_selections(Some(Autoscroll::newest()), cx, |selections| { + selections.select_anchor_ranges([position..position]); + }); + } + InlineCompletion::Edit(edits) => { + if edits.len() == 1 && edits[0].0.start == edits[0].0.end { + let text = edits[0].1.as_str(); + let mut partial_completion = text .chars() .by_ref() - .take_while(|c| c.is_whitespace() || !c.is_alphabetic()) + .take_while(|c| c.is_alphabetic()) .collect::(); - } + if partial_completion.is_empty() { + partial_completion = text + .chars() + .by_ref() + .take_while(|c| c.is_whitespace() || !c.is_alphabetic()) + .collect::(); + } - cx.emit(EditorEvent::InputHandled { - utf16_range_to_replace: None, - text: partial_completion.clone().into(), - }); + cx.emit(EditorEvent::InputHandled { + utf16_range_to_replace: None, + text: partial_completion.clone().into(), + }); - if let Some(range) = completion.delete_range { - self.change_selections(None, cx, |s| s.select_ranges([range])) - } - self.insert_with_autoindent_mode(&partial_completion, None, cx); + self.insert_with_autoindent_mode(&partial_completion, None, cx); - self.refresh_inline_completion(true, true, cx); - cx.notify(); + self.refresh_inline_completion(true, true, cx); + cx.notify(); + } } } } @@ -5396,106 +5428,178 @@ impl Editor { should_report_inline_completion_event: bool, cx: &mut ViewContext, ) -> bool { + if should_report_inline_completion_event { + self.report_inline_completion_event(false, cx); + } + if let Some(provider) = self.inline_completion_provider() { - provider.discard(should_report_inline_completion_event, cx); + provider.discard(cx); } self.take_active_inline_completion(cx).is_some() } - pub fn has_active_inline_completion(&self, cx: &AppContext) -> bool { - if let Some(completion) = self.active_inline_completion.as_ref() { - let buffer = self.buffer.read(cx).read(cx); - completion.position.is_valid(&buffer) - } else { - false - } + fn report_inline_completion_event(&self, accepted: bool, cx: &AppContext) { + let Some(provider) = self.inline_completion_provider() else { + return; + }; + let Some(project) = self.project.as_ref() else { + return; + }; + let Some((_, buffer, _)) = self + .buffer + .read(cx) + .excerpt_containing(self.selections.newest_anchor().head(), cx) + else { + return; + }; + + let project = project.read(cx); + let extension = buffer + .read(cx) + .file() + .and_then(|file| Some(file.path().extension()?.to_string_lossy().to_string())); + project.client().telemetry().report_inline_completion_event( + provider.name().into(), + accepted, + extension, + ); + } + + pub fn has_active_inline_completion(&self) -> bool { + self.active_inline_completion.is_some() } fn take_active_inline_completion( &mut self, cx: &mut ViewContext, - ) -> Option { - let completion = self.active_inline_completion.take()?; - let render_inlay_ids = completion.render_inlay_ids.clone(); - self.display_map.update(cx, |map, cx| { - map.splice_inlays(render_inlay_ids, Default::default(), cx); - }); - let buffer = self.buffer.read(cx).read(cx); - - if completion.position.is_valid(&buffer) { - Some(completion) - } else { - None - } + ) -> Option { + let active_inline_completion = self.active_inline_completion.take()?; + self.splice_inlays(active_inline_completion.inlay_ids, Default::default(), cx); + self.clear_highlights::(cx); + Some(active_inline_completion.completion) } - fn update_visible_inline_completion(&mut self, cx: &mut ViewContext) { + fn update_visible_inline_completion(&mut self, cx: &mut ViewContext) -> Option<()> { let selection = self.selections.newest_anchor(); let cursor = selection.head(); - + let multibuffer = self.buffer.read(cx).snapshot(cx); + let offset_selection = selection.map(|endpoint| endpoint.to_offset(&multibuffer)); let excerpt_id = cursor.excerpt_id; - if self.context_menu.read().is_none() - && self.completion_tasks.is_empty() - && selection.start == selection.end + if self.context_menu.read().is_some() + || (!self.completion_tasks.is_empty() && !self.has_active_inline_completion()) + || !offset_selection.is_empty() + || self + .active_inline_completion + .as_ref() + .map_or(false, |completion| { + let invalidation_range = completion.invalidation_range.to_offset(&multibuffer); + let invalidation_range = invalidation_range.start..=invalidation_range.end; + !invalidation_range.contains(&offset_selection.head()) + }) { - if let Some(provider) = self.inline_completion_provider() { - if let Some((buffer, cursor_buffer_position)) = - self.buffer.read(cx).text_anchor_for_position(cursor, cx) - { - if let Some(proposal) = - provider.active_completion_text(&buffer, cursor_buffer_position, cx) - { - let mut to_remove = Vec::new(); - if let Some(completion) = self.active_inline_completion.take() { - to_remove.extend(completion.render_inlay_ids.iter()); - } + self.discard_inline_completion(false, cx); + return None; + } - let to_add = proposal - .inlays - .iter() - .filter_map(|inlay| { - let snapshot = self.buffer.read(cx).snapshot(cx); - let id = post_inc(&mut self.next_inlay_id); - match inlay { - InlayProposal::Hint(position, hint) => { - let position = - snapshot.anchor_in_excerpt(excerpt_id, *position)?; - Some(Inlay::hint(id, position, hint)) - } - InlayProposal::Suggestion(position, text) => { - let position = - snapshot.anchor_in_excerpt(excerpt_id, *position)?; - Some(Inlay::suggestion(id, position, text.clone())) - } - } - }) - .collect_vec(); - - self.active_inline_completion = Some(CompletionState { - position: cursor, - text: proposal.text, - delete_range: proposal.delete_range.and_then(|range| { - let snapshot = self.buffer.read(cx).snapshot(cx); - let start = snapshot.anchor_in_excerpt(excerpt_id, range.start); - let end = snapshot.anchor_in_excerpt(excerpt_id, range.end); - Some(start?..end?) - }), - render_inlay_ids: to_add.iter().map(|i| i.id).collect(), - }); + self.take_active_inline_completion(cx); + let provider = self.inline_completion_provider()?; - self.display_map - .update(cx, move |map, cx| map.splice_inlays(to_remove, to_add, cx)); + let (buffer, cursor_buffer_position) = + self.buffer.read(cx).text_anchor_for_position(cursor, cx)?; - cx.notify(); - return; - } + let completion = provider.suggest(&buffer, cursor_buffer_position, cx)?; + let edits = completion + .edits + .into_iter() + .map(|(range, new_text)| { + ( + multibuffer + .anchor_in_excerpt(excerpt_id, range.start) + .unwrap() + ..multibuffer + .anchor_in_excerpt(excerpt_id, range.end) + .unwrap(), + new_text, + ) + }) + .collect::>(); + if edits.is_empty() { + return None; + } + + let first_edit_start = edits.first().unwrap().0.start; + let edit_start_row = first_edit_start + .to_point(&multibuffer) + .row + .saturating_sub(2); + + let last_edit_end = edits.last().unwrap().0.end; + let edit_end_row = cmp::min( + multibuffer.max_point().row, + last_edit_end.to_point(&multibuffer).row + 2, + ); + + let cursor_row = cursor.to_point(&multibuffer).row; + + 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; + completion = InlineCompletion::Move(first_edit_start); + } else if cursor_row > edit_end_row { + invalidation_row_range = edit_start_row..cursor_row; + completion = InlineCompletion::Move(first_edit_start); + } 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::suggestion( + post_inc(&mut self.next_inlay_id), + range.start, + new_text.as_str(), + ); + 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::( + edits.iter().map(|(range, _)| range.clone()).collect(), + HighlightStyle { + background_color: Some(background_color), + ..Default::default() + }, + cx, + ); } - } - self.discard_inline_completion(false, cx); + invalidation_row_range = edit_start_row..edit_end_row; + completion = InlineCompletion::Edit(edits); + }; + + let invalidation_range = multibuffer + .anchor_before(Point::new(invalidation_row_range.start, 0)) + ..multibuffer.anchor_after(Point::new( + invalidation_row_range.end, + multibuffer.line_len(MultiBufferRow(invalidation_row_range.end)), + )); + + self.active_inline_completion = Some(InlineCompletionState { + inlay_ids, + completion, + invalidation_range, + }); + cx.notify(); + + Some(()) } fn inline_completion_provider(&self) -> Option> { @@ -12617,7 +12721,7 @@ impl Editor { self.active_indent_guides_state.dirty = true; self.refresh_active_diagnostics(cx); self.refresh_code_actions(cx); - if self.has_active_inline_completion(cx) { + if self.has_active_inline_completion() { self.update_visible_inline_completion(cx); } cx.emit(EditorEvent::BufferEdited); @@ -13310,10 +13414,10 @@ impl Editor { } pub fn display_to_pixel_point( - &mut self, + &self, source: DisplayPoint, editor_snapshot: &EditorSnapshot, - cx: &mut ViewContext, + cx: &WindowContext, ) -> Option> { let line_height = self.style()?.text.line_height_in_pixels(cx.rem_size()); let text_layout_details = self.text_layout_details(cx); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 2df6d66b6a4b11..c3156da602ba7f 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -19,10 +19,10 @@ use crate::{ BlockId, ChunkReplacement, CodeActionsMenu, CursorShape, CustomBlockId, DisplayPoint, DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, ExpandExcerpts, FocusedBlock, GutterDimensions, HalfPageDown, - HalfPageUp, HandleInput, HoveredCursor, HoveredHunk, JumpData, LineDown, LineUp, OpenExcerpts, - PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, Selection, SoftWrap, ToPoint, - CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN, - MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, + HalfPageUp, HandleInput, HoveredCursor, HoveredHunk, InlineCompletion, JumpData, LineDown, + LineUp, OpenExcerpts, PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, Selection, + SoftWrap, ToPoint, CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT, + GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, }; use client::ParticipantIndex; use collections::{BTreeMap, HashMap, HashSet}; @@ -31,7 +31,7 @@ use gpui::{ anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg, transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem, ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity, - FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Length, + FontId, GlobalElementId, HighlightStyle, Hitbox, Hsla, InteractiveElement, IntoElement, Length, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, View, ViewContext, @@ -47,7 +47,10 @@ use language::{ ChunkRendererContext, }; use lsp::DiagnosticSeverity; -use multi_buffer::{Anchor, ExcerptId, ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow}; +use multi_buffer::{ + Anchor, AnchorRangeExt, ExcerptId, ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow, + MultiBufferSnapshot, +}; use project::{ project_settings::{GitGutterSetting, ProjectSettings}, ProjectPath, @@ -2720,6 +2723,157 @@ impl EditorElement { true } + #[allow(clippy::too_many_arguments)] + fn layout_inline_completion_popover( + &self, + text_bounds: &Bounds, + editor_snapshot: &EditorSnapshot, + visible_row_range: Range, + scroll_top: f32, + scroll_bottom: f32, + line_layouts: &[LineWithInvisibles], + line_height: Pixels, + scroll_pixel_position: gpui::Point, + editor_width: Pixels, + style: &EditorStyle, + cx: &mut WindowContext, + ) -> Option { + const PADDING_X: Pixels = Pixels(25.); + const PADDING_Y: Pixels = Pixels(2.); + + let active_inline_completion = self.editor.read(cx).active_inline_completion.as_ref()?; + + match &active_inline_completion.completion { + InlineCompletion::Move(target_position) => { + let container_element = div() + .bg(cx.theme().colors().editor_background) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .px_1(); + + let target_display_point = target_position.to_display_point(editor_snapshot); + if target_display_point.row().as_f32() < scroll_top { + let mut element = container_element + .child( + h_flex() + .gap_1() + .child(Icon::new(IconName::Tab)) + .child(Label::new("Jump to Edit")) + .child(Icon::new(IconName::ArrowUp)), + ) + .into_any(); + let size = element.layout_as_root(AvailableSpace::min_size(), cx); + let offset = point((text_bounds.size.width - size.width) / 2., PADDING_Y); + element.prepaint_at(text_bounds.origin + offset, cx); + Some(element) + } else if (target_display_point.row().as_f32() + 1.) > scroll_bottom { + let mut element = container_element + .child( + h_flex() + .gap_1() + .child(Icon::new(IconName::Tab)) + .child(Label::new("Jump to Edit")) + .child(Icon::new(IconName::ArrowDown)), + ) + .into_any(); + let size = element.layout_as_root(AvailableSpace::min_size(), cx); + let offset = point( + (text_bounds.size.width - size.width) / 2., + text_bounds.size.height - size.height - PADDING_Y, + ); + element.prepaint_at(text_bounds.origin + offset, cx); + Some(element) + } else { + let mut element = container_element + .child( + h_flex() + .gap_1() + .child(Icon::new(IconName::Tab)) + .child(Label::new("Jump to Edit")), + ) + .into_any(); + + let target_line_end = DisplayPoint::new( + target_display_point.row(), + editor_snapshot.line_len(target_display_point.row()), + ); + let origin = self.editor.update(cx, |editor, cx| { + editor.display_to_pixel_point(target_line_end, editor_snapshot, cx) + })?; + element.prepaint_as_root( + text_bounds.origin + origin + point(PADDING_X, px(0.)), + AvailableSpace::min_size(), + cx, + ); + Some(element) + } + } + InlineCompletion::Edit(edits) => { + let edit_start = edits + .first() + .unwrap() + .0 + .start + .to_display_point(editor_snapshot); + let edit_end = edits + .last() + .unwrap() + .0 + .end + .to_display_point(editor_snapshot); + + let is_visible = visible_row_range.contains(&edit_start.row()) + || visible_row_range.contains(&edit_end.row()); + if !is_visible { + return None; + } + + if all_edits_insertions_or_deletions(edits, &editor_snapshot.buffer_snapshot) { + return None; + } + + let (text, highlights) = + inline_completion_popover_text(edit_start, editor_snapshot, edits, cx); + + let longest_row = + editor_snapshot.longest_row_in_range(edit_start.row()..edit_end.row() + 1); + let longest_line_width = if visible_row_range.contains(&longest_row) { + line_layouts[(longest_row.0 - visible_row_range.start.0) as usize].width + } else { + layout_line( + longest_row, + editor_snapshot, + style, + editor_width, + |_| false, + cx, + ) + .width + }; + + let text = gpui::StyledText::new(text).with_highlights(&style.text, highlights); + + let mut element = div() + .bg(cx.theme().colors().editor_background) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .px_1() + .child(text) + .into_any(); + + let origin = text_bounds.origin + + point( + longest_line_width + PADDING_X - scroll_pixel_position.x, + edit_start.row().as_f32() * line_height - scroll_pixel_position.y, + ); + element.prepaint_as_root(origin, AvailableSpace::min_size(), cx); + Some(element) + } + } + } + fn layout_mouse_context_menu( &self, editor_snapshot: &EditorSnapshot, @@ -3942,6 +4096,16 @@ impl EditorElement { } } + fn paint_inline_completion_popover( + &mut self, + layout: &mut EditorLayout, + cx: &mut WindowContext, + ) { + if let Some(inline_completion_popover) = layout.inline_completion_popover.as_mut() { + inline_completion_popover.paint(cx); + } + } + fn paint_mouse_context_menu(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { if let Some(mouse_context_menu) = layout.mouse_context_menu.as_mut() { mouse_context_menu.paint(cx); @@ -4134,6 +4298,67 @@ impl EditorElement { } } +fn inline_completion_popover_text( + edit_start: DisplayPoint, + editor_snapshot: &EditorSnapshot, + edits: &Vec<(Range, String)>, + cx: &WindowContext, +) -> (String, Vec<(Range, HighlightStyle)>) { + let mut text = String::new(); + let mut offset = DisplayPoint::new(edit_start.row(), 0).to_offset(editor_snapshot, Bias::Left); + let mut highlights = Vec::new(); + for (old_range, new_text) in edits { + let old_offset_range = old_range.to_offset(&editor_snapshot.buffer_snapshot); + text.extend( + editor_snapshot + .buffer_snapshot + .chunks(offset..old_offset_range.start, false) + .map(|chunk| chunk.text), + ); + offset = old_offset_range.end; + + let start = text.len(); + text.push_str(new_text); + let end = text.len(); + highlights.push(( + start..end, + HighlightStyle { + background_color: Some(cx.theme().status().created_background), + ..Default::default() + }, + )); + } + (text, highlights) +} + +fn all_edits_insertions_or_deletions( + edits: &Vec<(Range, String)>, + snapshot: &MultiBufferSnapshot, +) -> bool { + let mut all_insertions = true; + let mut all_deletions = true; + + for (range, new_text) in edits.iter() { + let range_is_empty = range.to_offset(&snapshot).is_empty(); + let text_is_empty = new_text.is_empty(); + + if range_is_empty != text_is_empty { + if range_is_empty { + all_deletions = false; + } else { + all_insertions = false; + } + } else { + return false; + } + + if !all_insertions && !all_deletions { + return false; + } + } + all_insertions || all_deletions +} + #[allow(clippy::too_many_arguments)] fn prepaint_gutter_button( button: IconButton, @@ -5566,6 +5791,20 @@ impl Element for EditorElement { ); } + let inline_completion_popover = self.layout_inline_completion_popover( + &text_hitbox.bounds, + &snapshot, + start_row..end_row, + scroll_position.y, + scroll_position.y + height_in_lines, + &line_layouts, + line_height, + scroll_pixel_position, + editor_width, + &style, + cx, + ); + let mouse_context_menu = self.layout_mouse_context_menu( &snapshot, start_row..end_row, @@ -5652,6 +5891,7 @@ impl Element for EditorElement { cursors, visible_cursors, selections, + inline_completion_popover, mouse_context_menu, test_indicators, code_actions_indicator, @@ -5741,6 +5981,7 @@ impl Element for EditorElement { } self.paint_scrollbar(layout, cx); + self.paint_inline_completion_popover(layout, cx); self.paint_mouse_context_menu(layout, cx); }); }) @@ -5796,6 +6037,7 @@ pub struct EditorLayout { test_indicators: Vec, crease_toggles: Vec>, crease_trailers: Vec>, + inline_completion_popover: Option, mouse_context_menu: Option, tab_invisible: ShapedLine, space_invisible: ShapedLine, @@ -6837,6 +7079,169 @@ mod tests { } } + #[gpui::test] + fn test_inline_completion_popover_text(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // Test case 1: Simple insertion + { + let window = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple("Hello, world!", cx); + Editor::new(EditorMode::Full, buffer, None, true, cx) + }); + let cx = &mut VisualTestContext::from_window(*window, cx); + + window + .update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + let edit_range = snapshot.buffer_snapshot.anchor_after(Point::new(0, 6)) + ..snapshot.buffer_snapshot.anchor_before(Point::new(0, 6)); + let edit_start = DisplayPoint::new(DisplayRow(0), 6); + let edits = vec![(edit_range, " beautiful".to_string())]; + + let (text, highlights) = + inline_completion_popover_text(edit_start, &snapshot, &edits, cx); + + assert_eq!(text, "Hello, beautiful"); + assert_eq!(highlights.len(), 1); + assert_eq!(highlights[0].0, 6..16); + assert_eq!( + highlights[0].1.background_color, + Some(cx.theme().status().created_background) + ); + }) + .unwrap(); + } + + // Test case 2: Replacement + { + let window = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple("This is a test.", cx); + Editor::new(EditorMode::Full, buffer, None, true, cx) + }); + let cx = &mut VisualTestContext::from_window(*window, cx); + + window + .update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + let edit_start = DisplayPoint::new(DisplayRow(0), 0); + let edits = vec![( + snapshot.buffer_snapshot.anchor_after(Point::new(0, 0)) + ..snapshot.buffer_snapshot.anchor_before(Point::new(0, 4)), + "That".to_string(), + )]; + + let (text, highlights) = + inline_completion_popover_text(edit_start, &snapshot, &edits, cx); + + assert_eq!(text, "That"); + assert_eq!(highlights.len(), 1); + assert_eq!(highlights[0].0, 0..4); + assert_eq!( + highlights[0].1.background_color, + Some(cx.theme().status().created_background) + ); + }) + .unwrap(); + } + + // Test case 3: Multiple edits + { + let window = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple("Hello, world!", cx); + Editor::new(EditorMode::Full, buffer, None, true, cx) + }); + let cx = &mut VisualTestContext::from_window(*window, cx); + + window + .update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + let edit_start = DisplayPoint::new(DisplayRow(0), 0); + let edits = vec![ + ( + snapshot.buffer_snapshot.anchor_after(Point::new(0, 0)) + ..snapshot.buffer_snapshot.anchor_before(Point::new(0, 5)), + "Greetings".into(), + ), + ( + snapshot.buffer_snapshot.anchor_after(Point::new(0, 12)) + ..snapshot.buffer_snapshot.anchor_before(Point::new(0, 13)), + " and universe".into(), + ), + ]; + + let (text, highlights) = + inline_completion_popover_text(edit_start, &snapshot, &edits, cx); + + assert_eq!(text, "Greetings, world and universe"); + assert_eq!(highlights.len(), 2); + assert_eq!(highlights[0].0, 0..9); + assert_eq!(highlights[1].0, 16..29); + assert_eq!( + highlights[0].1.background_color, + Some(cx.theme().status().created_background) + ); + assert_eq!( + highlights[1].1.background_color, + Some(cx.theme().status().created_background) + ); + }) + .unwrap(); + } + + // Test case 4: Multiple lines with edits + { + let window = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple( + "First line\nSecond line\nThird line\nFourth line", + cx, + ); + Editor::new(EditorMode::Full, buffer, None, true, cx) + }); + let cx = &mut VisualTestContext::from_window(*window, cx); + + window + .update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + let edit_start = DisplayPoint::new(DisplayRow(1), 0); + let edits = vec![ + ( + snapshot.buffer_snapshot.anchor_before(Point::new(1, 7)) + ..snapshot.buffer_snapshot.anchor_before(Point::new(1, 11)), + "modified".to_string(), + ), + ( + snapshot.buffer_snapshot.anchor_before(Point::new(2, 0)) + ..snapshot.buffer_snapshot.anchor_before(Point::new(2, 10)), + "New third line".to_string(), + ), + ( + snapshot.buffer_snapshot.anchor_before(Point::new(3, 6)) + ..snapshot.buffer_snapshot.anchor_before(Point::new(3, 6)), + " updated".to_string(), + ), + ]; + + let (text, highlights) = + inline_completion_popover_text(edit_start, &snapshot, &edits, cx); + + assert_eq!(text, "Second modified\nNew third line\nFourth updated"); + assert_eq!(highlights.len(), 3); + assert_eq!(highlights[0].0, 7..15); // "modified" + assert_eq!(highlights[1].0, 16..30); // "New third line" + assert_eq!(highlights[2].0, 37..45); // " updated" + + for highlight in &highlights { + assert_eq!( + highlight.1.background_color, + Some(cx.theme().status().created_background) + ); + } + }) + .unwrap(); + } + } + fn collect_invisibles_from_new_editor( cx: &mut TestAppContext, editor_mode: EditorMode, diff --git a/crates/editor/src/inline_completion_tests.rs b/crates/editor/src/inline_completion_tests.rs new file mode 100644 index 00000000000000..b136f8ab1a5902 --- /dev/null +++ b/crates/editor/src/inline_completion_tests.rs @@ -0,0 +1,360 @@ +use gpui::Model; +use indoc::indoc; +use inline_completion::InlineCompletionProvider; +use multi_buffer::{Anchor, MultiBufferSnapshot, ToPoint}; +use std::ops::Range; +use text::{Point, ToOffset}; +use ui::Context; + +use crate::{ + editor_tests::init_test, test::editor_test_context::EditorTestContext, InlineCompletion, +}; + +#[gpui::test] +async fn test_inline_completion_insert(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new_model(|_| FakeInlineCompletionProvider::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); + cx.set_state("let absolute_zero_celsius = ˇ;"); + + propose_edits(&provider, vec![(28..28, "-273.15")], &mut cx); + cx.update_editor(|editor, cx| editor.update_visible_inline_completion(cx)); + + assert_editor_active_edit_completion(&mut cx, |_, edits| { + assert_eq!(edits.len(), 1); + assert_eq!(edits[0].1.as_str(), "-273.15"); + }); + + accept_completion(&mut cx); + + cx.assert_editor_state("let absolute_zero_celsius = -273.15ˇ;") +} + +#[gpui::test] +async fn test_inline_completion_modification(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new_model(|_| FakeInlineCompletionProvider::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); + cx.set_state("let pi = ˇ\"foo\";"); + + propose_edits(&provider, vec![(9..14, "3.14159")], &mut cx); + cx.update_editor(|editor, cx| editor.update_visible_inline_completion(cx)); + + assert_editor_active_edit_completion(&mut cx, |_, edits| { + assert_eq!(edits.len(), 1); + assert_eq!(edits[0].1.as_str(), "3.14159"); + }); + + accept_completion(&mut cx); + + cx.assert_editor_state("let pi = 3.14159ˇ;") +} + +#[gpui::test] +async fn test_inline_completion_jump_button(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new_model(|_| FakeInlineCompletionProvider::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); + + // Cursor is 2+ lines above the proposed edit + cx.set_state(indoc! {" + line 0 + line ˇ1 + line 2 + line 3 + line + "}); + + propose_edits( + &provider, + vec![(Point::new(4, 3)..Point::new(4, 3), " 4")], + &mut cx, + ); + + cx.update_editor(|editor, cx| editor.update_visible_inline_completion(cx)); + assert_editor_active_move_completion(&mut cx, |snapshot, move_target| { + assert_eq!(move_target.to_point(&snapshot), Point::new(4, 3)); + }); + + // When accepting, cursor is moved to the proposed location + accept_completion(&mut cx); + cx.assert_editor_state(indoc! {" + line 0 + line 1 + line 2 + line 3 + linˇe + "}); + + // Cursor is 2+ lines below the proposed edit + cx.set_state(indoc! {" + line 0 + line + line 2 + line 3 + line ˇ4 + "}); + + propose_edits( + &provider, + vec![(Point::new(1, 3)..Point::new(1, 3), " 1")], + &mut cx, + ); + + cx.update_editor(|editor, cx| editor.update_visible_inline_completion(cx)); + assert_editor_active_move_completion(&mut cx, |snapshot, move_target| { + assert_eq!(move_target.to_point(&snapshot), Point::new(1, 3)); + }); + + // When accepting, cursor is moved to the proposed location + accept_completion(&mut cx); + cx.assert_editor_state(indoc! {" + line 0 + linˇe + line 2 + line 3 + line 4 + "}); +} + +#[gpui::test] +async fn test_inline_completion_invalidation_range(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let provider = cx.new_model(|_| FakeInlineCompletionProvider::default()); + assign_editor_completion_provider(provider.clone(), &mut cx); + + // Cursor is 3+ lines above the proposed edit + cx.set_state(indoc! {" + line 0 + line ˇ1 + line 2 + line 3 + line 4 + line + "}); + let edit_location = Point::new(5, 3); + + propose_edits( + &provider, + vec![(edit_location..edit_location, " 5")], + &mut cx, + ); + + cx.update_editor(|editor, cx| editor.update_visible_inline_completion(cx)); + assert_editor_active_move_completion(&mut cx, |snapshot, move_target| { + assert_eq!(move_target.to_point(&snapshot), edit_location); + }); + + // If we move *towards* the completion, it stays active + cx.set_selections_state(indoc! {" + line 0 + line 1 + line ˇ2 + line 3 + line 4 + line + "}); + assert_editor_active_move_completion(&mut cx, |snapshot, move_target| { + assert_eq!(move_target.to_point(&snapshot), edit_location); + }); + + // If we move *away* from the completion, it is discarded + cx.set_selections_state(indoc! {" + line ˇ0 + line 1 + line 2 + line 3 + line 4 + line + "}); + cx.editor(|editor, _| { + assert!(editor.active_inline_completion.is_none()); + }); + + // Cursor is 3+ lines below the proposed edit + cx.set_state(indoc! {" + line + line 1 + line 2 + line 3 + line ˇ4 + line 5 + "}); + let edit_location = Point::new(0, 3); + + propose_edits( + &provider, + vec![(edit_location..edit_location, " 0")], + &mut cx, + ); + + cx.update_editor(|editor, cx| editor.update_visible_inline_completion(cx)); + assert_editor_active_move_completion(&mut cx, |snapshot, move_target| { + assert_eq!(move_target.to_point(&snapshot), edit_location); + }); + + // If we move *towards* the completion, it stays active + cx.set_selections_state(indoc! {" + line + line 1 + line 2 + line ˇ3 + line 4 + line 5 + "}); + assert_editor_active_move_completion(&mut cx, |snapshot, move_target| { + assert_eq!(move_target.to_point(&snapshot), edit_location); + }); + + // If we move *away* from the completion, it is discarded + cx.set_selections_state(indoc! {" + line + line 1 + line 2 + line 3 + line 4 + line ˇ5 + "}); + cx.editor(|editor, _| { + assert!(editor.active_inline_completion.is_none()); + }); +} + +fn assert_editor_active_edit_completion( + cx: &mut EditorTestContext, + assert: impl FnOnce(MultiBufferSnapshot, &Vec<(Range, String)>), +) { + cx.editor(|editor, cx| { + let completion_state = editor + .active_inline_completion + .as_ref() + .expect("editor has no active completion"); + + if let InlineCompletion::Edit(edits) = &completion_state.completion { + assert(editor.buffer().read(cx).snapshot(cx), edits); + } else { + panic!("expected edit completion"); + } + }) +} + +fn assert_editor_active_move_completion( + cx: &mut EditorTestContext, + assert: impl FnOnce(MultiBufferSnapshot, Anchor), +) { + cx.editor(|editor, cx| { + let completion_state = editor + .active_inline_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); + } else { + panic!("expected move completion"); + } + }) +} + +fn accept_completion(cx: &mut EditorTestContext) { + cx.update_editor(|editor, cx| { + editor.accept_inline_completion(&crate::AcceptInlineCompletion, cx) + }) +} + +fn propose_edits( + provider: &Model, + edits: Vec<(Range, &str)>, + cx: &mut EditorTestContext, +) { + let snapshot = cx.buffer_snapshot(); + let edits = edits.into_iter().map(|(range, text)| { + let range = snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end); + (range, text.into()) + }); + + cx.update(|cx| { + provider.update(cx, |provider, _| { + provider.set_inline_completion(Some(inline_completion::InlineCompletion { + edits: edits.collect(), + })) + }) + }); +} + +fn assign_editor_completion_provider( + provider: Model, + cx: &mut EditorTestContext, +) { + cx.update_editor(|editor, cx| { + editor.set_inline_completion_provider(Some(provider), cx); + }) +} + +#[derive(Default, Clone)] +struct FakeInlineCompletionProvider { + completion: Option, +} + +impl FakeInlineCompletionProvider { + pub fn set_inline_completion( + &mut self, + completion: Option, + ) { + self.completion = completion; + } +} + +impl InlineCompletionProvider for FakeInlineCompletionProvider { + fn name() -> &'static str { + "fake-completion-provider" + } + + fn is_enabled( + &self, + _buffer: &gpui::Model, + _cursor_position: language::Anchor, + _cx: &gpui::AppContext, + ) -> bool { + true + } + + fn refresh( + &mut self, + _buffer: gpui::Model, + _cursor_position: language::Anchor, + _debounce: bool, + _cx: &mut gpui::ModelContext, + ) { + } + + fn cycle( + &mut self, + _buffer: gpui::Model, + _cursor_position: language::Anchor, + _direction: inline_completion::Direction, + _cx: &mut gpui::ModelContext, + ) { + } + + fn accept(&mut self, _cx: &mut gpui::ModelContext) {} + + fn discard(&mut self, _cx: &mut gpui::ModelContext) {} + + fn suggest<'a>( + &mut self, + _buffer: &gpui::Model, + _cursor_position: language::Anchor, + _cx: &mut gpui::ModelContext, + ) -> Option { + self.completion.clone() + } +} diff --git a/crates/inline_completion/Cargo.toml b/crates/inline_completion/Cargo.toml index 237b0ff43ffb49..cdcf71c230559a 100644 --- a/crates/inline_completion/Cargo.toml +++ b/crates/inline_completion/Cargo.toml @@ -14,5 +14,3 @@ path = "src/inline_completion.rs" [dependencies] gpui.workspace = true language.workspace = true -project.workspace = true -text.workspace = true diff --git a/crates/inline_completion/src/inline_completion.rs b/crates/inline_completion/src/inline_completion.rs index 689bc0317445f6..fba19ca216c1d7 100644 --- a/crates/inline_completion/src/inline_completion.rs +++ b/crates/inline_completion/src/inline_completion.rs @@ -1,7 +1,6 @@ use gpui::{AppContext, Model, ModelContext}; use language::Buffer; use std::ops::Range; -use text::{Anchor, Rope}; // TODO: Find a better home for `Direction`. // @@ -13,15 +12,9 @@ pub enum Direction { Next, } -pub enum InlayProposal { - Hint(Anchor, project::InlayHint), - Suggestion(Anchor, Rope), -} - -pub struct CompletionProposal { - pub inlays: Vec, - pub text: Rope, - pub delete_range: Option>, +#[derive(Clone)] +pub struct InlineCompletion { + pub edits: Vec<(Range, String)>, } pub trait InlineCompletionProvider: 'static + Sized { @@ -47,16 +40,17 @@ pub trait InlineCompletionProvider: 'static + Sized { cx: &mut ModelContext, ); fn accept(&mut self, cx: &mut ModelContext); - fn discard(&mut self, should_report_inline_completion_event: bool, cx: &mut ModelContext); - fn active_completion_text<'a>( - &'a self, + fn discard(&mut self, cx: &mut ModelContext); + fn suggest( + &mut self, buffer: &Model, cursor_position: language::Anchor, - cx: &'a AppContext, - ) -> Option; + cx: &mut ModelContext, + ) -> Option; } pub trait InlineCompletionProviderHandle { + fn name(&self) -> &'static str; fn is_enabled( &self, buffer: &Model, @@ -78,19 +72,23 @@ pub trait InlineCompletionProviderHandle { cx: &mut AppContext, ); fn accept(&self, cx: &mut AppContext); - fn discard(&self, should_report_inline_completion_event: bool, cx: &mut AppContext); - fn active_completion_text<'a>( - &'a self, + fn discard(&self, cx: &mut AppContext); + fn suggest( + &self, buffer: &Model, cursor_position: language::Anchor, - cx: &'a AppContext, - ) -> Option; + cx: &mut AppContext, + ) -> Option; } impl InlineCompletionProviderHandle for Model where T: InlineCompletionProvider, { + fn name(&self) -> &'static str { + T::name() + } + fn is_enabled( &self, buffer: &Model, @@ -128,19 +126,16 @@ where self.update(cx, |this, cx| this.accept(cx)) } - fn discard(&self, should_report_inline_completion_event: bool, cx: &mut AppContext) { - self.update(cx, |this, cx| { - this.discard(should_report_inline_completion_event, cx) - }) + fn discard(&self, cx: &mut AppContext) { + self.update(cx, |this, cx| this.discard(cx)) } - fn active_completion_text<'a>( - &'a self, + fn suggest( + &self, buffer: &Model, cursor_position: language::Anchor, - cx: &'a AppContext, - ) -> Option { - self.read(cx) - .active_completion_text(buffer, cursor_position, cx) + cx: &mut AppContext, + ) -> Option { + self.update(cx, |this, cx| this.suggest(buffer, cursor_position, cx)) } } diff --git a/crates/inline_completion_button/Cargo.toml b/crates/inline_completion_button/Cargo.toml index 427d0dafd8e284..2029ab4da222ba 100644 --- a/crates/inline_completion_button/Cargo.toml +++ b/crates/inline_completion_button/Cargo.toml @@ -16,6 +16,7 @@ doctest = false anyhow.workspace = true copilot.workspace = true editor.workspace = true +feature_flags.workspace = true fs.workspace = true gpui.workspace = true language.workspace = true @@ -25,6 +26,7 @@ supermaven.workspace = true ui.workspace = true workspace.workspace = true zed_actions.workspace = true +zeta.workspace = true [dev-dependencies] copilot = { workspace = true, features = ["test-support"] } diff --git a/crates/inline_completion_button/src/inline_completion_button.rs b/crates/inline_completion_button/src/inline_completion_button.rs index 5470678d385972..a18c250875b6ac 100644 --- a/crates/inline_completion_button/src/inline_completion_button.rs +++ b/crates/inline_completion_button/src/inline_completion_button.rs @@ -1,6 +1,7 @@ use anyhow::Result; use copilot::{Copilot, Status}; use editor::{scroll::Autoscroll, Editor}; +use feature_flags::FeatureFlagAppExt; use fs::Fs; use gpui::{ div, Action, AnchorCorner, AppContext, AsyncWindowContext, Entity, IntoElement, ParentElement, @@ -15,6 +16,7 @@ use language::{ use settings::{update_settings_file, Settings, SettingsStore}; use std::{path::Path, sync::Arc}; use supermaven::{AccountStatus, Supermaven}; +use ui::{Button, LabelSize}; use workspace::{ create_and_open_local_file, item::ItemHandle, @@ -25,6 +27,7 @@ use workspace::{ StatusItemView, Toast, Workspace, }; use zed_actions::OpenBrowser; +use zeta::RateCompletionModal; const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot"; @@ -36,6 +39,7 @@ pub struct InlineCompletionButton { language: Option>, file: Option>, fs: Arc, + workspace: WeakView, } enum SupermavenButtonStatus { @@ -193,12 +197,35 @@ impl Render for InlineCompletionButton { ), ); } + + InlineCompletionProvider::Zeta => { + if !cx.is_staff() { + return div(); + } + + div().child( + Button::new("zeta", "Zeta") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, cx| { + if let Some(workspace) = this.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + RateCompletionModal::toggle(workspace, cx) + }); + } + })) + .tooltip(|cx| Tooltip::text("Rate Completions", cx)), + ) + } } } } impl InlineCompletionButton { - pub fn new(fs: Arc, cx: &mut ViewContext) -> Self { + pub fn new( + workspace: WeakView, + fs: Arc, + cx: &mut ViewContext, + ) -> Self { if let Some(copilot) = Copilot::global(cx) { cx.observe(&copilot, |_, _, cx| cx.notify()).detach() } @@ -211,6 +238,7 @@ impl InlineCompletionButton { editor_enabled: None, language: None, file: None, + workspace, fs, } } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 833a71c899c734..b3a953084759bb 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -563,7 +563,7 @@ impl<'a, 'b> DerefMut for ChunkRendererContext<'a, 'b> { pub struct Diff { pub(crate) base_version: clock::Global, line_ending: LineEnding, - edits: Vec<(Range, Arc)>, + pub edits: Vec<(Range, Arc)>, } #[derive(Clone, Copy)] diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index a3ac40b7143c03..5f3227cea8b8a4 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -197,6 +197,7 @@ pub enum InlineCompletionProvider { #[default] Copilot, Supermaven, + Zeta, } /// The settings for inline completions, such as [GitHub Copilot](https://github.com/features/copilot) diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 028ea0cfa4e784..6d618d1ec5711d 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -10,7 +10,9 @@ pub mod provider; mod settings; use crate::provider::anthropic::AnthropicLanguageModelProvider; -use crate::provider::cloud::{CloudLanguageModelProvider, RefreshLlmTokenListener}; +use crate::provider::cloud::CloudLanguageModelProvider; +pub use crate::provider::cloud::LlmApiToken; +pub use crate::provider::cloud::RefreshLlmTokenListener; use crate::provider::copilot_chat::CopilotChatLanguageModelProvider; use crate::provider::google::GoogleLanguageModelProvider; use crate::provider::ollama::OllamaLanguageModelProvider; diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index f54e8c8d19b40b..6d76b733b714c9 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -444,7 +444,7 @@ pub struct CloudLanguageModel { } #[derive(Clone, Default)] -struct LlmApiToken(Arc>>); +pub struct LlmApiToken(Arc>>); #[derive(Error, Debug)] pub struct PaymentRequiredError; @@ -814,7 +814,7 @@ fn response_lines( } impl LlmApiToken { - async fn acquire(&self, client: &Arc) -> Result { + pub async fn acquire(&self, client: &Arc) -> Result { let lock = self.0.upgradable_read().await; if let Some(token) = lock.as_ref() { Ok(token.to_string()) @@ -823,7 +823,7 @@ impl LlmApiToken { } } - async fn refresh(&self, client: &Arc) -> Result { + pub async fn refresh(&self, client: &Arc) -> Result { Self::fetch(self.0.write().await, client).await } diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index dfafff20898d35..1e928412499e49 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -169,6 +169,24 @@ pub struct Request { pub tools: Vec, } +#[derive(Debug, Serialize, Deserialize)] +pub struct CompletionRequest { + pub model: String, + pub prompt: String, + pub max_tokens: u32, + pub temperature: f32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prediction: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rewrite_speculation: Option, +} + +#[derive(Clone, Deserialize, Serialize, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Prediction { + Content { content: String }, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum ToolChoice { @@ -285,6 +303,21 @@ pub struct ResponseStreamEvent { pub usage: Option, } +#[derive(Serialize, Deserialize, Debug)] +pub struct CompletionResponse { + pub id: String, + pub object: String, + pub created: u64, + pub model: String, + pub choices: Vec, + pub usage: Usage, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct CompletionChoice { + pub text: String, +} + #[derive(Serialize, Deserialize, Debug)] pub struct Response { pub id: String, @@ -355,6 +388,56 @@ pub async fn complete( } } +pub async fn complete_text( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, + request: CompletionRequest, +) -> Result { + let uri = format!("{api_url}/completions"); + let request_builder = HttpRequest::builder() + .method(Method::POST) + .uri(uri) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_key)); + + let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?; + let mut response = client.send(request).await?; + + if response.status().is_success() { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + let response = serde_json::from_str(&body)?; + Ok(response) + } else { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + #[derive(Deserialize)] + struct OpenAiResponse { + error: OpenAiError, + } + + #[derive(Deserialize)] + struct OpenAiError { + message: String, + } + + match serde_json::from_str::(&body) { + Ok(response) if !response.error.message.is_empty() => Err(anyhow!( + "Failed to connect to OpenAI API: {}", + response.error.message, + )), + + _ => Err(anyhow!( + "Failed to connect to OpenAI API: {} {}", + response.status(), + body, + )), + } + } +} + fn adapt_response_to_stream(response: Response) -> ResponseStreamEvent { ResponseStreamEvent { created: response.created as u32, diff --git a/crates/rpc/src/llm.rs b/crates/rpc/src/llm.rs index 0a7510d891d352..975114350a4682 100644 --- a/crates/rpc/src/llm.rs +++ b/crates/rpc/src/llm.rs @@ -33,3 +33,14 @@ pub struct PerformCompletionParams { pub model: String, pub provider_request: Box, } + +#[derive(Debug, Serialize, Deserialize)] +pub struct PredictEditsParams { + pub input_events: String, + pub input_excerpt: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PredictEditsResponse { + pub output_excerpt: String, +} diff --git a/crates/supermaven/src/supermaven_completion_provider.rs b/crates/supermaven/src/supermaven_completion_provider.rs index 5e77cc21ef8e28..a943054d8308fe 100644 --- a/crates/supermaven/src/supermaven_completion_provider.rs +++ b/crates/supermaven/src/supermaven_completion_provider.rs @@ -1,14 +1,12 @@ use crate::{Supermaven, SupermavenCompletionStateId}; use anyhow::Result; -use client::telemetry::Telemetry; use futures::StreamExt as _; use gpui::{AppContext, EntityId, Model, ModelContext, Task}; -use inline_completion::{CompletionProposal, Direction, InlayProposal, InlineCompletionProvider}; +use inline_completion::{Direction, InlineCompletion, InlineCompletionProvider}; use language::{language_settings::all_language_settings, Anchor, Buffer, BufferSnapshot}; use std::{ ops::{AddAssign, Range}, path::Path, - sync::Arc, time::Duration, }; use text::{ToOffset, ToPoint}; @@ -22,7 +20,6 @@ pub struct SupermavenCompletionProvider { completion_id: Option, file_extension: Option, pending_refresh: Task>, - telemetry: Option>, } impl SupermavenCompletionProvider { @@ -33,31 +30,25 @@ impl SupermavenCompletionProvider { completion_id: None, file_extension: None, pending_refresh: Task::ready(Ok(())), - telemetry: None, } } - - pub fn with_telemetry(mut self, telemetry: Arc) -> Self { - self.telemetry = Some(telemetry); - self - } } -// Computes the completion state from the difference between the completion text. +// Computes the inline completion from the difference between the completion text. // this is defined by greedily matching the buffer text against the completion text, with any leftover buffer placed at the end. // for example, given the completion text "moo cows are cool" and the buffer text "cowsre pool", the completion state would be // the inlays "moo ", " a", and "cool" which will render as "[moo ]cows[ a]re [cool]pool" in the editor. -fn completion_state_from_diff( +fn completion_from_diff( snapshot: BufferSnapshot, completion_text: &str, position: Anchor, delete_range: Range, -) -> CompletionProposal { +) -> InlineCompletion { let buffer_text = snapshot .text_for_range(delete_range.clone()) .collect::(); - let mut inlays: Vec = Vec::new(); + let mut edits: Vec<(Range, String)> = Vec::new(); let completion_graphemes: Vec<&str> = completion_text.graphemes(true).collect(); let buffer_graphemes: Vec<&str> = buffer_text.graphemes(true).collect(); @@ -74,11 +65,10 @@ fn completion_state_from_diff( match k { Some(k) => { if k != 0 { + let offset = snapshot.anchor_after(offset); // the range from the current position to item is an inlay. - inlays.push(InlayProposal::Suggestion( - snapshot.anchor_after(offset), - completion_graphemes[i..i + k].join("").into(), - )); + let edit = (offset..offset, completion_graphemes[i..i + k].join("")); + edits.push(edit); } i += k + 1; j += 1; @@ -93,18 +83,14 @@ fn completion_state_from_diff( } if j == buffer_graphemes.len() && i < completion_graphemes.len() { + let offset = snapshot.anchor_after(offset); // there is leftover completion text, so drop it as an inlay. - inlays.push(InlayProposal::Suggestion( - snapshot.anchor_after(offset), - completion_graphemes[i..].join("").into(), - )); + let edit_range = offset..offset; + let edit_text = completion_graphemes[i..].join(""); + edits.push((edit_range, edit_text)); } - CompletionProposal { - inlays, - text: completion_text.into(), - delete_range: Some(delete_range), - } + InlineCompletion { edits } } impl InlineCompletionProvider for SupermavenCompletionProvider { @@ -171,44 +157,21 @@ impl InlineCompletionProvider for SupermavenCompletionProvider { } fn accept(&mut self, _cx: &mut ModelContext) { - if self.completion_id.is_some() { - if let Some(telemetry) = self.telemetry.as_ref() { - telemetry.report_inline_completion_event( - Self::name().to_string(), - true, - self.file_extension.clone(), - ); - } - } self.pending_refresh = Task::ready(Ok(())); self.completion_id = None; } - fn discard( - &mut self, - should_report_inline_completion_event: bool, - _cx: &mut ModelContext, - ) { - if should_report_inline_completion_event && self.completion_id.is_some() { - if let Some(telemetry) = self.telemetry.as_ref() { - telemetry.report_inline_completion_event( - Self::name().to_string(), - false, - self.file_extension.clone(), - ); - } - } - + fn discard(&mut self, _cx: &mut ModelContext) { self.pending_refresh = Task::ready(Ok(())); self.completion_id = None; } - fn active_completion_text<'a>( - &'a self, + fn suggest( + &mut self, buffer: &Model, cursor_position: Anchor, - cx: &'a AppContext, - ) -> Option { + cx: &mut ModelContext, + ) -> Option { let completion_text = self .supermaven .read(cx) @@ -223,7 +186,7 @@ impl InlineCompletionProvider for SupermavenCompletionProvider { let mut point = cursor_position.to_point(&snapshot); point.column = snapshot.line_len(point.row); let range = cursor_position..snapshot.anchor_after(point); - Some(completion_state_from_diff( + Some(completion_from_diff( snapshot, completion_text, cursor_position, diff --git a/crates/telemetry_events/src/telemetry_events.rs b/crates/telemetry_events/src/telemetry_events.rs index 0c4ee8cb9e48f3..0002a169d47288 100644 --- a/crates/telemetry_events/src/telemetry_events.rs +++ b/crates/telemetry_events/src/telemetry_events.rs @@ -93,6 +93,7 @@ impl Display for AssistantPhase { pub enum Event { Editor(EditorEvent), InlineCompletion(InlineCompletionEvent), + InlineCompletionRating(InlineCompletionRatingEvent), Call(CallEvent), Assistant(AssistantEvent), Cpu(CpuEvent), @@ -130,6 +131,21 @@ pub struct InlineCompletionEvent { pub file_extension: Option, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum InlineCompletionRating { + Positive, + Negative, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct InlineCompletionRatingEvent { + pub rating: InlineCompletionRating, + pub input_events: Arc, + pub input_excerpt: Arc, + pub output_excerpt: Arc, + pub feedback: String, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CallEvent { /// Operation performed: invite/join call; begin/end screenshare; share/unshare project; etc diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 59b5d3cb3d549a..ffa0ec8b96ddac 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -77,6 +77,7 @@ impl Vim { }); vim.copy_selections_content(editor, motion.linewise(), cx); editor.insert("", cx); + editor.refresh_inline_completion(true, false, cx); }); }); @@ -101,6 +102,7 @@ impl Vim { if objects_found { vim.copy_selections_content(editor, false, cx); editor.insert("", cx); + editor.refresh_inline_completion(true, false, cx); } }); }); diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index fee2ef56e17483..e633db1df0aede 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -72,6 +72,7 @@ impl Vim { selection.collapse_to(cursor, selection.goal) }); }); + editor.refresh_inline_completion(true, false, cx); }); }); } @@ -151,6 +152,7 @@ impl Vim { selection.collapse_to(cursor, selection.goal) }); }); + editor.refresh_inline_completion(true, false, cx); }); }); } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 843b094700ab56..43dbdc6316b361 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1162,6 +1162,15 @@ impl Vim { if self.mode == Mode::Replace { self.multi_replace(text, cx) } + + if self.mode == Mode::Normal { + self.update_editor(cx, |_, editor, cx| { + editor.accept_inline_completion( + &editor::actions::AcceptInlineCompletion {}, + cx, + ); + }); + } } } } @@ -1174,7 +1183,10 @@ impl Vim { editor.set_input_enabled(vim.editor_input_enabled()); editor.set_autoindent(vim.should_autoindent()); editor.selections.line_mode = matches!(vim.mode, Mode::VisualLine); - editor.set_inline_completions_enabled(matches!(vim.mode, Mode::Insert | Mode::Replace)); + editor.set_inline_completions_enabled(matches!( + vim.mode, + Mode::Insert | Mode::Normal | Mode::Replace + )); }); cx.notify() } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 2220cc7be09efb..1fbdcbab6f335c 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -126,6 +126,7 @@ vim_mode_setting.workspace = true welcome.workspace = true workspace.workspace = true zed_actions.workspace = true +zeta.workspace = true [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index c5980543564637..3e2ec18f1ff407 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -399,7 +399,7 @@ fn main() { cx, ); snippet_provider::init(cx); - inline_completion_registry::init(app_state.client.telemetry().clone(), cx); + inline_completion_registry::init(app_state.client.clone(), cx); let prompt_builder = assistant::init( app_state.fs.clone(), app_state.client.clone(), diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index a52c8ec405d067..5829726ada5a41 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -197,7 +197,7 @@ pub fn initialize_workspace( } let inline_completion_button = cx.new_view(|cx| { - inline_completion_button::InlineCompletionButton::new(app_state.fs.clone(), cx) + inline_completion_button::InlineCompletionButton::new(workspace.weak_handle(), app_state.fs.clone(), cx) }); let diagnostic_summary = diff --git a/crates/zed/src/zed/inline_completion_registry.rs b/crates/zed/src/zed/inline_completion_registry.rs index aa0707d851f94c..2b9e300273d51e 100644 --- a/crates/zed/src/zed/inline_completion_registry.rs +++ b/crates/zed/src/zed/inline_completion_registry.rs @@ -1,19 +1,20 @@ use std::{cell::RefCell, rc::Rc, sync::Arc}; -use client::telemetry::Telemetry; +use client::Client; use collections::HashMap; use copilot::{Copilot, CopilotCompletionProvider}; use editor::{Editor, EditorMode}; +use feature_flags::FeatureFlagAppExt; use gpui::{AnyWindowHandle, AppContext, Context, ViewContext, WeakView}; use language::language_settings::all_language_settings; use settings::SettingsStore; use supermaven::{Supermaven, SupermavenCompletionProvider}; -pub fn init(telemetry: Arc, cx: &mut AppContext) { +pub fn init(client: Arc, cx: &mut AppContext) { let editors: Rc, AnyWindowHandle>>> = Rc::default(); cx.observe_new_views({ let editors = editors.clone(); - let telemetry = telemetry.clone(); + let client = client.clone(); move |editor: &mut Editor, cx: &mut ViewContext| { if editor.mode() != EditorMode::Full { return; @@ -34,7 +35,7 @@ pub fn init(telemetry: Arc, cx: &mut AppContext) { .borrow_mut() .insert(editor_handle, cx.window_handle()); let provider = all_language_settings(None, cx).inline_completions.provider; - assign_inline_completion_provider(editor, provider, &telemetry, cx); + assign_inline_completion_provider(editor, provider, &client, cx); } }) .detach(); @@ -43,7 +44,7 @@ pub fn init(telemetry: Arc, cx: &mut AppContext) { for (editor, window) in editors.borrow().iter() { _ = window.update(cx, |_window, cx| { _ = editor.update(cx, |editor, cx| { - assign_inline_completion_provider(editor, provider, &telemetry, cx); + assign_inline_completion_provider(editor, provider, &client, cx); }) }); } @@ -55,7 +56,7 @@ pub fn init(telemetry: Arc, cx: &mut AppContext) { for (editor, window) in editors.borrow().iter() { _ = window.update(cx, |_window, cx| { _ = editor.update(cx, |editor, cx| { - assign_inline_completion_provider(editor, provider, &telemetry, cx); + assign_inline_completion_provider(editor, provider, &client, cx); }) }); } @@ -103,7 +104,7 @@ fn register_backward_compatible_actions(editor: &mut Editor, cx: &ViewContext, + client: &Arc, cx: &mut ViewContext, ) { match provider { @@ -117,17 +118,27 @@ fn assign_inline_completion_provider( }); } } - let provider = cx.new_model(|_| { - CopilotCompletionProvider::new(copilot).with_telemetry(telemetry.clone()) - }); + let provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot)); editor.set_inline_completion_provider(Some(provider), cx); } } language::language_settings::InlineCompletionProvider::Supermaven => { if let Some(supermaven) = Supermaven::global(cx) { - let provider = cx.new_model(|_| { - SupermavenCompletionProvider::new(supermaven).with_telemetry(telemetry.clone()) - }); + let provider = cx.new_model(|_| SupermavenCompletionProvider::new(supermaven)); + editor.set_inline_completion_provider(Some(provider), cx); + } + } + language::language_settings::InlineCompletionProvider::Zeta => { + if cx.is_staff() { + let zeta = zeta::Zeta::register(client.clone(), cx); + if let Some(buffer) = editor.buffer().read(cx).as_singleton() { + if buffer.read(cx).file().is_some() { + zeta.update(cx, |zeta, cx| { + zeta.register_buffer(&buffer, cx); + }); + } + } + let provider = cx.new_model(|_| zeta::ZetaInlineCompletionProvider::new(zeta)); editor.set_inline_completion_provider(Some(provider), cx); } } diff --git a/crates/zeta/Cargo.toml b/crates/zeta/Cargo.toml new file mode 100644 index 00000000000000..0b07703effef01 --- /dev/null +++ b/crates/zeta/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "zeta" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" +exclude = ["fixtures"] + +[lints] +workspace = true + +[lib] +path = "src/zeta.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +client.workspace = true +collections.workspace = true +editor.workspace = true +futures.workspace = true +gpui.workspace = true +http_client.workspace = true +inline_completion.workspace = true +language.workspace = true +language_models.workspace = true +log.workspace = true +menu.workspace = true +rpc.workspace = true +serde_json.workspace = true +settings.workspace = true +similar.workspace = true +telemetry_events.workspace = true +theme.workspace = true +util.workspace = true +ui.workspace = true +uuid.workspace = true +workspace.workspace = true + +[dev-dependencies] +collections = { workspace = true, features = ["test-support"] } +client = { workspace = true, features = ["test-support"] } +clock = { workspace = true, features = ["test-support"] } +ctor.workspace = true +editor = { workspace = true, features = ["test-support"] } +env_logger.workspace = true +gpui = { workspace = true, features = ["test-support"] } +http_client = { workspace = true, features = ["test-support"] } +indoc.workspace = true +language = { workspace = true, features = ["test-support"] } +reqwest_client = { workspace = true, features = ["test-support"] } +rpc = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +theme = { workspace = true, features = ["test-support"] } +tree-sitter-go.workspace = true +tree-sitter-rust.workspace = true +util = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } +worktree = { workspace = true, features = ["test-support"] } +call = { workspace = true, features = ["test-support"] } diff --git a/crates/zeta/LICENSE-GPL b/crates/zeta/LICENSE-GPL new file mode 120000 index 00000000000000..89e542f750cd38 --- /dev/null +++ b/crates/zeta/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/zeta/src/rate_completion_modal.rs b/crates/zeta/src/rate_completion_modal.rs new file mode 100644 index 00000000000000..2d5650ba16e573 --- /dev/null +++ b/crates/zeta/src/rate_completion_modal.rs @@ -0,0 +1,301 @@ +use crate::{InlineCompletion, InlineCompletionRating, Zeta}; +use editor::Editor; +use gpui::{ + prelude::*, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, HighlightStyle, + Model, StyledText, TextStyle, View, ViewContext, +}; +use language::{language_settings, OffsetRangeExt}; +use settings::Settings; +use theme::ThemeSettings; +use ui::{prelude::*, ListItem, ListItemSpacing}; +use workspace::{ModalView, Workspace}; + +pub struct RateCompletionModal { + zeta: Model, + active_completion: Option, + focus_handle: FocusHandle, + _subscription: gpui::Subscription, +} + +struct ActiveCompletion { + completion: InlineCompletion, + feedback_editor: View, +} + +impl RateCompletionModal { + pub fn toggle(workspace: &mut Workspace, cx: &mut ViewContext) { + if let Some(zeta) = Zeta::global(cx) { + workspace.toggle_modal(cx, |cx| RateCompletionModal::new(zeta, cx)); + } + } + + pub fn new(zeta: Model, cx: &mut ViewContext) -> Self { + let subscription = cx.observe(&zeta, |_, _, cx| cx.notify()); + Self { + zeta, + focus_handle: cx.focus_handle(), + active_completion: None, + _subscription: subscription, + } + } + + fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { + cx.emit(DismissEvent); + } + + pub fn select_completion( + &mut self, + completion: Option, + cx: &mut ViewContext, + ) { + // Avoid resetting completion rating if it's already selected. + if let Some(completion) = completion.as_ref() { + if let Some(prev_completion) = self.active_completion.as_ref() { + if completion.id == prev_completion.completion.id { + return; + } + } + } + + self.active_completion = completion.map(|completion| ActiveCompletion { + completion, + feedback_editor: cx.new_view(|cx| { + let mut editor = Editor::multi_line(cx); + editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); + editor.set_show_line_numbers(false, cx); + editor.set_show_git_diff_gutter(false, cx); + editor.set_show_code_actions(false, cx); + editor.set_show_runnables(false, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_show_inline_completions(Some(false), cx); + editor.set_placeholder_text("Your feedback about this completion...", cx); + editor + }), + }); + } + + fn render_active_completion(&mut self, cx: &mut ViewContext) -> Option { + let active_completion = self.active_completion.as_ref()?; + let completion_id = active_completion.completion.id; + + let mut diff = active_completion + .completion + .snapshot + .text_for_range(active_completion.completion.excerpt_range.clone()) + .collect::(); + + let mut delta = 0; + let mut diff_highlights = Vec::new(); + for (old_range, new_text) in active_completion.completion.edits.iter() { + let old_range = old_range.to_offset(&active_completion.completion.snapshot); + let old_start_in_text = + old_range.start - active_completion.completion.excerpt_range.start + delta; + let old_end_in_text = + old_range.end - active_completion.completion.excerpt_range.start + delta; + if old_start_in_text < old_end_in_text { + diff_highlights.push(( + old_start_in_text..old_end_in_text, + HighlightStyle { + background_color: Some(cx.theme().status().deleted_background), + strikethrough: Some(gpui::StrikethroughStyle { + thickness: px(1.), + color: Some(cx.theme().colors().text_muted), + }), + ..Default::default() + }, + )); + } + + if !new_text.is_empty() { + diff.insert_str(old_end_in_text, new_text); + diff_highlights.push(( + old_end_in_text..old_end_in_text + new_text.len(), + HighlightStyle { + background_color: Some(cx.theme().status().created_background), + ..Default::default() + }, + )); + delta += new_text.len(); + } + } + + let settings = ThemeSettings::get_global(cx).clone(); + let text_style = TextStyle { + color: cx.theme().colors().editor_foreground, + font_size: settings.buffer_font_size(cx).into(), + font_family: settings.buffer_font.family, + font_features: settings.buffer_font.features, + font_fallbacks: settings.buffer_font.fallbacks, + line_height: relative(settings.buffer_line_height.value()), + font_weight: settings.buffer_font.weight, + font_style: settings.buffer_font.style, + ..Default::default() + }; + + let rated = self.zeta.read(cx).is_completion_rated(completion_id); + Some( + v_flex() + .flex_1() + .size_full() + .gap_2() + .child(h_flex().justify_center().children(if rated { + Some( + Label::new("This completion was already rated") + .color(Color::Muted) + .size(LabelSize::Large), + ) + } else if active_completion.completion.edits.is_empty() { + Some( + Label::new("This completion didn't produce any edits") + .color(Color::Warning) + .size(LabelSize::Large), + ) + } else { + None + })) + .child( + v_flex() + .id("diff") + .flex_1() + .flex_basis(relative(0.75)) + .bg(cx.theme().colors().editor_background) + .overflow_y_scroll() + .p_2() + .border_color(cx.theme().colors().border) + .border_1() + .rounded_lg() + .child(StyledText::new(diff).with_highlights(&text_style, diff_highlights)), + ) + .child( + div() + .flex_1() + .flex_basis(relative(0.25)) + .bg(cx.theme().colors().editor_background) + .border_color(cx.theme().colors().border) + .border_1() + .rounded_lg() + .child(active_completion.feedback_editor.clone()), + ) + .child( + h_flex() + .gap_2() + .justify_end() + .child( + Button::new("bad", "👎 Bad Completion") + .size(ButtonSize::Large) + .disabled(rated) + .label_size(LabelSize::Large) + .color(Color::Error) + .on_click({ + let completion = active_completion.completion.clone(); + let feedback_editor = active_completion.feedback_editor.clone(); + cx.listener(move |this, _, cx| { + this.zeta.update(cx, |zeta, cx| { + zeta.rate_completion( + &completion, + InlineCompletionRating::Negative, + feedback_editor.read(cx).text(cx), + cx, + ) + }) + }) + }), + ) + .child( + Button::new("good", "👍 Good Completion") + .size(ButtonSize::Large) + .disabled(rated) + .label_size(LabelSize::Large) + .color(Color::Success) + .on_click({ + let completion = active_completion.completion.clone(); + let feedback_editor = active_completion.feedback_editor.clone(); + cx.listener(move |this, _, cx| { + this.zeta.update(cx, |zeta, cx| { + zeta.rate_completion( + &completion, + InlineCompletionRating::Positive, + feedback_editor.read(cx).text(cx), + cx, + ) + }) + }) + }), + ), + ), + ) + } +} + +impl Render for RateCompletionModal { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + h_flex() + .gap_2() + .bg(cx.theme().colors().elevated_surface_background) + .w(cx.viewport_size().width - px(256.)) + .h(cx.viewport_size().height - px(256.)) + .rounded_lg() + .shadow_lg() + .p_2() + .key_context("RateCompletionModal") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::dismiss)) + .child( + div() + .id("completion_list") + .w_96() + .h_full() + .overflow_y_scroll() + .child( + ui::List::new() + .empty_message( + "No completions, use the editor to generate some and rate them!", + ) + .children(self.zeta.read(cx).recent_completions().cloned().map( + |completion| { + let selected = + self.active_completion.as_ref().map_or(false, |selected| { + selected.completion.id == completion.id + }); + let rated = + self.zeta.read(cx).is_completion_rated(completion.id); + ListItem::new(completion.id) + .spacing(ListItemSpacing::Sparse) + .selected(selected) + .end_slot(if rated { + Icon::new(IconName::Check).color(Color::Success) + } else if completion.edits.is_empty() { + Icon::new(IconName::Ellipsis).color(Color::Muted) + } else { + Icon::new(IconName::Diff).color(Color::Muted) + }) + .child(Label::new( + completion.path.to_string_lossy().to_string(), + )) + .child( + Label::new(format!("({})", completion.id)) + .color(Color::Muted) + .size(LabelSize::XSmall), + ) + .on_click(cx.listener(move |this, _, cx| { + this.select_completion(Some(completion.clone()), cx); + })) + }, + )), + ), + ) + .children(self.render_active_completion(cx)) + .on_mouse_down_out(cx.listener(|_, _, cx| cx.emit(DismissEvent))) + } +} + +impl EventEmitter for RateCompletionModal {} + +impl FocusableView for RateCompletionModal { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for RateCompletionModal {} diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs new file mode 100644 index 00000000000000..dea15b0b08dbd1 --- /dev/null +++ b/crates/zeta/src/zeta.rs @@ -0,0 +1,960 @@ +mod rate_completion_modal; + +pub use rate_completion_modal::*; + +use anyhow::{anyhow, Context as _, Result}; +use client::Client; +use collections::{HashMap, HashSet, VecDeque}; +use futures::AsyncReadExt; +use gpui::{AppContext, Context, Global, Model, ModelContext, Subscription, Task}; +use http_client::{HttpClient, Method}; +use language::{ + language_settings::all_language_settings, Anchor, Buffer, BufferSnapshot, OffsetRangeExt, + Point, ToOffset, ToPoint, +}; +use language_models::LlmApiToken; +use rpc::{PredictEditsParams, PredictEditsResponse}; +use std::{ + borrow::Cow, + cmp, + fmt::Write, + mem, + ops::Range, + path::Path, + sync::Arc, + time::{Duration, Instant}, +}; +use telemetry_events::InlineCompletionRating; +use util::ResultExt; +use uuid::Uuid; + +const CURSOR_MARKER: &'static str = "<|user_cursor_is_here|>"; +const START_OF_FILE_MARKER: &'static str = "<|start_of_file|>"; +const EDITABLE_REGION_START_MARKER: &'static str = "<|editable_region_start|>"; +const EDITABLE_REGION_END_MARKER: &'static str = "<|editable_region_end|>"; +const BUFFER_CHANGE_GROUPING_INTERVAL: Duration = Duration::from_secs(1); + +#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)] +pub struct InlineCompletionId(Uuid); + +impl From for gpui::ElementId { + fn from(value: InlineCompletionId) -> Self { + gpui::ElementId::Uuid(value.0) + } +} + +impl std::fmt::Display for InlineCompletionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl InlineCompletionId { + fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +#[derive(Clone)] +struct ZetaGlobal(Model); + +impl Global for ZetaGlobal {} + +#[derive(Clone)] +pub struct InlineCompletion { + id: InlineCompletionId, + path: Arc, + excerpt_range: Range, + edits: Arc<[(Range, String)]>, + snapshot: BufferSnapshot, + input_events: Arc, + input_excerpt: Arc, + output_excerpt: Arc, +} + +impl InlineCompletion { + fn interpolate(&self, new_snapshot: BufferSnapshot) -> Option, String)>> { + let mut edits = Vec::new(); + + let mut user_edits = new_snapshot + .edits_since::(&self.snapshot.version) + .peekable(); + for (model_old_range, model_new_text) in self.edits.iter() { + let model_offset_range = model_old_range.to_offset(&self.snapshot); + while let Some(next_user_edit) = user_edits.peek() { + if next_user_edit.old.end < model_offset_range.start { + user_edits.next(); + } else { + break; + } + } + + if let Some(user_edit) = user_edits.peek() { + if user_edit.old.start > model_offset_range.end { + edits.push((model_old_range.clone(), model_new_text.clone())); + } else if user_edit.old == model_offset_range { + let user_new_text = new_snapshot + .text_for_range(user_edit.new.clone()) + .collect::(); + + if let Some(model_suffix) = model_new_text.strip_prefix(&user_new_text) { + if !model_suffix.is_empty() { + edits.push(( + new_snapshot.anchor_after(user_edit.new.end) + ..new_snapshot.anchor_before(user_edit.new.end), + model_suffix.into(), + )); + } + + user_edits.next(); + } else { + return None; + } + } else { + return None; + } + } else { + edits.push((model_old_range.clone(), model_new_text.clone())); + } + } + + Some(edits) + } +} + +impl std::fmt::Debug for InlineCompletion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InlineCompletion") + .field("id", &self.id) + .field("path", &self.path) + .field("edits", &self.edits) + .finish_non_exhaustive() + } +} + +pub struct Zeta { + client: Arc, + events: VecDeque, + registered_buffers: HashMap, + recent_completions: VecDeque, + rated_completions: HashSet, + llm_token: LlmApiToken, + _llm_token_subscription: Subscription, +} + +impl Zeta { + pub fn global(cx: &mut AppContext) -> Option> { + cx.try_global::().map(|global| global.0.clone()) + } + + pub fn register(client: Arc, cx: &mut AppContext) -> Model { + Self::global(cx).unwrap_or_else(|| { + let model = cx.new_model(|cx| Self::new(client, cx)); + cx.set_global(ZetaGlobal(model.clone())); + model + }) + } + + fn new(client: Arc, cx: &mut ModelContext) -> Self { + let refresh_llm_token_listener = language_models::RefreshLlmTokenListener::global(cx); + + Self { + client, + events: VecDeque::new(), + recent_completions: VecDeque::new(), + rated_completions: HashSet::default(), + registered_buffers: HashMap::default(), + llm_token: LlmApiToken::default(), + _llm_token_subscription: cx.subscribe( + &refresh_llm_token_listener, + |this, _listener, _event, cx| { + let client = this.client.clone(); + let llm_token = this.llm_token.clone(); + cx.spawn(|_this, _cx| async move { + llm_token.refresh(&client).await?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + }, + ), + } + } + + fn push_event(&mut self, event: Event) { + if let Some(Event::BufferChange { + new_snapshot: last_new_snapshot, + timestamp: last_timestamp, + .. + }) = self.events.back_mut() + { + // Coalesce edits for the same buffer when they happen one after the other. + let Event::BufferChange { + old_snapshot, + new_snapshot, + timestamp, + } = &event; + + if timestamp.duration_since(*last_timestamp) <= BUFFER_CHANGE_GROUPING_INTERVAL + && old_snapshot.remote_id() == last_new_snapshot.remote_id() + && old_snapshot.version == last_new_snapshot.version + { + *last_new_snapshot = new_snapshot.clone(); + *last_timestamp = *timestamp; + return; + } + } + + self.events.push_back(event); + if self.events.len() > 10 { + self.events.pop_front(); + } + } + + pub fn register_buffer(&mut self, buffer: &Model, cx: &mut ModelContext) { + let buffer_id = buffer.entity_id(); + let weak_buffer = buffer.downgrade(); + + if let std::collections::hash_map::Entry::Vacant(entry) = + self.registered_buffers.entry(buffer_id) + { + let snapshot = buffer.read(cx).snapshot(); + + entry.insert(RegisteredBuffer { + snapshot, + _subscriptions: [ + cx.subscribe(buffer, move |this, buffer, event, cx| { + this.handle_buffer_event(buffer, event, cx); + }), + cx.observe_release(buffer, move |this, _buffer, _cx| { + this.registered_buffers.remove(&weak_buffer.entity_id()); + }), + ], + }); + }; + } + + fn handle_buffer_event( + &mut self, + buffer: Model, + event: &language::BufferEvent, + cx: &mut ModelContext, + ) { + match event { + language::BufferEvent::Edited => { + self.report_changes_for_buffer(&buffer, cx); + } + _ => {} + } + } + + pub fn request_completion( + &mut self, + buffer: &Model, + position: language::Anchor, + cx: &mut ModelContext, + ) -> Task> { + let snapshot = self.report_changes_for_buffer(buffer, cx); + let point = position.to_point(&snapshot); + let offset = point.to_offset(&snapshot); + let excerpt_range = excerpt_range_for_position(point, &snapshot); + let events = self.events.clone(); + let path = snapshot + .file() + .map(|f| f.path().clone()) + .unwrap_or_else(|| Arc::from(Path::new("untitled"))); + + let client = self.client.clone(); + let llm_token = self.llm_token.clone(); + + cx.spawn(|this, mut cx| async move { + let start = std::time::Instant::now(); + + let token = llm_token.acquire(&client).await?; + + let mut input_events = String::new(); + for event in events { + if !input_events.is_empty() { + input_events.push('\n'); + input_events.push('\n'); + } + input_events.push_str(&event.to_prompt()); + } + let input_excerpt = prompt_for_excerpt(&snapshot, &excerpt_range, offset); + + log::debug!("Events:\n{}\nExcerpt:\n{}", input_events, input_excerpt); + + let http_client = client.http_client(); + let body = PredictEditsParams { + input_events: input_events.clone(), + input_excerpt: input_excerpt.clone(), + }; + let request_builder = http_client::Request::builder(); + let request = request_builder + .method(Method::POST) + .uri( + client + .http_client() + .build_zed_llm_url("/predict_edits", &[])? + .as_ref(), + ) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", token)) + .body(serde_json::to_string(&body)?.into())?; + let mut response = http_client.send(request).await?; + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + if !response.status().is_success() { + return Err(anyhow!( + "error predicting edits.\nStatus: {:?}\nBody: {}", + response.status(), + body + )); + } + + let response = serde_json::from_str::(&body)?; + let output_excerpt = response.output_excerpt; + log::debug!("prediction took: {:?}", start.elapsed()); + log::debug!("completion response: {}", output_excerpt); + + let content = output_excerpt.replace(CURSOR_MARKER, ""); + let mut new_text = content.as_str(); + + let codefence_start = new_text + .find(EDITABLE_REGION_START_MARKER) + .context("could not find start marker")?; + new_text = &new_text[codefence_start..]; + + let newline_ix = new_text.find('\n').context("could not find newline")?; + new_text = &new_text[newline_ix + 1..]; + + let codefence_end = new_text + .rfind(&format!("\n{EDITABLE_REGION_END_MARKER}")) + .context("could not find end marker")?; + new_text = &new_text[..codefence_end]; + log::debug!("sanitized completion response: {}", new_text); + + let old_text = snapshot + .text_for_range(excerpt_range.clone()) + .collect::(); + + let diff = similar::TextDiff::from_chars(old_text.as_str(), new_text); + + let mut edits: Vec<(Range, String)> = Vec::new(); + let mut old_start = excerpt_range.start; + for change in diff.iter_all_changes() { + let value = change.value(); + match change.tag() { + similar::ChangeTag::Equal => { + old_start += value.len(); + } + similar::ChangeTag::Delete => { + let old_end = old_start + value.len(); + if let Some((last_old_range, _)) = edits.last_mut() { + if last_old_range.end == old_start { + last_old_range.end = old_end; + } else { + edits.push((old_start..old_end, String::new())); + } + } else { + edits.push((old_start..old_end, String::new())); + } + + old_start = old_end; + } + similar::ChangeTag::Insert => { + if let Some((last_old_range, last_new_text)) = edits.last_mut() { + if last_old_range.end == old_start { + last_new_text.push_str(value); + } else { + edits.push((old_start..old_start, value.into())); + } + } else { + edits.push((old_start..old_start, value.into())); + } + } + } + } + + let edits = edits + .into_iter() + .map(|(mut old_range, new_text)| { + let prefix_len = common_prefix( + snapshot.chars_for_range(old_range.clone()), + new_text.chars(), + ); + old_range.start += prefix_len; + let suffix_len = common_prefix( + snapshot.reversed_chars_for_range(old_range.clone()), + new_text[prefix_len..].chars().rev(), + ); + old_range.end = old_range.end.saturating_sub(suffix_len); + + let new_text = new_text[prefix_len..new_text.len() - suffix_len].to_string(); + ( + snapshot.anchor_after(old_range.start) + ..snapshot.anchor_before(old_range.end), + new_text, + ) + }) + .collect(); + let inline_completion = InlineCompletion { + id: InlineCompletionId::new(), + path, + excerpt_range, + edits, + snapshot, + input_events: input_events.into(), + input_excerpt: input_excerpt.into(), + output_excerpt: output_excerpt.into(), + }; + this.update(&mut cx, |this, cx| { + this.recent_completions + .push_front(inline_completion.clone()); + if this.recent_completions.len() > 50 { + this.recent_completions.pop_back(); + } + cx.notify(); + })?; + + Ok(inline_completion) + }) + } + + pub fn is_completion_rated(&self, completion_id: InlineCompletionId) -> bool { + self.rated_completions.contains(&completion_id) + } + + pub fn rate_completion( + &mut self, + completion: &InlineCompletion, + rating: InlineCompletionRating, + feedback: String, + cx: &mut ModelContext, + ) { + self.rated_completions.insert(completion.id); + self.client + .telemetry() + .report_inline_completion_rating_event( + rating, + completion.input_events.clone(), + completion.input_excerpt.clone(), + completion.output_excerpt.clone(), + feedback, + ); + self.client.telemetry().flush_events(); + cx.notify(); + } + + pub fn recent_completions(&self) -> impl Iterator { + self.recent_completions.iter() + } + + fn report_changes_for_buffer( + &mut self, + buffer: &Model, + cx: &mut ModelContext, + ) -> BufferSnapshot { + self.register_buffer(buffer, cx); + + let registered_buffer = self + .registered_buffers + .get_mut(&buffer.entity_id()) + .unwrap(); + let new_snapshot = buffer.read(cx).snapshot(); + + if new_snapshot.version != registered_buffer.snapshot.version { + let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone()); + self.push_event(Event::BufferChange { + old_snapshot, + new_snapshot: new_snapshot.clone(), + timestamp: Instant::now(), + }); + } + + new_snapshot + } +} + +fn common_prefix, T2: Iterator>(a: T1, b: T2) -> usize { + a.zip(b) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a.len_utf8()) + .sum() +} + +fn prompt_for_excerpt( + snapshot: &BufferSnapshot, + excerpt_range: &Range, + offset: usize, +) -> String { + let mut prompt_excerpt = String::new(); + writeln!( + prompt_excerpt, + "```{}", + snapshot + .file() + .map_or(Cow::Borrowed("untitled"), |file| file + .path() + .to_string_lossy()) + ) + .unwrap(); + + if excerpt_range.start == 0 { + writeln!(prompt_excerpt, "{START_OF_FILE_MARKER}").unwrap(); + } + + let point_range = excerpt_range.to_point(snapshot); + if point_range.start.row > 0 && !snapshot.is_line_blank(point_range.start.row - 1) { + let extra_context_line_range = Point::new(point_range.start.row - 1, 0)..point_range.start; + for chunk in snapshot.text_for_range(extra_context_line_range) { + prompt_excerpt.push_str(chunk); + } + } + writeln!(prompt_excerpt, "{EDITABLE_REGION_START_MARKER}").unwrap(); + for chunk in snapshot.text_for_range(excerpt_range.start..offset) { + prompt_excerpt.push_str(chunk); + } + prompt_excerpt.push_str(CURSOR_MARKER); + for chunk in snapshot.text_for_range(offset..excerpt_range.end) { + prompt_excerpt.push_str(chunk); + } + write!(prompt_excerpt, "\n{EDITABLE_REGION_END_MARKER}").unwrap(); + + if point_range.end.row < snapshot.max_point().row + && !snapshot.is_line_blank(point_range.end.row + 1) + { + let extra_context_line_range = point_range.end + ..Point::new( + point_range.end.row + 1, + snapshot.line_len(point_range.end.row + 1), + ); + for chunk in snapshot.text_for_range(extra_context_line_range) { + prompt_excerpt.push_str(chunk); + } + } + + write!(prompt_excerpt, "\n```").unwrap(); + prompt_excerpt +} + +fn excerpt_range_for_position(point: Point, snapshot: &BufferSnapshot) -> Range { + const CONTEXT_LINES: u32 = 16; + + let mut context_lines_before = CONTEXT_LINES; + let mut context_lines_after = CONTEXT_LINES; + if point.row < CONTEXT_LINES { + context_lines_after += CONTEXT_LINES - point.row; + } else if point.row + CONTEXT_LINES > snapshot.max_point().row { + context_lines_before += (point.row + CONTEXT_LINES) - snapshot.max_point().row; + } + + let excerpt_start_row = point.row.saturating_sub(context_lines_before); + let excerpt_start = Point::new(excerpt_start_row, 0); + let excerpt_end_row = cmp::min(point.row + context_lines_after, snapshot.max_point().row); + let excerpt_end = Point::new(excerpt_end_row, snapshot.line_len(excerpt_end_row)); + excerpt_start.to_offset(snapshot)..excerpt_end.to_offset(snapshot) +} + +struct RegisteredBuffer { + snapshot: BufferSnapshot, + _subscriptions: [gpui::Subscription; 2], +} + +#[derive(Clone)] +enum Event { + BufferChange { + old_snapshot: BufferSnapshot, + new_snapshot: BufferSnapshot, + timestamp: Instant, + }, +} + +impl Event { + fn to_prompt(&self) -> String { + match self { + Event::BufferChange { + old_snapshot, + new_snapshot, + .. + } => { + let mut prompt = String::new(); + + let old_path = old_snapshot + .file() + .map(|f| f.path().as_ref()) + .unwrap_or(Path::new("untitled")); + let new_path = new_snapshot + .file() + .map(|f| f.path().as_ref()) + .unwrap_or(Path::new("untitled")); + if old_path != new_path { + writeln!(prompt, "User renamed {:?} to {:?}\n", old_path, new_path).unwrap(); + } + + let diff = + similar::TextDiff::from_lines(&old_snapshot.text(), &new_snapshot.text()) + .unified_diff() + .to_string(); + if !diff.is_empty() { + write!( + prompt, + "User edited {:?}:\n```diff\n{}\n```", + new_path, diff + ) + .unwrap(); + } + + prompt + } + } + } +} + +pub struct ZetaInlineCompletionProvider { + zeta: Model, + current_completion: Option, + pending_refresh: Task<()>, +} + +impl ZetaInlineCompletionProvider { + pub const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(75); + + pub fn new(zeta: Model) -> Self { + Self { + zeta, + current_completion: None, + pending_refresh: Task::ready(()), + } + } +} + +impl inline_completion::InlineCompletionProvider for ZetaInlineCompletionProvider { + fn name() -> &'static str { + "Zeta" + } + + fn is_enabled( + &self, + buffer: &Model, + cursor_position: language::Anchor, + cx: &AppContext, + ) -> bool { + let buffer = buffer.read(cx); + let file = buffer.file(); + let language = buffer.language_at(cursor_position); + let settings = all_language_settings(file, cx); + settings.inline_completions_enabled(language.as_ref(), file.map(|f| f.path().as_ref()), cx) + } + + fn refresh( + &mut self, + buffer: Model, + position: language::Anchor, + debounce: bool, + cx: &mut ModelContext, + ) { + self.pending_refresh = cx.spawn(|this, mut cx| async move { + if debounce { + cx.background_executor().timer(Self::DEBOUNCE_TIMEOUT).await; + } + + let completion_request = this.update(&mut cx, |this, cx| { + this.zeta.update(cx, |zeta, cx| { + zeta.request_completion(&buffer, position, cx) + }) + }); + + let mut completion = None; + if let Ok(completion_request) = completion_request { + completion = completion_request.await.log_err(); + } + + this.update(&mut cx, |this, cx| { + this.current_completion = completion; + cx.notify(); + }) + .ok(); + }); + } + + fn cycle( + &mut self, + _buffer: Model, + _cursor_position: language::Anchor, + _direction: inline_completion::Direction, + _cx: &mut ModelContext, + ) { + // Right now we don't support cycling. + } + + fn accept(&mut self, _cx: &mut ModelContext) {} + + fn discard(&mut self, _cx: &mut ModelContext) { + self.current_completion.take(); + } + + fn suggest( + &mut self, + buffer: &Model, + cursor_position: language::Anchor, + cx: &mut ModelContext, + ) -> Option { + let completion = self.current_completion.as_mut()?; + + let buffer = buffer.read(cx); + let Some(edits) = completion.interpolate(buffer.snapshot()) else { + self.current_completion.take(); + return None; + }; + + let cursor_row = cursor_position.to_point(buffer).row; + let (closest_edit_ix, (closest_edit_range, _)) = + edits.iter().enumerate().min_by_key(|(_, (range, _))| { + let distance_from_start = cursor_row.abs_diff(range.start.to_point(buffer).row); + let distance_from_end = cursor_row.abs_diff(range.end.to_point(buffer).row); + cmp::min(distance_from_start, distance_from_end) + })?; + + let mut edit_start_ix = closest_edit_ix; + for (range, _) in edits[..edit_start_ix].iter().rev() { + let distance_from_closest_edit = + closest_edit_range.start.to_point(buffer).row - range.end.to_point(buffer).row; + if distance_from_closest_edit <= 1 { + edit_start_ix -= 1; + } else { + break; + } + } + + let mut edit_end_ix = closest_edit_ix + 1; + for (range, _) in &edits[edit_end_ix..] { + let distance_from_closest_edit = + range.start.to_point(buffer).row - closest_edit_range.end.to_point(buffer).row; + if distance_from_closest_edit <= 1 { + edit_end_ix += 1; + } else { + break; + } + } + + Some(inline_completion::InlineCompletion { + edits: edits[edit_start_ix..edit_end_ix].to_vec(), + }) + } +} + +#[cfg(test)] +mod tests { + use client::test::FakeServer; + use clock::FakeSystemClock; + use gpui::TestAppContext; + use http_client::FakeHttpClient; + use indoc::indoc; + use language_models::RefreshLlmTokenListener; + use rpc::proto; + use settings::SettingsStore; + + use super::*; + + #[gpui::test] + fn test_inline_completion_basic_interpolation(cx: &mut AppContext) { + let buffer = cx.new_model(|cx| Buffer::local("Lorem ipsum dolor", cx)); + let completion = InlineCompletion { + edits: to_completion_edits( + [(2..5, "REM".to_string()), (9..11, "".to_string())], + &buffer, + cx, + ) + .into(), + path: Path::new("").into(), + snapshot: buffer.read(cx).snapshot(), + id: InlineCompletionId::new(), + excerpt_range: 0..0, + input_events: "".into(), + input_excerpt: "".into(), + output_excerpt: "".into(), + }; + + assert_eq!( + from_completion_edits( + &completion.interpolate(buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..5, "REM".to_string()), (9..11, "".to_string())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "")], None, cx)); + assert_eq!( + from_completion_edits( + &completion.interpolate(buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..2, "REM".to_string()), (6..8, "".to_string())] + ); + + buffer.update(cx, |buffer, cx| buffer.undo(cx)); + assert_eq!( + from_completion_edits( + &completion.interpolate(buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(2..5, "REM".to_string()), (9..11, "".to_string())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(2..5, "R")], None, cx)); + assert_eq!( + from_completion_edits( + &completion.interpolate(buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(3..3, "EM".to_string()), (7..9, "".to_string())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(3..3, "E")], None, cx)); + assert_eq!( + from_completion_edits( + &completion.interpolate(buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".to_string()), (8..10, "".to_string())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "M")], None, cx)); + assert_eq!( + from_completion_edits( + &completion.interpolate(buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(9..11, "".to_string())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..5, "")], None, cx)); + assert_eq!( + from_completion_edits( + &completion.interpolate(buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".to_string()), (8..10, "".to_string())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(8..10, "")], None, cx)); + assert_eq!( + from_completion_edits( + &completion.interpolate(buffer.read(cx).snapshot()).unwrap(), + &buffer, + cx + ), + vec![(4..4, "M".to_string())] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(4..6, "")], None, cx)); + assert_eq!(completion.interpolate(buffer.read(cx).snapshot()), None); + } + + #[gpui::test] + async fn test_inline_completion_end_of_buffer(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + client::init_settings(cx); + }); + + let buffer_content = "lorem\n"; + let completion_response = indoc! {" + ```animals.js + <|start_of_file|> + <|editable_region_start|> + lorem + ipsum + <|editable_region_end|> + ```"}; + + let http_client = FakeHttpClient::create(move |_| async move { + Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&PredictEditsResponse { + output_excerpt: completion_response.to_string(), + }) + .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 zeta = cx.new_model(|cx| Zeta::new(client, cx)); + let buffer = cx.new_model(|cx| Buffer::local(buffer_content, cx)); + 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(&buffer, cursor, cx)); + + let token_request = server.receive::().await.unwrap(); + server.respond( + token_request.receipt(), + proto::GetLlmTokenResponse { token: "".into() }, + ); + + let completion = completion_task.await.unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.edit(completion.edits.iter().cloned(), None, cx) + }); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "lorem\nipsum" + ); + } + + fn to_completion_edits( + iterator: impl IntoIterator, String)>, + buffer: &Model, + cx: &AppContext, + ) -> Vec<(Range, String)> { + let buffer = buffer.read(cx); + iterator + .into_iter() + .map(|(range, text)| { + ( + buffer.anchor_after(range.start)..buffer.anchor_before(range.end), + text, + ) + }) + .collect() + } + + fn from_completion_edits( + editor_edits: &[(Range, String)], + buffer: &Model, + cx: &AppContext, + ) -> Vec<(Range, String)> { + let buffer = buffer.read(cx); + editor_edits + .iter() + .map(|(range, text)| { + ( + range.start.to_offset(buffer)..range.end.to_offset(buffer), + text.clone(), + ) + }) + .collect() + } + + #[ctor::ctor] + fn init_logger() { + if std::env::var("RUST_LOG").is_ok() { + env_logger::init(); + } + } +}