Skip to main content

gpui_kit/agent/
thinking.rs

1//! Model reasoning, collapsed by default.
2//!
3//! # Why this is not a disclosure with a string in it
4//!
5//! Three facts get collapsed into one wherever reasoning is shown, and only
6//! one of them is true at a time:
7//!
8//! - reasoning exists and is not on screen, because nobody opened it;
9//! - reasoning exists and cannot be shown, because whoever produced it
10//!   withheld it;
11//! - there is no reasoning, because none was produced.
12//!
13//! An `Option<String>` can express two of those and quietly loses the third:
14//! `None` would have to stand for both "withheld" and "none", and a block that
15//! says nothing was produced when in fact it was withheld is stating something
16//! nobody established. So [`Reasoning`] has three variants, no `Option`, and
17//! no conversion from one — a caller holding a `None` has to decide which of
18//! the two absences it is before it can build this component.
19//!
20//! Withholding is somebody's decision, so [`Reasoning::Withheld`] carries that
21//! somebody's words and shows them verbatim.
22
23use 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/// What is known about a turn's reasoning.
40///
41/// Deliberately not `Option<SharedString>`, and deliberately without a
42/// `From<Option<_>>`: the two ways of having no text to show are different
43/// facts, and the type is the thing that stops them being confused.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Reasoning {
46    /// It exists and this is it. An empty string is still reasoning that
47    /// exists; it is not [`Reasoning::Absent`].
48    Present(SharedString),
49    /// It exists and was withheld, in the withholder's own words.
50    Withheld(SharedString),
51    /// None was produced.
52    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    /// The name the semantic node publishes.
65    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    /// Whether there is anything a disclosure could disclose.
74    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/// A collapsed block of model reasoning.
98///
99/// Whether it is open is the caller's, as it is for
100/// [`Accordion`](crate::navigation::accordion::Accordion): the block reports
101/// the state it should take and shows exactly the state it was given.
102#[derive(IntoElement)]
103pub struct ThinkingBlock {
104    ident: Ident,
105    reasoning: Reasoning,
106    expanded: bool,
107    /// Whether the reasoning is still arriving. Separate from the reasoning
108    /// itself, because text that has stopped growing and text that is still
109    /// growing look identical and mean different things.
110    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    /// The reasoning is a constructor argument because there is no sensible
128    /// default: which of the three states holds is the whole question.
129    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    /// Collapsed unless the caller says otherwise, and reasoning that cannot
140    /// be disclosed stays shut whatever the caller says.
141    pub fn expanded(mut self, expanded: bool) -> Self {
142        self.expanded = expanded;
143        self
144    }
145
146    /// Reports that the reasoning is still being produced.
147    ///
148    /// Nothing else on the block says this. Reasoning that has finished and
149    /// reasoning still being written are the same words in the same place, so
150    /// without this a reader watching a stalled run and a reader watching a
151    /// working one see the same picture.
152    pub fn thinking(mut self, thinking: bool) -> Self {
153        self.thinking = thinking;
154        self
155    }
156
157    /// Reports the state the block should take next.
158    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            // Still arriving outranks the settled word, because the breathing
182            // glyph that also reports it is not there for a reader who has
183            // animation turned off, and two states that look identical are
184            // one state as far as that reader is concerned.
185            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                // Deliberation breathes rather than turns: a turn claims work
204                // is being got through, and this one has nothing to report
205                // beyond that it is still going.
206                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            // Which of the three states holds is on screen without opening
219            // anything, because two of them can never be opened.
220            .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        // A block with nothing to disclose never reaches the handler at all,
239        // rather than installing one that would decline to fire.
240        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        // A withheld or absent block has no body to open, so it says which of
265        // the two it is where a body would be. Neither is drawn as the other,
266        // and neither is drawn as a shut disclosure.
267        let body = match &self.reasoning {
268            // A closed section renders no body at all, the rule `Accordion`
269            // keeps: nothing invisible stays addressable.
270            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}