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

Danilo Leal and Agus Zubiaga created

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>

Change summary

crates/agent/src/active_thread.rs | 186 ++++++++++++++++++++++++++------
crates/agent/src/tool_use.rs      |  12 ++
2 files changed, 161 insertions(+), 37 deletions(-)

Detailed changes

crates/agent/src/active_thread.rs 🔗

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

crates/agent/src/tool_use.rs 🔗

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