1use std::rc::Rc;
29
30use gpui::{App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, px};
31use gpui_kit_assets::Icon as Glyph;
32use gpui_kit_semantics::{NodeSpec, Role, Semantic};
33use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TextTone, Theme, TypeScale};
34
35use crate::controls::button::Button;
36use crate::display::badge::{Badge, Tone};
37use crate::display::icon::{Icon as IconView, IconTone};
38use crate::display::status::Callout;
39use crate::foundation::{Ident, Sizable, StyledExt, text};
40use crate::strings::{ActiveStrings, StringKey};
41
42type RetryHandler = Rc<dyn Fn(&mut Window, &mut App)>;
43
44#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ToolBody {
52 text: SharedString,
53 max_lines: Option<usize>,
54}
55
56impl ToolBody {
57 pub fn new(text: impl Into<SharedString>) -> Self {
58 Self {
59 text: text.into(),
60 max_lines: None,
61 }
62 }
63
64 pub fn max_lines(mut self, lines: usize) -> Self {
69 self.max_lines = Some(lines.max(1));
70 self
71 }
72
73 pub fn text(&self) -> &SharedString {
76 &self.text
77 }
78
79 pub fn line_count(&self) -> usize {
80 self.text.lines().count().max(1)
81 }
82
83 pub fn shown_line_count(&self) -> usize {
84 match self.max_lines {
85 Some(limit) => limit.min(self.line_count()),
86 None => self.line_count(),
87 }
88 }
89
90 pub fn is_truncated(&self) -> bool {
91 self.shown_line_count() < self.line_count()
92 }
93
94 fn shown_lines(&self) -> Vec<SharedString> {
96 self.text
97 .lines()
98 .take(self.shown_line_count())
99 .map(|line| SharedString::from(line.to_string()))
100 .collect()
101 }
102
103 pub fn shape(&self, cx: &App) -> SharedString {
105 let total = self.line_count();
106 if self.is_truncated() {
107 return cx.strings().format(
108 StringKey::AgentTruncated,
109 &[&self.shown_line_count().to_string(), &total.to_string()],
110 );
111 }
112 if total == 1 {
113 cx.strings().text(StringKey::AgentLinesOne)
114 } else {
115 cx.strings()
116 .format(StringKey::AgentLinesMany, &[&total.to_string()])
117 }
118 }
119}
120
121impl From<SharedString> for ToolBody {
122 fn from(value: SharedString) -> Self {
123 Self::new(value)
124 }
125}
126
127impl From<&'static str> for ToolBody {
128 fn from(value: &'static str) -> Self {
129 Self::new(value)
130 }
131}
132
133impl From<String> for ToolBody {
134 fn from(value: String) -> Self {
135 Self::new(value)
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum ToolOutput {
142 Body(ToolBody),
143 Silent,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum ToolCallState {
156 PendingApproval,
158 Running,
159 Succeeded {
160 output: ToolOutput,
161 },
162 Failed {
164 error: SharedString,
165 },
166 Refused {
169 reason: SharedString,
170 },
171}
172
173impl ToolCallState {
174 pub fn succeeded(output: impl Into<ToolBody>) -> Self {
175 Self::Succeeded {
176 output: ToolOutput::Body(output.into()),
177 }
178 }
179
180 pub fn succeeded_silently() -> Self {
182 Self::Succeeded {
183 output: ToolOutput::Silent,
184 }
185 }
186
187 pub fn failed(error: impl Into<SharedString>) -> Self {
188 Self::Failed {
189 error: error.into(),
190 }
191 }
192
193 pub fn refused(reason: impl Into<SharedString>) -> Self {
194 Self::Refused {
195 reason: reason.into(),
196 }
197 }
198
199 pub fn as_str(&self) -> &'static str {
202 match self {
203 Self::PendingApproval => "pending-approval",
204 Self::Running => "running",
205 Self::Succeeded { .. } => "succeeded",
206 Self::Failed { .. } => "failed",
207 Self::Refused { .. } => "refused",
208 }
209 }
210
211 pub fn reason(&self) -> Option<&SharedString> {
215 match self {
216 Self::Failed { error } => Some(error),
217 Self::Refused { reason } => Some(reason),
218 _ => None,
219 }
220 }
221
222 fn ran(&self) -> bool {
224 matches!(
225 self,
226 Self::Running | Self::Succeeded { .. } | Self::Failed { .. }
227 )
228 }
229
230 fn tone(&self) -> Tone {
231 match self {
232 Self::PendingApproval => Tone::Info,
233 Self::Running => Tone::Accent,
234 Self::Succeeded { .. } => Tone::Success,
235 Self::Failed { .. } => Tone::Danger,
236 Self::Refused { .. } => Tone::Warning,
237 }
238 }
239
240 fn glyph(&self) -> Glyph {
241 match self {
242 Self::PendingApproval => Glyph::Key,
243 Self::Running => Glyph::Refresh,
244 Self::Succeeded { .. } => Glyph::Check,
245 Self::Failed { .. } => Glyph::Danger,
246 Self::Refused { .. } => Glyph::CloseCircle,
247 }
248 }
249
250 fn key(&self) -> StringKey {
251 match self {
252 Self::PendingApproval => StringKey::AgentPendingApproval,
253 Self::Running => StringKey::AgentRunning,
254 Self::Succeeded { .. } => StringKey::AgentSucceeded,
255 Self::Failed { .. } => StringKey::AgentFailed,
256 Self::Refused { .. } => StringKey::AgentDeclined,
257 }
258 }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Default)]
267pub enum Elapsed {
268 Took(SharedString),
270 #[default]
271 Unknown,
272}
273
274impl Elapsed {
275 pub fn as_str(&self) -> &'static str {
276 match self {
277 Self::Took(_) => "known",
278 Self::Unknown => "unknown",
279 }
280 }
281
282 fn shown(&self, cx: &App) -> SharedString {
283 match self {
284 Self::Took(took) => took.clone(),
285 Self::Unknown => cx.strings().text(StringKey::AgentElapsedUnknown),
286 }
287 }
288}
289
290impl From<SharedString> for Elapsed {
291 fn from(value: SharedString) -> Self {
292 Self::Took(value)
293 }
294}
295
296impl From<&'static str> for Elapsed {
297 fn from(value: &'static str) -> Self {
298 Self::Took(SharedString::new_static(value))
299 }
300}
301
302impl From<String> for Elapsed {
303 fn from(value: String) -> Self {
304 Self::Took(SharedString::from(value))
305 }
306}
307
308#[derive(IntoElement)]
310pub struct ToolCallCard {
311 ident: Ident,
312 tool: SharedString,
313 arguments: Option<ToolBody>,
314 state: ToolCallState,
315 elapsed: Elapsed,
316 on_retry: Option<RetryHandler>,
317}
318
319impl std::fmt::Debug for ToolCallCard {
320 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321 formatter
322 .debug_struct("ToolCallCard")
323 .field("ident", &self.ident)
324 .field("tool", &self.tool)
325 .field("state", &self.state)
326 .field("elapsed", &self.elapsed)
327 .field("has_arguments", &self.arguments.is_some())
328 .field("has_handler", &self.on_retry.is_some())
329 .finish()
330 }
331}
332
333impl ToolCallCard {
334 pub fn new(ident: impl Into<Ident>, tool: impl Into<SharedString>) -> Self {
337 Self {
338 ident: ident.into(),
339 tool: tool.into(),
340 arguments: None,
341 state: ToolCallState::PendingApproval,
342 elapsed: Elapsed::Unknown,
343 on_retry: None,
344 }
345 }
346
347 pub fn arguments(mut self, arguments: impl Into<ToolBody>) -> Self {
349 self.arguments = Some(arguments.into());
350 self
351 }
352
353 pub fn state(mut self, state: ToolCallState) -> Self {
354 self.state = state;
355 self
356 }
357
358 pub fn elapsed(mut self, elapsed: impl Into<Elapsed>) -> Self {
359 self.elapsed = elapsed.into();
360 self
361 }
362
363 pub fn on_retry(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
369 self.on_retry = Some(Rc::new(handler));
370 self
371 }
372
373 fn retryable(&self) -> bool {
374 matches!(self.state, ToolCallState::Failed { .. }) && self.on_retry.is_some()
375 }
376}
377
378impl RenderOnce for ToolCallCard {
379 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
380 let theme = cx.theme().clone();
381 let ident = self.ident.clone();
382 let tone = self.state.tone();
383 let retryable = self.retryable();
384
385 let header = div()
386 .row()
387 .w_full()
388 .gap_token(&theme, Space::Sm)
389 .child({
390 let mark = IconView::new(self.state.glyph())
391 .small()
392 .tone(icon_tone(tone));
393 match self.state {
396 ToolCallState::Running => mark.spinning(ident.child("state.mark")),
397 _ => mark,
398 }
399 })
400 .child(
401 text(&theme, TypeScale::Code, self.tool.clone())
402 .flex_1()
403 .min_w_0()
404 .font_family(theme.typography.mono.clone()),
405 )
406 .child(
407 Badge::new(cx.strings().text(self.state.key()))
408 .tone(tone)
409 .id(ident.child("state")),
410 )
411 .children(self.state.ran().then(|| {
412 let words = self.elapsed.shown(cx);
413 text(&theme, TypeScale::Caption, words.clone())
414 .flex_none()
415 .text_tone(
416 &theme,
417 match self.elapsed {
418 Elapsed::Took(_) => TextTone::Muted,
419 Elapsed::Unknown => TextTone::Faint,
420 },
421 )
422 .semantic_in(
423 cx,
424 NodeSpec::new(ident.child("elapsed").semantic_id(), Role::Text)
425 .parent(ident.semantic_id())
426 .text(words)
427 .value(match &self.elapsed {
428 Elapsed::Took(took) => took.clone(),
429 Elapsed::Unknown => SharedString::new_static("unknown"),
430 }),
431 )
432 }));
433
434 let arguments = self.arguments.map(|body| {
435 block(
436 &ident.child("arguments"),
437 &ident,
438 &theme,
439 cx.strings().text(StringKey::AgentArguments),
440 &body,
441 cx,
442 )
443 });
444
445 let outcome = match &self.state {
449 ToolCallState::PendingApproval | ToolCallState::Running => None,
450 ToolCallState::Succeeded { output } => Some(match output {
451 ToolOutput::Body(body) => block(
452 &ident.child("result"),
453 &ident,
454 &theme,
455 cx.strings().text(StringKey::AgentResult),
456 body,
457 cx,
458 ),
459 ToolOutput::Silent => {
460 let words = cx.strings().text(StringKey::AgentNoOutput);
461 text(&theme, TypeScale::Caption, words.clone())
462 .text_tone(&theme, TextTone::Muted)
463 .semantic_in(
464 cx,
465 NodeSpec::new(ident.child("result").semantic_id(), Role::Text)
466 .parent(ident.semantic_id())
467 .text(words)
468 .value("nothing"),
469 )
470 .into_any_element()
471 }
472 }),
473 ToolCallState::Failed { error } => Some(
474 Callout::new(error.clone(), Tone::Danger)
475 .id(ident.child("error"))
476 .into_any_element(),
477 ),
478 ToolCallState::Refused { reason } => Some(
479 Callout::new(reason.clone(), Tone::Warning)
480 .id(ident.child("refusal"))
481 .into_any_element(),
482 ),
483 };
484
485 let retry = self.on_retry.filter(|_| retryable).map(|handler| {
486 Button::new(ident.child("retry"))
487 .label(cx.strings().text(StringKey::TryAgain))
488 .secondary()
489 .small()
490 .semantic_parent(ident.semantic_id())
491 .on_click(move |window, cx| handler(window, cx))
492 });
493
494 div()
495 .w_full()
496 .column()
497 .gap_token(&theme, Space::Sm)
498 .p_token(&theme, Space::Md)
499 .radius(&theme, Radius::Card)
500 .frame(&theme, Surface::Panel, Elevation::Raised)
501 .child(header)
502 .children(arguments)
503 .children(outcome)
504 .children(retry.map(|retry| div().row().child(retry)))
505 .semantic_in(
506 cx,
507 NodeSpec::new(ident.semantic_id(), Role::Group)
508 .text(self.tool.clone())
509 .value(self.state.as_str())
510 .busy(matches!(self.state, ToolCallState::Running)),
511 )
512 }
513}
514
515fn block(
518 ident: &Ident,
519 card: &Ident,
520 theme: &Theme,
521 label: SharedString,
522 body: &ToolBody,
523 cx: &mut App,
524) -> gpui::AnyElement {
525 let shape = body.shape(cx);
526 div()
527 .w_full()
528 .column()
529 .gap(px(2.0))
530 .child(
531 div()
532 .row()
533 .justify_between()
534 .gap_token(theme, Space::Sm)
535 .child(
536 text(theme, TypeScale::Caption, label.clone())
537 .text_tone(theme, TextTone::Faint),
538 )
539 .child(
543 text(theme, TypeScale::Caption, shape.clone())
544 .text_tone(theme, TextTone::Faint),
545 ),
546 )
547 .child(
548 div()
549 .w_full()
550 .px_token(theme, Space::Sm)
551 .py(px(2.0))
552 .radius(theme, Radius::Small)
553 .surface(theme, Surface::Raised)
554 .font_family(theme.typography.mono.clone())
555 .children(body.shown_lines().into_iter().map(|line| {
558 text(theme, TypeScale::Code, line).text_tone(theme, TextTone::Muted)
559 })),
560 )
561 .semantic_in(
562 cx,
563 NodeSpec::new(ident.semantic_id(), Role::Text)
564 .parent(card.semantic_id())
565 .text(label)
566 .value(shape),
569 )
570 .into_any_element()
571}
572
573fn icon_tone(tone: Tone) -> IconTone {
574 match tone {
575 Tone::Neutral => IconTone::Muted,
576 Tone::Accent => IconTone::Accent,
577 Tone::Success => IconTone::Success,
578 Tone::Warning => IconTone::Warning,
579 Tone::Danger => IconTone::Danger,
580 Tone::Info => IconTone::Info,
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn a_body_within_its_limit_is_not_truncated() {
590 let body = ToolBody::new("one\ntwo").max_lines(4);
591 assert_eq!(body.line_count(), 2);
592 assert_eq!(body.shown_line_count(), 2);
593 assert!(!body.is_truncated());
594 assert_eq!(body.shown_lines(), vec!["one", "two"]);
595 }
596
597 #[test]
598 fn a_body_past_its_limit_keeps_the_whole_count() {
599 let body = ToolBody::new("one\ntwo\nthree").max_lines(1);
600 assert!(body.is_truncated());
601 assert_eq!(body.shown_line_count(), 1);
602 assert_eq!(body.line_count(), 3);
603 assert_eq!(body.shown_lines(), vec!["one"]);
604 assert_eq!(
605 body.text().as_ref(),
606 "one\ntwo\nthree",
607 "the caller's data comes back whole; only the drawing is cut"
608 );
609 }
610
611 #[test]
612 fn a_limit_of_zero_still_draws_a_line() {
613 let body = ToolBody::new("one\ntwo").max_lines(0);
614 assert_eq!(body.shown_line_count(), 1);
615 }
616
617 #[test]
618 fn every_state_publishes_its_own_name() {
619 let names = [
620 ToolCallState::PendingApproval.as_str(),
621 ToolCallState::Running.as_str(),
622 ToolCallState::succeeded_silently().as_str(),
623 ToolCallState::failed("boom").as_str(),
624 ToolCallState::refused("no").as_str(),
625 ];
626 let mut unique = names.to_vec();
627 unique.sort_unstable();
628 unique.dedup();
629 assert_eq!(unique.len(), names.len());
630 }
631
632 #[test]
633 fn only_a_call_that_ran_has_a_duration_to_report() {
634 assert!(!ToolCallState::PendingApproval.ran());
635 assert!(!ToolCallState::refused("declined").ran());
636 assert!(ToolCallState::Running.ran());
637 assert!(ToolCallState::succeeded_silently().ran());
638 assert!(ToolCallState::failed("boom").ran());
639 }
640}