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
291        .reasoning
292        .get_or_insert_with(String::new)
293        .push_str(delta);
294      cx.notify();
295    }
296  }
297
298  /// Close the open assistant turn.
299  pub fn end_reply(&mut self, cx: &mut Context<Self>) {
300    if let Some(turn) = self.streaming_turn() {
301      turn.streaming = false;
302      cx.notify();
303    }
304  }
305
306  /// Close the open turn with a failure. Whatever text arrived is kept —
307  /// a truncated reply is still evidence of what went wrong.
308  pub fn fail_reply(&mut self, error: impl Into<String>, cx: &mut Context<Self>) {
309    let error = error.into();
310    if let Some(turn) = self.streaming_turn() {
311      turn.streaming = false;
312      turn.error = Some(error);
313      cx.notify();
314    }
315  }
316
317  /// Show a "working on it" line under the transcript.
318  pub fn set_pending(&mut self, label: Option<impl Into<SharedString>>, cx: &mut Context<Self>) {
319    self.pending = label.map(Into::into);
320    self.follow = true;
321    cx.notify();
322  }
323
324  /// Drop every turn.
325  pub fn clear(&mut self, cx: &mut Context<Self>) {
326    self.turns.clear();
327    self.pending = None;
328    self.follow = true;
329    cx.notify();
330  }
331
332  /// Re-attach to the bottom and scroll there.
333  pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
334    self.follow = true;
335    cx.notify();
336  }
337
338  /// Whether new text is currently pulling the view down.
339  pub fn is_following(&self) -> bool {
340    self.follow
341  }
342
343  /// How far the transcript can scroll, in pixels. Exposed for the test that
344  /// proves virtualizing doesn't change the layout.
345  #[cfg(test)]
346  pub(crate) fn scroll_extent(&self) -> Pixels {
347    self.scroll.max_offset().height
348  }
349
350  fn streaming_turn(&mut self) -> Option<&mut AITurn> {
351    self.turns.iter_mut().rev().find(|turn| turn.streaming)
352  }
353
354  /// Refresh the per-turn heights from the last frame and decide which turns
355  /// to build this one. Returns one flag per turn.
356  ///
357  /// A turn is built when it is anywhere near the viewport, when its height
358  /// has never been measured, or when virtualizing is off. Everything else
359  /// is a spacer — which is the whole point, because building a turn parses
360  /// its markdown, and doing that for a long transcript on every frame costs
361  /// more than the frame has.
362  fn measure(&mut self) -> Vec<bool> {
363    let count = self.turns.len();
364    let viewport = self.scroll.bounds();
365    // A resize reflows every turn, so nothing measured at the old width can
366    // be trusted. Clearing the heights is not enough on its own — an
367    // off-screen turn's bounds are its *spacer's*, so the stale height
368    // would be read straight back. Everything is drawn for one frame
369    // instead, which is what re-measures it.
370    // A width of zero means "not laid out yet", not "resized to nothing";
371    // the first real width is the baseline, not a change from it.
372    let resized =
373      self.measured_width > px(0.0) && (viewport.size.width - self.measured_width).abs() > px(0.5);
374    if resized || self.measured_width <= px(0.0) {
375      self.measured_width = viewport.size.width;
376      if resized {
377        self.heights.clear();
378      }
379    }
380    self.heights.resize(count, px(0.0));
381
382    // Measure first, decide second. A turn that was a spacer last frame
383    // reports the spacer's height, which is the same number — but a turn
384    // drawn in full reports the real one, which is how a height first gets
385    // recorded at all.
386    let mut drawn = vec![true; count];
387    let overscan = viewport.size.height.max(px(600.0));
388    let (top, bottom) = (viewport.top() - overscan, viewport.bottom() + overscan);
389    for (index, (height, drawn)) in self.heights.iter_mut().zip(drawn.iter_mut()).enumerate() {
390      let Some(bounds) = self.scroll.bounds_for_item(index) else {
391        continue;
392      };
393      if bounds.size.height > px(0.0) {
394        *height = bounds.size.height;
395      }
396      // A resize reflows everything, so every turn is drawn once at the
397      // new width — clearing the heights alone would not do it, since an
398      // off-screen turn's bounds are its spacer's and would just be read
399      // straight back. Never stand in for a turn whose height isn't
400      // known either: the spacer would collapse and take the scroll
401      // position with it.
402      if resized || !self.virtualize || *height <= px(0.0) {
403        continue;
404      }
405      *drawn = bounds.bottom() >= top && bounds.top() <= bottom;
406    }
407    drawn
408  }
409
410  /// How many turns were built on the last frame, for the test that proves
411  /// virtualizing skips the ones off screen without moving anything.
412  #[cfg(test)]
413  pub(crate) fn drawn_count(&mut self) -> usize {
414    self.measure().iter().filter(|drawn| **drawn).count()
415  }
416
417  /// How far the viewport sits above the end of the content, in pixels.
418  /// gpui's scroll offset runs negative as content moves up, so the bottom
419  /// is where `offset.y` reaches `-max_offset.height`.
420  fn distance_from_bottom(&self) -> f32 {
421    let offset = self.scroll.offset().y;
422    let max = self.scroll.max_offset().height;
423    f32::from(max + offset).max(0.0)
424  }
425
426  /// Re-decide whether to follow, after the reader moved the view
427  /// themselves. Scrolling back to within a line or two of the end
428  /// re-attaches, which is what makes "catch up" a scroll rather than a
429  /// button hunt.
430  fn on_scroll(
431    &mut self,
432    _event: &gpui::ScrollWheelEvent,
433    _window: &mut Window,
434    cx: &mut Context<Self>,
435  ) {
436    let following = self.distance_from_bottom() <= FOLLOW_SLACK;
437    if following != self.follow {
438      self.follow = following;
439      cx.notify();
440    }
441  }
442}
443
444impl Render for AIChatView {
445  fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
446    let t = theme(cx);
447    let dimmed = t.dimmed().hsla();
448    let font = t.font_size(self.size);
449    let empty = self.turns.is_empty() && self.pending.is_none();
450    let max_width = self.max_width;
451    let drawn = self.measure();
452
453    // Turns are direct children of the scrolling box rather than of an
454    // inner list, so gpui tracks each one's bounds and `scroll_to_item`
455    // can reach the last of them using the current frame's measurements.
456    let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(self.turns.len() + 1);
457
458    for (index, turn) in self.turns.iter().enumerate() {
459      // A turn that is far off screen stands in as a spacer of the
460      // height it last measured, so the scroll extent and every
461      // position in it stay exactly where they were.
462      if !drawn[index] {
463        rows.push(row(max_width).h(self.heights[index]).into_any_element());
464        continue;
465      }
466      let mut message = AIMessage::new(turn.role, turn.body.clone())
467        .streaming(turn.streaming)
468        .size(self.size);
469      if let Some(name) = &turn.name {
470        message = message.name(name.clone());
471      }
472      if let Some(meta) = &turn.meta {
473        message = message.meta(meta.clone());
474      }
475      if let Some(error) = &turn.error {
476        message = message.error(error.clone());
477      }
478
479      if let Some(reasoning) = &turn.reasoning {
480        // `AIReasoning` draws the text only when open, and reasoning is
481        // routinely longer than the answer — so a collapsed block was
482        // copying the largest string in the turn every frame to throw
483        // it away.
484        let text = if turn.reasoning_open {
485          reasoning.clone()
486        } else {
487          String::new()
488        };
489        message = message.child(
490          div().mt(px(8.0)).child(
491            AIReasoning::new(("guise-ai-reasoning", index), text)
492              .open(turn.reasoning_open)
493              .streaming(turn.streaming)
494              .size(self.size)
495              .on_toggle(cx.listener(move |this, _event, _window, cx| {
496                this.update_turn(index, |turn| turn.reasoning_open = !turn.reasoning_open, cx);
497              })),
498          ),
499        );
500      }
501
502      for (slot, tool) in turn.tools.iter().enumerate() {
503        let mut card = AIToolCall::new(("guise-ai-tool", index * 64 + slot), tool.name.clone())
504          .status(tool.status)
505          .open(tool.open)
506          .expandable(tool.arguments.is_some() || tool.result.is_some())
507          .size(self.size)
508          .on_toggle(cx.listener(move |this, _event, _window, cx| {
509            this.update_turn(
510              index,
511              |turn| {
512                if let Some(tool) = turn.tools.get_mut(slot) {
513                  tool.open = !tool.open;
514                }
515              },
516              cx,
517            );
518          }));
519        // Only a folded-open card draws these, and a tool result runs
520        // to tens of kilobytes.
521        if tool.open {
522          if let Some(arguments) = &tool.arguments {
523            card = card.arguments(arguments.clone());
524          }
525          if let Some(result) = &tool.result {
526            card = card.result(result.clone());
527          }
528        }
529        if let Some(meta) = &tool.meta {
530          card = card.meta(meta.clone());
531        }
532        message = message.child(div().mt(px(8.0)).child(card));
533      }
534
535      if !turn.sources.is_empty() {
536        let chips = div().flex().flex_row().flex_wrap().gap(px(4.0)).children(
537          turn.sources.iter().enumerate().map(|(slot, source)| {
538            AICitation::new(("guise-ai-cite", index * 64 + slot), slot + 1)
539              .label(source.title.clone())
540              .on_click(cx.listener(move |_this, _event, _window, cx| {
541                cx.emit(AIChatViewEvent::OpenSource(index, slot));
542              }))
543          }),
544        );
545        // `on_open` reports an index rather than an event, so it
546        // can't go through `cx.listener`; a weak handle re-enters the
547        // entity to emit.
548        let view = cx.entity().downgrade();
549        message =
550          message
551            .child(div().mt(px(8.0)).child(chips))
552            .child(div().mt(px(6.0)).child(
553              AISources::new(turn.sources.clone()).excerpts(true).on_open(
554                move |slot, _window, cx| {
555                  view
556                    .update(cx, |_this, cx| {
557                      cx.emit(AIChatViewEvent::OpenSource(index, slot));
558                    })
559                    .ok();
560                },
561              ),
562            ));
563      }
564
565      rows.push(row(max_width).child(message).into_any_element());
566    }
567
568    if let Some(pending) = self.pending.clone() {
569      rows.push(
570        row(max_width)
571          .child(AIThinking::new().label(pending).size(self.size))
572          .into_any_element(),
573      );
574    }
575
576    // Ask for the last row before painting; the scroll container resolves
577    // it against this frame's bounds, so a growing reply doesn't lag a
578    // frame behind the caret.
579    if self.follow && !rows.is_empty() {
580      self.scroll.scroll_to_item(rows.len() - 1);
581    }
582
583    div()
584      .id("guise-ai-chatview")
585      .track_focus(&self.focus)
586      .flex()
587      .flex_col()
588      .items_center()
589      .gap(px(18.0))
590      .size_full()
591      .overflow_y_scroll()
592      .track_scroll(&self.scroll)
593      .on_scroll_wheel(cx.listener(Self::on_scroll))
594      .p(px(16.0))
595      .text_size(px(font))
596      .when(empty, |view| {
597        view
598          .justify_center()
599          .child(div().text_color(dimmed).children(self.empty.clone()))
600      })
601      .when(!empty, |view| view.children(rows))
602      .probe("AIChatView")
603  }
604}
605
606/// One transcript row: full width, capped and centered when the view asks for
607/// a reading width.
608fn row(max_width: Option<f32>) -> gpui::Div {
609  let row = div().w_full();
610  match max_width {
611    Some(max) => row.max_w(px(max)),
612    None => row,
613  }
614}