Skip to main content

guise/ai/
streamingtext.rs

1//! `AIStreamingText` — markdown with a caret on the end.
2//!
3//! A reply that is still arriving reads as finished unless something says
4//! otherwise, and a spinner in the corner is the wrong signal — the text is
5//! already there, it is just not done. So this renders exactly what
6//! [`Markdown`] renders and puts a blinking block on the last line, the way a
7//! terminal shows a process still writing.
8//!
9//! It takes the whole text every frame rather than a delta, because that is
10//! what a `Render` pass has: the host appends to its own `String` and this
11//! draws it.
12//!
13//! ```ignore
14//! AIStreamingText::new(&partial_reply)
15//! ```
16
17use std::time::Duration;
18
19use gpui::prelude::*;
20use gpui::{div, px, Animation, AnimationExt, App, IntoElement, SharedString, Window};
21
22use crate::devtools::Probed;
23use crate::markdown::Markdown;
24use crate::theme::{theme, Size};
25
26/// How long the caret takes to go from solid to clear and back.
27const BLINK_MS: u64 = 900;
28
29/// Streaming markdown with a trailing caret.
30#[derive(IntoElement)]
31pub struct AIStreamingText {
32    text: SharedString,
33    size: Size,
34    caret: bool,
35}
36
37impl AIStreamingText {
38    pub fn new(text: impl Into<SharedString>) -> Self {
39        AIStreamingText {
40            text: text.into(),
41            size: Size::Sm,
42            caret: true,
43        }
44    }
45
46    pub fn size(mut self, size: Size) -> Self {
47        self.size = size;
48        self
49    }
50
51    /// Drop the caret while keeping the same layout — for the frame a reply
52    /// finishes on, so the text doesn't jump.
53    pub fn caret(mut self, caret: bool) -> Self {
54        self.caret = caret;
55        self
56    }
57}
58
59impl RenderOnce for AIStreamingText {
60    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
61        let t = theme(cx);
62        let font = t.font_size(self.size);
63        let caret_color = t.text().hsla();
64
65        // The caret sits on its own row under the text rather than inline:
66        // the markdown body is a column of laid-out lines, and threading a
67        // caret into the last one would mean shaping the text twice.
68        let caret = div()
69            .w(px(font * 0.5))
70            .h(px(font * 1.1))
71            .bg(caret_color)
72            .with_animation(
73                "guise-ai-caret",
74                Animation::new(Duration::from_millis(BLINK_MS)).repeat(),
75                // A square wave, not a fade: a caret that dims looks like a
76                // rendering artifact, one that switches looks deliberate.
77                |caret, delta| caret.opacity(if delta < 0.5 { 1.0 } else { 0.0 }),
78            );
79
80        div()
81            .flex()
82            .flex_col()
83            .w_full()
84            .child(Markdown::new(self.text).size(self.size))
85            .when(self.caret, |column| {
86                column.child(div().flex().items_center().h(px(font * 1.3)).child(caret))
87            })
88            .probe("AIStreamingText")
89    }
90}