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