acp tools: Add button to copy all observed messages (#40076)

Yordis Prieto and Agus Zubiaga created

Added a "Copy All Messages" button to the ACP logs toolbar that copies
all messages in the watched stream to the clipboard as structured JSON.

## Motivation

When troubleshooting ACP protocol implementations, it's helpful to
provide the entire message thread to an LLM for analysis. Previously, I
had to copy individual messages one at a time, which was tedious and
time-consuming. This feature allows copying the entire conversation
history in a single click.

Release Notes:

- Added: Copy All Messages button to ACP logs view

---------

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Co-authored-by: Agus Zubiaga <agus@zed.dev>

Change summary

crates/acp_tools/src/acp_tools.rs | 129 +++++++++++++++++++++++++++++++-
crates/zed/src/zed.rs             |   2 
2 files changed, 127 insertions(+), 4 deletions(-)

Detailed changes

crates/acp_tools/src/acp_tools.rs 🔗

@@ -4,22 +4,26 @@ use std::{
     fmt::Display,
     rc::{Rc, Weak},
     sync::Arc,
+    time::Duration,
 };
 
 use agent_client_protocol as acp;
 use collections::HashMap;
 use gpui::{
-    App, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment, ListState,
-    StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list, prelude::*,
+    App, ClipboardItem, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment,
+    ListState, StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list,
+    prelude::*,
 };
 use language::LanguageRegistry;
 use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle};
 use project::Project;
 use settings::Settings;
 use theme::ThemeSettings;
-use ui::prelude::*;
+use ui::{Tooltip, prelude::*};
 use util::ResultExt as _;
-use workspace::{Item, Workspace};
+use workspace::{
+    Item, ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
+};
 
 actions!(dev, [OpenAcpLogs]);
 
@@ -227,6 +231,34 @@ impl AcpTools {
         cx.notify();
     }
 
+    fn serialize_observed_messages(&self) -> Option<String> {
+        let connection = self.watched_connection.as_ref()?;
+
+        let messages: Vec<serde_json::Value> = connection
+            .messages
+            .iter()
+            .filter_map(|message| {
+                let params = match &message.params {
+                    Ok(Some(params)) => params.clone(),
+                    Ok(None) => serde_json::Value::Null,
+                    Err(err) => serde_json::to_value(err).ok()?,
+                };
+                Some(serde_json::json!({
+                    "_direction": match message.direction {
+                        acp::StreamMessageDirection::Incoming => "incoming",
+                        acp::StreamMessageDirection::Outgoing => "outgoing",
+                    },
+                    "_type": message.message_type.to_string().to_lowercase(),
+                    "id": message.request_id,
+                    "method": message.name.to_string(),
+                    "params": params,
+                }))
+            })
+            .collect();
+
+        serde_json::to_string_pretty(&messages).ok()
+    }
+
     fn render_message(
         &mut self,
         index: usize,
@@ -492,3 +524,92 @@ impl Render for AcpTools {
             })
     }
 }
+
+pub struct AcpToolsToolbarItemView {
+    acp_tools: Option<Entity<AcpTools>>,
+    just_copied: bool,
+}
+
+impl AcpToolsToolbarItemView {
+    pub fn new() -> Self {
+        Self {
+            acp_tools: None,
+            just_copied: false,
+        }
+    }
+}
+
+impl Render for AcpToolsToolbarItemView {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let Some(acp_tools) = self.acp_tools.as_ref() else {
+            return Empty.into_any_element();
+        };
+
+        let acp_tools = acp_tools.clone();
+
+        h_flex()
+            .gap_2()
+            .child(
+                IconButton::new(
+                    "copy_all_messages",
+                    if self.just_copied {
+                        IconName::Check
+                    } else {
+                        IconName::Copy
+                    },
+                )
+                .icon_size(IconSize::Small)
+                .tooltip(Tooltip::text(if self.just_copied {
+                    "Copied!"
+                } else {
+                    "Copy All Messages"
+                }))
+                .disabled(
+                    acp_tools
+                        .read(cx)
+                        .watched_connection
+                        .as_ref()
+                        .is_none_or(|connection| connection.messages.is_empty()),
+                )
+                .on_click(cx.listener(move |this, _, _window, cx| {
+                    if let Some(content) = acp_tools.read(cx).serialize_observed_messages() {
+                        cx.write_to_clipboard(ClipboardItem::new_string(content));
+
+                        this.just_copied = true;
+                        cx.spawn(async move |this, cx| {
+                            cx.background_executor().timer(Duration::from_secs(2)).await;
+                            this.update(cx, |this, cx| {
+                                this.just_copied = false;
+                                cx.notify();
+                            })
+                        })
+                        .detach();
+                    }
+                })),
+            )
+            .into_any()
+    }
+}
+
+impl EventEmitter<ToolbarItemEvent> for AcpToolsToolbarItemView {}
+
+impl ToolbarItemView for AcpToolsToolbarItemView {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> ToolbarItemLocation {
+        if let Some(item) = active_pane_item
+            && let Some(acp_tools) = item.downcast::<AcpTools>()
+        {
+            self.acp_tools = Some(acp_tools);
+            cx.notify();
+            return ToolbarItemLocation::PrimaryRight;
+        }
+        if self.acp_tools.take().is_some() {
+            cx.notify();
+        }
+        ToolbarItemLocation::Hidden
+    }
+}

crates/zed/src/zed.rs 🔗

@@ -1041,6 +1041,8 @@ fn initialize_pane(
             toolbar.add_item(lsp_log_item, window, cx);
             let dap_log_item = cx.new(|_| debugger_tools::DapLogToolbarItemView::new());
             toolbar.add_item(dap_log_item, window, cx);
+            let acp_tools_item = cx.new(|_| acp_tools::AcpToolsToolbarItemView::new());
+            toolbar.add_item(acp_tools_item, window, cx);
             let syntax_tree_item = cx.new(|_| language_tools::SyntaxTreeToolbarItemView::new());
             toolbar.add_item(syntax_tree_item, window, cx);
             let migration_banner = cx.new(|cx| MigrationBanner::new(workspace, cx));