Skip to main content

ui/components/ai/
agent_message.rs

1use std::sync::Arc;
2
3use gpui::{AnyElement, ClickEvent, Entity, SharedString};
4use markdown::Markdown;
5
6use crate::prelude::*;
7use crate::{
8    AgentMarkdown, BadgeColor, BadgeVariant, Card, CardVariant, CopyButton, DiffBlock, Disclosure,
9    Spinner, SpinnerSize, TerminalOutputBlock, ThinkingBlock,
10};
11
12/// Who/what produced a message: `User`/`Status` render as plain text,
13/// `Assistant` through [`AgentMarkdown`], `Thinking` through
14/// [`ThinkingBlock`], `ToolCall` as a collapsible card, `Streaming` as a
15/// spinner + "Assistant is responding…" row shown while an assistant turn
16/// is in progress.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18pub enum AgentMessageRole {
19    User,
20    #[default]
21    Assistant,
22    ToolCall,
23    Status,
24    Thinking,
25    Streaming,
26}
27
28/// Lifecycle state of a `ToolCall` message, driving its status badge.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum ToolCallState {
31    #[default]
32    Running,
33    Success,
34    Failed,
35}
36
37/// One entry of a tool call's collapsible body. Boltz-ui-local mirror of the
38/// terminal crate's tool-call content shapes — intentionally has no
39/// dependency on any acpx/schema type.
40#[derive(Clone)]
41pub enum ToolCallContentDisplay {
42    /// Markdown text content, e.g. an explanation or a file excerpt.
43    Text(Entity<Markdown>),
44    /// A unified old/new diff, rendered via [`DiffBlock`] (Decision #2a: no
45    /// buffer-aware inline-editable diff view).
46    Diff {
47        old_text: SharedString,
48        new_text: SharedString,
49        /// File extension (no leading dot, e.g. `"rs"`) for syntax
50        /// highlighting; `None` renders as plain text.
51        language: Option<SharedString>,
52    },
53    /// Captured terminal output, rendered via [`TerminalOutputBlock`]
54    /// (Decision #2b: static text, no PTY grid).
55    Terminal {
56        command: Option<SharedString>,
57        raw_output: SharedString,
58    },
59}
60
61/// A single chat message. Pure builder — all state is caller-owned.
62#[derive(IntoElement, RegisterComponent)]
63pub struct AgentMessageBubble {
64    id: ElementId,
65    role: AgentMessageRole,
66    body: SharedString,
67    /// Rendered body for `Assistant`/`Thinking` roles. Falls back to a plain
68    /// `body` label when unset (e.g. a caller not yet passing a persistent
69    /// `Entity<Markdown>` — see [`AgentMarkdown`]'s docs for why one is
70    /// needed for markdown to actually render).
71    markdown_body: Option<Entity<Markdown>>,
72    tool_name: Option<SharedString>,
73    tool_state: ToolCallState,
74    content: Vec<ToolCallContentDisplay>,
75    expanded: bool,
76    on_toggle_expanded: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
77    /// Optional trailing action row (e.g. a [`CopyButton`]) rendered for the
78    /// `Assistant` role beneath the body. Caller-owned; `None` (the default)
79    /// renders no actions, so existing call sites are unaffected.
80    actions: Option<AnyElement>,
81}
82
83impl AgentMessageBubble {
84    pub fn new(
85        id: impl Into<ElementId>,
86        role: AgentMessageRole,
87        body: impl Into<SharedString>,
88    ) -> Self {
89        Self {
90            id: id.into(),
91            role,
92            body: body.into(),
93            markdown_body: None,
94            tool_name: None,
95            tool_state: ToolCallState::default(),
96            content: Vec::new(),
97            expanded: false,
98            on_toggle_expanded: None,
99            actions: None,
100        }
101    }
102
103    /// Sets the rendered markdown body for `Assistant`/`Thinking` roles. See
104    /// [`AgentMarkdown`]'s docs on why this takes a persistent `Entity`
105    /// rather than raw text.
106    pub fn markdown_body(mut self, markdown: Entity<Markdown>) -> Self {
107        self.markdown_body = Some(markdown);
108        self
109    }
110
111    /// `ToolCall` header title; falls back to `body` when unset.
112    pub fn tool_name(mut self, name: impl Into<SharedString>) -> Self {
113        self.tool_name = Some(name.into());
114        self
115    }
116
117    pub fn tool_state(mut self, state: ToolCallState) -> Self {
118        self.tool_state = state;
119        self
120    }
121
122    /// The tool call's collapsible body content (text/diff/terminal entries,
123    /// rendered in order).
124    pub fn content(mut self, content: Vec<ToolCallContentDisplay>) -> Self {
125        self.content = content;
126        self
127    }
128
129    /// Whether the tool-call body is expanded (caller-owned).
130    pub fn expanded(mut self, expanded: bool) -> Self {
131        self.expanded = expanded;
132        self
133    }
134
135    pub fn on_toggle_expanded(
136        mut self,
137        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
138    ) -> Self {
139        self.on_toggle_expanded = Some(Arc::new(handler));
140        self
141    }
142
143    /// Trailing action row for the `Assistant` role, rendered beneath the
144    /// body (e.g. a [`CopyButton`]). Other roles ignore it. Pass an
145    /// `h_flex()` of action buttons to show several; `None` renders nothing.
146    pub fn actions(mut self, actions: impl IntoElement) -> Self {
147        self.actions = Some(actions.into_any_element());
148        self
149    }
150}
151
152fn render_tool_call_content(
153    bubble_id: &ElementId,
154    content: Vec<ToolCallContentDisplay>,
155) -> impl IntoElement {
156    v_flex()
157        .gap_2()
158        .children(content.into_iter().enumerate().map(|(index, entry)| {
159            let item_id = format!("{bubble_id}-content-{index}");
160            match entry {
161                ToolCallContentDisplay::Text(markdown) => {
162                    AgentMarkdown::new(item_id, markdown).into_any_element()
163                }
164                ToolCallContentDisplay::Diff {
165                    old_text,
166                    new_text,
167                    language,
168                } => {
169                    let diff = DiffBlock::new(item_id, old_text, new_text);
170                    match language {
171                        Some(language) => diff.language(language).into_any_element(),
172                        None => diff.into_any_element(),
173                    }
174                }
175                ToolCallContentDisplay::Terminal {
176                    command,
177                    raw_output,
178                } => {
179                    let terminal = TerminalOutputBlock::new(item_id, raw_output);
180                    match command {
181                        Some(command) => terminal.command(command).into_any_element(),
182                        None => terminal.into_any_element(),
183                    }
184                }
185            }
186        }))
187}
188
189fn tool_call_card(bubble: AgentMessageBubble, _cx: &App) -> AnyElement {
190    let (badge_label, badge_color) = match bubble.tool_state {
191        ToolCallState::Running => ("Running", BadgeColor::Secondary),
192        ToolCallState::Success => ("Success", BadgeColor::Success),
193        ToolCallState::Failed => ("Failed", BadgeColor::Danger),
194    };
195    let disclosure_id = format!("{}-disclosure", bubble.id);
196    let title = bubble.tool_name.unwrap_or_else(|| bubble.body.clone());
197    let has_content = !bubble.content.is_empty();
198    let expanded = bubble.expanded;
199
200    let tool_icon = Icon::new(IconName::ToolHammer)
201        .size(IconSize::Small)
202        .color(Color::Muted);
203    let badge = Badge::new(badge_label)
204        .variant(BadgeVariant::Soft)
205        .color(badge_color);
206    let disclosure =
207        Disclosure::new(disclosure_id, expanded).on_toggle_expanded(bubble.on_toggle_expanded);
208    let header = h_flex()
209        .id(bubble.id.clone())
210        .w_full()
211        .justify_between()
212        .child(h_flex().gap_2().child(tool_icon).child(Label::new(title)))
213        .child(
214            h_flex()
215                .gap_2()
216                .child(badge)
217                .when(has_content, |this| this.child(disclosure)),
218        );
219
220    Card::new()
221        .variant(CardVariant::Bordered)
222        .header(header)
223        .when(has_content && expanded, |this| {
224            this.child(render_tool_call_content(&bubble.id, bubble.content))
225        })
226        .into_any_element()
227}
228
229impl RenderOnce for AgentMessageBubble {
230    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
231        match self.role {
232            AgentMessageRole::User => {
233                let bubble = div()
234                    .max_w(relative(0.8))
235                    .px_3()
236                    .py_2()
237                    .rounded_lg()
238                    .bg(cx.theme().colors().element_active)
239                    .child(Label::new(self.body));
240                h_flex()
241                    .id(self.id)
242                    .w_full()
243                    .justify_end()
244                    .child(bubble)
245                    .into_any_element()
246            }
247            AgentMessageRole::Assistant => {
248                let body: AnyElement = match self.markdown_body {
249                    Some(markdown) => {
250                        AgentMarkdown::new(format!("{}-md", self.id), markdown).into_any_element()
251                    }
252                    None => Label::new(self.body).into_any_element(),
253                };
254                div()
255                    .id(self.id.clone())
256                    .w_full()
257                    .border_l_2()
258                    .border_color(cx.theme().colors().border_focused)
259                    .pl(DynamicSpacing::Base12.px(cx))
260                    .child(body)
261                    .when_some(self.actions, |this, actions| {
262                        // Wrap the action row in a group so per-message action
263                        // buttons can opt into `visible_on_hover` against it.
264                        this.group(format!("{}-actions", self.id))
265                            .child(
266                                h_flex()
267                                    .id(format!("{}-actions-row", self.id))
268                                    .mt_1()
269                                    .gap_1()
270                                    .child(actions),
271                            )
272                    })
273                    .into_any_element()
274            }
275            AgentMessageRole::Thinking => {
276                let expanded = self.expanded;
277                let toggle = self.on_toggle_expanded;
278                let content: AnyElement = match self.markdown_body {
279                    Some(markdown) => ThinkingBlock::new(self.id.clone(), markdown)
280                        .expanded(expanded)
281                        .when_some(toggle, |block, handler| {
282                            block.on_toggle_expanded(move |event, window, cx| {
283                                handler(event, window, cx)
284                            })
285                        })
286                        .into_any_element(),
287                    None => h_flex()
288                        .id(self.id.clone())
289                        .gap_1()
290                        .child(
291                            Icon::new(IconName::ToolThink)
292                                .size(IconSize::XSmall)
293                                .color(Color::Muted),
294                        )
295                        .child(
296                            Label::new(self.body)
297                                .size(LabelSize::Small)
298                                .color(Color::Muted),
299                        )
300                        .into_any_element(),
301                };
302                div().w_full().child(content).into_any_element()
303            }
304            AgentMessageRole::Status => h_flex()
305                .id(self.id)
306                .w_full()
307                .gap_1()
308                .child(
309                    Label::new(self.body)
310                        .size(LabelSize::Small)
311                        .color(Color::Muted),
312                )
313                .into_any_element(),
314            AgentMessageRole::Streaming => {
315                let label: SharedString = if self.body.is_empty() {
316                    "Assistant is responding…".into()
317                } else {
318                    self.body
319                };
320                h_flex()
321                    .id(self.id)
322                    .w_full()
323                    .gap(DynamicSpacing::Base02.rems(cx))
324                    .child(
325                        Spinner::new()
326                            .id("streaming-spinner")
327                            .size(SpinnerSize::Sm),
328                    )
329                    .child(
330                        Label::new(label)
331                            .size(LabelSize::Small)
332                            .color(Color::Muted),
333                    )
334                    .into_any_element()
335            }
336            AgentMessageRole::ToolCall => tool_call_card(self, cx),
337        }
338    }
339}
340
341impl Component for AgentMessageBubble {
342    fn scope() -> ComponentScope {
343        ComponentScope::Agent
344    }
345
346    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
347        let assistant_markdown = crate::agent_markdown_entity(
348            "Run `cargo test -p boltz-ui`.\n\n| Step | Result |\n|---|---|\n| build | ok |",
349            cx,
350        );
351        let thinking_markdown =
352            crate::agent_markdown_entity("Checking the test suite before making changes.", cx);
353
354        let tool_call =
355            AgentMessageBubble::new("m-tool-1", AgentMessageRole::ToolCall, "run_tests")
356                .tool_name("run_tests")
357                .tool_state(ToolCallState::Success)
358                .content(vec![
359                    ToolCallContentDisplay::Terminal {
360                        command: Some("cargo test -p boltz-ui".into()),
361                        raw_output: "test result: ok. 42 passed; 0 failed".into(),
362                    },
363                    ToolCallContentDisplay::Diff {
364                        old_text: "fn old() {}".into(),
365                        new_text: "fn new() {}".into(),
366                        language: Some("rs".into()),
367                    },
368                ])
369                .expanded(true);
370
371        let assistant = AgentMessageBubble::new("m-a", AgentMessageRole::Assistant, "")
372            .markdown_body(assistant_markdown)
373            .actions(
374                h_flex().child(
375                    CopyButton::new("m-a-copy", "Run `cargo test -p boltz-ui`.")
376                        .visible_on_hover("m-a-actions"),
377                ),
378            );
379
380        let thinking =
381            AgentMessageBubble::new("m-thinking", AgentMessageRole::Thinking, "Checking...")
382                .markdown_body(thinking_markdown)
383                .expanded(true);
384
385        let messages = v_flex()
386            .gap_2()
387            .child(AgentMessageBubble::new(
388                "m-user",
389                AgentMessageRole::User,
390                "How do I run tests?",
391            ))
392            .child(assistant)
393            .child(thinking)
394            .child(AgentMessageBubble::new(
395                "m-status",
396                AgentMessageRole::Status,
397                "Connecting…",
398            ))
399            .child(AgentMessageBubble::new(
400                "m-streaming",
401                AgentMessageRole::Streaming,
402                "",
403            ));
404
405        Some(
406            v_flex()
407                .w_96()
408                .gap_4()
409                .child(single_example("Roles", messages.into_any_element()))
410                .child(single_example(
411                    "Tool Call (expanded)",
412                    tool_call.into_any_element(),
413                ))
414                .into_any_element(),
415        )
416    }
417}