diff --git a/Cargo.lock b/Cargo.lock index 95fcf2224d..27c3a2fdb1 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 f5f330056a..736f39a984 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 35182dfaa6..5102b7408b 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -188,12 +188,6 @@ "alt-[": "copilot::PreviousSuggestion" } }, - { - "context": "Editor && extension == zmd", - "bindings": { - "cmd-enter": "ai::Assist" - } - }, { "context": "Editor && mode == auto_height", "bindings": { @@ -201,6 +195,12 @@ "cmd-alt-enter": "editor::NewlineBelow" } }, + { + "context": "ContextEditor > Editor", + "bindings": { + "cmd-enter": "assistant::Assist" + } + }, { "context": "BufferSearchBar", "bindings": { @@ -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 b367a4d43c..14817916f4 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 c68f41c6bf..cd42ee1153 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; - } + // 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(); - } - } - }); + 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 0000000000..ef202e7214 --- /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 619dd81a80..24b7d6356b 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(()) })