Introduce an Assistant Panel (#2584)

Antonio Scandurra created

This pull request introduces a new assistant panel to Zed that lets
users interact with OpenAI using their API key:

![CleanShot 2023-06-07 at 09 39
10@2x](https://github.com/zed-industries/zed/assets/482957/ba2a5830-9aeb-4c45-a182-a44d6a72675f)

After setting the key up, it will be saved to the keychain and
automatically loaded the next time the assistant panel is opened. The
key can be reset using `assistant: reset key`.

![CleanShot 2023-06-07 at 09 39
23@2x](https://github.com/zed-industries/zed/assets/482957/a6808bb0-0098-45ae-a2e3-f4d88472e626)

From there, users can type messages in a multi-buffer and hit
`cmd-enter` (`assistant: assist`) to stream assistant responses using
the OpenAI API. Responses can be canceled by hitting `escape`.

![CleanShot 2023-06-07 at 09 40
16@2x](https://github.com/zed-industries/zed/assets/482957/749779da-850e-4ad5-af04-74a3ca39f7ad)

Users can quote a selection from the active editor by hitting `cmd->`
(`assistant: quote selection`), which will embed the selected piece of
text in a Markdown fenced code block. Conversations with the assistant
are ephemeral at the moment, but can be easily copy/pasted:

![CleanShot 2023-06-07 at 09 50
33@2x](https://github.com/zed-industries/zed/assets/482957/b3386c10-4c51-4419-a0e0-517112ef6521)

Release Notes:

- Added a new assistant panel feature that enables interacting with
OpenAI using an API key. This replaces the previous experimental `ai:
assist` command that would work on any buffer. The experience is similar
to the one offered by ChatGPT with the added ability to edit, delete or
enhance previous messages. When hitting `cmd-enter`, the assistant will
start streaming responses from OpenAI. A response stream can be canceled
using `escape`. Moreover, the active editor's selection can be quoted in
the assistant panel using `cmd->`, which will automatically embed the
selected piece of text in a Markdown fenced code block.

Change summary

Cargo.lock                                    |   53 
assets/icons/speech_bubble_12.svg             |    4 
assets/keymaps/default.json                   |   14 
assets/settings/default.json                  |    8 
crates/ai/Cargo.toml                          |   15 
crates/ai/src/ai.rs                           |  278 ---
crates/ai/src/assistant.rs                    | 1383 +++++++++++++++++++++
crates/ai/src/assistant_settings.rs           |   40 
crates/editor/src/editor.rs                   |   43 
crates/editor/src/editor_tests.rs             |   29 
crates/editor/src/element.rs                  |  269 ++-
crates/editor/src/items.rs                    |   10 
crates/editor/src/multi_buffer.rs             |   11 
crates/editor/src/scroll.rs                   |   22 
crates/editor/src/scroll/actions.rs           |    6 
crates/editor/src/selections_collection.rs    |    3 
crates/terminal_view/Cargo.toml               |    2 
crates/theme/src/theme.rs                     |   18 
crates/vim/src/normal.rs                      |    2 
crates/workspace/src/dock.rs                  |    6 
crates/workspace/src/pane.rs                  |    3 
crates/workspace/src/workspace.rs             |   10 
crates/zed/src/languages/markdown/config.toml |    2 
crates/zed/src/zed.rs                         |   16 
styles/src/styleTree/app.ts                   |    2 
styles/src/styleTree/assistant.ts             |   85 +
26 files changed, 1,920 insertions(+), 414 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -100,15 +100,24 @@ name = "ai"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "chrono",
  "collections",
  "editor",
+ "fs",
  "futures 0.3.28",
  "gpui",
  "isahc",
- "rust-embed",
+ "language",
+ "menu",
+ "schemars",
+ "search",
  "serde",
  "serde_json",
+ "settings",
+ "theme",
+ "tiktoken-rs",
  "util",
+ "workspace",
 ]
 
 [[package]]
@@ -718,6 +727,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"
@@ -843,6 +867,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c3d4260bcc2e8fc9df1eac4919a720effeb63a3f0952f5bf4944adfa18897f09"
 dependencies = [
  "memchr",
+ "once_cell",
+ "regex-automata",
  "serde",
 ]
 
@@ -2177,6 +2203,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"
@@ -6921,6 +6957,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"

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 🔗

@@ -185,20 +185,22 @@
       ],
       "alt-\\": "copilot::Suggest",
       "alt-]": "copilot::NextSuggestion",
-      "alt-[": "copilot::PreviousSuggestion"
+      "alt-[": "copilot::PreviousSuggestion",
+      "cmd->": "assistant::QuoteSelection"
     }
   },
   {
-    "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": "AssistantEditor > Editor",
     "bindings": {
-      "alt-enter": "editor::Newline",
-      "cmd-alt-enter": "editor::NewlineBelow"
+      "cmd-enter": "assistant::Assist",
+      "cmd->": "assistant::QuoteSelection"
     }
   },
   {

assets/settings/default.json 🔗

@@ -81,6 +81,14 @@
       // 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": 450,
+      // Default height when the assistant is docked to the bottom.
+      "default_height": 320
+  },
   // 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.

crates/ai/Cargo.toml 🔗

@@ -11,15 +11,24 @@ doctest = false
 [dependencies]
 collections = { path = "../collections"}
 editor = { path = "../editor" }
+fs = { path = "../fs" }
 gpui = { path = "../gpui" }
+language = { path = "../language" }
+menu = { path = "../menu" }
+search = { path = "../search" }
+settings = { path = "../settings" }
+theme = { path = "../theme" }
 util = { path = "../util" }
+workspace = { path = "../workspace" }
 
-rust-embed.workspace = true
-serde.workspace = true
-serde_json.workspace = true
 anyhow.workspace = true
+chrono = "0.4"
 futures.workspace = true
 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"] }

crates/ai/src/ai.rs 🔗

@@ -1,29 +1,10 @@
-use anyhow::{anyhow, Result};
-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 serde::{Deserialize, Serialize};
-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};
-
-use rust_embed::RustEmbed;
-use std::str;
+pub mod assistant;
+mod assistant_settings;
 
-#[derive(RustEmbed)]
-#[folder = "../../assets/contexts"]
-#[exclude = "*.DS_Store"]
-pub struct ContextAssets;
-
-actions!(ai, [Assist]);
+pub use assistant::AssistantPanel;
+use gpui::AppContext;
+use serde::{Deserialize, Serialize};
+use std::fmt::{self, Display};
 
 // Data types for chat completion requests
 #[derive(Serialize)]
@@ -45,7 +26,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,
@@ -53,6 +34,26 @@ 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 {
+            Role::User => write!(f, "User"),
+            Role::Assistant => write!(f, "Assistant"),
+            Role::System => write!(f, "System"),
+        }
+    }
+}
+
 #[derive(Deserialize, Debug)]
 struct OpenAIResponseStreamEvent {
     pub id: Option<String>,
@@ -93,228 +94,5 @@ 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();
-            }
-        }
-    });
-}
-
-type CompletionId = usize;
-
-#[derive(Default)]
-struct Assistant(RefCell<AssistantState>);
-
-#[derive(Default)]
-struct AssistantState {
-    assist_stacks: HashMap<usize, Vec<(CompletionId, Task<Option<()>>)>>,
-    next_completion_id: CompletionId,
-}
-
-impl Assistant {
-    fn assist(self: &Rc<Self>, editor: &mut Editor, cx: &mut ViewContext<Editor>) -> 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 = ContextAssets::get("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<str> = 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((assist_id, assist_task));
-
-        Ok(())
-    }
-
-    fn cancel_last_assist(self: &Rc<Self>, 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<Background>,
-    mut request: OpenAIRequest,
-) -> Result<impl Stream<Item = Result<OpenAIResponseStreamEvent>>> {
-    request.stream = true;
-
-    let (tx, rx) = futures::channel::mpsc::unbounded::<Result<OpenAIResponseStreamEvent>>();
-
-    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<String, io::Error>,
-                ) -> Result<Option<OpenAIResponseStreamEvent>> {
-                    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,
-        ))
-    }
+    assistant::init(cx);
 }

crates/ai/src/assistant.rs 🔗

@@ -0,0 +1,1383 @@
+use crate::{
+    assistant_settings::{AssistantDockPosition, AssistantSettings},
+    OpenAIRequest, OpenAIResponseStreamEvent, RequestMessage, Role,
+};
+use anyhow::{anyhow, Result};
+use chrono::{DateTime, Local};
+use collections::{HashMap, HashSet};
+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,
+};
+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, cmp, fmt::Write, io, rc::Rc, sync::Arc, time::Duration};
+use util::{post_inc, truncate_and_trailoff, ResultExt, TryFutureExt};
+use workspace::{
+    dock::{DockPosition, Panel},
+    item::Item,
+    pane, Pane, Workspace,
+};
+
+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::<AssistantSettings>(cx);
+    cx.add_action(
+        |workspace: &mut Workspace, _: &NewContext, cx: &mut ViewContext<Workspace>| {
+            if let Some(this) = workspace.panel::<AssistantPanel>(cx) {
+                this.update(cx, |this, cx| this.add_context(cx))
+            }
+
+            workspace.focus_panel::<AssistantPanel>(cx);
+        },
+    );
+    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);
+}
+
+pub enum AssistantPanelEvent {
+    ZoomIn,
+    ZoomOut,
+    Focus,
+    Close,
+    DockPositionChanged,
+}
+
+pub struct AssistantPanel {
+    width: Option<f32>,
+    height: Option<f32>,
+    pane: ViewHandle<Pane>,
+    api_key: Rc<RefCell<Option<String>>>,
+    api_key_editor: Option<ViewHandle<Editor>>,
+    has_read_credentials: bool,
+    languages: Arc<LanguageRegistry>,
+    fs: Arc<dyn Fs>,
+    subscriptions: Vec<Subscription>,
+}
+
+impl AssistantPanel {
+    pub fn load(
+        workspace: WeakViewHandle<Workspace>,
+        cx: AsyncAppContext,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        cx.spawn(|mut cx| async move {
+            // TODO: deserialize state.
+            workspace.update(&mut cx, |workspace, cx| {
+                cx.add_view::<Self, _>(|cx| {
+                    let weak_self = cx.weak_handle();
+                    let pane = cx.add_view(|cx| {
+                        let mut pane = Pane::new(
+                            workspace.weak_handle(),
+                            workspace.project().clone(),
+                            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| {
+                            let weak_self = weak_self.clone();
+                            Flex::row()
+                                .with_child(Pane::render_tab_bar_button(
+                                    0,
+                                    "icons/plus_12.svg",
+                                    false,
+                                    Some(("New Context".into(), Some(Box::new(NewContext)))),
+                                    cx,
+                                    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(
+                                    1,
+                                    if pane.is_zoomed() {
+                                        "icons/minimize_8.svg"
+                                    } else {
+                                        "icons/maximize_8.svg"
+                                    },
+                                    pane.is_zoomed(),
+                                    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 mut this = Self {
+                        pane,
+                        api_key: Rc::new(RefCell::new(None)),
+                        api_key_editor: None,
+                        has_read_credentials: false,
+                        languages: workspace.app_state().languages.clone(),
+                        fs: workspace.app_state().fs.clone(),
+                        width: None,
+                        height: None,
+                        subscriptions: Default::default(),
+                    };
+
+                    let mut old_dock_position = this.position(cx);
+                    this.subscriptions = vec![
+                        cx.observe(&this.pane, |_, _, cx| cx.notify()),
+                        cx.subscribe(&this.pane, Self::handle_pane_event),
+                        cx.observe_global::<SettingsStore, _>(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);
+                            }
+                        }),
+                    ];
+
+                    this
+                })
+            })
+        })
+    }
+
+    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),
+            _ => {}
+        }
+    }
+
+    fn add_context(&mut self, cx: &mut ViewContext<Self>) {
+        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<AssistantEditor>,
+        event: &AssistantEditorEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            AssistantEditorEvent::TabContentChanged => self.pane.update(cx, |_, cx| cx.notify()),
+        }
+    }
+
+    fn save_api_key(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
+        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.borrow_mut() = Some(api_key);
+                self.api_key_editor.take();
+                cx.focus_self();
+                cx.notify();
+            }
+        } else {
+            cx.propagate_action();
+        }
+    }
+
+    fn reset_api_key(&mut self, _: &ResetKey, cx: &mut ViewContext<Self>) {
+        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<AssistantPanel>) -> ViewHandle<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
+    })
+}
+
+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> {
+        let style = &theme::current(cx).assistant;
+        if let Some(api_key_editor) = self.api_key_editor.as_ref() {
+            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(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<Self>) {
+        if cx.is_self_focused() {
+            if let Some(api_key_editor) = self.api_key_editor.as_ref() {
+                cx.focus(api_key_editor);
+            } else {
+                cx.focus(&self.pane);
+            }
+        }
+    }
+}
+
+impl Panel for AssistantPanel {
+    fn position(&self, cx: &WindowContext) -> DockPosition {
+        match settings::get::<AssistantSettings>(cx).dock {
+            AssistantDockPosition::Left => DockPosition::Left,
+            AssistantDockPosition::Bottom => DockPosition::Bottom,
+            AssistantDockPosition::Right => DockPosition::Right,
+        }
+    }
+
+    fn position_is_valid(&self, _: DockPosition) -> bool {
+        true
+    }
+
+    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
+        settings::update_settings_file::<AssistantSettings>(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, cx: &WindowContext) -> f32 {
+        let settings = settings::get::<AssistantSettings>(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>) {
+        match self.position(cx) {
+            DockPosition::Left | DockPosition::Right => self.width = Some(size),
+            DockPosition::Bottom => self.height = 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 {
+            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()
+                    .read_credentials(OPENAI_API_URL)
+                    .log_err()
+                    .flatten()
+                {
+                    String::from_utf8(api_key).log_err()
+                } else {
+                    None
+                };
+                if let Some(api_key) = api_key {
+                    *self.api_key.borrow_mut() = Some(api_key);
+                } else if self.api_key_editor.is_none() {
+                    self.api_key_editor = Some(build_api_key_editor(cx));
+                    cx.notify();
+                }
+            }
+
+            if self.pane.read(cx).items_len() == 0 {
+                self.add_context(cx);
+            }
+        }
+    }
+
+    fn icon_path(&self) -> &'static str {
+        "icons/speech_bubble_12.svg"
+    }
+
+    fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
+        ("Assistant Panel".into(), Some(Box::new(ToggleFocus)))
+    }
+
+    fn should_change_position_on_event(event: &Self::Event) -> bool {
+        matches!(event, AssistantPanelEvent::DockPositionChanged)
+    }
+
+    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()
+            || self
+                .api_key_editor
+                .as_ref()
+                .map_or(false, |editor| editor.is_focused(cx))
+    }
+
+    fn is_focus_event(event: &Self::Event) -> bool {
+        matches!(event, AssistantPanelEvent::Focus)
+    }
+}
+
+enum AssistantEvent {
+    MessagesEdited { ids: Vec<ExcerptId> },
+    SummaryChanged,
+    StreamedCompletion,
+}
+
+struct Assistant {
+    buffer: ModelHandle<MultiBuffer>,
+    messages: Vec<Message>,
+    messages_metadata: HashMap<ExcerptId, MessageMetadata>,
+    summary: Option<String>,
+    pending_summary: Task<Option<()>>,
+    completion_count: usize,
+    pending_completions: Vec<PendingCompletion>,
+    languages: Arc<LanguageRegistry>,
+    model: String,
+    token_count: Option<usize>,
+    max_token_count: usize,
+    pending_token_count: Task<Option<()>>,
+    api_key: Rc<RefCell<Option<String>>>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl Entity for Assistant {
+    type Event = AssistantEvent;
+}
+
+impl Assistant {
+    fn new(
+        api_key: Rc<RefCell<Option<String>>>,
+        language_registry: Arc<LanguageRegistry>,
+        cx: &mut ModelContext<Self>,
+    ) -> Self {
+        let model = "gpt-3.5-turbo";
+        let buffer = cx.add_model(|_| MultiBuffer::new(0));
+        let mut this = Self {
+            messages: Default::default(),
+            messages_metadata: Default::default(),
+            summary: None,
+            pending_summary: Task::ready(None),
+            completion_count: Default::default(),
+            pending_completions: Default::default(),
+            languages: language_registry,
+            token_count: None,
+            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)],
+            api_key,
+            buffer,
+        };
+        this.insert_message_after(ExcerptId::max(), Role::User, cx);
+        this.count_remaining_tokens(cx);
+        this
+    }
+
+    fn handle_buffer_event(
+        &mut self,
+        _: ModelHandle<MultiBuffer>,
+        event: &editor::multi_buffer::Event,
+        cx: &mut ModelContext<Self>,
+    ) {
+        match event {
+            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() });
+            }
+            _ => {}
+        }
+    }
+
+    fn count_remaining_tokens(&mut self, cx: &mut ModelContext<Self>) {
+        let messages = self
+            .messages
+            .iter()
+            .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::<Vec<_>>();
+        let model = self.model.clone();
+        self.pending_token_count = cx.spawn_weak(|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.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()
+        });
+    }
+
+    fn remaining_tokens(&self) -> Option<isize> {
+        Some(self.max_token_count as isize - self.token_count? as isize)
+    }
+
+    fn set_model(&mut self, model: String, cx: &mut ModelContext<Self>) {
+        self.model = model;
+        self.count_remaining_tokens(cx);
+        cx.notify();
+    }
+
+    fn assist(&mut self, cx: &mut ModelContext<Self>) -> Option<(Message, Message)> {
+        let messages = self
+            .messages
+            .iter()
+            .filter_map(|message| {
+                Some(RequestMessage {
+                    role: self.messages_metadata.get(&message.excerpt_id)?.role,
+                    content: message.content.read(cx).text(),
+                })
+            })
+            .collect();
+        let request = OpenAIRequest {
+            model: self.model.clone(),
+            messages,
+            stream: true,
+        };
+
+        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() {
+                            assistant_message.content.update(&mut cx, |content, cx| {
+                                let text: Arc<str> = 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);
+                                });
+                        }
+                    }
+
+                    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(())
+                };
+
+                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,
+        });
+        Some((assistant_message, user_message))
+    }
+
+    fn cancel_last_assist(&mut self) -> bool {
+        self.pending_completions.pop().is_some()
+    }
+
+    fn remove_empty_messages<'a>(
+        &mut self,
+        excerpts: HashSet<ExcerptId>,
+        protected_offsets: HashSet<usize>,
+        cx: &mut ModelContext<Self>,
+    ) {
+        let mut offset = 0;
+        let mut excerpts_to_remove = Vec::new();
+        self.messages.retain(|message| {
+            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_metadata.remove(&message.excerpt_id);
+                false
+            } else {
+                true
+            }
+        });
+
+        if !excerpts_to_remove.is_empty() {
+            self.buffer.update(cx, |buffer, cx| {
+                buffer.remove_excerpts(excerpts_to_remove, cx)
+            });
+            cx.notify();
+        }
+    }
+
+    fn cycle_message_role(&mut self, excerpt_id: ExcerptId, cx: &mut ModelContext<Self>) {
+        if let Some(metadata) = self.messages_metadata.get_mut(&excerpt_id) {
+            metadata.role.cycle();
+            cx.notify();
+        }
+    }
+
+    fn insert_message_after(
+        &mut self,
+        excerpt_id: ExcerptId,
+        role: Role,
+        cx: &mut ModelContext<Self>,
+    ) -> Message {
+        let content = cx.add_model(|cx| {
+            let mut buffer = Buffer::new(0, "", cx);
+            let markdown = self.languages.language_for_name("Markdown");
+            cx.spawn_weak(|buffer, mut cx| async move {
+                let markdown = markdown.await?;
+                let buffer = buffer
+                    .upgrade(&cx)
+                    .ok_or_else(|| anyhow!("buffer was dropped"))?;
+                buffer.update(&mut cx, |buffer, cx| {
+                    buffer.set_language(Some(markdown), cx)
+                });
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+            buffer.set_language_registry(self.languages.clone());
+            buffer
+        });
+        let new_excerpt_id = self.buffer.update(cx, |buffer, cx| {
+            buffer
+                .insert_excerpts_after(
+                    excerpt_id,
+                    content.clone(),
+                    vec![ExcerptRange {
+                        context: 0..0,
+                        primary: None,
+                    }],
+                    cx,
+                )
+                .pop()
+                .unwrap()
+        });
+
+        let ix = self
+            .messages
+            .iter()
+            .position(|message| message.excerpt_id == excerpt_id)
+            .map_or(self.messages.len(), |ix| ix + 1);
+        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 {
+                role,
+                sent_at: Local::now(),
+                error: None,
+            },
+        );
+        message
+    }
+
+    fn summarize(&mut self, cx: &mut ModelContext<Self>) {
+        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)
+                    .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,
+                        content:
+                            "Summarize the conversation into a short title without punctuation"
+                                .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 {
+    id: usize,
+    _task: Task<()>,
+}
+
+enum AssistantEditorEvent {
+    TabContentChanged,
+}
+
+struct AssistantEditor {
+    assistant: ModelHandle<Assistant>,
+    editor: ViewHandle<Editor>,
+    scroll_bottom: ScrollAnchor,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl AssistantEditor {
+    fn new(
+        api_key: Rc<RefCell<Option<String>>>,
+        language_registry: Arc<LanguageRegistry>,
+        cx: &mut ViewContext<Self>,
+    ) -> 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);
+            editor.set_show_gutter(false, cx);
+            editor.set_render_excerpt_header(
+                {
+                    let assistant = assistant.clone();
+                    move |_editor, params: editor::RenderExcerptHeaderParams, cx| {
+                        enum Sender {}
+                        enum ErrorTooltip {}
+
+                        let theme = theme::current(cx);
+                        let style = &theme.assistant;
+                        let excerpt_id = params.id;
+                        if let Some(metadata) = assistant
+                            .read(cx)
+                            .messages_metadata
+                            .get(&excerpt_id)
+                            .cloned()
+                        {
+                            let sender = MouseEventHandler::<Sender, _>::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)
+                                    })
+                                }
+                            });
+
+                            Flex::row()
+                                .with_child(sender.aligned())
+                                .with_child(
+                                    Label::new(
+                                        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.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::<ErrorTooltip>(
+                                            params.id.into(),
+                                            error,
+                                            None,
+                                            theme.tooltip.clone(),
+                                            cx,
+                                        )
+                                        .aligned()
+                                }))
+                                .aligned()
+                                .left()
+                                .contained()
+                                .with_style(style.header)
+                                .into_any()
+                        } else {
+                            Empty::new().into_any()
+                        }
+                    }
+                },
+                cx,
+            );
+            editor
+        });
+
+        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>) {
+        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)?
+            } else if newest_selection.head() == Anchor::max() {
+                assistant
+                    .messages
+                    .last()
+                    .map(|message| message.excerpt_id)?
+            } else {
+                newest_selection.head().excerpt_id()
+            };
+
+            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<Self>) {
+        if !self
+            .assistant
+            .update(cx, |assistant, _| assistant.cancel_last_assist())
+        {
+            cx.propagate_action();
+        }
+    }
+
+    fn handle_assistant_event(
+        &mut self,
+        _: ModelHandle<Assistant>,
+        event: &AssistantEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            AssistantEvent::MessagesEdited { ids } => {
+                let selections = self.editor.read(cx).selections.all::<usize>(cx);
+                let selection_heads = selections
+                    .iter()
+                    .map(|selection| selection.head())
+                    .collect::<HashSet<usize>>();
+                let ids = ids.iter().copied().collect::<HashSet<_>>();
+                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<Editor>,
+        event: &editor::Event,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            editor::Event::ScrollPositionChanged { .. } => self.update_scroll_bottom(cx),
+            _ => {}
+        }
+    }
+
+    fn update_scroll_bottom(&mut self, cx: &mut ViewContext<Self>) {
+        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,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        let Some(panel) = workspace.panel::<AssistantPanel>(cx) else {
+            return;
+        };
+        let Some(editor) = workspace.active_item(cx).and_then(|item| item.downcast::<Editor>()) else {
+            return;
+        };
+
+        let text = editor.read_with(cx, |editor, cx| {
+            let range = editor.selections.newest::<usize>(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::<String>();
+            if selected_text.is_empty() {
+                None
+            } else {
+                Some(if language_name == "markdown" {
+                    selected_text
+                        .lines()
+                        .map(|line| format!("> {}", line))
+                        .collect::<Vec<_>>()
+                        .join("\n")
+                } else {
+                    format!("```{language_name}\n{selected_text}\n```")
+                })
+            }
+        });
+
+        // Activate the panel
+        if !panel.read(cx).has_focus(cx) {
+            workspace.toggle_panel_focus::<AssistantPanel>(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::<AssistantEditor>())
+                    .ok_or_else(|| anyhow!("no active context"))
+                    .log_err()
+                {
+                    assistant.update(cx, |assistant, cx| {
+                        assistant
+                            .editor
+                            .update(cx, |editor, cx| editor.insert(&text, cx))
+                    });
+                }
+            });
+        }
+    }
+
+    fn copy(&mut self, _: &editor::Copy, cx: &mut ViewContext<Self>) {
+        let editor = self.editor.read(cx);
+        let assistant = self.assistant.read(cx);
+        if editor.selections.count() == 1 {
+            let selection = editor.selections.newest::<usize>(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() {
+                        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');
+                        }
+                    }
+                }
+
+                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>) {
+        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);
+        });
+    }
+
+    fn title(&self, cx: &AppContext) -> String {
+        self.assistant
+            .read(cx)
+            .summary
+            .clone()
+            .unwrap_or_else(|| "New Context".into())
+    }
+}
+
+impl Entity for AssistantEditor {
+    type Event = AssistantEditorEvent;
+}
+
+impl View for AssistantEditor {
+    fn ui_name() -> &'static str {
+        "AssistantEditor"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        enum Model {}
+        let theme = &theme::current(cx).assistant;
+        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(
+                ChildView::new(&self.editor, cx)
+                    .contained()
+                    .with_style(theme.container),
+            )
+            .with_child(
+                Flex::row()
+                    .with_child(
+                        MouseEventHandler::<Model, _>::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()
+    }
+
+    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if cx.is_self_focused() {
+            cx.focus(&self.editor);
+        }
+    }
+}
+
+impl Item for AssistantEditor {
+    fn tab_content<V: View>(
+        &self,
+        _: Option<usize>,
+        style: &theme::Tab,
+        cx: &gpui::AppContext,
+    ) -> AnyElement<V> {
+        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<Cow<str>> {
+        Some(self.title(cx).into())
+    }
+
+    fn as_searchable(
+        &self,
+        _: &ViewHandle<Self>,
+    ) -> Option<Box<dyn workspace::searchable::SearchableItemHandle>> {
+        Some(Box::new(self.editor.clone()))
+    }
+}
+
+#[derive(Clone, Debug)]
+struct Message {
+    excerpt_id: ExcerptId,
+    content: ModelHandle<Buffer>,
+}
+
+#[derive(Clone, Debug)]
+struct MessageMetadata {
+    role: Role,
+    sent_at: DateTime<Local>,
+    error: Option<String>,
+}
+
+async fn stream_completion(
+    api_key: String,
+    executor: Arc<Background>,
+    mut request: OpenAIRequest,
+) -> Result<impl Stream<Item = Result<OpenAIResponseStreamEvent>>> {
+    request.stream = true;
+
+    let (tx, rx) = futures::channel::mpsc::unbounded::<Result<OpenAIResponseStreamEvent>>();
+
+    let json_data = serde_json::to_string(&request)?;
+    let mut response = Request::post(format!("{OPENAI_API_URL}/chat/completions"))
+        .header("Content-Type", "application/json")
+        .header("Authorization", format!("Bearer {}", api_key))
+        .body(json_data)?
+        .send_async()
+        .await?;
+
+    let status = response.status();
+    if status == StatusCode::OK {
+        executor
+            .spawn(async move {
+                let mut lines = BufReader::new(response.body_mut()).lines();
+
+                fn parse_line(
+                    line: Result<String, io::Error>,
+                ) -> Result<Option<OpenAIResponseStreamEvent>> {
+                    if let Some(data) = line?.strip_prefix("data: ") {
+                        let event = serde_json::from_str(&data)?;
+                        Ok(Some(event))
+                    } else {
+                        Ok(None)
+                    }
+                }
+
+                while let Some(line) = lines.next().await {
+                    if let Some(event) = parse_line(line).transpose() {
+                        let done = event.as_ref().map_or(false, |event| {
+                            event
+                                .choices
+                                .last()
+                                .map_or(false, |choice| choice.finish_reason.is_some())
+                        });
+                        if tx.unbounded_send(event).is_err() {
+                            break;
+                        }
+
+                        if done {
+                            break;
+                        }
+                    }
+                }
+
+                anyhow::Ok(())
+            })
+            .detach();
+
+        Ok(rx)
+    } else {
+        let mut body = String::new();
+        response.body_mut().read_to_string(&mut body).await?;
+
+        #[derive(Deserialize)]
+        struct OpenAIResponse {
+            error: OpenAIError,
+        }
+
+        #[derive(Deserialize)]
+        struct OpenAIError {
+            message: String,
+        }
+
+        match serde_json::from_str::<OpenAIResponse>(&body) {
+            Ok(response) if !response.error.message.is_empty() => Err(anyhow!(
+                "Failed to connect to OpenAI API: {}",
+                response.error.message,
+            )),
+
+            _ => Err(anyhow!(
+                "Failed to connect to OpenAI API: {} {}",
+                response.status(),
+                body,
+            )),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    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 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_3.excerpt_id, message_4.excerpt_id]),
+                Default::default(),
+                cx,
+            );
+            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
+        });
+    }
+}

crates/ai/src/assistant_settings.rs 🔗

@@ -0,0 +1,40 @@
+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,
+}
+
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+pub struct AssistantSettingsContent {
+    pub dock: Option<AssistantDockPosition>,
+    pub default_width: Option<f32>,
+    pub default_height: Option<f32>,
+}
+
+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> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}

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;
@@ -31,11 +31,13 @@ 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,
 };
 use futures::FutureExt;
 use fuzzy::{StringMatch, StringMatchCandidate};
+use gpui::LayoutContext;
 use gpui::{
     actions,
     color::Color,
@@ -507,7 +509,9 @@ pub struct Editor {
     blink_manager: ModelHandle<BlinkManager>,
     show_local_selections: bool,
     mode: EditorMode,
+    show_gutter: bool,
     placeholder_text: Option<Arc<str>>,
+    render_excerpt_header: Option<element::RenderExcerptHeader>,
     highlighted_rows: Option<Range<u32>>,
     #[allow(clippy::type_complexity)]
     background_highlights: BTreeMap<TypeId, (fn(&Theme) -> Color, Vec<Range<Anchor>>)>,
@@ -537,6 +541,7 @@ pub struct Editor {
 
 pub struct EditorSnapshot {
     pub mode: EditorMode,
+    pub show_gutter: bool,
     pub display_snapshot: DisplaySnapshot,
     pub placeholder_text: Option<Arc<str>>,
     is_focused: bool,
@@ -1310,7 +1315,9 @@ impl Editor {
             blink_manager: blink_manager.clone(),
             show_local_selections: true,
             mode,
+            show_gutter: mode == EditorMode::Full,
             placeholder_text: None,
+            render_excerpt_header: None,
             highlighted_rows: None,
             background_highlights: Default::default(),
             nav_history: None,
@@ -1406,6 +1413,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(),
@@ -2386,7 +2394,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::<Vec<_>>()
@@ -3561,7 +3569,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;
                     }
                 })
             });
@@ -6764,6 +6774,25 @@ impl Editor {
         cx.notify();
     }
 
+    pub fn set_show_gutter(&mut self, show_gutter: bool, cx: &mut ViewContext<Self>) {
+        self.show_gutter = show_gutter;
+        cx.notify();
+    }
+
+    pub fn set_render_excerpt_header(
+        &mut self,
+        render_excerpt_header: impl 'static
+            + Fn(
+                &mut Editor,
+                RenderExcerptHeaderParams,
+                &mut LayoutContext<Editor>,
+            ) -> AnyElement<Editor>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.render_excerpt_header = Some(Arc::new(render_excerpt_header));
+        cx.notify();
+    }
+
     pub fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
         if let Some(buffer) = self.buffer().read(cx).as_singleton() {
             if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) {
@@ -6988,7 +7017,7 @@ impl Editor {
             multi_buffer::Event::DiagnosticsUpdated => {
                 self.refresh_active_diagnostics(cx);
             }
-            multi_buffer::Event::LanguageChanged => {}
+            _ => {}
         }
     }
 
@@ -7402,8 +7431,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()
     }

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,
@@ -5277,7 +5277,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)]
+        );
     });
 }
 
@@ -5794,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,

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<text::Anchor>,
+    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<Editor>,
+    ) -> AnyElement<Editor>,
+>;
+
 pub struct EditorElement {
     style: Arc<EditorStyle>,
+    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<PositionMap>,
@@ -1465,11 +1488,9 @@ impl EditorElement {
         line_height: f32,
         style: &EditorStyle,
         line_layouts: &[LineWithInvisibles],
-        include_root: bool,
         editor: &mut Editor,
         cx: &mut LayoutContext<Editor>,
     ) -> (f32, Vec<BlockLayout>) {
-        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::<JumpIcon, _>::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::<JumpIcon>(
-                            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(
@@ -1899,7 +1826,7 @@ impl Element<Editor> 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;
@@ -2080,12 +2007,6 @@ impl Element<Editor> 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<DisplayPoint>, Color)> = fold_ranges
             .into_iter()
             .map(|(id, fold)| {
@@ -2144,7 +2065,6 @@ impl Element<Editor> 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<Editor>,
+) -> AnyElement<Editor> {
+    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::<JumpIcon, _>::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::<JumpIcon>(
+            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,

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),
                 );
             }

crates/editor/src/multi_buffer.rs 🔗

@@ -64,6 +64,9 @@ pub enum Event {
     ExcerptsRemoved {
         ids: Vec<ExcerptId>,
     },
+    ExcerptsEdited {
+        ids: Vec<ExcerptId>,
+    },
     Edited,
     Reloaded,
     DiffBaseChanged,
@@ -394,6 +397,7 @@ impl MultiBuffer {
             original_indent_column: u32,
         }
         let mut buffer_edits: HashMap<u64, Vec<BufferEdit>> = Default::default();
+        let mut edited_excerpt_ids = Vec::new();
         let mut cursor = snapshot.excerpts.cursor::<usize>();
         for (ix, (range, new_text)) in edits.enumerate() {
             let new_text: Arc<str> = new_text.into();
@@ -410,6 +414,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() {
@@ -435,6 +440,7 @@ impl MultiBuffer {
                         original_indent_column,
                     });
             } else {
+                edited_excerpt_ids.push(end_excerpt.id);
                 let start_excerpt_range = buffer_start
                     ..start_excerpt
                         .range
@@ -481,6 +487,7 @@ impl MultiBuffer {
                             is_insertion: false,
                             original_indent_column,
                         });
+                    edited_excerpt_ids.push(excerpt.id);
                     cursor.next(&());
                 }
             }
@@ -553,6 +560,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<Self>) -> Option<TransactionId> {

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

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,

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<Anchor>]> {
         self.disjoint.clone()
     }

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"] }

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,
@@ -968,6 +969,23 @@ pub struct TerminalStyle {
     pub dim_foreground: Color,
 }
 
+#[derive(Clone, Deserialize, Default)]
+pub struct AssistantStyle {
+    pub container: ContainerStyle,
+    pub header: ContainerStyle,
+    pub sent_at: ContainedText,
+    pub user_sender: Interactive<ContainedText>,
+    pub assistant_sender: Interactive<ContainedText>,
+    pub system_sender: Interactive<ContainedText>,
+    pub model_info_container: ContainerStyle,
+    pub model: Interactive<ContainedText>,
+    pub remaining_tokens: ContainedText,
+    pub no_remaining_tokens: ContainedText,
+    pub error_icon: Icon,
+    pub api_key_editor: FieldEditor,
+    pub api_key_prompt: ContainedText,
+}
+
 #[derive(Clone, Deserialize, Default)]
 pub struct FeedbackStyle {
     pub submit_button: Interactive<ContainedText>,

crates/vim/src/normal.rs 🔗

@@ -400,7 +400,7 @@ fn scroll(editor: &mut Editor, amount: &ScrollAmount, cx: &mut ViewContext<Edito
         };
 
         let scroll_margin_rows = editor.vertical_scroll_margin() as u32;
-        let top_anchor = editor.scroll_manager.anchor().top_anchor;
+        let top_anchor = editor.scroll_manager.anchor().anchor;
 
         editor.change_selections(None, cx, |s| {
             s.replace_cursors_with(|snapshot| {

crates/workspace/src/dock.rs 🔗

@@ -188,6 +188,12 @@ impl Dock {
             .map_or(false, |panel| panel.has_focus(cx))
     }
 
+    pub fn panel<T: Panel>(&self) -> Option<ViewHandle<T>> {
+        self.panel_entries
+            .iter()
+            .find_map(|entry| entry.panel.as_any().clone().downcast())
+    }
+
     pub fn panel_index_for_type<T: Panel>(&self) -> Option<usize> {
         self.panel_entries
             .iter()

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 =

crates/workspace/src/workspace.rs 🔗

@@ -1673,6 +1673,16 @@ impl Workspace {
         None
     }
 
+    pub fn panel<T: Panel>(&self, cx: &WindowContext) -> Option<ViewHandle<T>> {
+        for dock in [&self.left_dock, &self.bottom_dock, &self.right_dock] {
+            let dock = dock.read(cx);
+            if let Some(panel) = dock.panel::<T>() {
+                return Some(panel);
+            }
+        }
+        None
+    }
+
     fn zoom_out(&mut self, cx: &mut ViewContext<Self>) {
         for pane in &self.panes {
             pane.update(cx, |pane, cx| pane.set_zoomed(false, cx));

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

crates/zed/src/zed.rs 🔗

@@ -4,6 +4,7 @@ pub mod menus;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
+use ai::AssistantPanel;
 use anyhow::Context;
 use assets::Assets;
 use breadcrumbs::Breadcrumbs;
@@ -253,6 +254,13 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
             workspace.toggle_panel_focus::<TerminalPanel>(cx);
         },
     );
+    cx.add_action(
+        |workspace: &mut Workspace,
+         _: &ai::assistant::ToggleFocus,
+         cx: &mut ViewContext<Workspace>| {
+            workspace.toggle_panel_focus::<AssistantPanel>(cx);
+        },
+    );
     cx.add_global_action({
         let app_state = Arc::downgrade(&app_state);
         move |_: &NewWindow, cx: &mut AppContext| {
@@ -358,7 +366,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);
@@ -376,7 +386,8 @@ pub fn initialize_workspace(
                 workspace.toggle_dock(project_panel_position, cx);
             }
 
-            workspace.add_panel(terminal_panel, cx)
+            workspace.add_panel(terminal_panel, cx);
+            workspace.add_panel(assistant_panel, cx);
         })?;
         Ok(())
     })
@@ -2189,6 +2200,7 @@ mod tests {
             pane::init(cx);
             project_panel::init(cx);
             terminal_view::init(cx);
+            ai::init(cx);
             app_state
         })
     }

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,

styles/src/styleTree/assistant.ts 🔗

@@ -0,0 +1,85 @@
+import { ColorScheme } from "../themes/common/colorScheme"
+import { text, border, background, foreground } from "./components"
+import editor from "./editor"
+
+export default function assistant(colorScheme: ColorScheme) {
+    const layer = colorScheme.highest;
+    return {
+      container: {
+        background: editor(colorScheme).background,
+        padding: { left: 12 }
+      },
+      header: {
+        border: border(layer, "default", { bottom: true, top: true }),
+        margin: { bottom: 6, top: 6 },
+        background: editor(colorScheme).background
+      },
+      userSender: {
+        ...text(layer, "sans", "default", { size: "sm", weight: "bold" }),
+      },
+      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" }),
+      },
+      modelInfoContainer: {
+        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"),
+        }
+      },
+      remainingTokens: {
+        background: background(layer, "on"),
+        border: border(layer, "on", { overlay: true }),
+        padding: 4,
+        margin: { left: 4 },
+        cornerRadius: 4,
+        ...text(layer, "sans", "positive", { size: "xs" }),
+      },
+      noRemainingTokens: {
+        background: background(layer, "on"),
+        border: border(layer, "on", { overlay: true }),
+        padding: 4,
+        margin: { left: 4 },
+        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,
+          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" }),
+      }
+    }
+}