diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 3906391b6a99a21934bdc3414b9adbb52b0ed1e4..ac99bb8f06b471bef170a2a474b2136d13cfd87a 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -395,7 +395,7 @@ impl ToolCall { .unwrap_or(false) } - fn to_markdown(&self, cx: &App) -> String { + pub fn to_markdown(&self, cx: &App) -> String { let mut markdown = format!( "**Tool Call: {}**\nStatus: {}\n\n", self.label.read(cx).source(), diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 29a2566ff17b5111131e2fe89a57888f9aba25c6..43cb0d6390431ad80111124e83e2d51b15f8d437 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -2758,6 +2758,23 @@ impl AcpThreadView { ContextMenu::build(window, cx, move |menu, _, cx| { let is_at_top = entity.read(cx).list_state.logical_scroll_top().item_ix == 0; + let copy_this_agent_response = + ContextMenuEntry::new("Copy This Agent Response").handler({ + let entity = entity.clone(); + move |_, cx| { + entity.update(cx, |this, cx| { + if let Some(thread) = this.thread() { + let entries = thread.read(cx).entries(); + if let Some(text) = + Self::get_agent_message_content(entries, entry_ix, cx) + { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + } + }); + } + }); + let scroll_item = if is_at_top { ContextMenuEntry::new("Scroll to Bottom").handler({ let entity = entity.clone(); @@ -2794,7 +2811,8 @@ impl AcpThreadView { }); menu.when_some(focus, |menu, focus| menu.context(focus)) - .action("Copy", Box::new(markdown::CopyAsMarkdown)) + .action("Copy Selection", Box::new(markdown::CopyAsMarkdown)) + .item(copy_this_agent_response) .separator() .item(scroll_item) .item(open_thread_as_markdown) @@ -7236,6 +7254,59 @@ impl AcpThreadView { self.message_editor.clone() } } + + fn get_agent_message_content( + entries: &[AgentThreadEntry], + entry_index: usize, + cx: &App, + ) -> Option { + let entry = entries.get(entry_index)?; + if matches!(entry, AgentThreadEntry::UserMessage(_)) { + return None; + } + + let start_index = (0..entry_index) + .rev() + .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) + .map(|i| i + 1) + .unwrap_or(0); + + let end_index = (entry_index + 1..entries.len()) + .find(|&i| matches!(entries.get(i), Some(AgentThreadEntry::UserMessage(_)))) + .map(|i| i - 1) + .unwrap_or(entries.len() - 1); + + let parts: Vec = (start_index..=end_index) + .filter_map(|i| entries.get(i)) + .filter_map(|entry| { + if let AgentThreadEntry::AssistantMessage(message) = entry { + let text: String = message + .chunks + .iter() + .filter_map(|chunk| match chunk { + AssistantMessageChunk::Message { block } => { + let markdown = block.to_markdown(cx); + if markdown.trim().is_empty() { + None + } else { + Some(markdown.to_string()) + } + } + AssistantMessageChunk::Thought { .. } => None, + }) + .collect::>() + .join("\n\n"); + + if text.is_empty() { None } else { Some(text) } + } else { + None + } + }) + .collect(); + + let text = parts.join("\n\n"); + if text.is_empty() { None } else { Some(text) } + } } fn loading_contents_spinner(size: IconSize) -> AnyElement {