Start on an assistant panel based on multi-buffers

Nathan Sobo and Antonio Scandurra created

Each message is represented as a multibuffer excerpt to allow for
fluid editing of the conversation transcript.

Co-Authored-By: Antonio Scandurra <antonio@zed.dev>

Change summary

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(-)

Detailed changes

Cargo.lock 🔗

@@ -106,9 +106,13 @@ dependencies = [
  "futures 0.3.28",
  "gpui",
  "isahc",
+ "language",
+ "search",
  "serde",
  "serde_json",
+ "theme",
  "util",
+ "workspace",
 ]
 
 [[package]]

assets/icons/speech_bubble_12.svg 🔗

@@ -1,3 +1,3 @@
-<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M10.6667 0.400196H1.33346C0.819658 0.400196 0.399658 0.820196 0.399658 1.3326V10.6658C0.399658 11.181 0.816998 11.5982 1.33206 11.5982C1.58966 11.5982 1.82206 11.4932 1.99146 11.3238L4.51706 8.79684H10.6639C11.1763 8.79684 11.5963 8.37544 11.5963 7.86304V1.3298C11.5963 0.815996 11.1749 0.395996 10.6625 0.395996L10.6667 0.400196ZM2.2667 2.2664H6.00008V3.1988H2.26628V2.265L2.2667 2.2664ZM7.8667 6.93316H2.2667V5.99936H7.8667V6.93176V6.93316ZM9.7329 5.06556H2.26488V4.13176H9.73164V5.06416L9.7329 5.06556Z" fill="#282C34"/>
+<svg width="12" height="11" viewBox="0 0 12 11" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.01077 0.000234794C2.69085 0.000234794 0.000639074 2.18612 0.000639074 4.88385C0.000639074 6.0491 0.501914 7.11387 1.33823 7.95254C1.04475 9.13517 0.0640321 10.1894 0.0522927 10.2011C-0.00053487 10.2539 -0.0153266 10.3356 0.0170743 10.4061C0.0464229 10.4763 0.111459 10.5185 0.187766 10.5185C1.74324 10.5185 2.89019 9.77286 3.4889 9.31197C4.25431 9.60052 5.10894 9.76722 6.01053 9.76722C9.33045 9.76722 12 7.58063 12 4.88361C12 2.18659 9.33045 0 6.01053 0L6.01077 0.000234794Z" fill="#FAFAFA"/>
 </svg>

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",

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

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<String>,
 }
 
-#[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<Editor>| {
-            assistant.assist(editor, cx).log_err();
-        }
-    });
-    cx.capture_action({
-        let assistant = assistant.clone();
-        move |_: &mut Editor, _: &editor::Cancel, cx: &mut ViewContext<Editor>| {
-            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<Editor>| {
+    //         assistant.assist(editor, cx).log_err();
+    //     }
+    // });
+    // cx.capture_action({
+    //     let assistant = assistant.clone();
+    //     move |_: &mut Editor, _: &editor::Cancel, cx: &mut ViewContext<Editor>| {
+    //         if !assistant.cancel_last_assist(cx.view_id()) {
+    //             cx.propagate_action();
+    //         }
+    //     }
+    // });
 }
 
 type CompletionId = usize;

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<f32>,
+    pane: ViewHandle<Pane>,
+    workspace: WeakViewHandle<Workspace>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl AssistantPanel {
+    pub fn new(workspace: &Workspace, cx: &mut ViewContext<Self>) -> 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<Pane>,
+        event: &pane::Event,
+        cx: &mut ViewContext<Self>,
+    ) {
+        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<Self>) -> AnyElement<Self> {
+        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<Self>) {}
+
+    fn size(&self, cx: &WindowContext) -> f32 {
+        self.width.unwrap_or(480.)
+    }
+
+    fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
+        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>) {
+        self.pane.update(cx, |pane, cx| pane.set_zoomed(zoomed, cx));
+    }
+
+    fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+        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<Box<dyn Action>>) {
+        ("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<Message>,
+    editor: ViewHandle<Editor>,
+}
+
+impl ContextEditor {
+    fn new(cx: &mut ViewContext<Self>) -> 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<Self>) {
+        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<str> = 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<Self>) -> AnyElement<Self> {
+        ChildView::new(&self.editor, cx).into_any()
+    }
+}
+
+impl Item for ContextEditor {
+    fn tab_content<V: View>(
+        &self,
+        _: Option<usize>,
+        style: &theme::Tab,
+        _: &gpui::AppContext,
+    ) -> AnyElement<V> {
+        Label::new("New Context", style.label.clone()).into_any()
+    }
+}
+
+struct Message {
+    role: Role,
+    content: ModelHandle<Buffer>,
+}

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(())
     })