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.
This commit is contained in:
Danilo Leal 2025-05-09 21:53:11 -03:00 committed by GitHub
parent daa777440d
commit 39da72161f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 108 additions and 12 deletions

View file

@ -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({

View file

@ -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);

View file

@ -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<Markdown>,
working_dir: Option<PathBuf>,
entity_id: EntityId,
exit_status: Option<ExitStatus>,
@ -404,7 +423,11 @@ struct TerminalToolCard {
}
impl TerminalToolCard {
pub fn new(input_command: String, working_dir: Option<PathBuf>, entity_id: EntityId) -> Self {
pub fn new(
input_command: Entity<Markdown>,
working_dir: Option<PathBuf>,
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<Workspace>,
cx: &mut Context<Self>,
) -> 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;

View file

@ -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),

View file

@ -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),

View file

@ -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();
}