From 80080a43e4ad52a3596b69346e97005d21f48500 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 26 May 2023 10:09:55 -0600 Subject: [PATCH 01/32] Start on an assistant panel based on multi-buffers Each message is represented as a multibuffer excerpt to allow for fluid editing of the conversation transcript. Co-Authored-By: Antonio Scandurra --- Cargo.lock | 4 + assets/icons/speech_bubble_12.svg | 4 +- assets/keymaps/default.json | 34 ++-- crates/ai/Cargo.toml | 4 + crates/ai/src/ai.rs | 46 +++-- crates/ai/src/assistant.rs | 316 ++++++++++++++++++++++++++++++ crates/zed/src/zed.rs | 7 +- 7 files changed, 381 insertions(+), 34 deletions(-) create mode 100644 crates/ai/src/assistant.rs diff --git a/Cargo.lock b/Cargo.lock index 95fcf2224d88ec6fe94a0d842fefec6252b03aa5..27c3a2fdb1768704f663bb0288acc56f53300154 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,9 +106,13 @@ dependencies = [ "futures 0.3.28", "gpui", "isahc", + "language", + "search", "serde", "serde_json", + "theme", "util", + "workspace", ] [[package]] diff --git a/assets/icons/speech_bubble_12.svg b/assets/icons/speech_bubble_12.svg index f5f330056a34f1261d31416b688bcc86dcdf8bf1..736f39a9840022eb882f8473710e73e8228e50ea 100644 --- a/assets/icons/speech_bubble_12.svg +++ b/assets/icons/speech_bubble_12.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 35182dfaa66763cb2ec885ba762b0d9f9fd41b31..5102b7408bd79ff017a57554b57487c502813fbf 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -189,16 +189,16 @@ } }, { - "context": "Editor && extension == zmd", + "context": "Editor && mode == auto_height", "bindings": { - "cmd-enter": "ai::Assist" + "alt-enter": "editor::Newline", + "cmd-alt-enter": "editor::NewlineBelow" } }, { - "context": "Editor && mode == auto_height", + "context": "ContextEditor > Editor", "bindings": { - "alt-enter": "editor::Newline", - "cmd-alt-enter": "editor::NewlineBelow" + "cmd-enter": "assistant::Assist" } }, { @@ -375,27 +375,39 @@ ], "cmd-b": [ "workspace::ToggleLeftDock", - { "focus": true } + { + "focus": true + } ], "cmd-shift-b": [ "workspace::ToggleLeftDock", - { "focus": false } + { + "focus": false + } ], "cmd-r": [ "workspace::ToggleRightDock", - { "focus": true } + { + "focus": true + } ], "cmd-shift-r": [ "workspace::ToggleRightDock", - { "focus": false } + { + "focus": false + } ], "cmd-j": [ "workspace::ToggleBottomDock", - { "focus": true } + { + "focus": true + } ], "cmd-shift-j": [ "workspace::ToggleBottomDock", - { "focus": false } + { + "focus": false + } ], "cmd-shift-f": "workspace::NewSearch", "cmd-k cmd-t": "theme_selector::Toggle", diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index b367a4d43cac845950dc123e66ed0c7be15da1f2..14817916f49943a4c5c75965a3c92b0ce38a96b5 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -13,7 +13,11 @@ assets = { path = "../assets"} collections = { path = "../collections"} editor = { path = "../editor" } gpui = { path = "../gpui" } +language = { path = "../language" } +search = { path = "../search" } +theme = { path = "../theme" } util = { path = "../util" } +workspace = { path = "../workspace" } serde.workspace = true serde_json.workspace = true diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index c68f41c6bf1cc39d20cc2a9f94dec899a5854516..cd42ee115328febc04e632e3632ad9030ad3edcd 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -1,3 +1,5 @@ +mod assistant; + use anyhow::{anyhow, Result}; use assets::Assets; use collections::HashMap; @@ -16,6 +18,8 @@ use std::{io, sync::Arc}; use util::channel::{ReleaseChannel, RELEASE_CHANNEL}; use util::{ResultExt, TryFutureExt}; +pub use assistant::AssistantPanel; + actions!(ai, [Assist]); // Data types for chat completion requests @@ -38,7 +42,7 @@ struct ResponseMessage { content: Option, } -#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)] #[serde(rename_all = "lowercase")] enum Role { User, @@ -86,25 +90,27 @@ struct OpenAIChoice { } pub fn init(cx: &mut AppContext) { - if *RELEASE_CHANNEL == ReleaseChannel::Stable { - return; - } - - let assistant = Rc::new(Assistant::default()); - cx.add_action({ - let assistant = assistant.clone(); - move |editor: &mut Editor, _: &Assist, cx: &mut ViewContext| { - assistant.assist(editor, cx).log_err(); - } - }); - cx.capture_action({ - let assistant = assistant.clone(); - move |_: &mut Editor, _: &editor::Cancel, cx: &mut ViewContext| { - if !assistant.cancel_last_assist(cx.view_id()) { - cx.propagate_action(); - } - } - }); + // if *RELEASE_CHANNEL == ReleaseChannel::Stable { + // return; + // } + + assistant::init(cx); + + // let assistant = Rc::new(Assistant::default()); + // cx.add_action({ + // let assistant = assistant.clone(); + // move |editor: &mut Editor, _: &Assist, cx: &mut ViewContext| { + // assistant.assist(editor, cx).log_err(); + // } + // }); + // cx.capture_action({ + // let assistant = assistant.clone(); + // move |_: &mut Editor, _: &editor::Cancel, cx: &mut ViewContext| { + // if !assistant.cancel_last_assist(cx.view_id()) { + // cx.propagate_action(); + // } + // } + // }); } type CompletionId = usize; diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs new file mode 100644 index 0000000000000000000000000000000000000000..ef202e72144d490ccfe713204c7c0aed0d1de04a --- /dev/null +++ b/crates/ai/src/assistant.rs @@ -0,0 +1,316 @@ +use crate::{stream_completion, OpenAIRequest, RequestMessage, Role}; +use editor::{Editor, ExcerptRange, MultiBuffer}; +use futures::StreamExt; +use gpui::{ + actions, elements::*, Action, AppContext, Entity, ModelHandle, Subscription, View, ViewContext, + ViewHandle, WeakViewHandle, WindowContext, +}; +use language::{language_settings::SoftWrap, Anchor, Buffer}; +use std::sync::Arc; +use util::ResultExt; +use workspace::{ + dock::{DockPosition, Panel}, + item::Item, + pane, Pane, Workspace, +}; + +actions!(assistant, [NewContext, Assist]); + +pub fn init(cx: &mut AppContext) { + cx.add_action(ContextEditor::assist); +} + +pub enum AssistantPanelEvent { + ZoomIn, + ZoomOut, + Focus, + Close, +} + +pub struct AssistantPanel { + width: Option, + pane: ViewHandle, + workspace: WeakViewHandle, + _subscriptions: Vec, +} + +impl AssistantPanel { + pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { + let weak_self = cx.weak_handle(); + let pane = cx.add_view(|cx| { + let window_id = cx.window_id(); + let mut pane = Pane::new( + workspace.weak_handle(), + workspace.app_state().background_actions, + Default::default(), + cx, + ); + pane.set_can_split(false, cx); + pane.set_can_navigate(false, cx); + pane.on_can_drop(move |_, cx| false); + pane.set_render_tab_bar_buttons(cx, move |pane, cx| { + let this = weak_self.clone(); + Flex::row() + .with_child(Pane::render_tab_bar_button( + 0, + "icons/plus_12.svg", + Some(("New Context".into(), Some(Box::new(NewContext)))), + cx, + move |_, cx| {}, + None, + )) + .with_child(Pane::render_tab_bar_button( + 1, + if pane.is_zoomed() { + "icons/minimize_8.svg" + } else { + "icons/maximize_8.svg" + }, + Some(("Toggle Zoom".into(), Some(Box::new(workspace::ToggleZoom)))), + cx, + move |pane, cx| pane.toggle_zoom(&Default::default(), cx), + None, + )) + .into_any() + }); + let buffer_search_bar = cx.add_view(search::BufferSearchBar::new); + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(buffer_search_bar, cx)); + pane + }); + let subscriptions = vec![ + cx.observe(&pane, |_, _, cx| cx.notify()), + cx.subscribe(&pane, Self::handle_pane_event), + ]; + + Self { + pane, + workspace: workspace.weak_handle(), + width: None, + _subscriptions: subscriptions, + } + } + + fn handle_pane_event( + &mut self, + _pane: ViewHandle, + event: &pane::Event, + cx: &mut ViewContext, + ) { + match event { + pane::Event::ZoomIn => cx.emit(AssistantPanelEvent::ZoomIn), + pane::Event::ZoomOut => cx.emit(AssistantPanelEvent::ZoomOut), + pane::Event::Focus => cx.emit(AssistantPanelEvent::Focus), + pane::Event::Remove => cx.emit(AssistantPanelEvent::Close), + _ => {} + } + } +} + +impl Entity for AssistantPanel { + type Event = AssistantPanelEvent; +} + +impl View for AssistantPanel { + fn ui_name() -> &'static str { + "AssistantPanel" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + ChildView::new(&self.pane, cx).into_any() + } +} + +impl Panel for AssistantPanel { + fn position(&self, cx: &WindowContext) -> DockPosition { + DockPosition::Right + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + matches!(position, DockPosition::Right) + } + + fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) {} + + fn size(&self, cx: &WindowContext) -> f32 { + self.width.unwrap_or(480.) + } + + fn set_size(&mut self, size: f32, cx: &mut ViewContext) { + self.width = Some(size); + cx.notify(); + } + + fn should_zoom_in_on_event(event: &AssistantPanelEvent) -> bool { + matches!(event, AssistantPanelEvent::ZoomIn) + } + + fn should_zoom_out_on_event(event: &AssistantPanelEvent) -> bool { + matches!(event, AssistantPanelEvent::ZoomOut) + } + + fn is_zoomed(&self, cx: &WindowContext) -> bool { + self.pane.read(cx).is_zoomed() + } + + fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext) { + self.pane.update(cx, |pane, cx| pane.set_zoomed(zoomed, cx)); + } + + fn set_active(&mut self, active: bool, cx: &mut ViewContext) { + if active && self.pane.read(cx).items_len() == 0 { + cx.defer(|this, cx| { + if let Some(workspace) = this.workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + let focus = this.pane.read(cx).has_focus(); + let editor = Box::new(cx.add_view(|cx| ContextEditor::new(cx))); + Pane::add_item(workspace, &this.pane, editor, true, focus, None, cx); + }) + } + }); + } + } + + fn icon_path(&self) -> &'static str { + "icons/speech_bubble_12.svg" + } + + fn icon_tooltip(&self) -> (String, Option>) { + ("Assistant Panel".into(), None) + } + + fn should_change_position_on_event(event: &Self::Event) -> bool { + false + } + + fn should_activate_on_event(_: &Self::Event) -> bool { + false + } + + fn should_close_on_event(event: &AssistantPanelEvent) -> bool { + matches!(event, AssistantPanelEvent::Close) + } + + fn has_focus(&self, cx: &WindowContext) -> bool { + self.pane.read(cx).has_focus() + } + + fn is_focus_event(event: &Self::Event) -> bool { + matches!(event, AssistantPanelEvent::Focus) + } +} + +struct ContextEditor { + messages: Vec, + editor: ViewHandle, +} + +impl ContextEditor { + fn new(cx: &mut ViewContext) -> Self { + let messages = vec![Message { + role: Role::User, + content: cx.add_model(|cx| Buffer::new(0, "", cx)), + }]; + + let multibuffer = cx.add_model(|cx| { + let mut multibuffer = MultiBuffer::new(0); + for message in &messages { + multibuffer.push_excerpts_with_context_lines( + message.content.clone(), + vec![Anchor::MIN..Anchor::MAX], + 0, + cx, + ); + } + multibuffer + }); + let editor = cx.add_view(|cx| { + let mut editor = Editor::for_multibuffer(multibuffer, None, cx); + editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + editor + }); + + Self { messages, editor } + } + + fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { + let messages = self + .messages + .iter() + .map(|message| RequestMessage { + role: message.role, + content: message.content.read(cx).text(), + }) + .collect(); + let request = OpenAIRequest { + model: "gpt-3.5-turbo".into(), + messages, + stream: true, + }; + + if let Some(api_key) = std::env::var("OPENAI_API_KEY").log_err() { + let stream = stream_completion(api_key, cx.background_executor().clone(), request); + let content = cx.add_model(|cx| Buffer::new(0, "", cx)); + self.messages.push(Message { + role: Role::Assistant, + content: content.clone(), + }); + self.editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |multibuffer, cx| { + multibuffer.push_excerpts_with_context_lines( + content.clone(), + vec![Anchor::MIN..Anchor::MAX], + 0, + cx, + ); + }); + }); + cx.spawn(|_, mut cx| async move { + let mut messages = stream.await?; + + while let Some(message) = messages.next().await { + let mut message = message?; + if let Some(choice) = message.choices.pop() { + content.update(&mut cx, |content, cx| { + let text: Arc = choice.delta.content?.into(); + content.edit([(content.len()..content.len(), text)], None, cx); + Some(()) + }); + } + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + } +} + +impl Entity for ContextEditor { + type Event = (); +} + +impl View for ContextEditor { + fn ui_name() -> &'static str { + "ContextEditor" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + ChildView::new(&self.editor, cx).into_any() + } +} + +impl Item for ContextEditor { + fn tab_content( + &self, + _: Option, + style: &theme::Tab, + _: &gpui::AppContext, + ) -> AnyElement { + Label::new("New Context", style.label.clone()).into_any() + } +} + +struct Message { + role: Role, + content: ModelHandle, +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 619dd81a80407faf2f001fb4d47449bee98938bf..24b7d6356b89a9b691170fa28ed9c31c0bc1606b 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2,6 +2,7 @@ pub mod languages; pub mod menus; #[cfg(any(test, feature = "test-support"))] pub mod test; +use ai::AssistantPanel; use anyhow::Context; use assets::Assets; use breadcrumbs::Breadcrumbs; @@ -357,7 +358,11 @@ pub fn initialize_workspace( workspace.toggle_dock(project_panel_position, false, cx); } - workspace.add_panel(terminal_panel, cx) + workspace.add_panel(terminal_panel, cx); + + // TODO: deserialize state. + let assistant_panel = cx.add_view(|cx| AssistantPanel::new(workspace, cx)); + workspace.add_panel(assistant_panel, cx); })?; Ok(()) }) From 8f6e67f440082e8a9807e5f0189556f922fb3183 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 26 May 2023 14:49:27 -0600 Subject: [PATCH 02/32] Cancel assists on escape --- assets/keymaps/default.json | 3 +- crates/ai/src/assistant.rs | 73 +++++++++++++++++++++++++++---------- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 5102b7408bd79ff017a57554b57487c502813fbf..0b89969c70806752dcfb1b15ba48f6f4fe91868b 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -198,7 +198,8 @@ { "context": "ContextEditor > Editor", "bindings": { - "cmd-enter": "assistant::Assist" + "cmd-enter": "assistant::Assist", + "escape": "assistant::CancelLastAssist" } }, { diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index ef202e72144d490ccfe713204c7c0aed0d1de04a..0c58f7a4791243b60d0fda3f66c202003e54c438 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -1,23 +1,24 @@ use crate::{stream_completion, OpenAIRequest, RequestMessage, Role}; -use editor::{Editor, ExcerptRange, MultiBuffer}; +use editor::{Editor, MultiBuffer}; use futures::StreamExt; use gpui::{ - actions, elements::*, Action, AppContext, Entity, ModelHandle, Subscription, View, ViewContext, - ViewHandle, WeakViewHandle, WindowContext, + actions, elements::*, Action, AppContext, Entity, ModelHandle, Subscription, Task, View, + ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; use language::{language_settings::SoftWrap, Anchor, Buffer}; use std::sync::Arc; -use util::ResultExt; +use util::{post_inc, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, item::Item, pane, Pane, Workspace, }; -actions!(assistant, [NewContext, Assist]); +actions!(assistant, [NewContext, Assist, CancelLastAssist]); pub fn init(cx: &mut AppContext) { cx.add_action(ContextEditor::assist); + cx.add_action(ContextEditor::cancel_last_assist); } pub enum AssistantPanelEvent { @@ -203,6 +204,13 @@ impl Panel for AssistantPanel { struct ContextEditor { messages: Vec, editor: ViewHandle, + completion_count: usize, + pending_completions: Vec, +} + +struct PendingCompletion { + id: usize, + task: Task>, } impl ContextEditor { @@ -230,7 +238,12 @@ impl ContextEditor { editor }); - Self { messages, editor } + Self { + messages, + editor, + completion_count: 0, + pending_completions: Vec::new(), + } } fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { @@ -265,22 +278,42 @@ impl ContextEditor { ); }); }); - cx.spawn(|_, mut cx| async move { - let mut messages = stream.await?; - - while let Some(message) = messages.next().await { - let mut message = message?; - if let Some(choice) = message.choices.pop() { - content.update(&mut cx, |content, cx| { - let text: Arc = choice.delta.content?.into(); - content.edit([(content.len()..content.len(), text)], None, cx); - Some(()) - }); + let task = cx.spawn(|this, mut cx| { + async move { + let mut messages = stream.await?; + + while let Some(message) = messages.next().await { + let mut message = message?; + if let Some(choice) = message.choices.pop() { + content.update(&mut cx, |content, cx| { + let text: Arc = choice.delta.content?.into(); + content.edit([(content.len()..content.len(), text)], None, cx); + Some(()) + }); + } } + + this.update(&mut cx, |this, _| { + this.pending_completions + .retain(|completion| completion.id != this.completion_count); + }) + .ok(); + + anyhow::Ok(()) } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); + .log_err() + }); + + self.pending_completions.push(PendingCompletion { + id: post_inc(&mut self.completion_count), + task, + }); + } + } + + fn cancel_last_assist(&mut self, _: &CancelLastAssist, cx: &mut ViewContext) { + if self.pending_completions.pop().is_none() { + cx.propagate_action(); } } } From 3904971bd80b9351cd7b6c2a5e02e83e19fbcc3b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 26 May 2023 15:38:03 -0600 Subject: [PATCH 03/32] Hide assistant gutter --- crates/ai/src/ai.rs | 1 - crates/ai/src/assistant.rs | 8 +++++++- crates/editor/src/editor.rs | 9 +++++++++ crates/editor/src/element.rs | 2 +- crates/theme/src/theme.rs | 6 ++++++ styles/src/styleTree/app.ts | 2 ++ styles/src/styleTree/assistant.ts | 13 +++++++++++++ 7 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 styles/src/styleTree/assistant.ts diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index cd42ee115328febc04e632e3632ad9030ad3edcd..64d8b6962801cb57a4d2c253e63e3aac05c14a07 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -15,7 +15,6 @@ use std::cell::RefCell; use std::fs; use std::rc::Rc; use std::{io, sync::Arc}; -use util::channel::{ReleaseChannel, RELEASE_CHANNEL}; use util::{ResultExt, TryFutureExt}; pub use assistant::AssistantPanel; diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 0c58f7a4791243b60d0fda3f66c202003e54c438..72e4e9fa427b22bcd985535e5ae431b52029f0f5 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -235,6 +235,7 @@ impl ContextEditor { let editor = cx.add_view(|cx| { let mut editor = Editor::for_multibuffer(multibuffer, None, cx); editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + editor.set_show_gutter(false, cx); editor }); @@ -328,7 +329,12 @@ impl View for ContextEditor { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - ChildView::new(&self.editor, cx).into_any() + let theme = &theme::current(cx).assistant; + + ChildView::new(&self.editor, cx) + .contained() + .with_style(theme.container) + .into_any() } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f5d109e15bd2138fba6914e058b575a5bdf42e80..31df5069db39f2c769f7d96b88958b4d9f606c2d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -496,6 +496,7 @@ pub struct Editor { blink_manager: ModelHandle, show_local_selections: bool, mode: EditorMode, + show_gutter: bool, placeholder_text: Option>, highlighted_rows: Option>, #[allow(clippy::type_complexity)] @@ -526,6 +527,7 @@ pub struct Editor { pub struct EditorSnapshot { pub mode: EditorMode, + pub show_gutter: bool, pub display_snapshot: DisplaySnapshot, pub placeholder_text: Option>, is_focused: bool, @@ -1297,6 +1299,7 @@ impl Editor { blink_manager: blink_manager.clone(), show_local_selections: true, mode, + show_gutter: mode == EditorMode::Full, placeholder_text: None, highlighted_rows: None, background_highlights: Default::default(), @@ -1393,6 +1396,7 @@ impl Editor { pub fn snapshot(&mut self, cx: &mut WindowContext) -> EditorSnapshot { EditorSnapshot { mode: self.mode, + show_gutter: self.show_gutter, display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)), scroll_anchor: self.scroll_manager.anchor(), ongoing_scroll: self.scroll_manager.ongoing_scroll(), @@ -6654,6 +6658,11 @@ impl Editor { cx.notify(); } + pub fn set_show_gutter(&mut self, show_gutter: bool, cx: &mut ViewContext) { + self.show_gutter = show_gutter; + cx.notify(); + } + pub fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext) { if let Some(buffer) = self.buffer().read(cx).as_singleton() { if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) { diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 6be065084814e570a0411ffb91df4a4eb380e1ac..5b4da4407360b1a2af410b411bc910385307401e 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1899,7 +1899,7 @@ impl Element for EditorElement { let gutter_padding; let gutter_width; let gutter_margin; - if snapshot.mode == EditorMode::Full { + if snapshot.show_gutter { let em_width = style.text.em_width(cx.font_cache()); gutter_padding = (em_width * style.gutter_padding_factor).round(); gutter_width = self.max_line_number_width(&snapshot, cx) + gutter_padding * 2.0; diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index b1c9e9c215324bbd6dcdf363d2aeb272ec86f057..010d6956eb47ac57b8a9d08b876715f86a1d61b8 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -60,6 +60,7 @@ pub struct Theme { pub incoming_call_notification: IncomingCallNotification, pub tooltip: TooltipStyle, pub terminal: TerminalStyle, + pub assistant: AssistantStyle, pub feedback: FeedbackStyle, pub welcome: WelcomeStyle, pub color_scheme: ColorScheme, @@ -967,6 +968,11 @@ pub struct TerminalStyle { pub dim_foreground: Color, } +#[derive(Clone, Deserialize, Default)] +pub struct AssistantStyle { + pub container: ContainerStyle, +} + #[derive(Clone, Deserialize, Default)] pub struct FeedbackStyle { pub submit_button: Interactive, diff --git a/styles/src/styleTree/app.ts b/styles/src/styleTree/app.ts index 6238e1abe1e07a8df61094c82c6aacd11ac32548..a9700a8d9994f0b8f63b74862b8db26c873a37da 100644 --- a/styles/src/styleTree/app.ts +++ b/styles/src/styleTree/app.ts @@ -22,6 +22,7 @@ import { ColorScheme } from "../themes/common/colorScheme" import feedback from "./feedback" import welcome from "./welcome" import copilot from "./copilot" +import assistant from "./assistant" export default function app(colorScheme: ColorScheme): Object { return { @@ -50,6 +51,7 @@ export default function app(colorScheme: ColorScheme): Object { simpleMessageNotification: simpleMessageNotification(colorScheme), tooltip: tooltip(colorScheme), terminal: terminal(colorScheme), + assistant: assistant(colorScheme), feedback: feedback(colorScheme), colorScheme: { ...colorScheme, diff --git a/styles/src/styleTree/assistant.ts b/styles/src/styleTree/assistant.ts new file mode 100644 index 0000000000000000000000000000000000000000..810831285e0ec38f248ae01b3b00ba10c4ca669c --- /dev/null +++ b/styles/src/styleTree/assistant.ts @@ -0,0 +1,13 @@ +import { ColorScheme } from "../themes/common/colorScheme" +import editor from "./editor" + +export default function assistant(colorScheme: ColorScheme) { + return { + container: { + background: editor(colorScheme).background, + padding: { + left: 10, + } + } + } +} From ffbfbe422b6a2c3a43013f9d90e30bb430db5650 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 26 May 2023 16:11:58 -0600 Subject: [PATCH 04/32] WIP: Not sure I actually want to rip this out --- crates/ai/src/ai.rs | 241 +----------------- crates/ai/src/assistant.rs | 128 +++++++--- crates/zed/src/languages/markdown/config.toml | 2 +- 3 files changed, 101 insertions(+), 270 deletions(-) diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 64d8b6962801cb57a4d2c253e63e3aac05c14a07..89999f26f39d63591cee4c43274d441439ea5f77 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -1,21 +1,7 @@ mod assistant; -use anyhow::{anyhow, Result}; -use assets::Assets; -use collections::HashMap; -use editor::Editor; -use futures::AsyncBufReadExt; -use futures::{io::BufReader, AsyncReadExt, Stream, StreamExt}; -use gpui::executor::Background; -use gpui::{actions, AppContext, Task, ViewContext}; -use isahc::prelude::*; -use isahc::{http::StatusCode, Request}; +use gpui::{actions, AppContext}; use serde::{Deserialize, Serialize}; -use std::cell::RefCell; -use std::fs; -use std::rc::Rc; -use std::{io, sync::Arc}; -use util::{ResultExt, TryFutureExt}; pub use assistant::AssistantPanel; @@ -89,230 +75,5 @@ struct OpenAIChoice { } pub fn init(cx: &mut AppContext) { - // if *RELEASE_CHANNEL == ReleaseChannel::Stable { - // return; - // } - assistant::init(cx); - - // let assistant = Rc::new(Assistant::default()); - // cx.add_action({ - // let assistant = assistant.clone(); - // move |editor: &mut Editor, _: &Assist, cx: &mut ViewContext| { - // assistant.assist(editor, cx).log_err(); - // } - // }); - // cx.capture_action({ - // let assistant = assistant.clone(); - // move |_: &mut Editor, _: &editor::Cancel, cx: &mut ViewContext| { - // if !assistant.cancel_last_assist(cx.view_id()) { - // cx.propagate_action(); - // } - // } - // }); -} - -type CompletionId = usize; - -#[derive(Default)] -struct Assistant(RefCell); - -#[derive(Default)] -struct AssistantState { - assist_stacks: HashMap>)>>, - next_completion_id: CompletionId, -} - -impl Assistant { - fn assist(self: &Rc, editor: &mut Editor, cx: &mut ViewContext) -> Result<()> { - let api_key = std::env::var("OPENAI_API_KEY")?; - - let selections = editor.selections.all(cx); - let (user_message, insertion_site) = editor.buffer().update(cx, |buffer, cx| { - // Insert markers around selected text as described in the system prompt above. - let snapshot = buffer.snapshot(cx); - let mut user_message = String::new(); - let mut user_message_suffix = String::new(); - let mut buffer_offset = 0; - for selection in selections { - if !selection.is_empty() { - if user_message_suffix.is_empty() { - user_message_suffix.push_str("\n\n"); - } - user_message_suffix.push_str("[Selected excerpt from above]\n"); - user_message_suffix - .extend(snapshot.text_for_range(selection.start..selection.end)); - user_message_suffix.push_str("\n\n"); - } - - user_message.extend(snapshot.text_for_range(buffer_offset..selection.start)); - user_message.push_str("[SELECTION_START]"); - user_message.extend(snapshot.text_for_range(selection.start..selection.end)); - buffer_offset = selection.end; - user_message.push_str("[SELECTION_END]"); - } - if buffer_offset < snapshot.len() { - user_message.extend(snapshot.text_for_range(buffer_offset..snapshot.len())); - } - user_message.push_str(&user_message_suffix); - - // Ensure the document ends with 4 trailing newlines. - let trailing_newline_count = snapshot - .reversed_chars_at(snapshot.len()) - .take_while(|c| *c == '\n') - .take(4); - let buffer_suffix = "\n".repeat(4 - trailing_newline_count.count()); - buffer.edit([(snapshot.len()..snapshot.len(), buffer_suffix)], None, cx); - - let snapshot = buffer.snapshot(cx); // Take a new snapshot after editing. - let insertion_site = snapshot.anchor_after(snapshot.len() - 2); - - (user_message, insertion_site) - }); - - let this = self.clone(); - let buffer = editor.buffer().clone(); - let executor = cx.background_executor().clone(); - let editor_id = cx.view_id(); - let assist_id = util::post_inc(&mut self.0.borrow_mut().next_completion_id); - let assist_task = cx.spawn(|_, mut cx| { - async move { - // TODO: We should have a get_string method on assets. This is repateated elsewhere. - let content = Assets::get("contexts/system.zmd").unwrap(); - let mut system_message = std::str::from_utf8(content.data.as_ref()) - .unwrap() - .to_string(); - - if let Ok(custom_system_message_path) = - std::env::var("ZED_ASSISTANT_SYSTEM_PROMPT_PATH") - { - system_message.push_str( - "\n\nAlso consider the following user-defined system prompt:\n\n", - ); - // TODO: Replace this with our file system trait object. - system_message.push_str( - &cx.background() - .spawn(async move { fs::read_to_string(custom_system_message_path) }) - .await?, - ); - } - - let stream = stream_completion( - api_key, - executor, - OpenAIRequest { - model: "gpt-4".to_string(), - messages: vec![ - RequestMessage { - role: Role::System, - content: system_message.to_string(), - }, - RequestMessage { - role: Role::User, - content: user_message, - }, - ], - stream: false, - }, - ); - - let mut messages = stream.await?; - while let Some(message) = messages.next().await { - let mut message = message?; - if let Some(choice) = message.choices.pop() { - buffer.update(&mut cx, |buffer, cx| { - let text: Arc = choice.delta.content?.into(); - buffer.edit([(insertion_site.clone()..insertion_site, text)], None, cx); - Some(()) - }); - } - } - - this.0 - .borrow_mut() - .assist_stacks - .get_mut(&editor_id) - .unwrap() - .retain(|(id, _)| *id != assist_id); - - anyhow::Ok(()) - } - .log_err() - }); - - self.0 - .borrow_mut() - .assist_stacks - .entry(cx.view_id()) - .or_default() - .push((dbg!(assist_id), assist_task)); - - Ok(()) - } - - fn cancel_last_assist(self: &Rc, editor_id: usize) -> bool { - self.0 - .borrow_mut() - .assist_stacks - .get_mut(&editor_id) - .and_then(|assists| assists.pop()) - .is_some() - } -} - -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("https://api.openai.com/v1/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() { - tx.unbounded_send(event).log_err(); - } - } - - anyhow::Ok(()) - }) - .detach(); - - Ok(rx) - } else { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - - 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 72e4e9fa427b22bcd985535e5ae431b52029f0f5..e2796f4ecfb656a748dc97958476c54f8c113547 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -1,12 +1,14 @@ -use crate::{stream_completion, OpenAIRequest, RequestMessage, Role}; +use crate::{OpenAIRequest, OpenAIResponseStreamEvent, RequestMessage, Role}; +use anyhow::{anyhow, Result}; use editor::{Editor, MultiBuffer}; -use futures::StreamExt; +use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; use gpui::{ - actions, elements::*, Action, AppContext, Entity, ModelHandle, Subscription, Task, View, - ViewContext, ViewHandle, WeakViewHandle, WindowContext, + actions, elements::*, executor::Background, Action, AppContext, Entity, ModelHandle, + Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; +use isahc::{http::StatusCode, Request, RequestExt}; use language::{language_settings::SoftWrap, Anchor, Buffer}; -use std::sync::Arc; +use std::{io, sync::Arc}; use util::{post_inc, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, @@ -17,8 +19,8 @@ use workspace::{ actions!(assistant, [NewContext, Assist, CancelLastAssist]); pub fn init(cx: &mut AppContext) { - cx.add_action(ContextEditor::assist); - cx.add_action(ContextEditor::cancel_last_assist); + cx.add_action(Assistant::assist); + cx.capture_action(Assistant::cancel_last_assist); } pub enum AssistantPanelEvent { @@ -37,9 +39,7 @@ pub struct AssistantPanel { impl AssistantPanel { pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { - let weak_self = cx.weak_handle(); let pane = cx.add_view(|cx| { - let window_id = cx.window_id(); let mut pane = Pane::new( workspace.weak_handle(), workspace.app_state().background_actions, @@ -48,16 +48,15 @@ impl AssistantPanel { ); pane.set_can_split(false, cx); pane.set_can_navigate(false, cx); - pane.on_can_drop(move |_, cx| false); + pane.on_can_drop(move |_, _| false); pane.set_render_tab_bar_buttons(cx, move |pane, cx| { - let this = weak_self.clone(); Flex::row() .with_child(Pane::render_tab_bar_button( 0, "icons/plus_12.svg", Some(("New Context".into(), Some(Box::new(NewContext)))), cx, - move |_, cx| {}, + move |_, _| todo!(), None, )) .with_child(Pane::render_tab_bar_button( @@ -123,7 +122,7 @@ impl View for AssistantPanel { } impl Panel for AssistantPanel { - fn position(&self, cx: &WindowContext) -> DockPosition { + fn position(&self, _: &WindowContext) -> DockPosition { DockPosition::Right } @@ -131,9 +130,11 @@ impl Panel for AssistantPanel { matches!(position, DockPosition::Right) } - fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) {} + fn set_position(&mut self, _: DockPosition, _: &mut ViewContext) { + // TODO! + } - fn size(&self, cx: &WindowContext) -> f32 { + fn size(&self, _: &WindowContext) -> f32 { self.width.unwrap_or(480.) } @@ -164,7 +165,7 @@ impl Panel for AssistantPanel { if let Some(workspace) = this.workspace.upgrade(cx) { workspace.update(cx, |workspace, cx| { let focus = this.pane.read(cx).has_focus(); - let editor = Box::new(cx.add_view(|cx| ContextEditor::new(cx))); + let editor = Box::new(cx.add_view(|cx| Assistant::new(cx))); Pane::add_item(workspace, &this.pane, editor, true, focus, None, cx); }) } @@ -180,7 +181,8 @@ impl Panel for AssistantPanel { ("Assistant Panel".into(), None) } - fn should_change_position_on_event(event: &Self::Event) -> bool { + fn should_change_position_on_event(_: &Self::Event) -> bool { + // TODO! false } @@ -201,7 +203,7 @@ impl Panel for AssistantPanel { } } -struct ContextEditor { +struct Assistant { messages: Vec, editor: ViewHandle, completion_count: usize, @@ -210,10 +212,10 @@ struct ContextEditor { struct PendingCompletion { id: usize, - task: Task>, + _task: Task>, } -impl ContextEditor { +impl Assistant { fn new(cx: &mut ViewContext) -> Self { let messages = vec![Message { role: Role::User, @@ -264,15 +266,26 @@ impl ContextEditor { if let Some(api_key) = std::env::var("OPENAI_API_KEY").log_err() { let stream = stream_completion(api_key, cx.background_executor().clone(), request); - let content = cx.add_model(|cx| Buffer::new(0, "", cx)); + let response_buffer = cx.add_model(|cx| Buffer::new(0, "", cx)); self.messages.push(Message { role: Role::Assistant, - content: content.clone(), + content: response_buffer.clone(), + }); + let next_request_buffer = cx.add_model(|cx| Buffer::new(0, "", cx)); + self.messages.push(Message { + role: Role::User, + content: next_request_buffer.clone(), }); self.editor.update(cx, |editor, cx| { editor.buffer().update(cx, |multibuffer, cx| { multibuffer.push_excerpts_with_context_lines( - content.clone(), + response_buffer.clone(), + vec![Anchor::MIN..Anchor::MAX], + 0, + cx, + ); + multibuffer.push_excerpts_with_context_lines( + next_request_buffer, vec![Anchor::MIN..Anchor::MAX], 0, cx, @@ -286,7 +299,7 @@ impl ContextEditor { while let Some(message) = messages.next().await { let mut message = message?; if let Some(choice) = message.choices.pop() { - content.update(&mut cx, |content, cx| { + response_buffer.update(&mut cx, |content, cx| { let text: Arc = choice.delta.content?.into(); content.edit([(content.len()..content.len(), text)], None, cx); Some(()) @@ -307,23 +320,23 @@ impl ContextEditor { self.pending_completions.push(PendingCompletion { id: post_inc(&mut self.completion_count), - task, + _task: task, }); } } - fn cancel_last_assist(&mut self, _: &CancelLastAssist, cx: &mut ViewContext) { + fn cancel_last_assist(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { if self.pending_completions.pop().is_none() { cx.propagate_action(); } } } -impl Entity for ContextEditor { +impl Entity for Assistant { type Event = (); } -impl View for ContextEditor { +impl View for Assistant { fn ui_name() -> &'static str { "ContextEditor" } @@ -338,7 +351,7 @@ impl View for ContextEditor { } } -impl Item for ContextEditor { +impl Item for Assistant { fn tab_content( &self, _: Option, @@ -353,3 +366,60 @@ struct Message { role: Role, content: ModelHandle, } + +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("https://api.openai.com/v1/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() { + tx.unbounded_send(event).log_err(); + } + } + + anyhow::Ok(()) + }) + .detach(); + + Ok(rx) + } else { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + Err(anyhow!( + "Failed to connect to OpenAI API: {} {}", + response.status(), + body, + )) + } +} diff --git a/crates/zed/src/languages/markdown/config.toml b/crates/zed/src/languages/markdown/config.toml index 55204cc7a57ad051004a4fc0d76746057908aa20..2fa3ff3cf2aba297517494cbd1f2e0608daaa402 100644 --- a/crates/zed/src/languages/markdown/config.toml +++ b/crates/zed/src/languages/markdown/config.toml @@ -1,5 +1,5 @@ name = "Markdown" -path_suffixes = ["md", "mdx", "zmd"] +path_suffixes = ["md", "mdx"] brackets = [ { start = "{", end = "}", close = true, newline = true }, { start = "[", end = "]", close = true, newline = true }, From 404bebab63d16c92b76c8920585fde8a79cc58fc Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 29 May 2023 11:20:24 +0200 Subject: [PATCH 05/32] Set markdown as the assistant's buffer languages --- crates/ai/src/assistant.rs | 243 ++++++++++++++++++++----------------- crates/zed/src/zed.rs | 7 +- 2 files changed, 136 insertions(+), 114 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index e2796f4ecfb656a748dc97958476c54f8c113547..1fa9c3cc4ffadfc2b7437a562ba0e175db1e6f78 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -3,11 +3,11 @@ use anyhow::{anyhow, Result}; use editor::{Editor, MultiBuffer}; use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; use gpui::{ - actions, elements::*, executor::Background, Action, AppContext, Entity, ModelHandle, - Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, + actions, elements::*, executor::Background, Action, AppContext, AsyncAppContext, Entity, + ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; use isahc::{http::StatusCode, Request, RequestExt}; -use language::{language_settings::SoftWrap, Anchor, Buffer}; +use language::{language_settings::SoftWrap, Anchor, Buffer, Language, LanguageRegistry}; use std::{io, sync::Arc}; use util::{post_inc, ResultExt, TryFutureExt}; use workspace::{ @@ -38,57 +38,70 @@ pub struct AssistantPanel { } impl AssistantPanel { - pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { - let pane = cx.add_view(|cx| { - let mut pane = Pane::new( - workspace.weak_handle(), - workspace.app_state().background_actions, - Default::default(), - cx, - ); - pane.set_can_split(false, cx); - pane.set_can_navigate(false, cx); - pane.on_can_drop(move |_, _| false); - pane.set_render_tab_bar_buttons(cx, move |pane, cx| { - Flex::row() - .with_child(Pane::render_tab_bar_button( - 0, - "icons/plus_12.svg", - Some(("New Context".into(), Some(Box::new(NewContext)))), - cx, - move |_, _| todo!(), - None, - )) - .with_child(Pane::render_tab_bar_button( - 1, - if pane.is_zoomed() { - "icons/minimize_8.svg" - } else { - "icons/maximize_8.svg" - }, - Some(("Toggle Zoom".into(), Some(Box::new(workspace::ToggleZoom)))), - cx, - move |pane, cx| pane.toggle_zoom(&Default::default(), cx), - None, - )) - .into_any() - }); - let buffer_search_bar = cx.add_view(search::BufferSearchBar::new); - pane.toolbar() - .update(cx, |toolbar, cx| toolbar.add_item(buffer_search_bar, cx)); - pane - }); - let subscriptions = vec![ - cx.observe(&pane, |_, _, cx| cx.notify()), - cx.subscribe(&pane, Self::handle_pane_event), - ]; - - Self { - pane, - workspace: workspace.weak_handle(), - width: None, - _subscriptions: subscriptions, - } + pub fn load( + workspace: WeakViewHandle, + cx: AsyncAppContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + // TODO: deserialize state. + workspace.update(&mut cx, |workspace, cx| { + cx.add_view(|cx| { + let pane = cx.add_view(|cx| { + let mut pane = Pane::new( + workspace.weak_handle(), + workspace.app_state().background_actions, + Default::default(), + cx, + ); + pane.set_can_split(false, cx); + pane.set_can_navigate(false, cx); + pane.on_can_drop(move |_, _| false); + pane.set_render_tab_bar_buttons(cx, move |pane, cx| { + Flex::row() + .with_child(Pane::render_tab_bar_button( + 0, + "icons/plus_12.svg", + Some(("New Context".into(), Some(Box::new(NewContext)))), + cx, + move |_, _| todo!(), + None, + )) + .with_child(Pane::render_tab_bar_button( + 1, + if pane.is_zoomed() { + "icons/minimize_8.svg" + } else { + "icons/maximize_8.svg" + }, + Some(( + "Toggle Zoom".into(), + Some(Box::new(workspace::ToggleZoom)), + )), + cx, + move |pane, cx| pane.toggle_zoom(&Default::default(), cx), + None, + )) + .into_any() + }); + let buffer_search_bar = cx.add_view(search::BufferSearchBar::new); + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(buffer_search_bar, cx)); + pane + }); + let subscriptions = vec![ + cx.observe(&pane, |_, _, cx| cx.notify()), + cx.subscribe(&pane, Self::handle_pane_event), + ]; + + Self { + pane, + workspace: workspace.weak_handle(), + width: None, + _subscriptions: subscriptions, + } + }) + }) + }) } fn handle_pane_event( @@ -161,15 +174,28 @@ impl Panel for AssistantPanel { fn set_active(&mut self, active: bool, cx: &mut ViewContext) { if active && self.pane.read(cx).items_len() == 0 { - cx.defer(|this, cx| { - if let Some(workspace) = this.workspace.upgrade(cx) { - workspace.update(cx, |workspace, cx| { - let focus = this.pane.read(cx).has_focus(); - let editor = Box::new(cx.add_view(|cx| Assistant::new(cx))); - Pane::add_item(workspace, &this.pane, editor, true, focus, None, cx); - }) - } - }); + let workspace = self.workspace.clone(); + let pane = self.pane.clone(); + let focus = self.has_focus(cx); + cx.spawn(|_, mut cx| async move { + let markdown = workspace + .read_with(&cx, |workspace, _| { + workspace + .app_state() + .languages + .language_for_name("Markdown") + })? + .await?; + workspace.update(&mut cx, |workspace, cx| { + let editor = Box::new(cx.add_view(|cx| { + Assistant::new(markdown, workspace.app_state().languages.clone(), cx) + })); + Pane::add_item(workspace, &pane, editor, true, focus, None, cx); + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); } } @@ -208,6 +234,8 @@ struct Assistant { editor: ViewHandle, completion_count: usize, pending_completions: Vec, + markdown: Arc, + language_registry: Arc, } struct PendingCompletion { @@ -216,37 +244,29 @@ struct PendingCompletion { } impl Assistant { - fn new(cx: &mut ViewContext) -> Self { - let messages = vec![Message { - role: Role::User, - content: cx.add_model(|cx| Buffer::new(0, "", cx)), - }]; - - let multibuffer = cx.add_model(|cx| { - let mut multibuffer = MultiBuffer::new(0); - for message in &messages { - multibuffer.push_excerpts_with_context_lines( - message.content.clone(), - vec![Anchor::MIN..Anchor::MAX], - 0, - cx, - ); - } - multibuffer - }); + fn new( + markdown: Arc, + language_registry: Arc, + cx: &mut ViewContext, + ) -> Self { let editor = cx.add_view(|cx| { + let multibuffer = cx.add_model(|_| MultiBuffer::new(0)); let mut editor = Editor::for_multibuffer(multibuffer, None, cx); editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); editor.set_show_gutter(false, cx); editor }); - Self { - messages, + let mut this = Self { + messages: Default::default(), editor, completion_count: 0, pending_completions: Vec::new(), - } + markdown, + language_registry, + }; + this.push_message(Role::User, cx); + this } fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { @@ -266,32 +286,8 @@ impl Assistant { if let Some(api_key) = std::env::var("OPENAI_API_KEY").log_err() { let stream = stream_completion(api_key, cx.background_executor().clone(), request); - let response_buffer = cx.add_model(|cx| Buffer::new(0, "", cx)); - self.messages.push(Message { - role: Role::Assistant, - content: response_buffer.clone(), - }); - let next_request_buffer = cx.add_model(|cx| Buffer::new(0, "", cx)); - self.messages.push(Message { - role: Role::User, - content: next_request_buffer.clone(), - }); - self.editor.update(cx, |editor, cx| { - editor.buffer().update(cx, |multibuffer, cx| { - multibuffer.push_excerpts_with_context_lines( - response_buffer.clone(), - vec![Anchor::MIN..Anchor::MAX], - 0, - cx, - ); - multibuffer.push_excerpts_with_context_lines( - next_request_buffer, - vec![Anchor::MIN..Anchor::MAX], - 0, - cx, - ); - }); - }); + let response_buffer = self.push_message(Role::Assistant, cx); + self.push_message(Role::User, cx); let task = cx.spawn(|this, mut cx| { async move { let mut messages = stream.await?; @@ -330,6 +326,33 @@ impl Assistant { cx.propagate_action(); } } + + fn push_message(&mut self, role: Role, cx: &mut ViewContext) -> ModelHandle { + let content = cx.add_model(|cx| { + let mut buffer = Buffer::new(0, "", cx); + buffer.set_language(Some(self.markdown.clone()), cx); + buffer.set_language_registry(self.language_registry.clone()); + buffer + }); + let message = Message { + role, + content: content.clone(), + }; + self.messages.push(message); + + self.editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |buffer, cx| { + buffer.push_excerpts_with_context_lines( + content.clone(), + vec![Anchor::MIN..Anchor::MAX], + 0, + cx, + ) + }); + }); + + content + } } impl Entity for Assistant { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 24b7d6356b89a9b691170fa28ed9c31c0bc1606b..4c3f21467fdaef1446fd6b2bbbdfa093a42b40f8 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -340,7 +340,9 @@ pub fn initialize_workspace( let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); - let (project_panel, terminal_panel) = futures::try_join!(project_panel, terminal_panel)?; + let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone()); + let (project_panel, terminal_panel, assistant_panel) = + futures::try_join!(project_panel, terminal_panel, assistant_panel)?; workspace_handle.update(&mut cx, |workspace, cx| { let project_panel_position = project_panel.position(cx); workspace.add_panel(project_panel, cx); @@ -359,9 +361,6 @@ pub fn initialize_workspace( } workspace.add_panel(terminal_panel, cx); - - // TODO: deserialize state. - let assistant_panel = cx.add_view(|cx| AssistantPanel::new(workspace, cx)); workspace.add_panel(assistant_panel, cx); })?; Ok(()) From 52e8bf29285aad456914a4f23b3c714d4d779def Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 29 May 2023 15:57:55 +0200 Subject: [PATCH 06/32] Show custom header for assistant messages --- crates/ai/src/assistant.rs | 167 +++++++++++++------ crates/editor/src/editor.rs | 25 ++- crates/editor/src/element.rs | 267 +++++++++++++++++------------- crates/theme/src/theme.rs | 3 + styles/src/styleTree/assistant.ts | 16 +- 5 files changed, 310 insertions(+), 168 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 1fa9c3cc4ffadfc2b7437a562ba0e175db1e6f78..14ac7411146c3256283307f2245517ca9dc6d21a 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -1,13 +1,15 @@ use crate::{OpenAIRequest, OpenAIResponseStreamEvent, RequestMessage, Role}; use anyhow::{anyhow, Result}; -use editor::{Editor, MultiBuffer}; +use collections::HashMap; +use editor::{Editor, ExcerptId, ExcerptRange, MultiBuffer}; use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; use gpui::{ actions, elements::*, executor::Background, Action, AppContext, AsyncAppContext, Entity, - ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, + ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, + WindowContext, }; use isahc::{http::StatusCode, Request, RequestExt}; -use language::{language_settings::SoftWrap, Anchor, Buffer, Language, LanguageRegistry}; +use language::{language_settings::SoftWrap, Buffer, Language, LanguageRegistry}; use std::{io, sync::Arc}; use util::{post_inc, ResultExt, TryFutureExt}; use workspace::{ @@ -19,8 +21,8 @@ use workspace::{ actions!(assistant, [NewContext, Assist, CancelLastAssist]); pub fn init(cx: &mut AppContext) { - cx.add_action(Assistant::assist); - cx.capture_action(Assistant::cancel_last_assist); + cx.add_action(AssistantEditor::assist); + cx.capture_action(AssistantEditor::cancel_last_assist); } pub enum AssistantPanelEvent { @@ -188,7 +190,7 @@ impl Panel for AssistantPanel { .await?; workspace.update(&mut cx, |workspace, cx| { let editor = Box::new(cx.add_view(|cx| { - Assistant::new(markdown, workspace.app_state().languages.clone(), cx) + AssistantEditor::new(markdown, workspace.app_state().languages.clone(), cx) })); Pane::add_item(workspace, &pane, editor, true, focus, None, cx); })?; @@ -230,38 +232,31 @@ impl Panel for AssistantPanel { } struct Assistant { + buffer: ModelHandle, messages: Vec, - editor: ViewHandle, + messages_by_id: HashMap, completion_count: usize, pending_completions: Vec, markdown: Arc, language_registry: Arc, } -struct PendingCompletion { - id: usize, - _task: Task>, +impl Entity for Assistant { + type Event = (); } impl Assistant { fn new( markdown: Arc, language_registry: Arc, - cx: &mut ViewContext, + cx: &mut ModelContext, ) -> Self { - let editor = cx.add_view(|cx| { - let multibuffer = cx.add_model(|_| MultiBuffer::new(0)); - let mut editor = Editor::for_multibuffer(multibuffer, None, cx); - editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); - editor.set_show_gutter(false, cx); - editor - }); - let mut this = Self { + buffer: cx.add_model(|_| MultiBuffer::new(0)), messages: Default::default(), - editor, - completion_count: 0, - pending_completions: Vec::new(), + messages_by_id: Default::default(), + completion_count: Default::default(), + pending_completions: Default::default(), markdown, language_registry, }; @@ -269,7 +264,7 @@ impl Assistant { this } - fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { + fn assist(&mut self, cx: &mut ModelContext) { let messages = self .messages .iter() @@ -285,8 +280,8 @@ impl Assistant { }; if let Some(api_key) = std::env::var("OPENAI_API_KEY").log_err() { - let stream = stream_completion(api_key, cx.background_executor().clone(), request); - let response_buffer = self.push_message(Role::Assistant, cx); + let stream = stream_completion(api_key, cx.background().clone(), request); + let response = self.push_message(Role::Assistant, cx); self.push_message(Role::User, cx); let task = cx.spawn(|this, mut cx| { async move { @@ -295,7 +290,7 @@ impl Assistant { while let Some(message) = messages.next().await { let mut message = message?; if let Some(choice) = message.choices.pop() { - response_buffer.update(&mut cx, |content, cx| { + response.content.update(&mut cx, |content, cx| { let text: Arc = choice.delta.content?.into(); content.edit([(content.len()..content.len(), text)], None, cx); Some(()) @@ -306,8 +301,7 @@ impl Assistant { this.update(&mut cx, |this, _| { this.pending_completions .retain(|completion| completion.id != this.completion_count); - }) - .ok(); + }); anyhow::Ok(()) } @@ -321,45 +315,123 @@ impl Assistant { } } - fn cancel_last_assist(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { - if self.pending_completions.pop().is_none() { - cx.propagate_action(); - } + fn cancel_last_assist(&mut self) -> bool { + self.pending_completions.pop().is_some() } - fn push_message(&mut self, role: Role, cx: &mut ViewContext) -> ModelHandle { + fn push_message(&mut self, role: Role, cx: &mut ModelContext) -> Message { let content = cx.add_model(|cx| { let mut buffer = Buffer::new(0, "", cx); buffer.set_language(Some(self.markdown.clone()), cx); buffer.set_language_registry(self.language_registry.clone()); buffer }); + let excerpt_id = self.buffer.update(cx, |buffer, cx| { + buffer + .push_excerpts( + content.clone(), + vec![ExcerptRange { + context: 0..0, + primary: None, + }], + cx, + ) + .pop() + .unwrap() + }); + let message = Message { role, content: content.clone(), }; - self.messages.push(message); + self.messages.push(message.clone()); + self.messages_by_id.insert(excerpt_id, message.clone()); + message + } +} - self.editor.update(cx, |editor, cx| { - editor.buffer().update(cx, |buffer, cx| { - buffer.push_excerpts_with_context_lines( - content.clone(), - vec![Anchor::MIN..Anchor::MAX], - 0, - cx, - ) - }); +struct PendingCompletion { + id: usize, + _task: Task>, +} + +struct AssistantEditor { + assistant: ModelHandle, + editor: ViewHandle, +} + +impl AssistantEditor { + fn new( + markdown: Arc, + language_registry: Arc, + cx: &mut ViewContext, + ) -> Self { + let assistant = cx.add_model(|cx| Assistant::new(markdown, language_registry, cx)); + let editor = cx.add_view(|cx| { + let mut editor = Editor::for_multibuffer(assistant.read(cx).buffer.clone(), None, cx); + editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + editor.set_show_gutter(false, cx); + editor.set_render_excerpt_header( + { + let assistant = assistant.clone(); + move |editor, params: editor::RenderExcerptHeaderParams, cx| { + let style = &theme::current(cx).assistant; + if let Some(message) = assistant.read(cx).messages_by_id.get(¶ms.id) { + let sender = match message.role { + Role::User => Label::new("You", style.user_sender.text.clone()) + .contained() + .with_style(style.user_sender.container), + Role::Assistant => { + Label::new("Assistant", style.assistant_sender.text.clone()) + .contained() + .with_style(style.assistant_sender.container) + } + Role::System => { + Label::new("System", style.assistant_sender.text.clone()) + .contained() + .with_style(style.assistant_sender.container) + } + }; + + Flex::row() + .with_child(sender) + .aligned() + .left() + .contained() + .with_style(style.header) + .into_any() + } else { + Empty::new().into_any() + } + } + }, + cx, + ); + editor }); + Self { assistant, editor } + } - content + fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { + self.assistant + .update(cx, |assistant, cx| assistant.assist(cx)); + } + + fn cancel_last_assist(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { + if !self + .assistant + .update(cx, |assistant, _| assistant.cancel_last_assist()) + { + cx.propagate_action(); + } } } -impl Entity for Assistant { +impl Entity for AssistantEditor { type Event = (); } -impl View for Assistant { +impl View for AssistantEditor { fn ui_name() -> &'static str { "ContextEditor" } @@ -374,7 +446,7 @@ impl View for Assistant { } } -impl Item for Assistant { +impl Item for AssistantEditor { fn tab_content( &self, _: Option, @@ -385,6 +457,7 @@ impl Item for Assistant { } } +#[derive(Clone)] struct Message { role: Role, content: ModelHandle, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 31df5069db39f2c769f7d96b88958b4d9f606c2d..a1e55dc03630d3d4136bc1ded66e3fe10ef5c0e9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -46,7 +46,8 @@ use gpui::{ platform::{CursorStyle, MouseButton}, serde_json::{self, json}, AnyElement, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, - ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, + LayoutContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, + WindowContext, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; @@ -498,6 +499,7 @@ pub struct Editor { mode: EditorMode, show_gutter: bool, placeholder_text: Option>, + render_excerpt_header: Option, highlighted_rows: Option>, #[allow(clippy::type_complexity)] background_highlights: BTreeMap Color, Vec>)>, @@ -1301,6 +1303,7 @@ impl Editor { mode, show_gutter: mode == EditorMode::Full, placeholder_text: None, + render_excerpt_header: None, highlighted_rows: None, background_highlights: Default::default(), nav_history: None, @@ -6663,6 +6666,20 @@ impl Editor { cx.notify(); } + pub fn set_render_excerpt_header( + &mut self, + render_excerpt_header: impl 'static + + Fn( + &mut Editor, + RenderExcerptHeaderParams, + &mut LayoutContext, + ) -> AnyElement, + cx: &mut ViewContext, + ) { + self.render_excerpt_header = Some(Arc::new(render_excerpt_header)); + cx.notify(); + } + pub fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext) { if let Some(buffer) = self.buffer().read(cx).as_singleton() { if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) { @@ -7308,8 +7325,12 @@ impl View for Editor { }); } + let mut editor = EditorElement::new(style.clone()); + if let Some(render_excerpt_header) = self.render_excerpt_header.clone() { + editor = editor.with_render_excerpt_header(render_excerpt_header); + } Stack::new() - .with_child(EditorElement::new(style.clone())) + .with_child(editor) .with_child(ChildView::new(&self.mouse_context_menu, cx)) .into_any() } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 5b4da4407360b1a2af410b411bc910385307401e..a028c7ca1c985aa6ecc1ff4e24430e23a8ffa3b7 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -91,18 +91,41 @@ impl SelectionLayout { } } -#[derive(Clone)] +pub struct RenderExcerptHeaderParams<'a> { + pub id: crate::ExcerptId, + pub buffer: &'a language::BufferSnapshot, + pub range: &'a crate::ExcerptRange, + pub starts_new_buffer: bool, + pub gutter_padding: f32, + pub editor_style: &'a EditorStyle, +} + +pub type RenderExcerptHeader = Arc< + dyn Fn( + &mut Editor, + RenderExcerptHeaderParams, + &mut LayoutContext, + ) -> AnyElement, +>; + pub struct EditorElement { style: Arc, + render_excerpt_header: RenderExcerptHeader, } impl EditorElement { pub fn new(style: EditorStyle) -> Self { Self { style: Arc::new(style), + render_excerpt_header: Arc::new(render_excerpt_header), } } + pub fn with_render_excerpt_header(mut self, render: RenderExcerptHeader) -> Self { + self.render_excerpt_header = render; + self + } + fn attach_mouse_handlers( scene: &mut SceneBuilder, position_map: &Arc, @@ -1465,11 +1488,9 @@ impl EditorElement { line_height: f32, style: &EditorStyle, line_layouts: &[LineWithInvisibles], - include_root: bool, editor: &mut Editor, cx: &mut LayoutContext, ) -> (f32, Vec) { - let tooltip_style = theme::current(cx).tooltip.clone(); let scroll_x = snapshot.scroll_anchor.offset.x(); let (fixed_blocks, non_fixed_blocks) = snapshot .blocks_in_range(rows.clone()) @@ -1510,112 +1531,18 @@ impl EditorElement { range, starts_new_buffer, .. - } => { - let id = *id; - let jump_icon = project::File::from_dyn(buffer.file()).map(|file| { - let jump_path = ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path.clone(), - }; - let jump_anchor = range - .primary - .as_ref() - .map_or(range.context.start, |primary| primary.start); - let jump_position = language::ToPoint::to_point(&jump_anchor, buffer); - - enum JumpIcon {} - MouseEventHandler::::new(id.into(), cx, |state, _| { - let style = style.jump_icon.style_for(state, false); - Svg::new("icons/arrow_up_right_8.svg") - .with_color(style.color) - .constrained() - .with_width(style.icon_width) - .aligned() - .contained() - .with_style(style.container) - .constrained() - .with_width(style.button_width) - .with_height(style.button_width) - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, editor, cx| { - if let Some(workspace) = editor - .workspace - .as_ref() - .and_then(|(workspace, _)| workspace.upgrade(cx)) - { - workspace.update(cx, |workspace, cx| { - Editor::jump( - workspace, - jump_path.clone(), - jump_position, - jump_anchor, - cx, - ); - }); - } - }) - .with_tooltip::( - id.into(), - "Jump to Buffer".to_string(), - Some(Box::new(crate::OpenExcerpts)), - tooltip_style.clone(), - cx, - ) - .aligned() - .flex_float() - }); - - if *starts_new_buffer { - let style = &self.style.diagnostic_path_header; - let font_size = - (style.text_scale_factor * self.style.text.font_size).round(); - - let path = buffer.resolve_file_path(cx, include_root); - let mut filename = None; - let mut parent_path = None; - // Can't use .and_then() because `.file_name()` and `.parent()` return references :( - if let Some(path) = path { - filename = path.file_name().map(|f| f.to_string_lossy().to_string()); - parent_path = - path.parent().map(|p| p.to_string_lossy().to_string() + "/"); - } - - Flex::row() - .with_child( - Label::new( - filename.unwrap_or_else(|| "untitled".to_string()), - style.filename.text.clone().with_font_size(font_size), - ) - .contained() - .with_style(style.filename.container) - .aligned(), - ) - .with_children(parent_path.map(|path| { - Label::new(path, style.path.text.clone().with_font_size(font_size)) - .contained() - .with_style(style.path.container) - .aligned() - })) - .with_children(jump_icon) - .contained() - .with_style(style.container) - .with_padding_left(gutter_padding) - .with_padding_right(gutter_padding) - .expanded() - .into_any_named("path header block") - } else { - let text_style = self.style.text.clone(); - Flex::row() - .with_child(Label::new("⋯", text_style)) - .with_children(jump_icon) - .contained() - .with_padding_left(gutter_padding) - .with_padding_right(gutter_padding) - .expanded() - .into_any_named("collapsed context") - } - } + } => (self.render_excerpt_header)( + editor, + RenderExcerptHeaderParams { + id: *id, + buffer, + range, + starts_new_buffer: *starts_new_buffer, + gutter_padding, + editor_style: style, + }, + cx, + ), }; element.layout( @@ -2080,12 +2007,6 @@ impl Element for EditorElement { ShowScrollbar::Never => false, }; - let include_root = editor - .project - .as_ref() - .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) - .unwrap_or_default(); - let fold_ranges: Vec<(BufferRow, Range, Color)> = fold_ranges .into_iter() .map(|(id, fold)| { @@ -2144,7 +2065,6 @@ impl Element for EditorElement { line_height, &style, &line_layouts, - include_root, editor, cx, ); @@ -2759,6 +2679,121 @@ impl HighlightedRange { } } +fn render_excerpt_header( + editor: &mut Editor, + RenderExcerptHeaderParams { + id, + buffer, + range, + starts_new_buffer, + gutter_padding, + editor_style, + }: RenderExcerptHeaderParams, + cx: &mut LayoutContext, +) -> AnyElement { + let tooltip_style = theme::current(cx).tooltip.clone(); + let include_root = editor + .project + .as_ref() + .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) + .unwrap_or_default(); + let jump_icon = project::File::from_dyn(buffer.file()).map(|file| { + let jump_path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }; + let jump_anchor = range + .primary + .as_ref() + .map_or(range.context.start, |primary| primary.start); + let jump_position = language::ToPoint::to_point(&jump_anchor, buffer); + + enum JumpIcon {} + MouseEventHandler::::new(id.into(), cx, |state, _| { + let style = editor_style.jump_icon.style_for(state, false); + Svg::new("icons/arrow_up_right_8.svg") + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .contained() + .with_style(style.container) + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, editor, cx| { + if let Some(workspace) = editor + .workspace + .as_ref() + .and_then(|(workspace, _)| workspace.upgrade(cx)) + { + workspace.update(cx, |workspace, cx| { + Editor::jump(workspace, jump_path.clone(), jump_position, jump_anchor, cx); + }); + } + }) + .with_tooltip::( + id.into(), + "Jump to Buffer".to_string(), + Some(Box::new(crate::OpenExcerpts)), + tooltip_style.clone(), + cx, + ) + .aligned() + .flex_float() + }); + + if starts_new_buffer { + let style = &editor_style.diagnostic_path_header; + let font_size = (style.text_scale_factor * editor_style.text.font_size).round(); + + let path = buffer.resolve_file_path(cx, include_root); + let mut filename = None; + let mut parent_path = None; + // Can't use .and_then() because `.file_name()` and `.parent()` return references :( + if let Some(path) = path { + filename = path.file_name().map(|f| f.to_string_lossy().to_string()); + parent_path = path.parent().map(|p| p.to_string_lossy().to_string() + "/"); + } + + Flex::row() + .with_child( + Label::new( + filename.unwrap_or_else(|| "untitled".to_string()), + style.filename.text.clone().with_font_size(font_size), + ) + .contained() + .with_style(style.filename.container) + .aligned(), + ) + .with_children(parent_path.map(|path| { + Label::new(path, style.path.text.clone().with_font_size(font_size)) + .contained() + .with_style(style.path.container) + .aligned() + })) + .with_children(jump_icon) + .contained() + .with_style(style.container) + .with_padding_left(gutter_padding) + .with_padding_right(gutter_padding) + .expanded() + .into_any_named("path header block") + } else { + let text_style = editor_style.text.clone(); + Flex::row() + .with_child(Label::new("⋯", text_style)) + .with_children(jump_icon) + .contained() + .with_padding_left(gutter_padding) + .with_padding_right(gutter_padding) + .expanded() + .into_any_named("collapsed context") + } +} + fn position_to_display_point( position: Vector2F, text_bounds: RectF, diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 010d6956eb47ac57b8a9d08b876715f86a1d61b8..0056230766800aa216cd164461471a42bf4772a7 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -971,6 +971,9 @@ pub struct TerminalStyle { #[derive(Clone, Deserialize, Default)] pub struct AssistantStyle { pub container: ContainerStyle, + pub header: ContainerStyle, + pub user_sender: ContainedText, + pub assistant_sender: ContainedText, } #[derive(Clone, Deserialize, Default)] diff --git a/styles/src/styleTree/assistant.ts b/styles/src/styleTree/assistant.ts index 810831285e0ec38f248ae01b3b00ba10c4ca669c..ea02c3b383976747010e2a31b32a3ba43833111d 100644 --- a/styles/src/styleTree/assistant.ts +++ b/styles/src/styleTree/assistant.ts @@ -1,13 +1,23 @@ import { ColorScheme } from "../themes/common/colorScheme" +import { text, border } from "./components" import editor from "./editor" export default function assistant(colorScheme: ColorScheme) { + const layer = colorScheme.highest; return { container: { background: editor(colorScheme).background, - padding: { - left: 10, - } + padding: { left: 12 } + }, + header: { + border: border(layer, "default", { bottom: true, top: true }), + margin: { bottom: 6, top: 6 } + }, + user_sender: { + ...text(layer, "sans", "default", { size: "sm", weight: "bold" }), + }, + assistant_sender: { + ...text(layer, "sans", "accent", { size: "sm", weight: "bold" }), } } } From 890c42a75a4fbe5a83f8fef399befc9de99c6045 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 29 May 2023 16:23:16 +0200 Subject: [PATCH 07/32] Show time in assistant messages --- Cargo.lock | 1 + crates/ai/Cargo.toml | 5 +++-- crates/ai/src/assistant.rs | 14 +++++++++++++- crates/theme/src/theme.rs | 1 + styles/src/styleTree/assistant.ts | 4 ++++ 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27c3a2fdb1768704f663bb0288acc56f53300154..a22db1b35d2c4acdc74c8d1af4603e22a1322158 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,6 +101,7 @@ version = "0.1.0" dependencies = [ "anyhow", "assets", + "chrono", "collections", "editor", "futures 0.3.28", diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index 14817916f49943a4c5c75965a3c92b0ce38a96b5..861a9e4785e0fd8373dc6d3ef59d23949a19a939 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -19,11 +19,12 @@ theme = { path = "../theme" } util = { path = "../util" } workspace = { path = "../workspace" } -serde.workspace = true -serde_json.workspace = true anyhow.workspace = true +chrono = "0.4" futures.workspace = true isahc.workspace = true +serde.workspace = true +serde_json.workspace = true [dev-dependencies] editor = { path = "../editor", features = ["test-support"] } diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 14ac7411146c3256283307f2245517ca9dc6d21a..c6c5d4d009ae1b89f65acb6738a27ec60a7ec054 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -1,5 +1,6 @@ use crate::{OpenAIRequest, OpenAIResponseStreamEvent, RequestMessage, Role}; use anyhow::{anyhow, Result}; +use chrono::{DateTime, Local}; use collections::HashMap; use editor::{Editor, ExcerptId, ExcerptRange, MultiBuffer}; use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; @@ -343,6 +344,7 @@ impl Assistant { let message = Message { role, content: content.clone(), + sent_at: Local::now(), }; self.messages.push(message.clone()); self.messages_by_id.insert(excerpt_id, message.clone()); @@ -394,7 +396,16 @@ impl AssistantEditor { }; Flex::row() - .with_child(sender) + .with_child(sender.aligned()) + .with_child( + Label::new( + message.sent_at.format("%I:%M%P").to_string(), + style.sent_at.text.clone(), + ) + .contained() + .with_style(style.sent_at.container) + .aligned(), + ) .aligned() .left() .contained() @@ -461,6 +472,7 @@ impl Item for AssistantEditor { struct Message { role: Role, content: ModelHandle, + sent_at: DateTime, } async fn stream_completion( diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 0056230766800aa216cd164461471a42bf4772a7..1f99742cfe20cd4770e65457f2462c3f11829cd3 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -972,6 +972,7 @@ pub struct TerminalStyle { pub struct AssistantStyle { pub container: ContainerStyle, pub header: ContainerStyle, + pub sent_at: ContainedText, pub user_sender: ContainedText, pub assistant_sender: ContainedText, } diff --git a/styles/src/styleTree/assistant.ts b/styles/src/styleTree/assistant.ts index ea02c3b383976747010e2a31b32a3ba43833111d..0ff65a22ae3a730e0152bf4995cd26e6599aea57 100644 --- a/styles/src/styleTree/assistant.ts +++ b/styles/src/styleTree/assistant.ts @@ -18,6 +18,10 @@ export default function assistant(colorScheme: ColorScheme) { }, assistant_sender: { ...text(layer, "sans", "accent", { size: "sm", weight: "bold" }), + }, + sent_at: { + margin: { top: 2, left: 8 }, + ...text(layer, "sans", "default", { size: "2xs" }), } } } From 69e8a166e4e3409583678d880f429c8c72e51b18 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 30 May 2023 15:25:53 +0200 Subject: [PATCH 08/32] Start on `assistant::QuoteSelection` --- assets/keymaps/default.json | 3 +- crates/ai/src/assistant.rs | 85 ++++++++++++++++++++++++++++++- crates/workspace/src/dock.rs | 6 +++ crates/workspace/src/workspace.rs | 10 ++++ 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 0b89969c70806752dcfb1b15ba48f6f4fe91868b..c6964b49e27dbff4d369656d88cd0a000abc5c13 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -199,7 +199,8 @@ "context": "ContextEditor > Editor", "bindings": { "cmd-enter": "assistant::Assist", - "escape": "assistant::CancelLastAssist" + "escape": "assistant::CancelLastAssist", + "cmd-?": "assistant::QuoteSelection" } }, { diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 23baaef04ac8470e0011e2ab7c47ffc0ab86484a..6e76e46c6102cb22de172e0a132d58018883e39c 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -19,11 +19,15 @@ use workspace::{ pane, Pane, Workspace, }; -actions!(assistant, [NewContext, Assist, CancelLastAssist]); +actions!( + assistant, + [NewContext, Assist, CancelLastAssist, QuoteSelection] +); pub fn init(cx: &mut AppContext) { cx.add_action(AssistantEditor::assist); cx.capture_action(AssistantEditor::cancel_last_assist); + cx.add_action(AssistantEditor::quote_selection); } pub enum AssistantPanelEvent { @@ -136,6 +140,12 @@ impl View for AssistantPanel { fn render(&mut self, cx: &mut ViewContext) -> AnyElement { ChildView::new(&self.pane, cx).into_any() } + + fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { + if cx.is_self_focused() { + cx.focus(&self.pane); + } + } } impl Panel for AssistantPanel { @@ -361,7 +371,7 @@ impl AssistantEditor { editor.set_render_excerpt_header( { let assistant = assistant.clone(); - move |editor, params: editor::RenderExcerptHeaderParams, cx| { + move |_editor, params: editor::RenderExcerptHeaderParams, cx| { let style = &theme::current(cx).assistant; if let Some(message) = assistant.read(cx).messages_by_id.get(¶ms.id) { let sender = match message.role { @@ -421,6 +431,71 @@ impl AssistantEditor { cx.propagate_action(); } } + + fn quote_selection( + workspace: &mut Workspace, + _: &QuoteSelection, + cx: &mut ViewContext, + ) { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + let Some(editor) = workspace.active_item(cx).and_then(|item| item.downcast::()) else { + return; + }; + + let text = editor.read_with(cx, |editor, cx| { + let range = editor.selections.newest::(cx).range(); + let buffer = editor.buffer().read(cx).snapshot(cx); + let start_language = buffer.language_at(range.start); + let end_language = buffer.language_at(range.end); + let language_name = if start_language == end_language { + start_language.map(|language| language.name()) + } else { + None + }; + let language_name = language_name.as_deref().unwrap_or("").to_lowercase(); + + let selected_text = buffer.text_for_range(range).collect::(); + if selected_text.is_empty() { + None + } else { + Some(if language_name == "markdown" { + selected_text + .lines() + .map(|line| format!("> {}", line)) + .collect::>() + .join("\n") + } else { + format!("```{language_name}\n{selected_text}\n```") + }) + } + }); + + // Activate the panel + if !panel.read(cx).has_focus(cx) { + workspace.toggle_panel_focus::(cx); + } + + if let Some(text) = text { + panel.update(cx, |panel, cx| { + if let Some(assistant) = panel + .pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .ok_or_else(|| anyhow!("no active context")) + .log_err() + { + assistant.update(cx, |assistant, cx| { + assistant + .editor + .update(cx, |editor, cx| editor.insert(&text, cx)) + }); + } + }); + } + } } impl Entity for AssistantEditor { @@ -440,6 +515,12 @@ impl View for AssistantEditor { .with_style(theme.container) .into_any() } + + fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { + if cx.is_self_focused() { + cx.focus(&self.editor); + } + } } impl Item for AssistantEditor { diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 73d4f79399bd73bd52c9b0ecce04076e76b0407e..49651486db3b12526d9e40cc92d01a1a13c3ca19 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -184,6 +184,12 @@ impl Dock { .map_or(false, |panel| panel.has_focus(cx)) } + pub fn panel(&self) -> Option> { + self.panel_entries + .iter() + .find_map(|entry| entry.panel.as_any().clone().downcast()) + } + pub fn panel_index_for_type(&self) -> Option { self.panel_entries .iter() diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 8afad46ea108c0ab5ef1e6eac4ccf19e1f4661f1..2aa937c9a932ddbc2e2cb9364607693cb2ab5621 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1678,6 +1678,16 @@ impl Workspace { } } + pub fn panel(&self, cx: &WindowContext) -> Option> { + for dock in [&self.left_dock, &self.bottom_dock, &self.right_dock] { + let dock = dock.read(cx); + if let Some(panel) = dock.panel::() { + return Some(panel); + } + } + None + } + fn zoom_out(&mut self, cx: &mut ViewContext) { for pane in &self.panes { pane.update(cx, |pane, cx| pane.set_zoomed(false, cx)); From cf934ab696ee69b05403e71445a86b2c03992f5e Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 2 Jun 2023 10:08:06 +0200 Subject: [PATCH 09/32] Fix compile errors --- assets/keymaps/default.json | 6 +++--- crates/ai/src/assistant.rs | 7 +++---- crates/editor/src/editor.rs | 1 + 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index f58e3c7cce790124cfaef66816899086182e3633..41087326d27afe9c80a4fc0a27e0b3e31ca8cbf2 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -185,7 +185,8 @@ ], "alt-\\": "copilot::Suggest", "alt-]": "copilot::NextSuggestion", - "alt-[": "copilot::PreviousSuggestion" + "alt-[": "copilot::PreviousSuggestion", + "cmd->": "assistant::QuoteSelection" } }, { @@ -199,8 +200,7 @@ "context": "ContextEditor > Editor", "bindings": { "cmd-enter": "assistant::Assist", - "escape": "assistant::CancelLastAssist", - "cmd-?": "assistant::QuoteSelection" + "cmd->": "assistant::QuoteSelection" } }, { diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 6e76e46c6102cb22de172e0a132d58018883e39c..38b64587a5e1df66fbce33f5ddd86fb27707c397 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -19,10 +19,7 @@ use workspace::{ pane, Pane, Workspace, }; -actions!( - assistant, - [NewContext, Assist, CancelLastAssist, QuoteSelection] -); +actions!(assistant, [NewContext, Assist, QuoteSelection]); pub fn init(cx: &mut AppContext) { cx.add_action(AssistantEditor::assist); @@ -69,6 +66,7 @@ impl AssistantPanel { .with_child(Pane::render_tab_bar_button( 0, "icons/plus_12.svg", + false, Some(("New Context".into(), Some(Box::new(NewContext)))), cx, move |_, _| todo!(), @@ -81,6 +79,7 @@ impl AssistantPanel { } else { "icons/maximize_8.svg" }, + pane.is_zoomed(), Some(( "Toggle Zoom".into(), Some(Box::new(workspace::ToggleZoom)), diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a3601f3c589877dd44ff997e40e92c2d8e2510c2..ee0644306828b735f8edf3ba5cdaa1a32beffedc 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -31,6 +31,7 @@ use copilot::Copilot; pub use display_map::DisplayPoint; use display_map::*; pub use editor_settings::EditorSettings; +pub use element::RenderExcerptHeaderParams; pub use element::{ Cursor, EditorElement, HighlightedRange, HighlightedRangeLine, LineWithInvisibles, }; From 55c8c6d3fbf1ba5ad5b4abf9d7e3b34ef2b6e7ba Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 2 Jun 2023 10:29:22 +0200 Subject: [PATCH 10/32] Allow adding new contexts --- crates/ai/src/ai.rs | 5 ++-- crates/ai/src/assistant.rs | 48 +++++++++++++++++++++++++++----------- crates/zed/src/zed.rs | 7 ++++++ 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 89999f26f39d63591cee4c43274d441439ea5f77..fe1125bb98c848c715b119b77bf6fc7c07e5d9a5 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -1,10 +1,9 @@ -mod assistant; +pub mod assistant; +pub use assistant::AssistantPanel; use gpui::{actions, AppContext}; use serde::{Deserialize, Serialize}; -pub use assistant::AssistantPanel; - actions!(ai, [Assist]); // Data types for chat completion requests diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 38b64587a5e1df66fbce33f5ddd86fb27707c397..6f5cbd6416ceb5bec5779d76b9cfe8452d4fa01c 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -19,9 +19,18 @@ use workspace::{ pane, Pane, Workspace, }; -actions!(assistant, [NewContext, Assist, QuoteSelection]); +actions!(assistant, [NewContext, Assist, QuoteSelection, ToggleFocus]); pub fn init(cx: &mut AppContext) { + cx.add_action( + |workspace: &mut Workspace, _: &NewContext, cx: &mut ViewContext| { + if let Some(this) = workspace.panel::(cx) { + this.update(cx, |this, cx| this.add_context(cx)) + } + + workspace.focus_panel::(cx); + }, + ); cx.add_action(AssistantEditor::assist); cx.capture_action(AssistantEditor::cancel_last_assist); cx.add_action(AssistantEditor::quote_selection); @@ -49,7 +58,8 @@ impl AssistantPanel { cx.spawn(|mut cx| async move { // TODO: deserialize state. workspace.update(&mut cx, |workspace, cx| { - cx.add_view(|cx| { + cx.add_view::(|cx| { + let weak_self = cx.weak_handle(); let pane = cx.add_view(|cx| { let mut pane = Pane::new( workspace.weak_handle(), @@ -62,6 +72,7 @@ impl AssistantPanel { pane.set_can_navigate(false, cx); pane.on_can_drop(move |_, _| false); pane.set_render_tab_bar_buttons(cx, move |pane, cx| { + let weak_self = weak_self.clone(); Flex::row() .with_child(Pane::render_tab_bar_button( 0, @@ -69,7 +80,14 @@ impl AssistantPanel { false, Some(("New Context".into(), Some(Box::new(NewContext)))), cx, - move |_, _| todo!(), + move |_, cx| { + let weak_self = weak_self.clone(); + cx.window_context().defer(move |cx| { + if let Some(this) = weak_self.upgrade(cx) { + this.update(cx, |this, cx| this.add_context(cx)); + } + }) + }, None, )) .with_child(Pane::render_tab_bar_button( @@ -125,6 +143,14 @@ impl AssistantPanel { _ => {} } } + + fn add_context(&mut self, cx: &mut ViewContext) { + let focus = self.has_focus(cx); + let editor = cx.add_view(|cx| AssistantEditor::new(self.languages.clone(), cx)); + self.pane.update(cx, |pane, cx| { + pane.add_item(Box::new(editor), true, focus, None, cx) + }); + } } impl Entity for AssistantPanel { @@ -187,11 +213,7 @@ impl Panel for AssistantPanel { fn set_active(&mut self, active: bool, cx: &mut ViewContext) { if active && self.pane.read(cx).items_len() == 0 { - let focus = self.has_focus(cx); - let editor = cx.add_view(|cx| AssistantEditor::new(self.languages.clone(), cx)); - self.pane.update(cx, |pane, cx| { - pane.add_item(Box::new(editor), true, focus, None, cx) - }); + self.add_context(cx); } } @@ -200,7 +222,7 @@ impl Panel for AssistantPanel { } fn icon_tooltip(&self) -> (String, Option>) { - ("Assistant Panel".into(), None) + ("Assistant Panel".into(), Some(Box::new(ToggleFocus))) } fn should_change_position_on_event(_: &Self::Event) -> bool { @@ -231,7 +253,7 @@ struct Assistant { messages_by_id: HashMap, completion_count: usize, pending_completions: Vec, - language_registry: Arc, + languages: Arc, } impl Entity for Assistant { @@ -246,7 +268,7 @@ impl Assistant { messages_by_id: Default::default(), completion_count: Default::default(), pending_completions: Default::default(), - language_registry, + languages: language_registry, }; this.push_message(Role::User, cx); this @@ -310,7 +332,7 @@ impl Assistant { fn push_message(&mut self, role: Role, cx: &mut ModelContext) -> Message { let content = cx.add_model(|cx| { let mut buffer = Buffer::new(0, "", cx); - let markdown = self.language_registry.language_for_name("Markdown"); + let markdown = self.languages.language_for_name("Markdown"); cx.spawn_weak(|buffer, mut cx| async move { let markdown = markdown.await?; let buffer = buffer @@ -322,7 +344,7 @@ impl Assistant { anyhow::Ok(()) }) .detach_and_log_err(cx); - buffer.set_language_registry(self.language_registry.clone()); + buffer.set_language_registry(self.languages.clone()); buffer }); let excerpt_id = self.buffer.update(cx, |buffer, cx| { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 83792514852fe30fb50766e8aca80b1d5e5ae140..38e7a20d310b53176ae116c43b79542fcdfca532 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -235,6 +235,13 @@ pub fn init(app_state: &Arc, cx: &mut gpui::AppContext) { workspace.toggle_panel_focus::(cx); }, ); + cx.add_action( + |workspace: &mut Workspace, + _: &ai::assistant::ToggleFocus, + cx: &mut ViewContext| { + workspace.toggle_panel_focus::(cx); + }, + ); cx.add_global_action({ let app_state = Arc::downgrade(&app_state); move |_: &NewWindow, cx: &mut AppContext| { From d0aff65b1cb9fe841aed9df8673e50f3deb96dc1 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 2 Jun 2023 10:55:19 +0200 Subject: [PATCH 11/32] Allow moving the assistant panel to other docks --- Cargo.lock | 3 + assets/settings/default.json | 10 +++ crates/ai/Cargo.toml | 3 + crates/ai/src/ai.rs | 1 + crates/ai/src/assistant.rs | 98 ++++++++++++++++++++++------- crates/ai/src/assistant_settings.rs | 42 +++++++++++++ crates/terminal_view/Cargo.toml | 2 - 7 files changed, 135 insertions(+), 24 deletions(-) create mode 100644 crates/ai/src/assistant_settings.rs diff --git a/Cargo.lock b/Cargo.lock index 9634cd2c8eb8956e9066b270385035fcc75677a7..4e3ded5bb6b73b2533aea317a03a028f824d62f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,13 +104,16 @@ dependencies = [ "chrono", "collections", "editor", + "fs", "futures 0.3.28", "gpui", "isahc", "language", + "schemars", "search", "serde", "serde_json", + "settings", "theme", "util", "workspace", diff --git a/assets/settings/default.json b/assets/settings/default.json index 23599c8dfb1e327d9d842b76fb8c5e2914c23b3f..695061a0aa07fb42ea6578a2b9e8d0f484a8bffe 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -81,6 +81,16 @@ // Default width of the project panel. "default_width": 240 }, + "assistant": { + // Where to dock the assistant. Can be 'left', 'right' or 'bottom'. + "dock": "right", + // Default width when the assistant is docked to the left or right. + "default_width": 480, + // Default height when the assistant is docked to the bottom. + "default_height": 320, + // OpenAI API key. + "openai_api_key": null + }, // Whether the screen sharing icon is shown in the os status bar. "show_call_status_icon": true, // Whether to use language servers to provide code intelligence. diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index 861a9e4785e0fd8373dc6d3ef59d23949a19a939..ce2a3338eb85598da4a6c8ce4a21821dc5eb7afb 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -12,9 +12,11 @@ doctest = false assets = { path = "../assets"} collections = { path = "../collections"} editor = { path = "../editor" } +fs = { path = "../fs" } gpui = { path = "../gpui" } language = { path = "../language" } search = { path = "../search" } +settings = { path = "../settings" } theme = { path = "../theme" } util = { path = "../util" } workspace = { path = "../workspace" } @@ -23,6 +25,7 @@ anyhow.workspace = true chrono = "0.4" futures.workspace = true isahc.workspace = true +schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index fe1125bb98c848c715b119b77bf6fc7c07e5d9a5..a5d5666a72fe2cd08431ccbec5cd94e6f603254d 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -1,4 +1,5 @@ pub mod assistant; +mod assistant_settings; pub use assistant::AssistantPanel; use gpui::{actions, AppContext}; diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 6f5cbd6416ceb5bec5779d76b9cfe8452d4fa01c..ecc27538ed1248a895a59b1b7de48f6056f6e3a3 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -1,8 +1,12 @@ -use crate::{OpenAIRequest, OpenAIResponseStreamEvent, RequestMessage, Role}; +use crate::{ + assistant_settings::{AssistantDockPosition, AssistantSettings}, + OpenAIRequest, OpenAIResponseStreamEvent, RequestMessage, Role, +}; use anyhow::{anyhow, Result}; use chrono::{DateTime, Local}; use collections::HashMap; use editor::{Editor, ExcerptId, ExcerptRange, MultiBuffer}; +use fs::Fs; use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; use gpui::{ actions, elements::*, executor::Background, Action, AppContext, AsyncAppContext, Entity, @@ -11,6 +15,7 @@ use gpui::{ }; use isahc::{http::StatusCode, Request, RequestExt}; use language::{language_settings::SoftWrap, Buffer, LanguageRegistry}; +use settings::SettingsStore; use std::{io, sync::Arc}; use util::{post_inc, ResultExt, TryFutureExt}; use workspace::{ @@ -22,6 +27,7 @@ use workspace::{ actions!(assistant, [NewContext, Assist, QuoteSelection, ToggleFocus]); pub fn init(cx: &mut AppContext) { + settings::register::(cx); cx.add_action( |workspace: &mut Workspace, _: &NewContext, cx: &mut ViewContext| { if let Some(this) = workspace.panel::(cx) { @@ -41,12 +47,15 @@ pub enum AssistantPanelEvent { ZoomOut, Focus, Close, + DockPositionChanged, } pub struct AssistantPanel { width: Option, + height: Option, pane: ViewHandle, languages: Arc, + fs: Arc, _subscriptions: Vec, } @@ -113,17 +122,40 @@ impl AssistantPanel { .update(cx, |toolbar, cx| toolbar.add_item(buffer_search_bar, cx)); pane }); - let subscriptions = vec![ - cx.observe(&pane, |_, _, cx| cx.notify()), - cx.subscribe(&pane, Self::handle_pane_event), - ]; - - Self { + let mut this = Self { pane, languages: workspace.app_state().languages.clone(), + fs: workspace.app_state().fs.clone(), width: None, - _subscriptions: subscriptions, - } + height: None, + _subscriptions: Default::default(), + }; + + let mut old_dock_position = this.position(cx); + let mut old_openai_api_key = settings::get::(cx) + .openai_api_key + .clone(); + this._subscriptions = vec![ + cx.observe(&this.pane, |_, _, cx| cx.notify()), + cx.subscribe(&this.pane, Self::handle_pane_event), + cx.observe_global::(move |this, cx| { + let new_dock_position = this.position(cx); + if new_dock_position != old_dock_position { + old_dock_position = new_dock_position; + cx.emit(AssistantPanelEvent::DockPositionChanged); + } + + let new_openai_api_key = settings::get::(cx) + .openai_api_key + .clone(); + if old_openai_api_key != new_openai_api_key { + old_openai_api_key = new_openai_api_key; + cx.notify(); + } + }), + ]; + + this }) }) }) @@ -174,24 +206,44 @@ impl View for AssistantPanel { } impl Panel for AssistantPanel { - fn position(&self, _: &WindowContext) -> DockPosition { - DockPosition::Right + fn position(&self, cx: &WindowContext) -> DockPosition { + match settings::get::(cx).dock { + AssistantDockPosition::Left => DockPosition::Left, + AssistantDockPosition::Bottom => DockPosition::Bottom, + AssistantDockPosition::Right => DockPosition::Right, + } } - fn position_is_valid(&self, position: DockPosition) -> bool { - matches!(position, DockPosition::Right) + fn position_is_valid(&self, _: DockPosition) -> bool { + true } - fn set_position(&mut self, _: DockPosition, _: &mut ViewContext) { - // TODO! + fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) { + settings::update_settings_file::(self.fs.clone(), cx, move |settings| { + let dock = match position { + DockPosition::Left => AssistantDockPosition::Left, + DockPosition::Bottom => AssistantDockPosition::Bottom, + DockPosition::Right => AssistantDockPosition::Right, + }; + settings.dock = Some(dock); + }); } - fn size(&self, _: &WindowContext) -> f32 { - self.width.unwrap_or(480.) + fn size(&self, cx: &WindowContext) -> f32 { + let settings = settings::get::(cx); + match self.position(cx) { + DockPosition::Left | DockPosition::Right => { + self.width.unwrap_or_else(|| settings.default_width) + } + DockPosition::Bottom => self.height.unwrap_or_else(|| settings.default_height), + } } fn set_size(&mut self, size: f32, cx: &mut ViewContext) { - self.width = Some(size); + match self.position(cx) { + DockPosition::Left | DockPosition::Right => self.width = Some(size), + DockPosition::Bottom => self.height = Some(size), + } cx.notify(); } @@ -225,9 +277,8 @@ impl Panel for AssistantPanel { ("Assistant Panel".into(), Some(Box::new(ToggleFocus))) } - fn should_change_position_on_event(_: &Self::Event) -> bool { - // TODO! - false + fn should_change_position_on_event(event: &Self::Event) -> bool { + matches!(event, AssistantPanelEvent::DockPositionChanged) } fn should_activate_on_event(_: &Self::Event) -> bool { @@ -289,7 +340,10 @@ impl Assistant { stream: true, }; - if let Some(api_key) = std::env::var("OPENAI_API_KEY").log_err() { + if let Some(api_key) = settings::get::(cx) + .openai_api_key + .clone() + { let stream = stream_completion(api_key, cx.background().clone(), request); let response = self.push_message(Role::Assistant, cx); self.push_message(Role::User, cx); diff --git a/crates/ai/src/assistant_settings.rs b/crates/ai/src/assistant_settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..c2652ec0cb34e477fe09fe5cba76b6688acaf224 --- /dev/null +++ b/crates/ai/src/assistant_settings.rs @@ -0,0 +1,42 @@ +use anyhow; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Setting; + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AssistantDockPosition { + Left, + Right, + Bottom, +} + +#[derive(Deserialize, Debug)] +pub struct AssistantSettings { + pub dock: AssistantDockPosition, + pub default_width: f32, + pub default_height: f32, + pub openai_api_key: Option, +} + +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] +pub struct AssistantSettingsContent { + pub dock: Option, + pub default_width: Option, + pub default_height: Option, + pub openai_api_key: Option, +} + +impl Setting for AssistantSettings { + const KEY: Option<&'static str> = Some("assistant"); + + type FileContent = AssistantSettingsContent; + + fn load( + default_value: &Self::FileContent, + user_values: &[&Self::FileContent], + _: &gpui::AppContext, + ) -> anyhow::Result { + Self::load_via_json_merge(default_value, user_values) + } +} diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index 6fa920d739382f4a3f8ccebe8f5b601bce3e4ee0..85de173604db82610a8cf1191771d920fa883583 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -37,8 +37,6 @@ lazy_static.workspace = true serde.workspace = true serde_derive.workspace = true - - [dev-dependencies] editor = { path = "../editor", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } From a81d164ea63b659a5f0cd697e9f23c5b0d387e07 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 2 Jun 2023 11:38:02 +0200 Subject: [PATCH 12/32] Allow saving the OpenAI API key in the assistant panel --- Cargo.lock | 1 + assets/settings/default.json | 2 +- crates/ai/Cargo.toml | 1 + crates/ai/src/assistant.rs | 65 +++++++++++++++++++++++++++++-- crates/theme/src/theme.rs | 2 + styles/src/styleTree/assistant.ts | 22 ++++++++++- 6 files changed, 88 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e3ded5bb6b73b2533aea317a03a028f824d62f9..e8b2dfadd4521bcafca6c5ced91233c57e87b019 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,6 +109,7 @@ dependencies = [ "gpui", "isahc", "language", + "menu", "schemars", "search", "serde", diff --git a/assets/settings/default.json b/assets/settings/default.json index 695061a0aa07fb42ea6578a2b9e8d0f484a8bffe..91868fb1e5cdef992a854816a092bafd8ae92b65 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -85,7 +85,7 @@ // Where to dock the assistant. Can be 'left', 'right' or 'bottom'. "dock": "right", // Default width when the assistant is docked to the left or right. - "default_width": 480, + "default_width": 450, // Default height when the assistant is docked to the bottom. "default_height": 320, // OpenAI API key. diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index ce2a3338eb85598da4a6c8ce4a21821dc5eb7afb..9052b1e5edf1fbc1ac300a6d30f556803f4b1dd4 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -15,6 +15,7 @@ editor = { path = "../editor" } fs = { path = "../fs" } gpui = { path = "../gpui" } language = { path = "../language" } +menu = { path = "../menu" } search = { path = "../search" } settings = { path = "../settings" } theme = { path = "../theme" } diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index ecc27538ed1248a895a59b1b7de48f6056f6e3a3..1bee432b7bd9f5985e6c3c068af74cbdc6328987 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -40,6 +40,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(AssistantEditor::assist); cx.capture_action(AssistantEditor::cancel_last_assist); cx.add_action(AssistantEditor::quote_selection); + cx.add_action(AssistantPanel::save_api_key); } pub enum AssistantPanelEvent { @@ -54,6 +55,7 @@ pub struct AssistantPanel { width: Option, height: Option, pane: ViewHandle, + api_key_editor: ViewHandle, languages: Arc, fs: Arc, _subscriptions: Vec, @@ -124,6 +126,17 @@ impl AssistantPanel { }); let mut this = Self { pane, + api_key_editor: cx.add_view(|cx| { + let mut editor = Editor::single_line( + Some(Arc::new(|theme| theme.assistant.api_key_editor.clone())), + cx, + ); + editor.set_placeholder_text( + "sk-000000000000000000000000000000000000000000000000", + cx, + ); + editor + }), languages: workspace.app_state().languages.clone(), fs: workspace.app_state().fs.clone(), width: None, @@ -150,6 +163,9 @@ impl AssistantPanel { .clone(); if old_openai_api_key != new_openai_api_key { old_openai_api_key = new_openai_api_key; + if this.has_focus(cx) { + cx.focus_self(); + } cx.notify(); } }), @@ -183,6 +199,17 @@ impl AssistantPanel { pane.add_item(Box::new(editor), true, focus, None, cx) }); } + + fn save_api_key(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + let api_key = self.api_key_editor.read(cx).text(cx); + if !api_key.is_empty() { + settings::update_settings_file::( + self.fs.clone(), + cx, + move |settings| settings.openai_api_key = Some(api_key), + ); + } + } } impl Entity for AssistantPanel { @@ -195,12 +222,44 @@ impl View for AssistantPanel { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - ChildView::new(&self.pane, cx).into_any() + let style = &theme::current(cx).assistant; + if settings::get::(cx) + .openai_api_key + .is_none() + { + Flex::column() + .with_child( + Text::new( + "Paste your OpenAI API key and press Enter to use the assistant", + style.api_key_prompt.text.clone(), + ) + .aligned(), + ) + .with_child( + ChildView::new(&self.api_key_editor, cx) + .contained() + .with_style(style.api_key_editor.container) + .aligned(), + ) + .contained() + .with_style(style.api_key_prompt.container) + .aligned() + .into_any() + } else { + ChildView::new(&self.pane, cx).into_any() + } } fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { if cx.is_self_focused() { - cx.focus(&self.pane); + if settings::get::(cx) + .openai_api_key + .is_some() + { + cx.focus(&self.pane); + } else { + cx.focus(&self.api_key_editor); + } } } } @@ -290,7 +349,7 @@ impl Panel for AssistantPanel { } fn has_focus(&self, cx: &WindowContext) -> bool { - self.pane.read(cx).has_focus() + self.pane.read(cx).has_focus() || self.api_key_editor.is_focused(cx) } fn is_focus_event(event: &Self::Event) -> bool { diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index f1e752b763e07f3449099b85d45961d80bca7dd8..8282336ba554e340e09b7fa15ce5a049bbd4af89 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -976,6 +976,8 @@ pub struct AssistantStyle { pub sent_at: ContainedText, pub user_sender: ContainedText, pub assistant_sender: ContainedText, + pub api_key_editor: FieldEditor, + pub api_key_prompt: ContainedText, } #[derive(Clone, Deserialize, Default)] diff --git a/styles/src/styleTree/assistant.ts b/styles/src/styleTree/assistant.ts index 0ff65a22ae3a730e0152bf4995cd26e6599aea57..085e43071c12e6896d229bff3ff8dd4ababff874 100644 --- a/styles/src/styleTree/assistant.ts +++ b/styles/src/styleTree/assistant.ts @@ -1,5 +1,5 @@ import { ColorScheme } from "../themes/common/colorScheme" -import { text, border } from "./components" +import { text, border, background } from "./components" import editor from "./editor" export default function assistant(colorScheme: ColorScheme) { @@ -22,6 +22,26 @@ export default function assistant(colorScheme: ColorScheme) { sent_at: { margin: { top: 2, left: 8 }, ...text(layer, "sans", "default", { size: "2xs" }), + }, + apiKeyEditor: { + background: background(layer, "on"), + cornerRadius: 6, + text: text(layer, "mono", "on"), + placeholderText: text(layer, "mono", "on", "disabled", { + size: "xs", + }), + selection: colorScheme.players[0], + border: border(layer, "on"), + padding: { + bottom: 4, + left: 8, + right: 8, + top: 4, + }, + }, + apiKeyPrompt: { + padding: 10, + ...text(layer, "sans", "default", { size: "xs" }), } } } From 3750e64d9f4e46e24670bb41704bebd2ea625e7d Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 2 Jun 2023 12:15:25 +0200 Subject: [PATCH 13/32] Save OpenAI API key in the keychain --- assets/settings/default.json | 4 +- crates/ai/src/assistant.rs | 157 ++++++++++++++++++---------- crates/ai/src/assistant_settings.rs | 2 - 3 files changed, 100 insertions(+), 63 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 91868fb1e5cdef992a854816a092bafd8ae92b65..e6f006bf60d79d34835ce063889ea68eeecdb562 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -87,9 +87,7 @@ // Default width when the assistant is docked to the left or right. "default_width": 450, // Default height when the assistant is docked to the bottom. - "default_height": 320, - // OpenAI API key. - "openai_api_key": null + "default_height": 320 }, // Whether the screen sharing icon is shown in the os status bar. "show_call_status_icon": true, diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 1bee432b7bd9f5985e6c3c068af74cbdc6328987..39e9a6ba157bf0501fb3d3e65b123874175b91c0 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -16,7 +16,7 @@ use gpui::{ use isahc::{http::StatusCode, Request, RequestExt}; use language::{language_settings::SoftWrap, Buffer, LanguageRegistry}; use settings::SettingsStore; -use std::{io, sync::Arc}; +use std::{cell::Cell, io, rc::Rc, sync::Arc}; use util::{post_inc, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, @@ -24,7 +24,12 @@ use workspace::{ pane, Pane, Workspace, }; -actions!(assistant, [NewContext, Assist, QuoteSelection, ToggleFocus]); +const OPENAI_API_URL: &'static str = "https://api.openai.com/v1"; + +actions!( + assistant, + [NewContext, Assist, QuoteSelection, ToggleFocus, ResetKey] +); pub fn init(cx: &mut AppContext) { settings::register::(cx); @@ -41,6 +46,7 @@ pub fn init(cx: &mut AppContext) { cx.capture_action(AssistantEditor::cancel_last_assist); cx.add_action(AssistantEditor::quote_selection); cx.add_action(AssistantPanel::save_api_key); + cx.add_action(AssistantPanel::reset_api_key); } pub enum AssistantPanelEvent { @@ -55,7 +61,9 @@ pub struct AssistantPanel { width: Option, height: Option, pane: ViewHandle, - api_key_editor: ViewHandle, + api_key: Rc>>, + api_key_editor: Option>, + has_read_credentials: bool, languages: Arc, fs: Arc, _subscriptions: Vec, @@ -124,19 +132,12 @@ impl AssistantPanel { .update(cx, |toolbar, cx| toolbar.add_item(buffer_search_bar, cx)); pane }); + let mut this = Self { pane, - api_key_editor: cx.add_view(|cx| { - let mut editor = Editor::single_line( - Some(Arc::new(|theme| theme.assistant.api_key_editor.clone())), - cx, - ); - editor.set_placeholder_text( - "sk-000000000000000000000000000000000000000000000000", - cx, - ); - editor - }), + api_key: Rc::new(Cell::new(None)), + api_key_editor: None, + has_read_credentials: false, languages: workspace.app_state().languages.clone(), fs: workspace.app_state().fs.clone(), width: None, @@ -145,9 +146,6 @@ impl AssistantPanel { }; let mut old_dock_position = this.position(cx); - let mut old_openai_api_key = settings::get::(cx) - .openai_api_key - .clone(); this._subscriptions = vec![ cx.observe(&this.pane, |_, _, cx| cx.notify()), cx.subscribe(&this.pane, Self::handle_pane_event), @@ -157,17 +155,6 @@ impl AssistantPanel { old_dock_position = new_dock_position; cx.emit(AssistantPanelEvent::DockPositionChanged); } - - let new_openai_api_key = settings::get::(cx) - .openai_api_key - .clone(); - if old_openai_api_key != new_openai_api_key { - old_openai_api_key = new_openai_api_key; - if this.has_focus(cx) { - cx.focus_self(); - } - cx.notify(); - } }), ]; @@ -194,22 +181,49 @@ impl AssistantPanel { fn add_context(&mut self, cx: &mut ViewContext) { let focus = self.has_focus(cx); - let editor = cx.add_view(|cx| AssistantEditor::new(self.languages.clone(), cx)); + let editor = cx + .add_view(|cx| AssistantEditor::new(self.api_key.clone(), self.languages.clone(), cx)); self.pane.update(cx, |pane, cx| { pane.add_item(Box::new(editor), true, focus, None, cx) }); } fn save_api_key(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { - let api_key = self.api_key_editor.read(cx).text(cx); - if !api_key.is_empty() { - settings::update_settings_file::( - self.fs.clone(), - cx, - move |settings| settings.openai_api_key = Some(api_key), - ); + if let Some(api_key) = self + .api_key_editor + .as_ref() + .map(|editor| editor.read(cx).text(cx)) + { + if !api_key.is_empty() { + cx.platform() + .write_credentials(OPENAI_API_URL, "Bearer", api_key.as_bytes()) + .log_err(); + self.api_key.set(Some(api_key)); + self.api_key_editor.take(); + cx.focus_self(); + cx.notify(); + } } } + + fn reset_api_key(&mut self, _: &ResetKey, cx: &mut ViewContext) { + cx.platform().delete_credentials(OPENAI_API_URL).log_err(); + self.api_key.take(); + self.api_key_editor = Some(build_api_key_editor(cx)); + cx.focus_self(); + cx.notify(); + } +} + +fn build_api_key_editor(cx: &mut ViewContext) -> ViewHandle { + cx.add_view(|cx| { + let mut editor = Editor::single_line( + Some(Arc::new(|theme| theme.assistant.api_key_editor.clone())), + cx, + ); + editor.set_placeholder_text("sk-000000000000000000000000000000000000000000000000", cx); + editor + }) } impl Entity for AssistantPanel { @@ -223,10 +237,7 @@ impl View for AssistantPanel { fn render(&mut self, cx: &mut ViewContext) -> AnyElement { let style = &theme::current(cx).assistant; - if settings::get::(cx) - .openai_api_key - .is_none() - { + if let Some(api_key_editor) = self.api_key_editor.as_ref() { Flex::column() .with_child( Text::new( @@ -236,7 +247,7 @@ impl View for AssistantPanel { .aligned(), ) .with_child( - ChildView::new(&self.api_key_editor, cx) + ChildView::new(api_key_editor, cx) .contained() .with_style(style.api_key_editor.container) .aligned(), @@ -252,13 +263,10 @@ impl View for AssistantPanel { fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { if cx.is_self_focused() { - if settings::get::(cx) - .openai_api_key - .is_some() - { - cx.focus(&self.pane); + if let Some(api_key_editor) = self.api_key_editor.as_ref() { + cx.focus(api_key_editor); } else { - cx.focus(&self.api_key_editor); + cx.focus(&self.pane); } } } @@ -323,8 +331,30 @@ impl Panel for AssistantPanel { } fn set_active(&mut self, active: bool, cx: &mut ViewContext) { - if active && self.pane.read(cx).items_len() == 0 { - self.add_context(cx); + if active { + if self.api_key.clone().take().is_none() && !self.has_read_credentials { + self.has_read_credentials = true; + let api_key = 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.set(Some(api_key)); + } else if self.api_key_editor.is_none() { + self.api_key_editor = Some(build_api_key_editor(cx)); + cx.notify(); + } + } + + if self.pane.read(cx).items_len() == 0 { + self.add_context(cx); + } } } @@ -349,7 +379,11 @@ impl Panel for AssistantPanel { } fn has_focus(&self, cx: &WindowContext) -> bool { - self.pane.read(cx).has_focus() || self.api_key_editor.is_focused(cx) + self.pane.read(cx).has_focus() + || self + .api_key_editor + .as_ref() + .map_or(false, |editor| editor.is_focused(cx)) } fn is_focus_event(event: &Self::Event) -> bool { @@ -364,6 +398,7 @@ struct Assistant { completion_count: usize, pending_completions: Vec, languages: Arc, + api_key: Rc>>, } impl Entity for Assistant { @@ -371,7 +406,11 @@ impl Entity for Assistant { } impl Assistant { - fn new(language_registry: Arc, cx: &mut ModelContext) -> Self { + fn new( + api_key: Rc>>, + language_registry: Arc, + cx: &mut ModelContext, + ) -> Self { let mut this = Self { buffer: cx.add_model(|_| MultiBuffer::new(0)), messages: Default::default(), @@ -379,6 +418,7 @@ impl Assistant { completion_count: Default::default(), pending_completions: Default::default(), languages: language_registry, + api_key, }; this.push_message(Role::User, cx); this @@ -399,10 +439,7 @@ impl Assistant { stream: true, }; - if let Some(api_key) = settings::get::(cx) - .openai_api_key - .clone() - { + if let Some(api_key) = self.api_key.clone().take() { let stream = stream_completion(api_key, cx.background().clone(), request); let response = self.push_message(Role::Assistant, cx); self.push_message(Role::User, cx); @@ -496,8 +533,12 @@ struct AssistantEditor { } impl AssistantEditor { - fn new(language_registry: Arc, cx: &mut ViewContext) -> Self { - let assistant = cx.add_model(|cx| Assistant::new(language_registry, cx)); + fn new( + api_key: Rc>>, + language_registry: Arc, + cx: &mut ViewContext, + ) -> Self { + let assistant = cx.add_model(|cx| Assistant::new(api_key, language_registry, cx)); let editor = cx.add_view(|cx| { let mut editor = Editor::for_multibuffer(assistant.read(cx).buffer.clone(), None, cx); editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); @@ -685,7 +726,7 @@ async fn stream_completion( let (tx, rx) = futures::channel::mpsc::unbounded::>(); let json_data = serde_json::to_string(&request)?; - let mut response = Request::post("https://api.openai.com/v1/chat/completions") + 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)? diff --git a/crates/ai/src/assistant_settings.rs b/crates/ai/src/assistant_settings.rs index c2652ec0cb34e477fe09fe5cba76b6688acaf224..eb92e0f6e8c0bdd6e554844f2565057ed92e9ebd 100644 --- a/crates/ai/src/assistant_settings.rs +++ b/crates/ai/src/assistant_settings.rs @@ -16,7 +16,6 @@ pub struct AssistantSettings { pub dock: AssistantDockPosition, pub default_width: f32, pub default_height: f32, - pub openai_api_key: Option, } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] @@ -24,7 +23,6 @@ pub struct AssistantSettingsContent { pub dock: Option, pub default_width: Option, pub default_height: Option, - pub openai_api_key: Option, } impl Setting for AssistantSettings { From f00f16fe3733640321ed5b31f8e744897cd0ecf2 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 2 Jun 2023 17:21:18 +0200 Subject: [PATCH 14/32] Show remaining tokens --- Cargo.lock | 43 ++++++++++++ crates/ai/Cargo.toml | 1 + crates/ai/src/assistant.rs | 108 ++++++++++++++++++++++++++++-- crates/editor/src/editor.rs | 2 +- crates/theme/src/theme.rs | 2 + styles/src/styleTree/assistant.ts | 14 ++++ 6 files changed, 162 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e8b2dfadd4521bcafca6c5ced91233c57e87b019..9c4628e9fb70500d8e3611c7f9b6cbf7a3289f79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,7 @@ dependencies = [ "serde_json", "settings", "theme", + "tiktoken-rs", "util", "workspace", ] @@ -745,6 +746,21 @@ dependencies = [ "which", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.3.2" @@ -870,6 +886,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d4260bcc2e8fc9df1eac4919a720effeb63a3f0952f5bf4944adfa18897f09" dependencies = [ "memchr", + "once_cell", + "regex-automata", "serde", ] @@ -2220,6 +2238,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -6969,6 +6997,21 @@ dependencies = [ "weezl", ] +[[package]] +name = "tiktoken-rs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ba161c549e2c0686f35f5d920e63fad5cafba2c28ad2caceaf07e5d9fa6e8c4" +dependencies = [ + "anyhow", + "base64 0.21.0", + "bstr", + "fancy-regex", + "lazy_static", + "parking_lot 0.12.1", + "rustc-hash", +] + [[package]] name = "time" version = "0.1.45" diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index 9052b1e5edf1fbc1ac300a6d30f556803f4b1dd4..e36df880d982f1e7ff267184b9c5a1877ea9772c 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -29,6 +29,7 @@ isahc.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true +tiktoken-rs = "0.4" [dev-dependencies] editor = { path = "../editor", features = ["test-support"] } diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 39e9a6ba157bf0501fb3d3e65b123874175b91c0..68f722f1eef128ce4d0bdb4b51f5157e5921d713 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -16,7 +16,8 @@ use gpui::{ use isahc::{http::StatusCode, Request, RequestExt}; use language::{language_settings::SoftWrap, Buffer, LanguageRegistry}; use settings::SettingsStore; -use std::{cell::Cell, io, rc::Rc, sync::Arc}; +use std::{cell::Cell, io, rc::Rc, sync::Arc, time::Duration}; +use tiktoken_rs::model::get_context_size; use util::{post_inc, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, @@ -398,7 +399,12 @@ struct Assistant { completion_count: usize, pending_completions: Vec, languages: Arc, + model: String, + token_count: Option, + max_token_count: usize, + pending_token_count: Task>, api_key: Rc>>, + _subscriptions: Vec, } impl Entity for Assistant { @@ -411,19 +417,78 @@ impl Assistant { language_registry: Arc, cx: &mut ModelContext, ) -> Self { + let model = "gpt-3.5-turbo"; + let buffer = cx.add_model(|_| MultiBuffer::new(0)); let mut this = Self { - buffer: cx.add_model(|_| MultiBuffer::new(0)), messages: Default::default(), messages_by_id: Default::default(), completion_count: Default::default(), pending_completions: Default::default(), languages: language_registry, + token_count: None, + max_token_count: get_context_size(model), + pending_token_count: Task::ready(None), + model: model.into(), + _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)], api_key, + buffer, }; this.push_message(Role::User, cx); + this.count_remaining_tokens(cx); this } + fn handle_buffer_event( + &mut self, + _: ModelHandle, + event: &editor::multi_buffer::Event, + cx: &mut ModelContext, + ) { + match event { + editor::multi_buffer::Event::ExcerptsAdded { .. } + | editor::multi_buffer::Event::ExcerptsRemoved { .. } + | editor::multi_buffer::Event::Edited => self.count_remaining_tokens(cx), + _ => {} + } + } + + fn count_remaining_tokens(&mut self, cx: &mut ModelContext) { + let messages = self + .messages + .iter() + .map(|message| tiktoken_rs::ChatCompletionRequestMessage { + role: match message.role { + Role::User => "user".into(), + Role::Assistant => "assistant".into(), + Role::System => "system".into(), + }, + content: message.content.read(cx).text(), + name: None, + }) + .collect::>(); + let model = self.model.clone(); + self.pending_token_count = cx.spawn(|this, mut cx| { + async move { + cx.background().timer(Duration::from_millis(200)).await; + let token_count = cx + .background() + .spawn(async move { tiktoken_rs::num_tokens_from_messages(&model, &messages) }) + .await?; + + this.update(&mut cx, |this, cx| { + this.token_count = Some(token_count); + cx.notify() + }); + anyhow::Ok(()) + } + .log_err() + }); + } + + fn remaining_tokens(&self) -> Option { + Some(self.max_token_count as isize - self.token_count? as isize) + } + fn assist(&mut self, cx: &mut ModelContext) { let messages = self .messages @@ -434,7 +499,7 @@ impl Assistant { }) .collect(); let request = OpenAIRequest { - model: "gpt-3.5-turbo".into(), + model: self.model.clone(), messages, stream: true, }; @@ -530,6 +595,7 @@ struct PendingCompletion { struct AssistantEditor { assistant: ModelHandle, editor: ViewHandle, + _subscriptions: Vec, } impl AssistantEditor { @@ -590,7 +656,11 @@ impl AssistantEditor { ); editor }); - Self { assistant, editor } + Self { + _subscriptions: vec![cx.observe(&assistant, |_, _, cx| cx.notify())], + assistant, + editor, + } } fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { @@ -684,10 +754,34 @@ impl View for AssistantEditor { fn render(&mut self, cx: &mut ViewContext) -> AnyElement { let theme = &theme::current(cx).assistant; + let remaining_tokens = self + .assistant + .read(cx) + .remaining_tokens() + .map(|remaining_tokens| { + let remaining_tokens_style = if remaining_tokens <= 0 { + &theme.no_remaining_tokens + } else { + &theme.remaining_tokens + }; + Label::new( + remaining_tokens.to_string(), + remaining_tokens_style.text.clone(), + ) + .contained() + .with_style(remaining_tokens_style.container) + .aligned() + .top() + .right() + }); - ChildView::new(&self.editor, cx) - .contained() - .with_style(theme.container) + Stack::new() + .with_child( + ChildView::new(&self.editor, cx) + .contained() + .with_style(theme.container), + ) + .with_children(remaining_tokens) .into_any() } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ee0644306828b735f8edf3ba5cdaa1a32beffedc..453468349be90a2b8f6165e6710a1ba23460d9ea 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -10,7 +10,7 @@ pub mod items; mod link_go_to_definition; mod mouse_context_menu; pub mod movement; -mod multi_buffer; +pub mod multi_buffer; mod persistence; pub mod scroll; pub mod selections_collection; diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 8282336ba554e340e09b7fa15ce5a049bbd4af89..97aac92afd153d7c7bb4267ac8e5ca445ada7a10 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -976,6 +976,8 @@ pub struct AssistantStyle { pub sent_at: ContainedText, pub user_sender: ContainedText, pub assistant_sender: ContainedText, + pub remaining_tokens: ContainedText, + pub no_remaining_tokens: ContainedText, pub api_key_editor: FieldEditor, pub api_key_prompt: ContainedText, } diff --git a/styles/src/styleTree/assistant.ts b/styles/src/styleTree/assistant.ts index 085e43071c12e6896d229bff3ff8dd4ababff874..3d21ee8519f3eeb30e8437e07f69eca5f72c8597 100644 --- a/styles/src/styleTree/assistant.ts +++ b/styles/src/styleTree/assistant.ts @@ -23,6 +23,20 @@ export default function assistant(colorScheme: ColorScheme) { margin: { top: 2, left: 8 }, ...text(layer, "sans", "default", { size: "2xs" }), }, + remaining_tokens: { + padding: 4, + margin: { right: 16, top: 4 }, + background: background(layer, "on"), + cornerRadius: 4, + ...text(layer, "sans", "positive", { size: "xs" }), + }, + no_remaining_tokens: { + padding: 4, + margin: { right: 16, top: 4 }, + background: background(layer, "on"), + cornerRadius: 4, + ...text(layer, "sans", "negative", { size: "xs" }), + }, apiKeyEditor: { background: background(layer, "on"), cornerRadius: 6, From bef6932da794877ab1011d1b3cacf28a9e4d17d0 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 5 Jun 2023 11:25:21 +0200 Subject: [PATCH 15/32] Avoid accidentally taking the `api_key` when requesting an assist --- crates/ai/src/ai.rs | 4 +--- crates/ai/src/assistant.rs | 25 ++++++++++++++----------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index a5d5666a72fe2cd08431ccbec5cd94e6f603254d..11704de03e6c8b5ecfb2d944e68bf545b45da742 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -2,11 +2,9 @@ pub mod assistant; mod assistant_settings; pub use assistant::AssistantPanel; -use gpui::{actions, AppContext}; +use gpui::AppContext; use serde::{Deserialize, Serialize}; -actions!(ai, [Assist]); - // Data types for chat completion requests #[derive(Serialize)] struct OpenAIRequest { diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 68f722f1eef128ce4d0bdb4b51f5157e5921d713..7020f9f38cc081ecc1baab6f2af318daf571fd0d 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -16,7 +16,7 @@ use gpui::{ use isahc::{http::StatusCode, Request, RequestExt}; use language::{language_settings::SoftWrap, Buffer, LanguageRegistry}; use settings::SettingsStore; -use std::{cell::Cell, io, rc::Rc, sync::Arc, time::Duration}; +use std::{cell::RefCell, io, rc::Rc, sync::Arc, time::Duration}; use tiktoken_rs::model::get_context_size; use util::{post_inc, ResultExt, TryFutureExt}; use workspace::{ @@ -62,7 +62,7 @@ pub struct AssistantPanel { width: Option, height: Option, pane: ViewHandle, - api_key: Rc>>, + api_key: Rc>>, api_key_editor: Option>, has_read_credentials: bool, languages: Arc, @@ -136,7 +136,7 @@ impl AssistantPanel { let mut this = Self { pane, - api_key: Rc::new(Cell::new(None)), + api_key: Rc::new(RefCell::new(None)), api_key_editor: None, has_read_credentials: false, languages: workspace.app_state().languages.clone(), @@ -199,7 +199,7 @@ impl AssistantPanel { cx.platform() .write_credentials(OPENAI_API_URL, "Bearer", api_key.as_bytes()) .log_err(); - self.api_key.set(Some(api_key)); + *self.api_key.borrow_mut() = Some(api_key); self.api_key_editor.take(); cx.focus_self(); cx.notify(); @@ -333,7 +333,7 @@ impl Panel for AssistantPanel { fn set_active(&mut self, active: bool, cx: &mut ViewContext) { if active { - if self.api_key.clone().take().is_none() && !self.has_read_credentials { + if self.api_key.borrow().is_none() && !self.has_read_credentials { self.has_read_credentials = true; let api_key = if let Some((_, api_key)) = cx .platform() @@ -346,7 +346,7 @@ impl Panel for AssistantPanel { None }; if let Some(api_key) = api_key { - self.api_key.set(Some(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(); @@ -403,7 +403,7 @@ struct Assistant { token_count: Option, max_token_count: usize, pending_token_count: Task>, - api_key: Rc>>, + api_key: Rc>>, _subscriptions: Vec, } @@ -413,7 +413,7 @@ impl Entity for Assistant { impl Assistant { fn new( - api_key: Rc>>, + api_key: Rc>>, language_registry: Arc, cx: &mut ModelContext, ) -> Self { @@ -504,7 +504,8 @@ impl Assistant { stream: true, }; - if let Some(api_key) = self.api_key.clone().take() { + let api_key = self.api_key.borrow().clone(); + if let Some(api_key) = api_key { let stream = stream_completion(api_key, cx.background().clone(), request); let response = self.push_message(Role::Assistant, cx); self.push_message(Role::User, cx); @@ -600,7 +601,7 @@ struct AssistantEditor { impl AssistantEditor { fn new( - api_key: Rc>>, + api_key: Rc>>, language_registry: Arc, cx: &mut ViewContext, ) -> Self { @@ -846,7 +847,9 @@ async fn stream_completion( while let Some(line) = lines.next().await { if let Some(event) = parse_line(line).transpose() { - tx.unbounded_send(event).log_err(); + if tx.unbounded_send(event).is_err() { + break; + } } } From 23836eb25119538283a38d697d7606a1fbcf7409 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 5 Jun 2023 22:58:08 -0600 Subject: [PATCH 16/32] Not working yet: Remove empty messages unless they contain the cursor Problem is, I'm trying to trust the excerpt id of the selection head, but it's a sentinel value and not the actual excerpt id of the message. I think we probably need to resolve to offsets instead. --- crates/ai/src/assistant.rs | 93 +++++++++++++++++++++- crates/editor/src/selections_collection.rs | 3 + 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 7020f9f38cc081ecc1baab6f2af318daf571fd0d..dcb658a0add8c63e84b3c3a96d835805a9d748e8 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -4,7 +4,7 @@ use crate::{ }; use anyhow::{anyhow, Result}; use chrono::{DateTime, Local}; -use collections::HashMap; +use collections::{HashMap, HashSet}; use editor::{Editor, ExcerptId, ExcerptRange, MultiBuffer}; use fs::Fs; use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; @@ -545,6 +545,59 @@ impl Assistant { self.pending_completions.pop().is_some() } + fn remove_empty_messages<'a>( + &mut self, + protected: impl IntoIterator, + cx: &mut ModelContext, + ) { + dbg!(&self.messages_by_id); + + let protected = protected + .into_iter() + .filter_map(|excerpt_id| { + self.messages_by_id + .get(&excerpt_id) + .map(|message| message.content.id()) + }) + .collect::>(); + + dbg!(&protected); + + let empty = self + .messages_by_id + .values() + .filter_map(|message| { + if message.content.read(cx).is_empty() { + Some(message.content.id()) + } else { + None + } + }) + .collect::>(); + + dbg!(&empty); + + let mut remove_excerpts = Vec::new(); + self.messages.retain(|message| { + let is_empty = empty.contains(&message.content.id()); + let is_protected = dbg!(&protected).contains(&message.content.id()); + let retain_message = !is_empty || is_protected; + if !retain_message { + remove_excerpts.push(message.excerpt_id); + self.messages_by_id.remove(&message.excerpt_id); + } + retain_message + }); + + if !remove_excerpts.is_empty() { + self.buffer.update(cx, |buffer, cx| { + dbg!(buffer.excerpt_ids()); + buffer.remove_excerpts(dbg!(remove_excerpts), cx) + }); + cx.notify(); + } + } + fn push_message(&mut self, role: Role, cx: &mut ModelContext) -> Message { let content = cx.add_model(|cx| { let mut buffer = Buffer::new(0, "", cx); @@ -578,6 +631,7 @@ impl Assistant { }); let message = Message { + excerpt_id, role, content: content.clone(), sent_at: Local::now(), @@ -657,8 +711,14 @@ impl AssistantEditor { ); editor }); + + let _subscriptions = vec![ + cx.observe(&assistant, |_, _, cx| cx.notify()), + cx.subscribe(&editor, Self::handle_editor_event), + ]; + Self { - _subscriptions: vec![cx.observe(&assistant, |_, _, cx| cx.notify())], + _subscriptions, assistant, editor, } @@ -678,6 +738,32 @@ impl AssistantEditor { } } + fn handle_editor_event( + &mut self, + _: ViewHandle, + event: &editor::Event, + cx: &mut ViewContext, + ) { + if *event == editor::Event::Edited { + self.remove_empty_messages(cx); + } + } + + // Removes empty messages that don't contain a cursor. + fn remove_empty_messages(&mut self, cx: &mut ViewContext) { + let anchored_selections = self.editor.read(cx).selections.disjoint_anchors(); + let protected_excerpts = anchored_selections + .iter() + .map(|selection| selection.head().excerpt_id()) + .collect::>(); + + dbg!(&protected_excerpts); + + self.assistant.update(cx, |assistant, cx| { + assistant.remove_empty_messages(protected_excerpts, cx) + }); + } + fn quote_selection( workspace: &mut Workspace, _: &QuoteSelection, @@ -804,8 +890,9 @@ impl Item for AssistantEditor { } } -#[derive(Clone)] +#[derive(Clone, Debug)] struct Message { + excerpt_id: ExcerptId, role: Role, content: ModelHandle, sent_at: DateTime, diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 4c32b543b210b509b75319cb3094621116b557c8..d82ce5e21637fde58d1e19c866befe53270f2e9f 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -76,6 +76,9 @@ impl SelectionsCollection { count } + /// The non-pending, non-overlapping selections. There could still be a pending + /// selection that overlaps these if the mouse is being dragged, etc. Returned as + /// selections over Anchors. pub fn disjoint_anchors(&self) -> Arc<[Selection]> { self.disjoint.clone() } From e46d1549d6869bdce7476ee360ae644e91b1298f Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 6 Jun 2023 10:12:15 +0200 Subject: [PATCH 17/32] Retain selection's head (as opposed to its end) on insertion This makes a difference when an edit spans two excerpts and the selection start won't necessarily be the same as the selection end after the edit. --- crates/editor/src/editor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 453468349be90a2b8f6165e6710a1ba23460d9ea..0eefa7e0985890dc75a7f060a440d26a4922cec1 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2382,7 +2382,7 @@ impl Editor { old_selections .iter() .map(|s| { - let anchor = snapshot.anchor_after(s.end); + let anchor = snapshot.anchor_after(s.head()); s.map(|_| anchor) }) .collect::>() From 80323244707e3d934cd4d04f9074ee9618e0cd28 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 6 Jun 2023 10:13:45 +0200 Subject: [PATCH 18/32] Prevent moving across excerpts on `Editor::delete` --- crates/editor/src/editor.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 0eefa7e0985890dc75a7f060a440d26a4922cec1..9f2c0c86ff0a962c339423dd70204fb02e5df317 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -3557,7 +3557,9 @@ impl Editor { s.move_with(|map, selection| { if selection.is_empty() && !line_mode { let cursor = movement::right(map, selection.head()); - selection.set_head(cursor, SelectionGoal::None); + selection.end = cursor; + selection.reversed = true; + selection.goal = SelectionGoal::None; } }) }); From 337dda8e3a8fd21aed3c6a9a482493f37bc063d9 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 6 Jun 2023 12:25:09 +0200 Subject: [PATCH 19/32] Only remove excerpts when an edit touches them --- crates/ai/src/assistant.rs | 100 ++++++++++++------------------ crates/editor/src/editor.rs | 2 +- crates/editor/src/multi_buffer.rs | 11 ++++ 3 files changed, 52 insertions(+), 61 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index dcb658a0add8c63e84b3c3a96d835805a9d748e8..1141bcee8e5d8176d0eab3e22b06415ff2c15527 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -392,6 +392,10 @@ impl Panel for AssistantPanel { } } +enum AssistantEvent { + MessagesEdited { ids: Vec }, +} + struct Assistant { buffer: ModelHandle, messages: Vec, @@ -408,7 +412,7 @@ struct Assistant { } impl Entity for Assistant { - type Event = (); + type Event = AssistantEvent; } impl Assistant { @@ -448,6 +452,9 @@ impl Assistant { editor::multi_buffer::Event::ExcerptsAdded { .. } | editor::multi_buffer::Event::ExcerptsRemoved { .. } | editor::multi_buffer::Event::Edited => self.count_remaining_tokens(cx), + editor::multi_buffer::Event::ExcerptsEdited { ids } => { + cx.emit(AssistantEvent::MessagesEdited { ids: ids.clone() }); + } _ => {} } } @@ -547,52 +554,30 @@ impl Assistant { fn remove_empty_messages<'a>( &mut self, - protected: impl IntoIterator, + excerpts: HashSet, + protected_offsets: HashSet, cx: &mut ModelContext, ) { - dbg!(&self.messages_by_id); - - let protected = protected - .into_iter() - .filter_map(|excerpt_id| { - self.messages_by_id - .get(&excerpt_id) - .map(|message| message.content.id()) - }) - .collect::>(); - - dbg!(&protected); - - let empty = self - .messages_by_id - .values() - .filter_map(|message| { - if message.content.read(cx).is_empty() { - Some(message.content.id()) - } else { - None - } - }) - .collect::>(); - - dbg!(&empty); - - let mut remove_excerpts = Vec::new(); + let mut offset = 0; + let mut excerpts_to_remove = Vec::new(); self.messages.retain(|message| { - let is_empty = empty.contains(&message.content.id()); - let is_protected = dbg!(&protected).contains(&message.content.id()); - let retain_message = !is_empty || is_protected; - if !retain_message { - remove_excerpts.push(message.excerpt_id); + let range = offset..offset + message.content.read(cx).len(); + offset = range.end + 1; + if range.is_empty() + && !protected_offsets.contains(&range.start) + && excerpts.contains(&message.excerpt_id) + { + excerpts_to_remove.push(message.excerpt_id); self.messages_by_id.remove(&message.excerpt_id); + false + } else { + true } - retain_message }); - if !remove_excerpts.is_empty() { + if !excerpts_to_remove.is_empty() { self.buffer.update(cx, |buffer, cx| { - dbg!(buffer.excerpt_ids()); - buffer.remove_excerpts(dbg!(remove_excerpts), cx) + buffer.remove_excerpts(excerpts_to_remove, cx) }); cx.notify(); } @@ -714,7 +699,7 @@ impl AssistantEditor { let _subscriptions = vec![ cx.observe(&assistant, |_, _, cx| cx.notify()), - cx.subscribe(&editor, Self::handle_editor_event), + cx.subscribe(&assistant, Self::handle_assistant_event), ]; Self { @@ -738,32 +723,27 @@ impl AssistantEditor { } } - fn handle_editor_event( + fn handle_assistant_event( &mut self, - _: ViewHandle, - event: &editor::Event, + assistant: ModelHandle, + event: &AssistantEvent, cx: &mut ViewContext, ) { - if *event == editor::Event::Edited { - self.remove_empty_messages(cx); + match event { + AssistantEvent::MessagesEdited { ids } => { + let selections = self.editor.read(cx).selections.all::(cx); + let selection_heads = selections + .iter() + .map(|selection| selection.head()) + .collect::>(); + let ids = ids.iter().copied().collect::>(); + assistant.update(cx, |assistant, cx| { + assistant.remove_empty_messages(ids, selection_heads, cx) + }); + } } } - // Removes empty messages that don't contain a cursor. - fn remove_empty_messages(&mut self, cx: &mut ViewContext) { - let anchored_selections = self.editor.read(cx).selections.disjoint_anchors(); - let protected_excerpts = anchored_selections - .iter() - .map(|selection| selection.head().excerpt_id()) - .collect::>(); - - dbg!(&protected_excerpts); - - self.assistant.update(cx, |assistant, cx| { - assistant.remove_empty_messages(protected_excerpts, cx) - }); - } - fn quote_selection( workspace: &mut Workspace, _: &QuoteSelection, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 9f2c0c86ff0a962c339423dd70204fb02e5df317..613536a95568f96a5c71e163c419e7708f36c687 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -6907,7 +6907,7 @@ impl Editor { multi_buffer::Event::DiagnosticsUpdated => { self.refresh_active_diagnostics(cx); } - multi_buffer::Event::LanguageChanged => {} + _ => {} } } diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index b7b2b89c8ca312667bfb3b5988da8292fd33c6bc..6011d1014234a394747f44bf7952333e219573cf 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -64,6 +64,9 @@ pub enum Event { ExcerptsRemoved { ids: Vec, }, + ExcerptsEdited { + ids: Vec, + }, Edited, Reloaded, DiffBaseChanged, @@ -387,6 +390,7 @@ impl MultiBuffer { original_indent_column: u32, } let mut buffer_edits: HashMap> = Default::default(); + let mut edited_excerpt_ids = Vec::new(); let mut cursor = snapshot.excerpts.cursor::(); for (ix, (range, new_text)) in edits.enumerate() { let new_text: Arc = new_text.into(); @@ -403,6 +407,7 @@ impl MultiBuffer { .start .to_offset(&start_excerpt.buffer) + start_overshoot; + edited_excerpt_ids.push(start_excerpt.id); cursor.seek(&range.end, Bias::Right, &()); if cursor.item().is_none() && range.end == *cursor.start() { @@ -428,6 +433,7 @@ impl MultiBuffer { original_indent_column, }); } else { + edited_excerpt_ids.push(end_excerpt.id); let start_excerpt_range = buffer_start ..start_excerpt .range @@ -474,6 +480,7 @@ impl MultiBuffer { is_insertion: false, original_indent_column, }); + edited_excerpt_ids.push(excerpt.id); cursor.next(&()); } } @@ -546,6 +553,10 @@ impl MultiBuffer { buffer.edit(insertions, insertion_autoindent_mode, cx); }) } + + cx.emit(Event::ExcerptsEdited { + ids: edited_excerpt_ids, + }); } pub fn start_transaction(&mut self, cx: &mut ModelContext) -> Option { From f4f060667e588536510679b78c10882371ff0e11 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 6 Jun 2023 12:36:26 +0200 Subject: [PATCH 20/32] Add assertion to pinpoint how deletion works across excerpts --- crates/editor/src/editor_tests.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index bc671b9ffc3652856d16faf567784eab438440b7..df6459918e57b339b19c2faceca29dc9551e79d3 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -5225,7 +5225,28 @@ fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) { Point::new(0, 1)..Point::new(0, 1), Point::new(1, 1)..Point::new(1, 1), ] - ) + ); + + // Ensure the cursor's head is respected when deleting across an excerpt boundary. + view.change_selections(None, cx, |s| { + s.select_ranges([Point::new(0, 2)..Point::new(1, 2)]) + }); + view.backspace(&Default::default(), cx); + assert_eq!(view.text(cx), "Xa\nbbb"); + assert_eq!( + view.selections.ranges(cx), + [Point::new(1, 0)..Point::new(1, 0)] + ); + + view.change_selections(None, cx, |s| { + s.select_ranges([Point::new(1, 1)..Point::new(0, 1)]) + }); + view.backspace(&Default::default(), cx); + assert_eq!(view.text(cx), "X\nbb"); + assert_eq!( + view.selections.ranges(cx), + [Point::new(0, 1)..Point::new(0, 1)] + ); }); } From ada222078c7d83fe479fa0c51a9fdf749dbff17b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 6 Jun 2023 12:43:08 +0200 Subject: [PATCH 21/32] Insert a user reply when hitting `cmd-enter` in an assistant message --- crates/ai/src/assistant.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 1141bcee8e5d8176d0eab3e22b06415ff2c15527..6c3189b660822b7fbca5db5a0623a6c268889307 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -5,7 +5,7 @@ use crate::{ use anyhow::{anyhow, Result}; use chrono::{DateTime, Local}; use collections::{HashMap, HashSet}; -use editor::{Editor, ExcerptId, ExcerptRange, MultiBuffer}; +use editor::{Anchor, Editor, ExcerptId, ExcerptRange, MultiBuffer}; use fs::Fs; use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; use gpui::{ @@ -710,8 +710,25 @@ impl AssistantEditor { } fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { - self.assistant - .update(cx, |assistant, cx| assistant.assist(cx)); + self.assistant.update(cx, |assistant, cx| { + let editor = self.editor.read(cx); + let newest_selection = editor.selections.newest_anchor(); + let message = if newest_selection.head() == Anchor::min() { + assistant.messages.first() + } else if newest_selection.head() == Anchor::max() { + assistant.messages.last() + } else { + assistant + .messages_by_id + .get(&newest_selection.head().excerpt_id()) + }; + + if message.map_or(false, |message| message.role == Role::Assistant) { + assistant.push_message(Role::User, cx); + } else { + assistant.assist(cx); + } + }); } fn cancel_last_assist(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { From 69b8267b6bd6eec3b65e1f2b32e237bf0f828e3a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 6 Jun 2023 13:04:52 +0200 Subject: [PATCH 22/32] Show the current model and allow clicking on it to change it --- crates/ai/src/assistant.rs | 85 +++++++++++++++++++++---------- crates/theme/src/theme.rs | 2 + styles/src/styleTree/assistant.ts | 26 ++++++++-- 3 files changed, 82 insertions(+), 31 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 6c3189b660822b7fbca5db5a0623a6c268889307..d61dec9f72eab784654eeb29f375559e18dab4fe 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -9,15 +9,17 @@ use editor::{Anchor, Editor, ExcerptId, ExcerptRange, MultiBuffer}; use fs::Fs; use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; use gpui::{ - actions, elements::*, executor::Background, Action, AppContext, AsyncAppContext, Entity, - ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, - WindowContext, + actions, + elements::*, + executor::Background, + platform::{CursorStyle, MouseButton}, + Action, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Subscription, Task, + View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; use isahc::{http::StatusCode, Request, RequestExt}; use language::{language_settings::SoftWrap, Buffer, LanguageRegistry}; use settings::SettingsStore; use std::{cell::RefCell, io, rc::Rc, sync::Arc, time::Duration}; -use tiktoken_rs::model::get_context_size; use util::{post_inc, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, @@ -430,7 +432,7 @@ impl Assistant { pending_completions: Default::default(), languages: language_registry, token_count: None, - max_token_count: get_context_size(model), + max_token_count: tiktoken_rs::model::get_context_size(model), pending_token_count: Task::ready(None), model: model.into(), _subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)], @@ -483,6 +485,7 @@ impl Assistant { .await?; this.update(&mut cx, |this, cx| { + this.max_token_count = tiktoken_rs::model::get_context_size(&this.model); this.token_count = Some(token_count); cx.notify() }); @@ -496,6 +499,12 @@ impl Assistant { Some(self.max_token_count as isize - self.token_count? as isize) } + fn set_model(&mut self, model: String, cx: &mut ModelContext) { + self.model = model; + self.count_remaining_tokens(cx); + cx.notify(); + } + fn assist(&mut self, cx: &mut ModelContext) { let messages = self .messages @@ -825,6 +834,16 @@ impl AssistantEditor { }); } } + + fn cycle_model(&mut self, cx: &mut ViewContext) { + self.assistant.update(cx, |assistant, cx| { + let new_model = match assistant.model.as_str() { + "gpt-4" => "gpt-3.5-turbo", + _ => "gpt-4", + }; + assistant.set_model(new_model.into(), cx); + }); + } } impl Entity for AssistantEditor { @@ -837,27 +856,23 @@ impl View for AssistantEditor { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + enum Model {} let theme = &theme::current(cx).assistant; - let remaining_tokens = self - .assistant - .read(cx) - .remaining_tokens() - .map(|remaining_tokens| { - let remaining_tokens_style = if remaining_tokens <= 0 { - &theme.no_remaining_tokens - } else { - &theme.remaining_tokens - }; - Label::new( - remaining_tokens.to_string(), - remaining_tokens_style.text.clone(), - ) - .contained() - .with_style(remaining_tokens_style.container) - .aligned() - .top() - .right() - }); + let assistant = &self.assistant.read(cx); + let model = assistant.model.clone(); + let remaining_tokens = assistant.remaining_tokens().map(|remaining_tokens| { + let remaining_tokens_style = if remaining_tokens <= 0 { + &theme.no_remaining_tokens + } else { + &theme.remaining_tokens + }; + Label::new( + remaining_tokens.to_string(), + remaining_tokens_style.text.clone(), + ) + .contained() + .with_style(remaining_tokens_style.container) + }); Stack::new() .with_child( @@ -865,7 +880,25 @@ impl View for AssistantEditor { .contained() .with_style(theme.container), ) - .with_children(remaining_tokens) + .with_child( + Flex::row() + .with_child( + MouseEventHandler::::new(0, cx, |state, _| { + let style = theme.model.style_for(state, false); + Label::new(model, style.text.clone()) + .contained() + .with_style(style.container) + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, |_, this, cx| this.cycle_model(cx)), + ) + .with_children(remaining_tokens) + .contained() + .with_style(theme.model_info_container) + .aligned() + .top() + .right(), + ) .into_any() } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 97aac92afd153d7c7bb4267ac8e5ca445ada7a10..f746f901939427e144b1c1dabbce697c9c08a4d5 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -976,6 +976,8 @@ pub struct AssistantStyle { pub sent_at: ContainedText, pub user_sender: ContainedText, pub assistant_sender: ContainedText, + pub model_info_container: ContainerStyle, + pub model: Interactive, pub remaining_tokens: ContainedText, pub no_remaining_tokens: ContainedText, pub api_key_editor: FieldEditor, diff --git a/styles/src/styleTree/assistant.ts b/styles/src/styleTree/assistant.ts index 3d21ee8519f3eeb30e8437e07f69eca5f72c8597..217476bc31a6b4b2813f64fde7393483b445d8a5 100644 --- a/styles/src/styleTree/assistant.ts +++ b/styles/src/styleTree/assistant.ts @@ -11,7 +11,8 @@ export default function assistant(colorScheme: ColorScheme) { }, header: { border: border(layer, "default", { bottom: true, top: true }), - margin: { bottom: 6, top: 6 } + margin: { bottom: 6, top: 6 }, + background: editor(colorScheme).background }, user_sender: { ...text(layer, "sans", "default", { size: "sm", weight: "bold" }), @@ -23,17 +24,32 @@ export default function assistant(colorScheme: ColorScheme) { margin: { top: 2, left: 8 }, ...text(layer, "sans", "default", { size: "2xs" }), }, - remaining_tokens: { - padding: 4, + model_info_container: { margin: { right: 16, top: 4 }, + }, + model: { background: background(layer, "on"), + border: border(layer, "on", { overlay: true }), + padding: 4, + cornerRadius: 4, + ...text(layer, "sans", "default", { size: "xs" }), + hover: { + background: background(layer, "on", "hovered"), + } + }, + remaining_tokens: { + background: background(layer, "on"), + border: border(layer, "on", { overlay: true }), + padding: 4, + margin: { left: 4 }, cornerRadius: 4, ...text(layer, "sans", "positive", { size: "xs" }), }, no_remaining_tokens: { - padding: 4, - margin: { right: 16, top: 4 }, background: background(layer, "on"), + border: border(layer, "on", { overlay: true }), + padding: 4, + margin: { left: 4 }, cornerRadius: 4, ...text(layer, "sans", "negative", { size: "xs" }), }, From 9c59146026eedc3fa5fa5fa2d4121f47eaec9eb5 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 6 Jun 2023 15:59:10 +0200 Subject: [PATCH 23/32] Set assistant editor's title based on the first question/answer pair Co-Authored-By: Julia Risley --- assets/keymaps/default.json | 2 +- crates/ai/src/assistant.rs | 119 +++++++++++++++++++++++++++++++---- crates/workspace/src/pane.rs | 3 +- 3 files changed, 111 insertions(+), 13 deletions(-) diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 41087326d27afe9c80a4fc0a27e0b3e31ca8cbf2..71c34da20129d11cacc6bd047fbc871b9ad81479 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -197,7 +197,7 @@ } }, { - "context": "ContextEditor > Editor", + "context": "AssistantEditor > Editor", "bindings": { "cmd-enter": "assistant::Assist", "cmd->": "assistant::QuoteSelection" diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index d61dec9f72eab784654eeb29f375559e18dab4fe..0f7ca509d449104ffc63f10aacc248921bdd6821 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -19,8 +19,8 @@ use gpui::{ use isahc::{http::StatusCode, Request, RequestExt}; use language::{language_settings::SoftWrap, Buffer, LanguageRegistry}; use settings::SettingsStore; -use std::{cell::RefCell, io, rc::Rc, sync::Arc, time::Duration}; -use util::{post_inc, ResultExt, TryFutureExt}; +use std::{borrow::Cow, cell::RefCell, io, rc::Rc, sync::Arc, time::Duration}; +use util::{post_inc, truncate_and_trailoff, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, item::Item, @@ -69,7 +69,7 @@ pub struct AssistantPanel { has_read_credentials: bool, languages: Arc, fs: Arc, - _subscriptions: Vec, + subscriptions: Vec, } impl AssistantPanel { @@ -145,11 +145,11 @@ impl AssistantPanel { fs: workspace.app_state().fs.clone(), width: None, height: None, - _subscriptions: Default::default(), + subscriptions: Default::default(), }; let mut old_dock_position = this.position(cx); - this._subscriptions = vec![ + this.subscriptions = vec![ cx.observe(&this.pane, |_, _, cx| cx.notify()), cx.subscribe(&this.pane, Self::handle_pane_event), cx.observe_global::(move |this, cx| { @@ -186,11 +186,24 @@ impl AssistantPanel { let focus = self.has_focus(cx); let editor = cx .add_view(|cx| AssistantEditor::new(self.api_key.clone(), self.languages.clone(), cx)); + self.subscriptions + .push(cx.subscribe(&editor, Self::handle_assistant_editor_event)); self.pane.update(cx, |pane, cx| { pane.add_item(Box::new(editor), true, focus, None, cx) }); } + fn handle_assistant_editor_event( + &mut self, + _: ViewHandle, + event: &AssistantEditorEvent, + cx: &mut ViewContext, + ) { + match event { + AssistantEditorEvent::TabContentChanged => self.pane.update(cx, |_, cx| cx.notify()), + } + } + fn save_api_key(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { if let Some(api_key) = self .api_key_editor @@ -396,12 +409,15 @@ impl Panel for AssistantPanel { enum AssistantEvent { MessagesEdited { ids: Vec }, + SummaryChanged, } struct Assistant { buffer: ModelHandle, messages: Vec, messages_by_id: HashMap, + summary: Option, + pending_summary: Task>, completion_count: usize, pending_completions: Vec, languages: Arc, @@ -428,6 +444,8 @@ impl Assistant { let mut this = Self { messages: Default::default(), messages_by_id: Default::default(), + summary: None, + pending_summary: Task::ready(None), completion_count: Default::default(), pending_completions: Default::default(), languages: language_registry, @@ -540,9 +558,10 @@ impl Assistant { } } - this.update(&mut cx, |this, _| { + this.update(&mut cx, |this, cx| { this.pending_completions .retain(|completion| completion.id != this.completion_count); + this.summarize(cx); }); anyhow::Ok(()) @@ -634,6 +653,54 @@ impl Assistant { self.messages_by_id.insert(excerpt_id, message.clone()); message } + + fn summarize(&mut self, cx: &mut ModelContext) { + if self.messages.len() >= 2 && self.summary.is_none() { + let api_key = self.api_key.borrow().clone(); + if let Some(api_key) = api_key { + let messages = self + .messages + .iter() + .take(2) + .map(|message| RequestMessage { + role: message.role, + content: message.content.read(cx).text(), + }) + .chain(Some(RequestMessage { + role: Role::User, + content: "Summarize the conversation into a short title without punctuation and with as few characters as possible" + .into(), + })) + .collect(); + let request = OpenAIRequest { + model: self.model.clone(), + messages, + stream: true, + }; + + let stream = stream_completion(api_key, cx.background().clone(), request); + self.pending_summary = cx.spawn(|this, mut cx| { + async move { + let mut messages = stream.await?; + + while let Some(message) = messages.next().await { + let mut message = message?; + if let Some(choice) = message.choices.pop() { + let text = choice.delta.content.unwrap_or_default(); + this.update(&mut cx, |this, cx| { + this.summary.get_or_insert(String::new()).push_str(&text); + cx.emit(AssistantEvent::SummaryChanged); + }); + } + } + + anyhow::Ok(()) + } + .log_err() + }); + } + } + } } struct PendingCompletion { @@ -641,6 +708,10 @@ struct PendingCompletion { _task: Task>, } +enum AssistantEditorEvent { + TabContentChanged, +} + struct AssistantEditor { assistant: ModelHandle, editor: ViewHandle, @@ -712,9 +783,9 @@ impl AssistantEditor { ]; Self { - _subscriptions, assistant, editor, + _subscriptions, } } @@ -767,6 +838,9 @@ impl AssistantEditor { assistant.remove_empty_messages(ids, selection_heads, cx) }); } + AssistantEvent::SummaryChanged => { + cx.emit(AssistantEditorEvent::TabContentChanged); + } } } @@ -844,15 +918,23 @@ impl AssistantEditor { assistant.set_model(new_model.into(), cx); }); } + + fn title(&self, cx: &AppContext) -> String { + self.assistant + .read(cx) + .summary + .clone() + .unwrap_or_else(|| "New Context".into()) + } } impl Entity for AssistantEditor { - type Event = (); + type Event = AssistantEditorEvent; } impl View for AssistantEditor { fn ui_name() -> &'static str { - "ContextEditor" + "AssistantEditor" } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { @@ -914,9 +996,14 @@ impl Item for AssistantEditor { &self, _: Option, style: &theme::Tab, - _: &gpui::AppContext, + cx: &gpui::AppContext, ) -> AnyElement { - Label::new("New Context", style.label.clone()).into_any() + let title = truncate_and_trailoff(&self.title(cx), editor::MAX_TAB_TITLE_LEN); + Label::new(title, style.label.clone()).into_any() + } + + fn tab_tooltip_text(&self, cx: &AppContext) -> Option> { + Some(self.title(cx).into()) } } @@ -964,9 +1051,19 @@ async fn stream_completion( 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; + } } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 1fa8c15f9bc1f5399fbcda51cb68d2b594543c46..ca5e4e981cef76802f15408d0f71c88e6101b1e1 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1151,7 +1151,8 @@ impl Pane { let theme = theme::current(cx).clone(); let mut tooltip_theme = theme.tooltip.clone(); tooltip_theme.max_text_width = None; - let tab_tooltip_text = item.tab_tooltip_text(cx).map(|a| a.to_string()); + let tab_tooltip_text = + item.tab_tooltip_text(cx).map(|text| text.into_owned()); move |mouse_state, cx| { let tab_style = From 2b1aeb07bc7acd378af090e403431585943139e4 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 6 Jun 2023 16:53:56 +0200 Subject: [PATCH 24/32] Show error message when requests to OpenAI fail Co-Authored-By: Julia Risley --- crates/ai/src/assistant.rs | 124 ++++++++++++++++++++++-------- crates/theme/src/theme.rs | 1 + styles/src/styleTree/assistant.ts | 19 +++-- 3 files changed, 106 insertions(+), 38 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 0f7ca509d449104ffc63f10aacc248921bdd6821..b70ed8c87cf657e80fe5dd2c9c80e0372e9687af 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -18,6 +18,7 @@ use gpui::{ }; use isahc::{http::StatusCode, Request, RequestExt}; use language::{language_settings::SoftWrap, Buffer, LanguageRegistry}; +use serde::Deserialize; use settings::SettingsStore; use std::{borrow::Cow, cell::RefCell, io, rc::Rc, sync::Arc, time::Duration}; use util::{post_inc, truncate_and_trailoff, ResultExt, TryFutureExt}; @@ -415,7 +416,7 @@ enum AssistantEvent { struct Assistant { buffer: ModelHandle, messages: Vec, - messages_by_id: HashMap, + messages_metadata: HashMap, summary: Option, pending_summary: Task>, completion_count: usize, @@ -443,7 +444,7 @@ impl Assistant { let buffer = cx.add_model(|_| MultiBuffer::new(0)); let mut this = Self { messages: Default::default(), - messages_by_id: Default::default(), + messages_metadata: Default::default(), summary: None, pending_summary: Task::ready(None), completion_count: Default::default(), @@ -541,16 +542,16 @@ impl Assistant { let api_key = self.api_key.borrow().clone(); if let Some(api_key) = api_key { let stream = stream_completion(api_key, cx.background().clone(), request); - let response = self.push_message(Role::Assistant, cx); + let (excerpt_id, content) = self.push_message(Role::Assistant, cx); self.push_message(Role::User, cx); - let task = cx.spawn(|this, mut cx| { - async move { + let task = cx.spawn(|this, mut cx| async move { + 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() { - response.content.update(&mut cx, |content, cx| { + content.update(&mut cx, |content, cx| { let text: Arc = choice.delta.content?.into(); content.edit([(content.len()..content.len(), text)], None, cx); Some(()) @@ -565,8 +566,16 @@ impl Assistant { }); anyhow::Ok(()) + }; + + if let Err(error) = stream_completion.await { + this.update(&mut cx, |this, cx| { + if let Some(metadata) = this.messages_metadata.get_mut(&excerpt_id) { + metadata.error = Some(error.to_string().trim().into()); + cx.notify(); + } + }) } - .log_err() }); self.pending_completions.push(PendingCompletion { @@ -596,7 +605,7 @@ impl Assistant { && excerpts.contains(&message.excerpt_id) { excerpts_to_remove.push(message.excerpt_id); - self.messages_by_id.remove(&message.excerpt_id); + self.messages_metadata.remove(&message.excerpt_id); false } else { true @@ -611,7 +620,11 @@ impl Assistant { } } - fn push_message(&mut self, role: Role, cx: &mut ModelContext) -> Message { + fn push_message( + &mut self, + role: Role, + cx: &mut ModelContext, + ) -> (ExcerptId, ModelHandle) { let content = cx.add_model(|cx| { let mut buffer = Buffer::new(0, "", cx); let markdown = self.languages.language_for_name("Markdown"); @@ -643,15 +656,20 @@ impl Assistant { .unwrap() }); - let message = Message { + self.messages.push(Message { excerpt_id, role, content: content.clone(), - sent_at: Local::now(), - }; - self.messages.push(message.clone()); - self.messages_by_id.insert(excerpt_id, message.clone()); - message + }); + self.messages_metadata.insert( + excerpt_id, + MessageMetadata { + role, + sent_at: Local::now(), + error: None, + }, + ); + (excerpt_id, content) } fn summarize(&mut self, cx: &mut ModelContext) { @@ -705,7 +723,7 @@ impl Assistant { struct PendingCompletion { id: usize, - _task: Task>, + _task: Task<()>, } enum AssistantEditorEvent { @@ -733,9 +751,13 @@ impl AssistantEditor { { let assistant = assistant.clone(); move |_editor, params: editor::RenderExcerptHeaderParams, cx| { - let style = &theme::current(cx).assistant; - if let Some(message) = assistant.read(cx).messages_by_id.get(¶ms.id) { - let sender = match message.role { + enum ErrorTooltip {} + + let theme = theme::current(cx); + let style = &theme.assistant; + if let Some(metadata) = assistant.read(cx).messages_metadata.get(¶ms.id) + { + let sender = match metadata.role { Role::User => Label::new("You", style.user_sender.text.clone()) .contained() .with_style(style.user_sender.container), @@ -755,13 +777,29 @@ impl AssistantEditor { .with_child(sender.aligned()) .with_child( Label::new( - message.sent_at.format("%I:%M%P").to_string(), + metadata.sent_at.format("%I:%M%P").to_string(), style.sent_at.text.clone(), ) .contained() .with_style(style.sent_at.container) .aligned(), ) + .with_children(metadata.error.clone().map(|error| { + Svg::new("icons/circle_x_mark_12.svg") + .with_color(style.error_icon.color) + .constrained() + .with_width(style.error_icon.width) + .contained() + .with_style(style.error_icon.container) + .with_tooltip::( + params.id.into(), + error, + None, + theme.tooltip.clone(), + cx, + ) + .aligned() + })) .aligned() .left() .contained() @@ -793,17 +831,18 @@ impl AssistantEditor { self.assistant.update(cx, |assistant, cx| { let editor = self.editor.read(cx); let newest_selection = editor.selections.newest_anchor(); - let message = if newest_selection.head() == Anchor::min() { - assistant.messages.first() + let role = if newest_selection.head() == Anchor::min() { + assistant.messages.first().map(|message| message.role) } else if newest_selection.head() == Anchor::max() { - assistant.messages.last() + assistant.messages.last().map(|message| message.role) } else { assistant - .messages_by_id + .messages_metadata .get(&newest_selection.head().excerpt_id()) + .map(|message| message.role) }; - if message.map_or(false, |message| message.role == Role::Assistant) { + if role.map_or(false, |role| role == Role::Assistant) { assistant.push_message(Role::User, cx); } else { assistant.assist(cx); @@ -1007,12 +1046,18 @@ impl Item for AssistantEditor { } } -#[derive(Clone, Debug)] +#[derive(Debug)] struct Message { excerpt_id: ExcerptId, role: Role, content: ModelHandle, +} + +#[derive(Debug)] +struct MessageMetadata { + role: Role, sent_at: DateTime, + error: Option, } async fn stream_completion( @@ -1076,10 +1121,27 @@ async fn stream_completion( let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; - Err(anyhow!( - "Failed to connect to OpenAI API: {} {}", - response.status(), - body, - )) + #[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/theme/src/theme.rs b/crates/theme/src/theme.rs index f746f901939427e144b1c1dabbce697c9c08a4d5..132e37ad1c830155f3713c92d0e224ddbfdc214a 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -980,6 +980,7 @@ pub struct AssistantStyle { pub model: Interactive, pub remaining_tokens: ContainedText, pub no_remaining_tokens: ContainedText, + pub error_icon: Icon, pub api_key_editor: FieldEditor, pub api_key_prompt: ContainedText, } diff --git a/styles/src/styleTree/assistant.ts b/styles/src/styleTree/assistant.ts index 217476bc31a6b4b2813f64fde7393483b445d8a5..4314741fb0b566b5abbc64eaad4af8afad55986c 100644 --- a/styles/src/styleTree/assistant.ts +++ b/styles/src/styleTree/assistant.ts @@ -1,5 +1,5 @@ import { ColorScheme } from "../themes/common/colorScheme" -import { text, border, background } from "./components" +import { text, border, background, foreground } from "./components" import editor from "./editor" export default function assistant(colorScheme: ColorScheme) { @@ -14,17 +14,17 @@ export default function assistant(colorScheme: ColorScheme) { margin: { bottom: 6, top: 6 }, background: editor(colorScheme).background }, - user_sender: { + userSender: { ...text(layer, "sans", "default", { size: "sm", weight: "bold" }), }, - assistant_sender: { + assistantSender: { ...text(layer, "sans", "accent", { size: "sm", weight: "bold" }), }, - sent_at: { + sentAt: { margin: { top: 2, left: 8 }, ...text(layer, "sans", "default", { size: "2xs" }), }, - model_info_container: { + modelInfoContainer: { margin: { right: 16, top: 4 }, }, model: { @@ -37,7 +37,7 @@ export default function assistant(colorScheme: ColorScheme) { background: background(layer, "on", "hovered"), } }, - remaining_tokens: { + remainingTokens: { background: background(layer, "on"), border: border(layer, "on", { overlay: true }), padding: 4, @@ -45,7 +45,7 @@ export default function assistant(colorScheme: ColorScheme) { cornerRadius: 4, ...text(layer, "sans", "positive", { size: "xs" }), }, - no_remaining_tokens: { + noRemainingTokens: { background: background(layer, "on"), border: border(layer, "on", { overlay: true }), padding: 4, @@ -53,6 +53,11 @@ export default function assistant(colorScheme: ColorScheme) { cornerRadius: 4, ...text(layer, "sans", "negative", { size: "xs" }), }, + errorIcon: { + margin: { left: 8 }, + color: foreground(layer, "negative"), + width: 12, + }, apiKeyEditor: { background: background(layer, "on"), cornerRadius: 6, From 093ce8a9ac6e20ebfdde2786733f3eff8d571fad Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 6 Jun 2023 17:45:58 +0200 Subject: [PATCH 25/32] Simplify prompt Co-Authored-By: Nathan Sobo --- crates/ai/src/assistant.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index b70ed8c87cf657e80fe5dd2c9c80e0372e9687af..f505ea1f3fe4e99e621549c9e174bac98c812000 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -686,8 +686,9 @@ impl Assistant { }) .chain(Some(RequestMessage { role: Role::User, - content: "Summarize the conversation into a short title without punctuation and with as few characters as possible" - .into(), + content: + "Summarize the conversation into a short title without punctuation" + .into(), })) .collect(); let request = OpenAIRequest { From ac7178068fd06bf000525e9d3071e59c36d7de91 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 6 Jun 2023 18:18:04 +0200 Subject: [PATCH 26/32] Include message headers in copied assistant text Co-Authored-By: Nathan Sobo --- crates/ai/src/ai.rs | 11 +++++++++ crates/ai/src/assistant.rs | 46 +++++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 11704de03e6c8b5ecfb2d944e68bf545b45da742..6f26f00c52e19620a3be150cb89df8b4223b8200 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -4,6 +4,7 @@ mod assistant_settings; pub use assistant::AssistantPanel; use gpui::AppContext; use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display}; // Data types for chat completion requests #[derive(Serialize)] @@ -33,6 +34,16 @@ enum Role { System, } +impl Display for Role { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Role::User => write!(f, "User"), + Role::Assistant => write!(f, "Assistant"), + Role::System => write!(f, "System"), + } + } +} + #[derive(Deserialize, Debug)] struct OpenAIResponseStreamEvent { pub id: Option, diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index f505ea1f3fe4e99e621549c9e174bac98c812000..a61ecf202d5737c0f718878dab70b9702dd44101 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -13,14 +13,14 @@ use gpui::{ elements::*, executor::Background, platform::{CursorStyle, MouseButton}, - Action, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Subscription, Task, - View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, + 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}; use serde::Deserialize; use settings::SettingsStore; -use std::{borrow::Cow, cell::RefCell, io, rc::Rc, sync::Arc, time::Duration}; +use std::{borrow::Cow, cell::RefCell, cmp, fmt::Write, io, rc::Rc, sync::Arc, time::Duration}; use util::{post_inc, truncate_and_trailoff, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, @@ -49,6 +49,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(AssistantEditor::assist); cx.capture_action(AssistantEditor::cancel_last_assist); cx.add_action(AssistantEditor::quote_selection); + cx.capture_action(AssistantEditor::copy); cx.add_action(AssistantPanel::save_api_key); cx.add_action(AssistantPanel::reset_api_key); } @@ -949,6 +950,45 @@ impl AssistantEditor { } } + fn copy(&mut self, _: &editor::Copy, cx: &mut ViewContext) { + let editor = self.editor.read(cx); + let assistant = self.assistant.read(cx); + if editor.selections.count() == 1 { + let selection = editor.selections.newest::(cx); + let mut offset = 0; + let mut copied_text = String::new(); + let mut spanned_messages = 0; + for message in &assistant.messages { + let message_range = offset..offset + message.content.read(cx).len() + 1; + + if message_range.start >= selection.range().end { + break; + } else if message_range.end >= selection.range().start { + let range = cmp::max(message_range.start, selection.range().start) + ..cmp::min(message_range.end, selection.range().end); + if !range.is_empty() { + spanned_messages += 1; + write!(&mut copied_text, "## {}\n\n", message.role).unwrap(); + for chunk in assistant.buffer.read(cx).snapshot(cx).text_for_range(range) { + copied_text.push_str(&chunk); + } + copied_text.push('\n'); + } + } + + offset = message_range.end; + } + + if spanned_messages > 1 { + cx.platform() + .write_to_clipboard(ClipboardItem::new(copied_text)); + return; + } + } + + cx.propagate_action(); + } + fn cycle_model(&mut self, cx: &mut ViewContext) { self.assistant.update(cx, |assistant, cx| { let new_model = match assistant.model.as_str() { From ef7ec265c8c288ab3c0d83c0c31d84815b17365d Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 6 Jun 2023 18:45:08 +0200 Subject: [PATCH 27/32] Cycle message roles on click Co-Authored-By: Nathan Sobo --- crates/ai/src/ai.rs | 10 +++ crates/ai/src/assistant.rs | 139 +++++++++++++++++++----------- crates/theme/src/theme.rs | 5 +- styles/src/styleTree/assistant.ts | 3 + 4 files changed, 106 insertions(+), 51 deletions(-) diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 6f26f00c52e19620a3be150cb89df8b4223b8200..40224b3229de1665e3fac89be0d035154e2cf67f 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -34,6 +34,16 @@ enum Role { System, } +impl Role { + pub fn cycle(&mut self) { + *self = match self { + Role::User => Role::Assistant, + Role::Assistant => Role::System, + Role::System => Role::User, + } + } +} + impl Display for Role { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index a61ecf202d5737c0f718878dab70b9702dd44101..4a8319015fd669150019b8caab408e529a17304c 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -485,14 +485,16 @@ impl Assistant { let messages = self .messages .iter() - .map(|message| tiktoken_rs::ChatCompletionRequestMessage { - role: match message.role { - Role::User => "user".into(), - Role::Assistant => "assistant".into(), - Role::System => "system".into(), - }, - content: message.content.read(cx).text(), - name: None, + .filter_map(|message| { + Some(tiktoken_rs::ChatCompletionRequestMessage { + role: match self.messages_metadata.get(&message.excerpt_id)?.role { + Role::User => "user".into(), + Role::Assistant => "assistant".into(), + Role::System => "system".into(), + }, + content: message.content.read(cx).text(), + name: None, + }) }) .collect::>(); let model = self.model.clone(); @@ -529,9 +531,11 @@ impl Assistant { let messages = self .messages .iter() - .map(|message| RequestMessage { - role: message.role, - content: message.content.read(cx).text(), + .filter_map(|message| { + Some(RequestMessage { + role: self.messages_metadata.get(&message.excerpt_id)?.role, + content: message.content.read(cx).text(), + }) }) .collect(); let request = OpenAIRequest { @@ -621,6 +625,13 @@ impl Assistant { } } + fn cycle_message_role(&mut self, excerpt_id: ExcerptId, cx: &mut ModelContext) { + if let Some(metadata) = self.messages_metadata.get_mut(&excerpt_id) { + metadata.role.cycle(); + cx.notify(); + } + } + fn push_message( &mut self, role: Role, @@ -659,7 +670,6 @@ impl Assistant { self.messages.push(Message { excerpt_id, - role, content: content.clone(), }); self.messages_metadata.insert( @@ -681,9 +691,11 @@ impl Assistant { .messages .iter() .take(2) - .map(|message| RequestMessage { - role: message.role, - content: message.content.read(cx).text(), + .filter_map(|message| { + Some(RequestMessage { + role: self.messages_metadata.get(&message.excerpt_id)?.role, + content: message.content.read(cx).text(), + }) }) .chain(Some(RequestMessage { role: Role::User, @@ -753,27 +765,51 @@ impl AssistantEditor { { let assistant = assistant.clone(); move |_editor, params: editor::RenderExcerptHeaderParams, cx| { + enum Sender {} enum ErrorTooltip {} let theme = theme::current(cx); let style = &theme.assistant; - if let Some(metadata) = assistant.read(cx).messages_metadata.get(¶ms.id) + let excerpt_id = params.id; + if let Some(metadata) = assistant + .read(cx) + .messages_metadata + .get(&excerpt_id) + .cloned() { - let sender = match metadata.role { - Role::User => Label::new("You", style.user_sender.text.clone()) - .contained() - .with_style(style.user_sender.container), - Role::Assistant => { - Label::new("Assistant", style.assistant_sender.text.clone()) - .contained() - .with_style(style.assistant_sender.container) + let sender = MouseEventHandler::::new( + params.id.into(), + cx, + |state, _| match metadata.role { + Role::User => { + let style = style.user_sender.style_for(state, false); + Label::new("You", style.text.clone()) + .contained() + .with_style(style.container) + } + Role::Assistant => { + let style = style.assistant_sender.style_for(state, false); + Label::new("Assistant", style.text.clone()) + .contained() + .with_style(style.container) + } + Role::System => { + let style = style.system_sender.style_for(state, false); + Label::new("System", style.text.clone()) + .contained() + .with_style(style.container) + } + }, + ) + .with_cursor_style(CursorStyle::PointingHand) + .on_down(MouseButton::Left, { + let assistant = assistant.clone(); + move |_, _, cx| { + assistant.update(cx, |assistant, cx| { + assistant.cycle_message_role(excerpt_id, cx) + }) } - Role::System => { - Label::new("System", style.assistant_sender.text.clone()) - .contained() - .with_style(style.assistant_sender.container) - } - }; + }); Flex::row() .with_child(sender.aligned()) @@ -786,7 +822,7 @@ impl AssistantEditor { .with_style(style.sent_at.container) .aligned(), ) - .with_children(metadata.error.clone().map(|error| { + .with_children(metadata.error.map(|error| { Svg::new("icons/circle_x_mark_12.svg") .with_color(style.error_icon.color) .constrained() @@ -833,21 +869,22 @@ impl AssistantEditor { self.assistant.update(cx, |assistant, cx| { let editor = self.editor.read(cx); let newest_selection = editor.selections.newest_anchor(); - let role = if newest_selection.head() == Anchor::min() { - assistant.messages.first().map(|message| message.role) + let excerpt_id = if newest_selection.head() == Anchor::min() { + assistant.messages.first().map(|message| message.excerpt_id) } else if newest_selection.head() == Anchor::max() { - assistant.messages.last().map(|message| message.role) + assistant.messages.last().map(|message| message.excerpt_id) } else { - assistant - .messages_metadata - .get(&newest_selection.head().excerpt_id()) - .map(|message| message.role) + Some(newest_selection.head().excerpt_id()) }; - if role.map_or(false, |role| role == Role::Assistant) { - assistant.push_message(Role::User, cx); - } else { - assistant.assist(cx); + if let Some(excerpt_id) = excerpt_id { + if let Some(metadata) = assistant.messages_metadata.get(&excerpt_id) { + if metadata.role == Role::User { + assistant.assist(cx); + } else { + assistant.push_message(Role::User, cx); + } + } } }); } @@ -967,12 +1004,17 @@ impl AssistantEditor { let range = cmp::max(message_range.start, selection.range().start) ..cmp::min(message_range.end, selection.range().end); if !range.is_empty() { - spanned_messages += 1; - write!(&mut copied_text, "## {}\n\n", message.role).unwrap(); - for chunk in assistant.buffer.read(cx).snapshot(cx).text_for_range(range) { - copied_text.push_str(&chunk); + if let Some(metadata) = assistant.messages_metadata.get(&message.excerpt_id) + { + spanned_messages += 1; + write!(&mut copied_text, "## {}\n\n", metadata.role).unwrap(); + for chunk in + assistant.buffer.read(cx).snapshot(cx).text_for_range(range) + { + copied_text.push_str(&chunk); + } + copied_text.push('\n'); } - copied_text.push('\n'); } } @@ -1090,11 +1132,10 @@ impl Item for AssistantEditor { #[derive(Debug)] struct Message { excerpt_id: ExcerptId, - role: Role, content: ModelHandle, } -#[derive(Debug)] +#[derive(Clone, Debug)] struct MessageMetadata { role: Role, sent_at: DateTime, diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 132e37ad1c830155f3713c92d0e224ddbfdc214a..f7df63ca099d9a50cf39c565d9cb658aafe098a1 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -974,8 +974,9 @@ pub struct AssistantStyle { pub container: ContainerStyle, pub header: ContainerStyle, pub sent_at: ContainedText, - pub user_sender: ContainedText, - pub assistant_sender: ContainedText, + pub user_sender: Interactive, + pub assistant_sender: Interactive, + pub system_sender: Interactive, pub model_info_container: ContainerStyle, pub model: Interactive, pub remaining_tokens: ContainedText, diff --git a/styles/src/styleTree/assistant.ts b/styles/src/styleTree/assistant.ts index 4314741fb0b566b5abbc64eaad4af8afad55986c..5e33967b50c30975184ead9094f75d2b3fdbe71d 100644 --- a/styles/src/styleTree/assistant.ts +++ b/styles/src/styleTree/assistant.ts @@ -20,6 +20,9 @@ export default function assistant(colorScheme: ColorScheme) { assistantSender: { ...text(layer, "sans", "accent", { size: "sm", weight: "bold" }), }, + systemSender: { + ...text(layer, "sans", "variant", { size: "sm", weight: "bold" }), + }, sentAt: { margin: { top: 2, left: 8 }, ...text(layer, "sans", "default", { size: "2xs" }), From 16090c35ae02d3af6bf226ca4de2fe21804a6de0 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 6 Jun 2023 19:15:06 +0200 Subject: [PATCH 28/32] Insert reply after assistant message when hitting `cmd-enter` Co-Authored-By: Nathan Sobo --- crates/ai/src/assistant.rs | 104 ++++++++++++++++++++++++++----------- 1 file changed, 73 insertions(+), 31 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 4a8319015fd669150019b8caab408e529a17304c..7f387194b35634f6364869d9798ce76ef5567b37 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -459,7 +459,7 @@ impl Assistant { api_key, buffer, }; - this.push_message(Role::User, cx); + this.insert_message_after(ExcerptId::max(), Role::User, cx); this.count_remaining_tokens(cx); this } @@ -498,7 +498,7 @@ impl Assistant { }) .collect::>(); let model = self.model.clone(); - self.pending_token_count = cx.spawn(|this, mut cx| { + self.pending_token_count = cx.spawn_weak(|this, mut cx| { async move { cx.background().timer(Duration::from_millis(200)).await; let token_count = cx @@ -506,11 +506,13 @@ impl Assistant { .spawn(async move { tiktoken_rs::num_tokens_from_messages(&model, &messages) }) .await?; - this.update(&mut cx, |this, cx| { - this.max_token_count = tiktoken_rs::model::get_context_size(&this.model); - this.token_count = Some(token_count); - cx.notify() - }); + this.upgrade(&cx) + .ok_or_else(|| anyhow!("assistant was dropped"))? + .update(&mut cx, |this, cx| { + this.max_token_count = tiktoken_rs::model::get_context_size(&this.model); + this.token_count = Some(token_count); + cx.notify() + }); anyhow::Ok(()) } .log_err() @@ -547,9 +549,10 @@ impl Assistant { let api_key = self.api_key.borrow().clone(); if let Some(api_key) = api_key { let stream = stream_completion(api_key, cx.background().clone(), request); - let (excerpt_id, content) = self.push_message(Role::Assistant, cx); - self.push_message(Role::User, cx); - let task = cx.spawn(|this, mut cx| async move { + let (excerpt_id, content) = + self.insert_message_after(ExcerptId::max(), Role::Assistant, cx); + self.insert_message_after(ExcerptId::max(), Role::User, cx); + let task = cx.spawn_weak(|this, mut cx| async move { let stream_completion = async { let mut messages = stream.await?; @@ -564,22 +567,26 @@ impl Assistant { } } - this.update(&mut cx, |this, cx| { - this.pending_completions - .retain(|completion| completion.id != this.completion_count); - this.summarize(cx); - }); + this.upgrade(&cx) + .ok_or_else(|| anyhow!("assistant was dropped"))? + .update(&mut cx, |this, cx| { + this.pending_completions + .retain(|completion| completion.id != this.completion_count); + this.summarize(cx); + }); anyhow::Ok(()) }; if let Err(error) = stream_completion.await { - this.update(&mut cx, |this, cx| { - if let Some(metadata) = this.messages_metadata.get_mut(&excerpt_id) { - metadata.error = Some(error.to_string().trim().into()); - cx.notify(); - } - }) + if let Some(this) = this.upgrade(&cx) { + this.update(&mut cx, |this, cx| { + if let Some(metadata) = this.messages_metadata.get_mut(&excerpt_id) { + metadata.error = Some(error.to_string().trim().into()); + cx.notify(); + } + }); + } } }); @@ -632,8 +639,9 @@ impl Assistant { } } - fn push_message( + fn insert_message_after( &mut self, + excerpt_id: ExcerptId, role: Role, cx: &mut ModelContext, ) -> (ExcerptId, ModelHandle) { @@ -654,9 +662,10 @@ impl Assistant { buffer.set_language_registry(self.languages.clone()); buffer }); - let excerpt_id = self.buffer.update(cx, |buffer, cx| { + let new_excerpt_id = self.buffer.update(cx, |buffer, cx| { buffer - .push_excerpts( + .insert_excerpts_after( + excerpt_id, content.clone(), vec![ExcerptRange { context: 0..0, @@ -668,19 +677,27 @@ impl Assistant { .unwrap() }); - self.messages.push(Message { - excerpt_id, - content: content.clone(), - }); + let ix = self + .messages + .iter() + .position(|message| message.excerpt_id == excerpt_id) + .map_or(self.messages.len(), |ix| ix + 1); + self.messages.insert( + ix, + Message { + excerpt_id: new_excerpt_id, + content: content.clone(), + }, + ); self.messages_metadata.insert( - excerpt_id, + new_excerpt_id, MessageMetadata { role, sent_at: Local::now(), error: None, }, ); - (excerpt_id, content) + (new_excerpt_id, content) } fn summarize(&mut self, cx: &mut ModelContext) { @@ -882,7 +899,7 @@ impl AssistantEditor { if metadata.role == Role::User { assistant.assist(cx); } else { - assistant.push_message(Role::User, cx); + assistant.insert_message_after(excerpt_id, Role::User, cx); } } } @@ -1227,3 +1244,28 @@ async fn stream_completion( } } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::AppContext; + + #[gpui::test] + fn test_inserting_and_removing_messages(cx: &mut AppContext) { + let registry = Arc::new(LanguageRegistry::test()); + + cx.add_model(|cx| { + let mut assistant = Assistant::new(Default::default(), registry, cx); + let (excerpt_1, _) = + assistant.insert_message_after(ExcerptId::max(), Role::Assistant, cx); + let (excerpt_2, _) = assistant.insert_message_after(excerpt_1, Role::User, cx); + let (excerpt_3, _) = assistant.insert_message_after(excerpt_1, Role::User, cx); + assistant.remove_empty_messages( + HashSet::from_iter([excerpt_2, excerpt_3]), + Default::default(), + cx, + ); + assistant + }); + } +} From a6feaf1300c3f28f762ed17c5e8d98c3d6a001c4 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 7 Jun 2023 09:24:18 +0200 Subject: [PATCH 29/32] Allow search assistant editors --- crates/ai/src/assistant.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 7f387194b35634f6364869d9798ce76ef5567b37..aee224e420eef6fc32da9f7990d78e86f57e71cc 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -221,6 +221,8 @@ impl AssistantPanel { cx.focus_self(); cx.notify(); } + } else { + cx.propagate_action(); } } @@ -1144,6 +1146,13 @@ impl Item for AssistantEditor { fn tab_tooltip_text(&self, cx: &AppContext) -> Option> { Some(self.title(cx).into()) } + + fn as_searchable( + &self, + _: &ViewHandle, + ) -> Option> { + Some(Box::new(self.editor.clone())) + } } #[derive(Debug)] From 43500dbf6031e5b482e93c4c18ed05dfb0f0fc73 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 7 Jun 2023 10:02:35 +0200 Subject: [PATCH 30/32] Fix zed tests --- crates/zed/src/zed.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index f58ea69b93666d6f38ae162247cb6c4404d6bbfc..ecdd1b7a180cee33fa938a39d901022855fe538a 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2200,6 +2200,7 @@ mod tests { pane::init(cx); project_panel::init(cx); terminal_view::init(cx); + ai::init(cx); app_state }) } From d26cc2c897732ddb1fe2dac34aabd25e4df6b469 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 7 Jun 2023 15:01:50 +0200 Subject: [PATCH 31/32] Maintain scroll bottom when streaming assistant responses --- crates/ai/src/assistant.rs | 205 +++++++++++++++++++++------- crates/editor/src/editor_tests.rs | 6 +- crates/editor/src/items.rs | 10 +- crates/editor/src/scroll.rs | 22 +-- crates/editor/src/scroll/actions.rs | 6 +- crates/vim/src/normal.rs | 2 +- 6 files changed, 176 insertions(+), 75 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index aee224e420eef6fc32da9f7990d78e86f57e71cc..1a6da539fa4667761367d48d585153c6846f40c7 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -5,13 +5,21 @@ use crate::{ use anyhow::{anyhow, Result}; use chrono::{DateTime, Local}; use collections::{HashMap, HashSet}; -use editor::{Anchor, Editor, ExcerptId, ExcerptRange, MultiBuffer}; +use editor::{ + display_map::ToDisplayPoint, + scroll::{ + autoscroll::{Autoscroll, AutoscrollStrategy}, + ScrollAnchor, + }, + Anchor, DisplayPoint, Editor, ExcerptId, ExcerptRange, MultiBuffer, +}; use fs::Fs; use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; use gpui::{ actions, elements::*, executor::Background, + geometry::vector::vec2f, platform::{CursorStyle, MouseButton}, Action, AppContext, AsyncAppContext, ClipboardItem, Entity, ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, @@ -414,6 +422,7 @@ impl Panel for AssistantPanel { enum AssistantEvent { MessagesEdited { ids: Vec }, SummaryChanged, + StreamedCompletion, } struct Assistant { @@ -531,7 +540,7 @@ impl Assistant { cx.notify(); } - fn assist(&mut self, cx: &mut ModelContext) { + fn assist(&mut self, cx: &mut ModelContext) -> Option<(Message, Message)> { let messages = self .messages .iter() @@ -548,24 +557,30 @@ impl Assistant { stream: true, }; - let api_key = self.api_key.borrow().clone(); - if let Some(api_key) = api_key { - let stream = stream_completion(api_key, cx.background().clone(), request); - let (excerpt_id, content) = - self.insert_message_after(ExcerptId::max(), Role::Assistant, cx); - self.insert_message_after(ExcerptId::max(), Role::User, cx); - let task = cx.spawn_weak(|this, mut cx| async move { + let api_key = self.api_key.borrow().clone()?; + let stream = stream_completion(api_key, cx.background().clone(), request); + let assistant_message = self.insert_message_after(ExcerptId::max(), Role::Assistant, cx); + let user_message = self.insert_message_after(ExcerptId::max(), Role::User, cx); + let task = cx.spawn_weak({ + let assistant_message = assistant_message.clone(); + |this, mut cx| async move { + let assistant_message = assistant_message; 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() { - content.update(&mut cx, |content, cx| { + assistant_message.content.update(&mut cx, |content, cx| { let text: Arc = choice.delta.content?.into(); content.edit([(content.len()..content.len(), text)], None, cx); Some(()) }); + this.upgrade(&cx) + .ok_or_else(|| anyhow!("assistant was dropped"))? + .update(&mut cx, |_, cx| { + cx.emit(AssistantEvent::StreamedCompletion); + }); } } @@ -580,23 +595,28 @@ impl Assistant { anyhow::Ok(()) }; - if let Err(error) = 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(&excerpt_id) { + let result = stream_completion.await; + if let Some(this) = this.upgrade(&cx) { + this.update(&mut cx, |this, cx| { + if let Err(error) = result { + if let Some(metadata) = this + .messages_metadata + .get_mut(&assistant_message.excerpt_id) + { metadata.error = Some(error.to_string().trim().into()); cx.notify(); } - }); - } + } + }); } - }); + } + }); - self.pending_completions.push(PendingCompletion { - id: post_inc(&mut self.completion_count), - _task: task, - }); - } + self.pending_completions.push(PendingCompletion { + id: post_inc(&mut self.completion_count), + _task: task, + }); + Some((assistant_message, user_message)) } fn cancel_last_assist(&mut self) -> bool { @@ -646,7 +666,7 @@ impl Assistant { excerpt_id: ExcerptId, role: Role, cx: &mut ModelContext, - ) -> (ExcerptId, ModelHandle) { + ) -> Message { let content = cx.add_model(|cx| { let mut buffer = Buffer::new(0, "", cx); let markdown = self.languages.language_for_name("Markdown"); @@ -684,13 +704,11 @@ impl Assistant { .iter() .position(|message| message.excerpt_id == excerpt_id) .map_or(self.messages.len(), |ix| ix + 1); - self.messages.insert( - ix, - Message { - excerpt_id: new_excerpt_id, - content: content.clone(), - }, - ); + let message = Message { + excerpt_id: new_excerpt_id, + content: content.clone(), + }; + self.messages.insert(ix, message.clone()); self.messages_metadata.insert( new_excerpt_id, MessageMetadata { @@ -699,7 +717,7 @@ impl Assistant { error: None, }, ); - (new_excerpt_id, content) + message } fn summarize(&mut self, cx: &mut ModelContext) { @@ -766,6 +784,7 @@ enum AssistantEditorEvent { struct AssistantEditor { assistant: ModelHandle, editor: ViewHandle, + scroll_bottom: ScrollAnchor, _subscriptions: Vec, } @@ -875,37 +894,64 @@ impl AssistantEditor { let _subscriptions = vec![ cx.observe(&assistant, |_, _, cx| cx.notify()), cx.subscribe(&assistant, Self::handle_assistant_event), + cx.subscribe(&editor, Self::handle_editor_event), ]; Self { assistant, editor, + scroll_bottom: ScrollAnchor { + offset: Default::default(), + anchor: Anchor::max(), + }, _subscriptions, } } fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { - self.assistant.update(cx, |assistant, cx| { + let user_message = self.assistant.update(cx, |assistant, cx| { let editor = self.editor.read(cx); let newest_selection = editor.selections.newest_anchor(); let excerpt_id = if newest_selection.head() == Anchor::min() { - assistant.messages.first().map(|message| message.excerpt_id) + assistant + .messages + .first() + .map(|message| message.excerpt_id)? } else if newest_selection.head() == Anchor::max() { - assistant.messages.last().map(|message| message.excerpt_id) + assistant + .messages + .last() + .map(|message| message.excerpt_id)? } else { - Some(newest_selection.head().excerpt_id()) + newest_selection.head().excerpt_id() }; - if let Some(excerpt_id) = excerpt_id { - if let Some(metadata) = assistant.messages_metadata.get(&excerpt_id) { - if metadata.role == Role::User { - assistant.assist(cx); - } else { - assistant.insert_message_after(excerpt_id, Role::User, cx); - } - } - } + let metadata = assistant.messages_metadata.get(&excerpt_id)?; + let user_message = if metadata.role == Role::User { + let (_, user_message) = assistant.assist(cx)?; + user_message + } else { + let user_message = assistant.insert_message_after(excerpt_id, Role::User, cx); + user_message + }; + Some(user_message) }); + + if let Some(user_message) = user_message { + self.editor.update(cx, |editor, cx| { + let cursor = editor + .buffer() + .read(cx) + .snapshot(cx) + .anchor_in_excerpt(user_message.excerpt_id, language::Anchor::MIN); + editor.change_selections( + Some(Autoscroll::Strategy(AutoscrollStrategy::Fit)), + cx, + |selections| selections.select_anchor_ranges([cursor..cursor]), + ); + }); + self.update_scroll_bottom(cx); + } } fn cancel_last_assist(&mut self, _: &editor::Cancel, cx: &mut ViewContext) { @@ -919,7 +965,7 @@ impl AssistantEditor { fn handle_assistant_event( &mut self, - assistant: ModelHandle, + _: ModelHandle, event: &AssistantEvent, cx: &mut ViewContext, ) { @@ -931,16 +977,70 @@ impl AssistantEditor { .map(|selection| selection.head()) .collect::>(); let ids = ids.iter().copied().collect::>(); - assistant.update(cx, |assistant, cx| { + self.assistant.update(cx, |assistant, cx| { assistant.remove_empty_messages(ids, selection_heads, cx) }); } AssistantEvent::SummaryChanged => { cx.emit(AssistantEditorEvent::TabContentChanged); } + AssistantEvent::StreamedCompletion => { + self.editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + let scroll_bottom_row = self + .scroll_bottom + .anchor + .to_display_point(&snapshot.display_snapshot) + .row(); + + let scroll_bottom = scroll_bottom_row as f32 + self.scroll_bottom.offset.y(); + let visible_line_count = editor.visible_line_count().unwrap_or(0.); + let scroll_top = scroll_bottom - visible_line_count; + editor + .set_scroll_position(vec2f(self.scroll_bottom.offset.x(), scroll_top), cx); + }); + } + } + } + + fn handle_editor_event( + &mut self, + _: ViewHandle, + event: &editor::Event, + cx: &mut ViewContext, + ) { + match event { + editor::Event::ScrollPositionChanged { .. } => self.update_scroll_bottom(cx), + _ => {} } } + fn update_scroll_bottom(&mut self, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + let scroll_position = editor + .scroll_manager + .anchor() + .scroll_position(&snapshot.display_snapshot); + let scroll_bottom = scroll_position.y() + editor.visible_line_count().unwrap_or(0.); + let scroll_bottom_point = cmp::min( + DisplayPoint::new(scroll_bottom.floor() as u32, 0), + snapshot.display_snapshot.max_point(), + ); + let scroll_bottom_anchor = snapshot + .buffer_snapshot + .anchor_after(scroll_bottom_point.to_point(&snapshot.display_snapshot)); + let scroll_bottom_offset = vec2f( + scroll_position.x(), + scroll_bottom - scroll_bottom_point.row() as f32, + ); + self.scroll_bottom = ScrollAnchor { + anchor: scroll_bottom_anchor, + offset: scroll_bottom_offset, + }; + }); + } + fn quote_selection( workspace: &mut Workspace, _: &QuoteSelection, @@ -1155,7 +1255,7 @@ impl Item for AssistantEditor { } } -#[derive(Debug)] +#[derive(Clone, Debug)] struct Message { excerpt_id: ExcerptId, content: ModelHandle, @@ -1265,15 +1365,16 @@ mod tests { cx.add_model(|cx| { let mut assistant = Assistant::new(Default::default(), registry, cx); - let (excerpt_1, _) = - assistant.insert_message_after(ExcerptId::max(), Role::Assistant, cx); - let (excerpt_2, _) = assistant.insert_message_after(excerpt_1, Role::User, cx); - let (excerpt_3, _) = assistant.insert_message_after(excerpt_1, Role::User, cx); + let message_1 = assistant.insert_message_after(ExcerptId::max(), Role::Assistant, cx); + let message_2 = assistant.insert_message_after(message_1.excerpt_id, Role::User, cx); + let message_3 = assistant.insert_message_after(message_1.excerpt_id, Role::User, cx); assistant.remove_empty_messages( - HashSet::from_iter([excerpt_2, excerpt_3]), + HashSet::from_iter([message_2.excerpt_id, message_3.excerpt_id]), Default::default(), cx, ); + assert_eq!(assistant.messages.len(), 1); + assert_eq!(assistant.messages[0].excerpt_id, message_1.excerpt_id); assistant }); } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index dca6f71797e068d39ec3419b150a2e93bc03c60c..a63f3404d3fbc79e4fbe31f8b0a8844a710a3d1b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -579,7 +579,7 @@ async fn test_navigation_history(cx: &mut TestAppContext) { assert_eq!(editor.scroll_manager.anchor(), original_scroll_position); // Ensure we don't panic when navigation data contains invalid anchors *and* points. - let mut invalid_anchor = editor.scroll_manager.anchor().top_anchor; + let mut invalid_anchor = editor.scroll_manager.anchor().anchor; invalid_anchor.text_anchor.buffer_id = Some(999); let invalid_point = Point::new(9999, 0); editor.navigate( @@ -587,7 +587,7 @@ async fn test_navigation_history(cx: &mut TestAppContext) { cursor_anchor: invalid_anchor, cursor_position: invalid_point, scroll_anchor: ScrollAnchor { - top_anchor: invalid_anchor, + anchor: invalid_anchor, offset: Default::default(), }, scroll_top_row: invalid_point.row, @@ -5815,7 +5815,7 @@ async fn test_following(cx: &mut gpui::TestAppContext) { let top_anchor = follower.buffer().read(cx).read(cx).anchor_after(0); follower.set_scroll_anchor( ScrollAnchor { - top_anchor, + anchor: top_anchor, offset: vec2f(0.0, 0.5), }, cx, diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 8da746075e25f7130b739b82d40e33ceb2ef8130..9d639f9b7bd6aeebe619b9382862c75f0347c7ad 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -196,7 +196,7 @@ impl FollowableItem for Editor { singleton: buffer.is_singleton(), title: (!buffer.is_singleton()).then(|| buffer.title(cx).into()), excerpts, - scroll_top_anchor: Some(serialize_anchor(&scroll_anchor.top_anchor)), + scroll_top_anchor: Some(serialize_anchor(&scroll_anchor.anchor)), scroll_x: scroll_anchor.offset.x(), scroll_y: scroll_anchor.offset.y(), selections: self @@ -253,7 +253,7 @@ impl FollowableItem for Editor { } Event::ScrollPositionChanged { .. } => { let scroll_anchor = self.scroll_manager.anchor(); - update.scroll_top_anchor = Some(serialize_anchor(&scroll_anchor.top_anchor)); + update.scroll_top_anchor = Some(serialize_anchor(&scroll_anchor.anchor)); update.scroll_x = scroll_anchor.offset.x(); update.scroll_y = scroll_anchor.offset.y(); true @@ -412,7 +412,7 @@ async fn update_editor_from_message( } else if let Some(scroll_top_anchor) = scroll_top_anchor { editor.set_scroll_anchor_remote( ScrollAnchor { - top_anchor: scroll_top_anchor, + anchor: scroll_top_anchor, offset: vec2f(message.scroll_x, message.scroll_y), }, cx, @@ -510,8 +510,8 @@ impl Item for Editor { }; let mut scroll_anchor = data.scroll_anchor; - if !buffer.can_resolve(&scroll_anchor.top_anchor) { - scroll_anchor.top_anchor = buffer.anchor_before( + if !buffer.can_resolve(&scroll_anchor.anchor) { + scroll_anchor.anchor = buffer.anchor_before( buffer.clip_point(Point::new(data.scroll_top_row, 0), Bias::Left), ); } diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index 21894dea88fbf68f9e93fd120082daa60714e2b7..17e8d18a625434b2fd81f8ea6938c72729aed423 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -36,21 +36,21 @@ pub struct ScrollbarAutoHide(pub bool); #[derive(Clone, Copy, Debug, PartialEq)] pub struct ScrollAnchor { pub offset: Vector2F, - pub top_anchor: Anchor, + pub anchor: Anchor, } impl ScrollAnchor { fn new() -> Self { Self { offset: Vector2F::zero(), - top_anchor: Anchor::min(), + anchor: Anchor::min(), } } pub fn scroll_position(&self, snapshot: &DisplaySnapshot) -> Vector2F { let mut scroll_position = self.offset; - if self.top_anchor != Anchor::min() { - let scroll_top = self.top_anchor.to_display_point(snapshot).row() as f32; + if self.anchor != Anchor::min() { + let scroll_top = self.anchor.to_display_point(snapshot).row() as f32; scroll_position.set_y(scroll_top + scroll_position.y()); } else { scroll_position.set_y(0.); @@ -59,7 +59,7 @@ impl ScrollAnchor { } pub fn top_row(&self, buffer: &MultiBufferSnapshot) -> u32 { - self.top_anchor.to_point(buffer).row + self.anchor.to_point(buffer).row } } @@ -179,7 +179,7 @@ impl ScrollManager { let (new_anchor, top_row) = if scroll_position.y() <= 0. { ( ScrollAnchor { - top_anchor: Anchor::min(), + anchor: Anchor::min(), offset: scroll_position.max(vec2f(0., 0.)), }, 0, @@ -193,7 +193,7 @@ impl ScrollManager { ( ScrollAnchor { - top_anchor, + anchor: top_anchor, offset: vec2f( scroll_position.x(), scroll_position.y() - top_anchor.to_display_point(&map).row() as f32, @@ -322,7 +322,7 @@ impl Editor { hide_hover(self, cx); let workspace_id = self.workspace.as_ref().map(|workspace| workspace.1); let top_row = scroll_anchor - .top_anchor + .anchor .to_point(&self.buffer().read(cx).snapshot(cx)) .row; self.scroll_manager @@ -337,7 +337,7 @@ impl Editor { hide_hover(self, cx); let workspace_id = self.workspace.as_ref().map(|workspace| workspace.1); let top_row = scroll_anchor - .top_anchor + .anchor .to_point(&self.buffer().read(cx).snapshot(cx)) .row; self.scroll_manager @@ -377,7 +377,7 @@ impl Editor { let screen_top = self .scroll_manager .anchor - .top_anchor + .anchor .to_display_point(&snapshot); if screen_top > newest_head { @@ -408,7 +408,7 @@ impl Editor { .anchor_at(Point::new(top_row as u32, 0), Bias::Left); let scroll_anchor = ScrollAnchor { offset: Vector2F::new(x, y), - top_anchor, + anchor: top_anchor, }; self.set_scroll_anchor(scroll_anchor, cx); } diff --git a/crates/editor/src/scroll/actions.rs b/crates/editor/src/scroll/actions.rs index a79b0f24498c27e43969e9fcddd4cf775733a983..da5e3603e7e2326c690ba3e3a80213874cf325b6 100644 --- a/crates/editor/src/scroll/actions.rs +++ b/crates/editor/src/scroll/actions.rs @@ -86,7 +86,7 @@ impl Editor { editor.set_scroll_anchor( ScrollAnchor { - top_anchor: new_anchor, + anchor: new_anchor, offset: Default::default(), }, cx, @@ -113,7 +113,7 @@ impl Editor { editor.set_scroll_anchor( ScrollAnchor { - top_anchor: new_anchor, + anchor: new_anchor, offset: Default::default(), }, cx, @@ -143,7 +143,7 @@ impl Editor { editor.set_scroll_anchor( ScrollAnchor { - top_anchor: new_anchor, + anchor: new_anchor, offset: Default::default(), }, cx, diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 91be1022a3dd7bb448dbbc447dff7175dfdceeb9..1f90d259d3e73801af58621ee4e80d925647e489 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -400,7 +400,7 @@ fn scroll(editor: &mut Editor, amount: &ScrollAmount, cx: &mut ViewContext Date: Wed, 7 Jun 2023 15:24:08 +0200 Subject: [PATCH 32/32] Fix assistant panel tests --- crates/ai/src/assistant.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 1a6da539fa4667761367d48d585153c6846f40c7..77353e1ee497190277218ef747c48a2b2afe3eb4 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -1365,16 +1365,18 @@ mod tests { cx.add_model(|cx| { let mut assistant = Assistant::new(Default::default(), registry, cx); - let message_1 = assistant.insert_message_after(ExcerptId::max(), Role::Assistant, cx); - let message_2 = assistant.insert_message_after(message_1.excerpt_id, Role::User, cx); - let message_3 = assistant.insert_message_after(message_1.excerpt_id, Role::User, cx); + let message_1 = assistant.messages[0].clone(); + let message_2 = assistant.insert_message_after(ExcerptId::max(), Role::Assistant, cx); + let message_3 = assistant.insert_message_after(message_2.excerpt_id, Role::User, cx); + let message_4 = assistant.insert_message_after(message_2.excerpt_id, Role::User, cx); assistant.remove_empty_messages( - HashSet::from_iter([message_2.excerpt_id, message_3.excerpt_id]), + HashSet::from_iter([message_3.excerpt_id, message_4.excerpt_id]), Default::default(), cx, ); - assert_eq!(assistant.messages.len(), 1); + assert_eq!(assistant.messages.len(), 2); assert_eq!(assistant.messages[0].excerpt_id, message_1.excerpt_id); + assert_eq!(assistant.messages[1].excerpt_id, message_2.excerpt_id); assistant }); }