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}