From 4d803fa62822b4879327d449702397d49e0aac80 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 1 Jul 2025 16:57:22 -0300 Subject: [PATCH] message markdown Co-authored-by: Conrad Irwin --- 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(-) diff --git a/Cargo.lock b/Cargo.lock index 42c2e2559e6669178a09c47f8ad6c36e0d61ba73..e076350c08ecfaeca23a64bfd53479e1ae963cff 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/acp/Cargo.toml b/crates/acp/Cargo.toml index cd8be590b46f9ab2c248bc02085de4ce827ab658..3d85c3bd4239285c487ebabd7356846f528ee18e 100644 --- a/crates/acp/Cargo.toml +++ b/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 diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index 64b23b2541ec5863935ee1c0349976f033eb903c..77f4097a728e8ee25d9fa977686f5eff453867ab 100644 --- a/crates/acp/src/acp.rs +++ b/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, } +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, }, 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, + 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, 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) { + pub fn push_assistant_chunk(&mut self, chunk: acp::MessageChunk, cx: &mut Context) { 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) -> Task> { + pub fn send(&mut self, message: &str, cx: &mut Context) -> Task> { 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, ) }) diff --git a/crates/acp/src/server.rs b/crates/acp/src/server.rs index 57f700c3126d52f19601fd99382cdc6f0e9a692c..323f6bf2d0f496f4fa322b73ca240b28c4196d0b 100644 --- a/crates/acp/src/server.rs +++ b/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) -> R, + callback: impl FnOnce(&mut AcpThread, &mut Context) -> R, ) -> Option { 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 { - dbg!(); let cx = &mut self.cx.clone(); cx.update(|cx| { self.update_thread(¶ms.thread_id.into(), cx, |thread, cx| { - let acp::MessageChunk::Text { chunk } = ¶ms.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 { + async fn glob_search( + &self, + _request: acp::GlobSearchParams, + ) -> Result { 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(()) diff --git a/crates/acp/src/thread_view.rs b/crates/acp/src/thread_view.rs index 59af84f39b6aace88000f52c1eff1cbddf81b9fe..314bcecdbb45bd3a5a1f6c87060b0f8a7d6dadc2 100644 --- a/crates/acp/src/thread_view.rs +++ b/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, @@ -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, ) -> 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() + } +}