Skip to main content

gpui_ai/
streaming_text.rs

1//! Streamed markdown answers with sources and follow-up suggestions.
2
3use crate::stream::{ProgressState, StreamedContent};
4use crate::theme::SemanticStyledExt as _;
5use crate::{
6    control::{composed_button, outlined_control},
7    handlers::SharedHandler,
8    surface::initial_badge,
9};
10use gpui::{
11    AnyElement, App, Axis, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
12    ParentElement as _, RenderOnce, Role, ScrollHandle, SharedString,
13    StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
14    prelude::FluentBuilder as _,
15};
16use gpui_base::Button;
17use gpui_component::{
18    ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, h_flex, hover_card::HoverCard,
19    scroll::ScrollableMask, text::TextView, v_flex,
20};
21use std::{
22    collections::{HashMap, HashSet},
23    rc::Rc,
24};
25
26/// The glyph that marks the tail of text that is still arriving.
27const STREAMING_CURSOR: &str = "▌";
28
29#[cfg(test)]
30thread_local! {
31    /// Counts `transform_citations` calls on the calling thread. Cache tests
32    /// assert on work performed rather than elapsed time; each test owns its
33    /// thread, so counts never cross tests.
34    static TRANSFORM_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
35
36    /// The Markdown source handed to the text view by the most recent render.
37    static RENDERED_SOURCE: std::cell::RefCell<Option<SharedString>> =
38        const { std::cell::RefCell::new(None) };
39}
40
41/// A source backing a streamed answer, shown as a chip under the text.
42///
43/// A source with a URL becomes an activatable chip that reports
44/// [`StreamingTextEvent::SourceActivated`]; its domain initial doubles as a
45/// favicon-style badge so a row of sources scans at a glance.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct SourceRef {
48    id: SharedString,
49    title: SharedString,
50    url: Option<SharedString>,
51}
52
53/// A stable follow-up suggestion shown after an answer settles.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct FollowUp {
56    id: SharedString,
57    label: SharedString,
58}
59
60/// A stable inline citation supplied by the application.
61///
62/// The destination is opaque application data. [`StreamingText`] never opens
63/// it directly; activation returns it through
64/// [`StreamingTextEvent::CitationActivated`].
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct CitationRef {
67    id: SharedString,
68    label: SharedString,
69    title: SharedString,
70    destination: SharedString,
71}
72
73impl CitationRef {
74    /// Creates a citation with stable identity and an application-owned target.
75    pub fn new(
76        id: impl Into<SharedString>,
77        label: impl Into<SharedString>,
78        title: impl Into<SharedString>,
79        destination: impl Into<SharedString>,
80    ) -> Self {
81        Self {
82            id: id.into(),
83            label: label.into(),
84            title: title.into(),
85            destination: destination.into(),
86        }
87    }
88
89    /// Returns the stable application-level citation identifier.
90    pub fn id(&self) -> &SharedString {
91        &self.id
92    }
93
94    /// Returns the visible citation label.
95    pub fn label(&self) -> &SharedString {
96        &self.label
97    }
98
99    /// Returns the accessible citation title.
100    pub fn title(&self) -> &SharedString {
101        &self.title
102    }
103
104    /// Returns the opaque destination passed back on activation.
105    pub fn destination(&self) -> &SharedString {
106        &self.destination
107    }
108
109    fn internal_url(&self) -> String {
110        format!("gpui-ai-citation://{}", percent_encode_id(&self.id))
111    }
112}
113
114impl FollowUp {
115    /// Creates a follow-up with a stable application-level identifier.
116    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
117        Self {
118            id: id.into(),
119            label: label.into(),
120        }
121    }
122
123    fn selected_event(&self) -> StreamingTextEvent {
124        StreamingTextEvent::FollowUpSelected {
125            id: self.id.clone(),
126        }
127    }
128}
129
130/// An interaction emitted by [`StreamingText`].
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum StreamingTextEvent {
133    /// A follow-up suggestion was selected.
134    FollowUpSelected {
135        /// Stable follow-up identifier.
136        id: SharedString,
137    },
138    /// An inline citation was activated.
139    CitationActivated {
140        /// Stable application-level citation identifier.
141        id: SharedString,
142        /// Opaque application-owned navigation destination.
143        destination: SharedString,
144    },
145    /// A source chip with a location was activated.
146    SourceActivated {
147        /// Stable source identifier.
148        id: SharedString,
149        /// The source location.
150        url: SharedString,
151    },
152}
153
154fn percent_encode_id(id: &str) -> String {
155    let mut encoded = String::with_capacity(id.len());
156    for byte in id.bytes() {
157        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
158            encoded.push(char::from(byte));
159        } else {
160            use std::fmt::Write as _;
161            let _ = write!(encoded, "%{byte:02X}");
162        }
163    }
164    encoded
165}
166
167fn escape_markdown_label(label: &str) -> String {
168    let mut escaped = String::with_capacity(label.len());
169    for character in label.chars() {
170        if matches!(character, '\\' | '[' | ']') {
171            escaped.push('\\');
172        }
173        escaped.push(character);
174    }
175    escaped
176}
177
178fn escape_markdown_title(title: &str) -> String {
179    let mut escaped = String::with_capacity(title.len());
180    for character in title.chars() {
181        match character {
182            '\\' | '"' => {
183                escaped.push('\\');
184                escaped.push(character);
185            }
186            '\r' | '\n' => escaped.push(' '),
187            _ => escaped.push(character),
188        }
189    }
190    escaped
191}
192
193#[derive(Clone, Copy)]
194struct FenceCandidate<'a> {
195    marker: u8,
196    length: usize,
197    trailing: &'a str,
198}
199
200#[derive(Clone, Copy)]
201struct OpenFence {
202    marker: u8,
203    length: usize,
204    quote_depth: usize,
205}
206
207fn block_quote_content(mut line: &str) -> (usize, &str) {
208    let mut depth = 0;
209    loop {
210        let bytes = line.as_bytes();
211        let indentation = bytes.iter().take_while(|byte| **byte == b' ').count();
212        if indentation > 3 || bytes.get(indentation) != Some(&b'>') {
213            return (depth, line);
214        }
215
216        depth += 1;
217        line = &line[indentation + 1..];
218        if line.starts_with([' ', '\t']) {
219            line = &line[1..];
220        }
221    }
222}
223
224fn fence_candidate(line: &str) -> Option<FenceCandidate<'_>> {
225    let bytes = line.as_bytes();
226    let indentation = bytes.iter().take_while(|byte| **byte == b' ').count();
227    if indentation > 3 {
228        return None;
229    }
230    let marker = *bytes.get(indentation)?;
231    if !matches!(marker, b'`' | b'~') {
232        return None;
233    }
234    let length = bytes[indentation..]
235        .iter()
236        .take_while(|byte| **byte == marker)
237        .count();
238    (length >= 3).then_some(FenceCandidate {
239        marker,
240        length,
241        trailing: &line[indentation + length..],
242    })
243}
244
245fn opens_fence(candidate: FenceCandidate<'_>) -> bool {
246    candidate.marker != b'`' || !candidate.trailing.contains('`')
247}
248
249fn closes_fence(candidate: FenceCandidate<'_>, open: OpenFence) -> bool {
250    candidate.marker == open.marker
251        && candidate.length >= open.length
252        && candidate
253            .trailing
254            .trim_end_matches(['\r', '\n'])
255            .bytes()
256            .all(|byte| matches!(byte, b' ' | b'\t'))
257}
258
259#[cfg(test)]
260fn transform_citation_markers(
261    source: &str,
262    citations: &[CitationRef],
263    interactive: bool,
264) -> String {
265    transform_citations(source, citations, interactive).markdown
266}
267
268struct CitationTransform {
269    markdown: String,
270    referenced: Vec<CitationRef>,
271}
272
273/// Marker id to citation. `or_insert` keeps the first declaration of a repeated
274/// id, which is what a linear search over `citations` resolved to.
275fn citation_index(citations: &[CitationRef]) -> HashMap<&str, &CitationRef> {
276    let mut index = HashMap::with_capacity(citations.len());
277    for citation in citations {
278        index.entry(citation.id.as_str()).or_insert(citation);
279    }
280    index
281}
282
283fn transform_citations(
284    source: &str,
285    citations: &[CitationRef],
286    interactive: bool,
287) -> CitationTransform {
288    #[cfg(test)]
289    TRANSFORM_CALLS.with(|calls| calls.set(calls.get() + 1));
290
291    // One index for the whole pass keeps marker resolution O(1), so the
292    // transform stays linear in source length plus citation count however many
293    // markers the answer carries. It is built on the first marker that needs
294    // resolving, so an answer that declares citations it has not reached yet —
295    // the ordinary early-stream shape — pays nothing for them.
296    let mut by_id: Option<HashMap<&str, &CitationRef>> = None;
297
298    let mut transformed = String::with_capacity(source.len());
299    let mut referenced = Vec::new();
300    let mut referenced_ids: HashSet<&str> = HashSet::new();
301    let mut fence: Option<OpenFence> = None;
302    let mut inline_ticks: Option<usize> = None;
303
304    for line in source.split_inclusive('\n') {
305        let (quote_depth, quote_content) = block_quote_content(line);
306        let candidate = fence_candidate(quote_content);
307
308        if let Some(open) = fence {
309            if quote_depth >= open.quote_depth {
310                transformed.push_str(line);
311                if quote_depth == open.quote_depth
312                    && candidate.is_some_and(|candidate| closes_fence(candidate, open))
313                {
314                    fence = None;
315                }
316                continue;
317            }
318            fence = None;
319        }
320
321        if inline_ticks.is_none()
322            && let Some(candidate) = candidate
323            && opens_fence(candidate)
324        {
325            fence = Some(OpenFence {
326                marker: candidate.marker,
327                length: candidate.length,
328                quote_depth,
329            });
330            transformed.push_str(line);
331            continue;
332        }
333
334        let mut offset = 0;
335        while offset < line.len() {
336            let remainder = &line[offset..];
337            if remainder.starts_with('`') {
338                let run = remainder.bytes().take_while(|byte| *byte == b'`').count();
339                transformed.push_str(&remainder[..run]);
340                match inline_ticks {
341                    Some(open) if open == run => inline_ticks = None,
342                    None => inline_ticks = Some(run),
343                    _ => {}
344                }
345                offset += run;
346                continue;
347            }
348
349            if inline_ticks.is_none()
350                && let Some(marker) = remainder.strip_prefix("[[cite:")
351                && let Some(end) = marker.find("]]")
352            {
353                let id = &marker[..end];
354                let index = by_id.get_or_insert_with(|| citation_index(citations));
355                if let Some(citation) = index.get(id).copied() {
356                    if referenced_ids.insert(citation.id.as_str()) {
357                        referenced.push(citation.clone());
358                    }
359                    let label = escape_markdown_label(&citation.label);
360                    if interactive {
361                        transformed.push('[');
362                        transformed.push_str(&label);
363                        transformed.push_str("](");
364                        transformed.push_str(&citation.internal_url());
365                        transformed.push_str(" \"");
366                        transformed.push_str(&escape_markdown_title(&citation.title));
367                        transformed.push_str("\")");
368                    } else {
369                        transformed.push_str("\\[");
370                        transformed.push_str(&label);
371                        transformed.push_str("\\]");
372                    }
373                    offset += "[[cite:".len() + end + "]]".len();
374                    continue;
375                }
376            }
377
378            if let Some(character) = remainder.chars().next() {
379                transformed.push(character);
380                offset += character.len_utf8();
381            } else {
382                break;
383            }
384        }
385    }
386
387    CitationTransform {
388        markdown: transformed,
389        referenced,
390    }
391}
392
393fn decorated_source(markdown: &SharedString, streaming: bool) -> SharedString {
394    if !streaming {
395        return markdown.clone();
396    }
397    let mut source = String::with_capacity(markdown.len() + STREAMING_CURSOR.len());
398    source.push_str(markdown);
399    source.push_str(STREAMING_CURSOR);
400    SharedString::from(source)
401}
402
403/// Everything a cached citation transform depends on, plus the revision of the
404/// [`StreamedContent`] it came from.
405///
406/// Revision is compared first because it is the only field that costs nothing
407/// to compare, but it cannot decide reuse alone: two separately constructed
408/// [`Progressive`](crate::stream::Progressive) values both report revision `0`,
409/// so equal revisions can still carry different text. The text, citation set,
410/// interactivity, and streaming flag are the authority.
411#[derive(PartialEq, Eq)]
412struct CitationKey {
413    revision: u64,
414    interactive: bool,
415    streaming: bool,
416    citations: Vec<CitationRef>,
417    text: SharedString,
418}
419
420impl CitationKey {
421    /// Whether the transform's own inputs are unchanged. Revision and the
422    /// streaming flag are not transform inputs, so a change confined to them
423    /// invalidates the cursor-decorated source and nothing else.
424    fn transforms_alike(&self, other: &Self) -> bool {
425        self.interactive == other.interactive
426            && self.text == other.text
427            && self.citations == other.citations
428    }
429}
430
431/// Citation transform output retained across frames for one component id.
432///
433/// [`StreamingText`] is `RenderOnce` and is rebuilt every frame, so the
434/// retained copy lives in keyed window state. Refreshing memoises a pure
435/// function and never notifies, so it cannot schedule a frame from `render`.
436struct CitationCache {
437    key: Option<CitationKey>,
438    markdown: SharedString,
439    source: SharedString,
440    referenced: Vec<CitationRef>,
441}
442
443impl CitationCache {
444    fn new() -> Self {
445        Self {
446            key: None,
447            markdown: SharedString::default(),
448            source: SharedString::default(),
449            referenced: Vec::new(),
450        }
451    }
452
453    fn refresh(&mut self, key: CitationKey) {
454        if self.key.as_ref().is_some_and(|cached| *cached == key) {
455            return;
456        }
457        let transform_stale = self
458            .key
459            .as_ref()
460            .is_none_or(|cached| !cached.transforms_alike(&key));
461        if transform_stale {
462            let transformed = transform_citations(&key.text, &key.citations, key.interactive);
463            self.markdown = SharedString::from(transformed.markdown);
464            self.referenced = transformed.referenced;
465        }
466        let cursor_stale = self
467            .key
468            .as_ref()
469            .is_none_or(|cached| cached.streaming != key.streaming);
470        if transform_stale || cursor_stale {
471            self.source = decorated_source(&self.markdown, key.streaming);
472        }
473        self.key = Some(key);
474    }
475}
476
477fn citation_event_for_url(url: &str, citations: &[CitationRef]) -> Option<StreamingTextEvent> {
478    citations
479        .iter()
480        .find(|citation| citation.internal_url() == url)
481        .map(|citation| StreamingTextEvent::CitationActivated {
482            id: citation.id.clone(),
483            destination: citation.destination.clone(),
484        })
485}
486
487fn citation_companion_link(
488    root_id: ElementId,
489    citation: CitationRef,
490    handler: SharedHandler<StreamingTextEvent>,
491    cx: &mut App,
492) -> gpui_base::Link {
493    let tokens = cx.theme().semantic_tokens();
494    let event = StreamingTextEvent::CitationActivated {
495        id: citation.id.clone(),
496        destination: citation.destination.clone(),
497    };
498    let local_id = SharedString::from(format!("citation-{}", citation.id));
499    let debug_id = citation.id.clone();
500    let visible_label = SharedString::from(format!("Citation: {}", citation.label));
501
502    gpui_base::Link::new((root_id, local_id))
503        .debug_selector(move || format!("streaming-citation-{debug_id}"))
504        .accessibility_label(citation.title)
505        .flex()
506        .flex_none()
507        .items_center()
508        .px(tokens.spacing.sm)
509        .py(tokens.spacing.xxs)
510        .border_1()
511        .border_color(tokens.colors.border)
512        .rounded(tokens.radius.sm)
513        .bg(tokens.colors.surface)
514        .text_token(tokens.typography.xs)
515        .text_color(tokens.colors.surface_foreground)
516        .hover(|style| style.bg(cx.theme().button_hover))
517        .active(|style| style.bg(cx.theme().button_active))
518        .focus_visible(|style| style.border_color(tokens.colors.ring))
519        .on_activate(move |_, window, cx| handler(&event, window, cx))
520        .child(div().child(visible_label))
521}
522
523fn follow_up_button(
524    follow_up: FollowUp,
525    handler: Option<SharedHandler<StreamingTextEvent>>,
526    window: &mut Window,
527    cx: &mut App,
528) -> Button {
529    let debug_id = follow_up.id.clone();
530    let event = follow_up.selected_event();
531    outlined_control(follow_up.id.clone(), follow_up.label, window, cx)
532        .debug_selector(move || format!("streaming-follow-up-{debug_id}"))
533        .accessibility_id(format!("follow-up-{}", follow_up.id))
534        .when_some(handler, |this, handler| {
535            this.on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
536        })
537}
538
539fn citation_preview(title: SharedString, destination: SharedString, cx: &mut App) -> AnyElement {
540    let tokens = cx.theme().semantic_tokens();
541    v_flex()
542        .gap(tokens.spacing.xxs)
543        .max_w(tokens.spacing.xxl * 9.0)
544        .child(crate::surface::subtitle(title, cx))
545        .child(
546            div()
547                .text_token(tokens.typography.xs)
548                .font_family(cx.theme().mono_font_family.clone())
549                .text_color(cx.theme().muted_foreground)
550                .truncate()
551                .child(destination),
552        )
553        .into_any_element()
554}
555
556/// A source chip: favicon-style initial, title, and — with a URL and a
557/// handler — an activatable link glyph.
558fn source_chip(
559    root_id: ElementId,
560    index: usize,
561    source: SourceRef,
562    handler: Option<SharedHandler<StreamingTextEvent>>,
563    cx: &mut App,
564) -> AnyElement {
565    let tokens = cx.theme().semantic_tokens();
566    let accessibility_label = source.accessibility_label();
567    let badge = initial_badge(source.initial(), cx);
568    let content = h_flex()
569        .items_center()
570        .gap(tokens.spacing.xs)
571        .text_token(tokens.typography.xs)
572        .text_color(cx.theme().foreground)
573        .child(badge)
574        .child(source.title.clone())
575        .when(source.url.is_some(), |this| {
576            this.child(
577                Icon::new(IconName::ExternalLink)
578                    .xsmall()
579                    .text_color(cx.theme().muted_foreground),
580            )
581        });
582    match (source.url.clone(), handler) {
583        (Some(url), Some(handler)) => {
584            let event = StreamingTextEvent::SourceActivated {
585                id: source.id.clone(),
586                url,
587            };
588            let debug_id = source.id.clone();
589            composed_button((root_id, format!("source-{index}")), accessibility_label)
590                .debug_selector(move || format!("streaming-source-{debug_id}"))
591                .px(tokens.spacing.sm)
592                .py(tokens.spacing.xxs)
593                .bg(cx.theme().secondary)
594                .border_1()
595                .border_color(cx.theme().border)
596                .rounded(tokens.radius.full)
597                .hover(|style| style.bg(cx.theme().accent.opacity(0.6)))
598                .active(|style| style.bg(cx.theme().accent))
599                .focus_visible(|style| style.border_color(cx.theme().ring))
600                .child(content)
601                .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
602                .into_any_element()
603        }
604        _ => h_flex()
605            .id((root_id, index.to_string()))
606            .role(Role::ListItem)
607            .aria_label(accessibility_label)
608            .px(tokens.spacing.sm)
609            .py(tokens.spacing.xxs)
610            .bg(cx.theme().secondary)
611            .border_1()
612            .border_color(cx.theme().border)
613            .rounded(tokens.radius.full)
614            .child(content)
615            .into_any_element(),
616    }
617}
618
619struct CitationDispatcher {
620    citations: Vec<CitationRef>,
621    on_event: SharedHandler<StreamingTextEvent>,
622}
623
624impl CitationDispatcher {
625    fn activate(&self, url: &str, event: &ClickEvent, window: &mut Window, cx: &mut App) -> bool {
626        if event.is_right_click() {
627            return true;
628        }
629        let Some(event) = citation_event_for_url(url, &self.citations) else {
630            return false;
631        };
632        (self.on_event)(&event, window, cx);
633        true
634    }
635}
636
637impl SourceRef {
638    /// Creates a source chip with a display title; the title doubles as its
639    /// stable identifier.
640    pub fn new(title: impl Into<SharedString>) -> Self {
641        let title = title.into();
642        Self {
643            id: title.clone(),
644            title,
645            url: None,
646        }
647    }
648
649    /// Creates a source with an explicit stable identifier.
650    pub fn with_id(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
651        Self {
652            id: id.into(),
653            title: title.into(),
654            url: None,
655        }
656    }
657
658    /// Adds the source's location; the chip becomes activatable and shows
659    /// the domain's initial as its badge.
660    pub fn url(mut self, url: impl Into<SharedString>) -> Self {
661        self.url = Some(url.into());
662        self
663    }
664
665    /// Returns the stable source identifier.
666    pub fn id(&self) -> &SharedString {
667        &self.id
668    }
669
670    /// Returns the display title.
671    pub fn title(&self) -> &SharedString {
672        &self.title
673    }
674
675    /// Returns the location, when set.
676    pub fn url_text(&self) -> Option<&SharedString> {
677        self.url.as_ref()
678    }
679
680    /// The host of the URL without a `www.` prefix, when a URL is set.
681    pub fn domain(&self) -> Option<String> {
682        let url = self.url.as_ref()?;
683        let rest = url
684            .split_once("://")
685            .map(|(_, rest)| rest)
686            .unwrap_or(url.as_ref());
687        let host = rest.split(['/', '?', '#']).next().unwrap_or(rest);
688        let host = host.strip_prefix("www.").unwrap_or(host);
689        (!host.is_empty()).then(|| host.to_owned())
690    }
691
692    /// One uppercase character standing in for a favicon.
693    pub fn initial(&self) -> String {
694        crate::surface::initial_of(&self.domain().unwrap_or_else(|| self.title.to_string()))
695    }
696
697    fn accessibility_label(&self) -> SharedString {
698        match self.domain() {
699            Some(domain) => format!("{}, {domain}", self.title).into(),
700            None => self.title.clone(),
701        }
702    }
703}
704
705/// A streamed markdown answer: live text, then sources and follow-ups.
706///
707/// The component renders a [`StreamedContent`] snapshot — the application
708/// owns the content and re-renders as chunks arrive. While streaming, a
709/// cursor glyph marks the end of the text; on failure the reason is shown
710/// under the answer.
711///
712/// # Example
713///
714/// ```no_run
715/// # use gpui_ai::prelude::*;
716/// # fn example(answer: StreamedContent) {
717/// StreamingText::new("answer", &answer)
718///     .citations([
719///         CitationRef::new("pricing", "Pricing report", "Open pricing report", "app://pricing"),
720///     ])
721///     .sources(["pricing.md", "suppliers.csv"])
722///     .follow_ups([
723///         FollowUp::new("delivery", "Compare delivery times"),
724///         FollowUp::new("history", "Show price history"),
725///     ])
726///     .on_event(|event, _, _| {
727///         // Route citation destinations or start follow-up work in the application.
728///     });
729/// # }
730/// ```
731#[derive(IntoElement)]
732pub struct StreamingText {
733    id: ElementId,
734    style: StyleRefinement,
735    text: SharedString,
736    state: ProgressState,
737    /// The snapshot's revision, carried privately so the citation transform can
738    /// be reused across frames. Not part of any public signature.
739    revision: u64,
740    sources: Vec<SourceRef>,
741    citations: Vec<CitationRef>,
742    follow_ups: Vec<FollowUp>,
743    on_event: Option<SharedHandler<StreamingTextEvent>>,
744}
745
746impl StreamingText {
747    /// Creates the component from a snapshot of streamed content.
748    pub fn new(id: impl Into<ElementId>, content: &StreamedContent) -> Self {
749        Self {
750            id: id.into(),
751            style: StyleRefinement::default(),
752            text: SharedString::from(content.text().to_string()),
753            state: content.state().clone(),
754            revision: content.revision(),
755            sources: Vec::new(),
756            citations: Vec::new(),
757            follow_ups: Vec::new(),
758            on_event: None,
759        }
760    }
761
762    /// Adds source chips shown once streaming has finished.
763    pub fn sources(mut self, sources: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
764        self.sources = sources.into_iter().map(SourceRef::new).collect();
765        self
766    }
767
768    /// Adds pre-built [`SourceRef`]s (equivalent to [`Self::sources`] today;
769    /// kept separate so richer source metadata can grow here).
770    pub fn source_refs(mut self, sources: impl IntoIterator<Item = SourceRef>) -> Self {
771        self.sources = sources.into_iter().collect();
772        self
773    }
774
775    /// Adds inline citation metadata resolved from `[[cite:<stable-id>]]`
776    /// markers in the streamed Markdown source.
777    ///
778    /// At the current upstream pin, the inline Markdown glyph is pointer-only.
779    /// When an event handler is present, the component therefore renders a
780    /// named companion Link as the keyboard and AccessKit authority; both
781    /// representations emit the same [`StreamingTextEvent::CitationActivated`].
782    pub fn citations(mut self, citations: impl IntoIterator<Item = CitationRef>) -> Self {
783        self.citations = citations.into_iter().collect();
784        self
785    }
786
787    /// Adds follow-up suggestions shown once streaming has finished.
788    pub fn follow_ups(mut self, follow_ups: impl IntoIterator<Item = FollowUp>) -> Self {
789        self.follow_ups = follow_ups.into_iter().collect();
790        self
791    }
792
793    /// Handles typed answer interactions.
794    pub fn on_event(
795        mut self,
796        handler: impl Fn(&StreamingTextEvent, &mut Window, &mut App) + 'static,
797    ) -> Self {
798        self.on_event = Some(Rc::new(handler));
799        self
800    }
801}
802
803impl Styled for StreamingText {
804    fn style(&mut self) -> &mut StyleRefinement {
805        &mut self.style
806    }
807}
808
809impl RenderOnce for StreamingText {
810    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
811        let tokens = cx.theme().semantic_tokens();
812        let streaming = self.state == ProgressState::Running;
813        let settled = self.state == ProgressState::Complete;
814        let interactive_citations = self.on_event.is_some();
815        let root_id = self.id.clone();
816        let sources_settled = crate::motion::acknowledged_state(
817            ElementId::from((root_id.clone(), "sources-settled")),
818            (settled && !self.sources.is_empty()) as u64,
819            window,
820            cx,
821        );
822        let cache = window.use_keyed_state((root_id.clone(), "citation-cache"), cx, |_, _| {
823            CitationCache::new()
824        });
825        let (source, referenced_citations) = cache.update(cx, |cache, _| {
826            cache.refresh(CitationKey {
827                revision: self.revision,
828                interactive: interactive_citations,
829                streaming,
830                citations: self.citations.clone(),
831                text: self.text.clone(),
832            });
833            (cache.source.clone(), cache.referenced.clone())
834        });
835        #[cfg(test)]
836        RENDERED_SOURCE.with(|rendered| *rendered.borrow_mut() = Some(source.clone()));
837        let on_event = self.on_event.clone();
838        let failure = match &self.state {
839            ProgressState::Failed(reason) => Some(reason.clone()),
840            _ => None,
841        };
842        let dispatcher = on_event.as_ref().map(|handler| {
843            let initial_citations = referenced_citations.clone();
844            let initial_handler = handler.clone();
845            let dispatcher =
846                window.use_keyed_state((root_id.clone(), "citation-dispatch"), cx, move |_, _| {
847                    CitationDispatcher {
848                        citations: initial_citations,
849                        on_event: initial_handler,
850                    }
851                });
852            dispatcher.update(cx, |dispatcher, _| {
853                dispatcher.citations = referenced_citations.clone();
854                dispatcher.on_event = handler.clone();
855            });
856            dispatcher
857        });
858        let mut answer = TextView::markdown((root_id.clone(), "answer"), source).selectable(true);
859        if let Some(dispatcher) = dispatcher {
860            let dispatcher = dispatcher.downgrade();
861            answer = answer.on_link_click(move |url, event, window, cx| {
862                let handled = dispatcher
863                    .update(cx, |dispatcher, cx| {
864                        dispatcher.activate(url, event, window, cx)
865                    })
866                    .is_ok_and(|handled| handled);
867                if !handled && !event.is_right_click() {
868                    cx.open_url(url);
869                }
870            });
871        }
872        let citation_scroll_handle = window
873            .use_keyed_state((root_id.clone(), "citation-scroll-handle"), cx, |_, _| {
874                ScrollHandle::new()
875            })
876            .read(cx)
877            .clone();
878
879        v_flex()
880            .id(self.id)
881            .w_full()
882            .min_w_0()
883            .role(Role::Article)
884            .aria_label("Answer")
885            .when_some(failure.clone(), |this, reason| {
886                this.aria_description(reason)
887            })
888            .gap(tokens.spacing.md)
889            .child(
890                div()
891                    .debug_selector(|| "streaming-text-body".to_owned())
892                    .text_token(tokens.typography.sm)
893                    .text_color(cx.theme().foreground)
894                    .child(answer),
895            )
896            .when_some(failure, |this, reason| {
897                // The glyph rides a first-line slot: the row stays
898                // items_start, so however far the reason wraps, the mark
899                // holds centered on the first line.
900                this.child(
901                    h_flex()
902                        .items_start()
903                        .gap(tokens.spacing.sm)
904                        .text_token(tokens.typography.xs)
905                        .text_color(cx.theme().danger)
906                        .child(crate::surface::leading_glyph_slot(
907                            crate::sizing::slot_sm(cx),
908                            Icon::new(IconName::CircleX).xsmall(),
909                        ))
910                        .child(div().min_w_0().child(reason)),
911                )
912            })
913            .when_some(on_event.clone(), |this, handler| {
914                this.when(!referenced_citations.is_empty(), |this| {
915                    this.child(
916                        v_flex()
917                            .id((root_id.clone(), "inline-citations"))
918                            .role(Role::Group)
919                            .aria_label("Inline citation links")
920                            .tab_group()
921                            .gap(tokens.spacing.xxs)
922                            .child(
923                                div()
924                                    .relative()
925                                    .w_full()
926                                    .child(
927                                        h_flex()
928                                            .id((root_id.clone(), "inline-citation-scroll"))
929                                            .debug_selector(|| {
930                                                "streaming-citation-scroll".to_owned()
931                                            })
932                                            .w_full()
933                                            .max_w_full()
934                                            .min_w_0()
935                                            .overflow_x_scroll()
936                                            .track_scroll(&citation_scroll_handle)
937                                            .gap(tokens.spacing.xs)
938                                            .children(referenced_citations.into_iter().map(
939                                                |citation| {
940                                                    let preview_title = citation.title.clone();
941                                                    let preview_destination =
942                                                        citation.destination.clone();
943                                                    let card_id = ElementId::from((
944                                                        root_id.clone(),
945                                                        format!("citation-card-{}", citation.id),
946                                                    ));
947                                                    HoverCard::new(card_id)
948                                                        .trigger(citation_companion_link(
949                                                            root_id.clone(),
950                                                            citation,
951                                                            handler.clone(),
952                                                            cx,
953                                                        ))
954                                                        .content(move |_, _, cx| {
955                                                            citation_preview(
956                                                                preview_title.clone(),
957                                                                preview_destination.clone(),
958                                                                cx,
959                                                            )
960                                                        })
961                                                },
962                                            )),
963                                    )
964                                    .child(
965                                        ScrollableMask::new(
966                                            Axis::Horizontal,
967                                            &citation_scroll_handle,
968                                        )
969                                        .id((root_id.clone(), "inline-citation-scroll-mask")),
970                                    ),
971                            ),
972                    )
973                })
974            })
975            .when(settled && !self.sources.is_empty(), |this| {
976                // The sources row is a discrete addition: it appears whole
977                // when the answer settles, acknowledged once — an answer
978                // that mounts already settled shows it at rest, and
979                // repeated snapshots replay nothing. Streamed prose above
980                // is never animated.
981                let settled_in = sources_settled;
982                this.child(
983                    h_flex()
984                        .id((root_id.clone(), "sources"))
985                        .opacity(settled_in)
986                        .top(tokens.spacing.xxs * (1.0 - settled_in) * crate::motion::travel(cx))
987                        .role(Role::List)
988                        .aria_label("Sources")
989                        .flex_wrap()
990                        .gap(tokens.spacing.xs)
991                        .children(self.sources.into_iter().enumerate().map(|(ix, source)| {
992                            source_chip(root_id.clone(), ix, source, on_event.clone(), cx)
993                        })),
994                )
995            })
996            .when(settled && !self.follow_ups.is_empty(), |this| {
997                let handler = on_event.clone();
998                this.child(
999                    h_flex()
1000                        .id((root_id, "follow-ups"))
1001                        .role(Role::Group)
1002                        .aria_label("Follow-up suggestions")
1003                        .flex_wrap()
1004                        .gap(tokens.spacing.xs)
1005                        .children(self.follow_ups.into_iter().map(|follow_up| {
1006                            follow_up_button(follow_up, handler.clone(), window, cx)
1007                        })),
1008                )
1009            })
1010            .refine_style(&self.style)
1011    }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017    use crate::stream::Progressive;
1018    use gpui::{Element as _, Entity, TestAppContext, VisualTestContext, accesskit, canvas};
1019    use std::{
1020        sync::{Arc, Mutex},
1021        time::Instant,
1022    };
1023
1024    /// The pre-cache transform, kept verbatim so the indexed rewrite can be
1025    /// proven byte-identical rather than merely plausible.
1026    fn transform_citations_by_linear_scan(
1027        source: &str,
1028        citations: &[CitationRef],
1029        interactive: bool,
1030    ) -> CitationTransform {
1031        let mut transformed = String::with_capacity(source.len());
1032        let mut referenced = Vec::new();
1033        let mut fence: Option<OpenFence> = None;
1034        let mut inline_ticks: Option<usize> = None;
1035
1036        for line in source.split_inclusive('\n') {
1037            let (quote_depth, quote_content) = block_quote_content(line);
1038            let candidate = fence_candidate(quote_content);
1039
1040            if let Some(open) = fence {
1041                if quote_depth >= open.quote_depth {
1042                    transformed.push_str(line);
1043                    if quote_depth == open.quote_depth
1044                        && candidate.is_some_and(|candidate| closes_fence(candidate, open))
1045                    {
1046                        fence = None;
1047                    }
1048                    continue;
1049                }
1050                fence = None;
1051            }
1052
1053            if inline_ticks.is_none()
1054                && let Some(candidate) = candidate
1055                && opens_fence(candidate)
1056            {
1057                fence = Some(OpenFence {
1058                    marker: candidate.marker,
1059                    length: candidate.length,
1060                    quote_depth,
1061                });
1062                transformed.push_str(line);
1063                continue;
1064            }
1065
1066            let mut offset = 0;
1067            while offset < line.len() {
1068                let remainder = &line[offset..];
1069                if remainder.starts_with('`') {
1070                    let run = remainder.bytes().take_while(|byte| *byte == b'`').count();
1071                    transformed.push_str(&remainder[..run]);
1072                    match inline_ticks {
1073                        Some(open) if open == run => inline_ticks = None,
1074                        None => inline_ticks = Some(run),
1075                        _ => {}
1076                    }
1077                    offset += run;
1078                    continue;
1079                }
1080
1081                if inline_ticks.is_none()
1082                    && let Some(marker) = remainder.strip_prefix("[[cite:")
1083                    && let Some(end) = marker.find("]]")
1084                {
1085                    let id = &marker[..end];
1086                    if let Some(citation) = citations.iter().find(|citation| citation.id == id) {
1087                        if !referenced
1088                            .iter()
1089                            .any(|referenced: &CitationRef| referenced.id == citation.id)
1090                        {
1091                            referenced.push(citation.clone());
1092                        }
1093                        let label = escape_markdown_label(&citation.label);
1094                        if interactive {
1095                            transformed.push('[');
1096                            transformed.push_str(&label);
1097                            transformed.push_str("](");
1098                            transformed.push_str(&citation.internal_url());
1099                            transformed.push_str(" \"");
1100                            transformed.push_str(&escape_markdown_title(&citation.title));
1101                            transformed.push_str("\")");
1102                        } else {
1103                            transformed.push_str("\\[");
1104                            transformed.push_str(&label);
1105                            transformed.push_str("\\]");
1106                        }
1107                        offset += "[[cite:".len() + end + "]]".len();
1108                        continue;
1109                    }
1110                }
1111
1112                if let Some(character) = remainder.chars().next() {
1113                    transformed.push(character);
1114                    offset += character.len_utf8();
1115                } else {
1116                    break;
1117                }
1118            }
1119        }
1120
1121        CitationTransform {
1122            markdown: transformed,
1123            referenced,
1124        }
1125    }
1126
1127    fn citations() -> [CitationRef; 2] {
1128        [
1129            CitationRef::new("pricing", "Pricing", "Open pricing source", "app://pricing"),
1130            CitationRef::new(
1131                "supplier notes",
1132                "Supplier [notes]",
1133                "Open \"supplier\" notes",
1134                "app://suppliers?section=notes",
1135            ),
1136        ]
1137    }
1138
1139    #[test]
1140    fn maps_all_shared_lifecycle_states() {
1141        let cases = [
1142            Progressive::pending("answer".to_owned()),
1143            Progressive::running("answer".to_owned()),
1144            Progressive::complete("answer".to_owned()),
1145            Progressive::failed("answer".to_owned(), "offline"),
1146        ];
1147        for content in cases {
1148            assert_eq!(
1149                StreamingText::new("answer", &content).state,
1150                *content.state()
1151            );
1152        }
1153    }
1154
1155    #[test]
1156    fn sources_derive_domains_and_initials() {
1157        let web = SourceRef::new("Wholesale pricing").url("https://www.alpenrose.com/pricing?q=1");
1158        assert_eq!(web.domain().as_deref(), Some("alpenrose.com"));
1159        assert_eq!(web.initial(), "A");
1160        assert_eq!(
1161            web.accessibility_label(),
1162            "Wholesale pricing, alpenrose.com"
1163        );
1164        let file = SourceRef::new("pricing.md");
1165        assert_eq!(file.domain(), None);
1166        assert_eq!(file.initial(), "P");
1167        assert_eq!(file.id(), "pricing.md");
1168        let custom = SourceRef::with_id("src-1", "(notes)");
1169        assert_eq!(custom.initial(), "N");
1170        assert_eq!(SourceRef::new("…").initial(), "•");
1171    }
1172
1173    #[test]
1174    fn duplicate_follow_up_labels_emit_distinct_stable_ids() {
1175        let first = FollowUp::new("first", "Try again");
1176        let second = FollowUp::new("second", "Try again");
1177        assert_eq!(
1178            first.selected_event(),
1179            StreamingTextEvent::FollowUpSelected { id: "first".into() }
1180        );
1181        assert_eq!(
1182            second.selected_event(),
1183            StreamingTextEvent::FollowUpSelected {
1184                id: "second".into()
1185            }
1186        );
1187    }
1188
1189    #[test]
1190    fn complete_known_markers_become_internal_markdown_links() {
1191        assert_eq!(
1192            transform_citation_markers("See [[cite:pricing]].", &citations(), true),
1193            "See [Pricing](gpui-ai-citation://pricing \"Open pricing source\")."
1194        );
1195        assert_eq!(
1196            transform_citation_markers("See [[cite:supplier notes]].", &citations(), true),
1197            "See [Supplier \\[notes\\]](gpui-ai-citation://supplier%20notes \"Open \\\"supplier\\\" notes\")."
1198        );
1199    }
1200
1201    #[test]
1202    fn unknown_and_incomplete_markers_remain_readable_literals() {
1203        assert_eq!(
1204            transform_citation_markers("Unknown [[cite:missing]].", &citations(), true),
1205            "Unknown [[cite:missing]]."
1206        );
1207        assert_eq!(
1208            transform_citation_markers("Partial [[cite:pricing", &citations(), true),
1209            "Partial [[cite:pricing"
1210        );
1211    }
1212
1213    #[test]
1214    fn noninteractive_citations_render_visible_non_link_labels() {
1215        assert_eq!(
1216            transform_citation_markers("See [[cite:pricing]].", &citations(), false),
1217            "See \\[Pricing\\]."
1218        );
1219    }
1220
1221    #[test]
1222    fn markers_inside_inline_and_fenced_code_are_not_rewritten() {
1223        let source = concat!(
1224            "Outside [[cite:pricing]] and `inline [[cite:pricing]]`.\n\n",
1225            "```text\n[[cite:pricing]]\n```\n",
1226            "~~~\n[[cite:pricing]]\n~~~\n"
1227        );
1228        let expected = concat!(
1229            "Outside [Pricing](gpui-ai-citation://pricing \"Open pricing source\") and `inline [[cite:pricing]]`.\n\n",
1230            "```text\n[[cite:pricing]]\n```\n",
1231            "~~~\n[[cite:pricing]]\n~~~\n"
1232        );
1233        assert_eq!(
1234            transform_citation_markers(source, &citations(), true),
1235            expected
1236        );
1237    }
1238
1239    #[test]
1240    fn trailing_text_cannot_close_a_fenced_code_block() {
1241        let source = concat!(
1242            "```rust\n",
1243            "[[cite:pricing]]\n",
1244            "```not-a-close\n",
1245            "[[cite:pricing]]\n",
1246            "```\n",
1247            "Outside [[cite:pricing]]."
1248        );
1249        let expected = concat!(
1250            "```rust\n",
1251            "[[cite:pricing]]\n",
1252            "```not-a-close\n",
1253            "[[cite:pricing]]\n",
1254            "```\n",
1255            "Outside [Pricing](gpui-ai-citation://pricing \"Open pricing source\")."
1256        );
1257
1258        assert_eq!(
1259            transform_citation_markers(source, &citations(), true),
1260            expected
1261        );
1262    }
1263
1264    #[test]
1265    fn block_quote_fences_protect_markers_and_accept_longer_closers() {
1266        let source = concat!(
1267            "> ```rust\n",
1268            "> [[cite:pricing]]\n",
1269            "> ````\n",
1270            "Outside [[cite:pricing]]."
1271        );
1272        let expected = concat!(
1273            "> ```rust\n",
1274            "> [[cite:pricing]]\n",
1275            "> ````\n",
1276            "Outside [Pricing](gpui-ai-citation://pricing \"Open pricing source\")."
1277        );
1278
1279        assert_eq!(
1280            transform_citation_markers(source, &citations(), true),
1281            expected
1282        );
1283    }
1284
1285    #[test]
1286    fn duplicate_labels_route_by_stable_id_and_preserve_destination() {
1287        let refs = [
1288            CitationRef::new("first", "Report", "Open first", "app://first"),
1289            CitationRef::new("second", "Report", "Open second", "app://second"),
1290        ];
1291
1292        assert_eq!(
1293            citation_event_for_url("gpui-ai-citation://second", &refs),
1294            Some(StreamingTextEvent::CitationActivated {
1295                id: "second".into(),
1296                destination: "app://second".into(),
1297            })
1298        );
1299    }
1300
1301    #[test]
1302    fn only_complete_referenced_citations_receive_companion_controls() {
1303        let transformed = transform_citations(
1304            "[[cite:pricing]] again [[cite:pricing]] then [[cite:supplier notes",
1305            &citations(),
1306            true,
1307        );
1308
1309        assert_eq!(transformed.referenced.len(), 1);
1310        assert_eq!(transformed.referenced[0].id(), "pricing");
1311    }
1312
1313    fn numbered_citations(count: usize) -> Vec<CitationRef> {
1314        (0..count)
1315            .map(|index| {
1316                CitationRef::new(
1317                    format!("source-{index}"),
1318                    format!("Source [{index}]"),
1319                    format!("Open \"source {index}\""),
1320                    format!("app://sources/{index}?section=notes"),
1321                )
1322            })
1323            .collect()
1324    }
1325
1326    /// Prose of at least `bytes` length carrying one marker per sentence,
1327    /// cycling through `citations` so a long answer references many of them.
1328    fn answer_of(bytes: usize, citations: &[CitationRef]) -> String {
1329        const SENTENCE: &str = "Wholesale prices moved again this quarter, and the report explains the shift in detail. ";
1330        let mut source = String::with_capacity(bytes + SENTENCE.len() + 64);
1331        let mut sentence = 0usize;
1332        while source.len() < bytes {
1333            source.push_str(SENTENCE);
1334            if let Some(citation) = citations.get(sentence % citations.len().max(1)) {
1335                source.push_str("[[cite:");
1336                source.push_str(citation.id());
1337                source.push_str("]] ");
1338            }
1339            sentence += 1;
1340            if sentence.is_multiple_of(4) {
1341                source.push_str("\n\n");
1342            }
1343        }
1344        source
1345    }
1346
1347    #[test]
1348    fn the_indexed_transform_matches_the_linear_scan_byte_for_byte() {
1349        let dense = numbered_citations(100);
1350        let duplicate_ids = [
1351            CitationRef::new("pricing", "First", "Open first", "app://first"),
1352            CitationRef::new("pricing", "Second", "Open second", "app://second"),
1353        ];
1354        let edge_cases = concat!(
1355            "Outside [[cite:pricing]] and `inline [[cite:pricing]]`.\n\n",
1356            "```text\n[[cite:pricing]]\n```\n",
1357            "~~~\n[[cite:supplier notes]]\n~~~\n",
1358            "> ```rust\n> [[cite:pricing]]\n> ````\n",
1359            "Unknown [[cite:missing]], partial [[cite:pricing, repeat [[cite:pricing]].\n",
1360            "Escaped [[cite:supplier notes]] twice [[cite:supplier notes]].\n"
1361        );
1362        let cases: [(&str, &[CitationRef]); 5] = [
1363            (edge_cases, &citations()),
1364            (edge_cases, &duplicate_ids),
1365            (&answer_of(4 * 1024, &dense), &dense),
1366            (&answer_of(4 * 1024, &citations()), &citations()),
1367            (&answer_of(1024, &[]), &[]),
1368        ];
1369
1370        for (source, refs) in cases {
1371            for interactive in [true, false] {
1372                let indexed = transform_citations(source, refs, interactive);
1373                let scanned = transform_citations_by_linear_scan(source, refs, interactive);
1374                assert_eq!(indexed.markdown, scanned.markdown);
1375                assert_eq!(indexed.referenced, scanned.referenced);
1376            }
1377        }
1378    }
1379
1380    /// Fastest of `rounds` timed runs, after one untimed warm-up, in
1381    /// microseconds per pass. The minimum is the least noisy estimator here:
1382    /// this measures a pure function against an unloaded allocator.
1383    fn microseconds_per_pass(rounds: usize, passes: usize, mut pass: impl FnMut() -> usize) -> f64 {
1384        let mut observed = 0usize;
1385        let mut best = f64::MAX;
1386        for round in 0..=rounds {
1387            let started = Instant::now();
1388            for _ in 0..passes {
1389                observed = observed.wrapping_add(pass());
1390            }
1391            let elapsed = started.elapsed().as_secs_f64() * 1e6 / passes as f64;
1392            if round > 0 {
1393                best = best.min(elapsed);
1394            }
1395        }
1396        assert!(observed > 0, "the timed transform must produce output");
1397        best
1398    }
1399
1400    #[test]
1401    fn citation_transform_timings_are_informational() {
1402        println!("citation transform, microseconds per pass (best of 3)");
1403        println!(
1404            "{:>10} {:>10} {:>10} {:>12} {:>14}",
1405            "bytes", "citations", "markers", "indexed", "linear scan"
1406        );
1407        for bytes in [1024, 32 * 1024, 256 * 1024] {
1408            for count in [0, 10, 100] {
1409                let refs = numbered_citations(count);
1410                // An answer that declares citations it has not reached yet is
1411                // the ordinary early-stream shape, so it gets its own row.
1412                for cited in [true, false] {
1413                    if count == 0 && !cited {
1414                        continue;
1415                    }
1416                    let source = answer_of(bytes, if cited { refs.as_slice() } else { &[] });
1417                    let markers = source.matches("[[cite:").count();
1418                    let passes = (1 << 20usize) / bytes;
1419                    let indexed = microseconds_per_pass(3, passes, || {
1420                        transform_citations(&source, &refs, true).markdown.len()
1421                    });
1422                    let scanned = microseconds_per_pass(3, passes, || {
1423                        transform_citations_by_linear_scan(&source, &refs, true)
1424                            .markdown
1425                            .len()
1426                    });
1427                    println!(
1428                        "{bytes:>10} {count:>10} {markers:>10} {indexed:>12.1} {scanned:>14.1}"
1429                    );
1430                }
1431            }
1432        }
1433    }
1434
1435    struct CacheProbe {
1436        content: StreamedContent,
1437        citations: Vec<CitationRef>,
1438        interactive: bool,
1439    }
1440
1441    impl gpui::Render for CacheProbe {
1442        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1443            StreamingText::new("cache-probe", &self.content)
1444                .citations(self.citations.clone())
1445                .when(self.interactive, |this| this.on_event(|_, _, _| {}))
1446        }
1447    }
1448
1449    fn transform_calls() -> usize {
1450        TRANSFORM_CALLS.with(std::cell::Cell::get)
1451    }
1452
1453    fn rendered_source() -> SharedString {
1454        RENDERED_SOURCE.with(|rendered| {
1455            rendered
1456                .borrow()
1457                .clone()
1458                .expect("a frame should have rendered the answer")
1459        })
1460    }
1461
1462    fn cache_probe(
1463        cx: &mut TestAppContext,
1464        content: StreamedContent,
1465    ) -> (Entity<CacheProbe>, &mut VisualTestContext) {
1466        cx.update(crate::init);
1467        let (probe, cx) = cx.add_window_view(move |_, _| CacheProbe {
1468            content,
1469            citations: citations().to_vec(),
1470            interactive: true,
1471        });
1472        let cx: &mut VisualTestContext = cx;
1473        cx.update(|window, cx| window.draw(cx).clear(cx));
1474        (probe, cx)
1475    }
1476
1477    fn draw(cx: &mut VisualTestContext) {
1478        cx.update(|window, cx| window.draw(cx).clear(cx));
1479    }
1480
1481    fn cited_answer() -> String {
1482        "Prices rose; see [[cite:pricing]] and [[cite:supplier notes]].".to_owned()
1483    }
1484
1485    #[gpui::test]
1486    fn an_unrelated_re_render_reuses_the_cached_citation_transform(cx: &mut TestAppContext) {
1487        let (probe, cx) = cache_probe(cx, Progressive::running(cited_answer()));
1488        let transformed_once = transform_calls();
1489        assert!(transformed_once > 0, "the first frame must transform");
1490        let first_source = rendered_source();
1491
1492        // Nothing about the answer changed; only the parent asked for a frame.
1493        probe.update(cx, |_, cx| cx.notify());
1494        draw(cx);
1495        draw(cx);
1496
1497        assert_eq!(transform_calls(), transformed_once);
1498        assert_eq!(rendered_source(), first_source);
1499    }
1500
1501    #[gpui::test]
1502    fn appending_streamed_text_invalidates_the_cached_transform(cx: &mut TestAppContext) {
1503        let (probe, cx) = cache_probe(cx, Progressive::running(cited_answer()));
1504        let before = transform_calls();
1505
1506        probe.update(cx, |probe, cx| {
1507            probe.content.append(" Also [[cite:pricing]] again.");
1508            cx.notify();
1509        });
1510        draw(cx);
1511
1512        assert_eq!(transform_calls(), before + 1);
1513        assert!(rendered_source().contains("again"));
1514    }
1515
1516    #[gpui::test]
1517    fn replacing_text_at_the_same_revision_invalidates_the_cache(cx: &mut TestAppContext) {
1518        let (probe, cx) = cache_probe(
1519            cx,
1520            Progressive::complete("First [[cite:pricing]] answer.".to_owned()),
1521        );
1522        let before = transform_calls();
1523        assert!(rendered_source().contains("First"));
1524
1525        // A freshly constructed snapshot reports revision zero exactly like the
1526        // one it replaces, so revision alone would report a false cache hit.
1527        probe.update(cx, |probe, cx| {
1528            probe.content = Progressive::complete("Second [[cite:pricing]] answer.".to_owned());
1529            assert_eq!(probe.content.revision(), 0);
1530            cx.notify();
1531        });
1532        draw(cx);
1533
1534        assert_eq!(transform_calls(), before + 1);
1535        assert!(rendered_source().contains("Second"));
1536    }
1537
1538    #[gpui::test]
1539    fn changing_the_citation_set_invalidates_the_cache(cx: &mut TestAppContext) {
1540        let (probe, cx) = cache_probe(cx, Progressive::complete(cited_answer()));
1541        let before = transform_calls();
1542        assert!(rendered_source().contains("Supplier"));
1543
1544        probe.update(cx, |probe, cx| {
1545            probe.citations = vec![citations()[0].clone()];
1546            cx.notify();
1547        });
1548        draw(cx);
1549
1550        assert_eq!(transform_calls(), before + 1);
1551        let source = rendered_source();
1552        assert!(source.contains("Pricing"));
1553        assert!(source.contains("[[cite:supplier notes]]"));
1554    }
1555
1556    #[gpui::test]
1557    fn changing_interactivity_invalidates_the_cache(cx: &mut TestAppContext) {
1558        let (probe, cx) = cache_probe(cx, Progressive::complete(cited_answer()));
1559        let before = transform_calls();
1560        assert!(rendered_source().contains("gpui-ai-citation://pricing"));
1561
1562        probe.update(cx, |probe, cx| {
1563            probe.interactive = false;
1564            cx.notify();
1565        });
1566        draw(cx);
1567
1568        assert_eq!(transform_calls(), before + 1);
1569        let source = rendered_source();
1570        assert!(!source.contains("gpui-ai-citation://"));
1571        assert!(source.contains("\\[Pricing\\]"));
1572    }
1573
1574    #[gpui::test]
1575    fn a_lifecycle_move_rebuilds_only_the_cursor(cx: &mut TestAppContext) {
1576        let (probe, cx) = cache_probe(cx, Progressive::running(cited_answer()));
1577        let before = transform_calls();
1578        let streaming_source = rendered_source();
1579        assert!(streaming_source.ends_with(STREAMING_CURSOR));
1580
1581        probe.update(cx, |probe, cx| {
1582            probe.content.finish();
1583            cx.notify();
1584        });
1585        draw(cx);
1586        assert!(
1587            probe.read_with(cx, |probe, _| probe.content.revision()) > 0,
1588            "finishing must advance the revision"
1589        );
1590
1591        // The cursor is decoration over the transform, not an input to it.
1592        assert_eq!(transform_calls(), before);
1593        let settled_source = rendered_source();
1594        assert_ne!(settled_source, streaming_source);
1595        assert_eq!(
1596            Some(settled_source.as_str()),
1597            streaming_source.strip_suffix(STREAMING_CURSOR)
1598        );
1599
1600        // A second lifecycle move that leaves the cursor off keeps both parts.
1601        probe.update(cx, |probe, cx| {
1602            probe.content.fail("offline");
1603            cx.notify();
1604        });
1605        draw(cx);
1606        assert_eq!(transform_calls(), before);
1607        assert_eq!(rendered_source(), settled_source);
1608    }
1609
1610    struct CapturedCitationA11y {
1611        role: Option<Role>,
1612        node: accesskit::Node,
1613    }
1614
1615    struct CitationA11yProbe {
1616        captured: Arc<Mutex<Option<CapturedCitationA11y>>>,
1617    }
1618
1619    impl gpui::Render for CitationA11yProbe {
1620        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1621            let captured = self.captured.clone();
1622            canvas(
1623                move |_, window, cx| {
1624                    let element = citation_companion_link(
1625                        ElementId::from("answer"),
1626                        CitationRef::new(
1627                            "pricing",
1628                            "Pricing",
1629                            "Open pricing source",
1630                            "app://pricing",
1631                        ),
1632                        Rc::new(|_, _, _| {}),
1633                        cx,
1634                    )
1635                    .render(window, cx)
1636                    .into_element();
1637                    let role = element.a11y_role();
1638                    let mut node = accesskit::Node::new(role.unwrap_or(Role::Unknown));
1639                    element.write_a11y_info(&mut node);
1640                    *captured.lock().expect("capture mutex should be available") =
1641                        Some(CapturedCitationA11y { role, node });
1642                },
1643                |_, _, _, _| {},
1644            )
1645        }
1646    }
1647
1648    #[gpui::test]
1649    fn companion_link_exposes_role_name_and_click_action(cx: &mut gpui::TestAppContext) {
1650        cx.update(crate::init);
1651        let captured = Arc::new(Mutex::new(None));
1652        let result = captured.clone();
1653        let (_, cx) = cx.add_window_view(move |_, _| CitationA11yProbe { captured });
1654        cx.update(|window, cx| window.draw(cx).clear(cx));
1655
1656        let captured = result
1657            .lock()
1658            .expect("capture mutex should be available")
1659            .take()
1660            .expect("citation link should be captured");
1661        assert_eq!(captured.role, Some(Role::Link));
1662        assert_eq!(captured.node.label(), Some("Open pricing source"));
1663        assert!(captured.node.supports_action(accesskit::Action::Click));
1664    }
1665
1666    struct FollowUpA11yProbe {
1667        captured: Arc<Mutex<Option<CapturedCitationA11y>>>,
1668    }
1669
1670    impl gpui::Render for FollowUpA11yProbe {
1671        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1672            let captured = self.captured.clone();
1673            canvas(
1674                move |_, window, cx| {
1675                    let element = follow_up_button(
1676                        FollowUp::new("compare", "Compare suppliers"),
1677                        Some(Rc::new(|_, _, _| {})),
1678                        window,
1679                        cx,
1680                    )
1681                    .render(window, cx)
1682                    .into_element();
1683                    let role = element.a11y_role();
1684                    let mut node = accesskit::Node::new(Role::Unknown);
1685                    element.write_a11y_info(&mut node);
1686                    *captured.lock().expect("capture mutex should be available") =
1687                        Some(CapturedCitationA11y { role, node });
1688                },
1689                |_, _, _, _| {},
1690            )
1691        }
1692    }
1693
1694    #[gpui::test]
1695    fn follow_up_exposes_production_role_name_and_click_action(cx: &mut gpui::TestAppContext) {
1696        cx.update(crate::init);
1697        let captured = Arc::new(Mutex::new(None));
1698        let result = captured.clone();
1699        let (_, cx) = cx.add_window_view(move |_, _| FollowUpA11yProbe { captured });
1700        cx.update(|window, cx| window.draw(cx).clear(cx));
1701
1702        let captured = result
1703            .lock()
1704            .expect("capture mutex should be available")
1705            .take()
1706            .expect("follow-up button should be captured");
1707        assert_eq!(captured.role, Some(Role::Button));
1708        assert_eq!(captured.node.label(), Some("Compare suppliers"));
1709        assert!(captured.node.supports_action(accesskit::Action::Click));
1710    }
1711}