From 39da72161f6bd9472b11725f587ee989072618f4 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 9 May 2025 21:53:11 -0300 Subject: [PATCH] agent: Make terminal command render with Markdown in the tool card (#30430) Closes https://github.com/zed-industries/zed/issues/30411 Rendering as markdown gives us text selection and copying for free. In the future, we may want to explore having these commands be actual editors, allowing you to step in, change the command, and re-run it right from there. Release Notes: - agent: Made the terminal command in the tool card selectable and copyable. --- crates/agent/src/active_thread.rs | 3 + crates/assistant_tools/src/edit_file_tool.rs | 2 +- crates/assistant_tools/src/terminal_tool.rs | 78 +++++++++++++++++--- crates/editor/src/code_context_menus.rs | 1 + crates/editor/src/hover_popover.rs | 1 + crates/markdown/src/markdown.rs | 35 ++++++++- 6 files changed, 108 insertions(+), 12 deletions(-) diff --git a/crates/agent/src/active_thread.rs b/crates/agent/src/active_thread.rs index 9f466883cc..841482e482 100644 --- a/crates/agent/src/active_thread.rs +++ b/crates/agent/src/active_thread.rs @@ -2400,6 +2400,7 @@ impl ActiveThread { markdown_element.code_block_renderer( markdown::CodeBlockRenderer::Default { copy_button: false, + copy_button_on_hover: false, border: true, }, ) @@ -2719,6 +2720,7 @@ impl ActiveThread { ) .code_block_renderer(markdown::CodeBlockRenderer::Default { copy_button: false, + copy_button_on_hover: false, border: false, }) .on_url_click({ @@ -2749,6 +2751,7 @@ impl ActiveThread { ) .code_block_renderer(markdown::CodeBlockRenderer::Default { copy_button: false, + copy_button_on_hover: false, border: false, }) .on_url_click({ diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index bbc92ea735..8c60f980da 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -637,7 +637,7 @@ impl ToolCard for EditFileToolCard { .p_3() .gap_1() .border_t_1() - .rounded_md() + .rounded_b_md() .border_color(border_color) .bg(cx.theme().colors().editor_background); diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index d8415415e9..5ca65741ac 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -2,13 +2,18 @@ use crate::schema::json_schema_for; use anyhow::{Context as _, Result, anyhow, bail}; use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus}; use futures::{FutureExt as _, future::Shared}; -use gpui::{AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, WeakEntity, Window}; +use gpui::{ + AnyWindowHandle, App, AppContext, Empty, Entity, EntityId, Task, TextStyleRefinement, + WeakEntity, Window, +}; use language::LineEnding; use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; +use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use project::{Project, terminals::TerminalKind}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::Settings; use std::{ env, path::{Path, PathBuf}, @@ -17,6 +22,7 @@ use std::{ time::{Duration, Instant}, }; use terminal_view::TerminalView; +use theme::ThemeSettings; use ui::{Disclosure, Tooltip, prelude::*}; use util::{ get_system_shell, markdown::MarkdownInlineCode, size::format_file_size, @@ -211,8 +217,21 @@ impl Tool for TerminalTool { } }); + let command_markdown = cx.new(|cx| { + Markdown::new( + format!("```bash\n{}\n```", input.command).into(), + None, + None, + cx, + ) + }); + let card = cx.new(|cx| { - TerminalToolCard::new(input.command.clone(), working_dir.clone(), cx.entity_id()) + TerminalToolCard::new( + command_markdown.clone(), + working_dir.clone(), + cx.entity_id(), + ) }); let output = cx.spawn({ @@ -388,7 +407,7 @@ fn working_dir( } struct TerminalToolCard { - input_command: String, + input_command: Entity, working_dir: Option, entity_id: EntityId, exit_status: Option, @@ -404,7 +423,11 @@ struct TerminalToolCard { } impl TerminalToolCard { - pub fn new(input_command: String, working_dir: Option, entity_id: EntityId) -> Self { + pub fn new( + input_command: Entity, + working_dir: Option, + entity_id: EntityId, + ) -> Self { Self { input_command, working_dir, @@ -427,7 +450,7 @@ impl ToolCard for TerminalToolCard { fn render( &mut self, status: &ToolUseStatus, - _window: &mut Window, + window: &mut Window, _workspace: WeakEntity, cx: &mut Context, ) -> impl IntoElement { @@ -571,11 +594,25 @@ impl ToolCard for TerminalToolCard { .rounded_lg() .overflow_hidden() .child( - v_flex().p_2().gap_0p5().bg(header_bg).child(header).child( - Label::new(self.input_command.clone()) - .buffer_font(cx) - .size(LabelSize::Small), - ), + v_flex() + .p_2() + .gap_0p5() + .bg(header_bg) + .text_xs() + .child(header) + .child( + MarkdownElement::new( + self.input_command.clone(), + markdown_style(window, cx), + ) + .code_block_renderer( + markdown::CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: true, + border: false, + }, + ), + ), ) .when(self.preview_expanded && !should_hide_terminal, |this| { this.child( @@ -594,6 +631,27 @@ impl ToolCard for TerminalToolCard { } } +fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle { + let theme_settings = ThemeSettings::get_global(cx); + let buffer_font_size = TextSize::Default.rems(cx); + let mut text_style = window.text_style(); + + text_style.refine(&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()), + color: Some(cx.theme().colors().text), + ..Default::default() + }); + + MarkdownStyle { + base_text_style: text_style.clone(), + selection_background_color: cx.theme().players().local().selection, + ..Default::default() + } +} + #[cfg(test)] mod tests { use editor::EditorSettings; diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 84c8042d74..4f97ec04ef 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -640,6 +640,7 @@ impl CompletionsMenu { MarkdownElement::new(markdown.clone(), hover_markdown_style(window, cx)) .code_block_renderer(markdown::CodeBlockRenderer::Default { copy_button: false, + copy_button_on_hover: false, border: false, }) .on_url_click(open_markdown_url), diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 297abd5c37..d741a980c0 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -897,6 +897,7 @@ impl InfoPopover { MarkdownElement::new(markdown, hover_markdown_style(window, cx)) .code_block_renderer(markdown::CodeBlockRenderer::Default { copy_button: false, + copy_button_on_hover: false, border: false, }) .on_url_click(open_markdown_url), diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index f9d9c8871c..a3a8e7c456 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -113,6 +113,7 @@ struct Options { pub enum CodeBlockRenderer { Default { copy_button: bool, + copy_button_on_hover: bool, border: bool, }, Custom { @@ -444,6 +445,7 @@ impl MarkdownElement { style, code_block_renderer: CodeBlockRenderer::Default { copy_button: true, + copy_button_on_hover: false, border: false, }, on_url_click: None, @@ -815,7 +817,7 @@ impl Element for MarkdownElement { (CodeBlockRenderer::Default { .. }, _) | (_, true) => { // This is a parent container that we can position the copy button inside. builder.push_div( - div().relative().w_full(), + div().group("code_block").relative().w_full(), range, markdown_end, ); @@ -1066,6 +1068,37 @@ impl Element for MarkdownElement { }); } + if let CodeBlockRenderer::Default { + copy_button_on_hover: true, + .. + } = &self.code_block_renderer + { + builder.modify_current_div(|el| { + let content_range = parser::extract_code_block_content_range( + parsed_markdown.source()[range.clone()].trim(), + ); + let content_range = content_range.start + range.start + ..content_range.end + range.start; + + let code = parsed_markdown.source()[content_range].to_string(); + let codeblock = render_copy_code_block_button( + range.end, + code, + self.markdown.clone(), + cx, + ); + el.child( + div() + .absolute() + .top_0() + .right_0() + .w_5() + .visible_on_hover("code_block") + .child(codeblock), + ) + }); + } + // Pop the parent container. builder.pop_div(); }