Skip to main content

guise/ai/
reasoning.rs

1//! `AIReasoning` — extended thinking, folded away.
2//!
3//! Reasoning output is usually longer than the answer it produced and is not
4//! what the reader came for, so it collapses by default. It is not hidden
5//! though: being able to open it is the point, and while it is still streaming
6//! the header says so, because a collapsed block that is quietly filling up is
7//! the one case where the user wants to know before they open it.
8//!
9//! The open state belongs to whatever owns the transcript — a turn scrolled
10//! out of view should not forget it was expanded — so this is a controlled
11//! component: pass `open` and an `on_toggle`.
12//!
13//! ```ignore
14//! AIReasoning::new(("reasoning", turn_index), text)
15//!     .open(turn.reasoning_open)
16//!     .streaming(turn.streaming)
17//!     .on_toggle(cx.listener(|this, _, _, cx| this.toggle_reasoning(cx)))
18//! ```
19
20use gpui::prelude::*;
21use gpui::{div, px, App, ClickEvent, ElementId, IntoElement, SharedString, Window};
22
23use crate::devtools::Probed;
24use crate::icon::{Icon, IconName};
25use crate::input::ClickHandler;
26use crate::markdown::Markdown;
27use crate::theme::{theme, ColorName, Size};
28
29/// A collapsible block of the model's reasoning.
30#[derive(IntoElement)]
31pub struct AIReasoning {
32    id: ElementId,
33    text: SharedString,
34    label: Option<SharedString>,
35    open: bool,
36    streaming: bool,
37    size: Size,
38    on_toggle: Option<ClickHandler>,
39}
40
41impl AIReasoning {
42    pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
43        AIReasoning {
44            id: id.into(),
45            text: text.into(),
46            label: None,
47            open: false,
48            streaming: false,
49            size: Size::Sm,
50            on_toggle: None,
51        }
52    }
53
54    /// Override the header text. The default names the state.
55    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
56        self.label = Some(label.into());
57        self
58    }
59
60    pub fn open(mut self, open: bool) -> Self {
61        self.open = open;
62        self
63    }
64
65    /// Still arriving, so the header says so even while collapsed.
66    pub fn streaming(mut self, streaming: bool) -> Self {
67        self.streaming = streaming;
68        self
69    }
70
71    pub fn size(mut self, size: Size) -> Self {
72        self.size = size;
73        self
74    }
75
76    pub fn on_toggle(
77        mut self,
78        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
79    ) -> Self {
80        self.on_toggle = Some(Box::new(handler));
81        self
82    }
83}
84
85impl RenderOnce for AIReasoning {
86    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
87        let t = theme(cx);
88        let dimmed = t.dimmed().hsla();
89        let border = t.border().hsla();
90        let font_xs = t.font_size(Size::Xs);
91
92        let label = self.label.unwrap_or_else(|| {
93            if self.streaming {
94                SharedString::new_static("Thinking\u{2026}")
95            } else {
96                SharedString::new_static("Reasoning")
97            }
98        });
99
100        let header = div()
101            .id(self.id)
102            .flex()
103            .items_center()
104            .gap(px(6.0))
105            .cursor_pointer()
106            .text_size(px(font_xs))
107            .text_color(dimmed)
108            .child(
109                Icon::new(if self.open {
110                    IconName::ChevronDown
111                } else {
112                    IconName::ChevronRight
113                })
114                .size(Size::Xs)
115                .color(ColorName::Gray),
116            )
117            .child(
118                Icon::new(IconName::Brain)
119                    .size(Size::Xs)
120                    .color(ColorName::Gray),
121            )
122            .child(label)
123            .when_some(self.on_toggle, |header, handler| {
124                header.on_click(move |event, window, cx| handler(event, window, cx))
125            });
126
127        div()
128            .flex()
129            .flex_col()
130            .gap(px(6.0))
131            .w_full()
132            .child(header)
133            .when(self.open, |column| {
134                column.child(
135                    div()
136                        .w_full()
137                        .pl(px(10.0))
138                        .border_l_2()
139                        .border_color(border)
140                        .text_color(dimmed)
141                        .child(Markdown::new(self.text).size(self.size)),
142                )
143            })
144            .probe("AIReasoning")
145    }
146}