message markdown

Agus Zubiaga and Conrad Irwin created

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

Cargo.lock                    |   2 
crates/acp/Cargo.toml         |   3 
crates/acp/src/acp.rs         | 101 ++++++++++++++++------
crates/acp/src/server.rs      |  42 ++-------
crates/acp/src/thread_view.rs | 160 +++++++++++++++++++++++++++++++++---
5 files changed, 230 insertions(+), 78 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -17,11 +17,13 @@ dependencies = [
  "futures 0.3.31",
  "gpui",
  "language",
+ "markdown",
  "parking_lot",
  "project",
  "serde_json",
  "settings",
  "smol",
+ "theme",
  "ui",
  "util",
  "uuid",

crates/acp/Cargo.toml 🔗

@@ -26,9 +26,12 @@ editor.workspace = true
 futures.workspace = true
 gpui.workspace = true
 language.workspace = true
+markdown.workspace = true
 parking_lot.workspace = true
 project.workspace = true
+settings.workspace = true
 smol.workspace = true
+theme.workspace = true
 ui.workspace = true
 util.workspace = true
 uuid.workspace = true

crates/acp/src/acp.rs 🔗

@@ -1,11 +1,15 @@
 mod server;
 mod thread_view;
 
+use agentic_coding_protocol::{self as acp, Role};
 use anyhow::Result;
 use chrono::{DateTime, Utc};
-use gpui::{Context, Entity, SharedString, Task};
+use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task};
+use language::LanguageRegistry;
+use markdown::Markdown;
 use project::Project;
 use std::{ops::Range, path::PathBuf, sync::Arc};
+use ui::App;
 
 pub use server::AcpServer;
 pub use thread_view::AcpThreadView;
@@ -30,23 +34,29 @@ pub struct FileContent {
     pub content: SharedString,
 }
 
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
-pub enum Role {
-    User,
-    Assistant,
-}
-
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub struct Message {
-    pub role: Role,
+    pub role: acp::Role,
     pub chunks: Vec<MessageChunk>,
 }
 
+impl Message {
+    fn into_acp(self, cx: &App) -> acp::Message {
+        acp::Message {
+            role: self.role,
+            chunks: self
+                .chunks
+                .into_iter()
+                .map(|chunk| chunk.into_acp(cx))
+                .collect(),
+        }
+    }
+}
+
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub enum MessageChunk {
     Text {
-        // todo! should it be shared string? what about streaming?
-        chunk: String,
+        chunk: Entity<Markdown>,
     },
     File {
         content: FileContent,
@@ -68,10 +78,36 @@ pub enum MessageChunk {
     },
 }
 
-impl From<&str> for MessageChunk {
-    fn from(chunk: &str) -> Self {
+impl MessageChunk {
+    pub fn from_acp(
+        chunk: acp::MessageChunk,
+        language_registry: Arc<LanguageRegistry>,
+        cx: &mut App,
+    ) -> Self {
+        match chunk {
+            acp::MessageChunk::Text { chunk } => MessageChunk::Text {
+                chunk: cx.new(|cx| Markdown::new(chunk.into(), Some(language_registry), None, cx)),
+            },
+        }
+    }
+
+    pub fn into_acp(self, cx: &App) -> acp::MessageChunk {
+        match self {
+            MessageChunk::Text { chunk } => acp::MessageChunk::Text {
+                chunk: chunk.read(cx).source().to_string(),
+            },
+            MessageChunk::File { .. } => todo!(),
+            MessageChunk::Directory { .. } => todo!(),
+            MessageChunk::Symbol { .. } => todo!(),
+            MessageChunk::Fetch { .. } => todo!(),
+        }
+    }
+
+    pub fn from_str(chunk: &str, language_registry: Arc<LanguageRegistry>, cx: &mut App) -> Self {
         MessageChunk::Text {
-            chunk: chunk.to_string().into(),
+            chunk: cx.new(|cx| {
+                Markdown::new(chunk.to_owned().into(), Some(language_registry), None, cx)
+            }),
         }
     }
 }
@@ -156,7 +192,7 @@ impl AcpThread {
         cx.emit(AcpThreadEvent::NewEntry)
     }
 
-    pub fn push_assistant_chunk(&mut self, chunk: MessageChunk, cx: &mut Context<Self>) {
+    pub fn push_assistant_chunk(&mut self, chunk: acp::MessageChunk, cx: &mut Context<Self>) {
         if let Some(last_entry) = self.entries.last_mut()
             && let AgentThreadEntryContent::Message(Message {
                 ref mut chunks,
@@ -167,33 +203,46 @@ impl AcpThread {
 
             if let (
                 Some(MessageChunk::Text { chunk: old_chunk }),
-                MessageChunk::Text { chunk: new_chunk },
+                acp::MessageChunk::Text { chunk: new_chunk },
             ) = (chunks.last_mut(), &chunk)
             {
-                old_chunk.push_str(&new_chunk);
+                old_chunk.update(cx, |old_chunk, cx| {
+                    old_chunk.append(&new_chunk, cx);
+                });
             } else {
-                chunks.push(chunk);
-                return cx.notify();
+                chunks.push(MessageChunk::from_acp(
+                    chunk,
+                    self.project.read(cx).languages().clone(),
+                    cx,
+                ));
             }
 
             return;
         }
 
+        let chunk = MessageChunk::from_acp(chunk, self.project.read(cx).languages().clone(), cx);
+
         self.push_entry(
             AgentThreadEntryContent::Message(Message {
                 role: Role::Assistant,
                 chunks: vec![chunk],
             }),
-        });
-        cx.notify();
+            cx,
+        );
     }
 
-    pub fn send(&mut self, message: Message, cx: &mut Context<Self>) -> Task<Result<()>> {
+    pub fn send(&mut self, message: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
         let agent = self.server.clone();
         let id = self.id.clone();
+        let chunk = MessageChunk::from_str(message, self.project.read(cx).languages().clone(), cx);
+        let message = Message {
+            role: Role::User,
+            chunks: vec![chunk],
+        };
         self.push_entry(AgentThreadEntryContent::Message(message.clone()), cx);
+        let acp_message = message.into_acp(cx);
         cx.spawn(async move |_, cx| {
-            agent.send_message(id, message, cx).await?;
+            agent.send_message(id, acp_message, cx).await?;
             Ok(())
         })
     }
@@ -237,13 +286,7 @@ mod tests {
         thread
             .update(cx, |thread, cx| {
                 thread.send(
-                    Message {
-                        role: Role::User,
-                        chunks: vec![
-                            "Read the '/private/tmp/foo' file and output all of its contents."
-                                .into(),
-                        ],
-                    },
+                    "Read the '/private/tmp/foo' file and output all of its contents.",
                     cx,
                 )
             })

crates/acp/src/server.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{AcpThread, AgentThreadEntryContent, MessageChunk, Role, ThreadEntryId, ThreadId};
+use crate::{AcpThread, AgentThreadEntryContent, ThreadEntryId, ThreadId};
 use agentic_coding_protocol as acp;
 use anyhow::{Context as _, Result};
 use async_trait::async_trait;
@@ -42,7 +42,7 @@ impl AcpClientDelegate {
         &self,
         thread_id: &ThreadId,
         cx: &mut App,
-        callback: impl FnMut(&mut AcpThread, &mut Context<AcpThread>) -> R,
+        callback: impl FnOnce(&mut AcpThread, &mut Context<AcpThread>) -> R,
     ) -> Option<R> {
         let thread = self.threads.lock().get(&thread_id)?.clone();
         let Some(thread) = thread.upgrade() else {
@@ -80,18 +80,11 @@ impl acp::Client for AcpClientDelegate {
         &self,
         params: acp::StreamMessageChunkParams,
     ) -> Result<acp::StreamMessageChunkResponse> {
-        dbg!();
         let cx = &mut self.cx.clone();
 
         cx.update(|cx| {
             self.update_thread(&params.thread_id.into(), cx, |thread, cx| {
-                let acp::MessageChunk::Text { chunk } = &params.chunk;
-                thread.push_assistant_chunk(
-                    MessageChunk::Text {
-                        chunk: chunk.into(),
-                    },
-                    cx,
-                )
+                thread.push_assistant_chunk(params.chunk, cx)
             });
         })?;
 
@@ -186,7 +179,10 @@ impl acp::Client for AcpClientDelegate {
         })
     }
 
-    async fn glob_search(&self, request: acp::GlobSearchParams) -> Result<acp::GlobSearchResponse> {
+    async fn glob_search(
+        &self,
+        _request: acp::GlobSearchParams,
+    ) -> Result<acp::GlobSearchResponse> {
         todo!()
     }
 }
@@ -238,31 +234,13 @@ impl AcpServer {
     pub async fn send_message(
         &self,
         thread_id: ThreadId,
-        message: crate::Message,
-        cx: &mut AsyncApp,
+        message: acp::Message,
+        _cx: &mut AsyncApp,
     ) -> Result<()> {
         self.connection
             .request(acp::SendMessageParams {
                 thread_id: thread_id.clone().into(),
-                message: acp::Message {
-                    role: match message.role {
-                        Role::User => acp::Role::User,
-                        Role::Assistant => acp::Role::Assistant,
-                    },
-                    chunks: message
-                        .chunks
-                        .into_iter()
-                        .map(|chunk| match chunk {
-                            MessageChunk::Text { chunk } => acp::MessageChunk::Text {
-                                chunk: chunk.into(),
-                            },
-                            MessageChunk::File { .. } => todo!(),
-                            MessageChunk::Directory { .. } => todo!(),
-                            MessageChunk::Symbol { .. } => todo!(),
-                            MessageChunk::Fetch { .. } => todo!(),
-                        })
-                        .collect(),
-                },
+                message,
             })
             .await?;
         Ok(())

crates/acp/src/thread_view.rs 🔗

@@ -1,18 +1,21 @@
+use std::rc::Rc;
+
 use anyhow::Result;
 use editor::{Editor, MultiBuffer};
 use gpui::{
-    App, Empty, Entity, Focusable, ListState, SharedString, Subscription, Window, div, list,
-    prelude::*,
+    App, EdgesRefinement, Empty, Entity, Focusable, ListState, SharedString, StyleRefinement,
+    Subscription, TextStyleRefinement, UnderlineStyle, Window, div, list, prelude::*,
 };
 use gpui::{FocusHandle, Task};
 use language::Buffer;
+use markdown::{HeadingLevelStyles, MarkdownElement, MarkdownStyle};
+use settings::Settings as _;
+use theme::ThemeSettings;
 use ui::Tooltip;
 use ui::prelude::*;
 use zed_actions::agent::Chat;
 
-use crate::{
-    AcpThread, AcpThreadEvent, AgentThreadEntryContent, Message, MessageChunk, Role, ThreadEntry,
-};
+use crate::{AcpThread, AcpThreadEvent, AgentThreadEntryContent, MessageChunk, Role, ThreadEntry};
 
 pub struct AcpThreadView {
     thread: Entity<AcpThread>,
@@ -93,13 +96,7 @@ impl AcpThreadView {
             return;
         }
 
-        let task = self.thread.update(cx, |thread, cx| {
-            let message = Message {
-                role: Role::User,
-                chunks: vec![MessageChunk::Text { chunk: text.into() }],
-            };
-            thread.send(message, cx)
-        });
+        let task = self.thread.update(cx, |thread, cx| thread.send(&text, cx));
 
         self.send_task = Some(cx.spawn(async move |this, cx| {
             task.await?;
@@ -117,16 +114,21 @@ impl AcpThreadView {
     fn render_entry(
         &self,
         entry: &ThreadEntry,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &Context<Self>,
     ) -> AnyElement {
         match &entry.content {
             AgentThreadEntryContent::Message(message) => {
+                let style = if message.role == Role::User {
+                    user_message_markdown_style(window, cx)
+                } else {
+                    default_markdown_style(window, cx)
+                };
                 let message_body = div()
                     .children(message.chunks.iter().map(|chunk| match chunk {
                         MessageChunk::Text { chunk } => {
-                            // todo! markdown
-                            Label::new(chunk.clone())
+                            // todo!() open link
+                            MarkdownElement::new(chunk.clone(), style.clone())
                         }
                         _ => todo!(),
                     }))
@@ -134,7 +136,8 @@ impl AcpThreadView {
 
                 match message.role {
                     Role::User => div()
-                        .my_1()
+                        .text_xs()
+                        .m_1()
                         .p_2()
                         .bg(cx.theme().colors().editor_background)
                         .rounded_lg()
@@ -143,7 +146,12 @@ impl AcpThreadView {
                         .border_color(cx.theme().colors().border)
                         .child(message_body)
                         .into_any(),
-                    Role::Assistant => message_body,
+                    Role::Assistant => div()
+                        .text_ui(cx)
+                        .px_2()
+                        .py_4()
+                        .child(message_body)
+                        .into_any(),
                 }
             }
             AgentThreadEntryContent::ReadFile { path, content: _ } => {
@@ -237,3 +245,121 @@ impl Render for AcpThreadView {
             )
     }
 }
+
+fn user_message_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
+    let mut style = default_markdown_style(window, cx);
+    let mut text_style = window.text_style();
+    let theme_settings = ThemeSettings::get_global(cx);
+
+    let buffer_font = theme_settings.buffer_font.family.clone();
+    let buffer_font_size = TextSize::Small.rems(cx);
+
+    text_style.refine(&TextStyleRefinement {
+        font_family: Some(buffer_font),
+        font_size: Some(buffer_font_size.into()),
+        ..Default::default()
+    });
+
+    style.base_text_style = text_style;
+    style
+}
+
+fn default_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
+    let theme_settings = ThemeSettings::get_global(cx);
+    let colors = cx.theme().colors();
+    let ui_font_size = TextSize::Default.rems(cx);
+    let buffer_font_size = TextSize::Small.rems(cx);
+    let mut text_style = window.text_style();
+    let line_height = buffer_font_size * 1.75;
+
+    text_style.refine(&TextStyleRefinement {
+        font_family: Some(theme_settings.ui_font.family.clone()),
+        font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
+        font_features: Some(theme_settings.ui_font.features.clone()),
+        font_size: Some(ui_font_size.into()),
+        line_height: Some(line_height.into()),
+        color: Some(cx.theme().colors().text),
+        ..Default::default()
+    });
+
+    MarkdownStyle {
+        base_text_style: text_style.clone(),
+        syntax: cx.theme().syntax().clone(),
+        selection_background_color: cx.theme().colors().element_selection_background,
+        code_block_overflow_x_scroll: true,
+        table_overflow_x_scroll: true,
+        heading_level_styles: Some(HeadingLevelStyles {
+            h1: Some(TextStyleRefinement {
+                font_size: Some(rems(1.15).into()),
+                ..Default::default()
+            }),
+            h2: Some(TextStyleRefinement {
+                font_size: Some(rems(1.1).into()),
+                ..Default::default()
+            }),
+            h3: Some(TextStyleRefinement {
+                font_size: Some(rems(1.05).into()),
+                ..Default::default()
+            }),
+            h4: Some(TextStyleRefinement {
+                font_size: Some(rems(1.).into()),
+                ..Default::default()
+            }),
+            h5: Some(TextStyleRefinement {
+                font_size: Some(rems(0.95).into()),
+                ..Default::default()
+            }),
+            h6: Some(TextStyleRefinement {
+                font_size: Some(rems(0.875).into()),
+                ..Default::default()
+            }),
+        }),
+        code_block: StyleRefinement {
+            padding: EdgesRefinement {
+                top: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
+                left: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
+                right: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
+                bottom: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(Pixels(8.)))),
+            },
+            background: Some(colors.editor_background.into()),
+            text: Some(TextStyleRefinement {
+                font_family: Some(theme_settings.buffer_font.family.clone()),
+                font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
+                font_features: Some(theme_settings.buffer_font.features.clone()),
+                font_size: Some(buffer_font_size.into()),
+                ..Default::default()
+            }),
+            ..Default::default()
+        },
+        inline_code: TextStyleRefinement {
+            font_family: Some(theme_settings.buffer_font.family.clone()),
+            font_fallbacks: theme_settings.buffer_font.fallbacks.clone(),
+            font_features: Some(theme_settings.buffer_font.features.clone()),
+            font_size: Some(buffer_font_size.into()),
+            background_color: Some(colors.editor_foreground.opacity(0.08)),
+            ..Default::default()
+        },
+        link: TextStyleRefinement {
+            background_color: Some(colors.editor_foreground.opacity(0.025)),
+            underline: Some(UnderlineStyle {
+                color: Some(colors.text_accent.opacity(0.5)),
+                thickness: px(1.),
+                ..Default::default()
+            }),
+            ..Default::default()
+        },
+        link_callback: Some(Rc::new(move |_url, _cx| {
+            // todo!()
+            // if MentionLink::is_valid(url) {
+            //     let colors = cx.theme().colors();
+            //     Some(TextStyleRefinement {
+            //         background_color: Some(colors.element_background),
+            //         ..Default::default()
+            //     })
+            // } else {
+            None
+            // }
+        })),
+        ..Default::default()
+    }
+}