use std::rc::Rc;
use gpui::{
App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, 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, TypeScale};
use crate::display::icon::{Icon as IconView, IconTone};
use crate::foundation::{FocusRing, Ident, Pressable, Sizable, StyledExt, text};
use crate::strings::{ActiveStrings, StringKey};
type ToggleHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Reasoning {
Present(SharedString),
Withheld(SharedString),
Absent,
}
impl Reasoning {
pub fn present(text: impl Into<SharedString>) -> Self {
Self::Present(text.into())
}
pub fn withheld(reason: impl Into<SharedString>) -> Self {
Self::Withheld(reason.into())
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Present(_) => "present",
Self::Withheld(_) => "withheld",
Self::Absent => "absent",
}
}
pub fn is_disclosable(&self) -> bool {
matches!(self, Self::Present(_))
}
}
impl From<SharedString> for Reasoning {
fn from(value: SharedString) -> Self {
Self::Present(value)
}
}
impl From<&'static str> for Reasoning {
fn from(value: &'static str) -> Self {
Self::Present(SharedString::new_static(value))
}
}
impl From<String> for Reasoning {
fn from(value: String) -> Self {
Self::Present(SharedString::from(value))
}
}
#[derive(IntoElement)]
pub struct ThinkingBlock {
ident: Ident,
reasoning: Reasoning,
expanded: bool,
thinking: bool,
on_toggle: Option<ToggleHandler>,
}
impl std::fmt::Debug for ThinkingBlock {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ThinkingBlock")
.field("ident", &self.ident)
.field("reasoning", &self.reasoning.as_str())
.field("expanded", &self.expanded)
.field("has_handler", &self.on_toggle.is_some())
.finish()
}
}
impl ThinkingBlock {
pub fn new(ident: impl Into<Ident>, reasoning: Reasoning) -> Self {
Self {
ident: ident.into(),
reasoning,
expanded: false,
thinking: false,
on_toggle: None,
}
}
pub fn expanded(mut self, expanded: bool) -> Self {
self.expanded = expanded;
self
}
pub fn thinking(mut self, thinking: bool) -> Self {
self.thinking = thinking;
self
}
pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
fn open(&self) -> bool {
self.expanded && self.reasoning.is_disclosable()
}
fn actionable(&self) -> bool {
self.reasoning.is_disclosable() && self.on_toggle.is_some()
}
}
impl RenderOnce for ThinkingBlock {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let ident = self.ident.clone();
let open = self.open();
let actionable = self.actionable();
let label = cx.strings().text(StringKey::AgentReasoning);
let (mark, tone) = match self.reasoning {
Reasoning::Present(_) if self.thinking => {
(StringKey::AgentReasoningThinking, IconTone::Accent)
}
Reasoning::Present(_) => (StringKey::AgentReasoning, IconTone::Muted),
Reasoning::Withheld(_) => (StringKey::AgentReasoningWithheld, IconTone::Warning),
Reasoning::Absent => (StringKey::AgentReasoningAbsent, IconTone::Faint),
};
let mark = cx.strings().text(mark);
let mut header = div()
.id(ident.element_id())
.row()
.w_full()
.gap_token(&theme, Space::Sm)
.px_token(&theme, Space::Sm)
.py(px(theme.space(Space::Xs)))
.child({
let mark = IconView::new(Glyph::Chat).small().tone(tone);
if self.thinking {
mark.breathing(ident.child("mark"))
} else {
mark
}
})
.child(
text(&theme, TypeScale::Label, label.clone())
.flex_1()
.min_w_0()
.text_tone(&theme, gpui_kit_theme::TextTone::Muted),
)
.child(
text(&theme, TypeScale::Caption, mark)
.flex_none()
.text_color(match self.reasoning {
Reasoning::Withheld(_) => theme.colors.warning,
_ if self.thinking => theme.colors.accent,
_ => theme.colors.text_faint,
}),
)
.when(actionable, |element| {
element
.cursor_pointer()
.tab_index(0)
.pressable(cx)
.hover(|style| style.bg(theme.colors.hover))
.focus_ring(&theme)
});
if let Some(handler) = self.on_toggle.clone().filter(|_| actionable) {
let key_handler = Rc::clone(&handler);
header = header
.on_click(move |_, window, cx| handler(!open, window, cx))
.on_key_down(move |event, window, cx| {
if matches!(event.keystroke.key.as_str(), "enter" | "space") {
key_handler(!open, window, cx);
cx.stop_propagation();
}
});
}
let header = header.semantic_in(
cx,
NodeSpec::new(
ident.semantic_id(),
if actionable { Role::Button } else { Role::Text },
)
.text(label)
.value(self.reasoning.as_str())
.busy(self.thinking)
.expanded(open),
);
let body = match &self.reasoning {
Reasoning::Present(text) if open => Some(
div()
.w_full()
.px_token(&theme, Space::Sm)
.pb(px(theme.space(Space::Xs)))
.children(text.lines().map(|line| {
crate::foundation::text(
&theme,
TypeScale::Body,
SharedString::from(line.to_string()),
)
.text_tone(&theme, TextTone::Muted)
}))
.semantic_in(
cx,
NodeSpec::new(ident.child("body").semantic_id(), Role::Text)
.parent(ident.semantic_id())
.value("present"),
),
),
Reasoning::Present(_) => None,
Reasoning::Withheld(reason) => Some(
text(&theme, TypeScale::Body, reason.clone())
.w_full()
.px_token(&theme, Space::Sm)
.pb(px(theme.space(Space::Xs)))
.text_color(theme.colors.warning)
.semantic_in(
cx,
NodeSpec::new(ident.child("withheld").semantic_id(), Role::Status)
.parent(ident.semantic_id())
.text(reason.clone())
.value("withheld"),
),
),
Reasoning::Absent => None,
};
div()
.w_full()
.column()
.radius(&theme, Radius::Card)
.frame(&theme, Surface::Panel, Elevation::Raised)
.overflow_hidden()
.child(header)
.children(body)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_three_states_publish_three_names() {
assert_eq!(Reasoning::present("because").as_str(), "present");
assert_eq!(Reasoning::withheld("policy").as_str(), "withheld");
assert_eq!(Reasoning::Absent.as_str(), "absent");
}
#[test]
fn reasoning_that_exists_but_is_empty_is_not_absent() {
assert_eq!(Reasoning::present(String::new()).as_str(), "present");
assert_ne!(Reasoning::present(String::new()), Reasoning::Absent);
}
#[test]
fn only_reasoning_that_is_there_can_be_opened() {
assert!(Reasoning::present("because").is_disclosable());
assert!(!Reasoning::withheld("policy").is_disclosable());
assert!(!Reasoning::Absent.is_disclosable());
}
}