agent: Use Markdown to render tool input and output content (#28127)

Release Notes:

- agent: Tool call's input and output content are now rendered with
Markdown, which allows them to be selected and copied.

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
This commit is contained in:
Danilo Leal 2025-04-04 18:53:21 -03:00 committed by GitHub
parent b8d05bb641
commit 288da0f072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 161 additions and 37 deletions

View file

@ -49,7 +49,7 @@ pub struct ActiveThread {
show_scrollbar: bool,
hide_scrollbar_task: Option<Task<()>>,
rendered_messages_by_id: HashMap<MessageId, RenderedMessage>,
rendered_tool_use_labels: HashMap<LanguageModelToolUseId, Entity<Markdown>>,
rendered_tool_uses: HashMap<LanguageModelToolUseId, RenderedToolUse>,
editing_message: Option<(MessageId, EditMessageState)>,
expanded_tool_uses: HashMap<LanguageModelToolUseId, bool>,
expanded_thinking_segments: HashMap<(MessageId, usize), bool>,
@ -65,6 +65,13 @@ struct RenderedMessage {
segments: Vec<RenderedMessageSegment>,
}
#[derive(Clone)]
struct RenderedToolUse {
label: Entity<Markdown>,
input: Entity<Markdown>,
output: Entity<Markdown>,
}
impl RenderedMessage {
fn from_segments(
segments: &[MessageSegment],
@ -235,7 +242,7 @@ fn render_markdown(
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.1)),
background_color: Some(colors.editor_foreground.opacity(0.08)),
..Default::default()
},
link: TextStyleRefinement {
@ -270,6 +277,74 @@ fn render_markdown(
})
}
fn render_tool_use_markdown(
text: SharedString,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
window: &Window,
cx: &mut App,
) -> Entity<Markdown> {
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();
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()),
color: Some(cx.theme().colors().text),
..Default::default()
});
let markdown_style = MarkdownStyle {
base_text_style: text_style,
syntax: cx.theme().syntax().clone(),
selection_background_color: cx.theme().players().local().selection,
code_block_overflow_x_scroll: true,
code_block: StyleRefinement {
margin: EdgesRefinement::default(),
padding: EdgesRefinement::default(),
background: Some(colors.editor_background.into()),
border_color: None,
border_widths: EdgesRefinement::default(),
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(TextSize::XSmall.rems(cx).into()),
..Default::default()
},
heading: StyleRefinement {
text: Some(TextStyleRefinement {
font_size: Some(ui_font_size.into()),
..Default::default()
}),
..Default::default()
},
..Default::default()
};
cx.new(|cx| {
Markdown::new(text, markdown_style, Some(language_registry), None, cx).open_url(
move |text, window, cx| {
open_markdown_link(text, workspace.clone(), window, cx);
},
)
})
}
fn open_markdown_link(
text: SharedString,
workspace: WeakEntity<Workspace>,
@ -374,7 +449,7 @@ impl ActiveThread {
save_thread_task: None,
messages: Vec::new(),
rendered_messages_by_id: HashMap::default(),
rendered_tool_use_labels: HashMap::default(),
rendered_tool_uses: HashMap::default(),
expanded_tool_uses: HashMap::default(),
expanded_thinking_segments: HashMap::default(),
list_state: list_state.clone(),
@ -393,9 +468,11 @@ impl ActiveThread {
this.push_message(&message.id, &message.segments, window, cx);
for tool_use in thread.read(cx).tool_uses_for_message(message.id, cx) {
this.render_tool_use_label_markdown(
this.render_tool_use_markdown(
tool_use.id.clone(),
tool_use.ui_text.clone(),
&tool_use.input,
tool_use.status.text(),
window,
cx,
);
@ -486,23 +563,44 @@ impl ActiveThread {
self.rendered_messages_by_id.remove(id);
}
fn render_tool_use_label_markdown(
fn render_tool_use_markdown(
&mut self,
tool_use_id: LanguageModelToolUseId,
tool_label: impl Into<SharedString>,
tool_input: &serde_json::Value,
tool_output: SharedString,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.rendered_tool_use_labels.insert(
tool_use_id,
render_markdown(
let rendered = RenderedToolUse {
label: render_tool_use_markdown(
tool_label.into(),
self.language_registry.clone(),
self.workspace.clone(),
window,
cx,
),
);
input: render_tool_use_markdown(
format!(
"```json\n{}\n```",
serde_json::to_string_pretty(tool_input).unwrap_or_default()
)
.into(),
self.language_registry.clone(),
self.workspace.clone(),
window,
cx,
),
output: render_tool_use_markdown(
tool_output,
self.language_registry.clone(),
self.workspace.clone(),
window,
cx,
),
};
self.rendered_tool_uses
.insert(tool_use_id.clone(), rendered);
}
fn handle_thread_event(
@ -587,9 +685,11 @@ impl ActiveThread {
.update(cx, |thread, cx| thread.use_pending_tools(cx));
for tool_use in tool_uses {
self.render_tool_use_label_markdown(
self.render_tool_use_markdown(
tool_use.id.clone(),
tool_use.ui_text.clone(),
&tool_use.input,
"".into(),
window,
cx,
);
@ -602,9 +702,11 @@ impl ActiveThread {
} => {
let canceled = *canceled;
if let Some(tool_use) = pending_tool_use {
self.render_tool_use_label_markdown(
self.render_tool_use_markdown(
tool_use.id.clone(),
SharedString::from(tool_use.ui_text.clone()),
tool_use.ui_text.clone(),
&tool_use.input,
"".into(),
window,
cx,
);
@ -1261,7 +1363,7 @@ impl ActiveThread {
.pb_2p5()
.when(!tool_uses.is_empty(), |parent| {
parent.child(
v_flex().children(
div().children(
tool_uses
.into_iter()
.map(|tool_use| self.render_tool_use(tool_use, cx)),
@ -1695,11 +1797,13 @@ impl ActiveThread {
}
});
let content_container = || v_flex().py_1().gap_0p5().px_2p5();
let rendered_tool_use = self.rendered_tool_uses.get(&tool_use.id).cloned();
let results_content_container = || v_flex().p_2().gap_0p5();
let results_content = v_flex()
.gap_1()
.child(
content_container()
results_content_container()
.child(
Label::new("Input")
.size(LabelSize::XSmall)
@ -1707,16 +1811,16 @@ impl ActiveThread {
.buffer_font(cx),
)
.child(
Label::new(
serde_json::to_string_pretty(&tool_use.input).unwrap_or_default(),
)
.size(LabelSize::Small)
.buffer_font(cx),
div().w_full().text_ui_sm(cx).children(
rendered_tool_use
.as_ref()
.map(|rendered| rendered.input.clone()),
),
),
)
.map(|container| match tool_use.status {
ToolUseStatus::Finished(output) => container.child(
content_container()
ToolUseStatus::Finished(_) => container.child(
results_content_container()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.child(
@ -1725,10 +1829,16 @@ impl ActiveThread {
.color(Color::Muted)
.buffer_font(cx),
)
.child(Label::new(output).size(LabelSize::Small).buffer_font(cx)),
.child(
div().w_full().text_ui_sm(cx).children(
rendered_tool_use
.as_ref()
.map(|rendered| rendered.output.clone()),
),
),
),
ToolUseStatus::Running => container.child(
content_container().child(
results_content_container().child(
h_flex()
.gap_1()
.pb_1()
@ -1756,8 +1866,8 @@ impl ActiveThread {
),
),
),
ToolUseStatus::Error(err) => container.child(
content_container()
ToolUseStatus::Error(_) => container.child(
results_content_container()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.child(
@ -1766,11 +1876,17 @@ impl ActiveThread {
.color(Color::Muted)
.buffer_font(cx),
)
.child(Label::new(err).size(LabelSize::Small).buffer_font(cx)),
.child(
div().text_ui_sm(cx).children(
rendered_tool_use
.as_ref()
.map(|rendered| rendered.output.clone()),
),
),
),
ToolUseStatus::Pending => container,
ToolUseStatus::NeedsConfirmation => container.child(
content_container()
results_content_container()
.border_t_1()
.border_color(self.tool_card_border_color(cx))
.child(
@ -1786,13 +1902,13 @@ impl ActiveThread {
div()
.h_full()
.absolute()
.w_8()
.w_12()
.bottom_0()
.map(|element| {
if is_status_finished {
element.right_7()
element.right_6()
} else {
element.right(px(46.))
element.right(px(44.))
}
})
.bg(linear_gradient(
@ -1828,9 +1944,7 @@ impl ActiveThread {
)
.child(
h_flex().pr_8().text_ui_sm(cx).children(
self.rendered_tool_use_labels
.get(&tool_use.id)
.cloned(),
rendered_tool_use.map(|rendered| rendered.label)
),
),
)
@ -1918,9 +2032,7 @@ impl ActiveThread {
)
.child(
h_flex().pr_8().text_ui_sm(cx).children(
self.rendered_tool_use_labels
.get(&tool_use.id)
.cloned(),
rendered_tool_use.map(|rendered| rendered.label)
),
),
)

View file

@ -35,6 +35,18 @@ pub enum ToolUseStatus {
Error(SharedString),
}
impl ToolUseStatus {
pub fn text(&self) -> SharedString {
match self {
ToolUseStatus::NeedsConfirmation => "".into(),
ToolUseStatus::Pending => "".into(),
ToolUseStatus::Running => "".into(),
ToolUseStatus::Finished(out) => out.clone(),
ToolUseStatus::Error(out) => out.clone(),
}
}
}
pub struct ToolUseState {
tools: Arc<ToolWorkingSet>,
tool_uses_by_assistant_message: HashMap<MessageId, Vec<LanguageModelToolUse>>,