Skip to main content

ui/components/ai/
agent_message.rs

1use std::sync::Arc;
2
3use gpui::{ClickEvent, SharedString};
4
5use crate::prelude::*;
6use crate::{AgentMarkdown, BadgeColor, BadgeVariant, Card, CardVariant, Disclosure};
7
8/// Who/what produced a message: `User`/`Status` render as plain text,
9/// `Assistant`/`Thinking` through [`AgentMarkdown`], `ToolCall` as a card.
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11pub enum AgentMessageRole {
12    User,
13    #[default]
14    Assistant,
15    ToolCall,
16    Status,
17    Thinking,
18}
19
20/// Lifecycle state of a `ToolCall` message, driving its status badge.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub enum ToolCallState {
23    #[default]
24    Running,
25    Success,
26    Failed,
27}
28
29/// A single chat message. Pure builder — all state is caller-owned.
30#[derive(IntoElement, RegisterComponent)]
31pub struct AgentMessageBubble {
32    id: ElementId,
33    role: AgentMessageRole,
34    body: SharedString,
35    tool_name: Option<SharedString>,
36    tool_state: ToolCallState,
37    tool_input: Option<SharedString>,
38    tool_output: Option<SharedString>,
39    expanded: bool,
40    on_toggle_expanded: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
41}
42
43impl AgentMessageBubble {
44    pub fn new(
45        id: impl Into<ElementId>,
46        role: AgentMessageRole,
47        body: impl Into<SharedString>,
48    ) -> Self {
49        Self {
50            id: id.into(),
51            role,
52            body: body.into(),
53            tool_name: None,
54            tool_state: ToolCallState::default(),
55            tool_input: None,
56            tool_output: None,
57            expanded: false,
58            on_toggle_expanded: None,
59        }
60    }
61
62    /// `ToolCall` header title; falls back to `body` when unset.
63    pub fn tool_name(mut self, name: impl Into<SharedString>) -> Self {
64        self.tool_name = Some(name.into());
65        self
66    }
67
68    pub fn tool_state(mut self, state: ToolCallState) -> Self {
69        self.tool_state = state;
70        self
71    }
72    pub fn tool_input(mut self, input: impl Into<SharedString>) -> Self {
73        self.tool_input = Some(input.into());
74        self
75    }
76
77    pub fn tool_output(mut self, output: impl Into<SharedString>) -> Self {
78        self.tool_output = Some(output.into());
79        self
80    }
81
82    /// Whether the raw input/output section is expanded (caller-owned).
83    pub fn expanded(mut self, expanded: bool) -> Self {
84        self.expanded = expanded;
85        self
86    }
87
88    pub fn on_toggle_expanded(
89        mut self,
90        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
91    ) -> Self {
92        self.on_toggle_expanded = Some(Arc::new(handler));
93        self
94    }
95}
96
97fn raw_block(label: &'static str, content: SharedString, cx: &App) -> AnyElement {
98    v_flex()
99        .gap_1()
100        .child(Label::new(label).size(LabelSize::Small).color(Color::Muted))
101        .child(
102            div()
103                .p_2()
104                .rounded_md()
105                .bg(cx.theme().colors().element_background)
106                .child(content),
107        )
108        .into_any_element()
109}
110
111fn tool_call_card(bubble: AgentMessageBubble, cx: &App) -> AnyElement {
112    let (badge_label, badge_color) = match bubble.tool_state {
113        ToolCallState::Running => ("Running", BadgeColor::Secondary),
114        ToolCallState::Success => ("Success", BadgeColor::Success),
115        ToolCallState::Failed => ("Failed", BadgeColor::Danger),
116    };
117    let disclosure_id = format!("{}-disclosure", bubble.id);
118    let title = bubble.tool_name.unwrap_or_else(|| bubble.body.clone());
119    let has_raw = bubble.tool_input.is_some() || bubble.tool_output.is_some();
120    let expanded = bubble.expanded;
121
122    let tool_icon = Icon::new(IconName::ToolHammer)
123        .size(IconSize::Small)
124        .color(Color::Muted);
125    let badge = Badge::new(badge_label)
126        .variant(BadgeVariant::Soft)
127        .color(badge_color);
128    let disclosure =
129        Disclosure::new(disclosure_id, expanded).on_toggle_expanded(bubble.on_toggle_expanded);
130    let header = h_flex()
131        .id(bubble.id)
132        .w_full()
133        .justify_between()
134        .child(h_flex().gap_2().child(tool_icon).child(Label::new(title)))
135        .child(
136            h_flex()
137                .gap_2()
138                .child(badge)
139                .when(has_raw, |this| this.child(disclosure)),
140        );
141
142    Card::new()
143        .variant(CardVariant::Bordered)
144        .header(header)
145        .when(has_raw && expanded, |this| {
146            this.child(
147                v_flex()
148                    .gap_2()
149                    .when_some(bubble.tool_input, |this, input| {
150                        this.child(raw_block("Input", input, cx))
151                    })
152                    .when_some(bubble.tool_output, |this, output| {
153                        this.child(raw_block("Output", output, cx))
154                    }),
155            )
156        })
157        .into_any_element()
158}
159
160impl RenderOnce for AgentMessageBubble {
161    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
162        match self.role {
163            AgentMessageRole::User => {
164                let bubble = div()
165                    .max_w(relative(0.8))
166                    .px_3()
167                    .py_2()
168                    .rounded_lg()
169                    .bg(cx.theme().colors().element_active)
170                    .child(Label::new(self.body));
171                h_flex()
172                    .id(self.id)
173                    .w_full()
174                    .justify_end()
175                    .child(bubble)
176                    .into_any_element()
177            }
178            AgentMessageRole::Assistant => {
179                let md = AgentMarkdown::new(format!("{}-md", self.id), self.body);
180                div()
181                    .id(self.id.clone())
182                    .w_full()
183                    .child(md)
184                    .into_any_element()
185            }
186            AgentMessageRole::Thinking => {
187                let md = AgentMarkdown::new(format!("{}-md", self.id), self.body);
188                let header = h_flex()
189                    .gap_1()
190                    .child(
191                        Icon::new(IconName::ToolThink)
192                            .size(IconSize::XSmall)
193                            .color(Color::Muted),
194                    )
195                    .child(
196                        Label::new("Thinking")
197                            .size(LabelSize::Small)
198                            .color(Color::Muted),
199                    );
200                v_flex()
201                    .id(self.id.clone())
202                    .w_full()
203                    .gap_1()
204                    .child(header)
205                    .child(div().opacity(0.7).child(md))
206                    .into_any_element()
207            }
208            AgentMessageRole::Status => h_flex()
209                .id(self.id)
210                .w_full()
211                .gap_1()
212                .child(
213                    Label::new(self.body)
214                        .size(LabelSize::Small)
215                        .color(Color::Muted),
216                )
217                .into_any_element(),
218            AgentMessageRole::ToolCall => tool_call_card(self, cx),
219        }
220    }
221}
222
223impl Component for AgentMessageBubble {
224    fn scope() -> ComponentScope {
225        ComponentScope::Agent
226    }
227
228    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
229        let tool_call =
230            AgentMessageBubble::new("m-tool-1", AgentMessageRole::ToolCall, "run_tests")
231                .tool_name("run_tests")
232                .tool_state(ToolCallState::Success)
233                .tool_input("cargo test -p boltz-ui")
234                .tool_output("test result: ok. 42 passed; 0 failed")
235                .expanded(true);
236
237        let assistant = AgentMessageBubble::new(
238            "m-a",
239            AgentMessageRole::Assistant,
240            "Run `cargo test -p boltz-ui`.",
241        );
242        let messages = v_flex()
243            .gap_2()
244            .child(AgentMessageBubble::new(
245                "m-user",
246                AgentMessageRole::User,
247                "How do I run tests?",
248            ))
249            .child(assistant)
250            .child(AgentMessageBubble::new(
251                "m-thinking",
252                AgentMessageRole::Thinking,
253                "Checking...",
254            ))
255            .child(AgentMessageBubble::new(
256                "m-status",
257                AgentMessageRole::Status,
258                "Connecting…",
259            ));
260
261        Some(
262            v_flex()
263                .w_96()
264                .gap_4()
265                .child(single_example("Roles", messages.into_any_element()))
266                .child(single_example(
267                    "Tool Call (expanded)",
268                    tool_call.into_any_element(),
269                ))
270                .into_any_element(),
271        )
272    }
273}