use std::rc::Rc;
use gpui::{App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, px};
use gpui_kit_assets::Icon as Glyph;
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TextTone, Theme, TypeScale};
use crate::controls::button::Button;
use crate::display::badge::{Badge, Tone};
use crate::display::icon::{Icon as IconView, IconTone};
use crate::display::status::Callout;
use crate::foundation::{Ident, Sizable, StyledExt, text};
use crate::strings::{ActiveStrings, StringKey};
type RetryHandler = Rc<dyn Fn(&mut Window, &mut App)>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolBody {
text: SharedString,
max_lines: Option<usize>,
}
impl ToolBody {
pub fn new(text: impl Into<SharedString>) -> Self {
Self {
text: text.into(),
max_lines: None,
}
}
pub fn max_lines(mut self, lines: usize) -> Self {
self.max_lines = Some(lines.max(1));
self
}
pub fn text(&self) -> &SharedString {
&self.text
}
pub fn line_count(&self) -> usize {
self.text.lines().count().max(1)
}
pub fn shown_line_count(&self) -> usize {
match self.max_lines {
Some(limit) => limit.min(self.line_count()),
None => self.line_count(),
}
}
pub fn is_truncated(&self) -> bool {
self.shown_line_count() < self.line_count()
}
fn shown_lines(&self) -> Vec<SharedString> {
self.text
.lines()
.take(self.shown_line_count())
.map(|line| SharedString::from(line.to_string()))
.collect()
}
pub fn shape(&self, cx: &App) -> SharedString {
let total = self.line_count();
if self.is_truncated() {
return cx.strings().format(
StringKey::AgentTruncated,
&[&self.shown_line_count().to_string(), &total.to_string()],
);
}
if total == 1 {
cx.strings().text(StringKey::AgentLinesOne)
} else {
cx.strings()
.format(StringKey::AgentLinesMany, &[&total.to_string()])
}
}
}
impl From<SharedString> for ToolBody {
fn from(value: SharedString) -> Self {
Self::new(value)
}
}
impl From<&'static str> for ToolBody {
fn from(value: &'static str) -> Self {
Self::new(value)
}
}
impl From<String> for ToolBody {
fn from(value: String) -> Self {
Self::new(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolOutput {
Body(ToolBody),
Silent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolCallState {
PendingApproval,
Running,
Succeeded {
output: ToolOutput,
},
Failed {
error: SharedString,
},
Refused {
reason: SharedString,
},
}
impl ToolCallState {
pub fn succeeded(output: impl Into<ToolBody>) -> Self {
Self::Succeeded {
output: ToolOutput::Body(output.into()),
}
}
pub fn succeeded_silently() -> Self {
Self::Succeeded {
output: ToolOutput::Silent,
}
}
pub fn failed(error: impl Into<SharedString>) -> Self {
Self::Failed {
error: error.into(),
}
}
pub fn refused(reason: impl Into<SharedString>) -> Self {
Self::Refused {
reason: reason.into(),
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::PendingApproval => "pending-approval",
Self::Running => "running",
Self::Succeeded { .. } => "succeeded",
Self::Failed { .. } => "failed",
Self::Refused { .. } => "refused",
}
}
pub fn reason(&self) -> Option<&SharedString> {
match self {
Self::Failed { error } => Some(error),
Self::Refused { reason } => Some(reason),
_ => None,
}
}
fn ran(&self) -> bool {
matches!(
self,
Self::Running | Self::Succeeded { .. } | Self::Failed { .. }
)
}
fn tone(&self) -> Tone {
match self {
Self::PendingApproval => Tone::Info,
Self::Running => Tone::Accent,
Self::Succeeded { .. } => Tone::Success,
Self::Failed { .. } => Tone::Danger,
Self::Refused { .. } => Tone::Warning,
}
}
fn glyph(&self) -> Glyph {
match self {
Self::PendingApproval => Glyph::Key,
Self::Running => Glyph::Refresh,
Self::Succeeded { .. } => Glyph::Check,
Self::Failed { .. } => Glyph::Danger,
Self::Refused { .. } => Glyph::CloseCircle,
}
}
fn key(&self) -> StringKey {
match self {
Self::PendingApproval => StringKey::AgentPendingApproval,
Self::Running => StringKey::AgentRunning,
Self::Succeeded { .. } => StringKey::AgentSucceeded,
Self::Failed { .. } => StringKey::AgentFailed,
Self::Refused { .. } => StringKey::AgentDeclined,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Elapsed {
Took(SharedString),
#[default]
Unknown,
}
impl Elapsed {
pub fn as_str(&self) -> &'static str {
match self {
Self::Took(_) => "known",
Self::Unknown => "unknown",
}
}
fn shown(&self, cx: &App) -> SharedString {
match self {
Self::Took(took) => took.clone(),
Self::Unknown => cx.strings().text(StringKey::AgentElapsedUnknown),
}
}
}
impl From<SharedString> for Elapsed {
fn from(value: SharedString) -> Self {
Self::Took(value)
}
}
impl From<&'static str> for Elapsed {
fn from(value: &'static str) -> Self {
Self::Took(SharedString::new_static(value))
}
}
impl From<String> for Elapsed {
fn from(value: String) -> Self {
Self::Took(SharedString::from(value))
}
}
#[derive(IntoElement)]
pub struct ToolCallCard {
ident: Ident,
tool: SharedString,
arguments: Option<ToolBody>,
state: ToolCallState,
elapsed: Elapsed,
on_retry: Option<RetryHandler>,
}
impl std::fmt::Debug for ToolCallCard {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ToolCallCard")
.field("ident", &self.ident)
.field("tool", &self.tool)
.field("state", &self.state)
.field("elapsed", &self.elapsed)
.field("has_arguments", &self.arguments.is_some())
.field("has_handler", &self.on_retry.is_some())
.finish()
}
}
impl ToolCallCard {
pub fn new(ident: impl Into<Ident>, tool: impl Into<SharedString>) -> Self {
Self {
ident: ident.into(),
tool: tool.into(),
arguments: None,
state: ToolCallState::PendingApproval,
elapsed: Elapsed::Unknown,
on_retry: None,
}
}
pub fn arguments(mut self, arguments: impl Into<ToolBody>) -> Self {
self.arguments = Some(arguments.into());
self
}
pub fn state(mut self, state: ToolCallState) -> Self {
self.state = state;
self
}
pub fn elapsed(mut self, elapsed: impl Into<Elapsed>) -> Self {
self.elapsed = elapsed.into();
self
}
pub fn on_retry(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_retry = Some(Rc::new(handler));
self
}
fn retryable(&self) -> bool {
matches!(self.state, ToolCallState::Failed { .. }) && self.on_retry.is_some()
}
}
impl RenderOnce for ToolCallCard {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let ident = self.ident.clone();
let tone = self.state.tone();
let retryable = self.retryable();
let header = div()
.row()
.w_full()
.gap_token(&theme, Space::Sm)
.child({
let mark = IconView::new(self.state.glyph())
.small()
.tone(icon_tone(tone));
match self.state {
ToolCallState::Running => mark.spinning(ident.child("state.mark")),
_ => mark,
}
})
.child(
text(&theme, TypeScale::Code, self.tool.clone())
.flex_1()
.min_w_0()
.font_family(theme.typography.mono.clone()),
)
.child(
Badge::new(cx.strings().text(self.state.key()))
.tone(tone)
.id(ident.child("state")),
)
.children(self.state.ran().then(|| {
let words = self.elapsed.shown(cx);
text(&theme, TypeScale::Caption, words.clone())
.flex_none()
.text_tone(
&theme,
match self.elapsed {
Elapsed::Took(_) => TextTone::Muted,
Elapsed::Unknown => TextTone::Faint,
},
)
.semantic_in(
cx,
NodeSpec::new(ident.child("elapsed").semantic_id(), Role::Text)
.parent(ident.semantic_id())
.text(words)
.value(match &self.elapsed {
Elapsed::Took(took) => took.clone(),
Elapsed::Unknown => SharedString::new_static("unknown"),
}),
)
}));
let arguments = self.arguments.map(|body| {
block(
&ident.child("arguments"),
&ident,
&theme,
cx.strings().text(StringKey::AgentArguments),
&body,
cx,
)
});
let outcome = match &self.state {
ToolCallState::PendingApproval | ToolCallState::Running => None,
ToolCallState::Succeeded { output } => Some(match output {
ToolOutput::Body(body) => block(
&ident.child("result"),
&ident,
&theme,
cx.strings().text(StringKey::AgentResult),
body,
cx,
),
ToolOutput::Silent => {
let words = cx.strings().text(StringKey::AgentNoOutput);
text(&theme, TypeScale::Caption, words.clone())
.text_tone(&theme, TextTone::Muted)
.semantic_in(
cx,
NodeSpec::new(ident.child("result").semantic_id(), Role::Text)
.parent(ident.semantic_id())
.text(words)
.value("nothing"),
)
.into_any_element()
}
}),
ToolCallState::Failed { error } => Some(
Callout::new(error.clone(), Tone::Danger)
.id(ident.child("error"))
.into_any_element(),
),
ToolCallState::Refused { reason } => Some(
Callout::new(reason.clone(), Tone::Warning)
.id(ident.child("refusal"))
.into_any_element(),
),
};
let retry = self.on_retry.filter(|_| retryable).map(|handler| {
Button::new(ident.child("retry"))
.label(cx.strings().text(StringKey::TryAgain))
.secondary()
.small()
.semantic_parent(ident.semantic_id())
.on_click(move |window, cx| handler(window, cx))
});
div()
.w_full()
.column()
.gap_token(&theme, Space::Sm)
.p_token(&theme, Space::Md)
.radius(&theme, Radius::Card)
.frame(&theme, Surface::Panel, Elevation::Raised)
.child(header)
.children(arguments)
.children(outcome)
.children(retry.map(|retry| div().row().child(retry)))
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Group)
.text(self.tool.clone())
.value(self.state.as_str())
.busy(matches!(self.state, ToolCallState::Running)),
)
}
}
fn block(
ident: &Ident,
card: &Ident,
theme: &Theme,
label: SharedString,
body: &ToolBody,
cx: &mut App,
) -> gpui::AnyElement {
let shape = body.shape(cx);
div()
.w_full()
.column()
.gap(px(2.0))
.child(
div()
.row()
.justify_between()
.gap_token(theme, Space::Sm)
.child(
text(theme, TypeScale::Caption, label.clone())
.text_tone(theme, TextTone::Faint),
)
.child(
text(theme, TypeScale::Caption, shape.clone())
.text_tone(theme, TextTone::Faint),
),
)
.child(
div()
.w_full()
.px_token(theme, Space::Sm)
.py(px(2.0))
.radius(theme, Radius::Small)
.surface(theme, Surface::Raised)
.font_family(theme.typography.mono.clone())
.children(body.shown_lines().into_iter().map(|line| {
text(theme, TypeScale::Code, line).text_tone(theme, TextTone::Muted)
})),
)
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Text)
.parent(card.semantic_id())
.text(label)
.value(shape),
)
.into_any_element()
}
fn icon_tone(tone: Tone) -> IconTone {
match tone {
Tone::Neutral => IconTone::Muted,
Tone::Accent => IconTone::Accent,
Tone::Success => IconTone::Success,
Tone::Warning => IconTone::Warning,
Tone::Danger => IconTone::Danger,
Tone::Info => IconTone::Info,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_body_within_its_limit_is_not_truncated() {
let body = ToolBody::new("one\ntwo").max_lines(4);
assert_eq!(body.line_count(), 2);
assert_eq!(body.shown_line_count(), 2);
assert!(!body.is_truncated());
assert_eq!(body.shown_lines(), vec!["one", "two"]);
}
#[test]
fn a_body_past_its_limit_keeps_the_whole_count() {
let body = ToolBody::new("one\ntwo\nthree").max_lines(1);
assert!(body.is_truncated());
assert_eq!(body.shown_line_count(), 1);
assert_eq!(body.line_count(), 3);
assert_eq!(body.shown_lines(), vec!["one"]);
assert_eq!(
body.text().as_ref(),
"one\ntwo\nthree",
"the caller's data comes back whole; only the drawing is cut"
);
}
#[test]
fn a_limit_of_zero_still_draws_a_line() {
let body = ToolBody::new("one\ntwo").max_lines(0);
assert_eq!(body.shown_line_count(), 1);
}
#[test]
fn every_state_publishes_its_own_name() {
let names = [
ToolCallState::PendingApproval.as_str(),
ToolCallState::Running.as_str(),
ToolCallState::succeeded_silently().as_str(),
ToolCallState::failed("boom").as_str(),
ToolCallState::refused("no").as_str(),
];
let mut unique = names.to_vec();
unique.sort_unstable();
unique.dedup();
assert_eq!(unique.len(), names.len());
}
#[test]
fn only_a_call_that_ran_has_a_duration_to_report() {
assert!(!ToolCallState::PendingApproval.ran());
assert!(!ToolCallState::refused("declined").ran());
assert!(ToolCallState::Running.ran());
assert!(ToolCallState::succeeded_silently().ran());
assert!(ToolCallState::failed("boom").ran());
}
}