Skip to main content

guise/ai/
chatview.rs

1//! `AIChatView` — the transcript.
2//!
3//! This owns the conversation so a host doesn't have to re-derive one from its
4//! own state every frame: push a user turn, open an assistant turn, feed it
5//! deltas as they arrive, close it. The host keeps the network; this keeps the
6//! list, the scroll position, and the per-turn disclosure state.
7//!
8//! The one behaviour worth naming is stick-to-bottom. A transcript that
9//! auto-scrolls unconditionally rips the page away from someone reading back
10//! through it; one that never scrolls leaves the newest text off-screen. So it
11//! follows the tail only while the view is already at the tail, and a scroll
12//! away from the bottom detaches it until the user comes back or sends
13//! something.
14//!
15//! ```ignore
16//! let chat = cx.new(|cx| AIChatView::new(cx));
17//! chat.update(cx, |chat, cx| {
18//!     chat.push(AITurn::user(prompt), cx);
19//!     chat.begin_reply(cx);
20//! });
21//! // …as tokens arrive
22//! chat.update(cx, |chat, cx| chat.push_delta(&token, cx));
23//! chat.update(cx, |chat, cx| chat.end_reply(cx));
24//! ```
25
26use gpui::prelude::*;
27use gpui::{
28    div, px, Context, EventEmitter, FocusHandle, IntoElement, Pixels, ScrollHandle, SharedString,
29    Window,
30};
31
32use super::{AICitation, AIMessage, AIReasoning, AIRole, AISource, AISources, AIThinking};
33use super::{AIToolCall, AIToolStatus};
34use crate::devtools::Probed;
35use crate::theme::{theme, Size};
36
37/// How close to the end counts as "at the bottom", in pixels. A couple of
38/// lines of slack, so a small overscroll or a resize doesn't detach the view.
39const FOLLOW_SLACK: f32 = 48.0;
40
41/// One tool invocation inside a turn.
42#[derive(Debug, Clone, Default)]
43pub struct AITurnTool {
44    pub name: String,
45    pub status: AIToolStatus,
46    pub arguments: Option<String>,
47    pub result: Option<String>,
48    pub meta: Option<String>,
49    /// Whether the card is expanded. Owned here so scrolling away and back
50    /// doesn't reset it.
51    pub open: bool,
52}
53
54impl AITurnTool {
55    pub fn new(name: impl Into<String>) -> Self {
56        AITurnTool {
57            name: name.into(),
58            ..Default::default()
59        }
60    }
61
62    pub fn status(mut self, status: AIToolStatus) -> Self {
63        self.status = status;
64        self
65    }
66
67    pub fn arguments(mut self, arguments: impl Into<String>) -> Self {
68        self.arguments = Some(arguments.into());
69        self
70    }
71
72    pub fn result(mut self, result: impl Into<String>) -> Self {
73        self.result = Some(result.into());
74        self
75    }
76}
77
78/// One turn of the conversation.
79#[derive(Debug, Clone, Default)]
80pub struct AITurn {
81    pub role: AIRole,
82    pub body: String,
83    /// Extended-thinking output, folded away by default.
84    pub reasoning: Option<String>,
85    pub reasoning_open: bool,
86    pub tools: Vec<AITurnTool>,
87    pub sources: Vec<AISource>,
88    /// Still being written to.
89    pub streaming: bool,
90    /// The turn failed; the text stays and this is shown under it.
91    pub error: Option<String>,
92    /// Overrides the role's name in the header — a model id, say.
93    pub name: Option<String>,
94    /// Trailing header detail: a timestamp, a token count.
95    pub meta: Option<String>,
96}
97
98impl AITurn {
99    pub fn new(role: AIRole, body: impl Into<String>) -> Self {
100        AITurn {
101            role,
102            body: body.into(),
103            ..Default::default()
104        }
105    }
106
107    pub fn user(body: impl Into<String>) -> Self {
108        AITurn::new(AIRole::User, body)
109    }
110
111    pub fn assistant(body: impl Into<String>) -> Self {
112        AITurn::new(AIRole::Assistant, body)
113    }
114
115    pub fn system(body: impl Into<String>) -> Self {
116        AITurn::new(AIRole::System, body)
117    }
118
119    pub fn name(mut self, name: impl Into<String>) -> Self {
120        self.name = Some(name.into());
121        self
122    }
123
124    pub fn meta(mut self, meta: impl Into<String>) -> Self {
125        self.meta = Some(meta.into());
126        self
127    }
128
129    pub fn reasoning(mut self, reasoning: impl Into<String>) -> Self {
130        self.reasoning = Some(reasoning.into());
131        self
132    }
133
134    pub fn sources(mut self, sources: impl IntoIterator<Item = AISource>) -> Self {
135        self.sources = sources.into_iter().collect();
136        self
137    }
138
139    pub fn tools(mut self, tools: impl IntoIterator<Item = AITurnTool>) -> Self {
140        self.tools = tools.into_iter().collect();
141        self
142    }
143}
144
145/// What the transcript asks the host to do.
146#[derive(Debug, Clone)]
147pub enum AIChatViewEvent {
148    /// A source was clicked: `(turn index, source index)`.
149    OpenSource(usize, usize),
150}
151
152/// A scrolling conversation.
153pub struct AIChatView {
154    turns: Vec<AITurn>,
155    scroll: ScrollHandle,
156    focus: FocusHandle,
157    /// Whether new text should pull the view down. Cleared when the reader
158    /// scrolls up, restored when they return to the bottom or send.
159    follow: bool,
160    /// Shown after the last turn while waiting for the first token.
161    pending: Option<SharedString>,
162    empty: Option<SharedString>,
163    size: Size,
164    max_width: Option<f32>,
165    /// Height each turn measured at, from the last frame that drew it in full.
166    /// A turn scrolled well outside the viewport is replaced by a spacer of
167    /// this height — see [`Self::render`].
168    heights: Vec<Pixels>,
169    /// The viewport width those heights were measured at. A resize reflows
170    /// every turn, so it invalidates all of them.
171    measured_width: Pixels,
172    /// Whether to skip building turns that are far off screen.
173    virtualize: bool,
174}
175
176impl EventEmitter<AIChatViewEvent> for AIChatView {}
177
178impl AIChatView {
179    pub fn new(cx: &mut Context<Self>) -> Self {
180        AIChatView {
181            turns: Vec::new(),
182            scroll: ScrollHandle::new(),
183            focus: cx.focus_handle(),
184            follow: true,
185            pending: None,
186            empty: None,
187            size: Size::Sm,
188            max_width: None,
189            heights: Vec::new(),
190            measured_width: px(0.0),
191            virtualize: true,
192        }
193    }
194
195    /// Seed the transcript — restoring a saved conversation.
196    pub fn turns(mut self, turns: impl IntoIterator<Item = AITurn>) -> Self {
197        self.turns = turns.into_iter().collect();
198        self
199    }
200
201    /// What to show before anything has been said.
202    pub fn empty_message(mut self, message: impl Into<SharedString>) -> Self {
203        self.empty = Some(message.into());
204        self
205    }
206
207    pub fn size(mut self, size: Size) -> Self {
208        self.size = size;
209        self
210    }
211
212    /// Cap the reading width and center it. Long lines are hard to read, and a
213    /// transcript in a wide window is the usual way to get them.
214    pub fn max_width(mut self, width: f32) -> Self {
215        self.max_width = Some(width);
216        self
217    }
218
219    /// Build every turn every frame, however long the conversation gets.
220    ///
221    /// On by default, virtualizing means a turn scrolled more than a screen
222    /// away is drawn as a spacer of the height it last measured, because
223    /// building it means re-parsing its markdown — which is linear in the size
224    /// of the whole transcript and lands on every frame. Turn it off if you
225    /// need every turn's element tree live at all times (an in-place find, a
226    /// screenshot of the full history).
227    pub fn virtualize(mut self, virtualize: bool) -> Self {
228        self.virtualize = virtualize;
229        self
230    }
231
232    pub fn focus_handle(&self) -> FocusHandle {
233        self.focus.clone()
234    }
235
236    pub fn turn_count(&self) -> usize {
237        self.turns.len()
238    }
239
240    pub fn all(&self) -> &[AITurn] {
241        &self.turns
242    }
243
244    pub fn turn(&self, index: usize) -> Option<&AITurn> {
245        self.turns.get(index)
246    }
247
248    /// Edit a turn in place — attaching a tool result, marking an error.
249    pub fn update_turn(
250        &mut self,
251        index: usize,
252        edit: impl FnOnce(&mut AITurn),
253        cx: &mut Context<Self>,
254    ) {
255        if let Some(turn) = self.turns.get_mut(index) {
256            edit(turn);
257            cx.notify();
258        }
259    }
260
261    /// Append a turn and return its index. Sending always re-attaches the
262    /// view to the bottom: the user just acted, so they want to see the result.
263    pub fn push(&mut self, turn: AITurn, cx: &mut Context<Self>) -> usize {
264        self.turns.push(turn);
265        self.follow = true;
266        cx.notify();
267        self.turns.len() - 1
268    }
269
270    /// Open an empty assistant turn to stream into, and return its index.
271    pub fn begin_reply(&mut self, cx: &mut Context<Self>) -> usize {
272        let mut turn = AITurn::assistant(String::new());
273        turn.streaming = true;
274        self.pending = None;
275        self.push(turn, cx)
276    }
277
278    /// Append to the open assistant turn. Does nothing if none is open, so a
279    /// late-arriving delta after a cancel can't resurrect a finished turn.
280    pub fn push_delta(&mut self, delta: &str, cx: &mut Context<Self>) {
281        if let Some(turn) = self.streaming_turn() {
282            turn.body.push_str(delta);
283            cx.notify();
284        }
285    }
286
287    /// Append to the open turn's reasoning block.
288    pub fn push_reasoning(&mut self, delta: &str, cx: &mut Context<Self>) {
289        if let Some(turn) = self.streaming_turn() {
290            turn.reasoning
291                .get_or_insert_with(String::new)
292                .push_str(delta);
293            cx.notify();
294        }
295    }
296
297    /// Close the open assistant turn.
298    pub fn end_reply(&mut self, cx: &mut Context<Self>) {
299        if let Some(turn) = self.streaming_turn() {
300            turn.streaming = false;
301            cx.notify();
302        }
303    }
304
305    /// Close the open turn with a failure. Whatever text arrived is kept —
306    /// a truncated reply is still evidence of what went wrong.
307    pub fn fail_reply(&mut self, error: impl Into<String>, cx: &mut Context<Self>) {
308        let error = error.into();
309        if let Some(turn) = self.streaming_turn() {
310            turn.streaming = false;
311            turn.error = Some(error);
312            cx.notify();
313        }
314    }
315
316    /// Show a "working on it" line under the transcript.
317    pub fn set_pending(&mut self, label: Option<impl Into<SharedString>>, cx: &mut Context<Self>) {
318        self.pending = label.map(Into::into);
319        self.follow = true;
320        cx.notify();
321    }
322
323    /// Drop every turn.
324    pub fn clear(&mut self, cx: &mut Context<Self>) {
325        self.turns.clear();
326        self.pending = None;
327        self.follow = true;
328        cx.notify();
329    }
330
331    /// Re-attach to the bottom and scroll there.
332    pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
333        self.follow = true;
334        cx.notify();
335    }
336
337    /// Whether new text is currently pulling the view down.
338    pub fn is_following(&self) -> bool {
339        self.follow
340    }
341
342    /// How far the transcript can scroll, in pixels. Exposed for the test that
343    /// proves virtualizing doesn't change the layout.
344    #[cfg(test)]
345    pub(crate) fn scroll_extent(&self) -> Pixels {
346        self.scroll.max_offset().height
347    }
348
349    fn streaming_turn(&mut self) -> Option<&mut AITurn> {
350        self.turns.iter_mut().rev().find(|turn| turn.streaming)
351    }
352
353    /// Refresh the per-turn heights from the last frame and decide which turns
354    /// to build this one. Returns one flag per turn.
355    ///
356    /// A turn is built when it is anywhere near the viewport, when its height
357    /// has never been measured, or when virtualizing is off. Everything else
358    /// is a spacer — which is the whole point, because building a turn parses
359    /// its markdown, and doing that for a long transcript on every frame costs
360    /// more than the frame has.
361    fn measure(&mut self) -> Vec<bool> {
362        let count = self.turns.len();
363        let viewport = self.scroll.bounds();
364        // A resize reflows every turn, so nothing measured at the old width can
365        // be trusted. Clearing the heights is not enough on its own — an
366        // off-screen turn's bounds are its *spacer's*, so the stale height
367        // would be read straight back. Everything is drawn for one frame
368        // instead, which is what re-measures it.
369        // A width of zero means "not laid out yet", not "resized to nothing";
370        // the first real width is the baseline, not a change from it.
371        let resized = self.measured_width > px(0.0)
372            && (viewport.size.width - self.measured_width).abs() > px(0.5);
373        if resized || self.measured_width <= px(0.0) {
374            self.measured_width = viewport.size.width;
375            if resized {
376                self.heights.clear();
377            }
378        }
379        self.heights.resize(count, px(0.0));
380
381        // Measure first, decide second. A turn that was a spacer last frame
382        // reports the spacer's height, which is the same number — but a turn
383        // drawn in full reports the real one, which is how a height first gets
384        // recorded at all.
385        let mut drawn = vec![true; count];
386        let overscan = viewport.size.height.max(px(600.0));
387        let (top, bottom) = (viewport.top() - overscan, viewport.bottom() + overscan);
388        for (index, (height, drawn)) in self.heights.iter_mut().zip(drawn.iter_mut()).enumerate() {
389            let Some(bounds) = self.scroll.bounds_for_item(index) else {
390                continue;
391            };
392            if bounds.size.height > px(0.0) {
393                *height = bounds.size.height;
394            }
395            // A resize reflows everything, so every turn is drawn once at the
396            // new width — clearing the heights alone would not do it, since an
397            // off-screen turn's bounds are its spacer's and would just be read
398            // straight back. Never stand in for a turn whose height isn't
399            // known either: the spacer would collapse and take the scroll
400            // position with it.
401            if resized || !self.virtualize || *height <= px(0.0) {
402                continue;
403            }
404            *drawn = bounds.bottom() >= top && bounds.top() <= bottom;
405        }
406        drawn
407    }
408
409    /// How many turns were built on the last frame, for the test that proves
410    /// virtualizing skips the ones off screen without moving anything.
411    #[cfg(test)]
412    pub(crate) fn drawn_count(&mut self) -> usize {
413        self.measure().iter().filter(|drawn| **drawn).count()
414    }
415
416    /// How far the viewport sits above the end of the content, in pixels.
417    /// gpui's scroll offset runs negative as content moves up, so the bottom
418    /// is where `offset.y` reaches `-max_offset.height`.
419    fn distance_from_bottom(&self) -> f32 {
420        let offset = self.scroll.offset().y;
421        let max = self.scroll.max_offset().height;
422        f32::from(max + offset).max(0.0)
423    }
424
425    /// Re-decide whether to follow, after the reader moved the view
426    /// themselves. Scrolling back to within a line or two of the end
427    /// re-attaches, which is what makes "catch up" a scroll rather than a
428    /// button hunt.
429    fn on_scroll(
430        &mut self,
431        _event: &gpui::ScrollWheelEvent,
432        _window: &mut Window,
433        cx: &mut Context<Self>,
434    ) {
435        let following = self.distance_from_bottom() <= FOLLOW_SLACK;
436        if following != self.follow {
437            self.follow = following;
438            cx.notify();
439        }
440    }
441}
442
443impl Render for AIChatView {
444    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
445        let t = theme(cx);
446        let dimmed = t.dimmed().hsla();
447        let font = t.font_size(self.size);
448        let empty = self.turns.is_empty() && self.pending.is_none();
449        let max_width = self.max_width;
450        let drawn = self.measure();
451
452        // Turns are direct children of the scrolling box rather than of an
453        // inner list, so gpui tracks each one's bounds and `scroll_to_item`
454        // can reach the last of them using the current frame's measurements.
455        let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(self.turns.len() + 1);
456
457        for (index, turn) in self.turns.iter().enumerate() {
458            // A turn that is far off screen stands in as a spacer of the
459            // height it last measured, so the scroll extent and every
460            // position in it stay exactly where they were.
461            if !drawn[index] {
462                rows.push(row(max_width).h(self.heights[index]).into_any_element());
463                continue;
464            }
465            let mut message = AIMessage::new(turn.role, turn.body.clone())
466                .streaming(turn.streaming)
467                .size(self.size);
468            if let Some(name) = &turn.name {
469                message = message.name(name.clone());
470            }
471            if let Some(meta) = &turn.meta {
472                message = message.meta(meta.clone());
473            }
474            if let Some(error) = &turn.error {
475                message = message.error(error.clone());
476            }
477
478            if let Some(reasoning) = &turn.reasoning {
479                // `AIReasoning` draws the text only when open, and reasoning is
480                // routinely longer than the answer — so a collapsed block was
481                // copying the largest string in the turn every frame to throw
482                // it away.
483                let text = if turn.reasoning_open {
484                    reasoning.clone()
485                } else {
486                    String::new()
487                };
488                message = message.child(
489                    div().mt(px(8.0)).child(
490                        AIReasoning::new(("guise-ai-reasoning", index), text)
491                            .open(turn.reasoning_open)
492                            .streaming(turn.streaming)
493                            .size(self.size)
494                            .on_toggle(cx.listener(move |this, _event, _window, cx| {
495                                this.update_turn(
496                                    index,
497                                    |turn| turn.reasoning_open = !turn.reasoning_open,
498                                    cx,
499                                );
500                            })),
501                    ),
502                );
503            }
504
505            for (slot, tool) in turn.tools.iter().enumerate() {
506                let mut card =
507                    AIToolCall::new(("guise-ai-tool", index * 64 + slot), tool.name.clone())
508                        .status(tool.status)
509                        .open(tool.open)
510                        .expandable(tool.arguments.is_some() || tool.result.is_some())
511                        .size(self.size)
512                        .on_toggle(cx.listener(move |this, _event, _window, cx| {
513                            this.update_turn(
514                                index,
515                                |turn| {
516                                    if let Some(tool) = turn.tools.get_mut(slot) {
517                                        tool.open = !tool.open;
518                                    }
519                                },
520                                cx,
521                            );
522                        }));
523                // Only a folded-open card draws these, and a tool result runs
524                // to tens of kilobytes.
525                if tool.open {
526                    if let Some(arguments) = &tool.arguments {
527                        card = card.arguments(arguments.clone());
528                    }
529                    if let Some(result) = &tool.result {
530                        card = card.result(result.clone());
531                    }
532                }
533                if let Some(meta) = &tool.meta {
534                    card = card.meta(meta.clone());
535                }
536                message = message.child(div().mt(px(8.0)).child(card));
537            }
538
539            if !turn.sources.is_empty() {
540                let chips = div().flex().flex_row().flex_wrap().gap(px(4.0)).children(
541                    turn.sources.iter().enumerate().map(|(slot, source)| {
542                        AICitation::new(("guise-ai-cite", index * 64 + slot), slot + 1)
543                            .label(source.title.clone())
544                            .on_click(cx.listener(move |_this, _event, _window, cx| {
545                                cx.emit(AIChatViewEvent::OpenSource(index, slot));
546                            }))
547                    }),
548                );
549                // `on_open` reports an index rather than an event, so it
550                // can't go through `cx.listener`; a weak handle re-enters the
551                // entity to emit.
552                let view = cx.entity().downgrade();
553                message =
554                    message
555                        .child(div().mt(px(8.0)).child(chips))
556                        .child(div().mt(px(6.0)).child(
557                            AISources::new(turn.sources.clone()).excerpts(true).on_open(
558                                move |slot, _window, cx| {
559                                    view.update(cx, |_this, cx| {
560                                        cx.emit(AIChatViewEvent::OpenSource(index, slot));
561                                    })
562                                    .ok();
563                                },
564                            ),
565                        ));
566            }
567
568            rows.push(row(max_width).child(message).into_any_element());
569        }
570
571        if let Some(pending) = self.pending.clone() {
572            rows.push(
573                row(max_width)
574                    .child(AIThinking::new().label(pending).size(self.size))
575                    .into_any_element(),
576            );
577        }
578
579        // Ask for the last row before painting; the scroll container resolves
580        // it against this frame's bounds, so a growing reply doesn't lag a
581        // frame behind the caret.
582        if self.follow && !rows.is_empty() {
583            self.scroll.scroll_to_item(rows.len() - 1);
584        }
585
586        div()
587            .id("guise-ai-chatview")
588            .track_focus(&self.focus)
589            .flex()
590            .flex_col()
591            .items_center()
592            .gap(px(18.0))
593            .size_full()
594            .overflow_y_scroll()
595            .track_scroll(&self.scroll)
596            .on_scroll_wheel(cx.listener(Self::on_scroll))
597            .p(px(16.0))
598            .text_size(px(font))
599            .when(empty, |view| {
600                view.justify_center()
601                    .child(div().text_color(dimmed).children(self.empty.clone()))
602            })
603            .when(!empty, |view| view.children(rows))
604            .probe("AIChatView")
605    }
606}
607
608/// One transcript row: full width, capped and centered when the view asks for
609/// a reading width.
610fn row(max_width: Option<f32>) -> gpui::Div {
611    let row = div().w_full();
612    match max_width {
613        Some(max) => row.max_w(px(max)),
614        None => row,
615    }
616}