Skip to main content

ui/components/ai/
agent_message.rs

1use std::sync::Arc;
2
3use gpui::{ClickEvent, Entity, SharedString};
4use markdown::Markdown;
5
6use crate::prelude::*;
7use crate::{
8    AgentMarkdown, BadgeColor, BadgeVariant, Card, CardVariant, 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}
78
79impl AgentMessageBubble {
80    pub fn new(
81        id: impl Into<ElementId>,
82        role: AgentMessageRole,
83        body: impl Into<SharedString>,
84    ) -> Self {
85        Self {
86            id: id.into(),
87            role,
88            body: body.into(),
89            markdown_body: None,
90            tool_name: None,
91            tool_state: ToolCallState::default(),
92            content: Vec::new(),
93            expanded: false,
94            on_toggle_expanded: None,
95        }
96    }
97
98    /// Sets the rendered markdown body for `Assistant`/`Thinking` roles. See
99    /// [`AgentMarkdown`]'s docs on why this takes a persistent `Entity`
100    /// rather than raw text.
101    pub fn markdown_body(mut self, markdown: Entity<Markdown>) -> Self {
102        self.markdown_body = Some(markdown);
103        self
104    }
105
106    /// `ToolCall` header title; falls back to `body` when unset.
107    pub fn tool_name(mut self, name: impl Into<SharedString>) -> Self {
108        self.tool_name = Some(name.into());
109        self
110    }
111
112    pub fn tool_state(mut self, state: ToolCallState) -> Self {
113        self.tool_state = state;
114        self
115    }
116
117    /// The tool call's collapsible body content (text/diff/terminal entries,
118    /// rendered in order).
119    pub fn content(mut self, content: Vec<ToolCallContentDisplay>) -> Self {
120        self.content = content;
121        self
122    }
123
124    /// Whether the tool-call body is expanded (caller-owned).
125    pub fn expanded(mut self, expanded: bool) -> Self {
126        self.expanded = expanded;
127        self
128    }
129
130    pub fn on_toggle_expanded(
131        mut self,
132        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
133    ) -> Self {
134        self.on_toggle_expanded = Some(Arc::new(handler));
135        self
136    }
137}
138
139fn render_tool_call_content(
140    bubble_id: &ElementId,
141    content: Vec<ToolCallContentDisplay>,
142) -> impl IntoElement {
143    v_flex()
144        .gap_2()
145        .children(content.into_iter().enumerate().map(|(index, entry)| {
146            let item_id = format!("{bubble_id}-content-{index}");
147            match entry {
148                ToolCallContentDisplay::Text(markdown) => {
149                    AgentMarkdown::new(item_id, markdown).into_any_element()
150                }
151                ToolCallContentDisplay::Diff {
152                    old_text,
153                    new_text,
154                    language,
155                } => {
156                    let diff = DiffBlock::new(item_id, old_text, new_text);
157                    match language {
158                        Some(language) => diff.language(language).into_any_element(),
159                        None => diff.into_any_element(),
160                    }
161                }
162                ToolCallContentDisplay::Terminal {
163                    command,
164                    raw_output,
165                } => {
166                    let terminal = TerminalOutputBlock::new(item_id, raw_output);
167                    match command {
168                        Some(command) => terminal.command(command).into_any_element(),
169                        None => terminal.into_any_element(),
170                    }
171                }
172            }
173        }))
174}
175
176fn tool_call_card(bubble: AgentMessageBubble, _cx: &App) -> AnyElement {
177    let (badge_label, badge_color) = match bubble.tool_state {
178        ToolCallState::Running => ("Running", BadgeColor::Secondary),
179        ToolCallState::Success => ("Success", BadgeColor::Success),
180        ToolCallState::Failed => ("Failed", BadgeColor::Danger),
181    };
182    let disclosure_id = format!("{}-disclosure", bubble.id);
183    let title = bubble.tool_name.unwrap_or_else(|| bubble.body.clone());
184    let has_content = !bubble.content.is_empty();
185    let expanded = bubble.expanded;
186
187    let tool_icon = Icon::new(IconName::ToolHammer)
188        .size(IconSize::Small)
189        .color(Color::Muted);
190    let badge = Badge::new(badge_label)
191        .variant(BadgeVariant::Soft)
192        .color(badge_color);
193    let disclosure =
194        Disclosure::new(disclosure_id, expanded).on_toggle_expanded(bubble.on_toggle_expanded);
195    let header = h_flex()
196        .id(bubble.id.clone())
197        .w_full()
198        .justify_between()
199        .child(h_flex().gap_2().child(tool_icon).child(Label::new(title)))
200        .child(
201            h_flex()
202                .gap_2()
203                .child(badge)
204                .when(has_content, |this| this.child(disclosure)),
205        );
206
207    Card::new()
208        .variant(CardVariant::Bordered)
209        .header(header)
210        .when(has_content && expanded, |this| {
211            this.child(render_tool_call_content(&bubble.id, bubble.content))
212        })
213        .into_any_element()
214}
215
216impl RenderOnce for AgentMessageBubble {
217    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
218        match self.role {
219            AgentMessageRole::User => {
220                let bubble = div()
221                    .max_w(relative(0.8))
222                    .px_3()
223                    .py_2()
224                    .rounded_lg()
225                    .bg(cx.theme().colors().element_active)
226                    .child(Label::new(self.body));
227                h_flex()
228                    .id(self.id)
229                    .w_full()
230                    .justify_end()
231                    .child(bubble)
232                    .into_any_element()
233            }
234            AgentMessageRole::Assistant => {
235                let body: AnyElement = match self.markdown_body {
236                    Some(markdown) => {
237                        AgentMarkdown::new(format!("{}-md", self.id), markdown).into_any_element()
238                    }
239                    None => Label::new(self.body).into_any_element(),
240                };
241                div()
242                    .id(self.id.clone())
243                    .w_full()
244                    .border_l_2()
245                    .border_color(cx.theme().colors().border_focused)
246                    .pl(DynamicSpacing::Base12.px(cx))
247                    .child(body)
248                    .into_any_element()
249            }
250            AgentMessageRole::Thinking => {
251                let expanded = self.expanded;
252                let toggle = self.on_toggle_expanded;
253                let content: AnyElement = match self.markdown_body {
254                    Some(markdown) => ThinkingBlock::new(self.id.clone(), markdown)
255                        .expanded(expanded)
256                        .when_some(toggle, |block, handler| {
257                            block.on_toggle_expanded(move |event, window, cx| {
258                                handler(event, window, cx)
259                            })
260                        })
261                        .into_any_element(),
262                    None => h_flex()
263                        .id(self.id.clone())
264                        .gap_1()
265                        .child(
266                            Icon::new(IconName::ToolThink)
267                                .size(IconSize::XSmall)
268                                .color(Color::Muted),
269                        )
270                        .child(
271                            Label::new(self.body)
272                                .size(LabelSize::Small)
273                                .color(Color::Muted),
274                        )
275                        .into_any_element(),
276                };
277                div().w_full().child(content).into_any_element()
278            }
279            AgentMessageRole::Status => h_flex()
280                .id(self.id)
281                .w_full()
282                .gap_1()
283                .child(
284                    Label::new(self.body)
285                        .size(LabelSize::Small)
286                        .color(Color::Muted),
287                )
288                .into_any_element(),
289            AgentMessageRole::Streaming => {
290                let label: SharedString = if self.body.is_empty() {
291                    "Assistant is responding…".into()
292                } else {
293                    self.body
294                };
295                h_flex()
296                    .id(self.id)
297                    .w_full()
298                    .gap(DynamicSpacing::Base02.rems(cx))
299                    .child(
300                        Spinner::new()
301                            .id("streaming-spinner")
302                            .size(SpinnerSize::Sm),
303                    )
304                    .child(
305                        Label::new(label)
306                            .size(LabelSize::Small)
307                            .color(Color::Muted),
308                    )
309                    .into_any_element()
310            }
311            AgentMessageRole::ToolCall => tool_call_card(self, cx),
312        }
313    }
314}
315
316impl Component for AgentMessageBubble {
317    fn scope() -> ComponentScope {
318        ComponentScope::Agent
319    }
320
321    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
322        let assistant_markdown = crate::agent_markdown_entity(
323            "Run `cargo test -p boltz-ui`.\n\n| Step | Result |\n|---|---|\n| build | ok |",
324            cx,
325        );
326        let thinking_markdown =
327            crate::agent_markdown_entity("Checking the test suite before making changes.", cx);
328
329        let tool_call =
330            AgentMessageBubble::new("m-tool-1", AgentMessageRole::ToolCall, "run_tests")
331                .tool_name("run_tests")
332                .tool_state(ToolCallState::Success)
333                .content(vec![
334                    ToolCallContentDisplay::Terminal {
335                        command: Some("cargo test -p boltz-ui".into()),
336                        raw_output: "test result: ok. 42 passed; 0 failed".into(),
337                    },
338                    ToolCallContentDisplay::Diff {
339                        old_text: "fn old() {}".into(),
340                        new_text: "fn new() {}".into(),
341                        language: Some("rs".into()),
342                    },
343                ])
344                .expanded(true);
345
346        let assistant = AgentMessageBubble::new("m-a", AgentMessageRole::Assistant, "")
347            .markdown_body(assistant_markdown);
348
349        let thinking =
350            AgentMessageBubble::new("m-thinking", AgentMessageRole::Thinking, "Checking...")
351                .markdown_body(thinking_markdown)
352                .expanded(true);
353
354        let messages = v_flex()
355            .gap_2()
356            .child(AgentMessageBubble::new(
357                "m-user",
358                AgentMessageRole::User,
359                "How do I run tests?",
360            ))
361            .child(assistant)
362            .child(thinking)
363            .child(AgentMessageBubble::new(
364                "m-status",
365                AgentMessageRole::Status,
366                "Connecting…",
367            ))
368            .child(AgentMessageBubble::new(
369                "m-streaming",
370                AgentMessageRole::Streaming,
371                "",
372            ));
373
374        Some(
375            v_flex()
376                .w_96()
377                .gap_4()
378                .child(single_example("Roles", messages.into_any_element()))
379                .child(single_example(
380                    "Tool Call (expanded)",
381                    tool_call.into_any_element(),
382                ))
383                .into_any_element(),
384        )
385    }
386}