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#[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum ToolCallState {
28 #[default]
29 Running,
30 Success,
31 Failed,
32}
33
34#[derive(Clone)]
38pub enum ToolCallContentDisplay {
39 Text(Entity<Markdown>),
41 Diff {
44 old_text: SharedString,
45 new_text: SharedString,
46 language: Option<SharedString>,
49 },
50 Terminal {
53 command: Option<SharedString>,
54 raw_output: SharedString,
55 },
56}
57
58#[derive(IntoElement, RegisterComponent)]
60pub struct AgentMessageBubble {
61 id: ElementId,
62 role: AgentMessageRole,
63 body: SharedString,
64 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 pub fn markdown_body(mut self, markdown: Entity<Markdown>) -> Self {
99 self.markdown_body = Some(markdown);
100 self
101 }
102
103 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 pub fn content(mut self, content: Vec<ToolCallContentDisplay>) -> Self {
117 self.content = content;
118 self
119 }
120
121 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}