From 3ad7f528cb0eb2f953d138d3d499163d8993f8ba Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 18 Aug 2023 17:58:22 +0200 Subject: [PATCH 01/60] Start on a refactoring assistant --- crates/ai/src/ai.rs | 1 + crates/ai/src/refactor.rs | 88 +++++++++++++++++++++++++++++++++++++++ prompt.md | 11 +++++ 3 files changed, 100 insertions(+) create mode 100644 crates/ai/src/refactor.rs create mode 100644 prompt.md diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 7cc5f08f7c..7874bb46a5 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -1,5 +1,6 @@ pub mod assistant; mod assistant_settings; +mod refactor; use anyhow::Result; pub use assistant::AssistantPanel; diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs new file mode 100644 index 0000000000..e1b57680ee --- /dev/null +++ b/crates/ai/src/refactor.rs @@ -0,0 +1,88 @@ +use collections::HashMap; +use editor::Editor; +use gpui::{ + actions, elements::*, AnyViewHandle, AppContext, Entity, Task, View, ViewContext, ViewHandle, +}; +use std::sync::Arc; +use workspace::{Modal, Workspace}; + +actions!(assistant, [Refactor]); + +fn init(cx: &mut AppContext) { + cx.set_global(RefactoringAssistant::new()); + cx.add_action(RefactoringModal::deploy); +} + +pub struct RefactoringAssistant { + pending_edits_by_editor: HashMap>>, +} + +impl RefactoringAssistant { + fn new() -> Self { + Self { + pending_edits_by_editor: Default::default(), + } + } + + fn refactor(&mut self, editor: &ViewHandle, prompt: &str, cx: &mut AppContext) {} +} + +struct RefactoringModal { + prompt_editor: ViewHandle, + has_focus: bool, +} + +impl Entity for RefactoringModal { + type Event = (); +} + +impl View for RefactoringModal { + fn ui_name() -> &'static str { + "RefactoringModal" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + todo!() + } + + fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext) { + self.has_focus = true; + } + + fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext) { + self.has_focus = false; + } +} + +impl Modal for RefactoringModal { + fn has_focus(&self) -> bool { + self.has_focus + } + + fn dismiss_on_event(event: &Self::Event) -> bool { + todo!() + } +} + +impl RefactoringModal { + fn deploy(workspace: &mut Workspace, _: &Refactor, cx: &mut ViewContext) { + workspace.toggle_modal(cx, |_, cx| { + let prompt_editor = cx.add_view(|cx| { + Editor::auto_height( + 4, + Some(Arc::new(|theme| theme.search.editor.input.clone())), + cx, + ) + }); + cx.add_view(|_| RefactoringModal { + prompt_editor, + has_focus: false, + }) + }); + } +} + +// ABCDEFG +// XCDEFG +// +// diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000000..33213a5859 --- /dev/null +++ b/prompt.md @@ -0,0 +1,11 @@ +Given a snippet as the input, you must produce an array of edits. An edit has the following structure: + +{ skip: "skip", delete: "delete", insert: "insert" } + +`skip` is a string in the input that should be left unchanged. `delete` is a string in the input located right after the skipped text that should be deleted. `insert` is a new string that should be inserted after the end of the text in `skip`. It's crucial that a string in the input can only be skipped or deleted once and only once. + +Your task is to produce an array of edits. `delete` and `insert` can be empty if nothing changed. When `skip`, `delete` or `insert` are longer than 20 characters, split them into multiple edits. + +Check your reasoning by concatenating all the strings in `skip` and `delete`. If the text is the same as the input snippet then the edits are valid. + +It's crucial that you reply only with edits. No prose or remarks. From 42f02eb4e7adafe27d444b8b9ffbe68ddce9e714 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 21 Aug 2023 15:11:06 +0200 Subject: [PATCH 02/60] Incrementally diff input coming from GPT --- Cargo.lock | 1 + crates/ai/Cargo.toml | 1 + crates/ai/src/ai.rs | 107 +++++++++++++++-- crates/ai/src/assistant.rs | 99 +--------------- crates/ai/src/refactor.rs | 233 +++++++++++++++++++++++++++++++++---- prompt.md | 11 -- 6 files changed, 315 insertions(+), 137 deletions(-) delete mode 100644 prompt.md diff --git a/Cargo.lock b/Cargo.lock index 69285a1abf..f802d90739 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,7 @@ dependencies = [ "serde", "serde_json", "settings", + "similar", "smol", "theme", "tiktoken-rs 0.4.5", diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index 013565e14f..bae20f7537 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -29,6 +29,7 @@ regex.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true +similar = "1.3" smol.workspace = true tiktoken-rs = "0.4" diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 7874bb46a5..511e7fddd7 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -2,27 +2,31 @@ pub mod assistant; mod assistant_settings; mod refactor; -use anyhow::Result; +use anyhow::{anyhow, Result}; pub use assistant::AssistantPanel; use chrono::{DateTime, Local}; use collections::HashMap; use fs::Fs; -use futures::StreamExt; -use gpui::AppContext; +use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; +use gpui::{executor::Background, AppContext}; +use isahc::{http::StatusCode, Request, RequestExt}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::{ cmp::Reverse, ffi::OsStr, fmt::{self, Display}, + io, path::PathBuf, sync::Arc, }; use util::paths::CONVERSATIONS_DIR; +const OPENAI_API_URL: &'static str = "https://api.openai.com/v1"; + // Data types for chat completion requests #[derive(Debug, Serialize)] -struct OpenAIRequest { +pub struct OpenAIRequest { model: String, messages: Vec, stream: bool, @@ -116,7 +120,7 @@ struct RequestMessage { } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -struct ResponseMessage { +pub struct ResponseMessage { role: Option, content: Option, } @@ -150,7 +154,7 @@ impl Display for Role { } #[derive(Deserialize, Debug)] -struct OpenAIResponseStreamEvent { +pub struct OpenAIResponseStreamEvent { pub id: Option, pub object: String, pub created: u32, @@ -160,14 +164,14 @@ struct OpenAIResponseStreamEvent { } #[derive(Deserialize, Debug)] -struct Usage { +pub struct Usage { pub prompt_tokens: u32, pub completion_tokens: u32, pub total_tokens: u32, } #[derive(Deserialize, Debug)] -struct ChatChoiceDelta { +pub struct ChatChoiceDelta { pub index: u32, pub delta: ResponseMessage, pub finish_reason: Option, @@ -190,4 +194,91 @@ struct OpenAIChoice { pub fn init(cx: &mut AppContext) { assistant::init(cx); + refactor::init(cx); +} + +pub async fn stream_completion( + api_key: String, + executor: Arc, + mut request: OpenAIRequest, +) -> Result>> { + request.stream = true; + + let (tx, rx) = futures::channel::mpsc::unbounded::>(); + + let json_data = serde_json::to_string(&request)?; + let mut response = Request::post(format!("{OPENAI_API_URL}/chat/completions")) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_key)) + .body(json_data)? + .send_async() + .await?; + + let status = response.status(); + if status == StatusCode::OK { + executor + .spawn(async move { + let mut lines = BufReader::new(response.body_mut()).lines(); + + fn parse_line( + line: Result, + ) -> Result> { + if let Some(data) = line?.strip_prefix("data: ") { + let event = serde_json::from_str(&data)?; + Ok(Some(event)) + } else { + Ok(None) + } + } + + while let Some(line) = lines.next().await { + if let Some(event) = parse_line(line).transpose() { + let done = event.as_ref().map_or(false, |event| { + event + .choices + .last() + .map_or(false, |choice| choice.finish_reason.is_some()) + }); + if tx.unbounded_send(event).is_err() { + break; + } + + if done { + break; + } + } + } + + anyhow::Ok(()) + }) + .detach(); + + Ok(rx) + } 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, + )), + } + } } diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index e5026182ed..f134eeeeb6 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -1,7 +1,7 @@ use crate::{ assistant_settings::{AssistantDockPosition, AssistantSettings}, - MessageId, MessageMetadata, MessageStatus, OpenAIRequest, OpenAIResponseStreamEvent, - RequestMessage, Role, SavedConversation, SavedConversationMetadata, SavedMessage, + stream_completion, MessageId, MessageMetadata, MessageStatus, OpenAIRequest, RequestMessage, + Role, SavedConversation, SavedConversationMetadata, SavedMessage, OPENAI_API_URL, }; use anyhow::{anyhow, Result}; use chrono::{DateTime, Local}; @@ -12,26 +12,23 @@ use editor::{ Anchor, Editor, ToOffset, }; use fs::Fs; -use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; +use futures::StreamExt; use gpui::{ actions, elements::*, - executor::Background, geometry::vector::{vec2f, Vector2F}, platform::{CursorStyle, MouseButton}, Action, AppContext, AsyncAppContext, ClipboardItem, Entity, ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; -use isahc::{http::StatusCode, Request, RequestExt}; use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset as _}; use search::BufferSearchBar; -use serde::Deserialize; use settings::SettingsStore; use std::{ cell::RefCell, cmp, env, fmt::Write, - io, iter, + iter, ops::Range, path::{Path, PathBuf}, rc::Rc, @@ -46,8 +43,6 @@ use workspace::{ Save, ToggleZoom, Toolbar, Workspace, }; -const OPENAI_API_URL: &'static str = "https://api.openai.com/v1"; - actions!( assistant, [ @@ -2144,92 +2139,6 @@ impl Message { } } -async fn stream_completion( - api_key: String, - executor: Arc, - mut request: OpenAIRequest, -) -> Result>> { - request.stream = true; - - let (tx, rx) = futures::channel::mpsc::unbounded::>(); - - let json_data = serde_json::to_string(&request)?; - let mut response = Request::post(format!("{OPENAI_API_URL}/chat/completions")) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_key)) - .body(json_data)? - .send_async() - .await?; - - let status = response.status(); - if status == StatusCode::OK { - executor - .spawn(async move { - let mut lines = BufReader::new(response.body_mut()).lines(); - - fn parse_line( - line: Result, - ) -> Result> { - if let Some(data) = line?.strip_prefix("data: ") { - let event = serde_json::from_str(&data)?; - Ok(Some(event)) - } else { - Ok(None) - } - } - - while let Some(line) = lines.next().await { - if let Some(event) = parse_line(line).transpose() { - let done = event.as_ref().map_or(false, |event| { - event - .choices - .last() - .map_or(false, |choice| choice.finish_reason.is_some()) - }); - if tx.unbounded_send(event).is_err() { - break; - } - - if done { - break; - } - } - } - - anyhow::Ok(()) - }) - .detach(); - - Ok(rx) - } 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, - )), - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index e1b57680ee..fc6cbdb8c4 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -1,16 +1,24 @@ -use collections::HashMap; -use editor::Editor; +use crate::{stream_completion, OpenAIRequest, RequestMessage, Role}; +use collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use editor::{Anchor, Editor, MultiBuffer, MultiBufferSnapshot, ToOffset}; +use futures::{io::BufWriter, AsyncReadExt, AsyncWriteExt, StreamExt}; use gpui::{ actions, elements::*, AnyViewHandle, AppContext, Entity, Task, View, ViewContext, ViewHandle, + WeakViewHandle, }; -use std::sync::Arc; +use menu::Confirm; +use serde::Deserialize; +use similar::ChangeTag; +use std::{env, iter, ops::Range, sync::Arc}; +use util::TryFutureExt; use workspace::{Modal, Workspace}; actions!(assistant, [Refactor]); -fn init(cx: &mut AppContext) { +pub fn init(cx: &mut AppContext) { cx.set_global(RefactoringAssistant::new()); cx.add_action(RefactoringModal::deploy); + cx.add_action(RefactoringModal::confirm); } pub struct RefactoringAssistant { @@ -24,10 +32,122 @@ impl RefactoringAssistant { } } - fn refactor(&mut self, editor: &ViewHandle, prompt: &str, cx: &mut AppContext) {} + fn refactor(&mut self, editor: &ViewHandle, prompt: &str, cx: &mut AppContext) { + let buffer = editor.read(cx).buffer().read(cx).snapshot(cx); + let selection = editor.read(cx).selections.newest_anchor().clone(); + let selected_text = buffer + .text_for_range(selection.start..selection.end) + .collect::(); + let language_name = buffer + .language_at(selection.start) + .map(|language| language.name()); + let language_name = language_name.as_deref().unwrap_or(""); + let request = OpenAIRequest { + model: "gpt-4".into(), + messages: vec![ + RequestMessage { + role: Role::User, + content: format!( + "Given the following {language_name} snippet:\n{selected_text}\n{prompt}. Avoid making remarks and reply only with the new code." + ), + }], + stream: true, + }; + let api_key = env::var("OPENAI_API_KEY").unwrap(); + let response = stream_completion(api_key, cx.background().clone(), request); + let editor = editor.downgrade(); + self.pending_edits_by_editor.insert( + editor.id(), + cx.spawn(|mut cx| { + async move { + let selection_start = selection.start.to_offset(&buffer); + + // Find unique words in the selected text to use as diff boundaries. + let mut duplicate_words = HashSet::default(); + let mut unique_old_words = HashMap::default(); + for (range, word) in words(&selected_text) { + if !duplicate_words.contains(word) { + if unique_old_words.insert(word, range.end).is_some() { + unique_old_words.remove(word); + duplicate_words.insert(word); + } + } + } + + let mut new_text = String::new(); + let mut messages = response.await?; + let mut new_word_search_start_ix = 0; + let mut last_old_word_end_ix = 0; + + 'outer: loop { + let start = new_word_search_start_ix; + let mut words = words(&new_text[start..]); + while let Some((range, new_word)) = words.next() { + // We found a word in the new text that was unique in the old text. We can use + // it as a diff boundary, and start applying edits. + if let Some(old_word_end_ix) = unique_old_words.remove(new_word) { + if old_word_end_ix > last_old_word_end_ix { + drop(words); + + let remainder = new_text.split_off(start + range.end); + let edits = diff( + selection_start + last_old_word_end_ix, + &selected_text[last_old_word_end_ix..old_word_end_ix], + &new_text, + &buffer, + ); + editor.update(&mut cx, |editor, cx| { + editor + .buffer() + .update(cx, |buffer, cx| buffer.edit(edits, None, cx)) + })?; + + new_text = remainder; + new_word_search_start_ix = 0; + last_old_word_end_ix = old_word_end_ix; + continue 'outer; + } + } + + new_word_search_start_ix = start + range.end; + } + drop(words); + + // Buffer incoming text, stopping if the stream was exhausted. + if let Some(message) = messages.next().await { + let mut message = message?; + if let Some(choice) = message.choices.pop() { + if let Some(text) = choice.delta.content { + new_text.push_str(&text); + } + } + } else { + break; + } + } + + let edits = diff( + selection_start + last_old_word_end_ix, + &selected_text[last_old_word_end_ix..], + &new_text, + &buffer, + ); + editor.update(&mut cx, |editor, cx| { + editor + .buffer() + .update(cx, |buffer, cx| buffer.edit(edits, None, cx)) + })?; + + anyhow::Ok(()) + } + .log_err() + }), + ); + } } struct RefactoringModal { + editor: WeakViewHandle, prompt_editor: ViewHandle, has_focus: bool, } @@ -42,7 +162,7 @@ impl View for RefactoringModal { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - todo!() + ChildView::new(&self.prompt_editor, cx).into_any() } fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext) { @@ -60,29 +180,96 @@ impl Modal for RefactoringModal { } fn dismiss_on_event(event: &Self::Event) -> bool { - todo!() + // TODO + false } } impl RefactoringModal { fn deploy(workspace: &mut Workspace, _: &Refactor, cx: &mut ViewContext) { - workspace.toggle_modal(cx, |_, cx| { - let prompt_editor = cx.add_view(|cx| { - Editor::auto_height( - 4, - Some(Arc::new(|theme| theme.search.editor.input.clone())), - cx, - ) + if let Some(editor) = workspace + .active_item(cx) + .and_then(|item| Some(item.downcast::()?.downgrade())) + { + workspace.toggle_modal(cx, |_, cx| { + let prompt_editor = cx.add_view(|cx| { + Editor::auto_height( + 4, + Some(Arc::new(|theme| theme.search.editor.input.clone())), + cx, + ) + }); + cx.add_view(|_| RefactoringModal { + editor, + prompt_editor, + has_focus: false, + }) }); - cx.add_view(|_| RefactoringModal { - prompt_editor, - has_focus: false, - }) - }); + } + } + + fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { + if let Some(editor) = self.editor.upgrade(cx) { + let prompt = self.prompt_editor.read(cx).text(cx); + cx.update_global(|assistant: &mut RefactoringAssistant, cx| { + assistant.refactor(&editor, &prompt, cx); + }); + } } } +fn words(text: &str) -> impl Iterator, &str)> { + let mut word_start_ix = None; + let mut chars = text.char_indices(); + iter::from_fn(move || { + while let Some((ix, ch)) = chars.next() { + if let Some(start_ix) = word_start_ix { + if !ch.is_alphanumeric() { + let word = &text[start_ix..ix]; + word_start_ix.take(); + return Some((start_ix..ix, word)); + } + } else { + if ch.is_alphanumeric() { + word_start_ix = Some(ix); + } + } + } + None + }) +} -// ABCDEFG -// XCDEFG -// -// +fn diff<'a>( + start_ix: usize, + old_text: &'a str, + new_text: &'a str, + old_buffer_snapshot: &MultiBufferSnapshot, +) -> Vec<(Range, &'a str)> { + let mut edit_start = start_ix; + let mut edits = Vec::new(); + let diff = similar::TextDiff::from_words(old_text, &new_text); + for change in diff.iter_all_changes() { + let value = change.value(); + let edit_end = edit_start + value.len(); + match change.tag() { + ChangeTag::Equal => { + edit_start = edit_end; + } + ChangeTag::Delete => { + edits.push(( + old_buffer_snapshot.anchor_after(edit_start) + ..old_buffer_snapshot.anchor_before(edit_end), + "", + )); + edit_start = edit_end; + } + ChangeTag::Insert => { + edits.push(( + old_buffer_snapshot.anchor_after(edit_start) + ..old_buffer_snapshot.anchor_after(edit_start), + value, + )); + } + } + } + edits +} diff --git a/prompt.md b/prompt.md deleted file mode 100644 index 33213a5859..0000000000 --- a/prompt.md +++ /dev/null @@ -1,11 +0,0 @@ -Given a snippet as the input, you must produce an array of edits. An edit has the following structure: - -{ skip: "skip", delete: "delete", insert: "insert" } - -`skip` is a string in the input that should be left unchanged. `delete` is a string in the input located right after the skipped text that should be deleted. `insert` is a new string that should be inserted after the end of the text in `skip`. It's crucial that a string in the input can only be skipped or deleted once and only once. - -Your task is to produce an array of edits. `delete` and `insert` can be empty if nothing changed. When `skip`, `delete` or `insert` are longer than 20 characters, split them into multiple edits. - -Check your reasoning by concatenating all the strings in `skip` and `delete`. If the text is the same as the input snippet then the edits are valid. - -It's crucial that you reply only with edits. No prose or remarks. From 5b9d48d723eff66eb99afc72208ab90ba2015bb1 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 21 Aug 2023 15:53:43 +0200 Subject: [PATCH 03/60] Avoid diffing when the length is too small --- crates/ai/src/refactor.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index fc6cbdb8c4..1a1d02cf1f 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -80,13 +80,17 @@ impl RefactoringAssistant { let mut last_old_word_end_ix = 0; 'outer: loop { + const MIN_DIFF_LEN: usize = 50; + let start = new_word_search_start_ix; let mut words = words(&new_text[start..]); while let Some((range, new_word)) = words.next() { // We found a word in the new text that was unique in the old text. We can use // it as a diff boundary, and start applying edits. - if let Some(old_word_end_ix) = unique_old_words.remove(new_word) { - if old_word_end_ix > last_old_word_end_ix { + if let Some(old_word_end_ix) = unique_old_words.get(new_word).copied() { + if old_word_end_ix.saturating_sub(last_old_word_end_ix) + > MIN_DIFF_LEN + { drop(words); let remainder = new_text.split_off(start + range.end); From 5453553cfa4eb8b1a21a066402ed3ba82067a240 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 22 Aug 2023 08:16:22 +0200 Subject: [PATCH 04/60] WIP --- Cargo.lock | 1 + crates/ai/Cargo.toml | 1 + crates/ai/src/refactor.rs | 341 ++++++++++++++++++++++++++------------ 3 files changed, 234 insertions(+), 109 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f802d90739..af16a88596 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,7 @@ dependencies = [ "fs", "futures 0.3.28", "gpui", + "indoc", "isahc", "language", "menu", diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index bae20f7537..5ef371e342 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -24,6 +24,7 @@ workspace = { path = "../workspace" } anyhow.workspace = true chrono = { version = "0.4", features = ["serde"] } futures.workspace = true +indoc.workspace = true isahc.workspace = true regex.workspace = true schemars.workspace = true diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index 1a1d02cf1f..1923ef7845 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -1,14 +1,13 @@ use crate::{stream_completion, OpenAIRequest, RequestMessage, Role}; -use collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use editor::{Anchor, Editor, MultiBuffer, MultiBufferSnapshot, ToOffset}; -use futures::{io::BufWriter, AsyncReadExt, AsyncWriteExt, StreamExt}; +use collections::HashMap; +use editor::{Editor, ToOffset}; +use futures::StreamExt; use gpui::{ actions, elements::*, AnyViewHandle, AppContext, Entity, Task, View, ViewContext, ViewHandle, WeakViewHandle, }; use menu::Confirm; -use serde::Deserialize; -use similar::ChangeTag; +use similar::{Change, ChangeTag, TextDiff}; use std::{env, iter, ops::Range, sync::Arc}; use util::TryFutureExt; use workspace::{Modal, Workspace}; @@ -33,12 +32,12 @@ impl RefactoringAssistant { } fn refactor(&mut self, editor: &ViewHandle, prompt: &str, cx: &mut AppContext) { - let buffer = editor.read(cx).buffer().read(cx).snapshot(cx); + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); let selection = editor.read(cx).selections.newest_anchor().clone(); - let selected_text = buffer + let selected_text = snapshot .text_for_range(selection.start..selection.end) .collect::(); - let language_name = buffer + let language_name = snapshot .language_at(selection.start) .map(|language| language.name()); let language_name = language_name.as_deref().unwrap_or(""); @@ -48,7 +47,7 @@ impl RefactoringAssistant { RequestMessage { role: Role::User, content: format!( - "Given the following {language_name} snippet:\n{selected_text}\n{prompt}. Avoid making remarks and reply only with the new code." + "Given the following {language_name} snippet:\n{selected_text}\n{prompt}. Avoid making remarks and reply only with the new code. Preserve indentation." ), }], stream: true, @@ -60,86 +59,149 @@ impl RefactoringAssistant { editor.id(), cx.spawn(|mut cx| { async move { - let selection_start = selection.start.to_offset(&buffer); - - // Find unique words in the selected text to use as diff boundaries. - let mut duplicate_words = HashSet::default(); - let mut unique_old_words = HashMap::default(); - for (range, word) in words(&selected_text) { - if !duplicate_words.contains(word) { - if unique_old_words.insert(word, range.end).is_some() { - unique_old_words.remove(word); - duplicate_words.insert(word); - } - } - } + let selection_start = selection.start.to_offset(&snapshot); let mut new_text = String::new(); let mut messages = response.await?; - let mut new_word_search_start_ix = 0; - let mut last_old_word_end_ix = 0; - 'outer: loop { - const MIN_DIFF_LEN: usize = 50; + let mut transaction = None; - let start = new_word_search_start_ix; - let mut words = words(&new_text[start..]); - while let Some((range, new_word)) = words.next() { - // We found a word in the new text that was unique in the old text. We can use - // it as a diff boundary, and start applying edits. - if let Some(old_word_end_ix) = unique_old_words.get(new_word).copied() { - if old_word_end_ix.saturating_sub(last_old_word_end_ix) - > MIN_DIFF_LEN + while let Some(message) = messages.next().await { + smol::future::yield_now().await; + let mut message = message?; + if let Some(choice) = message.choices.pop() { + if let Some(text) = choice.delta.content { + new_text.push_str(&text); + + println!("-------------------------------------"); + + println!( + "{}", + similar::TextDiff::from_words(&selected_text, &new_text) + .unified_diff() + ); + + let mut changes = + similar::TextDiff::from_words(&selected_text, &new_text) + .iter_all_changes() + .collect::>(); + + let mut ix = 0; + while ix < changes.len() { + let deletion_start_ix = ix; + let mut deletion_end_ix = ix; + while changes + .get(ix) + .map_or(false, |change| change.tag() == ChangeTag::Delete) + { + ix += 1; + deletion_end_ix += 1; + } + + let insertion_start_ix = ix; + let mut insertion_end_ix = ix; + while changes + .get(ix) + .map_or(false, |change| change.tag() == ChangeTag::Insert) + { + ix += 1; + insertion_end_ix += 1; + } + + if deletion_end_ix > deletion_start_ix + && insertion_end_ix > insertion_start_ix + { + for _ in deletion_start_ix..deletion_end_ix { + let deletion = changes.remove(deletion_end_ix); + changes.insert(insertion_end_ix - 1, deletion); + } + } + + ix += 1; + } + + while changes + .last() + .map_or(false, |change| change.tag() != ChangeTag::Insert) { - drop(words); - - let remainder = new_text.split_off(start + range.end); - let edits = diff( - selection_start + last_old_word_end_ix, - &selected_text[last_old_word_end_ix..old_word_end_ix], - &new_text, - &buffer, - ); - editor.update(&mut cx, |editor, cx| { - editor - .buffer() - .update(cx, |buffer, cx| buffer.edit(edits, None, cx)) - })?; - - new_text = remainder; - new_word_search_start_ix = 0; - last_old_word_end_ix = old_word_end_ix; - continue 'outer; + changes.pop(); } - } - new_word_search_start_ix = start + range.end; - } - drop(words); + editor.update(&mut cx, |editor, cx| { + editor.buffer().update(cx, |buffer, cx| { + if let Some(transaction) = transaction.take() { + buffer.undo(cx); // TODO: Undo the transaction instead + } - // Buffer incoming text, stopping if the stream was exhausted. - if let Some(message) = messages.next().await { - let mut message = message?; - if let Some(choice) = message.choices.pop() { - if let Some(text) = choice.delta.content { - new_text.push_str(&text); - } + buffer.start_transaction(cx); + let mut edit_start = selection_start; + dbg!(&changes); + for change in changes { + let value = change.value(); + let edit_end = edit_start + value.len(); + match change.tag() { + ChangeTag::Equal => { + edit_start = edit_end; + } + ChangeTag::Delete => { + let range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + buffer.edit([(range, "")], None, cx); + edit_start = edit_end; + } + ChangeTag::Insert => { + let insertion_start = + snapshot.anchor_after(edit_start); + buffer.edit( + [(insertion_start..insertion_start, value)], + None, + cx, + ); + } + } + } + transaction = buffer.end_transaction(cx); + }) + })?; } - } else { - break; } } - let edits = diff( - selection_start + last_old_word_end_ix, - &selected_text[last_old_word_end_ix..], - &new_text, - &buffer, - ); editor.update(&mut cx, |editor, cx| { - editor - .buffer() - .update(cx, |buffer, cx| buffer.edit(edits, None, cx)) + editor.buffer().update(cx, |buffer, cx| { + if let Some(transaction) = transaction.take() { + buffer.undo(cx); // TODO: Undo the transaction instead + } + + buffer.start_transaction(cx); + let mut edit_start = selection_start; + for change in similar::TextDiff::from_words(&selected_text, &new_text) + .iter_all_changes() + { + let value = change.value(); + let edit_end = edit_start + value.len(); + match change.tag() { + ChangeTag::Equal => { + edit_start = edit_end; + } + ChangeTag::Delete => { + let range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + buffer.edit([(range, "")], None, cx); + edit_start = edit_end; + } + ChangeTag::Insert => { + let insertion_start = snapshot.anchor_after(edit_start); + buffer.edit( + [(insertion_start..insertion_start, value)], + None, + cx, + ); + } + } + } + buffer.end_transaction(cx); + }) })?; anyhow::Ok(()) @@ -197,11 +259,13 @@ impl RefactoringModal { { workspace.toggle_modal(cx, |_, cx| { let prompt_editor = cx.add_view(|cx| { - Editor::auto_height( + let mut editor = Editor::auto_height( 4, Some(Arc::new(|theme| theme.search.editor.input.clone())), cx, - ) + ); + editor.set_text("Replace with match statement.", cx); + editor }); cx.add_view(|_| RefactoringModal { editor, @@ -242,38 +306,97 @@ fn words(text: &str) -> impl Iterator, &str)> { }) } -fn diff<'a>( - start_ix: usize, - old_text: &'a str, - new_text: &'a str, - old_buffer_snapshot: &MultiBufferSnapshot, -) -> Vec<(Range, &'a str)> { - let mut edit_start = start_ix; - let mut edits = Vec::new(); - let diff = similar::TextDiff::from_words(old_text, &new_text); - for change in diff.iter_all_changes() { - let value = change.value(); - let edit_end = edit_start + value.len(); - match change.tag() { - ChangeTag::Equal => { - edit_start = edit_end; - } - ChangeTag::Delete => { - edits.push(( - old_buffer_snapshot.anchor_after(edit_start) - ..old_buffer_snapshot.anchor_before(edit_end), - "", - )); - edit_start = edit_end; - } - ChangeTag::Insert => { - edits.push(( - old_buffer_snapshot.anchor_after(edit_start) - ..old_buffer_snapshot.anchor_after(edit_start), - value, - )); - } +fn streaming_diff<'a>(old_text: &'a str, new_text: &'a str) -> Vec> { + let changes = TextDiff::configure() + .algorithm(similar::Algorithm::Patience) + .diff_words(old_text, new_text); + let mut changes = changes.iter_all_changes().peekable(); + + let mut result = vec![]; + + loop { + let mut deletions = vec![]; + let mut insertions = vec![]; + + while changes + .peek() + .map_or(false, |change| change.tag() == ChangeTag::Delete) + { + deletions.push(changes.next().unwrap()); + } + + while changes + .peek() + .map_or(false, |change| change.tag() == ChangeTag::Insert) + { + insertions.push(changes.next().unwrap()); + } + + if !deletions.is_empty() && !insertions.is_empty() { + result.append(&mut insertions); + result.append(&mut deletions); + } else { + result.append(&mut deletions); + result.append(&mut insertions); + } + + if let Some(change) = changes.next() { + result.push(change); + } else { + break; } } - edits + + // Remove all non-inserts at the end. + while result + .last() + .map_or(false, |change| change.tag() != ChangeTag::Insert) + { + result.pop(); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + + #[test] + fn test_streaming_diff() { + let old_text = indoc! {" + match (self.format, src_format) { + (Format::A8, Format::A8) + | (Format::Rgb24, Format::Rgb24) + | (Format::Rgba32, Format::Rgba32) => { + return self + .blit_from_with::(dst_rect, src_bytes, src_stride, src_format); + } + (Format::A8, Format::Rgb24) => { + return self + .blit_from_with::(dst_rect, src_bytes, src_stride, src_format); + } + (Format::Rgb24, Format::A8) => { + return self + .blit_from_with::(dst_rect, src_bytes, src_stride, src_format); + } + (Format::Rgb24, Format::Rgba32) => { + return self.blit_from_with::( + dst_rect, src_bytes, src_stride, src_format, + ); + } + (Format::Rgba32, Format::Rgb24) + | (Format::Rgba32, Format::A8) + | (Format::A8, Format::Rgba32) => { + unimplemented!() + } + _ => {} + } + "}; + let new_text = indoc! {" + if self.format == src_format + "}; + dbg!(streaming_diff(old_text, new_text)); + } } From 1ae5a909cdc3f4a0db34951b26555602626736e9 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 22 Aug 2023 12:07:41 +0200 Subject: [PATCH 05/60] Start on a custom diff implementation --- crates/ai/src/ai.rs | 1 + crates/ai/src/diff.rs | 180 ++++++++++++++++++++++++++++++++++++++ crates/ai/src/refactor.rs | 43 --------- 3 files changed, 181 insertions(+), 43 deletions(-) create mode 100644 crates/ai/src/diff.rs diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 511e7fddd7..52f31d2f56 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -1,5 +1,6 @@ pub mod assistant; mod assistant_settings; +mod diff; mod refactor; use anyhow::{anyhow, Result}; diff --git a/crates/ai/src/diff.rs b/crates/ai/src/diff.rs new file mode 100644 index 0000000000..b70aa40b62 --- /dev/null +++ b/crates/ai/src/diff.rs @@ -0,0 +1,180 @@ +use std::{ + cmp, + fmt::{self, Debug}, +}; + +use collections::BinaryHeap; + +struct Matrix { + cells: Vec, + rows: usize, + cols: usize, +} + +impl Matrix { + fn new() -> Self { + Self { + cells: Vec::new(), + rows: 0, + cols: 0, + } + } + + fn resize(&mut self, rows: usize, cols: usize) { + self.cells.resize(rows * cols, 0); + self.rows = rows; + self.cols = cols; + } + + fn get(&self, row: usize, col: usize) -> isize { + if row >= self.rows { + panic!("row out of bounds") + } + + if col >= self.cols { + panic!("col out of bounds") + } + self.cells[col * self.rows + row] + } + + fn set(&mut self, row: usize, col: usize, value: isize) { + if row >= self.rows { + panic!("row out of bounds") + } + + if col >= self.cols { + panic!("col out of bounds") + } + + self.cells[col * self.rows + row] = value; + } +} + +impl Debug for Matrix { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + writeln!(f)?; + for i in 0..self.rows { + for j in 0..self.cols { + write!(f, "{:5}", self.get(i, j))?; + } + writeln!(f)?; + } + Ok(()) + } +} + +#[derive(Debug)] +enum Hunk { + Insert(char), + Remove(char), + Keep(char), +} + +struct Diff { + old: String, + new: String, + scores: Matrix, + last_diff_row: usize, +} + +impl Diff { + fn new(old: String) -> Self { + let mut scores = Matrix::new(); + scores.resize(old.len() + 1, 1); + for i in 0..=old.len() { + scores.set(i, 0, -(i as isize)); + } + dbg!(&scores); + Self { + old, + new: String::new(), + scores, + last_diff_row: 0, + } + } + + fn push_new(&mut self, text: &str) -> Vec { + let last_diff_column = self.new.len(); + self.new.push_str(text); + self.scores.resize(self.old.len() + 1, self.new.len() + 1); + + for j in last_diff_column + 1..=self.new.len() { + self.scores.set(0, j, -(j as isize)); + for i in 1..=self.old.len() { + let insertion_score = self.scores.get(i, j - 1) - 1; + let deletion_score = self.scores.get(i - 1, j) - 10; + let equality_score = if self.old.as_bytes()[i - 1] == self.new.as_bytes()[j - 1] { + self.scores.get(i - 1, j - 1) + 5 + } else { + self.scores.get(i - 1, j - 1) - 20 + }; + let score = insertion_score.max(deletion_score).max(equality_score); + self.scores.set(i, j, score); + } + } + + let mut max_score = isize::MIN; + let mut best_row = self.last_diff_row; + for i in self.last_diff_row..=self.old.len() { + let score = self.scores.get(i, self.new.len()); + if score > max_score { + max_score = score; + best_row = i; + } + } + + let mut hunks = Vec::new(); + let mut i = best_row; + let mut j = self.new.len(); + while (i, j) != (self.last_diff_row, last_diff_column) { + let insertion_score = if j > last_diff_column { + Some((i, j - 1)) + } else { + None + }; + let deletion_score = if i > self.last_diff_row { + Some((i - 1, j)) + } else { + None + }; + let equality_score = if i > self.last_diff_row && j > last_diff_column { + Some((i - 1, j - 1)) + } else { + None + }; + + let (prev_i, prev_j) = [insertion_score, deletion_score, equality_score] + .iter() + .max_by_key(|cell| cell.map(|(i, j)| self.scores.get(i, j))) + .unwrap() + .unwrap(); + + if prev_i == i && prev_j == j - 1 { + hunks.push(Hunk::Insert(self.new.chars().skip(j - 1).next().unwrap())); + } else if prev_i == i - 1 && prev_j == j { + hunks.push(Hunk::Remove(self.old.chars().skip(i - 1).next().unwrap())); + } else { + hunks.push(Hunk::Keep(self.old.chars().skip(i - 1).next().unwrap())); + } + + i = prev_i; + j = prev_j; + } + self.last_diff_row = best_row; + hunks.reverse(); + hunks + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_diff() { + let mut diff = Diff::new("hello world".to_string()); + dbg!(diff.push_new("hello")); + dbg!(diff.push_new(" ciaone")); + dbg!(diff.push_new(" world")); + } +} diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index 1923ef7845..5bd1b5dcca 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -357,46 +357,3 @@ fn streaming_diff<'a>(old_text: &'a str, new_text: &'a str) -> Vec { - return self - .blit_from_with::(dst_rect, src_bytes, src_stride, src_format); - } - (Format::A8, Format::Rgb24) => { - return self - .blit_from_with::(dst_rect, src_bytes, src_stride, src_format); - } - (Format::Rgb24, Format::A8) => { - return self - .blit_from_with::(dst_rect, src_bytes, src_stride, src_format); - } - (Format::Rgb24, Format::Rgba32) => { - return self.blit_from_with::( - dst_rect, src_bytes, src_stride, src_format, - ); - } - (Format::Rgba32, Format::Rgb24) - | (Format::Rgba32, Format::A8) - | (Format::A8, Format::Rgba32) => { - unimplemented!() - } - _ => {} - } - "}; - let new_text = indoc! {" - if self.format == src_format - "}; - dbg!(streaming_diff(old_text, new_text)); - } -} From 69b69678381cdd3d5ec0f0cb05941c13362af45f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 22 Aug 2023 13:59:45 +0200 Subject: [PATCH 06/60] Integrate the new diff algorithm into the modal assistant --- crates/ai/src/diff.rs | 72 +++++++---- crates/ai/src/refactor.rs | 264 ++++++++------------------------------ 2 files changed, 101 insertions(+), 235 deletions(-) diff --git a/crates/ai/src/diff.rs b/crates/ai/src/diff.rs index b70aa40b62..5e73c94ff8 100644 --- a/crates/ai/src/diff.rs +++ b/crates/ai/src/diff.rs @@ -64,41 +64,40 @@ impl Debug for Matrix { } #[derive(Debug)] -enum Hunk { - Insert(char), - Remove(char), - Keep(char), +pub enum Hunk { + Insert { len: usize }, + Remove { len: usize }, + Keep { len: usize }, } -struct Diff { +pub struct Diff { old: String, new: String, scores: Matrix, - last_diff_row: usize, + old_text_ix: usize, } impl Diff { - fn new(old: String) -> Self { + pub fn new(old: String) -> Self { let mut scores = Matrix::new(); scores.resize(old.len() + 1, 1); for i in 0..=old.len() { scores.set(i, 0, -(i as isize)); } - dbg!(&scores); Self { old, new: String::new(), scores, - last_diff_row: 0, + old_text_ix: 0, } } - fn push_new(&mut self, text: &str) -> Vec { - let last_diff_column = self.new.len(); + pub fn push_new(&mut self, text: &str) -> Vec { + let new_text_ix = self.new.len(); self.new.push_str(text); self.scores.resize(self.old.len() + 1, self.new.len() + 1); - for j in last_diff_column + 1..=self.new.len() { + for j in new_text_ix + 1..=self.new.len() { self.scores.set(0, j, -(j as isize)); for i in 1..=self.old.len() { let insertion_score = self.scores.get(i, j - 1) - 1; @@ -114,8 +113,8 @@ impl Diff { } let mut max_score = isize::MIN; - let mut best_row = self.last_diff_row; - for i in self.last_diff_row..=self.old.len() { + let mut best_row = self.old_text_ix; + for i in self.old_text_ix..=self.old.len() { let score = self.scores.get(i, self.new.len()); if score > max_score { max_score = score; @@ -126,18 +125,18 @@ impl Diff { let mut hunks = Vec::new(); let mut i = best_row; let mut j = self.new.len(); - while (i, j) != (self.last_diff_row, last_diff_column) { - let insertion_score = if j > last_diff_column { + while (i, j) != (self.old_text_ix, new_text_ix) { + let insertion_score = if j > new_text_ix { Some((i, j - 1)) } else { None }; - let deletion_score = if i > self.last_diff_row { + let deletion_score = if i > self.old_text_ix { Some((i - 1, j)) } else { None }; - let equality_score = if i > self.last_diff_row && j > last_diff_column { + let equality_score = if i > self.old_text_ix && j > new_text_ix { Some((i - 1, j - 1)) } else { None @@ -150,20 +149,42 @@ impl Diff { .unwrap(); if prev_i == i && prev_j == j - 1 { - hunks.push(Hunk::Insert(self.new.chars().skip(j - 1).next().unwrap())); + if let Some(Hunk::Insert { len }) = hunks.last_mut() { + *len += 1; + } else { + hunks.push(Hunk::Insert { len: 1 }) + } } else if prev_i == i - 1 && prev_j == j { - hunks.push(Hunk::Remove(self.old.chars().skip(i - 1).next().unwrap())); + if let Some(Hunk::Remove { len }) = hunks.last_mut() { + *len += 1; + } else { + hunks.push(Hunk::Remove { len: 1 }) + } } else { - hunks.push(Hunk::Keep(self.old.chars().skip(i - 1).next().unwrap())); + if let Some(Hunk::Keep { len }) = hunks.last_mut() { + *len += 1; + } else { + hunks.push(Hunk::Keep { len: 1 }) + } } i = prev_i; j = prev_j; } - self.last_diff_row = best_row; + self.old_text_ix = best_row; hunks.reverse(); hunks } + + pub fn finish(self) -> Option { + if self.old_text_ix < self.old.len() { + Some(Hunk::Remove { + len: self.old.len() - self.old_text_ix, + }) + } else { + None + } + } } #[cfg(test)] @@ -173,8 +194,9 @@ mod tests { #[test] fn test_diff() { let mut diff = Diff::new("hello world".to_string()); - dbg!(diff.push_new("hello")); - dbg!(diff.push_new(" ciaone")); - dbg!(diff.push_new(" world")); + diff.push_new("hello"); + diff.push_new(" ciaone"); + diff.push_new(" world"); + diff.finish(); } } diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index 5bd1b5dcca..dcec04deef 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -1,7 +1,7 @@ use crate::{stream_completion, OpenAIRequest, RequestMessage, Role}; use collections::HashMap; use editor::{Editor, ToOffset}; -use futures::StreamExt; +use futures::{channel::mpsc, SinkExt, StreamExt}; use gpui::{ actions, elements::*, AnyViewHandle, AppContext, Entity, Task, View, ViewContext, ViewHandle, WeakViewHandle, @@ -59,151 +59,67 @@ impl RefactoringAssistant { editor.id(), cx.spawn(|mut cx| { async move { - let selection_start = selection.start.to_offset(&snapshot); + let mut edit_start = selection.start.to_offset(&snapshot); - let mut new_text = String::new(); - let mut messages = response.await?; + let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); + let diff = cx.background().spawn(async move { + let mut messages = response.await?.ready_chunks(4); + let mut diff = crate::diff::Diff::new(selected_text); - let mut transaction = None; - - while let Some(message) = messages.next().await { - smol::future::yield_now().await; - let mut message = message?; - if let Some(choice) = message.choices.pop() { - if let Some(text) = choice.delta.content { - new_text.push_str(&text); - - println!("-------------------------------------"); - - println!( - "{}", - similar::TextDiff::from_words(&selected_text, &new_text) - .unified_diff() - ); - - let mut changes = - similar::TextDiff::from_words(&selected_text, &new_text) - .iter_all_changes() - .collect::>(); - - let mut ix = 0; - while ix < changes.len() { - let deletion_start_ix = ix; - let mut deletion_end_ix = ix; - while changes - .get(ix) - .map_or(false, |change| change.tag() == ChangeTag::Delete) - { - ix += 1; - deletion_end_ix += 1; + while let Some(messages) = messages.next().await { + let mut new_text = String::new(); + for message in messages { + let mut message = message?; + if let Some(choice) = message.choices.pop() { + if let Some(text) = choice.delta.content { + new_text.push_str(&text); } - - let insertion_start_ix = ix; - let mut insertion_end_ix = ix; - while changes - .get(ix) - .map_or(false, |change| change.tag() == ChangeTag::Insert) - { - ix += 1; - insertion_end_ix += 1; - } - - if deletion_end_ix > deletion_start_ix - && insertion_end_ix > insertion_start_ix - { - for _ in deletion_start_ix..deletion_end_ix { - let deletion = changes.remove(deletion_end_ix); - changes.insert(insertion_end_ix - 1, deletion); - } - } - - ix += 1; } - - while changes - .last() - .map_or(false, |change| change.tag() != ChangeTag::Insert) - { - changes.pop(); - } - - editor.update(&mut cx, |editor, cx| { - editor.buffer().update(cx, |buffer, cx| { - if let Some(transaction) = transaction.take() { - buffer.undo(cx); // TODO: Undo the transaction instead - } - - buffer.start_transaction(cx); - let mut edit_start = selection_start; - dbg!(&changes); - for change in changes { - let value = change.value(); - let edit_end = edit_start + value.len(); - match change.tag() { - ChangeTag::Equal => { - edit_start = edit_end; - } - ChangeTag::Delete => { - let range = snapshot.anchor_after(edit_start) - ..snapshot.anchor_before(edit_end); - buffer.edit([(range, "")], None, cx); - edit_start = edit_end; - } - ChangeTag::Insert => { - let insertion_start = - snapshot.anchor_after(edit_start); - buffer.edit( - [(insertion_start..insertion_start, value)], - None, - cx, - ); - } - } - } - transaction = buffer.end_transaction(cx); - }) - })?; } + + let hunks = diff.push_new(&new_text); + hunks_tx.send((hunks, new_text)).await?; } + + if let Some(hunk) = diff.finish() { + hunks_tx.send((vec![hunk], String::new())).await?; + } + + anyhow::Ok(()) + }); + + while let Some((hunks, new_text)) = hunks_rx.next().await { + editor.update(&mut cx, |editor, cx| { + editor.buffer().update(cx, |buffer, cx| { + buffer.start_transaction(cx); + let mut new_text_ix = 0; + for hunk in hunks { + match hunk { + crate::diff::Hunk::Insert { len } => { + let text = &new_text[new_text_ix..new_text_ix + len]; + let edit_start = snapshot.anchor_after(edit_start); + buffer.edit([(edit_start..edit_start, text)], None, cx); + new_text_ix += len; + } + crate::diff::Hunk::Remove { len } => { + let edit_end = edit_start + len; + let edit_range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + buffer.edit([(edit_range, "")], None, cx); + edit_start = edit_end; + } + crate::diff::Hunk::Keep { len } => { + edit_start += len; + new_text_ix += len; + } + } + } + buffer.end_transaction(cx); + }) + })?; } - editor.update(&mut cx, |editor, cx| { - editor.buffer().update(cx, |buffer, cx| { - if let Some(transaction) = transaction.take() { - buffer.undo(cx); // TODO: Undo the transaction instead - } - - buffer.start_transaction(cx); - let mut edit_start = selection_start; - for change in similar::TextDiff::from_words(&selected_text, &new_text) - .iter_all_changes() - { - let value = change.value(); - let edit_end = edit_start + value.len(); - match change.tag() { - ChangeTag::Equal => { - edit_start = edit_end; - } - ChangeTag::Delete => { - let range = snapshot.anchor_after(edit_start) - ..snapshot.anchor_before(edit_end); - buffer.edit([(range, "")], None, cx); - edit_start = edit_end; - } - ChangeTag::Insert => { - let insertion_start = snapshot.anchor_after(edit_start); - buffer.edit( - [(insertion_start..insertion_start, value)], - None, - cx, - ); - } - } - } - buffer.end_transaction(cx); - }) - })?; - + diff.await?; anyhow::Ok(()) } .log_err() @@ -285,75 +201,3 @@ impl RefactoringModal { } } } -fn words(text: &str) -> impl Iterator, &str)> { - let mut word_start_ix = None; - let mut chars = text.char_indices(); - iter::from_fn(move || { - while let Some((ix, ch)) = chars.next() { - if let Some(start_ix) = word_start_ix { - if !ch.is_alphanumeric() { - let word = &text[start_ix..ix]; - word_start_ix.take(); - return Some((start_ix..ix, word)); - } - } else { - if ch.is_alphanumeric() { - word_start_ix = Some(ix); - } - } - } - None - }) -} - -fn streaming_diff<'a>(old_text: &'a str, new_text: &'a str) -> Vec> { - let changes = TextDiff::configure() - .algorithm(similar::Algorithm::Patience) - .diff_words(old_text, new_text); - let mut changes = changes.iter_all_changes().peekable(); - - let mut result = vec![]; - - loop { - let mut deletions = vec![]; - let mut insertions = vec![]; - - while changes - .peek() - .map_or(false, |change| change.tag() == ChangeTag::Delete) - { - deletions.push(changes.next().unwrap()); - } - - while changes - .peek() - .map_or(false, |change| change.tag() == ChangeTag::Insert) - { - insertions.push(changes.next().unwrap()); - } - - if !deletions.is_empty() && !insertions.is_empty() { - result.append(&mut insertions); - result.append(&mut deletions); - } else { - result.append(&mut deletions); - result.append(&mut insertions); - } - - if let Some(change) = changes.next() { - result.push(change); - } else { - break; - } - } - - // Remove all non-inserts at the end. - while result - .last() - .map_or(false, |change| change.tag() != ChangeTag::Insert) - { - result.pop(); - } - - result -} From 3a511db5c985c0b7bb129f9b15347a4a93dd95ca Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 22 Aug 2023 18:41:22 +0200 Subject: [PATCH 07/60] :art: --- crates/ai/src/diff.rs | 85 +++++++++++++++++++++++---------------- crates/ai/src/refactor.rs | 12 ++---- 2 files changed, 53 insertions(+), 44 deletions(-) diff --git a/crates/ai/src/diff.rs b/crates/ai/src/diff.rs index 5e73c94ff8..1b5b4cbd20 100644 --- a/crates/ai/src/diff.rs +++ b/crates/ai/src/diff.rs @@ -65,7 +65,7 @@ impl Debug for Matrix { #[derive(Debug)] pub enum Hunk { - Insert { len: usize }, + Insert { text: String }, Remove { len: usize }, Keep { len: usize }, } @@ -75,37 +75,42 @@ pub struct Diff { new: String, scores: Matrix, old_text_ix: usize, + new_text_ix: usize, } impl Diff { + const INSERTION_SCORE: isize = -1; + const DELETION_SCORE: isize = -4; + const EQUALITY_SCORE: isize = 5; + pub fn new(old: String) -> Self { let mut scores = Matrix::new(); scores.resize(old.len() + 1, 1); for i in 0..=old.len() { - scores.set(i, 0, -(i as isize)); + scores.set(i, 0, i as isize * Self::DELETION_SCORE); } Self { old, new: String::new(), scores, old_text_ix: 0, + new_text_ix: 0, } } pub fn push_new(&mut self, text: &str) -> Vec { - let new_text_ix = self.new.len(); self.new.push_str(text); self.scores.resize(self.old.len() + 1, self.new.len() + 1); - for j in new_text_ix + 1..=self.new.len() { - self.scores.set(0, j, -(j as isize)); + for j in self.new_text_ix + 1..=self.new.len() { + self.scores.set(0, j, j as isize * Self::INSERTION_SCORE); for i in 1..=self.old.len() { - let insertion_score = self.scores.get(i, j - 1) - 1; - let deletion_score = self.scores.get(i - 1, j) - 10; + let insertion_score = self.scores.get(i, j - 1) + Self::INSERTION_SCORE; + let deletion_score = self.scores.get(i - 1, j) + Self::DELETION_SCORE; let equality_score = if self.old.as_bytes()[i - 1] == self.new.as_bytes()[j - 1] { - self.scores.get(i - 1, j - 1) + 5 + self.scores.get(i - 1, j - 1) + Self::EQUALITY_SCORE } else { - self.scores.get(i - 1, j - 1) - 20 + isize::MIN }; let score = insertion_score.max(deletion_score).max(equality_score); self.scores.set(i, j, score); @@ -114,19 +119,30 @@ impl Diff { let mut max_score = isize::MIN; let mut best_row = self.old_text_ix; + let mut best_col = self.new_text_ix; for i in self.old_text_ix..=self.old.len() { - let score = self.scores.get(i, self.new.len()); - if score > max_score { - max_score = score; - best_row = i; + for j in self.new_text_ix..=self.new.len() { + let score = self.scores.get(i, j); + if score > max_score { + max_score = score; + best_row = i; + best_col = j; + } } } + let hunks = self.backtrack(best_row, best_col); + self.old_text_ix = best_row; + self.new_text_ix = best_col; + hunks + } + + fn backtrack(&self, old_text_ix: usize, new_text_ix: usize) -> Vec { let mut hunks = Vec::new(); - let mut i = best_row; - let mut j = self.new.len(); - while (i, j) != (self.old_text_ix, new_text_ix) { - let insertion_score = if j > new_text_ix { + let mut i = old_text_ix; + let mut j = new_text_ix; + while (i, j) != (self.old_text_ix, self.new_text_ix) { + let insertion_score = if j > self.new_text_ix { Some((i, j - 1)) } else { None @@ -136,8 +152,12 @@ impl Diff { } else { None }; - let equality_score = if i > self.old_text_ix && j > new_text_ix { - Some((i - 1, j - 1)) + let equality_score = if i > self.old_text_ix && j > self.new_text_ix { + if self.old.as_bytes()[i - 1] == self.new.as_bytes()[j - 1] { + Some((i - 1, j - 1)) + } else { + None + } } else { None }; @@ -149,10 +169,12 @@ impl Diff { .unwrap(); if prev_i == i && prev_j == j - 1 { - if let Some(Hunk::Insert { len }) = hunks.last_mut() { - *len += 1; + if let Some(Hunk::Insert { text }) = hunks.last_mut() { + text.insert_str(0, &self.new[prev_j..j]); } else { - hunks.push(Hunk::Insert { len: 1 }) + hunks.push(Hunk::Insert { + text: self.new[prev_j..j].to_string(), + }) } } else if prev_i == i - 1 && prev_j == j { if let Some(Hunk::Remove { len }) = hunks.last_mut() { @@ -171,19 +193,12 @@ impl Diff { i = prev_i; j = prev_j; } - self.old_text_ix = best_row; hunks.reverse(); hunks } - pub fn finish(self) -> Option { - if self.old_text_ix < self.old.len() { - Some(Hunk::Remove { - len: self.old.len() - self.old_text_ix, - }) - } else { - None - } + pub fn finish(self) -> Vec { + self.backtrack(self.old.len(), self.new.len()) } } @@ -194,9 +209,9 @@ mod tests { #[test] fn test_diff() { let mut diff = Diff::new("hello world".to_string()); - diff.push_new("hello"); - diff.push_new(" ciaone"); - diff.push_new(" world"); - diff.finish(); + dbg!(diff.push_new("hello")); + dbg!(diff.push_new(" ciaone")); + // dbg!(diff.push_new(" world")); + dbg!(diff.finish()); } } diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index dcec04deef..87f7495fcf 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -47,7 +47,7 @@ impl RefactoringAssistant { RequestMessage { role: Role::User, content: format!( - "Given the following {language_name} snippet:\n{selected_text}\n{prompt}. Avoid making remarks and reply only with the new code. Preserve indentation." + "Given the following {language_name} snippet:\n{selected_text}\n{prompt}. Never make remarks and reply only with the new code. Never change the leading whitespace on each line." ), }], stream: true, @@ -81,9 +81,7 @@ impl RefactoringAssistant { hunks_tx.send((hunks, new_text)).await?; } - if let Some(hunk) = diff.finish() { - hunks_tx.send((vec![hunk], String::new())).await?; - } + hunks_tx.send((diff.finish(), String::new())).await?; anyhow::Ok(()) }); @@ -92,14 +90,11 @@ impl RefactoringAssistant { editor.update(&mut cx, |editor, cx| { editor.buffer().update(cx, |buffer, cx| { buffer.start_transaction(cx); - let mut new_text_ix = 0; for hunk in hunks { match hunk { - crate::diff::Hunk::Insert { len } => { - let text = &new_text[new_text_ix..new_text_ix + len]; + crate::diff::Hunk::Insert { text } => { let edit_start = snapshot.anchor_after(edit_start); buffer.edit([(edit_start..edit_start, text)], None, cx); - new_text_ix += len; } crate::diff::Hunk::Remove { len } => { let edit_end = edit_start + len; @@ -110,7 +105,6 @@ impl RefactoringAssistant { } crate::diff::Hunk::Keep { len } => { edit_start += len; - new_text_ix += len; } } } From a9871a7a7051c3c4fec4cc1da03f0752e824dc4c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 23 Aug 2023 09:09:01 +0200 Subject: [PATCH 08/60] Add randomized tests for incremental diff --- Cargo.lock | 4 +++ crates/ai/Cargo.toml | 5 +++ crates/ai/src/ai.rs | 8 +++++ crates/ai/src/diff.rs | 69 ++++++++++++++++++++++++++++++++++----- crates/ai/src/refactor.rs | 7 ++-- 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index af16a88596..3283d32a94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,15 +102,19 @@ dependencies = [ "anyhow", "chrono", "collections", + "ctor", "editor", + "env_logger 0.9.3", "fs", "futures 0.3.28", "gpui", "indoc", "isahc", "language", + "log", "menu", "project", + "rand 0.8.5", "regex", "schemars", "search", diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index 5ef371e342..db8772bcb1 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -37,3 +37,8 @@ tiktoken-rs = "0.4" [dev-dependencies] editor = { path = "../editor", features = ["test-support"] } project = { path = "../project", features = ["test-support"] } + +ctor.workspace = true +env_logger.workspace = true +log.workspace = true +rand.workspace = true diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 52f31d2f56..bad153879f 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -283,3 +283,11 @@ pub async fn stream_completion( } } } + +#[cfg(test)] +#[ctor::ctor] +fn init_logger() { + if std::env::var("RUST_LOG").is_ok() { + env_logger::init(); + } +} diff --git a/crates/ai/src/diff.rs b/crates/ai/src/diff.rs index 1b5b4cbd20..355748ea3d 100644 --- a/crates/ai/src/diff.rs +++ b/crates/ai/src/diff.rs @@ -204,14 +204,67 @@ impl Diff { #[cfg(test)] mod tests { - use super::*; + use std::env; - #[test] - fn test_diff() { - let mut diff = Diff::new("hello world".to_string()); - dbg!(diff.push_new("hello")); - dbg!(diff.push_new(" ciaone")); - // dbg!(diff.push_new(" world")); - dbg!(diff.finish()); + use super::*; + use rand::prelude::*; + + #[gpui::test(iterations = 100)] + fn test_random_diffs(mut rng: StdRng) { + let old_text_len = env::var("OLD_TEXT_LEN") + .map(|i| i.parse().expect("invalid `OLD_TEXT_LEN` variable")) + .unwrap_or(10); + let new_text_len = env::var("NEW_TEXT_LEN") + .map(|i| i.parse().expect("invalid `NEW_TEXT_LEN` variable")) + .unwrap_or(10); + + let old = util::RandomCharIter::new(&mut rng) + .take(old_text_len) + .collect::(); + log::info!("old text: {:?}", old); + + let mut diff = Diff::new(old.clone()); + let mut hunks = Vec::new(); + let mut new_len = 0; + let mut new = String::new(); + while new_len < new_text_len { + let new_chunk_len = rng.gen_range(1..=new_text_len - new_len); + let new_chunk = util::RandomCharIter::new(&mut rng) + .take(new_len) + .collect::(); + log::info!("new chunk: {:?}", new_chunk); + new_len += new_chunk_len; + new.push_str(&new_chunk); + let new_hunks = diff.push_new(&new_chunk); + log::info!("hunks: {:?}", new_hunks); + hunks.extend(new_hunks); + } + let final_hunks = diff.finish(); + log::info!("final hunks: {:?}", final_hunks); + hunks.extend(final_hunks); + + log::info!("new text: {:?}", new); + let mut old_ix = 0; + let mut new_ix = 0; + let mut patched = String::new(); + for hunk in hunks { + match hunk { + Hunk::Keep { len } => { + assert_eq!(&old[old_ix..old_ix + len], &new[new_ix..new_ix + len]); + patched.push_str(&old[old_ix..old_ix + len]); + old_ix += len; + new_ix += len; + } + Hunk::Remove { len } => { + old_ix += len; + } + Hunk::Insert { text } => { + assert_eq!(text, &new[new_ix..new_ix + text.len()]); + patched.push_str(&text); + new_ix += text.len(); + } + } + } + assert_eq!(patched, new); } } diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index 87f7495fcf..d68d8ce4ed 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -78,15 +78,14 @@ impl RefactoringAssistant { } let hunks = diff.push_new(&new_text); - hunks_tx.send((hunks, new_text)).await?; + hunks_tx.send(hunks).await?; } - - hunks_tx.send((diff.finish(), String::new())).await?; + hunks_tx.send(diff.finish()).await?; anyhow::Ok(()) }); - while let Some((hunks, new_text)) = hunks_rx.next().await { + while let Some(hunks) = hunks_rx.next().await { editor.update(&mut cx, |editor, cx| { editor.buffer().update(cx, |buffer, cx| { buffer.start_transaction(cx); From c2935056e8cf79ae6ce1479a3cb8eaee60b9d174 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 23 Aug 2023 09:32:38 +0200 Subject: [PATCH 09/60] Support multi-byte characters in diff --- crates/ai/src/diff.rs | 57 ++++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/crates/ai/src/diff.rs b/crates/ai/src/diff.rs index 355748ea3d..c68f9b3d5d 100644 --- a/crates/ai/src/diff.rs +++ b/crates/ai/src/diff.rs @@ -1,6 +1,7 @@ use std::{ cmp, fmt::{self, Debug}, + ops::Range, }; use collections::BinaryHeap; @@ -71,8 +72,8 @@ pub enum Hunk { } pub struct Diff { - old: String, - new: String, + old: Vec, + new: Vec, scores: Matrix, old_text_ix: usize, new_text_ix: usize, @@ -84,6 +85,7 @@ impl Diff { const EQUALITY_SCORE: isize = 5; pub fn new(old: String) -> Self { + let old = old.chars().collect::>(); let mut scores = Matrix::new(); scores.resize(old.len() + 1, 1); for i in 0..=old.len() { @@ -91,7 +93,7 @@ impl Diff { } Self { old, - new: String::new(), + new: Vec::new(), scores, old_text_ix: 0, new_text_ix: 0, @@ -99,7 +101,7 @@ impl Diff { } pub fn push_new(&mut self, text: &str) -> Vec { - self.new.push_str(text); + self.new.extend(text.chars()); self.scores.resize(self.old.len() + 1, self.new.len() + 1); for j in self.new_text_ix + 1..=self.new.len() { @@ -107,7 +109,7 @@ impl Diff { for i in 1..=self.old.len() { let insertion_score = self.scores.get(i, j - 1) + Self::INSERTION_SCORE; let deletion_score = self.scores.get(i - 1, j) + Self::DELETION_SCORE; - let equality_score = if self.old.as_bytes()[i - 1] == self.new.as_bytes()[j - 1] { + let equality_score = if self.old[i - 1] == self.new[j - 1] { self.scores.get(i - 1, j - 1) + Self::EQUALITY_SCORE } else { isize::MIN @@ -138,6 +140,7 @@ impl Diff { } fn backtrack(&self, old_text_ix: usize, new_text_ix: usize) -> Vec { + let mut pending_insert: Option> = None; let mut hunks = Vec::new(); let mut i = old_text_ix; let mut j = new_text_ix; @@ -153,7 +156,7 @@ impl Diff { None }; let equality_score = if i > self.old_text_ix && j > self.new_text_ix { - if self.old.as_bytes()[i - 1] == self.new.as_bytes()[j - 1] { + if self.old[i - 1] == self.new[j - 1] { Some((i - 1, j - 1)) } else { None @@ -169,30 +172,44 @@ impl Diff { .unwrap(); if prev_i == i && prev_j == j - 1 { - if let Some(Hunk::Insert { text }) = hunks.last_mut() { - text.insert_str(0, &self.new[prev_j..j]); + if let Some(pending_insert) = pending_insert.as_mut() { + pending_insert.start = prev_j; } else { - hunks.push(Hunk::Insert { - text: self.new[prev_j..j].to_string(), - }) - } - } else if prev_i == i - 1 && prev_j == j { - if let Some(Hunk::Remove { len }) = hunks.last_mut() { - *len += 1; - } else { - hunks.push(Hunk::Remove { len: 1 }) + pending_insert = Some(prev_j..j); } } else { - if let Some(Hunk::Keep { len }) = hunks.last_mut() { - *len += 1; + if let Some(range) = pending_insert.take() { + hunks.push(Hunk::Insert { + text: self.new[range].iter().collect(), + }); + } + + let char_len = self.old[i - 1].len_utf8(); + if prev_i == i - 1 && prev_j == j { + if let Some(Hunk::Remove { len }) = hunks.last_mut() { + *len += char_len; + } else { + hunks.push(Hunk::Remove { len: char_len }) + } } else { - hunks.push(Hunk::Keep { len: 1 }) + if let Some(Hunk::Keep { len }) = hunks.last_mut() { + *len += char_len; + } else { + hunks.push(Hunk::Keep { len: char_len }) + } } } i = prev_i; j = prev_j; } + + if let Some(range) = pending_insert.take() { + hunks.push(Hunk::Insert { + text: self.new[range].iter().collect(), + }); + } + hunks.reverse(); hunks } From a93583065b03738d5ededcc6ea919a6ba491a354 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 23 Aug 2023 09:58:41 +0200 Subject: [PATCH 10/60] Delete unused imports --- crates/ai/src/diff.rs | 3 --- crates/ai/src/refactor.rs | 3 +-- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/ai/src/diff.rs b/crates/ai/src/diff.rs index c68f9b3d5d..b6e25e0624 100644 --- a/crates/ai/src/diff.rs +++ b/crates/ai/src/diff.rs @@ -1,11 +1,8 @@ use std::{ - cmp, fmt::{self, Debug}, ops::Range, }; -use collections::BinaryHeap; - struct Matrix { cells: Vec, rows: usize, diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index d68d8ce4ed..245e59a464 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -7,8 +7,7 @@ use gpui::{ WeakViewHandle, }; use menu::Confirm; -use similar::{Change, ChangeTag, TextDiff}; -use std::{env, iter, ops::Range, sync::Arc}; +use std::{env, sync::Arc}; use util::TryFutureExt; use workspace::{Modal, Workspace}; From a2671a29a0a773e30af0a259bb3c1e590c71cecc Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 23 Aug 2023 10:28:43 +0200 Subject: [PATCH 11/60] Highlight text when the diff is the same --- crates/ai/src/refactor.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index 245e59a464..7b2f5a248a 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -86,6 +86,8 @@ impl RefactoringAssistant { while let Some(hunks) = hunks_rx.next().await { editor.update(&mut cx, |editor, cx| { + let mut highlights = Vec::new(); + editor.buffer().update(cx, |buffer, cx| { buffer.start_transaction(cx); for hunk in hunks { @@ -102,16 +104,33 @@ impl RefactoringAssistant { edit_start = edit_end; } crate::diff::Hunk::Keep { len } => { + let edit_end = edit_start + len; + let edit_range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + highlights.push(edit_range); edit_start += len; } } } buffer.end_transaction(cx); - }) + }); + + editor.highlight_text::( + highlights, + gpui::fonts::HighlightStyle { + fade_out: Some(0.6), + ..Default::default() + }, + cx, + ); })?; } diff.await?; + editor.update(&mut cx, |editor, cx| { + editor.clear_text_highlights::(cx); + })?; + anyhow::Ok(()) } .log_err() @@ -172,7 +191,7 @@ impl RefactoringModal { Some(Arc::new(|theme| theme.search.editor.input.clone())), cx, ); - editor.set_text("Replace with match statement.", cx); + editor.set_text("Replace with if statement.", cx); editor }); cx.add_view(|_| RefactoringModal { From aa6d6582fd5ea323a970961b3c16cfb9e5a60c67 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 23 Aug 2023 12:49:55 +0200 Subject: [PATCH 12/60] Add basic styling --- crates/ai/src/refactor.rs | 53 +++++++++++++++++++++--------- crates/theme/src/theme.rs | 10 ++++++ styles/src/style_tree/assistant.ts | 16 +++++++++ 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index 7b2f5a248a..58cd360186 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -3,10 +3,10 @@ use collections::HashMap; use editor::{Editor, ToOffset}; use futures::{channel::mpsc, SinkExt, StreamExt}; use gpui::{ - actions, elements::*, AnyViewHandle, AppContext, Entity, Task, View, ViewContext, ViewHandle, - WeakViewHandle, + actions, elements::*, platform::MouseButton, AnyViewHandle, AppContext, Entity, Task, View, + ViewContext, ViewHandle, WeakViewHandle, }; -use menu::Confirm; +use menu::{Cancel, Confirm}; use std::{env, sync::Arc}; use util::TryFutureExt; use workspace::{Modal, Workspace}; @@ -17,6 +17,7 @@ pub fn init(cx: &mut AppContext) { cx.set_global(RefactoringAssistant::new()); cx.add_action(RefactoringModal::deploy); cx.add_action(RefactoringModal::confirm); + cx.add_action(RefactoringModal::cancel); } pub struct RefactoringAssistant { @@ -139,14 +140,18 @@ impl RefactoringAssistant { } } +enum Event { + Dismissed, +} + struct RefactoringModal { - editor: WeakViewHandle, + active_editor: WeakViewHandle, prompt_editor: ViewHandle, has_focus: bool, } impl Entity for RefactoringModal { - type Event = (); + type Event = Event; } impl View for RefactoringModal { @@ -155,11 +160,24 @@ impl View for RefactoringModal { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - ChildView::new(&self.prompt_editor, cx).into_any() + let theme = theme::current(cx); + + ChildView::new(&self.prompt_editor, cx) + .constrained() + .with_width(theme.assistant.modal.width) + .contained() + .with_style(theme.assistant.modal.container) + .mouse::(0) + .on_click_out(MouseButton::Left, |_, _, cx| cx.emit(Event::Dismissed)) + .on_click_out(MouseButton::Right, |_, _, cx| cx.emit(Event::Dismissed)) + .aligned() + .right() + .into_any() } - fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext) { + fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { self.has_focus = true; + cx.focus(&self.prompt_editor); } fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext) { @@ -173,29 +191,29 @@ impl Modal for RefactoringModal { } fn dismiss_on_event(event: &Self::Event) -> bool { - // TODO - false + matches!(event, Self::Event::Dismissed) } } impl RefactoringModal { fn deploy(workspace: &mut Workspace, _: &Refactor, cx: &mut ViewContext) { - if let Some(editor) = workspace + if let Some(active_editor) = workspace .active_item(cx) .and_then(|item| Some(item.downcast::()?.downgrade())) { workspace.toggle_modal(cx, |_, cx| { let prompt_editor = cx.add_view(|cx| { let mut editor = Editor::auto_height( - 4, - Some(Arc::new(|theme| theme.search.editor.input.clone())), + theme::current(cx).assistant.modal.editor_max_lines, + Some(Arc::new(|theme| theme.assistant.modal.editor.clone())), cx, ); - editor.set_text("Replace with if statement.", cx); + editor + .set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); editor }); cx.add_view(|_| RefactoringModal { - editor, + active_editor, prompt_editor, has_focus: false, }) @@ -203,12 +221,17 @@ impl RefactoringModal { } } + fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { + cx.emit(Event::Dismissed); + } + fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { - if let Some(editor) = self.editor.upgrade(cx) { + if let Some(editor) = self.active_editor.upgrade(cx) { let prompt = self.prompt_editor.read(cx).text(cx); cx.update_global(|assistant: &mut RefactoringAssistant, cx| { assistant.refactor(&editor, &prompt, cx); }); + cx.emit(Event::Dismissed); } } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 80e823632a..a42a893241 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -1124,6 +1124,16 @@ pub struct AssistantStyle { pub api_key_editor: FieldEditor, pub api_key_prompt: ContainedText, pub saved_conversation: SavedConversation, + pub modal: ModalAssistantStyle, +} + +#[derive(Clone, Deserialize, Default, JsonSchema)] +pub struct ModalAssistantStyle { + #[serde(flatten)] + pub container: ContainerStyle, + pub width: f32, + pub editor_max_lines: usize, + pub editor: FieldEditor, } #[derive(Clone, Deserialize, Default, JsonSchema)] diff --git a/styles/src/style_tree/assistant.ts b/styles/src/style_tree/assistant.ts index cfc1f8d813..88efabee1e 100644 --- a/styles/src/style_tree/assistant.ts +++ b/styles/src/style_tree/assistant.ts @@ -59,6 +59,22 @@ export default function assistant(): any { background: background(theme.highest), padding: { left: 12 }, }, + modal: { + background: background(theme.lowest), + border: border(theme.lowest), + shadow: theme.modal_shadow, + corner_radius: 12, + padding: { left: 12, right: 0, top: 12, bottom: 12 }, + margin: { right: 12 }, + width: 500, + editor_max_lines: 6, + editor: { + background: background(theme.lowest), + text: text(theme.lowest, "mono", "on"), + placeholder_text: text(theme.lowest, "sans", "on", "disabled"), + selection: theme.players[0], + } + }, message_header: { margin: { bottom: 4, top: 4 }, background: background(theme.highest), From 2e1a4b25912f158121303a7779b65410cabcea6c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 23 Aug 2023 13:26:30 +0200 Subject: [PATCH 13/60] Adjust scoring --- crates/ai/src/diff.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ai/src/diff.rs b/crates/ai/src/diff.rs index b6e25e0624..0f4f328602 100644 --- a/crates/ai/src/diff.rs +++ b/crates/ai/src/diff.rs @@ -79,7 +79,7 @@ pub struct Diff { impl Diff { const INSERTION_SCORE: isize = -1; const DELETION_SCORE: isize = -4; - const EQUALITY_SCORE: isize = 5; + const EQUALITY_SCORE: isize = 15; pub fn new(old: String) -> Self { let old = old.chars().collect::>(); From d3238441ce56c00810a7b651de5d511255a161c1 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 23 Aug 2023 16:13:37 +0200 Subject: [PATCH 14/60] :art: --- crates/ai/src/refactor.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index 58cd360186..aab056fd32 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -1,4 +1,4 @@ -use crate::{stream_completion, OpenAIRequest, RequestMessage, Role}; +use crate::{diff::Diff, stream_completion, OpenAIRequest, RequestMessage, Role}; use collections::HashMap; use editor::{Editor, ToOffset}; use futures::{channel::mpsc, SinkExt, StreamExt}; @@ -64,7 +64,7 @@ impl RefactoringAssistant { let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); let diff = cx.background().spawn(async move { let mut messages = response.await?.ready_chunks(4); - let mut diff = crate::diff::Diff::new(selected_text); + let mut diff = Diff::new(selected_text); while let Some(messages) = messages.next().await { let mut new_text = String::new(); From e4f49746e1b389310ee50e48329095d44056e9c0 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 23 Aug 2023 17:09:15 +0200 Subject: [PATCH 15/60] Group modal assistant edits into the same transaction Co-Authored-By: Kyle Caverly --- crates/ai/src/refactor.rs | 30 ++++++++++---- crates/editor/src/multi_buffer.rs | 65 +++++++++++++++++++++++++++++++ crates/language/src/buffer.rs | 8 ++++ crates/text/src/text.rs | 38 ++++++++++++++++-- 4 files changed, 131 insertions(+), 10 deletions(-) diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index aab056fd32..1eb54d9373 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -85,35 +85,51 @@ impl RefactoringAssistant { anyhow::Ok(()) }); + let mut last_transaction = None; while let Some(hunks) = hunks_rx.next().await { editor.update(&mut cx, |editor, cx| { let mut highlights = Vec::new(); editor.buffer().update(cx, |buffer, cx| { + buffer.finalize_last_transaction(cx); + buffer.start_transaction(cx); - for hunk in hunks { - match hunk { + buffer.edit( + hunks.into_iter().filter_map(|hunk| match hunk { crate::diff::Hunk::Insert { text } => { let edit_start = snapshot.anchor_after(edit_start); - buffer.edit([(edit_start..edit_start, text)], None, cx); + Some((edit_start..edit_start, text)) } crate::diff::Hunk::Remove { len } => { let edit_end = edit_start + len; let edit_range = snapshot.anchor_after(edit_start) ..snapshot.anchor_before(edit_end); - buffer.edit([(edit_range, "")], None, cx); edit_start = edit_end; + Some((edit_range, String::new())) } crate::diff::Hunk::Keep { len } => { let edit_end = edit_start + len; let edit_range = snapshot.anchor_after(edit_start) ..snapshot.anchor_before(edit_end); - highlights.push(edit_range); edit_start += len; + highlights.push(edit_range); + None } + }), + None, + cx, + ); + if let Some(transaction) = buffer.end_transaction(cx) { + if let Some(last_transaction) = last_transaction { + buffer.merge_transaction_into( + last_transaction, + transaction, + cx, + ); } + last_transaction = Some(transaction); + buffer.finalize_last_transaction(cx); } - buffer.end_transaction(cx); }); editor.highlight_text::( @@ -199,7 +215,7 @@ impl RefactoringModal { fn deploy(workspace: &mut Workspace, _: &Refactor, cx: &mut ViewContext) { if let Some(active_editor) = workspace .active_item(cx) - .and_then(|item| Some(item.downcast::()?.downgrade())) + .and_then(|item| Some(item.act_as::(cx)?.downgrade())) { workspace.toggle_modal(cx, |_, cx| { let prompt_editor = cx.add_view(|cx| { diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index 8417c411f2..28b31ef097 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -615,6 +615,42 @@ impl MultiBuffer { } } + pub fn merge_transaction_into( + &mut self, + transaction: TransactionId, + destination: TransactionId, + cx: &mut ModelContext, + ) { + if let Some(buffer) = self.as_singleton() { + buffer.update(cx, |buffer, _| { + buffer.merge_transaction_into(transaction, destination) + }); + } else { + if let Some(transaction) = self.history.remove_transaction(transaction) { + if let Some(destination) = self.history.transaction_mut(destination) { + for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions { + if let Some(destination_buffer_transaction_id) = + destination.buffer_transactions.get(&buffer_id) + { + if let Some(state) = self.buffers.borrow().get(&buffer_id) { + state.buffer.update(cx, |buffer, _| { + buffer.merge_transaction_into( + buffer_transaction_id, + *destination_buffer_transaction_id, + ) + }); + } + } else { + destination + .buffer_transactions + .insert(buffer_id, buffer_transaction_id); + } + } + } + } + } + } + pub fn finalize_last_transaction(&mut self, cx: &mut ModelContext) { self.history.finalize_last_transaction(); for BufferState { buffer, .. } in self.buffers.borrow().values() { @@ -3333,6 +3369,35 @@ impl History { } } + fn remove_transaction(&mut self, transaction_id: TransactionId) -> Option { + if let Some(ix) = self + .undo_stack + .iter() + .rposition(|transaction| transaction.id == transaction_id) + { + Some(self.undo_stack.remove(ix)) + } else if let Some(ix) = self + .redo_stack + .iter() + .rposition(|transaction| transaction.id == transaction_id) + { + Some(self.redo_stack.remove(ix)) + } else { + None + } + } + + fn transaction_mut(&mut self, transaction_id: TransactionId) -> Option<&mut Transaction> { + self.undo_stack + .iter_mut() + .find(|transaction| transaction.id == transaction_id) + .or_else(|| { + self.redo_stack + .iter_mut() + .find(|transaction| transaction.id == transaction_id) + }) + } + fn pop_undo(&mut self) -> Option<&mut Transaction> { assert_eq!(self.transaction_depth, 0); if let Some(transaction) = self.undo_stack.pop() { diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 0b10432a9f..69668a97c6 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1294,6 +1294,14 @@ impl Buffer { self.text.forget_transaction(transaction_id); } + pub fn merge_transaction_into( + &mut self, + transaction: TransactionId, + destination: TransactionId, + ) { + self.text.merge_transaction_into(transaction, destination); + } + pub fn wait_for_edits( &mut self, edit_ids: impl IntoIterator, diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 7c94f25e1e..e47e20da0d 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -278,20 +278,43 @@ impl History { &self.redo_stack[redo_stack_start_len..] } - fn forget(&mut self, transaction_id: TransactionId) { + fn forget(&mut self, transaction_id: TransactionId) -> Option { assert_eq!(self.transaction_depth, 0); if let Some(entry_ix) = self .undo_stack .iter() .rposition(|entry| entry.transaction.id == transaction_id) { - self.undo_stack.remove(entry_ix); + Some(self.undo_stack.remove(entry_ix).transaction) } else if let Some(entry_ix) = self .redo_stack .iter() .rposition(|entry| entry.transaction.id == transaction_id) { - self.undo_stack.remove(entry_ix); + Some(self.redo_stack.remove(entry_ix).transaction) + } else { + None + } + } + + fn transaction_mut(&mut self, transaction_id: TransactionId) -> Option<&mut Transaction> { + let entry = self + .undo_stack + .iter_mut() + .rfind(|entry| entry.transaction.id == transaction_id) + .or_else(|| { + self.redo_stack + .iter_mut() + .rfind(|entry| entry.transaction.id == transaction_id) + })?; + Some(&mut entry.transaction) + } + + fn merge_transaction_into(&mut self, transaction: TransactionId, destination: TransactionId) { + if let Some(transaction) = self.forget(transaction) { + if let Some(destination) = self.transaction_mut(destination) { + destination.edit_ids.extend(transaction.edit_ids); + } } } @@ -1202,6 +1225,15 @@ impl Buffer { self.history.forget(transaction_id); } + pub fn merge_transaction_into( + &mut self, + transaction: TransactionId, + destination: TransactionId, + ) { + self.history + .merge_transaction_into(transaction, destination); + } + pub fn redo(&mut self) -> Option<(TransactionId, Operation)> { if let Some(entry) = self.history.pop_redo() { let transaction = entry.transaction.clone(); From a69461dba2e8ac1dc78306d5eb9191514c7e787c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 23 Aug 2023 17:18:36 +0200 Subject: [PATCH 16/60] Don't score whitespace matches Co-Authored-By: Kyle Caverly --- crates/ai/src/diff.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ai/src/diff.rs b/crates/ai/src/diff.rs index 0f4f328602..3ba0d005e7 100644 --- a/crates/ai/src/diff.rs +++ b/crates/ai/src/diff.rs @@ -107,7 +107,11 @@ impl Diff { let insertion_score = self.scores.get(i, j - 1) + Self::INSERTION_SCORE; let deletion_score = self.scores.get(i - 1, j) + Self::DELETION_SCORE; let equality_score = if self.old[i - 1] == self.new[j - 1] { - self.scores.get(i - 1, j - 1) + Self::EQUALITY_SCORE + if self.old[i - 1] == ' ' { + self.scores.get(i - 1, j - 1) + } else { + self.scores.get(i - 1, j - 1) + Self::EQUALITY_SCORE + } } else { isize::MIN }; From 301a12923f5d548bdc9d4e5b60acff9cab4e0d3f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 23 Aug 2023 18:20:42 +0200 Subject: [PATCH 17/60] Merge transactions into the original assistant transaction Co-Authored-By: Nathan Sobo Co-Authored-By: Kyle Caverly --- crates/ai/src/refactor.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index 1eb54d9373..f668f8d5ac 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -85,7 +85,7 @@ impl RefactoringAssistant { anyhow::Ok(()) }); - let mut last_transaction = None; + let mut first_transaction = None; while let Some(hunks) = hunks_rx.next().await { editor.update(&mut cx, |editor, cx| { let mut highlights = Vec::new(); @@ -120,14 +120,15 @@ impl RefactoringAssistant { cx, ); if let Some(transaction) = buffer.end_transaction(cx) { - if let Some(last_transaction) = last_transaction { + if let Some(first_transaction) = first_transaction { buffer.merge_transaction_into( - last_transaction, transaction, + first_transaction, cx, ); + } else { + first_transaction = Some(transaction); } - last_transaction = Some(transaction); buffer.finalize_last_transaction(cx); } }); From f22acb602e9aaf2b9db8685aaec7c44fa3963cd5 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 23 Aug 2023 19:21:44 +0200 Subject: [PATCH 18/60] Apply a score boost when consecutive triplets of characters match --- crates/ai/src/diff.rs | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/crates/ai/src/diff.rs b/crates/ai/src/diff.rs index 3ba0d005e7..378206497b 100644 --- a/crates/ai/src/diff.rs +++ b/crates/ai/src/diff.rs @@ -1,3 +1,4 @@ +use collections::HashMap; use std::{ fmt::{self, Debug}, ops::Range, @@ -74,12 +75,13 @@ pub struct Diff { scores: Matrix, old_text_ix: usize, new_text_ix: usize, + equal_runs: HashMap<(usize, usize), u32>, } impl Diff { const INSERTION_SCORE: isize = -1; - const DELETION_SCORE: isize = -4; - const EQUALITY_SCORE: isize = 15; + const DELETION_SCORE: isize = -5; + const EQUALITY_BASE: isize = 2; pub fn new(old: String) -> Self { let old = old.chars().collect::>(); @@ -94,6 +96,7 @@ impl Diff { scores, old_text_ix: 0, new_text_ix: 0, + equal_runs: Default::default(), } } @@ -107,36 +110,38 @@ impl Diff { let insertion_score = self.scores.get(i, j - 1) + Self::INSERTION_SCORE; let deletion_score = self.scores.get(i - 1, j) + Self::DELETION_SCORE; let equality_score = if self.old[i - 1] == self.new[j - 1] { + let mut equal_run = self.equal_runs.get(&(i - 1, j - 1)).copied().unwrap_or(0); + equal_run += 1; + self.equal_runs.insert((i, j), equal_run); + if self.old[i - 1] == ' ' { self.scores.get(i - 1, j - 1) } else { - self.scores.get(i - 1, j - 1) + Self::EQUALITY_SCORE + self.scores.get(i - 1, j - 1) + Self::EQUALITY_BASE.pow(equal_run / 3) } } else { isize::MIN }; + let score = insertion_score.max(deletion_score).max(equality_score); self.scores.set(i, j, score); } } let mut max_score = isize::MIN; - let mut best_row = self.old_text_ix; - let mut best_col = self.new_text_ix; + let mut next_old_text_ix = self.old_text_ix; + let next_new_text_ix = self.new.len(); for i in self.old_text_ix..=self.old.len() { - for j in self.new_text_ix..=self.new.len() { - let score = self.scores.get(i, j); - if score > max_score { - max_score = score; - best_row = i; - best_col = j; - } + let score = self.scores.get(i, next_new_text_ix); + if score > max_score { + max_score = score; + next_old_text_ix = i; } } - let hunks = self.backtrack(best_row, best_col); - self.old_text_ix = best_row; - self.new_text_ix = best_col; + let hunks = self.backtrack(next_old_text_ix, next_new_text_ix); + self.old_text_ix = next_old_text_ix; + self.new_text_ix = next_new_text_ix; hunks } From 985397b55c45b2d1c6985a37453901ca81a8a454 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 24 Aug 2023 09:52:07 +0200 Subject: [PATCH 19/60] :memo: --- crates/ai/src/refactor.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index f668f8d5ac..2821a1e845 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -91,6 +91,7 @@ impl RefactoringAssistant { let mut highlights = Vec::new(); editor.buffer().update(cx, |buffer, cx| { + // Avoid grouping assistant edits with user edits. buffer.finalize_last_transaction(cx); buffer.start_transaction(cx); @@ -121,6 +122,7 @@ impl RefactoringAssistant { ); if let Some(transaction) = buffer.end_transaction(cx) { if let Some(first_transaction) = first_transaction { + // Group all assistant edits into the first transaction. buffer.merge_transaction_into( transaction, first_transaction, @@ -128,8 +130,8 @@ impl RefactoringAssistant { ); } else { first_transaction = Some(transaction); + buffer.finalize_last_transaction(cx); } - buffer.finalize_last_transaction(cx); } }); From 481bcbf2046db23919ab9f9d2e2c10ab577c0830 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 24 Aug 2023 12:45:21 +0200 Subject: [PATCH 20/60] Normalize indentation when refactoring --- crates/ai/src/refactor.rs | 84 +++++++++++++++++++++++++++++++++++---- crates/rope/src/rope.rs | 10 +++++ 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index 2821a1e845..9b36d760b7 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -1,13 +1,14 @@ use crate::{diff::Diff, stream_completion, OpenAIRequest, RequestMessage, Role}; use collections::HashMap; -use editor::{Editor, ToOffset}; +use editor::{Editor, ToOffset, ToPoint}; use futures::{channel::mpsc, SinkExt, StreamExt}; use gpui::{ actions, elements::*, platform::MouseButton, AnyViewHandle, AppContext, Entity, Task, View, ViewContext, ViewHandle, WeakViewHandle, }; +use language::{Point, Rope}; use menu::{Cancel, Confirm}; -use std::{env, sync::Arc}; +use std::{cmp, env, sync::Arc}; use util::TryFutureExt; use workspace::{Modal, Workspace}; @@ -36,7 +37,48 @@ impl RefactoringAssistant { let selection = editor.read(cx).selections.newest_anchor().clone(); let selected_text = snapshot .text_for_range(selection.start..selection.end) - .collect::(); + .collect::(); + + let mut normalized_selected_text = selected_text.clone(); + let mut base_indentation: Option = None; + let selection_start = selection.start.to_point(&snapshot); + let selection_end = selection.end.to_point(&snapshot); + if selection_start.row < selection_end.row { + for row in selection_start.row..=selection_end.row { + if snapshot.is_line_blank(row) { + continue; + } + + let line_indentation = snapshot.indent_size_for_line(row); + if let Some(base_indentation) = base_indentation.as_mut() { + if line_indentation.len < base_indentation.len { + *base_indentation = line_indentation; + } + } else { + base_indentation = Some(line_indentation); + } + } + } + + if let Some(base_indentation) = base_indentation { + for row in selection_start.row..=selection_end.row { + let selection_row = row - selection_start.row; + let line_start = + normalized_selected_text.point_to_offset(Point::new(selection_row, 0)); + let indentation_len = if row == selection_start.row { + base_indentation.len.saturating_sub(selection_start.column) + } else { + let line_len = normalized_selected_text.line_len(selection_row); + cmp::min(line_len, base_indentation.len) + }; + let indentation_end = cmp::min( + line_start + indentation_len as usize, + normalized_selected_text.len(), + ); + normalized_selected_text.replace(line_start..indentation_end, ""); + } + } + let language_name = snapshot .language_at(selection.start) .map(|language| language.name()); @@ -47,7 +89,7 @@ impl RefactoringAssistant { RequestMessage { role: Role::User, content: format!( - "Given the following {language_name} snippet:\n{selected_text}\n{prompt}. Never make remarks and reply only with the new code. Never change the leading whitespace on each line." + "Given the following {language_name} snippet:\n{normalized_selected_text}\n{prompt}. Never make remarks and reply only with the new code." ), }], stream: true, @@ -64,21 +106,49 @@ impl RefactoringAssistant { let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); let diff = cx.background().spawn(async move { let mut messages = response.await?.ready_chunks(4); - let mut diff = Diff::new(selected_text); + let mut diff = Diff::new(selected_text.to_string()); + let indentation_len; + let indentation_text; + if let Some(base_indentation) = base_indentation { + indentation_len = base_indentation.len; + indentation_text = match base_indentation.kind { + language::IndentKind::Space => " ", + language::IndentKind::Tab => "\t", + }; + } else { + indentation_len = 0; + indentation_text = ""; + }; + + let mut new_text = + indentation_text.repeat( + indentation_len.saturating_sub(selection_start.column) as usize, + ); while let Some(messages) = messages.next().await { - let mut new_text = String::new(); for message in messages { let mut message = message?; if let Some(choice) = message.choices.pop() { if let Some(text) = choice.delta.content { - new_text.push_str(&text); + let mut lines = text.split('\n'); + if let Some(first_line) = lines.next() { + new_text.push_str(&first_line); + } + + for line in lines { + new_text.push('\n'); + new_text.push_str( + &indentation_text.repeat(indentation_len as usize), + ); + new_text.push_str(line); + } } } } let hunks = diff.push_new(&new_text); hunks_tx.send(hunks).await?; + new_text.clear(); } hunks_tx.send(diff.finish()).await?; diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index 2bfb090bb2..9c764c468e 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -384,6 +384,16 @@ impl<'a> From<&'a str> for Rope { } } +impl<'a> FromIterator<&'a str> for Rope { + fn from_iter>(iter: T) -> Self { + let mut rope = Rope::new(); + for chunk in iter { + rope.push(chunk); + } + rope + } +} + impl From for Rope { fn from(text: String) -> Self { Rope::from(text.as_str()) From 9674b038559623cdb0fed1c2e6023327e5b59772 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 24 Aug 2023 12:45:44 +0200 Subject: [PATCH 21/60] Make scoring more precise by using floats when diffing AI refactors --- Cargo.lock | 1 + crates/ai/Cargo.toml | 1 + crates/ai/src/diff.rs | 30 +++++++++++++++++------------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3283d32a94..2a4c6c4f43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,7 @@ dependencies = [ "language", "log", "menu", + "ordered-float", "project", "rand 0.8.5", "regex", diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index db8772bcb1..b03405bb93 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -26,6 +26,7 @@ chrono = { version = "0.4", features = ["serde"] } futures.workspace = true indoc.workspace = true isahc.workspace = true +ordered-float.workspace = true regex.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/ai/src/diff.rs b/crates/ai/src/diff.rs index 378206497b..7c5af34ff5 100644 --- a/crates/ai/src/diff.rs +++ b/crates/ai/src/diff.rs @@ -1,11 +1,13 @@ use collections::HashMap; +use ordered_float::OrderedFloat; use std::{ + cmp, fmt::{self, Debug}, ops::Range, }; struct Matrix { - cells: Vec, + cells: Vec, rows: usize, cols: usize, } @@ -20,12 +22,12 @@ impl Matrix { } fn resize(&mut self, rows: usize, cols: usize) { - self.cells.resize(rows * cols, 0); + self.cells.resize(rows * cols, 0.); self.rows = rows; self.cols = cols; } - fn get(&self, row: usize, col: usize) -> isize { + fn get(&self, row: usize, col: usize) -> f64 { if row >= self.rows { panic!("row out of bounds") } @@ -36,7 +38,7 @@ impl Matrix { self.cells[col * self.rows + row] } - fn set(&mut self, row: usize, col: usize, value: isize) { + fn set(&mut self, row: usize, col: usize, value: f64) { if row >= self.rows { panic!("row out of bounds") } @@ -79,16 +81,17 @@ pub struct Diff { } impl Diff { - const INSERTION_SCORE: isize = -1; - const DELETION_SCORE: isize = -5; - const EQUALITY_BASE: isize = 2; + const INSERTION_SCORE: f64 = -1.; + const DELETION_SCORE: f64 = -5.; + const EQUALITY_BASE: f64 = 1.618; + const MAX_EQUALITY_EXPONENT: i32 = 32; pub fn new(old: String) -> Self { let old = old.chars().collect::>(); let mut scores = Matrix::new(); scores.resize(old.len() + 1, 1); for i in 0..=old.len() { - scores.set(i, 0, i as isize * Self::DELETION_SCORE); + scores.set(i, 0, i as f64 * Self::DELETION_SCORE); } Self { old, @@ -105,7 +108,7 @@ impl Diff { self.scores.resize(self.old.len() + 1, self.new.len() + 1); for j in self.new_text_ix + 1..=self.new.len() { - self.scores.set(0, j, j as isize * Self::INSERTION_SCORE); + self.scores.set(0, j, j as f64 * Self::INSERTION_SCORE); for i in 1..=self.old.len() { let insertion_score = self.scores.get(i, j - 1) + Self::INSERTION_SCORE; let deletion_score = self.scores.get(i - 1, j) + Self::DELETION_SCORE; @@ -117,10 +120,11 @@ impl Diff { if self.old[i - 1] == ' ' { self.scores.get(i - 1, j - 1) } else { - self.scores.get(i - 1, j - 1) + Self::EQUALITY_BASE.pow(equal_run / 3) + let exponent = cmp::min(equal_run as i32 / 3, Self::MAX_EQUALITY_EXPONENT); + self.scores.get(i - 1, j - 1) + Self::EQUALITY_BASE.powi(exponent) } } else { - isize::MIN + f64::NEG_INFINITY }; let score = insertion_score.max(deletion_score).max(equality_score); @@ -128,7 +132,7 @@ impl Diff { } } - let mut max_score = isize::MIN; + let mut max_score = f64::NEG_INFINITY; let mut next_old_text_ix = self.old_text_ix; let next_new_text_ix = self.new.len(); for i in self.old_text_ix..=self.old.len() { @@ -173,7 +177,7 @@ impl Diff { let (prev_i, prev_j) = [insertion_score, deletion_score, equality_score] .iter() - .max_by_key(|cell| cell.map(|(i, j)| self.scores.get(i, j))) + .max_by_key(|cell| cell.map(|(i, j)| OrderedFloat(self.scores.get(i, j)))) .unwrap() .unwrap(); From 71a5964c187475564d5b43a66be9642f1b3c078e Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 24 Aug 2023 14:26:42 +0200 Subject: [PATCH 22/60] Rename `merge_transaction_into` to `merge_transactions` --- crates/ai/src/refactor.rs | 2 +- crates/editor/src/multi_buffer.rs | 6 +++--- crates/language/src/buffer.rs | 8 ++------ crates/text/src/text.rs | 11 +++-------- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index 9b36d760b7..82bebeb336 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -193,7 +193,7 @@ impl RefactoringAssistant { if let Some(transaction) = buffer.end_transaction(cx) { if let Some(first_transaction) = first_transaction { // Group all assistant edits into the first transaction. - buffer.merge_transaction_into( + buffer.merge_transactions( transaction, first_transaction, cx, diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index 28b31ef097..88c66d5200 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -615,7 +615,7 @@ impl MultiBuffer { } } - pub fn merge_transaction_into( + pub fn merge_transactions( &mut self, transaction: TransactionId, destination: TransactionId, @@ -623,7 +623,7 @@ impl MultiBuffer { ) { if let Some(buffer) = self.as_singleton() { buffer.update(cx, |buffer, _| { - buffer.merge_transaction_into(transaction, destination) + buffer.merge_transactions(transaction, destination) }); } else { if let Some(transaction) = self.history.remove_transaction(transaction) { @@ -634,7 +634,7 @@ impl MultiBuffer { { if let Some(state) = self.buffers.borrow().get(&buffer_id) { state.buffer.update(cx, |buffer, _| { - buffer.merge_transaction_into( + buffer.merge_transactions( buffer_transaction_id, *destination_buffer_transaction_id, ) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 69668a97c6..e2154f498e 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1294,12 +1294,8 @@ impl Buffer { self.text.forget_transaction(transaction_id); } - pub fn merge_transaction_into( - &mut self, - transaction: TransactionId, - destination: TransactionId, - ) { - self.text.merge_transaction_into(transaction, destination); + pub fn merge_transactions(&mut self, transaction: TransactionId, destination: TransactionId) { + self.text.merge_transactions(transaction, destination); } pub fn wait_for_edits( diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index e47e20da0d..8f15535ccf 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -310,7 +310,7 @@ impl History { Some(&mut entry.transaction) } - fn merge_transaction_into(&mut self, transaction: TransactionId, destination: TransactionId) { + fn merge_transactions(&mut self, transaction: TransactionId, destination: TransactionId) { if let Some(transaction) = self.forget(transaction) { if let Some(destination) = self.transaction_mut(destination) { destination.edit_ids.extend(transaction.edit_ids); @@ -1225,13 +1225,8 @@ impl Buffer { self.history.forget(transaction_id); } - pub fn merge_transaction_into( - &mut self, - transaction: TransactionId, - destination: TransactionId, - ) { - self.history - .merge_transaction_into(transaction, destination); + pub fn merge_transactions(&mut self, transaction: TransactionId, destination: TransactionId) { + self.history.merge_transactions(transaction, destination); } pub fn redo(&mut self) -> Option<(TransactionId, Operation)> { From 24685061899a7ed5a0901cb180a57d71df5c38a8 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 24 Aug 2023 14:29:05 +0200 Subject: [PATCH 23/60] Always clear refactoring text highlights, even if an error occurs --- crates/ai/src/refactor.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactor.rs index 82bebeb336..1cb370dbba 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactor.rs @@ -101,6 +101,16 @@ impl RefactoringAssistant { editor.id(), cx.spawn(|mut cx| { async move { + let _clear_highlights = util::defer({ + let mut cx = cx.clone(); + let editor = editor.clone(); + move || { + let _ = editor.update(&mut cx, |editor, cx| { + editor.clear_text_highlights::(cx); + }); + } + }); + let mut edit_start = selection.start.to_offset(&snapshot); let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); @@ -215,11 +225,7 @@ impl RefactoringAssistant { ); })?; } - diff.await?; - editor.update(&mut cx, |editor, cx| { - editor.clear_text_highlights::(cx); - })?; anyhow::Ok(()) } From c1d9b37dbc3102285b1499fa7123853291025dc9 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 24 Aug 2023 15:46:18 +0200 Subject: [PATCH 24/60] Move to an inline refactoring prompt --- crates/ai/src/ai.rs | 7 +- .../{refactor.rs => refactoring_assistant.rs} | 166 +++++------------- crates/ai/src/refactoring_modal.rs | 134 ++++++++++++++ crates/ai/src/{diff.rs => streaming_diff.rs} | 8 +- styles/src/style_tree/assistant.ts | 3 +- 5 files changed, 186 insertions(+), 132 deletions(-) rename crates/ai/src/{refactor.rs => refactoring_assistant.rs} (69%) create mode 100644 crates/ai/src/refactoring_modal.rs rename crates/ai/src/{diff.rs => streaming_diff.rs} (98%) diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index bad153879f..48f490c9c0 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -1,7 +1,8 @@ pub mod assistant; mod assistant_settings; -mod diff; -mod refactor; +mod refactoring_assistant; +mod refactoring_modal; +mod streaming_diff; use anyhow::{anyhow, Result}; pub use assistant::AssistantPanel; @@ -195,7 +196,7 @@ struct OpenAIChoice { pub fn init(cx: &mut AppContext) { assistant::init(cx); - refactor::init(cx); + refactoring_modal::init(cx); } pub async fn stream_completion( diff --git a/crates/ai/src/refactor.rs b/crates/ai/src/refactoring_assistant.rs similarity index 69% rename from crates/ai/src/refactor.rs rename to crates/ai/src/refactoring_assistant.rs index 1cb370dbba..5562cb4606 100644 --- a/crates/ai/src/refactor.rs +++ b/crates/ai/src/refactoring_assistant.rs @@ -1,25 +1,16 @@ -use crate::{diff::Diff, stream_completion, OpenAIRequest, RequestMessage, Role}; use collections::HashMap; use editor::{Editor, ToOffset, ToPoint}; use futures::{channel::mpsc, SinkExt, StreamExt}; -use gpui::{ - actions, elements::*, platform::MouseButton, AnyViewHandle, AppContext, Entity, Task, View, - ViewContext, ViewHandle, WeakViewHandle, -}; +use gpui::{AppContext, Task, ViewHandle}; use language::{Point, Rope}; -use menu::{Cancel, Confirm}; -use std::{cmp, env, sync::Arc}; +use std::{cmp, env, fmt::Write}; use util::TryFutureExt; -use workspace::{Modal, Workspace}; -actions!(assistant, [Refactor]); - -pub fn init(cx: &mut AppContext) { - cx.set_global(RefactoringAssistant::new()); - cx.add_action(RefactoringModal::deploy); - cx.add_action(RefactoringModal::confirm); - cx.add_action(RefactoringModal::cancel); -} +use crate::{ + stream_completion, + streaming_diff::{Hunk, StreamingDiff}, + OpenAIRequest, RequestMessage, Role, +}; pub struct RefactoringAssistant { pending_edits_by_editor: HashMap>>, @@ -32,7 +23,30 @@ impl RefactoringAssistant { } } - fn refactor(&mut self, editor: &ViewHandle, prompt: &str, cx: &mut AppContext) { + pub fn update(cx: &mut AppContext, f: F) -> T + where + F: FnOnce(&mut Self, &mut AppContext) -> T, + { + if !cx.has_global::() { + cx.set_global(Self::new()); + } + + cx.update_global(f) + } + + pub fn refactor( + &mut self, + editor: &ViewHandle, + user_prompt: &str, + cx: &mut AppContext, + ) { + let api_key = if let Ok(api_key) = env::var("OPENAI_API_KEY") { + api_key + } else { + // TODO: ensure the API key is present by going through the assistant panel's flow. + return; + }; + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); let selection = editor.read(cx).selections.newest_anchor().clone(); let selected_text = snapshot @@ -83,18 +97,20 @@ impl RefactoringAssistant { .language_at(selection.start) .map(|language| language.name()); let language_name = language_name.as_deref().unwrap_or(""); + + let mut prompt = String::new(); + writeln!(prompt, "Given the following {language_name} snippet:").unwrap(); + writeln!(prompt, "{normalized_selected_text}").unwrap(); + writeln!(prompt, "{user_prompt}.").unwrap(); + writeln!(prompt, "Never make remarks, reply only with the new code.").unwrap(); let request = OpenAIRequest { model: "gpt-4".into(), - messages: vec![ - RequestMessage { + messages: vec![RequestMessage { role: Role::User, - content: format!( - "Given the following {language_name} snippet:\n{normalized_selected_text}\n{prompt}. Never make remarks and reply only with the new code." - ), + content: prompt, }], stream: true, }; - let api_key = env::var("OPENAI_API_KEY").unwrap(); let response = stream_completion(api_key, cx.background().clone(), request); let editor = editor.downgrade(); self.pending_edits_by_editor.insert( @@ -116,7 +132,7 @@ impl RefactoringAssistant { let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); let diff = cx.background().spawn(async move { let mut messages = response.await?.ready_chunks(4); - let mut diff = Diff::new(selected_text.to_string()); + let mut diff = StreamingDiff::new(selected_text.to_string()); let indentation_len; let indentation_text; @@ -177,18 +193,18 @@ impl RefactoringAssistant { buffer.start_transaction(cx); buffer.edit( hunks.into_iter().filter_map(|hunk| match hunk { - crate::diff::Hunk::Insert { text } => { + Hunk::Insert { text } => { let edit_start = snapshot.anchor_after(edit_start); Some((edit_start..edit_start, text)) } - crate::diff::Hunk::Remove { len } => { + Hunk::Remove { len } => { let edit_end = edit_start + len; let edit_range = snapshot.anchor_after(edit_start) ..snapshot.anchor_before(edit_end); edit_start = edit_end; Some((edit_range, String::new())) } - crate::diff::Hunk::Keep { len } => { + Hunk::Keep { len } => { let edit_end = edit_start + len; let edit_range = snapshot.anchor_after(edit_start) ..snapshot.anchor_before(edit_end); @@ -234,99 +250,3 @@ impl RefactoringAssistant { ); } } - -enum Event { - Dismissed, -} - -struct RefactoringModal { - active_editor: WeakViewHandle, - prompt_editor: ViewHandle, - has_focus: bool, -} - -impl Entity for RefactoringModal { - type Event = Event; -} - -impl View for RefactoringModal { - fn ui_name() -> &'static str { - "RefactoringModal" - } - - fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - let theme = theme::current(cx); - - ChildView::new(&self.prompt_editor, cx) - .constrained() - .with_width(theme.assistant.modal.width) - .contained() - .with_style(theme.assistant.modal.container) - .mouse::(0) - .on_click_out(MouseButton::Left, |_, _, cx| cx.emit(Event::Dismissed)) - .on_click_out(MouseButton::Right, |_, _, cx| cx.emit(Event::Dismissed)) - .aligned() - .right() - .into_any() - } - - fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { - self.has_focus = true; - cx.focus(&self.prompt_editor); - } - - fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext) { - self.has_focus = false; - } -} - -impl Modal for RefactoringModal { - fn has_focus(&self) -> bool { - self.has_focus - } - - fn dismiss_on_event(event: &Self::Event) -> bool { - matches!(event, Self::Event::Dismissed) - } -} - -impl RefactoringModal { - fn deploy(workspace: &mut Workspace, _: &Refactor, cx: &mut ViewContext) { - if let Some(active_editor) = workspace - .active_item(cx) - .and_then(|item| Some(item.act_as::(cx)?.downgrade())) - { - workspace.toggle_modal(cx, |_, cx| { - let prompt_editor = cx.add_view(|cx| { - let mut editor = Editor::auto_height( - theme::current(cx).assistant.modal.editor_max_lines, - Some(Arc::new(|theme| theme.assistant.modal.editor.clone())), - cx, - ); - editor - .set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); - editor - }); - cx.add_view(|_| RefactoringModal { - active_editor, - prompt_editor, - has_focus: false, - }) - }); - } - } - - fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { - cx.emit(Event::Dismissed); - } - - fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { - if let Some(editor) = self.active_editor.upgrade(cx) { - let prompt = self.prompt_editor.read(cx).text(cx); - cx.update_global(|assistant: &mut RefactoringAssistant, cx| { - assistant.refactor(&editor, &prompt, cx); - }); - cx.emit(Event::Dismissed); - } - } -} diff --git a/crates/ai/src/refactoring_modal.rs b/crates/ai/src/refactoring_modal.rs new file mode 100644 index 0000000000..2203acc921 --- /dev/null +++ b/crates/ai/src/refactoring_modal.rs @@ -0,0 +1,134 @@ +use crate::refactoring_assistant::RefactoringAssistant; +use collections::HashSet; +use editor::{ + display_map::{BlockContext, BlockDisposition, BlockProperties, BlockStyle}, + scroll::autoscroll::{Autoscroll, AutoscrollStrategy}, + Editor, +}; +use gpui::{ + actions, elements::*, platform::MouseButton, AnyViewHandle, AppContext, Entity, View, + ViewContext, ViewHandle, WeakViewHandle, +}; +use std::sync::Arc; +use workspace::Workspace; + +actions!(assistant, [Refactor]); + +pub fn init(cx: &mut AppContext) { + cx.add_action(RefactoringModal::deploy); + cx.add_action(RefactoringModal::confirm); + cx.add_action(RefactoringModal::cancel); +} + +enum Event { + Dismissed, +} + +struct RefactoringModal { + active_editor: WeakViewHandle, + prompt_editor: ViewHandle, + has_focus: bool, +} + +impl Entity for RefactoringModal { + type Event = Event; +} + +impl View for RefactoringModal { + fn ui_name() -> &'static str { + "RefactoringModal" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + ChildView::new(&self.prompt_editor, cx) + .mouse::(0) + .on_click_out(MouseButton::Left, |_, _, cx| cx.emit(Event::Dismissed)) + .on_click_out(MouseButton::Right, |_, _, cx| cx.emit(Event::Dismissed)) + .into_any() + } + + fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + self.has_focus = true; + cx.focus(&self.prompt_editor); + } + + fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + if !self.prompt_editor.is_focused(cx) { + self.has_focus = false; + cx.emit(Event::Dismissed); + } + } +} + +impl RefactoringModal { + fn deploy(workspace: &mut Workspace, _: &Refactor, cx: &mut ViewContext) { + if let Some(active_editor) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + { + active_editor.update(cx, |editor, cx| { + let position = editor.selections.newest_anchor().head(); + let prompt_editor = cx.add_view(|cx| { + Editor::single_line( + Some(Arc::new(|theme| theme.assistant.modal.editor.clone())), + cx, + ) + }); + let active_editor = cx.weak_handle(); + let refactoring = cx.add_view(|_| RefactoringModal { + active_editor, + prompt_editor, + has_focus: false, + }); + cx.focus(&refactoring); + + let block_id = editor.insert_blocks( + [BlockProperties { + style: BlockStyle::Flex, + position, + height: 2, + render: Arc::new({ + let refactoring = refactoring.clone(); + move |cx: &mut BlockContext| { + ChildView::new(&refactoring, cx) + .contained() + .with_padding_left(cx.gutter_width) + .aligned() + .left() + .into_any() + } + }), + disposition: BlockDisposition::Below, + }], + Some(Autoscroll::Strategy(AutoscrollStrategy::Newest)), + cx, + )[0]; + cx.subscribe(&refactoring, move |_, refactoring, event, cx| { + let Event::Dismissed = event; + if let Some(active_editor) = refactoring.read(cx).active_editor.upgrade(cx) { + cx.window_context().defer(move |cx| { + active_editor.update(cx, |editor, cx| { + editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); + }) + }); + } + }) + .detach(); + }); + } + } + + fn cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { + cx.emit(Event::Dismissed); + } + + fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + if let Some(editor) = self.active_editor.upgrade(cx) { + let prompt = self.prompt_editor.read(cx).text(cx); + RefactoringAssistant::update(cx, |assistant, cx| { + assistant.refactor(&editor, &prompt, cx); + }); + cx.emit(Event::Dismissed); + } + } +} diff --git a/crates/ai/src/diff.rs b/crates/ai/src/streaming_diff.rs similarity index 98% rename from crates/ai/src/diff.rs rename to crates/ai/src/streaming_diff.rs index 7c5af34ff5..1e5189d4d8 100644 --- a/crates/ai/src/diff.rs +++ b/crates/ai/src/streaming_diff.rs @@ -71,7 +71,7 @@ pub enum Hunk { Keep { len: usize }, } -pub struct Diff { +pub struct StreamingDiff { old: Vec, new: Vec, scores: Matrix, @@ -80,10 +80,10 @@ pub struct Diff { equal_runs: HashMap<(usize, usize), u32>, } -impl Diff { +impl StreamingDiff { const INSERTION_SCORE: f64 = -1.; const DELETION_SCORE: f64 = -5.; - const EQUALITY_BASE: f64 = 1.618; + const EQUALITY_BASE: f64 = 2.; const MAX_EQUALITY_EXPONENT: i32 = 32; pub fn new(old: String) -> Self { @@ -250,7 +250,7 @@ mod tests { .collect::(); log::info!("old text: {:?}", old); - let mut diff = Diff::new(old.clone()); + let mut diff = StreamingDiff::new(old.clone()); let mut hunks = Vec::new(); let mut new_len = 0; let mut new = String::new(); diff --git a/styles/src/style_tree/assistant.ts b/styles/src/style_tree/assistant.ts index 88efabee1e..a02d7eb40c 100644 --- a/styles/src/style_tree/assistant.ts +++ b/styles/src/style_tree/assistant.ts @@ -69,8 +69,7 @@ export default function assistant(): any { width: 500, editor_max_lines: 6, editor: { - background: background(theme.lowest), - text: text(theme.lowest, "mono", "on"), + text: text(theme.lowest, "mono", "on", { size: "sm" }), placeholder_text: text(theme.lowest, "sans", "on", "disabled"), selection: theme.players[0], } From cbf7160054962980968dae6176e79d6c308289c4 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 24 Aug 2023 16:32:55 +0200 Subject: [PATCH 25/60] Improve scoring --- crates/ai/src/streaming_diff.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ai/src/streaming_diff.rs b/crates/ai/src/streaming_diff.rs index 1e5189d4d8..5425b75bbe 100644 --- a/crates/ai/src/streaming_diff.rs +++ b/crates/ai/src/streaming_diff.rs @@ -83,8 +83,8 @@ pub struct StreamingDiff { impl StreamingDiff { const INSERTION_SCORE: f64 = -1.; const DELETION_SCORE: f64 = -5.; - const EQUALITY_BASE: f64 = 2.; - const MAX_EQUALITY_EXPONENT: i32 = 32; + const EQUALITY_BASE: f64 = 1.4; + const MAX_EQUALITY_EXPONENT: i32 = 64; pub fn new(old: String) -> Self { let old = old.chars().collect::>(); @@ -120,7 +120,7 @@ impl StreamingDiff { if self.old[i - 1] == ' ' { self.scores.get(i - 1, j - 1) } else { - let exponent = cmp::min(equal_run as i32 / 3, Self::MAX_EQUALITY_EXPONENT); + let exponent = cmp::min(equal_run as i32, Self::MAX_EQUALITY_EXPONENT); self.scores.get(i - 1, j - 1) + Self::EQUALITY_BASE.powi(exponent) } } else { From 805e44915cdcb9e96879ea472879a2827dfe4d40 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 24 Aug 2023 17:23:12 +0200 Subject: [PATCH 26/60] WIP --- crates/ai/src/refactoring_modal.rs | 7 +++++-- crates/theme/src/theme.rs | 2 -- styles/src/style_tree/assistant.ts | 13 +++++-------- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/ai/src/refactoring_modal.rs b/crates/ai/src/refactoring_modal.rs index 2203acc921..675e0fae99 100644 --- a/crates/ai/src/refactoring_modal.rs +++ b/crates/ai/src/refactoring_modal.rs @@ -40,7 +40,12 @@ impl View for RefactoringModal { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + let theme = theme::current(cx); ChildView::new(&self.prompt_editor, cx) + .aligned() + .left() + .contained() + .with_style(theme.assistant.modal.container) .mouse::(0) .on_click_out(MouseButton::Left, |_, _, cx| cx.emit(Event::Dismissed)) .on_click_out(MouseButton::Right, |_, _, cx| cx.emit(Event::Dismissed)) @@ -93,8 +98,6 @@ impl RefactoringModal { ChildView::new(&refactoring, cx) .contained() .with_padding_left(cx.gutter_width) - .aligned() - .left() .into_any() } }), diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index a42a893241..ebc9591239 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -1131,8 +1131,6 @@ pub struct AssistantStyle { pub struct ModalAssistantStyle { #[serde(flatten)] pub container: ContainerStyle, - pub width: f32, - pub editor_max_lines: usize, pub editor: FieldEditor, } diff --git a/styles/src/style_tree/assistant.ts b/styles/src/style_tree/assistant.ts index a02d7eb40c..ac91d1118d 100644 --- a/styles/src/style_tree/assistant.ts +++ b/styles/src/style_tree/assistant.ts @@ -60,14 +60,11 @@ export default function assistant(): any { padding: { left: 12 }, }, modal: { - background: background(theme.lowest), - border: border(theme.lowest), - shadow: theme.modal_shadow, - corner_radius: 12, - padding: { left: 12, right: 0, top: 12, bottom: 12 }, - margin: { right: 12 }, - width: 500, - editor_max_lines: 6, + border: border(theme.lowest, "on", { + top: true, + bottom: true, + overlay: true, + }), editor: { text: text(theme.lowest, "mono", "on", { size: "sm" }), placeholder_text: text(theme.lowest, "sans", "on", "disabled"), From cb4b816d0e8e8b08874a7ed54625d8b85b4c4e48 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 24 Aug 2023 17:31:15 +0200 Subject: [PATCH 27/60] Add todo for modal assistant --- todo.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 todo.md diff --git a/todo.md b/todo.md new file mode 100644 index 0000000000..59b4a5a839 --- /dev/null +++ b/todo.md @@ -0,0 +1,7 @@ +- Style the current inline editor +- Find a way to understand whether we want to refactor or append, or both. (function calls) +- Add a system prompt that makes GPT an expert of language X +- Provide context around the cursor/selection. We should try to fill the context window as much as possible (try to fill half of it so that we can spit out another half) +- When you hit escape, the assistant should stop. +- When you hit undo and you undo a transaction from the assistant, we should stop generating. +- Keep the inline editor around until the assistant is done. Add a cancel button to stop, and and undo button to undo the whole thing. (Interactive) From b6035ee6a6b804436fcb564aaef4d8f55ab3e472 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Thu, 24 Aug 2023 20:00:25 +0200 Subject: [PATCH 28/60] WIP --- todo.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/todo.md b/todo.md index 59b4a5a839..e07d19bc95 100644 --- a/todo.md +++ b/todo.md @@ -5,3 +5,59 @@ - When you hit escape, the assistant should stop. - When you hit undo and you undo a transaction from the assistant, we should stop generating. - Keep the inline editor around until the assistant is done. Add a cancel button to stop, and and undo button to undo the whole thing. (Interactive) + + +# 9:39 AM + +- Hit `ctrl-enter` + +- Puts me in assistant mode with the selected text highlighted in a special color. If text was selected, I'm in transformation mode. +- If there's no selection, put me on the line below, aligned with the indent of the line. +- Enter starts generation +- Ctrl-enter inserts a newline +- Once generations starts, enter "confirms it" by dismissing the inline editor. +- Escape in the inline editor cancels/undoes/dismisses. +- To generate text in reference to other text, we can *mark* text. + + +- Hit ctrl-enter deploys an edit prompt + - Empty selection (cursor) => append text + - On end of line: Edit prompt on end of line. + - Middle of line: Edit prompt near cursor head on a different line + - Non-empty selection => refactor + - Edit prompt near cursor head on a different line + - What was selected when you hit ctrl-enter is colored. +- Selection is cleared and cursor is moved to prompt input +- When cursor is inside a prompt + - Escape cancels/undoes + - Enter confirms +- Multicursor + - Run the same prompt for every selection in parallel + - Position the prompt editor at the newest cursor +- Follow up ship: Marks + - Global across all buffers + - Select text, hit a binding + - That text gets added to the marks + - Simplest: Marks are a set, and you add to them with this binding. + - Could this be a stack? That might be too much. + - When you hit ctrl-enter to generate / transform text, we include the marked text in the context. + +- During inference, always send marked text. +- During inference, send as much context as possible given the user's desired generation length. + +- This would assume a convenient binding for setting the generation length. + + +~~~~~~~~~ + +Dial up / dial down how much context we send +Dial up / down your max generation length. + + +------- (merge to main) + +- Text in the prompt should soft wrap + +----------- (maybe pause) + +- Excurse outside of the editor without dismissing it... kind of like a message in the assistant. From c1bd03587501ead64f0d1783d362d5c9554e400a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 25 Aug 2023 11:39:27 +0200 Subject: [PATCH 29/60] Rework inline assistant --- Cargo.lock | 1 - assets/keymaps/default.json | 3 +- crates/ai/Cargo.toml | 1 - crates/ai/src/ai.rs | 3 - crates/ai/src/assistant.rs | 574 +++++++++++++++++++++++-- crates/ai/src/refactoring_assistant.rs | 252 ----------- crates/ai/src/refactoring_modal.rs | 137 ------ crates/ai/src/streaming_diff.rs | 12 +- crates/editor/src/editor.rs | 4 +- crates/editor/src/multi_buffer.rs | 16 +- crates/language/src/buffer.rs | 16 + crates/text/src/text.rs | 9 + crates/theme/src/theme.rs | 5 +- styles/src/style_tree/assistant.ts | 5 +- 14 files changed, 600 insertions(+), 438 deletions(-) delete mode 100644 crates/ai/src/refactoring_assistant.rs delete mode 100644 crates/ai/src/refactoring_modal.rs diff --git a/Cargo.lock b/Cargo.lock index 2a4c6c4f43..e74068b2d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,7 +122,6 @@ dependencies = [ "serde", "serde_json", "settings", - "similar", "smol", "theme", "tiktoken-rs 0.4.5", diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 3ec994335e..a81c18f708 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -528,7 +528,8 @@ "bindings": { "alt-enter": "editor::OpenExcerpts", "cmd-f8": "editor::GoToHunk", - "cmd-shift-f8": "editor::GoToPrevHunk" + "cmd-shift-f8": "editor::GoToPrevHunk", + "ctrl-enter": "assistant::InlineAssist" } }, { diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index b03405bb93..4438f88108 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -31,7 +31,6 @@ regex.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true -similar = "1.3" smol.workspace = true tiktoken-rs = "0.4" diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 48f490c9c0..0b56fedb11 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -1,7 +1,5 @@ pub mod assistant; mod assistant_settings; -mod refactoring_assistant; -mod refactoring_modal; mod streaming_diff; use anyhow::{anyhow, Result}; @@ -196,7 +194,6 @@ struct OpenAIChoice { pub fn init(cx: &mut AppContext) { assistant::init(cx); - refactoring_modal::init(cx); } pub async fn stream_completion( diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index f134eeeeb6..58be0fe584 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -1,18 +1,22 @@ use crate::{ assistant_settings::{AssistantDockPosition, AssistantSettings}, - stream_completion, MessageId, MessageMetadata, MessageStatus, OpenAIRequest, RequestMessage, - Role, SavedConversation, SavedConversationMetadata, SavedMessage, OPENAI_API_URL, + stream_completion, + streaming_diff::{Hunk, StreamingDiff}, + MessageId, MessageMetadata, MessageStatus, OpenAIRequest, RequestMessage, Role, + SavedConversation, SavedConversationMetadata, SavedMessage, OPENAI_API_URL, }; use anyhow::{anyhow, Result}; use chrono::{DateTime, Local}; use collections::{HashMap, HashSet}; use editor::{ - display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint}, + display_map::{ + BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint, + }, scroll::autoscroll::{Autoscroll, AutoscrollStrategy}, - Anchor, Editor, ToOffset, + Anchor, Editor, ToOffset, ToPoint, }; use fs::Fs; -use futures::StreamExt; +use futures::{channel::mpsc, SinkExt, StreamExt}; use gpui::{ actions, elements::*, @@ -21,7 +25,10 @@ use gpui::{ Action, AppContext, AsyncAppContext, ClipboardItem, Entity, ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; -use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset as _}; +use language::{ + language_settings::SoftWrap, Buffer, LanguageRegistry, Point, Rope, Selection, ToOffset as _, + TransactionId, +}; use search::BufferSearchBar; use settings::SettingsStore; use std::{ @@ -53,6 +60,7 @@ actions!( QuoteSelection, ToggleFocus, ResetKey, + InlineAssist ] ); @@ -84,6 +92,9 @@ pub fn init(cx: &mut AppContext) { workspace.toggle_panel_focus::(cx); }, ); + cx.add_action(AssistantPanel::inline_assist); + cx.add_action(InlineAssistant::confirm); + cx.add_action(InlineAssistant::cancel); } #[derive(Debug)] @@ -113,6 +124,9 @@ pub struct AssistantPanel { languages: Arc, fs: Arc, subscriptions: Vec, + next_inline_assist_id: usize, + pending_inline_assists: HashMap, + pending_inline_assist_ids_by_editor: HashMap, Vec>, _watch_saved_conversations: Task>, } @@ -176,6 +190,9 @@ impl AssistantPanel { width: None, height: None, subscriptions: Default::default(), + next_inline_assist_id: 0, + pending_inline_assists: Default::default(), + pending_inline_assist_ids_by_editor: Default::default(), _watch_saved_conversations, }; @@ -196,6 +213,425 @@ impl AssistantPanel { }) } + fn inline_assist(workspace: &mut Workspace, _: &InlineAssist, cx: &mut ViewContext) { + let assistant = if let Some(assistant) = workspace.panel::(cx) { + if assistant + .update(cx, |assistant, cx| assistant.load_api_key(cx)) + .is_some() + { + assistant + } else { + workspace.focus_panel::(cx); + return; + } + } else { + return; + }; + + let active_editor = if let Some(active_editor) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + { + active_editor + } else { + return; + }; + + assistant.update(cx, |assistant, cx| { + assistant.new_inline_assist(&active_editor, cx) + }); + } + + fn new_inline_assist(&mut self, editor: &ViewHandle, cx: &mut ViewContext) { + let id = post_inc(&mut self.next_inline_assist_id); + let (block_id, inline_assistant, selection) = editor.update(cx, |editor, cx| { + let selection = editor.selections.newest_anchor().clone(); + let prompt_editor = cx.add_view(|cx| { + Editor::single_line( + Some(Arc::new(|theme| theme.assistant.inline.editor.clone())), + cx, + ) + }); + let assist_kind = if editor.selections.newest::(cx).is_empty() { + InlineAssistKind::Insert + } else { + InlineAssistKind::Edit + }; + let assistant = cx.add_view(|_| InlineAssistant { + id, + prompt_editor, + confirmed: false, + has_focus: false, + assist_kind, + }); + cx.focus(&assistant); + + let block_id = editor.insert_blocks( + [BlockProperties { + style: BlockStyle::Flex, + position: selection.head(), + height: 2, + render: Arc::new({ + let assistant = assistant.clone(); + move |cx: &mut BlockContext| { + ChildView::new(&assistant, cx) + .contained() + .with_padding_left(match assist_kind { + InlineAssistKind::Edit => cx.gutter_width, + InlineAssistKind::Insert => cx.anchor_x, + }) + .into_any() + } + }), + disposition: if selection.reversed { + BlockDisposition::Above + } else { + BlockDisposition::Below + }, + }], + Some(Autoscroll::Strategy(AutoscrollStrategy::Newest)), + cx, + )[0]; + editor.highlight_background::( + vec![selection.start..selection.end], + |theme| theme.assistant.inline.pending_edit_background, + cx, + ); + + (block_id, assistant, selection) + }); + + self.pending_inline_assists.insert( + id, + PendingInlineAssist { + editor: editor.downgrade(), + selection, + inline_assistant_block_id: Some(block_id), + code_generation: Task::ready(None), + transaction_id: None, + _subscriptions: vec![ + cx.subscribe(&inline_assistant, Self::handle_inline_assistant_event), + cx.subscribe(editor, { + let inline_assistant = inline_assistant.downgrade(); + move |_, editor, event, cx| { + if let Some(inline_assistant) = inline_assistant.upgrade(cx) { + if let editor::Event::SelectionsChanged { local } = event { + if *local && inline_assistant.read(cx).has_focus { + cx.focus(&editor); + } + } + } + } + }), + ], + }, + ); + self.pending_inline_assist_ids_by_editor + .entry(editor.downgrade()) + .or_default() + .push(id); + } + + fn handle_inline_assistant_event( + &mut self, + inline_assistant: ViewHandle, + event: &InlineAssistantEvent, + cx: &mut ViewContext, + ) { + let assist_id = inline_assistant.read(cx).id; + match event { + InlineAssistantEvent::Confirmed { prompt } => { + self.generate_code(assist_id, prompt, cx); + } + InlineAssistantEvent::Canceled => { + self.complete_inline_assist(assist_id, true, cx); + } + InlineAssistantEvent::Dismissed => { + self.dismiss_inline_assist(assist_id, cx); + } + } + } + + fn complete_inline_assist( + &mut self, + assist_id: usize, + cancel: bool, + cx: &mut ViewContext, + ) { + self.dismiss_inline_assist(assist_id, cx); + + if let Some(pending_assist) = self.pending_inline_assists.remove(&assist_id) { + self.pending_inline_assist_ids_by_editor + .remove(&pending_assist.editor); + + if let Some(editor) = pending_assist.editor.upgrade(cx) { + editor.update(cx, |editor, cx| { + editor.clear_background_highlights::(cx); + editor.clear_text_highlights::(cx); + }); + + if cancel { + if let Some(transaction_id) = pending_assist.transaction_id { + editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |buffer, cx| { + buffer.undo_and_forget(transaction_id, cx) + }); + }); + } + } + } + } + } + + fn dismiss_inline_assist(&mut self, assist_id: usize, cx: &mut ViewContext) { + if let Some(pending_assist) = self.pending_inline_assists.get_mut(&assist_id) { + if let Some(editor) = pending_assist.editor.upgrade(cx) { + if let Some(block_id) = pending_assist.inline_assistant_block_id.take() { + editor.update(cx, |editor, cx| { + editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); + }); + } + } + } + } + + pub fn generate_code( + &mut self, + inline_assist_id: usize, + user_prompt: &str, + cx: &mut ViewContext, + ) { + let api_key = if let Some(api_key) = self.api_key.borrow().clone() { + api_key + } else { + return; + }; + + let pending_assist = + if let Some(pending_assist) = self.pending_inline_assists.get_mut(&inline_assist_id) { + pending_assist + } else { + return; + }; + + let editor = if let Some(editor) = pending_assist.editor.upgrade(cx) { + editor + } else { + return; + }; + + let selection = pending_assist.selection.clone(); + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + let selected_text = snapshot + .text_for_range(selection.start..selection.end) + .collect::(); + + let mut normalized_selected_text = selected_text.clone(); + let mut base_indentation: Option = None; + let selection_start = selection.start.to_point(&snapshot); + let selection_end = selection.end.to_point(&snapshot); + if selection_start.row < selection_end.row { + for row in selection_start.row..=selection_end.row { + if snapshot.is_line_blank(row) { + continue; + } + + let line_indentation = snapshot.indent_size_for_line(row); + if let Some(base_indentation) = base_indentation.as_mut() { + if line_indentation.len < base_indentation.len { + *base_indentation = line_indentation; + } + } else { + base_indentation = Some(line_indentation); + } + } + } + + if let Some(base_indentation) = base_indentation { + for row in selection_start.row..=selection_end.row { + let selection_row = row - selection_start.row; + let line_start = + normalized_selected_text.point_to_offset(Point::new(selection_row, 0)); + let indentation_len = if row == selection_start.row { + base_indentation.len.saturating_sub(selection_start.column) + } else { + let line_len = normalized_selected_text.line_len(selection_row); + cmp::min(line_len, base_indentation.len) + }; + let indentation_end = cmp::min( + line_start + indentation_len as usize, + normalized_selected_text.len(), + ); + normalized_selected_text.replace(line_start..indentation_end, ""); + } + } + + let language_name = snapshot + .language_at(selection.start) + .map(|language| language.name()); + let language_name = language_name.as_deref().unwrap_or(""); + + let mut prompt = String::new(); + writeln!(prompt, "Given the following {language_name} snippet:").unwrap(); + writeln!(prompt, "{normalized_selected_text}").unwrap(); + writeln!(prompt, "{user_prompt}.").unwrap(); + writeln!(prompt, "Never make remarks, reply only with the new code.").unwrap(); + let request = OpenAIRequest { + model: "gpt-4".into(), + messages: vec![RequestMessage { + role: Role::User, + content: prompt, + }], + stream: true, + }; + let response = stream_completion(api_key, cx.background().clone(), request); + let editor = editor.downgrade(); + + pending_assist.code_generation = cx.spawn(|this, mut cx| { + async move { + let _cleanup = util::defer({ + let mut cx = cx.clone(); + let this = this.clone(); + move || { + let _ = this.update(&mut cx, |this, cx| { + this.complete_inline_assist(inline_assist_id, false, cx) + }); + } + }); + + let mut edit_start = selection.start.to_offset(&snapshot); + + let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); + let diff = cx.background().spawn(async move { + let mut messages = response.await?; + let mut diff = StreamingDiff::new(selected_text.to_string()); + + let indentation_len; + let indentation_text; + if let Some(base_indentation) = base_indentation { + indentation_len = base_indentation.len; + indentation_text = match base_indentation.kind { + language::IndentKind::Space => " ", + language::IndentKind::Tab => "\t", + }; + } else { + indentation_len = 0; + indentation_text = ""; + }; + + let mut new_text = indentation_text + .repeat(indentation_len.saturating_sub(selection_start.column) as usize); + while let Some(message) = messages.next().await { + let mut message = message?; + if let Some(choice) = message.choices.pop() { + if let Some(text) = choice.delta.content { + let mut lines = text.split('\n'); + if let Some(first_line) = lines.next() { + new_text.push_str(&first_line); + } + + for line in lines { + new_text.push('\n'); + new_text.push_str( + &indentation_text.repeat(indentation_len as usize), + ); + new_text.push_str(line); + } + } + } + + let hunks = diff.push_new(&new_text); + hunks_tx.send(hunks).await?; + new_text.clear(); + } + hunks_tx.send(diff.finish()).await?; + + anyhow::Ok(()) + }); + + while let Some(hunks) = hunks_rx.next().await { + let this = this + .upgrade(&cx) + .ok_or_else(|| anyhow!("assistant was dropped"))?; + editor.update(&mut cx, |editor, cx| { + let mut highlights = Vec::new(); + + let transaction = editor.buffer().update(cx, |buffer, cx| { + // Avoid grouping assistant edits with user edits. + buffer.finalize_last_transaction(cx); + + buffer.start_transaction(cx); + buffer.edit( + hunks.into_iter().filter_map(|hunk| match hunk { + Hunk::Insert { text } => { + let edit_start = snapshot.anchor_after(edit_start); + Some((edit_start..edit_start, text)) + } + Hunk::Remove { len } => { + let edit_end = edit_start + len; + let edit_range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + edit_start = edit_end; + Some((edit_range, String::new())) + } + Hunk::Keep { len } => { + let edit_end = edit_start + len; + let edit_range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + edit_start += len; + highlights.push(edit_range); + None + } + }), + None, + cx, + ); + + buffer.end_transaction(cx) + }); + + if let Some(transaction) = transaction { + this.update(cx, |this, cx| { + if let Some(pending_assist) = + this.pending_inline_assists.get_mut(&inline_assist_id) + { + if let Some(first_transaction) = pending_assist.transaction_id { + // Group all assistant edits into the first transaction. + editor.buffer().update(cx, |buffer, cx| { + buffer.merge_transactions( + transaction, + first_transaction, + cx, + ) + }); + } else { + pending_assist.transaction_id = Some(transaction); + editor.buffer().update(cx, |buffer, cx| { + buffer.finalize_last_transaction(cx) + }); + } + } + }); + } + + editor.highlight_text::( + highlights, + gpui::fonts::HighlightStyle { + fade_out: Some(0.6), + ..Default::default() + }, + cx, + ); + })?; + } + diff.await?; + + anyhow::Ok(()) + } + .log_err() + }); + } + fn new_conversation(&mut self, cx: &mut ViewContext) -> ViewHandle { let editor = cx.add_view(|cx| { ConversationEditor::new( @@ -565,6 +1001,32 @@ impl AssistantPanel { .iter() .position(|editor| editor.read(cx).conversation.read(cx).path.as_deref() == Some(path)) } + + pub fn load_api_key(&mut self, cx: &mut ViewContext) -> Option { + if self.api_key.borrow().is_none() && !self.has_read_credentials { + self.has_read_credentials = true; + let api_key = if let Ok(api_key) = env::var("OPENAI_API_KEY") { + Some(api_key) + } else if let Some((_, api_key)) = cx + .platform() + .read_credentials(OPENAI_API_URL) + .log_err() + .flatten() + { + String::from_utf8(api_key).log_err() + } else { + None + }; + if let Some(api_key) = api_key { + *self.api_key.borrow_mut() = Some(api_key); + } else if self.api_key_editor.is_none() { + self.api_key_editor = Some(build_api_key_editor(cx)); + cx.notify(); + } + } + + self.api_key.borrow().clone() + } } fn build_api_key_editor(cx: &mut ViewContext) -> ViewHandle { @@ -748,27 +1210,7 @@ impl Panel for AssistantPanel { fn set_active(&mut self, active: bool, cx: &mut ViewContext) { if active { - if self.api_key.borrow().is_none() && !self.has_read_credentials { - self.has_read_credentials = true; - let api_key = if let Ok(api_key) = env::var("OPENAI_API_KEY") { - Some(api_key) - } else if let Some((_, api_key)) = cx - .platform() - .read_credentials(OPENAI_API_URL) - .log_err() - .flatten() - { - String::from_utf8(api_key).log_err() - } else { - None - }; - if let Some(api_key) = api_key { - *self.api_key.borrow_mut() = Some(api_key); - } else if self.api_key_editor.is_none() { - self.api_key_editor = Some(build_api_key_editor(cx)); - cx.notify(); - } - } + self.load_api_key(cx); if self.editors.is_empty() { self.new_conversation(cx); @@ -2139,6 +2581,84 @@ impl Message { } } +enum InlineAssistantEvent { + Confirmed { prompt: String }, + Canceled, + Dismissed, +} + +#[derive(Copy, Clone)] +enum InlineAssistKind { + Edit, + Insert, +} + +struct InlineAssistant { + id: usize, + prompt_editor: ViewHandle, + confirmed: bool, + assist_kind: InlineAssistKind, + has_focus: bool, +} + +impl Entity for InlineAssistant { + type Event = InlineAssistantEvent; +} + +impl View for InlineAssistant { + fn ui_name() -> &'static str { + "InlineAssistant" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + let theme = theme::current(cx); + let prompt_editor = ChildView::new(&self.prompt_editor, cx).aligned().left(); + match self.assist_kind { + InlineAssistKind::Edit => prompt_editor + .contained() + .with_style(theme.assistant.inline.container) + .into_any(), + InlineAssistKind::Insert => prompt_editor.into_any(), + } + } + + fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { + cx.focus(&self.prompt_editor); + self.has_focus = true; + } + + fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext) { + self.has_focus = false; + } +} + +impl InlineAssistant { + fn cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { + cx.emit(InlineAssistantEvent::Canceled); + } + + fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + if self.confirmed { + cx.emit(InlineAssistantEvent::Dismissed); + } else { + let prompt = self.prompt_editor.read(cx).text(cx); + self.prompt_editor + .update(cx, |editor, _| editor.set_read_only(true)); + cx.emit(InlineAssistantEvent::Confirmed { prompt }); + self.confirmed = true; + } + } +} + +struct PendingInlineAssist { + editor: WeakViewHandle, + selection: Selection, + inline_assistant_block_id: Option, + code_generation: Task>, + transaction_id: Option, + _subscriptions: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ai/src/refactoring_assistant.rs b/crates/ai/src/refactoring_assistant.rs deleted file mode 100644 index 5562cb4606..0000000000 --- a/crates/ai/src/refactoring_assistant.rs +++ /dev/null @@ -1,252 +0,0 @@ -use collections::HashMap; -use editor::{Editor, ToOffset, ToPoint}; -use futures::{channel::mpsc, SinkExt, StreamExt}; -use gpui::{AppContext, Task, ViewHandle}; -use language::{Point, Rope}; -use std::{cmp, env, fmt::Write}; -use util::TryFutureExt; - -use crate::{ - stream_completion, - streaming_diff::{Hunk, StreamingDiff}, - OpenAIRequest, RequestMessage, Role, -}; - -pub struct RefactoringAssistant { - pending_edits_by_editor: HashMap>>, -} - -impl RefactoringAssistant { - fn new() -> Self { - Self { - pending_edits_by_editor: Default::default(), - } - } - - pub fn update(cx: &mut AppContext, f: F) -> T - where - F: FnOnce(&mut Self, &mut AppContext) -> T, - { - if !cx.has_global::() { - cx.set_global(Self::new()); - } - - cx.update_global(f) - } - - pub fn refactor( - &mut self, - editor: &ViewHandle, - user_prompt: &str, - cx: &mut AppContext, - ) { - let api_key = if let Ok(api_key) = env::var("OPENAI_API_KEY") { - api_key - } else { - // TODO: ensure the API key is present by going through the assistant panel's flow. - return; - }; - - let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); - let selection = editor.read(cx).selections.newest_anchor().clone(); - let selected_text = snapshot - .text_for_range(selection.start..selection.end) - .collect::(); - - let mut normalized_selected_text = selected_text.clone(); - let mut base_indentation: Option = None; - let selection_start = selection.start.to_point(&snapshot); - let selection_end = selection.end.to_point(&snapshot); - if selection_start.row < selection_end.row { - for row in selection_start.row..=selection_end.row { - if snapshot.is_line_blank(row) { - continue; - } - - let line_indentation = snapshot.indent_size_for_line(row); - if let Some(base_indentation) = base_indentation.as_mut() { - if line_indentation.len < base_indentation.len { - *base_indentation = line_indentation; - } - } else { - base_indentation = Some(line_indentation); - } - } - } - - if let Some(base_indentation) = base_indentation { - for row in selection_start.row..=selection_end.row { - let selection_row = row - selection_start.row; - let line_start = - normalized_selected_text.point_to_offset(Point::new(selection_row, 0)); - let indentation_len = if row == selection_start.row { - base_indentation.len.saturating_sub(selection_start.column) - } else { - let line_len = normalized_selected_text.line_len(selection_row); - cmp::min(line_len, base_indentation.len) - }; - let indentation_end = cmp::min( - line_start + indentation_len as usize, - normalized_selected_text.len(), - ); - normalized_selected_text.replace(line_start..indentation_end, ""); - } - } - - let language_name = snapshot - .language_at(selection.start) - .map(|language| language.name()); - let language_name = language_name.as_deref().unwrap_or(""); - - let mut prompt = String::new(); - writeln!(prompt, "Given the following {language_name} snippet:").unwrap(); - writeln!(prompt, "{normalized_selected_text}").unwrap(); - writeln!(prompt, "{user_prompt}.").unwrap(); - writeln!(prompt, "Never make remarks, reply only with the new code.").unwrap(); - let request = OpenAIRequest { - model: "gpt-4".into(), - messages: vec![RequestMessage { - role: Role::User, - content: prompt, - }], - stream: true, - }; - let response = stream_completion(api_key, cx.background().clone(), request); - let editor = editor.downgrade(); - self.pending_edits_by_editor.insert( - editor.id(), - cx.spawn(|mut cx| { - async move { - let _clear_highlights = util::defer({ - let mut cx = cx.clone(); - let editor = editor.clone(); - move || { - let _ = editor.update(&mut cx, |editor, cx| { - editor.clear_text_highlights::(cx); - }); - } - }); - - let mut edit_start = selection.start.to_offset(&snapshot); - - let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); - let diff = cx.background().spawn(async move { - let mut messages = response.await?.ready_chunks(4); - let mut diff = StreamingDiff::new(selected_text.to_string()); - - let indentation_len; - let indentation_text; - if let Some(base_indentation) = base_indentation { - indentation_len = base_indentation.len; - indentation_text = match base_indentation.kind { - language::IndentKind::Space => " ", - language::IndentKind::Tab => "\t", - }; - } else { - indentation_len = 0; - indentation_text = ""; - }; - - let mut new_text = - indentation_text.repeat( - indentation_len.saturating_sub(selection_start.column) as usize, - ); - while let Some(messages) = messages.next().await { - for message in messages { - let mut message = message?; - if let Some(choice) = message.choices.pop() { - if let Some(text) = choice.delta.content { - let mut lines = text.split('\n'); - if let Some(first_line) = lines.next() { - new_text.push_str(&first_line); - } - - for line in lines { - new_text.push('\n'); - new_text.push_str( - &indentation_text.repeat(indentation_len as usize), - ); - new_text.push_str(line); - } - } - } - } - - let hunks = diff.push_new(&new_text); - hunks_tx.send(hunks).await?; - new_text.clear(); - } - hunks_tx.send(diff.finish()).await?; - - anyhow::Ok(()) - }); - - let mut first_transaction = None; - while let Some(hunks) = hunks_rx.next().await { - editor.update(&mut cx, |editor, cx| { - let mut highlights = Vec::new(); - - editor.buffer().update(cx, |buffer, cx| { - // Avoid grouping assistant edits with user edits. - buffer.finalize_last_transaction(cx); - - buffer.start_transaction(cx); - buffer.edit( - hunks.into_iter().filter_map(|hunk| match hunk { - Hunk::Insert { text } => { - let edit_start = snapshot.anchor_after(edit_start); - Some((edit_start..edit_start, text)) - } - Hunk::Remove { len } => { - let edit_end = edit_start + len; - let edit_range = snapshot.anchor_after(edit_start) - ..snapshot.anchor_before(edit_end); - edit_start = edit_end; - Some((edit_range, String::new())) - } - Hunk::Keep { len } => { - let edit_end = edit_start + len; - let edit_range = snapshot.anchor_after(edit_start) - ..snapshot.anchor_before(edit_end); - edit_start += len; - highlights.push(edit_range); - None - } - }), - None, - cx, - ); - if let Some(transaction) = buffer.end_transaction(cx) { - if let Some(first_transaction) = first_transaction { - // Group all assistant edits into the first transaction. - buffer.merge_transactions( - transaction, - first_transaction, - cx, - ); - } else { - first_transaction = Some(transaction); - buffer.finalize_last_transaction(cx); - } - } - }); - - editor.highlight_text::( - highlights, - gpui::fonts::HighlightStyle { - fade_out: Some(0.6), - ..Default::default() - }, - cx, - ); - })?; - } - diff.await?; - - anyhow::Ok(()) - } - .log_err() - }), - ); - } -} diff --git a/crates/ai/src/refactoring_modal.rs b/crates/ai/src/refactoring_modal.rs deleted file mode 100644 index 675e0fae99..0000000000 --- a/crates/ai/src/refactoring_modal.rs +++ /dev/null @@ -1,137 +0,0 @@ -use crate::refactoring_assistant::RefactoringAssistant; -use collections::HashSet; -use editor::{ - display_map::{BlockContext, BlockDisposition, BlockProperties, BlockStyle}, - scroll::autoscroll::{Autoscroll, AutoscrollStrategy}, - Editor, -}; -use gpui::{ - actions, elements::*, platform::MouseButton, AnyViewHandle, AppContext, Entity, View, - ViewContext, ViewHandle, WeakViewHandle, -}; -use std::sync::Arc; -use workspace::Workspace; - -actions!(assistant, [Refactor]); - -pub fn init(cx: &mut AppContext) { - cx.add_action(RefactoringModal::deploy); - cx.add_action(RefactoringModal::confirm); - cx.add_action(RefactoringModal::cancel); -} - -enum Event { - Dismissed, -} - -struct RefactoringModal { - active_editor: WeakViewHandle, - prompt_editor: ViewHandle, - has_focus: bool, -} - -impl Entity for RefactoringModal { - type Event = Event; -} - -impl View for RefactoringModal { - fn ui_name() -> &'static str { - "RefactoringModal" - } - - fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - let theme = theme::current(cx); - ChildView::new(&self.prompt_editor, cx) - .aligned() - .left() - .contained() - .with_style(theme.assistant.modal.container) - .mouse::(0) - .on_click_out(MouseButton::Left, |_, _, cx| cx.emit(Event::Dismissed)) - .on_click_out(MouseButton::Right, |_, _, cx| cx.emit(Event::Dismissed)) - .into_any() - } - - fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { - self.has_focus = true; - cx.focus(&self.prompt_editor); - } - - fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { - if !self.prompt_editor.is_focused(cx) { - self.has_focus = false; - cx.emit(Event::Dismissed); - } - } -} - -impl RefactoringModal { - fn deploy(workspace: &mut Workspace, _: &Refactor, cx: &mut ViewContext) { - if let Some(active_editor) = workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) - { - active_editor.update(cx, |editor, cx| { - let position = editor.selections.newest_anchor().head(); - let prompt_editor = cx.add_view(|cx| { - Editor::single_line( - Some(Arc::new(|theme| theme.assistant.modal.editor.clone())), - cx, - ) - }); - let active_editor = cx.weak_handle(); - let refactoring = cx.add_view(|_| RefactoringModal { - active_editor, - prompt_editor, - has_focus: false, - }); - cx.focus(&refactoring); - - let block_id = editor.insert_blocks( - [BlockProperties { - style: BlockStyle::Flex, - position, - height: 2, - render: Arc::new({ - let refactoring = refactoring.clone(); - move |cx: &mut BlockContext| { - ChildView::new(&refactoring, cx) - .contained() - .with_padding_left(cx.gutter_width) - .into_any() - } - }), - disposition: BlockDisposition::Below, - }], - Some(Autoscroll::Strategy(AutoscrollStrategy::Newest)), - cx, - )[0]; - cx.subscribe(&refactoring, move |_, refactoring, event, cx| { - let Event::Dismissed = event; - if let Some(active_editor) = refactoring.read(cx).active_editor.upgrade(cx) { - cx.window_context().defer(move |cx| { - active_editor.update(cx, |editor, cx| { - editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); - }) - }); - } - }) - .detach(); - }); - } - } - - fn cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { - cx.emit(Event::Dismissed); - } - - fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { - if let Some(editor) = self.active_editor.upgrade(cx) { - let prompt = self.prompt_editor.read(cx).text(cx); - RefactoringAssistant::update(cx, |assistant, cx| { - assistant.refactor(&editor, &prompt, cx); - }); - cx.emit(Event::Dismissed); - } - } -} diff --git a/crates/ai/src/streaming_diff.rs b/crates/ai/src/streaming_diff.rs index 5425b75bbe..7ea7f6dacd 100644 --- a/crates/ai/src/streaming_diff.rs +++ b/crates/ai/src/streaming_diff.rs @@ -83,8 +83,8 @@ pub struct StreamingDiff { impl StreamingDiff { const INSERTION_SCORE: f64 = -1.; const DELETION_SCORE: f64 = -5.; - const EQUALITY_BASE: f64 = 1.4; - const MAX_EQUALITY_EXPONENT: i32 = 64; + const EQUALITY_BASE: f64 = 2.; + const MAX_EQUALITY_EXPONENT: i32 = 20; pub fn new(old: String) -> Self { let old = old.chars().collect::>(); @@ -117,12 +117,8 @@ impl StreamingDiff { equal_run += 1; self.equal_runs.insert((i, j), equal_run); - if self.old[i - 1] == ' ' { - self.scores.get(i - 1, j - 1) - } else { - let exponent = cmp::min(equal_run as i32, Self::MAX_EQUALITY_EXPONENT); - self.scores.get(i - 1, j - 1) + Self::EQUALITY_BASE.powi(exponent) - } + let exponent = cmp::min(equal_run as i32 / 4, Self::MAX_EQUALITY_EXPONENT); + self.scores.get(i - 1, j - 1) + Self::EQUALITY_BASE.powi(exponent) } else { f64::NEG_INFINITY }; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 904e77c9f0..0283b396f3 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -8209,7 +8209,7 @@ impl View for Editor { "Editor" } - fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + fn focus_in(&mut self, focused: AnyViewHandle, cx: &mut ViewContext) { if cx.is_self_focused() { let focused_event = EditorFocused(cx.handle()); cx.emit(Event::Focused); @@ -8217,7 +8217,7 @@ impl View for Editor { } if let Some(rename) = self.pending_rename.as_ref() { cx.focus(&rename.editor); - } else { + } else if cx.is_self_focused() || !focused.is::() { if !self.focused { self.blink_manager.update(cx, BlinkManager::enable); } diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index 88c66d5200..0990fdbcb7 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -626,7 +626,7 @@ impl MultiBuffer { buffer.merge_transactions(transaction, destination) }); } else { - if let Some(transaction) = self.history.remove_transaction(transaction) { + if let Some(transaction) = self.history.forget(transaction) { if let Some(destination) = self.history.transaction_mut(destination) { for (buffer_id, buffer_transaction_id) in transaction.buffer_transactions { if let Some(destination_buffer_transaction_id) = @@ -822,6 +822,18 @@ impl MultiBuffer { None } + pub fn undo_and_forget(&mut self, transaction_id: TransactionId, cx: &mut ModelContext) { + if let Some(buffer) = self.as_singleton() { + buffer.update(cx, |buffer, cx| buffer.undo_and_forget(transaction_id, cx)); + } else if let Some(transaction) = self.history.forget(transaction_id) { + for (buffer_id, transaction_id) in transaction.buffer_transactions { + if let Some(BufferState { buffer, .. }) = self.buffers.borrow().get(&buffer_id) { + buffer.update(cx, |buffer, cx| buffer.undo_and_forget(transaction_id, cx)); + } + } + } + } + pub fn stream_excerpts_with_context_lines( &mut self, excerpts: Vec<(ModelHandle, Vec>)>, @@ -3369,7 +3381,7 @@ impl History { } } - fn remove_transaction(&mut self, transaction_id: TransactionId) -> Option { + fn forget(&mut self, transaction_id: TransactionId) -> Option { if let Some(ix) = self .undo_stack .iter() diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index e2154f498e..e8bbe29b47 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1664,6 +1664,22 @@ impl Buffer { } } + pub fn undo_and_forget( + &mut self, + transaction_id: TransactionId, + cx: &mut ModelContext, + ) -> bool { + let was_dirty = self.is_dirty(); + let old_version = self.version.clone(); + if let Some(operation) = self.text.undo_and_forget(transaction_id) { + self.send_operation(Operation::Buffer(operation), cx); + self.did_edit(&old_version, was_dirty, cx); + true + } else { + false + } + } + pub fn undo_to_transaction( &mut self, transaction_id: TransactionId, diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 8f15535ccf..02f1be718f 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -22,6 +22,7 @@ use postage::{oneshot, prelude::*}; pub use rope::*; pub use selection::*; +use util::ResultExt; use std::{ cmp::{self, Ordering, Reverse}, @@ -1206,6 +1207,14 @@ impl Buffer { } } + pub fn undo_and_forget(&mut self, transaction_id: TransactionId) -> Option { + if let Some(transaction) = self.history.forget(transaction_id) { + self.undo_or_redo(transaction).log_err() + } else { + None + } + } + #[allow(clippy::needless_collect)] pub fn undo_to_transaction(&mut self, transaction_id: TransactionId) -> Vec { let transactions = self diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index ebc9591239..02d0de4905 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -1124,14 +1124,15 @@ pub struct AssistantStyle { pub api_key_editor: FieldEditor, pub api_key_prompt: ContainedText, pub saved_conversation: SavedConversation, - pub modal: ModalAssistantStyle, + pub inline: InlineAssistantStyle, } #[derive(Clone, Deserialize, Default, JsonSchema)] -pub struct ModalAssistantStyle { +pub struct InlineAssistantStyle { #[serde(flatten)] pub container: ContainerStyle, pub editor: FieldEditor, + pub pending_edit_background: Color, } #[derive(Clone, Deserialize, Default, JsonSchema)] diff --git a/styles/src/style_tree/assistant.ts b/styles/src/style_tree/assistant.ts index ac91d1118d..97bb3402b6 100644 --- a/styles/src/style_tree/assistant.ts +++ b/styles/src/style_tree/assistant.ts @@ -59,7 +59,7 @@ export default function assistant(): any { background: background(theme.highest), padding: { left: 12 }, }, - modal: { + inline: { border: border(theme.lowest, "on", { top: true, bottom: true, @@ -69,7 +69,8 @@ export default function assistant(): any { text: text(theme.lowest, "mono", "on", { size: "sm" }), placeholder_text: text(theme.lowest, "sans", "on", "disabled"), selection: theme.players[0], - } + }, + pending_edit_background: background(theme.highest, "positive"), }, message_header: { margin: { bottom: 4, top: 4 }, From 66a496edd7e484f4db0076c86c4fdbb5fba4ac6b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 25 Aug 2023 12:16:28 +0200 Subject: [PATCH 30/60] Allow generating code without editing it --- crates/ai/src/assistant.rs | 159 +++++++++++++++++++----------- crates/editor/src/multi_buffer.rs | 10 ++ 2 files changed, 112 insertions(+), 57 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 58be0fe584..4c75506b7a 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -244,40 +244,47 @@ impl AssistantPanel { fn new_inline_assist(&mut self, editor: &ViewHandle, cx: &mut ViewContext) { let id = post_inc(&mut self.next_inline_assist_id); - let (block_id, inline_assistant, selection) = editor.update(cx, |editor, cx| { - let selection = editor.selections.newest_anchor().clone(); - let prompt_editor = cx.add_view(|cx| { - Editor::single_line( - Some(Arc::new(|theme| theme.assistant.inline.editor.clone())), - cx, - ) - }); - let assist_kind = if editor.selections.newest::(cx).is_empty() { - InlineAssistKind::Insert - } else { - InlineAssistKind::Edit - }; - let assistant = cx.add_view(|_| InlineAssistant { + let selection = editor.read(cx).selections.newest_anchor().clone(); + let assist_kind = if editor.read(cx).selections.newest::(cx).is_empty() { + InlineAssistKind::Insert + } else { + InlineAssistKind::Refactor + }; + let prompt_editor = cx.add_view(|cx| { + Editor::single_line( + Some(Arc::new(|theme| theme.assistant.inline.editor.clone())), + cx, + ) + }); + let inline_assistant = cx.add_view(|cx| { + let assistant = InlineAssistant { id, prompt_editor, confirmed: false, has_focus: false, assist_kind, - }); - cx.focus(&assistant); - - let block_id = editor.insert_blocks( + }; + cx.focus_self(); + assistant + }); + let block_id = editor.update(cx, |editor, cx| { + editor.highlight_background::( + vec![selection.start..selection.end], + |theme| theme.assistant.inline.pending_edit_background, + cx, + ); + editor.insert_blocks( [BlockProperties { style: BlockStyle::Flex, position: selection.head(), height: 2, render: Arc::new({ - let assistant = assistant.clone(); + let inline_assistant = inline_assistant.clone(); move |cx: &mut BlockContext| { - ChildView::new(&assistant, cx) + ChildView::new(&inline_assistant, cx) .contained() .with_padding_left(match assist_kind { - InlineAssistKind::Edit => cx.gutter_width, + InlineAssistKind::Refactor => cx.gutter_width, InlineAssistKind::Insert => cx.anchor_x, }) .into_any() @@ -291,19 +298,13 @@ impl AssistantPanel { }], Some(Autoscroll::Strategy(AutoscrollStrategy::Newest)), cx, - )[0]; - editor.highlight_background::( - vec![selection.start..selection.end], - |theme| theme.assistant.inline.pending_edit_background, - cx, - ); - - (block_id, assistant, selection) + )[0] }); self.pending_inline_assists.insert( id, PendingInlineAssist { + kind: assist_kind, editor: editor.downgrade(), selection, inline_assistant_block_id: Some(block_id), @@ -341,7 +342,7 @@ impl AssistantPanel { let assist_id = inline_assistant.read(cx).id; match event { InlineAssistantEvent::Confirmed { prompt } => { - self.generate_code(assist_id, prompt, cx); + self.generate(assist_id, prompt, cx); } InlineAssistantEvent::Canceled => { self.complete_inline_assist(assist_id, true, cx); @@ -395,12 +396,7 @@ impl AssistantPanel { } } - pub fn generate_code( - &mut self, - inline_assist_id: usize, - user_prompt: &str, - cx: &mut ViewContext, - ) { + fn generate(&mut self, inline_assist_id: usize, user_prompt: &str, cx: &mut ViewContext) { let api_key = if let Some(api_key) = self.api_key.borrow().clone() { api_key } else { @@ -426,27 +422,32 @@ impl AssistantPanel { .text_for_range(selection.start..selection.end) .collect::(); - let mut normalized_selected_text = selected_text.clone(); let mut base_indentation: Option = None; let selection_start = selection.start.to_point(&snapshot); let selection_end = selection.end.to_point(&snapshot); - if selection_start.row < selection_end.row { - for row in selection_start.row..=selection_end.row { - if snapshot.is_line_blank(row) { - continue; - } - - let line_indentation = snapshot.indent_size_for_line(row); - if let Some(base_indentation) = base_indentation.as_mut() { - if line_indentation.len < base_indentation.len { - *base_indentation = line_indentation; - } - } else { - base_indentation = Some(line_indentation); - } + let mut start_row = selection_start.row; + if snapshot.is_line_blank(start_row) { + if let Some(prev_non_blank_row) = snapshot.prev_non_blank_row(start_row) { + start_row = prev_non_blank_row; } } + for row in start_row..=selection_end.row { + if snapshot.is_line_blank(row) { + continue; + } + + let line_indentation = snapshot.indent_size_for_line(row); + if let Some(base_indentation) = base_indentation.as_mut() { + if line_indentation.len < base_indentation.len { + *base_indentation = line_indentation; + } + } else { + base_indentation = Some(line_indentation); + } + } + + let mut normalized_selected_text = selected_text.clone(); if let Some(base_indentation) = base_indentation { for row in selection_start.row..=selection_end.row { let selection_row = row - selection_start.row; @@ -472,10 +473,53 @@ impl AssistantPanel { let language_name = language_name.as_deref().unwrap_or(""); let mut prompt = String::new(); - writeln!(prompt, "Given the following {language_name} snippet:").unwrap(); - writeln!(prompt, "{normalized_selected_text}").unwrap(); - writeln!(prompt, "{user_prompt}.").unwrap(); - writeln!(prompt, "Never make remarks, reply only with the new code.").unwrap(); + writeln!(prompt, "You're an expert {language_name} engineer.").unwrap(); + writeln!( + prompt, + "You're currently working inside an editor on this code:" + ) + .unwrap(); + match pending_assist.kind { + InlineAssistKind::Refactor => { + writeln!(prompt, "```{language_name}").unwrap(); + writeln!(prompt, "{normalized_selected_text}").unwrap(); + writeln!(prompt, "```").unwrap(); + writeln!( + prompt, + "Modify the code given the user prompt: {user_prompt}" + ) + .unwrap(); + } + InlineAssistKind::Insert => { + writeln!(prompt, "```{language_name}").unwrap(); + for chunk in snapshot.text_for_range(Anchor::min()..selection.head()) { + write!(prompt, "{chunk}").unwrap(); + } + write!(prompt, "<|>").unwrap(); + for chunk in snapshot.text_for_range(selection.head()..Anchor::max()) { + write!(prompt, "{chunk}").unwrap(); + } + writeln!(prompt).unwrap(); + writeln!(prompt, "```").unwrap(); + writeln!( + prompt, + "Assume the cursor is located where the `<|>` marker is." + ) + .unwrap(); + writeln!( + prompt, + "Complete the code given the user prompt: {user_prompt}" + ) + .unwrap(); + } + } + writeln!( + prompt, + "You MUST not return anything that isn't valid {language_name}" + ) + .unwrap(); + writeln!(prompt, "DO NOT wrap your response in Markdown blocks.").unwrap(); + let request = OpenAIRequest { model: "gpt-4".into(), messages: vec![RequestMessage { @@ -2589,7 +2633,7 @@ enum InlineAssistantEvent { #[derive(Copy, Clone)] enum InlineAssistKind { - Edit, + Refactor, Insert, } @@ -2614,7 +2658,7 @@ impl View for InlineAssistant { let theme = theme::current(cx); let prompt_editor = ChildView::new(&self.prompt_editor, cx).aligned().left(); match self.assist_kind { - InlineAssistKind::Edit => prompt_editor + InlineAssistKind::Refactor => prompt_editor .contained() .with_style(theme.assistant.inline.container) .into_any(), @@ -2651,6 +2695,7 @@ impl InlineAssistant { } struct PendingInlineAssist { + kind: InlineAssistKind, editor: WeakViewHandle, selection: Selection, inline_assistant_block_id: Option, diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index 0990fdbcb7..ac3b726b26 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -2352,6 +2352,16 @@ impl MultiBufferSnapshot { } } + pub fn prev_non_blank_row(&self, mut row: u32) -> Option { + while row > 0 { + row -= 1; + if !self.is_line_blank(row) { + return Some(row); + } + } + None + } + pub fn line_len(&self, row: u32) -> u32 { if let Some((_, range)) = self.buffer_line_for_row(row) { range.end.column - range.start.column From 144f5c5d41a5c6d02bc5f0a2066aca6dec36f91c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 25 Aug 2023 12:25:43 +0200 Subject: [PATCH 31/60] Use a left bias for the prompt editor --- crates/ai/src/assistant.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 4c75506b7a..e68d3ef7b8 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -276,7 +276,9 @@ impl AssistantPanel { editor.insert_blocks( [BlockProperties { style: BlockStyle::Flex, - position: selection.head(), + position: selection + .head() + .bias_left(&editor.buffer().read(cx).snapshot(cx)), height: 2, render: Arc::new({ let inline_assistant = inline_assistant.clone(); @@ -506,6 +508,7 @@ impl AssistantPanel { "Assume the cursor is located where the `<|>` marker is." ) .unwrap(); + writeln!(prompt, "Assume your answer will be inserted at the cursor.").unwrap(); writeln!( prompt, "Complete the code given the user prompt: {user_prompt}" @@ -513,12 +516,9 @@ impl AssistantPanel { .unwrap(); } } - writeln!( - prompt, - "You MUST not return anything that isn't valid {language_name}" - ) - .unwrap(); + writeln!(prompt, "Your answer MUST always be valid {language_name}.").unwrap(); writeln!(prompt, "DO NOT wrap your response in Markdown blocks.").unwrap(); + writeln!(prompt, "Never make remarks, always output code.").unwrap(); let request = OpenAIRequest { model: "gpt-4".into(), From 971c833e8008c6a488b10a0e59ba8c62f478ecd6 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 25 Aug 2023 12:35:36 +0200 Subject: [PATCH 32/60] Improve background highlighting of inline assists --- crates/ai/src/assistant.rs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index e68d3ef7b8..9311a00751 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -26,7 +26,7 @@ use gpui::{ Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; use language::{ - language_settings::SoftWrap, Buffer, LanguageRegistry, Point, Rope, Selection, ToOffset as _, + language_settings::SoftWrap, Buffer, LanguageRegistry, Point, Rope, ToOffset as _, TransactionId, }; use search::BufferSearchBar; @@ -244,7 +244,9 @@ impl AssistantPanel { fn new_inline_assist(&mut self, editor: &ViewHandle, cx: &mut ViewContext) { let id = post_inc(&mut self.next_inline_assist_id); + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); let selection = editor.read(cx).selections.newest_anchor().clone(); + let range = selection.start.bias_left(&snapshot)..selection.end.bias_right(&snapshot); let assist_kind = if editor.read(cx).selections.newest::(cx).is_empty() { InlineAssistKind::Insert } else { @@ -269,16 +271,14 @@ impl AssistantPanel { }); let block_id = editor.update(cx, |editor, cx| { editor.highlight_background::( - vec![selection.start..selection.end], + vec![range.clone()], |theme| theme.assistant.inline.pending_edit_background, cx, ); editor.insert_blocks( [BlockProperties { style: BlockStyle::Flex, - position: selection - .head() - .bias_left(&editor.buffer().read(cx).snapshot(cx)), + position: selection.head().bias_left(&snapshot), height: 2, render: Arc::new({ let inline_assistant = inline_assistant.clone(); @@ -308,7 +308,7 @@ impl AssistantPanel { PendingInlineAssist { kind: assist_kind, editor: editor.downgrade(), - selection, + range, inline_assistant_block_id: Some(block_id), code_generation: Task::ready(None), transaction_id: None, @@ -418,15 +418,15 @@ impl AssistantPanel { return; }; - let selection = pending_assist.selection.clone(); + let range = pending_assist.range.clone(); let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); let selected_text = snapshot - .text_for_range(selection.start..selection.end) + .text_for_range(range.start..range.end) .collect::(); let mut base_indentation: Option = None; - let selection_start = selection.start.to_point(&snapshot); - let selection_end = selection.end.to_point(&snapshot); + let selection_start = range.start.to_point(&snapshot); + let selection_end = range.end.to_point(&snapshot); let mut start_row = selection_start.row; if snapshot.is_line_blank(start_row) { if let Some(prev_non_blank_row) = snapshot.prev_non_blank_row(start_row) { @@ -470,7 +470,7 @@ impl AssistantPanel { } let language_name = snapshot - .language_at(selection.start) + .language_at(range.start) .map(|language| language.name()); let language_name = language_name.as_deref().unwrap_or(""); @@ -494,11 +494,11 @@ impl AssistantPanel { } InlineAssistKind::Insert => { writeln!(prompt, "```{language_name}").unwrap(); - for chunk in snapshot.text_for_range(Anchor::min()..selection.head()) { + for chunk in snapshot.text_for_range(Anchor::min()..range.start) { write!(prompt, "{chunk}").unwrap(); } write!(prompt, "<|>").unwrap(); - for chunk in snapshot.text_for_range(selection.head()..Anchor::max()) { + for chunk in snapshot.text_for_range(range.start..Anchor::max()) { write!(prompt, "{chunk}").unwrap(); } writeln!(prompt).unwrap(); @@ -543,7 +543,7 @@ impl AssistantPanel { } }); - let mut edit_start = selection.start.to_offset(&snapshot); + let mut edit_start = range.start.to_offset(&snapshot); let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); let diff = cx.background().spawn(async move { @@ -2697,7 +2697,7 @@ impl InlineAssistant { struct PendingInlineAssist { kind: InlineAssistKind, editor: WeakViewHandle, - selection: Selection, + range: Range, inline_assistant_block_id: Option, code_generation: Task>, transaction_id: Option, From 0444b5a7757abce9dab6cbdf218014cc07e72c4a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 25 Aug 2023 13:36:52 +0200 Subject: [PATCH 33/60] :lipstick: --- crates/ai/src/assistant.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 9311a00751..b8458bb9ac 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -214,12 +214,12 @@ impl AssistantPanel { } fn inline_assist(workspace: &mut Workspace, _: &InlineAssist, cx: &mut ViewContext) { - let assistant = if let Some(assistant) = workspace.panel::(cx) { - if assistant + let this = if let Some(this) = workspace.panel::(cx) { + if this .update(cx, |assistant, cx| assistant.load_api_key(cx)) .is_some() { - assistant + this } else { workspace.focus_panel::(cx); return; @@ -237,7 +237,7 @@ impl AssistantPanel { return; }; - assistant.update(cx, |assistant, cx| { + this.update(cx, |assistant, cx| { assistant.new_inline_assist(&active_editor, cx) }); } @@ -1046,7 +1046,7 @@ impl AssistantPanel { .position(|editor| editor.read(cx).conversation.read(cx).path.as_deref() == Some(path)) } - pub fn load_api_key(&mut self, cx: &mut ViewContext) -> Option { + fn load_api_key(&mut self, cx: &mut ViewContext) -> Option { if self.api_key.borrow().is_none() && !self.has_read_credentials { self.has_read_credentials = true; let api_key = if let Ok(api_key) = env::var("OPENAI_API_KEY") { From fdbf4680bb4b68d6eb760022e4e7560252fda5ee Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 25 Aug 2023 15:38:38 +0200 Subject: [PATCH 34/60] Ensure the inline assistant works with gpt-3.5 --- crates/ai/src/assistant.rs | 137 ++++++++++++++++++++++++++++++++----- 1 file changed, 118 insertions(+), 19 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index cfe3da22b5..4405712afe 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -344,7 +344,7 @@ impl AssistantPanel { let assist_id = inline_assistant.read(cx).id; match event { InlineAssistantEvent::Confirmed { prompt } => { - self.generate(assist_id, prompt, cx); + self.confirm_inline_assist(assist_id, prompt, cx); } InlineAssistantEvent::Canceled => { self.complete_inline_assist(assist_id, true, cx); @@ -398,7 +398,12 @@ impl AssistantPanel { } } - fn generate(&mut self, inline_assist_id: usize, user_prompt: &str, cx: &mut ViewContext) { + fn confirm_inline_assist( + &mut self, + inline_assist_id: usize, + user_prompt: &str, + cx: &mut ViewContext, + ) { let api_key = if let Some(api_key) = self.api_key.borrow().clone() { api_key } else { @@ -473,26 +478,51 @@ impl AssistantPanel { .language_at(range.start) .map(|language| language.name()); let language_name = language_name.as_deref().unwrap_or(""); + let model = settings::get::(cx) + .default_open_ai_model + .clone(); let mut prompt = String::new(); writeln!(prompt, "You're an expert {language_name} engineer.").unwrap(); - writeln!( - prompt, - "You're currently working inside an editor on this code:" - ) - .unwrap(); match pending_assist.kind { InlineAssistKind::Refactor => { + writeln!( + prompt, + "You're currently working inside an editor on this code:" + ) + .unwrap(); + writeln!(prompt, "```{language_name}").unwrap(); + for chunk in snapshot.text_for_range(Anchor::min()..Anchor::max()) { + write!(prompt, "{chunk}").unwrap(); + } + writeln!(prompt, "```").unwrap(); + + writeln!( + prompt, + "In particular, the user has selected the following code:" + ) + .unwrap(); writeln!(prompt, "```{language_name}").unwrap(); writeln!(prompt, "{normalized_selected_text}").unwrap(); writeln!(prompt, "```").unwrap(); + writeln!(prompt).unwrap(); writeln!( prompt, - "Modify the code given the user prompt: {user_prompt}" + "Modify the selected code given the user prompt: {user_prompt}" + ) + .unwrap(); + writeln!( + prompt, + "You MUST reply only with the edited selected code, not the entire file." ) .unwrap(); } InlineAssistKind::Insert => { + writeln!( + prompt, + "You're currently working inside an editor on this code:" + ) + .unwrap(); writeln!(prompt, "```{language_name}").unwrap(); for chunk in snapshot.text_for_range(Anchor::min()..range.start) { write!(prompt, "{chunk}").unwrap(); @@ -517,11 +547,11 @@ impl AssistantPanel { } } writeln!(prompt, "Your answer MUST always be valid {language_name}.").unwrap(); - writeln!(prompt, "DO NOT wrap your response in Markdown blocks.").unwrap(); + writeln!(prompt, "Always wrap your response in a Markdown codeblock.").unwrap(); writeln!(prompt, "Never make remarks, always output code.").unwrap(); let request = OpenAIRequest { - model: "gpt-4".into(), + model: model.full_name().into(), messages: vec![RequestMessage { role: Role::User, content: prompt, @@ -563,23 +593,92 @@ impl AssistantPanel { indentation_text = ""; }; - let mut new_text = indentation_text - .repeat(indentation_len.saturating_sub(selection_start.column) as usize); + let mut inside_first_line = true; + let mut starts_with_fenced_code_block = None; + let mut has_pending_newline = false; + let mut new_text = String::new(); + while let Some(message) = messages.next().await { let mut message = message?; - if let Some(choice) = message.choices.pop() { + if let Some(mut choice) = message.choices.pop() { + if has_pending_newline { + has_pending_newline = false; + choice + .delta + .content + .get_or_insert(String::new()) + .insert(0, '\n'); + } + + // Buffer a trailing codeblock fence. Note that we don't stop + // right away because this may be an inner fence that we need + // to insert into the editor. + if starts_with_fenced_code_block.is_some() + && choice.delta.content.as_deref() == Some("\n```") + { + new_text.push_str("\n```"); + continue; + } + + // If this was the last completion and we started with a codeblock + // fence and we ended with another codeblock fence, then we can + // stop right away. Otherwise, whatever text we buffered will be + // processed normally. + if choice.finish_reason.is_some() + && starts_with_fenced_code_block.unwrap_or(false) + && new_text == "\n```" + { + break; + } + if let Some(text) = choice.delta.content { + // Never push a newline if there's nothing after it. This is + // useful to detect if the newline was pushed because of a + // trailing codeblock fence. + let text = if let Some(prefix) = text.strip_suffix('\n') { + has_pending_newline = true; + prefix + } else { + text.as_str() + }; + + if text.is_empty() { + continue; + } + let mut lines = text.split('\n'); - if let Some(first_line) = lines.next() { - new_text.push_str(&first_line); + if let Some(line) = lines.next() { + if starts_with_fenced_code_block.is_none() { + starts_with_fenced_code_block = + Some(line.starts_with("```")); + } + + // Avoid pushing the first line if it's the start of a fenced code block. + if !inside_first_line || !starts_with_fenced_code_block.unwrap() + { + new_text.push_str(&line); + } } for line in lines { - new_text.push('\n'); - new_text.push_str( - &indentation_text.repeat(indentation_len as usize), - ); + if inside_first_line && starts_with_fenced_code_block.unwrap() { + // If we were inside the first line and that line was the + // start of a fenced code block, we just need to push the + // leading indentation of the original selection. + new_text.push_str(&indentation_text.repeat( + indentation_len.saturating_sub(selection_start.column) + as usize, + )); + } else { + // Otherwise, we need to push a newline and the base indentation. + new_text.push('\n'); + new_text.push_str( + &indentation_text.repeat(indentation_len as usize), + ); + } + new_text.push_str(line); + inside_first_line = false; } } } From b101a7edffe582a218a07ec93b55afa052ae3222 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 25 Aug 2023 15:54:52 +0200 Subject: [PATCH 35/60] Cancel last inline assist when escaping from the editor --- crates/ai/src/assistant.rs | 66 ++++++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 4405712afe..a1de7bf736 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -7,7 +7,7 @@ use crate::{ }; use anyhow::{anyhow, Result}; use chrono::{DateTime, Local}; -use collections::{HashMap, HashSet}; +use collections::{hash_map, HashMap, HashSet}; use editor::{ display_map::{ BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint, @@ -93,6 +93,7 @@ pub fn init(cx: &mut AppContext) { }, ); cx.add_action(AssistantPanel::inline_assist); + cx.add_action(AssistantPanel::cancel_last_inline_assist); cx.add_action(InlineAssistant::confirm); cx.add_action(InlineAssistant::cancel); } @@ -347,25 +348,64 @@ impl AssistantPanel { self.confirm_inline_assist(assist_id, prompt, cx); } InlineAssistantEvent::Canceled => { - self.complete_inline_assist(assist_id, true, cx); + self.close_inline_assist(assist_id, true, cx); } InlineAssistantEvent::Dismissed => { - self.dismiss_inline_assist(assist_id, cx); + self.hide_inline_assist(assist_id, cx); } } } - fn complete_inline_assist( - &mut self, - assist_id: usize, - cancel: bool, - cx: &mut ViewContext, + fn cancel_last_inline_assist( + workspace: &mut Workspace, + _: &editor::Cancel, + cx: &mut ViewContext, ) { - self.dismiss_inline_assist(assist_id, cx); + let panel = if let Some(panel) = workspace.panel::(cx) { + panel + } else { + return; + }; + let editor = if let Some(editor) = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + { + editor + } else { + return; + }; + + let handled = panel.update(cx, |panel, cx| { + if let Some(assist_id) = panel + .pending_inline_assist_ids_by_editor + .get(&editor.downgrade()) + .and_then(|assist_ids| assist_ids.last().copied()) + { + panel.close_inline_assist(assist_id, true, cx); + true + } else { + false + } + }); + + if !handled { + cx.propagate_action(); + } + } + + fn close_inline_assist(&mut self, assist_id: usize, cancel: bool, cx: &mut ViewContext) { + self.hide_inline_assist(assist_id, cx); if let Some(pending_assist) = self.pending_inline_assists.remove(&assist_id) { - self.pending_inline_assist_ids_by_editor - .remove(&pending_assist.editor); + if let hash_map::Entry::Occupied(mut entry) = self + .pending_inline_assist_ids_by_editor + .entry(pending_assist.editor) + { + entry.get_mut().retain(|id| *id != assist_id); + if entry.get().is_empty() { + entry.remove(); + } + } if let Some(editor) = pending_assist.editor.upgrade(cx) { editor.update(cx, |editor, cx| { @@ -386,7 +426,7 @@ impl AssistantPanel { } } - fn dismiss_inline_assist(&mut self, assist_id: usize, cx: &mut ViewContext) { + fn hide_inline_assist(&mut self, assist_id: usize, cx: &mut ViewContext) { if let Some(pending_assist) = self.pending_inline_assists.get_mut(&assist_id) { if let Some(editor) = pending_assist.editor.upgrade(cx) { if let Some(block_id) = pending_assist.inline_assistant_block_id.take() { @@ -568,7 +608,7 @@ impl AssistantPanel { let this = this.clone(); move || { let _ = this.update(&mut cx, |this, cx| { - this.complete_inline_assist(inline_assist_id, false, cx) + this.close_inline_assist(inline_assist_id, false, cx) }); } }); From 75a6a94e96a6bfcff40747449f1712bd1f55a2bc Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 25 Aug 2023 17:24:26 +0200 Subject: [PATCH 36/60] Add placeholder text for inline assistant prompts --- crates/ai/src/assistant.rs | 43 +++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index a1de7bf736..326dbfc046 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -249,15 +249,21 @@ impl AssistantPanel { let selection = editor.read(cx).selections.newest_anchor().clone(); let range = selection.start.bias_left(&snapshot)..selection.end.bias_right(&snapshot); let assist_kind = if editor.read(cx).selections.newest::(cx).is_empty() { - InlineAssistKind::Insert + InlineAssistKind::Generate } else { - InlineAssistKind::Refactor + InlineAssistKind::Transform }; let prompt_editor = cx.add_view(|cx| { - Editor::single_line( + let mut editor = Editor::single_line( Some(Arc::new(|theme| theme.assistant.inline.editor.clone())), cx, - ) + ); + let placeholder = match assist_kind { + InlineAssistKind::Transform => "Enter transformation prompt…", + InlineAssistKind::Generate => "Enter generation prompt…", + }; + editor.set_placeholder_text(placeholder, cx); + editor }); let inline_assistant = cx.add_view(|cx| { let assistant = InlineAssistant { @@ -284,12 +290,12 @@ impl AssistantPanel { render: Arc::new({ let inline_assistant = inline_assistant.clone(); move |cx: &mut BlockContext| { + let theme = theme::current(cx); ChildView::new(&inline_assistant, cx) .contained() - .with_padding_left(match assist_kind { - InlineAssistKind::Refactor => cx.gutter_width, - InlineAssistKind::Insert => cx.anchor_x, - }) + .with_padding_left(cx.anchor_x) + .contained() + .with_style(theme.assistant.inline.container) .into_any() } }), @@ -525,7 +531,7 @@ impl AssistantPanel { let mut prompt = String::new(); writeln!(prompt, "You're an expert {language_name} engineer.").unwrap(); match pending_assist.kind { - InlineAssistKind::Refactor => { + InlineAssistKind::Transform => { writeln!( prompt, "You're currently working inside an editor on this code:" @@ -557,7 +563,7 @@ impl AssistantPanel { ) .unwrap(); } - InlineAssistKind::Insert => { + InlineAssistKind::Generate => { writeln!( prompt, "You're currently working inside an editor on this code:" @@ -2775,8 +2781,8 @@ enum InlineAssistantEvent { #[derive(Copy, Clone)] enum InlineAssistKind { - Refactor, - Insert, + Transform, + Generate, } struct InlineAssistant { @@ -2797,15 +2803,10 @@ impl View for InlineAssistant { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - let theme = theme::current(cx); - let prompt_editor = ChildView::new(&self.prompt_editor, cx).aligned().left(); - match self.assist_kind { - InlineAssistKind::Refactor => prompt_editor - .contained() - .with_style(theme.assistant.inline.container) - .into_any(), - InlineAssistKind::Insert => prompt_editor.into_any(), - } + ChildView::new(&self.prompt_editor, cx) + .aligned() + .left() + .into_any() } fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { From c4966ff57a24e8d7cdecbaa08cfccc6aa2109f0f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 25 Aug 2023 17:35:14 +0200 Subject: [PATCH 37/60] Remove warning --- crates/ai/src/assistant.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 326dbfc046..65b255d458 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -271,7 +271,6 @@ impl AssistantPanel { prompt_editor, confirmed: false, has_focus: false, - assist_kind, }; cx.focus_self(); assistant @@ -2789,7 +2788,6 @@ struct InlineAssistant { id: usize, prompt_editor: ViewHandle, confirmed: bool, - assist_kind: InlineAssistKind, has_focus: bool, } From 7c5200e757feaec01cdbef34d8b2ad8d528c728f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 25 Aug 2023 17:51:13 +0200 Subject: [PATCH 38/60] More styling --- styles/src/style_tree/assistant.ts | 3 +- todo.md | 45 +++++++++++------------------- 2 files changed, 19 insertions(+), 29 deletions(-) diff --git a/styles/src/style_tree/assistant.ts b/styles/src/style_tree/assistant.ts index 97bb3402b6..bdca8a16e5 100644 --- a/styles/src/style_tree/assistant.ts +++ b/styles/src/style_tree/assistant.ts @@ -60,13 +60,14 @@ export default function assistant(): any { padding: { left: 12 }, }, inline: { + margin: { top: 3, bottom: 3 }, border: border(theme.lowest, "on", { top: true, bottom: true, overlay: true, }), editor: { - text: text(theme.lowest, "mono", "on", { size: "sm" }), + text: text(theme.highest, "mono", "default", { size: "sm" }), placeholder_text: text(theme.lowest, "sans", "on", "disabled"), selection: theme.players[0], }, diff --git a/todo.md b/todo.md index e07d19bc95..8a9a8b5b3d 100644 --- a/todo.md +++ b/todo.md @@ -1,36 +1,25 @@ -- Style the current inline editor -- Find a way to understand whether we want to refactor or append, or both. (function calls) -- Add a system prompt that makes GPT an expert of language X -- Provide context around the cursor/selection. We should try to fill the context window as much as possible (try to fill half of it so that we can spit out another half) -- When you hit escape, the assistant should stop. -- When you hit undo and you undo a transaction from the assistant, we should stop generating. -- Keep the inline editor around until the assistant is done. Add a cancel button to stop, and and undo button to undo the whole thing. (Interactive) - - -# 9:39 AM - -- Hit `ctrl-enter` - -- Puts me in assistant mode with the selected text highlighted in a special color. If text was selected, I'm in transformation mode. -- If there's no selection, put me on the line below, aligned with the indent of the line. -- Enter starts generation -- Ctrl-enter inserts a newline -- Once generations starts, enter "confirms it" by dismissing the inline editor. -- Escape in the inline editor cancels/undoes/dismisses. -- To generate text in reference to other text, we can *mark* text. - - - Hit ctrl-enter deploys an edit prompt - Empty selection (cursor) => append text - On end of line: Edit prompt on end of line. - - Middle of line: Edit prompt near cursor head on a different line + - [x] Middle of line: Edit prompt near cursor head on a different line - Non-empty selection => refactor - - Edit prompt near cursor head on a different line - - What was selected when you hit ctrl-enter is colored. -- Selection is cleared and cursor is moved to prompt input + - [x] Edit prompt near cursor head on a different line + - [x] What was selected when you hit ctrl-enter is colored. +- [x] Add placeholder text + - If non-empty selection: Enter prompt to transform selected text + - If empty selection: Enter prompt to generate text - When cursor is inside a prompt - - Escape cancels/undoes - - Enter confirms + - [x] Escape cancels/undoes + - [x] Enter confirms +- [ ] Selection is cleared and cursor is moved to prompt input +- [ ] Ability to highlight background multiple times for the same type +- [x] Basic Styling +- [ ] Match lowest indentation level of selected lines when inserting an inline assist +- [ ] Look into why insert prompts have a weird indentation sometimes + + + + - Multicursor - Run the same prompt for every selection in parallel - Position the prompt editor at the newest cursor From c8e5c3963b89187ff2997395bad60f6dba5e7b10 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Sat, 26 Aug 2023 09:51:47 +0200 Subject: [PATCH 39/60] Clear selection when deploying inline assistant --- crates/ai/src/assistant.rs | 3 +++ todo.md | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 65b255d458..b99d4b4fac 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -276,6 +276,9 @@ impl AssistantPanel { assistant }); let block_id = editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |selections| { + selections.select_anchor_ranges([selection.head()..selection.head()]) + }); editor.highlight_background::( vec![range.clone()], |theme| theme.assistant.inline.pending_edit_background, diff --git a/todo.md b/todo.md index 8a9a8b5b3d..71ca5a7c7b 100644 --- a/todo.md +++ b/todo.md @@ -14,12 +14,8 @@ - [ ] Selection is cleared and cursor is moved to prompt input - [ ] Ability to highlight background multiple times for the same type - [x] Basic Styling -- [ ] Match lowest indentation level of selected lines when inserting an inline assist - [ ] Look into why insert prompts have a weird indentation sometimes - - - - Multicursor - Run the same prompt for every selection in parallel - Position the prompt editor at the newest cursor From 658d616b9661f086681b25e33b5e76620fd18b1d Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Sat, 26 Aug 2023 11:55:03 +0200 Subject: [PATCH 40/60] Allow multiple inline assistant highlights at once --- crates/ai/src/assistant.rs | 216 +++++++++++++++++++++++------------- crates/editor/src/editor.rs | 1 + todo.md | 4 +- 3 files changed, 143 insertions(+), 78 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index b99d4b4fac..c5bf027fcc 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -13,13 +13,14 @@ use editor::{ BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint, }, scroll::autoscroll::{Autoscroll, AutoscrollStrategy}, - Anchor, Editor, ToOffset, ToPoint, + Anchor, Editor, MultiBufferSnapshot, ToOffset, ToPoint, }; use fs::Fs; use futures::{channel::mpsc, SinkExt, StreamExt}; use gpui::{ actions, elements::*, + fonts::HighlightStyle, geometry::vector::{vec2f, Vector2F}, platform::{CursorStyle, MouseButton}, Action, AppContext, AsyncAppContext, ClipboardItem, Entity, ModelContext, ModelHandle, @@ -279,11 +280,6 @@ impl AssistantPanel { editor.change_selections(None, cx, |selections| { selections.select_anchor_ranges([selection.head()..selection.head()]) }); - editor.highlight_background::( - vec![range.clone()], - |theme| theme.assistant.inline.pending_edit_background, - cx, - ); editor.insert_blocks( [BlockProperties { style: BlockStyle::Flex, @@ -318,6 +314,7 @@ impl AssistantPanel { kind: assist_kind, editor: editor.downgrade(), range, + highlighted_ranges: Default::default(), inline_assistant_block_id: Some(block_id), code_generation: Task::ready(None), transaction_id: None, @@ -342,6 +339,7 @@ impl AssistantPanel { .entry(editor.downgrade()) .or_default() .push(id); + self.update_highlights_for_editor(&editor, cx); } fn handle_inline_assistant_event( @@ -416,10 +414,7 @@ impl AssistantPanel { } if let Some(editor) = pending_assist.editor.upgrade(cx) { - editor.update(cx, |editor, cx| { - editor.clear_background_highlights::(cx); - editor.clear_text_highlights::(cx); - }); + self.update_highlights_for_editor(&editor, cx); if cancel { if let Some(transaction_id) = pending_assist.transaction_id { @@ -741,78 +736,75 @@ impl AssistantPanel { }); while let Some(hunks) = hunks_rx.next().await { - let this = this + let editor = editor .upgrade(&cx) - .ok_or_else(|| anyhow!("assistant was dropped"))?; - editor.update(&mut cx, |editor, cx| { - let mut highlights = Vec::new(); + .ok_or_else(|| anyhow!("editor was dropped"))?; - let transaction = editor.buffer().update(cx, |buffer, cx| { - // Avoid grouping assistant edits with user edits. - buffer.finalize_last_transaction(cx); + this.update(&mut cx, |this, cx| { + let pending_assist = if let Some(pending_assist) = + this.pending_inline_assists.get_mut(&inline_assist_id) + { + pending_assist + } else { + return; + }; - buffer.start_transaction(cx); - buffer.edit( - hunks.into_iter().filter_map(|hunk| match hunk { - Hunk::Insert { text } => { - let edit_start = snapshot.anchor_after(edit_start); - Some((edit_start..edit_start, text)) - } - Hunk::Remove { len } => { - let edit_end = edit_start + len; - let edit_range = snapshot.anchor_after(edit_start) - ..snapshot.anchor_before(edit_end); - edit_start = edit_end; - Some((edit_range, String::new())) - } - Hunk::Keep { len } => { - let edit_end = edit_start + len; - let edit_range = snapshot.anchor_after(edit_start) - ..snapshot.anchor_before(edit_end); - edit_start += len; - highlights.push(edit_range); - None - } - }), - None, - cx, - ); + pending_assist.highlighted_ranges.clear(); + editor.update(cx, |editor, cx| { + let transaction = editor.buffer().update(cx, |buffer, cx| { + // Avoid grouping assistant edits with user edits. + buffer.finalize_last_transaction(cx); - buffer.end_transaction(cx) + buffer.start_transaction(cx); + buffer.edit( + hunks.into_iter().filter_map(|hunk| match hunk { + Hunk::Insert { text } => { + let edit_start = snapshot.anchor_after(edit_start); + Some((edit_start..edit_start, text)) + } + Hunk::Remove { len } => { + let edit_end = edit_start + len; + let edit_range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + edit_start = edit_end; + Some((edit_range, String::new())) + } + Hunk::Keep { len } => { + let edit_end = edit_start + len; + let edit_range = snapshot.anchor_after(edit_start) + ..snapshot.anchor_before(edit_end); + edit_start += len; + pending_assist.highlighted_ranges.push(edit_range); + None + } + }), + None, + cx, + ); + + buffer.end_transaction(cx) + }); + + if let Some(transaction) = transaction { + if let Some(first_transaction) = pending_assist.transaction_id { + // Group all assistant edits into the first transaction. + editor.buffer().update(cx, |buffer, cx| { + buffer.merge_transactions( + transaction, + first_transaction, + cx, + ) + }); + } else { + pending_assist.transaction_id = Some(transaction); + editor.buffer().update(cx, |buffer, cx| { + buffer.finalize_last_transaction(cx) + }); + } + } }); - if let Some(transaction) = transaction { - this.update(cx, |this, cx| { - if let Some(pending_assist) = - this.pending_inline_assists.get_mut(&inline_assist_id) - { - if let Some(first_transaction) = pending_assist.transaction_id { - // Group all assistant edits into the first transaction. - editor.buffer().update(cx, |buffer, cx| { - buffer.merge_transactions( - transaction, - first_transaction, - cx, - ) - }); - } else { - pending_assist.transaction_id = Some(transaction); - editor.buffer().update(cx, |buffer, cx| { - buffer.finalize_last_transaction(cx) - }); - } - } - }); - } - - editor.highlight_text::( - highlights, - gpui::fonts::HighlightStyle { - fade_out: Some(0.6), - ..Default::default() - }, - cx, - ); + this.update_highlights_for_editor(&editor, cx); })?; } diff.await?; @@ -823,6 +815,55 @@ impl AssistantPanel { }); } + fn update_highlights_for_editor( + &self, + editor: &ViewHandle, + cx: &mut ViewContext, + ) { + let mut background_ranges = Vec::new(); + let mut foreground_ranges = Vec::new(); + let empty_inline_assist_ids = Vec::new(); + let inline_assist_ids = self + .pending_inline_assist_ids_by_editor + .get(&editor.downgrade()) + .unwrap_or(&empty_inline_assist_ids); + + for inline_assist_id in inline_assist_ids { + if let Some(pending_assist) = self.pending_inline_assists.get(inline_assist_id) { + background_ranges.push(pending_assist.range.clone()); + foreground_ranges.extend(pending_assist.highlighted_ranges.iter().cloned()); + } + } + + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + merge_ranges(&mut background_ranges, &snapshot); + merge_ranges(&mut foreground_ranges, &snapshot); + editor.update(cx, |editor, cx| { + if background_ranges.is_empty() { + editor.clear_background_highlights::(cx); + } else { + editor.highlight_background::( + background_ranges, + |theme| theme.assistant.inline.pending_edit_background, + cx, + ); + } + + if foreground_ranges.is_empty() { + editor.clear_text_highlights::(cx); + } else { + editor.highlight_text::( + foreground_ranges, + HighlightStyle { + fade_out: Some(0.6), + ..Default::default() + }, + cx, + ); + } + }); + } + fn new_conversation(&mut self, cx: &mut ViewContext) -> ViewHandle { let editor = cx.add_view(|cx| { ConversationEditor::new( @@ -2842,12 +2883,35 @@ struct PendingInlineAssist { kind: InlineAssistKind, editor: WeakViewHandle, range: Range, + highlighted_ranges: Vec>, inline_assistant_block_id: Option, code_generation: Task>, transaction_id: Option, _subscriptions: Vec, } +fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { + ranges.sort_unstable_by(|a, b| { + a.start + .cmp(&b.start, buffer) + .then_with(|| b.end.cmp(&a.end, buffer)) + }); + + let mut ix = 0; + while ix + 1 < ranges.len() { + let b = ranges[ix + 1].clone(); + let a = &mut ranges[ix]; + if a.end.cmp(&b.start, buffer).is_gt() { + if a.end.cmp(&b.end, buffer).is_lt() { + a.end = b.end; + } + ranges.remove(ix + 1); + } else { + ix += 1; + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b206f2ec8b..d5141a7f7d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -7592,6 +7592,7 @@ impl Editor { } results } + pub fn background_highlights_in_range_for( &self, search_range: Range, diff --git a/todo.md b/todo.md index 71ca5a7c7b..0507708502 100644 --- a/todo.md +++ b/todo.md @@ -11,8 +11,8 @@ - When cursor is inside a prompt - [x] Escape cancels/undoes - [x] Enter confirms -- [ ] Selection is cleared and cursor is moved to prompt input -- [ ] Ability to highlight background multiple times for the same type +- [x] Selection is cleared and cursor is moved to prompt input +- [x] Ability to highlight background multiple times for the same type - [x] Basic Styling - [ ] Look into why insert prompts have a weird indentation sometimes From 55bf45d2657278da8c1a4d70ae4baca2099b510d Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Sat, 26 Aug 2023 12:07:03 +0200 Subject: [PATCH 41/60] Add disabled style for prompt editor after confirming --- crates/ai/src/assistant.rs | 11 +++++++++-- crates/editor/src/editor.rs | 9 +++++++++ crates/theme/src/theme.rs | 1 + styles/src/style_tree/assistant.ts | 10 +++++++++- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index c5bf027fcc..f7abcdf748 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -2871,8 +2871,15 @@ impl InlineAssistant { cx.emit(InlineAssistantEvent::Dismissed); } else { let prompt = self.prompt_editor.read(cx).text(cx); - self.prompt_editor - .update(cx, |editor, _| editor.set_read_only(true)); + self.prompt_editor.update(cx, |editor, cx| { + editor.set_read_only(true); + editor.set_field_editor_style( + Some(Arc::new(|theme| { + theme.assistant.inline.disabled_editor.clone() + })), + cx, + ); + }); cx.emit(InlineAssistantEvent::Confirmed { prompt }); self.confirmed = true; } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d5141a7f7d..75fb6006c0 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1606,6 +1606,15 @@ impl Editor { self.read_only = read_only; } + pub fn set_field_editor_style( + &mut self, + style: Option>, + cx: &mut ViewContext, + ) { + self.get_field_editor_theme = style; + cx.notify(); + } + pub fn replica_id_map(&self) -> Option<&HashMap> { self.replica_id_mapping.as_ref() } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index bc0c98bac7..7913685b7a 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -1158,6 +1158,7 @@ pub struct InlineAssistantStyle { #[serde(flatten)] pub container: ContainerStyle, pub editor: FieldEditor, + pub disabled_editor: FieldEditor, pub pending_edit_background: Color, } diff --git a/styles/src/style_tree/assistant.ts b/styles/src/style_tree/assistant.ts index bdca8a16e5..8bef2ce16b 100644 --- a/styles/src/style_tree/assistant.ts +++ b/styles/src/style_tree/assistant.ts @@ -68,9 +68,17 @@ export default function assistant(): any { }), editor: { text: text(theme.highest, "mono", "default", { size: "sm" }), - placeholder_text: text(theme.lowest, "sans", "on", "disabled"), + placeholder_text: text(theme.highest, "sans", "on", "disabled"), selection: theme.players[0], }, + disabled_editor: { + text: text(theme.highest, "mono", "disabled", { size: "sm" }), + placeholder_text: text(theme.highest, "sans", "on", "disabled"), + selection: { + cursor: text(theme.highest, "mono", "disabled").color, + selection: theme.players[0].selection, + }, + }, pending_edit_background: background(theme.highest, "positive"), }, message_header: { From 937aabfdfdd435807368068f6e47f7d03981919c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 28 Aug 2023 11:24:55 +0200 Subject: [PATCH 42/60] Extract a `strip_markdown_codeblock` function --- crates/ai/src/assistant.rs | 197 +++++++++++++++++++++---------------- 1 file changed, 110 insertions(+), 87 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index f7abcdf748..0333a723e9 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -16,7 +16,7 @@ use editor::{ Anchor, Editor, MultiBufferSnapshot, ToOffset, ToPoint, }; use fs::Fs; -use futures::{channel::mpsc, SinkExt, StreamExt}; +use futures::{channel::mpsc, SinkExt, Stream, StreamExt}; use gpui::{ actions, elements::*, @@ -620,7 +620,10 @@ impl AssistantPanel { let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); let diff = cx.background().spawn(async move { - let mut messages = response.await?; + let chunks = strip_markdown_codeblock(response.await?.filter_map( + |message| async move { message.ok()?.choices.pop()?.delta.content }, + )); + futures::pin_mut!(chunks); let mut diff = StreamingDiff::new(selected_text.to_string()); let indentation_len; @@ -636,93 +639,21 @@ impl AssistantPanel { indentation_text = ""; }; - let mut inside_first_line = true; - let mut starts_with_fenced_code_block = None; - let mut has_pending_newline = false; - let mut new_text = String::new(); + let mut new_text = indentation_text + .repeat(indentation_len.saturating_sub(selection_start.column) as usize); - while let Some(message) = messages.next().await { - let mut message = message?; - if let Some(mut choice) = message.choices.pop() { - if has_pending_newline { - has_pending_newline = false; - choice - .delta - .content - .get_or_insert(String::new()) - .insert(0, '\n'); - } + while let Some(message) = chunks.next().await { + let mut lines = message.split('\n'); + if let Some(first_line) = lines.next() { + new_text.push_str(first_line); + } - // Buffer a trailing codeblock fence. Note that we don't stop - // right away because this may be an inner fence that we need - // to insert into the editor. - if starts_with_fenced_code_block.is_some() - && choice.delta.content.as_deref() == Some("\n```") - { - new_text.push_str("\n```"); - continue; - } - - // If this was the last completion and we started with a codeblock - // fence and we ended with another codeblock fence, then we can - // stop right away. Otherwise, whatever text we buffered will be - // processed normally. - if choice.finish_reason.is_some() - && starts_with_fenced_code_block.unwrap_or(false) - && new_text == "\n```" - { - break; - } - - if let Some(text) = choice.delta.content { - // Never push a newline if there's nothing after it. This is - // useful to detect if the newline was pushed because of a - // trailing codeblock fence. - let text = if let Some(prefix) = text.strip_suffix('\n') { - has_pending_newline = true; - prefix - } else { - text.as_str() - }; - - if text.is_empty() { - continue; - } - - let mut lines = text.split('\n'); - if let Some(line) = lines.next() { - if starts_with_fenced_code_block.is_none() { - starts_with_fenced_code_block = - Some(line.starts_with("```")); - } - - // Avoid pushing the first line if it's the start of a fenced code block. - if !inside_first_line || !starts_with_fenced_code_block.unwrap() - { - new_text.push_str(&line); - } - } - - for line in lines { - if inside_first_line && starts_with_fenced_code_block.unwrap() { - // If we were inside the first line and that line was the - // start of a fenced code block, we just need to push the - // leading indentation of the original selection. - new_text.push_str(&indentation_text.repeat( - indentation_len.saturating_sub(selection_start.column) - as usize, - )); - } else { - // Otherwise, we need to push a newline and the base indentation. - new_text.push('\n'); - new_text.push_str( - &indentation_text.repeat(indentation_len as usize), - ); - } - - new_text.push_str(line); - inside_first_line = false; - } + for line in lines { + new_text.push('\n'); + if !line.is_empty() { + new_text + .push_str(&indentation_text.repeat(indentation_len as usize)); + new_text.push_str(line); } } @@ -2919,10 +2850,58 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { } } +fn strip_markdown_codeblock(stream: impl Stream) -> impl Stream { + let mut first_line = true; + let mut buffer = String::new(); + let mut starts_with_fenced_code_block = false; + stream.filter_map(move |chunk| { + buffer.push_str(&chunk); + + if first_line { + if buffer == "" || buffer == "`" || buffer == "``" { + return futures::future::ready(None); + } else if buffer.starts_with("```") { + starts_with_fenced_code_block = true; + if let Some(newline_ix) = buffer.find('\n') { + buffer.replace_range(..newline_ix + 1, ""); + first_line = false; + } else { + return futures::future::ready(None); + } + } + } + + let text = if starts_with_fenced_code_block { + buffer + .strip_suffix("\n```") + .or_else(|| buffer.strip_suffix("\n``")) + .or_else(|| buffer.strip_suffix("\n`")) + .or_else(|| buffer.strip_suffix('\n')) + .unwrap_or(&buffer) + } else { + &buffer + }; + + if text.contains('\n') { + first_line = false; + } + + let remainder = buffer.split_off(text.len()); + let result = if buffer.is_empty() { + None + } else { + Some(buffer.clone()) + }; + buffer = remainder; + futures::future::ready(result) + }) +} + #[cfg(test)] mod tests { use super::*; use crate::MessageId; + use futures::stream; use gpui::AppContext; #[gpui::test] @@ -3291,6 +3270,50 @@ mod tests { ); } + #[gpui::test] + async fn test_strip_markdown_codeblock() { + assert_eq!( + strip_markdown_codeblock(chunks("Lorem ipsum dolor", 2)) + .collect::() + .await, + "Lorem ipsum dolor" + ); + assert_eq!( + strip_markdown_codeblock(chunks("```\nLorem ipsum dolor", 2)) + .collect::() + .await, + "Lorem ipsum dolor" + ); + assert_eq!( + strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```", 2)) + .collect::() + .await, + "Lorem ipsum dolor" + ); + assert_eq!( + strip_markdown_codeblock(chunks("```html\n```js\nLorem ipsum dolor\n```\n```", 2)) + .collect::() + .await, + "```js\nLorem ipsum dolor\n```" + ); + assert_eq!( + strip_markdown_codeblock(chunks("``\nLorem ipsum dolor\n```", 2)) + .collect::() + .await, + "``\nLorem ipsum dolor\n```" + ); + + fn chunks(text: &str, size: usize) -> impl Stream { + stream::iter( + text.chars() + .collect::>() + .chunks(size) + .map(|chunk| chunk.iter().collect::()) + .collect::>(), + ) + } + } + fn messages( conversation: &ModelHandle, cx: &AppContext, From d804afcfa96e44044ad64a89d2b72fe07b2bec9b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 28 Aug 2023 11:57:02 +0200 Subject: [PATCH 43/60] Don't auto-indent when the assistant starts responding with indentation --- crates/ai/src/assistant.rs | 81 +++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 0333a723e9..7803a89ea9 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -472,48 +472,48 @@ impl AssistantPanel { .text_for_range(range.start..range.end) .collect::(); - let mut base_indentation: Option = None; let selection_start = range.start.to_point(&snapshot); let selection_end = range.end.to_point(&snapshot); + + let mut base_indent: Option = None; let mut start_row = selection_start.row; if snapshot.is_line_blank(start_row) { if let Some(prev_non_blank_row) = snapshot.prev_non_blank_row(start_row) { start_row = prev_non_blank_row; } } - for row in start_row..=selection_end.row { if snapshot.is_line_blank(row) { continue; } - let line_indentation = snapshot.indent_size_for_line(row); - if let Some(base_indentation) = base_indentation.as_mut() { - if line_indentation.len < base_indentation.len { - *base_indentation = line_indentation; + let line_indent = snapshot.indent_size_for_line(row); + if let Some(base_indent) = base_indent.as_mut() { + if line_indent.len < base_indent.len { + *base_indent = line_indent; } } else { - base_indentation = Some(line_indentation); + base_indent = Some(line_indent); } } let mut normalized_selected_text = selected_text.clone(); - if let Some(base_indentation) = base_indentation { + if let Some(base_indent) = base_indent { for row in selection_start.row..=selection_end.row { let selection_row = row - selection_start.row; let line_start = normalized_selected_text.point_to_offset(Point::new(selection_row, 0)); - let indentation_len = if row == selection_start.row { - base_indentation.len.saturating_sub(selection_start.column) + let indent_len = if row == selection_start.row { + base_indent.len.saturating_sub(selection_start.column) } else { let line_len = normalized_selected_text.line_len(selection_row); - cmp::min(line_len, base_indentation.len) + cmp::min(line_len, base_indent.len) }; - let indentation_end = cmp::min( - line_start + indentation_len as usize, + let indent_end = cmp::min( + line_start + indent_len as usize, normalized_selected_text.len(), ); - normalized_selected_text.replace(line_start..indentation_end, ""); + normalized_selected_text.replace(line_start..indent_end, ""); } } @@ -581,7 +581,11 @@ impl AssistantPanel { "Assume the cursor is located where the `<|>` marker is." ) .unwrap(); - writeln!(prompt, "Assume your answer will be inserted at the cursor.").unwrap(); + writeln!( + prompt, + "Code can't be replaced, so assume your answer will be inserted at the cursor." + ) + .unwrap(); writeln!( prompt, "Complete the code given the user prompt: {user_prompt}" @@ -591,7 +595,11 @@ impl AssistantPanel { } writeln!(prompt, "Your answer MUST always be valid {language_name}.").unwrap(); writeln!(prompt, "Always wrap your response in a Markdown codeblock.").unwrap(); - writeln!(prompt, "Never make remarks, always output code.").unwrap(); + writeln!( + prompt, + "Never make remarks about the output, always output just code." + ) + .unwrap(); let request = OpenAIRequest { model: model.full_name().into(), @@ -626,40 +634,51 @@ impl AssistantPanel { futures::pin_mut!(chunks); let mut diff = StreamingDiff::new(selected_text.to_string()); - let indentation_len; - let indentation_text; - if let Some(base_indentation) = base_indentation { - indentation_len = base_indentation.len; - indentation_text = match base_indentation.kind { + let indent_len; + let indent_text; + if let Some(base_indent) = base_indent { + indent_len = base_indent.len; + indent_text = match base_indent.kind { language::IndentKind::Space => " ", language::IndentKind::Tab => "\t", }; } else { - indentation_len = 0; - indentation_text = ""; + indent_len = 0; + indent_text = ""; }; - let mut new_text = indentation_text - .repeat(indentation_len.saturating_sub(selection_start.column) as usize); + let mut autoindent = true; + let mut first_chunk = true; + let mut new_text = String::new(); - while let Some(message) = chunks.next().await { - let mut lines = message.split('\n'); + while let Some(chunk) = chunks.next().await { + if first_chunk && (chunk.starts_with(' ') || chunk.starts_with('\t')) { + autoindent = false; + } + + if first_chunk && autoindent { + let first_line_indent = + indent_len.saturating_sub(selection_start.column) as usize; + new_text = indent_text.repeat(first_line_indent); + } + + let mut lines = chunk.split('\n'); if let Some(first_line) = lines.next() { new_text.push_str(first_line); } for line in lines { new_text.push('\n'); - if !line.is_empty() { - new_text - .push_str(&indentation_text.repeat(indentation_len as usize)); - new_text.push_str(line); + if !line.is_empty() && autoindent { + new_text.push_str(&indent_text.repeat(indent_len as usize)); } + new_text.push_str(line); } let hunks = diff.push_new(&new_text); hunks_tx.send(hunks).await?; new_text.clear(); + first_chunk = false; } hunks_tx.send(diff.finish()).await?; From 1fb7ce0f4a3dfa492cbc73a95db56c6671bcd23c Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 28 Aug 2023 12:13:44 +0200 Subject: [PATCH 44/60] Show icon to toggle inline assist --- Cargo.lock | 1 + crates/ai/src/assistant.rs | 6 ++++- crates/quick_action_bar/Cargo.toml | 1 + .../quick_action_bar/src/quick_action_bar.rs | 23 +++++++++++++++++-- crates/zed/src/zed.rs | 5 ++-- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c232afa081..84b8093be2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5647,6 +5647,7 @@ dependencies = [ name = "quick_action_bar" version = "0.1.0" dependencies = [ + "ai", "editor", "gpui", "search", diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 7803a89ea9..193cba8db4 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -215,7 +215,11 @@ impl AssistantPanel { }) } - fn inline_assist(workspace: &mut Workspace, _: &InlineAssist, cx: &mut ViewContext) { + pub fn inline_assist( + workspace: &mut Workspace, + _: &InlineAssist, + cx: &mut ViewContext, + ) { let this = if let Some(this) = workspace.panel::(cx) { if this .update(cx, |assistant, cx| assistant.load_api_key(cx)) diff --git a/crates/quick_action_bar/Cargo.toml b/crates/quick_action_bar/Cargo.toml index 6953ac0e02..1f8ec4e92b 100644 --- a/crates/quick_action_bar/Cargo.toml +++ b/crates/quick_action_bar/Cargo.toml @@ -9,6 +9,7 @@ path = "src/quick_action_bar.rs" doctest = false [dependencies] +ai = { path = "../ai" } editor = { path = "../editor" } gpui = { path = "../gpui" } search = { path = "../search" } diff --git a/crates/quick_action_bar/src/quick_action_bar.rs b/crates/quick_action_bar/src/quick_action_bar.rs index 3055399c13..a7734deac5 100644 --- a/crates/quick_action_bar/src/quick_action_bar.rs +++ b/crates/quick_action_bar/src/quick_action_bar.rs @@ -1,25 +1,29 @@ +use ai::{assistant::InlineAssist, AssistantPanel}; use editor::Editor; use gpui::{ elements::{Empty, Flex, MouseEventHandler, ParentElement, Svg}, platform::{CursorStyle, MouseButton}, Action, AnyElement, Element, Entity, EventContext, Subscription, View, ViewContext, ViewHandle, + WeakViewHandle, }; use search::{buffer_search, BufferSearchBar}; -use workspace::{item::ItemHandle, ToolbarItemLocation, ToolbarItemView}; +use workspace::{item::ItemHandle, ToolbarItemLocation, ToolbarItemView, Workspace}; pub struct QuickActionBar { buffer_search_bar: ViewHandle, active_item: Option>, _inlay_hints_enabled_subscription: Option, + workspace: WeakViewHandle, } impl QuickActionBar { - pub fn new(buffer_search_bar: ViewHandle) -> Self { + pub fn new(buffer_search_bar: ViewHandle, workspace: &Workspace) -> Self { Self { buffer_search_bar, active_item: None, _inlay_hints_enabled_subscription: None, + workspace: workspace.weak_handle(), } } @@ -86,6 +90,21 @@ impl View for QuickActionBar { )); } + bar.add_child(render_quick_action_bar_button( + 2, + "icons/radix/magic-wand.svg", + false, + ("Generate code...".into(), Some(Box::new(InlineAssist))), + cx, + move |this, cx| { + if let Some(workspace) = this.workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + AssistantPanel::inline_assist(workspace, &Default::default(), cx); + }); + } + }, + )); + bar.into_any() } } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index de05c259c8..6421818b62 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -264,8 +264,9 @@ pub fn initialize_workspace( toolbar.add_item(breadcrumbs, cx); let buffer_search_bar = cx.add_view(BufferSearchBar::new); toolbar.add_item(buffer_search_bar.clone(), cx); - let quick_action_bar = - cx.add_view(|_| QuickActionBar::new(buffer_search_bar)); + let quick_action_bar = cx.add_view(|_| { + QuickActionBar::new(buffer_search_bar, workspace) + }); toolbar.add_item(quick_action_bar, cx); let project_search_bar = cx.add_view(|_| ProjectSearchBar::new()); toolbar.add_item(project_search_bar, cx); From c587cf66ce5cb6f38d3cee843e049adf3830babe Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 28 Aug 2023 12:24:01 +0200 Subject: [PATCH 45/60] Remove ellipsis from tooltip --- crates/quick_action_bar/src/quick_action_bar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/quick_action_bar/src/quick_action_bar.rs b/crates/quick_action_bar/src/quick_action_bar.rs index 7d6587795e..da5a8e6d72 100644 --- a/crates/quick_action_bar/src/quick_action_bar.rs +++ b/crates/quick_action_bar/src/quick_action_bar.rs @@ -96,7 +96,7 @@ impl View for QuickActionBar { 2, "icons/radix/magic-wand.svg", false, - ("Generate code...".into(), Some(Box::new(InlineAssist))), + ("Generate code".into(), Some(Box::new(InlineAssist))), cx, move |this, cx| { if let Some(workspace) = this.workspace.upgrade(cx) { From 8c4d2ccf80a066d9344702950de9986fc3d1f636 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 28 Aug 2023 14:23:42 +0200 Subject: [PATCH 46/60] Close inline assist when the associated transaction is undone --- crates/ai/src/assistant.rs | 34 ++++++++++++++++++++++++++-------- crates/editor/src/editor.rs | 6 ++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 3b444082c6..4dad12ad08 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -249,7 +249,7 @@ impl AssistantPanel { } fn new_inline_assist(&mut self, editor: &ViewHandle, cx: &mut ViewContext) { - let id = post_inc(&mut self.next_inline_assist_id); + let inline_assist_id = post_inc(&mut self.next_inline_assist_id); let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); let selection = editor.read(cx).selections.newest_anchor().clone(); let range = selection.start.bias_left(&snapshot)..selection.end.bias_right(&snapshot); @@ -272,7 +272,7 @@ impl AssistantPanel { }); let inline_assistant = cx.add_view(|cx| { let assistant = InlineAssistant { - id, + id: inline_assist_id, prompt_editor, confirmed: false, has_focus: false, @@ -313,7 +313,7 @@ impl AssistantPanel { }); self.pending_inline_assists.insert( - id, + inline_assist_id, PendingInlineAssist { kind: assist_kind, editor: editor.downgrade(), @@ -326,12 +326,30 @@ impl AssistantPanel { cx.subscribe(&inline_assistant, Self::handle_inline_assistant_event), cx.subscribe(editor, { let inline_assistant = inline_assistant.downgrade(); - move |_, editor, event, cx| { + move |this, editor, event, cx| { if let Some(inline_assistant) = inline_assistant.upgrade(cx) { - if let editor::Event::SelectionsChanged { local } = event { - if *local && inline_assistant.read(cx).has_focus { - cx.focus(&editor); + match event { + editor::Event::SelectionsChanged { local } => { + if *local && inline_assistant.read(cx).has_focus { + cx.focus(&editor); + } } + editor::Event::TransactionUndone { + transaction_id: tx_id, + } => { + if let Some(pending_assist) = + this.pending_inline_assists.get(&inline_assist_id) + { + if pending_assist.transaction_id == Some(*tx_id) { + this.close_inline_assist( + inline_assist_id, + false, + cx, + ); + } + } + } + _ => {} } } } @@ -342,7 +360,7 @@ impl AssistantPanel { self.pending_inline_assist_ids_by_editor .entry(editor.downgrade()) .or_default() - .push(id); + .push(inline_assist_id); self.update_highlights_for_editor(&editor, cx); } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6396536b83..fde280f8fe 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -4975,6 +4975,9 @@ impl Editor { self.unmark_text(cx); self.refresh_copilot_suggestions(true, cx); cx.emit(Event::Edited); + cx.emit(Event::TransactionUndone { + transaction_id: tx_id, + }); } } @@ -8404,6 +8407,9 @@ pub enum Event { local: bool, autoscroll: bool, }, + TransactionUndone { + transaction_id: TransactionId, + }, Closed, } From b9df85e01fa5d6934e67da4c1238173f002db5cb Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 28 Aug 2023 14:25:01 +0200 Subject: [PATCH 47/60] Remove todo.md --- todo.md | 48 ------------------------------------------------ 1 file changed, 48 deletions(-) delete mode 100644 todo.md diff --git a/todo.md b/todo.md deleted file mode 100644 index 0507708502..0000000000 --- a/todo.md +++ /dev/null @@ -1,48 +0,0 @@ -- Hit ctrl-enter deploys an edit prompt - - Empty selection (cursor) => append text - - On end of line: Edit prompt on end of line. - - [x] Middle of line: Edit prompt near cursor head on a different line - - Non-empty selection => refactor - - [x] Edit prompt near cursor head on a different line - - [x] What was selected when you hit ctrl-enter is colored. -- [x] Add placeholder text - - If non-empty selection: Enter prompt to transform selected text - - If empty selection: Enter prompt to generate text -- When cursor is inside a prompt - - [x] Escape cancels/undoes - - [x] Enter confirms -- [x] Selection is cleared and cursor is moved to prompt input -- [x] Ability to highlight background multiple times for the same type -- [x] Basic Styling -- [ ] Look into why insert prompts have a weird indentation sometimes - -- Multicursor - - Run the same prompt for every selection in parallel - - Position the prompt editor at the newest cursor -- Follow up ship: Marks - - Global across all buffers - - Select text, hit a binding - - That text gets added to the marks - - Simplest: Marks are a set, and you add to them with this binding. - - Could this be a stack? That might be too much. - - When you hit ctrl-enter to generate / transform text, we include the marked text in the context. - -- During inference, always send marked text. -- During inference, send as much context as possible given the user's desired generation length. - -- This would assume a convenient binding for setting the generation length. - - -~~~~~~~~~ - -Dial up / dial down how much context we send -Dial up / down your max generation length. - - -------- (merge to main) - -- Text in the prompt should soft wrap - ------------ (maybe pause) - -- Excurse outside of the editor without dismissing it... kind of like a message in the assistant. From 52e1e014ad08ecba01843c36f4ff6dac905933c5 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 28 Aug 2023 14:42:41 +0200 Subject: [PATCH 48/60] Allow redoing edits performed by inline assistant after cancelling it --- crates/ai/src/assistant.rs | 2 +- crates/editor/src/multi_buffer.rs | 24 ++++++++++++++++++------ crates/language/src/buffer.rs | 4 ++-- crates/text/src/text.rs | 29 +++++++++++++++++++++-------- 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 4dad12ad08..952c924292 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -442,7 +442,7 @@ impl AssistantPanel { if let Some(transaction_id) = pending_assist.transaction_id { editor.update(cx, |editor, cx| { editor.buffer().update(cx, |buffer, cx| { - buffer.undo_and_forget(transaction_id, cx) + buffer.undo_transaction(transaction_id, cx) }); }); } diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index c7c4028995..0c499c16c4 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -824,13 +824,15 @@ impl MultiBuffer { None } - pub fn undo_and_forget(&mut self, transaction_id: TransactionId, cx: &mut ModelContext) { + pub fn undo_transaction(&mut self, transaction_id: TransactionId, cx: &mut ModelContext) { if let Some(buffer) = self.as_singleton() { - buffer.update(cx, |buffer, cx| buffer.undo_and_forget(transaction_id, cx)); - } else if let Some(transaction) = self.history.forget(transaction_id) { - for (buffer_id, transaction_id) in transaction.buffer_transactions { - if let Some(BufferState { buffer, .. }) = self.buffers.borrow().get(&buffer_id) { - buffer.update(cx, |buffer, cx| buffer.undo_and_forget(transaction_id, cx)); + buffer.update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); + } else if let Some(transaction) = self.history.remove_from_undo(transaction_id) { + for (buffer_id, transaction_id) in &transaction.buffer_transactions { + if let Some(BufferState { buffer, .. }) = self.buffers.borrow().get(buffer_id) { + buffer.update(cx, |buffer, cx| { + buffer.undo_transaction(*transaction_id, cx) + }); } } } @@ -3454,6 +3456,16 @@ impl History { } } + fn remove_from_undo(&mut self, transaction_id: TransactionId) -> Option<&Transaction> { + let ix = self + .undo_stack + .iter() + .rposition(|transaction| transaction.id == transaction_id)?; + let transaction = self.undo_stack.remove(ix); + self.redo_stack.push(transaction); + self.redo_stack.last() + } + fn group(&mut self) -> Option { let mut count = 0; let mut transactions = self.undo_stack.iter(); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 2ed99d8526..4310f84830 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1668,14 +1668,14 @@ impl Buffer { } } - pub fn undo_and_forget( + pub fn undo_transaction( &mut self, transaction_id: TransactionId, cx: &mut ModelContext, ) -> bool { let was_dirty = self.is_dirty(); let old_version = self.version.clone(); - if let Some(operation) = self.text.undo_and_forget(transaction_id) { + if let Some(operation) = self.text.undo_transaction(transaction_id) { self.send_operation(Operation::Buffer(operation), cx); self.did_edit(&old_version, was_dirty, cx); true diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 43bcef0825..6a00ea12db 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -264,7 +264,19 @@ impl History { } } - fn remove_from_undo(&mut self, transaction_id: TransactionId) -> &[HistoryEntry] { + fn remove_from_undo(&mut self, transaction_id: TransactionId) -> Option<&HistoryEntry> { + assert_eq!(self.transaction_depth, 0); + + let entry_ix = self + .undo_stack + .iter() + .rposition(|entry| entry.transaction.id == transaction_id)?; + let entry = self.undo_stack.remove(entry_ix); + self.redo_stack.push(entry); + self.redo_stack.last() + } + + fn remove_from_undo_until(&mut self, transaction_id: TransactionId) -> &[HistoryEntry] { assert_eq!(self.transaction_depth, 0); let redo_stack_start_len = self.redo_stack.len(); @@ -1207,19 +1219,20 @@ impl Buffer { } } - pub fn undo_and_forget(&mut self, transaction_id: TransactionId) -> Option { - if let Some(transaction) = self.history.forget(transaction_id) { - self.undo_or_redo(transaction).log_err() - } else { - None - } + pub fn undo_transaction(&mut self, transaction_id: TransactionId) -> Option { + let transaction = self + .history + .remove_from_undo(transaction_id)? + .transaction + .clone(); + self.undo_or_redo(transaction).log_err() } #[allow(clippy::needless_collect)] pub fn undo_to_transaction(&mut self, transaction_id: TransactionId) -> Vec { let transactions = self .history - .remove_from_undo(transaction_id) + .remove_from_undo_until(transaction_id) .iter() .map(|entry| entry.transaction.clone()) .collect::>(); From ccec59337a6dea4f2daed7ed5fc2cc6350241970 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 28 Aug 2023 14:46:05 +0200 Subject: [PATCH 49/60] :memo: --- crates/ai/src/assistant.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 952c924292..80c3771085 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -341,6 +341,9 @@ impl AssistantPanel { this.pending_inline_assists.get(&inline_assist_id) { if pending_assist.transaction_id == Some(*tx_id) { + // Notice we are supplying `undo: false` here. This + // is because there's no need to undo the transaction + // because the user just did so. this.close_inline_assist( inline_assist_id, false, @@ -421,7 +424,7 @@ impl AssistantPanel { } } - fn close_inline_assist(&mut self, assist_id: usize, cancel: bool, cx: &mut ViewContext) { + fn close_inline_assist(&mut self, assist_id: usize, undo: bool, cx: &mut ViewContext) { self.hide_inline_assist(assist_id, cx); if let Some(pending_assist) = self.pending_inline_assists.remove(&assist_id) { @@ -438,7 +441,7 @@ impl AssistantPanel { if let Some(editor) = pending_assist.editor.upgrade(cx) { self.update_highlights_for_editor(&editor, cx); - if cancel { + if undo { if let Some(transaction_id) = pending_assist.transaction_id { editor.update(cx, |editor, cx| { editor.buffer().update(cx, |buffer, cx| { From c2b60df5afaeb624b21e89ec3a61b9b794331840 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 28 Aug 2023 16:36:07 +0200 Subject: [PATCH 50/60] Allow including conversation when triggering inline assist --- crates/ai/src/assistant.rs | 156 ++++++++++++++++++++++++----- crates/theme/src/theme.rs | 1 + styles/src/style_tree/assistant.ts | 58 ++++++++++- 3 files changed, 189 insertions(+), 26 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 80c3771085..ae223fdb57 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -19,12 +19,16 @@ use fs::Fs; use futures::{channel::mpsc, SinkExt, Stream, StreamExt}; use gpui::{ actions, - elements::*, + elements::{ + ChildView, Component, Empty, Flex, Label, MouseEventHandler, ParentElement, SafeStylable, + Stack, Svg, Text, UniformList, UniformListState, + }, fonts::HighlightStyle, geometry::vector::{vec2f, Vector2F}, platform::{CursorStyle, MouseButton}, - Action, AppContext, AsyncAppContext, ClipboardItem, Entity, ModelContext, ModelHandle, - Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, + Action, AnyElement, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, ModelContext, + ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, + WindowContext, }; use language::{ language_settings::SoftWrap, Buffer, LanguageRegistry, Point, Rope, ToOffset as _, @@ -33,7 +37,7 @@ use language::{ use search::BufferSearchBar; use settings::SettingsStore; use std::{ - cell::RefCell, + cell::{Cell, RefCell}, cmp, env, fmt::Write, iter, @@ -43,7 +47,10 @@ use std::{ sync::Arc, time::Duration, }; -use theme::AssistantStyle; +use theme::{ + components::{action_button::Button, ComponentExt}, + AssistantStyle, +}; use util::{paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, @@ -61,7 +68,8 @@ actions!( QuoteSelection, ToggleFocus, ResetKey, - InlineAssist + InlineAssist, + ToggleIncludeConversation, ] ); @@ -97,6 +105,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(AssistantPanel::cancel_last_inline_assist); cx.add_action(InlineAssistant::confirm); cx.add_action(InlineAssistant::cancel); + cx.add_action(InlineAssistant::toggle_include_conversation); } #[derive(Debug)] @@ -129,6 +138,7 @@ pub struct AssistantPanel { next_inline_assist_id: usize, pending_inline_assists: HashMap, pending_inline_assist_ids_by_editor: HashMap, Vec>, + include_conversation_in_next_inline_assist: bool, _watch_saved_conversations: Task>, } @@ -195,6 +205,7 @@ impl AssistantPanel { next_inline_assist_id: 0, pending_inline_assists: Default::default(), pending_inline_assist_ids_by_editor: Default::default(), + include_conversation_in_next_inline_assist: false, _watch_saved_conversations, }; @@ -270,12 +281,15 @@ impl AssistantPanel { editor.set_placeholder_text(placeholder, cx); editor }); + let measurements = Rc::new(Cell::new(BlockMeasurements::default())); let inline_assistant = cx.add_view(|cx| { let assistant = InlineAssistant { id: inline_assist_id, prompt_editor, confirmed: false, has_focus: false, + include_conversation: self.include_conversation_in_next_inline_assist, + measurements: measurements.clone(), }; cx.focus_self(); assistant @@ -292,13 +306,11 @@ impl AssistantPanel { render: Arc::new({ let inline_assistant = inline_assistant.clone(); move |cx: &mut BlockContext| { - let theme = theme::current(cx); - ChildView::new(&inline_assistant, cx) - .contained() - .with_padding_left(cx.anchor_x) - .contained() - .with_style(theme.assistant.inline.container) - .into_any() + measurements.set(BlockMeasurements { + anchor_x: cx.anchor_x, + gutter_width: cx.gutter_width, + }); + ChildView::new(&inline_assistant, cx).into_any() } }), disposition: if selection.reversed { @@ -375,8 +387,11 @@ impl AssistantPanel { ) { let assist_id = inline_assistant.read(cx).id; match event { - InlineAssistantEvent::Confirmed { prompt } => { - self.confirm_inline_assist(assist_id, prompt, cx); + InlineAssistantEvent::Confirmed { + prompt, + include_conversation, + } => { + self.confirm_inline_assist(assist_id, prompt, *include_conversation, cx); } InlineAssistantEvent::Canceled => { self.close_inline_assist(assist_id, true, cx); @@ -470,14 +485,24 @@ impl AssistantPanel { &mut self, inline_assist_id: usize, user_prompt: &str, + include_conversation: bool, cx: &mut ViewContext, ) { + self.include_conversation_in_next_inline_assist = include_conversation; + let api_key = if let Some(api_key) = self.api_key.borrow().clone() { api_key } else { return; }; + let conversation = if include_conversation { + self.active_editor() + .map(|editor| editor.read(cx).conversation.clone()) + } else { + None + }; + let pending_assist = if let Some(pending_assist) = self.pending_inline_assists.get_mut(&inline_assist_id) { pending_assist @@ -626,14 +651,25 @@ impl AssistantPanel { ) .unwrap(); - let request = OpenAIRequest { + let mut request = OpenAIRequest { model: model.full_name().into(), - messages: vec![RequestMessage { - role: Role::User, - content: prompt, - }], + messages: Vec::new(), stream: true, }; + if let Some(conversation) = conversation { + let conversation = conversation.read(cx); + let buffer = conversation.buffer.read(cx); + request.messages.extend( + conversation + .messages(cx) + .map(|message| message.to_open_ai_message(buffer)), + ); + } + + request.messages.push(RequestMessage { + role: Role::User, + content: prompt, + }); let response = stream_completion(api_key, cx.background().clone(), request); let editor = editor.downgrade(); @@ -2799,7 +2835,10 @@ impl Message { } enum InlineAssistantEvent { - Confirmed { prompt: String }, + Confirmed { + prompt: String, + include_conversation: bool, + }, Canceled, Dismissed, } @@ -2815,6 +2854,8 @@ struct InlineAssistant { prompt_editor: ViewHandle, confirmed: bool, has_focus: bool, + include_conversation: bool, + measurements: Rc>, } impl Entity for InlineAssistant { @@ -2827,9 +2868,55 @@ impl View for InlineAssistant { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - ChildView::new(&self.prompt_editor, cx) - .aligned() - .left() + let theme = theme::current(cx); + + Flex::row() + .with_child( + Button::action(ToggleIncludeConversation) + .with_tooltip("Include Conversation", theme.tooltip.clone()) + .with_id(self.id) + .with_contents(theme::components::svg::Svg::new("icons/ai.svg")) + .toggleable(self.include_conversation) + .with_style(theme.assistant.inline.include_conversation.clone()) + .element() + .aligned() + .constrained() + .dynamically({ + let measurements = self.measurements.clone(); + move |constraint, _, _| { + let measurements = measurements.get(); + SizeConstraint { + min: vec2f(measurements.gutter_width, constraint.min.y()), + max: vec2f(measurements.gutter_width, constraint.max.y()), + } + } + }), + ) + .with_child(Empty::new().constrained().dynamically({ + let measurements = self.measurements.clone(); + move |constraint, _, _| { + let measurements = measurements.get(); + SizeConstraint { + min: vec2f( + measurements.anchor_x - measurements.gutter_width, + constraint.min.y(), + ), + max: vec2f( + measurements.anchor_x - measurements.gutter_width, + constraint.max.y(), + ), + } + } + })) + .with_child( + ChildView::new(&self.prompt_editor, cx) + .aligned() + .left() + .flex(1., true), + ) + .contained() + .with_style(theme.assistant.inline.container) + .into_any() .into_any() } @@ -2862,10 +2949,29 @@ impl InlineAssistant { cx, ); }); - cx.emit(InlineAssistantEvent::Confirmed { prompt }); + cx.emit(InlineAssistantEvent::Confirmed { + prompt, + include_conversation: self.include_conversation, + }); self.confirmed = true; } } + + fn toggle_include_conversation( + &mut self, + _: &ToggleIncludeConversation, + cx: &mut ViewContext, + ) { + self.include_conversation = !self.include_conversation; + cx.notify(); + } +} + +// This wouldn't need to exist if we could pass parameters when rendering child views. +#[derive(Copy, Clone, Default)] +struct BlockMeasurements { + anchor_x: f32, + gutter_width: f32, } struct PendingInlineAssist { diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 7913685b7a..261933f057 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -1160,6 +1160,7 @@ pub struct InlineAssistantStyle { pub editor: FieldEditor, pub disabled_editor: FieldEditor, pub pending_edit_background: Color, + pub include_conversation: ToggleIconButtonStyle, } #[derive(Clone, Deserialize, Default, JsonSchema)] diff --git a/styles/src/style_tree/assistant.ts b/styles/src/style_tree/assistant.ts index 8bef2ce16b..e660bf078f 100644 --- a/styles/src/style_tree/assistant.ts +++ b/styles/src/style_tree/assistant.ts @@ -1,5 +1,5 @@ import { text, border, background, foreground, TextStyle } from "./components" -import { Interactive, interactive } from "../element" +import { Interactive, interactive, toggleable } from "../element" import { tab_bar_button } from "../component/tab_bar_button" import { StyleSets, useTheme } from "../theme" @@ -80,6 +80,62 @@ export default function assistant(): any { }, }, pending_edit_background: background(theme.highest, "positive"), + include_conversation: toggleable({ + base: interactive({ + base: { + icon_size: 12, + color: foreground(theme.highest, "variant"), + + button_width: 12, + background: background(theme.highest, "on"), + corner_radius: 2, + border: { + width: 1., color: background(theme.highest, "on") + }, + padding: { + left: 4, + right: 4, + top: 4, + bottom: 4, + }, + }, + state: { + hovered: { + ...text(theme.highest, "mono", "variant", "hovered"), + background: background(theme.highest, "on", "hovered"), + border: { + width: 1., color: background(theme.highest, "on", "hovered") + }, + }, + clicked: { + ...text(theme.highest, "mono", "variant", "pressed"), + background: background(theme.highest, "on", "pressed"), + border: { + width: 1., color: background(theme.highest, "on", "pressed") + }, + }, + }, + }), + state: { + active: { + default: { + icon_size: 12, + button_width: 12, + color: foreground(theme.highest, "variant"), + background: background(theme.highest, "accent"), + border: border(theme.highest, "accent"), + }, + hovered: { + background: background(theme.highest, "accent", "hovered"), + border: border(theme.highest, "accent", "hovered"), + }, + clicked: { + background: background(theme.highest, "accent", "pressed"), + border: border(theme.highest, "accent", "pressed"), + }, + }, + }, + }), }, message_header: { margin: { bottom: 4, top: 4 }, From 08df24412a4f4c04572696699b0f495caf269c21 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 29 Aug 2023 14:31:58 +0200 Subject: [PATCH 51/60] Delete less aggressively --- crates/ai/src/streaming_diff.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ai/src/streaming_diff.rs b/crates/ai/src/streaming_diff.rs index 7ea7f6dacd..7399a7b4fa 100644 --- a/crates/ai/src/streaming_diff.rs +++ b/crates/ai/src/streaming_diff.rs @@ -82,9 +82,9 @@ pub struct StreamingDiff { impl StreamingDiff { const INSERTION_SCORE: f64 = -1.; - const DELETION_SCORE: f64 = -5.; - const EQUALITY_BASE: f64 = 2.; - const MAX_EQUALITY_EXPONENT: i32 = 20; + const DELETION_SCORE: f64 = -20.; + const EQUALITY_BASE: f64 = 1.8; + const MAX_EQUALITY_EXPONENT: i32 = 16; pub fn new(old: String) -> Self { let old = old.chars().collect::>(); From 2332f824421875ce6db4c24a27f5df4755cce3a5 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 29 Aug 2023 14:41:02 +0200 Subject: [PATCH 52/60] More polish --- crates/ai/src/assistant.rs | 61 ++++++++++++++++++++---------- styles/src/style_tree/assistant.ts | 1 + 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index ae223fdb57..62ff7212bf 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -567,24 +567,37 @@ impl AssistantPanel { } } - let language_name = snapshot - .language_at(range.start) - .map(|language| language.name()); - let language_name = language_name.as_deref().unwrap_or(""); + let language = snapshot.language_at(range.start); + let language_name = if let Some(language) = language.as_ref() { + if Arc::ptr_eq(language, &language::PLAIN_TEXT) { + None + } else { + Some(language.name()) + } + } else { + None + }; + let language_name = language_name.as_deref(); let model = settings::get::(cx) .default_open_ai_model .clone(); let mut prompt = String::new(); - writeln!(prompt, "You're an expert {language_name} engineer.").unwrap(); + if let Some(language_name) = language_name { + writeln!(prompt, "You're an expert {language_name} engineer.").unwrap(); + } match pending_assist.kind { InlineAssistKind::Transform => { writeln!( prompt, - "You're currently working inside an editor on this code:" + "You're currently working inside an editor on this file:" ) .unwrap(); - writeln!(prompt, "```{language_name}").unwrap(); + if let Some(language_name) = language_name { + writeln!(prompt, "```{language_name}").unwrap(); + } else { + writeln!(prompt, "```").unwrap(); + } for chunk in snapshot.text_for_range(Anchor::min()..Anchor::max()) { write!(prompt, "{chunk}").unwrap(); } @@ -592,31 +605,39 @@ impl AssistantPanel { writeln!( prompt, - "In particular, the user has selected the following code:" + "In particular, the user has selected the following text:" ) .unwrap(); - writeln!(prompt, "```{language_name}").unwrap(); + if let Some(language_name) = language_name { + writeln!(prompt, "```{language_name}").unwrap(); + } else { + writeln!(prompt, "```").unwrap(); + } writeln!(prompt, "{normalized_selected_text}").unwrap(); writeln!(prompt, "```").unwrap(); writeln!(prompt).unwrap(); writeln!( prompt, - "Modify the selected code given the user prompt: {user_prompt}" + "Modify the selected text given the user prompt: {user_prompt}" ) .unwrap(); writeln!( prompt, - "You MUST reply only with the edited selected code, not the entire file." + "You MUST reply only with the edited selected text, not the entire file." ) .unwrap(); } InlineAssistKind::Generate => { writeln!( prompt, - "You're currently working inside an editor on this code:" + "You're currently working inside an editor on this file:" ) .unwrap(); - writeln!(prompt, "```{language_name}").unwrap(); + if let Some(language_name) = language_name { + writeln!(prompt, "```{language_name}").unwrap(); + } else { + writeln!(prompt, "```").unwrap(); + } for chunk in snapshot.text_for_range(Anchor::min()..range.start) { write!(prompt, "{chunk}").unwrap(); } @@ -633,23 +654,21 @@ impl AssistantPanel { .unwrap(); writeln!( prompt, - "Code can't be replaced, so assume your answer will be inserted at the cursor." + "Text can't be replaced, so assume your answer will be inserted at the cursor." ) .unwrap(); writeln!( prompt, - "Complete the code given the user prompt: {user_prompt}" + "Complete the text given the user prompt: {user_prompt}" ) .unwrap(); } } - writeln!(prompt, "Your answer MUST always be valid {language_name}.").unwrap(); + if let Some(language_name) = language_name { + writeln!(prompt, "Your answer MUST always be valid {language_name}.").unwrap(); + } writeln!(prompt, "Always wrap your response in a Markdown codeblock.").unwrap(); - writeln!( - prompt, - "Never make remarks about the output, always output just code." - ) - .unwrap(); + writeln!(prompt, "Never make remarks about the output.").unwrap(); let mut request = OpenAIRequest { model: model.full_name().into(), diff --git a/styles/src/style_tree/assistant.ts b/styles/src/style_tree/assistant.ts index e660bf078f..4a33ef9b19 100644 --- a/styles/src/style_tree/assistant.ts +++ b/styles/src/style_tree/assistant.ts @@ -60,6 +60,7 @@ export default function assistant(): any { padding: { left: 12 }, }, inline: { + background: background(theme.highest), margin: { top: 3, bottom: 3 }, border: border(theme.lowest, "on", { top: true, From 72413dbaf235ddd9f332e4e7cbd9569936f32932 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 29 Aug 2023 14:51:00 +0200 Subject: [PATCH 53/60] Remove the ability to reply to specific message in assistant --- crates/ai/src/assistant.rs | 255 +++++++++++++++++-------------------- 1 file changed, 114 insertions(+), 141 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 62ff7212bf..ab60d108f0 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -1767,15 +1767,20 @@ impl Conversation { cx: &mut ModelContext, ) -> Vec { let mut user_messages = Vec::new(); - let mut tasks = Vec::new(); - let last_message_id = self.message_anchors.iter().rev().find_map(|message| { - message - .start - .is_valid(self.buffer.read(cx)) - .then_some(message.id) - }); + let last_message_id = if let Some(last_message_id) = + self.message_anchors.iter().rev().find_map(|message| { + message + .start + .is_valid(self.buffer.read(cx)) + .then_some(message.id) + }) { + last_message_id + } else { + return Default::default(); + }; + let mut should_assist = false; for selected_message_id in selected_messages { let selected_message_role = if let Some(metadata) = self.messages_metadata.get(&selected_message_id) { @@ -1792,144 +1797,111 @@ impl Conversation { cx, ) { user_messages.push(user_message); - } else { - continue; } } else { - let request = OpenAIRequest { - model: self.model.full_name().to_string(), - messages: self - .messages(cx) - .filter(|message| matches!(message.status, MessageStatus::Done)) - .flat_map(|message| { - let mut system_message = None; - if message.id == selected_message_id { - system_message = Some(RequestMessage { - role: Role::System, - content: concat!( - "Treat the following messages as additional knowledge you have learned about, ", - "but act as if they were not part of this conversation. That is, treat them ", - "as if the user didn't see them and couldn't possibly inquire about them." - ).into() - }); - } - - Some(message.to_open_ai_message(self.buffer.read(cx))).into_iter().chain(system_message) - }) - .chain(Some(RequestMessage { - role: Role::System, - content: format!( - "Direct your reply to message with id {}. Do not include a [Message X] header.", - selected_message_id.0 - ), - })) - .collect(), - stream: true, - }; - - let Some(api_key) = self.api_key.borrow().clone() else { - continue; - }; - let stream = stream_completion(api_key, cx.background().clone(), request); - let assistant_message = self - .insert_message_after( - selected_message_id, - Role::Assistant, - MessageStatus::Pending, - cx, - ) - .unwrap(); - - // Queue up the user's next reply - if Some(selected_message_id) == last_message_id { - let user_message = self - .insert_message_after( - assistant_message.id, - Role::User, - MessageStatus::Done, - cx, - ) - .unwrap(); - user_messages.push(user_message); - } - - tasks.push(cx.spawn_weak({ - |this, mut cx| async move { - let assistant_message_id = assistant_message.id; - let stream_completion = async { - let mut messages = stream.await?; - - while let Some(message) = messages.next().await { - let mut message = message?; - if let Some(choice) = message.choices.pop() { - this.upgrade(&cx) - .ok_or_else(|| anyhow!("conversation was dropped"))? - .update(&mut cx, |this, cx| { - let text: Arc = choice.delta.content?.into(); - let message_ix = this.message_anchors.iter().position( - |message| message.id == assistant_message_id, - )?; - this.buffer.update(cx, |buffer, cx| { - let offset = this.message_anchors[message_ix + 1..] - .iter() - .find(|message| message.start.is_valid(buffer)) - .map_or(buffer.len(), |message| { - message - .start - .to_offset(buffer) - .saturating_sub(1) - }); - buffer.edit([(offset..offset, text)], None, cx); - }); - cx.emit(ConversationEvent::StreamedCompletion); - - Some(()) - }); - } - smol::future::yield_now().await; - } - - this.upgrade(&cx) - .ok_or_else(|| anyhow!("conversation was dropped"))? - .update(&mut cx, |this, cx| { - this.pending_completions.retain(|completion| { - completion.id != this.completion_count - }); - this.summarize(cx); - }); - - anyhow::Ok(()) - }; - - let result = stream_completion.await; - if let Some(this) = this.upgrade(&cx) { - this.update(&mut cx, |this, cx| { - if let Some(metadata) = - this.messages_metadata.get_mut(&assistant_message.id) - { - match result { - Ok(_) => { - metadata.status = MessageStatus::Done; - } - Err(error) => { - metadata.status = MessageStatus::Error( - error.to_string().trim().into(), - ); - } - } - cx.notify(); - } - }); - } - } - })); + should_assist = true; } } - if !tasks.is_empty() { + if should_assist { + let Some(api_key) = self.api_key.borrow().clone() else { + return Default::default(); + }; + + let request = OpenAIRequest { + model: self.model.full_name().to_string(), + messages: self + .messages(cx) + .filter(|message| matches!(message.status, MessageStatus::Done)) + .map(|message| message.to_open_ai_message(self.buffer.read(cx))) + .collect(), + stream: true, + }; + + let stream = stream_completion(api_key, cx.background().clone(), request); + let assistant_message = self + .insert_message_after(last_message_id, Role::Assistant, MessageStatus::Pending, cx) + .unwrap(); + + // Queue up the user's next reply. + let user_message = self + .insert_message_after(assistant_message.id, Role::User, MessageStatus::Done, cx) + .unwrap(); + user_messages.push(user_message); + + let task = cx.spawn_weak({ + |this, mut cx| async move { + let assistant_message_id = assistant_message.id; + let stream_completion = async { + let mut messages = stream.await?; + + while let Some(message) = messages.next().await { + let mut message = message?; + if let Some(choice) = message.choices.pop() { + this.upgrade(&cx) + .ok_or_else(|| anyhow!("conversation was dropped"))? + .update(&mut cx, |this, cx| { + let text: Arc = choice.delta.content?.into(); + let message_ix = + this.message_anchors.iter().position(|message| { + message.id == assistant_message_id + })?; + this.buffer.update(cx, |buffer, cx| { + let offset = this.message_anchors[message_ix + 1..] + .iter() + .find(|message| message.start.is_valid(buffer)) + .map_or(buffer.len(), |message| { + message + .start + .to_offset(buffer) + .saturating_sub(1) + }); + buffer.edit([(offset..offset, text)], None, cx); + }); + cx.emit(ConversationEvent::StreamedCompletion); + + Some(()) + }); + } + smol::future::yield_now().await; + } + + this.upgrade(&cx) + .ok_or_else(|| anyhow!("conversation was dropped"))? + .update(&mut cx, |this, cx| { + this.pending_completions + .retain(|completion| completion.id != this.completion_count); + this.summarize(cx); + }); + + anyhow::Ok(()) + }; + + let result = stream_completion.await; + if let Some(this) = this.upgrade(&cx) { + this.update(&mut cx, |this, cx| { + if let Some(metadata) = + this.messages_metadata.get_mut(&assistant_message.id) + { + match result { + Ok(_) => { + metadata.status = MessageStatus::Done; + } + Err(error) => { + metadata.status = + MessageStatus::Error(error.to_string().trim().into()); + } + } + cx.notify(); + } + }); + } + } + }); + self.pending_completions.push(PendingCompletion { id: post_inc(&mut self.completion_count), - _tasks: tasks, + _task: task, }); } @@ -2296,7 +2268,7 @@ impl Conversation { struct PendingCompletion { id: usize, - _tasks: Vec>, + _task: Task<()>, } enum ConversationEditorEvent { @@ -2844,8 +2816,9 @@ pub struct Message { impl Message { fn to_open_ai_message(&self, buffer: &Buffer) -> RequestMessage { - let mut content = format!("[Message {}]\n", self.id.0).to_string(); - content.extend(buffer.text_for_range(self.offset_range.clone())); + let content = buffer + .text_for_range(self.offset_range.clone()) + .collect::(); RequestMessage { role: self.role, content: content.trim_end().into(), From df377d5195f980e87cd8dada82ab1db4adb76532 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 29 Aug 2023 17:32:23 +0200 Subject: [PATCH 54/60] Use Inline Assist across the board --- crates/quick_action_bar/src/quick_action_bar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/quick_action_bar/src/quick_action_bar.rs b/crates/quick_action_bar/src/quick_action_bar.rs index da5a8e6d72..de4e8828b3 100644 --- a/crates/quick_action_bar/src/quick_action_bar.rs +++ b/crates/quick_action_bar/src/quick_action_bar.rs @@ -96,7 +96,7 @@ impl View for QuickActionBar { 2, "icons/radix/magic-wand.svg", false, - ("Generate code".into(), Some(Box::new(InlineAssist))), + ("Inline Assist".into(), Some(Box::new(InlineAssist))), cx, move |this, cx| { if let Some(workspace) = this.workspace.upgrade(cx) { From 16422a06ad96909160b5eae36c1c771fce4c45d2 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 29 Aug 2023 18:21:23 +0200 Subject: [PATCH 55/60] Remember whether include conversation was toggled --- crates/ai/src/assistant.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index ab60d108f0..4aca6ae626 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -399,6 +399,11 @@ impl AssistantPanel { InlineAssistantEvent::Dismissed => { self.hide_inline_assist(assist_id, cx); } + InlineAssistantEvent::IncludeConversationToggled { + include_conversation, + } => { + self.include_conversation_in_next_inline_assist = *include_conversation; + } } } @@ -488,8 +493,6 @@ impl AssistantPanel { include_conversation: bool, cx: &mut ViewContext, ) { - self.include_conversation_in_next_inline_assist = include_conversation; - let api_key = if let Some(api_key) = self.api_key.borrow().clone() { api_key } else { @@ -2833,6 +2836,9 @@ enum InlineAssistantEvent { }, Canceled, Dismissed, + IncludeConversationToggled { + include_conversation: bool, + }, } #[derive(Copy, Clone)] @@ -2955,6 +2961,9 @@ impl InlineAssistant { cx: &mut ViewContext, ) { self.include_conversation = !self.include_conversation; + cx.emit(InlineAssistantEvent::IncludeConversationToggled { + include_conversation: self.include_conversation, + }); cx.notify(); } } From 87e25c8c238b8d4f63431452c057a0a387f6d8ee Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 29 Aug 2023 18:21:35 +0200 Subject: [PATCH 56/60] Use model from conversation when available --- crates/ai/src/assistant.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 4aca6ae626..46756ad569 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -581,9 +581,6 @@ impl AssistantPanel { None }; let language_name = language_name.as_deref(); - let model = settings::get::(cx) - .default_open_ai_model - .clone(); let mut prompt = String::new(); if let Some(language_name) = language_name { @@ -673,25 +670,30 @@ impl AssistantPanel { writeln!(prompt, "Always wrap your response in a Markdown codeblock.").unwrap(); writeln!(prompt, "Never make remarks about the output.").unwrap(); - let mut request = OpenAIRequest { - model: model.full_name().into(), - messages: Vec::new(), - stream: true, - }; + let mut messages = Vec::new(); + let mut model = settings::get::(cx) + .default_open_ai_model + .clone(); if let Some(conversation) = conversation { let conversation = conversation.read(cx); let buffer = conversation.buffer.read(cx); - request.messages.extend( + messages.extend( conversation .messages(cx) .map(|message| message.to_open_ai_message(buffer)), ); + model = conversation.model.clone(); } - request.messages.push(RequestMessage { + messages.push(RequestMessage { role: Role::User, content: prompt, }); + let request = OpenAIRequest { + model: model.full_name().into(), + messages, + stream: true, + }; let response = stream_completion(api_key, cx.background().clone(), request); let editor = editor.downgrade(); From 5c498c86103c390b6c5930641699fc4923ebc3a8 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 30 Aug 2023 11:04:48 +0200 Subject: [PATCH 57/60] Show inline assistant errors --- crates/ai/src/assistant.rs | 160 +++++++++++++++++++++++++++++-------- 1 file changed, 125 insertions(+), 35 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 46756ad569..d19730172a 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -40,7 +40,7 @@ use std::{ cell::{Cell, RefCell}, cmp, env, fmt::Write, - iter, + future, iter, ops::Range, path::{Path, PathBuf}, rc::Rc, @@ -55,7 +55,7 @@ use util::{paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, searchable::Direction, - Save, ToggleZoom, Toolbar, Workspace, + Save, Toast, ToggleZoom, Toolbar, Workspace, }; actions!( @@ -290,6 +290,7 @@ impl AssistantPanel { has_focus: false, include_conversation: self.include_conversation_in_next_inline_assist, measurements: measurements.clone(), + error: None, }; cx.focus_self(); assistant @@ -331,7 +332,7 @@ impl AssistantPanel { editor: editor.downgrade(), range, highlighted_ranges: Default::default(), - inline_assistant_block_id: Some(block_id), + inline_assistant: Some((block_id, inline_assistant.clone())), code_generation: Task::ready(None), transaction_id: None, _subscriptions: vec![ @@ -477,7 +478,7 @@ impl AssistantPanel { fn hide_inline_assist(&mut self, assist_id: usize, cx: &mut ViewContext) { if let Some(pending_assist) = self.pending_inline_assists.get_mut(&assist_id) { if let Some(editor) = pending_assist.editor.upgrade(cx) { - if let Some(block_id) = pending_assist.inline_assistant_block_id.take() { + if let Some((block_id, _)) = pending_assist.inline_assistant.take() { editor.update(cx, |editor, cx| { editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); }); @@ -699,22 +700,17 @@ impl AssistantPanel { pending_assist.code_generation = cx.spawn(|this, mut cx| { async move { - let _cleanup = util::defer({ - let mut cx = cx.clone(); - let this = this.clone(); - move || { - let _ = this.update(&mut cx, |this, cx| { - this.close_inline_assist(inline_assist_id, false, cx) - }); - } - }); - let mut edit_start = range.start.to_offset(&snapshot); let (mut hunks_tx, mut hunks_rx) = mpsc::channel(1); let diff = cx.background().spawn(async move { let chunks = strip_markdown_codeblock(response.await?.filter_map( - |message| async move { message.ok()?.choices.pop()?.delta.content }, + |message| async move { + match message { + Ok(mut message) => Some(Ok(message.choices.pop()?.delta.content?)), + Err(error) => Some(Err(error)), + } + }, )); futures::pin_mut!(chunks); let mut diff = StreamingDiff::new(selected_text.to_string()); @@ -737,6 +733,7 @@ impl AssistantPanel { let mut new_text = String::new(); while let Some(chunk) = chunks.next().await { + let chunk = chunk?; if first_chunk && (chunk.starts_with(' ') || chunk.starts_with('\t')) { autoindent = false; } @@ -771,9 +768,17 @@ impl AssistantPanel { }); while let Some(hunks) = hunks_rx.next().await { - let editor = editor - .upgrade(&cx) - .ok_or_else(|| anyhow!("editor was dropped"))?; + let editor = if let Some(editor) = editor.upgrade(&cx) { + editor + } else { + break; + }; + + let this = if let Some(this) = this.upgrade(&cx) { + this + } else { + break; + }; this.update(&mut cx, |this, cx| { let pending_assist = if let Some(pending_assist) = @@ -840,9 +845,42 @@ impl AssistantPanel { }); this.update_highlights_for_editor(&editor, cx); - })?; + }); + } + + if let Err(error) = diff.await { + this.update(&mut cx, |this, cx| { + let pending_assist = if let Some(pending_assist) = + this.pending_inline_assists.get_mut(&inline_assist_id) + { + pending_assist + } else { + return; + }; + + if let Some((_, inline_assistant)) = + pending_assist.inline_assistant.as_ref() + { + inline_assistant.update(cx, |inline_assistant, cx| { + inline_assistant.set_error(error, cx); + }); + } else if let Some(workspace) = this.workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new( + inline_assist_id, + format!("Inline assistant error: {}", error), + ), + cx, + ); + }) + } + })?; + } else { + let _ = this.update(&mut cx, |this, cx| { + this.close_inline_assist(inline_assist_id, false, cx) + }); } - diff.await?; anyhow::Ok(()) } @@ -2856,6 +2894,7 @@ struct InlineAssistant { has_focus: bool, include_conversation: bool, measurements: Rc>, + error: Option, } impl Entity for InlineAssistant { @@ -2868,17 +2907,42 @@ impl View for InlineAssistant { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + enum ErrorIcon {} let theme = theme::current(cx); Flex::row() .with_child( - Button::action(ToggleIncludeConversation) - .with_tooltip("Include Conversation", theme.tooltip.clone()) - .with_id(self.id) - .with_contents(theme::components::svg::Svg::new("icons/ai.svg")) - .toggleable(self.include_conversation) - .with_style(theme.assistant.inline.include_conversation.clone()) - .element() + Flex::row() + .with_child( + Button::action(ToggleIncludeConversation) + .with_tooltip("Include Conversation", theme.tooltip.clone()) + .with_id(self.id) + .with_contents(theme::components::svg::Svg::new("icons/ai.svg")) + .toggleable(self.include_conversation) + .with_style(theme.assistant.inline.include_conversation.clone()) + .element() + .aligned(), + ) + .with_children(if let Some(error) = self.error.as_ref() { + Some( + Svg::new("icons/circle_x_mark_12.svg") + .with_color(theme.assistant.error_icon.color) + .constrained() + .with_width(theme.assistant.error_icon.width) + .contained() + .with_style(theme.assistant.error_icon.container) + .with_tooltip::( + self.id, + error.to_string(), + None, + theme.tooltip.clone(), + cx, + ) + .aligned(), + ) + } else { + None + }) .aligned() .constrained() .dynamically({ @@ -2954,6 +3018,8 @@ impl InlineAssistant { include_conversation: self.include_conversation, }); self.confirmed = true; + self.error = None; + cx.notify(); } } @@ -2968,6 +3034,19 @@ impl InlineAssistant { }); cx.notify(); } + + fn set_error(&mut self, error: anyhow::Error, cx: &mut ViewContext) { + self.error = Some(error); + self.confirmed = false; + self.prompt_editor.update(cx, |editor, cx| { + editor.set_read_only(false); + editor.set_field_editor_style( + Some(Arc::new(|theme| theme.assistant.inline.editor.clone())), + cx, + ); + }); + cx.notify(); + } } // This wouldn't need to exist if we could pass parameters when rendering child views. @@ -2982,7 +3061,7 @@ struct PendingInlineAssist { editor: WeakViewHandle, range: Range, highlighted_ranges: Vec>, - inline_assistant_block_id: Option, + inline_assistant: Option<(BlockId, ViewHandle)>, code_generation: Task>, transaction_id: Option, _subscriptions: Vec, @@ -3010,23 +3089,29 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { } } -fn strip_markdown_codeblock(stream: impl Stream) -> impl Stream { +fn strip_markdown_codeblock( + stream: impl Stream>, +) -> impl Stream> { let mut first_line = true; let mut buffer = String::new(); let mut starts_with_fenced_code_block = false; stream.filter_map(move |chunk| { + let chunk = match chunk { + Ok(chunk) => chunk, + Err(err) => return future::ready(Some(Err(err))), + }; buffer.push_str(&chunk); if first_line { if buffer == "" || buffer == "`" || buffer == "``" { - return futures::future::ready(None); + return future::ready(None); } else if buffer.starts_with("```") { starts_with_fenced_code_block = true; if let Some(newline_ix) = buffer.find('\n') { buffer.replace_range(..newline_ix + 1, ""); first_line = false; } else { - return futures::future::ready(None); + return future::ready(None); } } } @@ -3050,10 +3135,10 @@ fn strip_markdown_codeblock(stream: impl Stream) -> impl Stream() .await, "Lorem ipsum dolor" ); assert_eq!( strip_markdown_codeblock(chunks("```\nLorem ipsum dolor", 2)) + .map(|chunk| chunk.unwrap()) .collect::() .await, "Lorem ipsum dolor" ); assert_eq!( strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```", 2)) + .map(|chunk| chunk.unwrap()) .collect::() .await, "Lorem ipsum dolor" ); assert_eq!( strip_markdown_codeblock(chunks("```html\n```js\nLorem ipsum dolor\n```\n```", 2)) + .map(|chunk| chunk.unwrap()) .collect::() .await, "```js\nLorem ipsum dolor\n```" ); assert_eq!( strip_markdown_codeblock(chunks("``\nLorem ipsum dolor\n```", 2)) + .map(|chunk| chunk.unwrap()) .collect::() .await, "``\nLorem ipsum dolor\n```" ); - fn chunks(text: &str, size: usize) -> impl Stream { + fn chunks(text: &str, size: usize) -> impl Stream> { stream::iter( text.chars() .collect::>() .chunks(size) - .map(|chunk| chunk.iter().collect::()) + .map(|chunk| Ok(chunk.iter().collect::())) .collect::>(), ) } From c6f439051131ddeca55f9c9c30a7cf3554cfbe00 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 30 Aug 2023 11:30:51 +0200 Subject: [PATCH 58/60] Retain search history for inline assistants This only works in-memory for now. --- crates/ai/src/assistant.rs | 132 ++++++++++++++++++++++++++++++------- 1 file changed, 109 insertions(+), 23 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index d19730172a..fd6e6c63eb 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -7,13 +7,13 @@ use crate::{ }; use anyhow::{anyhow, Result}; use chrono::{DateTime, Local}; -use collections::{hash_map, HashMap, HashSet}; +use collections::{hash_map, HashMap, HashSet, VecDeque}; use editor::{ display_map::{ BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint, }, scroll::autoscroll::{Autoscroll, AutoscrollStrategy}, - Anchor, Editor, MultiBufferSnapshot, ToOffset, ToPoint, + Anchor, Editor, MoveDown, MoveUp, MultiBufferSnapshot, ToOffset, ToPoint, }; use fs::Fs; use futures::{channel::mpsc, SinkExt, Stream, StreamExt}; @@ -106,6 +106,8 @@ pub fn init(cx: &mut AppContext) { cx.add_action(InlineAssistant::confirm); cx.add_action(InlineAssistant::cancel); cx.add_action(InlineAssistant::toggle_include_conversation); + cx.add_action(InlineAssistant::move_up); + cx.add_action(InlineAssistant::move_down); } #[derive(Debug)] @@ -139,10 +141,13 @@ pub struct AssistantPanel { pending_inline_assists: HashMap, pending_inline_assist_ids_by_editor: HashMap, Vec>, include_conversation_in_next_inline_assist: bool, + inline_prompt_history: VecDeque, _watch_saved_conversations: Task>, } impl AssistantPanel { + const INLINE_PROMPT_HISTORY_MAX_LEN: usize = 20; + pub fn load( workspace: WeakViewHandle, cx: AsyncAppContext, @@ -206,6 +211,7 @@ impl AssistantPanel { pending_inline_assists: Default::default(), pending_inline_assist_ids_by_editor: Default::default(), include_conversation_in_next_inline_assist: false, + inline_prompt_history: Default::default(), _watch_saved_conversations, }; @@ -269,29 +275,16 @@ impl AssistantPanel { } else { InlineAssistKind::Transform }; - let prompt_editor = cx.add_view(|cx| { - let mut editor = Editor::single_line( - Some(Arc::new(|theme| theme.assistant.inline.editor.clone())), - cx, - ); - let placeholder = match assist_kind { - InlineAssistKind::Transform => "Enter transformation prompt…", - InlineAssistKind::Generate => "Enter generation prompt…", - }; - editor.set_placeholder_text(placeholder, cx); - editor - }); let measurements = Rc::new(Cell::new(BlockMeasurements::default())); let inline_assistant = cx.add_view(|cx| { - let assistant = InlineAssistant { - id: inline_assist_id, - prompt_editor, - confirmed: false, - has_focus: false, - include_conversation: self.include_conversation_in_next_inline_assist, - measurements: measurements.clone(), - error: None, - }; + let assistant = InlineAssistant::new( + inline_assist_id, + assist_kind, + measurements.clone(), + self.include_conversation_in_next_inline_assist, + self.inline_prompt_history.clone(), + cx, + ); cx.focus_self(); assistant }); @@ -520,6 +513,10 @@ impl AssistantPanel { return; }; + self.inline_prompt_history.push_back(user_prompt.into()); + if self.inline_prompt_history.len() > Self::INLINE_PROMPT_HISTORY_MAX_LEN { + self.inline_prompt_history.pop_front(); + } let range = pending_assist.range.clone(); let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); let selected_text = snapshot @@ -2895,6 +2892,10 @@ struct InlineAssistant { include_conversation: bool, measurements: Rc>, error: Option, + prompt_history: VecDeque, + prompt_history_ix: Option, + pending_prompt: String, + _subscription: Subscription, } impl Entity for InlineAssistant { @@ -2995,6 +2996,54 @@ impl View for InlineAssistant { } impl InlineAssistant { + fn new( + id: usize, + kind: InlineAssistKind, + measurements: Rc>, + include_conversation: bool, + prompt_history: VecDeque, + cx: &mut ViewContext, + ) -> Self { + let prompt_editor = cx.add_view(|cx| { + let mut editor = Editor::single_line( + Some(Arc::new(|theme| theme.assistant.inline.editor.clone())), + cx, + ); + let placeholder = match kind { + InlineAssistKind::Transform => "Enter transformation prompt…", + InlineAssistKind::Generate => "Enter generation prompt…", + }; + editor.set_placeholder_text(placeholder, cx); + editor + }); + let subscription = cx.subscribe(&prompt_editor, Self::handle_prompt_editor_events); + Self { + id, + prompt_editor, + confirmed: false, + has_focus: false, + include_conversation, + measurements, + error: None, + prompt_history, + prompt_history_ix: None, + pending_prompt: String::new(), + _subscription: subscription, + } + } + + fn handle_prompt_editor_events( + &mut self, + _: ViewHandle, + event: &editor::Event, + cx: &mut ViewContext, + ) { + if let editor::Event::Edited = event { + self.pending_prompt = self.prompt_editor.read(cx).text(cx); + cx.notify(); + } + } + fn cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { cx.emit(InlineAssistantEvent::Canceled); } @@ -3047,6 +3096,43 @@ impl InlineAssistant { }); cx.notify(); } + + fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext) { + if let Some(ix) = self.prompt_history_ix { + if ix > 0 { + self.prompt_history_ix = Some(ix - 1); + let prompt = self.prompt_history[ix - 1].clone(); + self.set_prompt(&prompt, cx); + } + } else if !self.prompt_history.is_empty() { + self.prompt_history_ix = Some(self.prompt_history.len() - 1); + let prompt = self.prompt_history[self.prompt_history.len() - 1].clone(); + self.set_prompt(&prompt, cx); + } + } + + fn move_down(&mut self, _: &MoveDown, cx: &mut ViewContext) { + if let Some(ix) = self.prompt_history_ix { + if ix < self.prompt_history.len() - 1 { + self.prompt_history_ix = Some(ix + 1); + let prompt = self.prompt_history[ix + 1].clone(); + self.set_prompt(&prompt, cx); + } else { + self.prompt_history_ix = None; + let pending_prompt = self.pending_prompt.clone(); + self.set_prompt(&pending_prompt, cx); + } + } + } + + fn set_prompt(&mut self, prompt: &str, cx: &mut ViewContext) { + self.prompt_editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |buffer, cx| { + let len = buffer.len(cx); + buffer.edit([(0..len, prompt)], None, cx); + }); + }); + } } // This wouldn't need to exist if we could pass parameters when rendering child views. From 5f6562c21448e79971250352a395aee730b420d2 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 30 Aug 2023 12:07:58 +0200 Subject: [PATCH 59/60] Detect indentation from GPT output --- crates/ai/src/assistant.rs | 44 ++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index fd6e6c63eb..7b360534ec 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -712,7 +712,7 @@ impl AssistantPanel { futures::pin_mut!(chunks); let mut diff = StreamingDiff::new(selected_text.to_string()); - let indent_len; + let mut indent_len; let indent_text; if let Some(base_indent) = base_indent { indent_len = base_indent.len; @@ -725,30 +725,43 @@ impl AssistantPanel { indent_text = ""; }; - let mut autoindent = true; - let mut first_chunk = true; + let mut first_line_len = 0; + let mut first_line_non_whitespace_char_ix = None; + let mut first_line = true; let mut new_text = String::new(); while let Some(chunk) = chunks.next().await { let chunk = chunk?; - if first_chunk && (chunk.starts_with(' ') || chunk.starts_with('\t')) { - autoindent = false; - } - - if first_chunk && autoindent { - let first_line_indent = - indent_len.saturating_sub(selection_start.column) as usize; - new_text = indent_text.repeat(first_line_indent); - } let mut lines = chunk.split('\n'); - if let Some(first_line) = lines.next() { - new_text.push_str(first_line); + if let Some(mut line) = lines.next() { + if first_line { + if first_line_non_whitespace_char_ix.is_none() { + if let Some(mut char_ix) = + line.find(|ch: char| !ch.is_whitespace()) + { + line = &line[char_ix..]; + char_ix += first_line_len; + first_line_non_whitespace_char_ix = Some(char_ix); + let first_line_indent = char_ix + .saturating_sub(selection_start.column as usize) + as usize; + new_text.push_str(&indent_text.repeat(first_line_indent)); + indent_len = indent_len.saturating_sub(char_ix as u32); + } + } + first_line_len += line.len(); + } + + if first_line_non_whitespace_char_ix.is_some() { + new_text.push_str(line); + } } for line in lines { + first_line = false; new_text.push('\n'); - if !line.is_empty() && autoindent { + if !line.is_empty() { new_text.push_str(&indent_text.repeat(indent_len as usize)); } new_text.push_str(line); @@ -757,7 +770,6 @@ impl AssistantPanel { let hunks = diff.push_new(&new_text); hunks_tx.send(hunks).await?; new_text.clear(); - first_chunk = false; } hunks_tx.send(diff.finish()).await?; From bf67d3710a3d2a5406aa490ed97b89d0902a4f90 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 30 Aug 2023 12:08:14 +0200 Subject: [PATCH 60/60] Remove trailing backticks when assistant ends with a trailing newline --- crates/ai/src/assistant.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 7b360534ec..2aaf75ae39 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -3216,7 +3216,8 @@ fn strip_markdown_codeblock( let text = if starts_with_fenced_code_block { buffer - .strip_suffix("\n```") + .strip_suffix("\n```\n") + .or_else(|| buffer.strip_suffix("\n```")) .or_else(|| buffer.strip_suffix("\n``")) .or_else(|| buffer.strip_suffix("\n`")) .or_else(|| buffer.strip_suffix('\n')) @@ -3636,6 +3637,13 @@ mod tests { .await, "Lorem ipsum dolor" ); + assert_eq!( + strip_markdown_codeblock(chunks("```\nLorem ipsum dolor\n```\n", 2)) + .map(|chunk| chunk.unwrap()) + .collect::() + .await, + "Lorem ipsum dolor" + ); assert_eq!( strip_markdown_codeblock(chunks("```html\n```js\nLorem ipsum dolor\n```\n```", 2)) .map(|chunk| chunk.unwrap())