1use std::rc::Rc;
24
25use gpui::{
26 App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
27 StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
28};
29use gpui_kit_assets::Icon as Glyph;
30use gpui_kit_semantics::{NodeSpec, Role, Semantic};
31use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TextTone, TypeScale};
32
33use crate::display::icon::{Icon as IconView, IconTone};
34use crate::foundation::{FocusRing, Ident, Pressable, Sizable, StyledExt, text};
35use crate::strings::{ActiveStrings, StringKey};
36
37type ToggleHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Reasoning {
46 Present(SharedString),
49 Withheld(SharedString),
51 Absent,
53}
54
55impl Reasoning {
56 pub fn present(text: impl Into<SharedString>) -> Self {
57 Self::Present(text.into())
58 }
59
60 pub fn withheld(reason: impl Into<SharedString>) -> Self {
61 Self::Withheld(reason.into())
62 }
63
64 pub fn as_str(&self) -> &'static str {
66 match self {
67 Self::Present(_) => "present",
68 Self::Withheld(_) => "withheld",
69 Self::Absent => "absent",
70 }
71 }
72
73 pub fn is_disclosable(&self) -> bool {
75 matches!(self, Self::Present(_))
76 }
77}
78
79impl From<SharedString> for Reasoning {
80 fn from(value: SharedString) -> Self {
81 Self::Present(value)
82 }
83}
84
85impl From<&'static str> for Reasoning {
86 fn from(value: &'static str) -> Self {
87 Self::Present(SharedString::new_static(value))
88 }
89}
90
91impl From<String> for Reasoning {
92 fn from(value: String) -> Self {
93 Self::Present(SharedString::from(value))
94 }
95}
96
97#[derive(IntoElement)]
103pub struct ThinkingBlock {
104 ident: Ident,
105 reasoning: Reasoning,
106 expanded: bool,
107 thinking: bool,
111 on_toggle: Option<ToggleHandler>,
112}
113
114impl std::fmt::Debug for ThinkingBlock {
115 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 formatter
117 .debug_struct("ThinkingBlock")
118 .field("ident", &self.ident)
119 .field("reasoning", &self.reasoning.as_str())
120 .field("expanded", &self.expanded)
121 .field("has_handler", &self.on_toggle.is_some())
122 .finish()
123 }
124}
125
126impl ThinkingBlock {
127 pub fn new(ident: impl Into<Ident>, reasoning: Reasoning) -> Self {
130 Self {
131 ident: ident.into(),
132 reasoning,
133 expanded: false,
134 thinking: false,
135 on_toggle: None,
136 }
137 }
138
139 pub fn expanded(mut self, expanded: bool) -> Self {
142 self.expanded = expanded;
143 self
144 }
145
146 pub fn thinking(mut self, thinking: bool) -> Self {
153 self.thinking = thinking;
154 self
155 }
156
157 pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
159 self.on_toggle = Some(Rc::new(handler));
160 self
161 }
162
163 fn open(&self) -> bool {
164 self.expanded && self.reasoning.is_disclosable()
165 }
166
167 fn actionable(&self) -> bool {
168 self.reasoning.is_disclosable() && self.on_toggle.is_some()
169 }
170}
171
172impl RenderOnce for ThinkingBlock {
173 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
174 let theme = cx.theme().clone();
175 let ident = self.ident.clone();
176 let open = self.open();
177 let actionable = self.actionable();
178 let label = cx.strings().text(StringKey::AgentReasoning);
179
180 let (mark, tone) = match self.reasoning {
181 Reasoning::Present(_) if self.thinking => {
186 (StringKey::AgentReasoningThinking, IconTone::Accent)
187 }
188 Reasoning::Present(_) => (StringKey::AgentReasoning, IconTone::Muted),
189 Reasoning::Withheld(_) => (StringKey::AgentReasoningWithheld, IconTone::Warning),
190 Reasoning::Absent => (StringKey::AgentReasoningAbsent, IconTone::Faint),
191 };
192 let mark = cx.strings().text(mark);
193
194 let mut header = div()
195 .id(ident.element_id())
196 .row()
197 .w_full()
198 .gap_token(&theme, Space::Sm)
199 .px_token(&theme, Space::Sm)
200 .py(px(theme.space(Space::Xs)))
201 .child({
202 let mark = IconView::new(Glyph::Chat).small().tone(tone);
203 if self.thinking {
207 mark.breathing(ident.child("mark"))
208 } else {
209 mark
210 }
211 })
212 .child(
213 text(&theme, TypeScale::Label, label.clone())
214 .flex_1()
215 .min_w_0()
216 .text_tone(&theme, gpui_kit_theme::TextTone::Muted),
217 )
218 .child(
221 text(&theme, TypeScale::Caption, mark)
222 .flex_none()
223 .text_color(match self.reasoning {
224 Reasoning::Withheld(_) => theme.colors.warning,
225 _ if self.thinking => theme.colors.accent,
226 _ => theme.colors.text_faint,
227 }),
228 )
229 .when(actionable, |element| {
230 element
231 .cursor_pointer()
232 .tab_index(0)
233 .pressable(cx)
234 .hover(|style| style.bg(theme.colors.hover))
235 .focus_ring(&theme)
236 });
237
238 if let Some(handler) = self.on_toggle.clone().filter(|_| actionable) {
241 let key_handler = Rc::clone(&handler);
242 header = header
243 .on_click(move |_, window, cx| handler(!open, window, cx))
244 .on_key_down(move |event, window, cx| {
245 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
246 key_handler(!open, window, cx);
247 cx.stop_propagation();
248 }
249 });
250 }
251
252 let header = header.semantic_in(
253 cx,
254 NodeSpec::new(
255 ident.semantic_id(),
256 if actionable { Role::Button } else { Role::Text },
257 )
258 .text(label)
259 .value(self.reasoning.as_str())
260 .busy(self.thinking)
261 .expanded(open),
262 );
263
264 let body = match &self.reasoning {
268 Reasoning::Present(text) if open => Some(
271 div()
272 .w_full()
273 .px_token(&theme, Space::Sm)
274 .pb(px(theme.space(Space::Xs)))
275 .children(text.lines().map(|line| {
276 crate::foundation::text(
277 &theme,
278 TypeScale::Body,
279 SharedString::from(line.to_string()),
280 )
281 .text_tone(&theme, TextTone::Muted)
282 }))
283 .semantic_in(
284 cx,
285 NodeSpec::new(ident.child("body").semantic_id(), Role::Text)
286 .parent(ident.semantic_id())
287 .value("present"),
288 ),
289 ),
290 Reasoning::Present(_) => None,
291 Reasoning::Withheld(reason) => Some(
292 text(&theme, TypeScale::Body, reason.clone())
293 .w_full()
294 .px_token(&theme, Space::Sm)
295 .pb(px(theme.space(Space::Xs)))
296 .text_color(theme.colors.warning)
297 .semantic_in(
298 cx,
299 NodeSpec::new(ident.child("withheld").semantic_id(), Role::Status)
300 .parent(ident.semantic_id())
301 .text(reason.clone())
302 .value("withheld"),
303 ),
304 ),
305 Reasoning::Absent => None,
306 };
307
308 div()
309 .w_full()
310 .column()
311 .radius(&theme, Radius::Card)
312 .frame(&theme, Surface::Panel, Elevation::Raised)
313 .overflow_hidden()
314 .child(header)
315 .children(body)
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn the_three_states_publish_three_names() {
325 assert_eq!(Reasoning::present("because").as_str(), "present");
326 assert_eq!(Reasoning::withheld("policy").as_str(), "withheld");
327 assert_eq!(Reasoning::Absent.as_str(), "absent");
328 }
329
330 #[test]
331 fn reasoning_that_exists_but_is_empty_is_not_absent() {
332 assert_eq!(Reasoning::present(String::new()).as_str(), "present");
333 assert_ne!(Reasoning::present(String::new()), Reasoning::Absent);
334 }
335
336 #[test]
337 fn only_reasoning_that_is_there_can_be_opened() {
338 assert!(Reasoning::present("because").is_disclosable());
339 assert!(!Reasoning::withheld("policy").is_disclosable());
340 assert!(!Reasoning::Absent.is_disclosable());
341 }
342}