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#[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum ToolCallState {
31 #[default]
32 Running,
33 Success,
34 Failed,
35}
36
37#[derive(Clone)]
41pub enum ToolCallContentDisplay {
42 Text(Entity<Markdown>),
44 Diff {
47 old_text: SharedString,
48 new_text: SharedString,
49 language: Option<SharedString>,
52 },
53 Terminal {
56 command: Option<SharedString>,
57 raw_output: SharedString,
58 },
59}
60
61#[derive(IntoElement, RegisterComponent)]
63pub struct AgentMessageBubble {
64 id: ElementId,
65 role: AgentMessageRole,
66 body: SharedString,
67 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 pub fn markdown_body(mut self, markdown: Entity<Markdown>) -> Self {
102 self.markdown_body = Some(markdown);
103 self
104 }
105
106 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 pub fn content(mut self, content: Vec<ToolCallContentDisplay>) -> Self {
120 self.content = content;
121 self
122 }
123
124 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}