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#[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 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 pub fn markdown_body(mut self, markdown: Entity<Markdown>) -> Self {
107 self.markdown_body = Some(markdown);
108 self
109 }
110
111 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 pub fn content(mut self, content: Vec<ToolCallContentDisplay>) -> Self {
125 self.content = content;
126 self
127 }
128
129 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 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 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}