context_pill.rs

  1use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration};
  2
  3use file_icons::FileIcons;
  4use futures::FutureExt as _;
  5use gpui::{
  6    Animation, AnimationExt as _, AnyView, ClickEvent, Entity, Image, MouseButton, Task,
  7    pulsating_between,
  8};
  9use language_model::LanguageModelImage;
 10use project::Project;
 11use prompt_store::PromptStore;
 12use rope::Point;
 13use ui::{Tooltip, prelude::*, tooltip_container};
 14
 15use agent::context::{
 16    AgentContextHandle, ContextId, ContextKind, DirectoryContextHandle, FetchedUrlContext,
 17    FileContextHandle, ImageContext, ImageStatus, RulesContextHandle, SelectionContextHandle,
 18    SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle,
 19};
 20use util::paths::PathStyle;
 21
 22#[derive(IntoElement)]
 23pub enum ContextPill {
 24    Added {
 25        context: AddedContext,
 26        dupe_name: bool,
 27        focused: bool,
 28        on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
 29        on_remove: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
 30    },
 31    Suggested {
 32        name: SharedString,
 33        icon_path: Option<SharedString>,
 34        kind: ContextKind,
 35        focused: bool,
 36        on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
 37    },
 38}
 39
 40impl ContextPill {
 41    pub fn added(
 42        context: AddedContext,
 43        dupe_name: bool,
 44        focused: bool,
 45        on_remove: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
 46    ) -> Self {
 47        Self::Added {
 48            context,
 49            dupe_name,
 50            on_remove,
 51            focused,
 52            on_click: None,
 53        }
 54    }
 55
 56    pub fn suggested(
 57        name: SharedString,
 58        icon_path: Option<SharedString>,
 59        kind: ContextKind,
 60        focused: bool,
 61    ) -> Self {
 62        Self::Suggested {
 63            name,
 64            icon_path,
 65            kind,
 66            focused,
 67            on_click: None,
 68        }
 69    }
 70
 71    pub fn on_click(mut self, listener: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>) -> Self {
 72        match &mut self {
 73            ContextPill::Added { on_click, .. } => {
 74                *on_click = Some(listener);
 75            }
 76            ContextPill::Suggested { on_click, .. } => {
 77                *on_click = Some(listener);
 78            }
 79        }
 80        self
 81    }
 82
 83    pub fn id(&self) -> ElementId {
 84        match self {
 85            Self::Added { context, .. } => context.handle.element_id("context-pill".into()),
 86            Self::Suggested { .. } => "suggested-context-pill".into(),
 87        }
 88    }
 89
 90    pub fn icon(&self) -> Icon {
 91        match self {
 92            Self::Suggested {
 93                icon_path: Some(icon_path),
 94                ..
 95            } => Icon::from_path(icon_path),
 96            Self::Suggested { kind, .. } => Icon::new(kind.icon()),
 97            Self::Added { context, .. } => context.icon(),
 98        }
 99    }
100}
101
102impl RenderOnce for ContextPill {
103    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
104        let color = cx.theme().colors();
105
106        let base_pill = h_flex()
107            .id(self.id())
108            .pl_1()
109            .pb(px(1.))
110            .border_1()
111            .rounded_sm()
112            .gap_1()
113            .child(self.icon().size(IconSize::XSmall).color(Color::Muted));
114
115        match &self {
116            ContextPill::Added {
117                context,
118                dupe_name,
119                on_remove,
120                focused,
121                on_click,
122            } => {
123                let status_is_error = matches!(context.status, ContextStatus::Error { .. });
124                let status_is_warning = matches!(context.status, ContextStatus::Warning { .. });
125
126                base_pill
127                    .pr(if on_remove.is_some() { px(2.) } else { px(4.) })
128                    .map(|pill| {
129                        if status_is_error {
130                            pill.bg(cx.theme().status().error_background)
131                                .border_color(cx.theme().status().error_border)
132                        } else if status_is_warning {
133                            pill.bg(cx.theme().status().warning_background)
134                                .border_color(cx.theme().status().warning_border)
135                        } else if *focused {
136                            pill.bg(color.element_background)
137                                .border_color(color.border_focused)
138                        } else {
139                            pill.bg(color.element_background)
140                                .border_color(color.border.opacity(0.5))
141                        }
142                    })
143                    .child(
144                        h_flex()
145                            .id("context-data")
146                            .gap_1()
147                            .child(
148                                div().max_w_64().child(
149                                    Label::new(context.name.clone())
150                                        .size(LabelSize::Small)
151                                        .truncate(),
152                                ),
153                            )
154                            .when_some(context.parent.as_ref(), |element, parent_name| {
155                                if *dupe_name {
156                                    element.child(
157                                        Label::new(parent_name.clone())
158                                            .size(LabelSize::XSmall)
159                                            .color(Color::Muted),
160                                    )
161                                } else {
162                                    element
163                                }
164                            })
165                            .when_some(context.tooltip.as_ref(), |element, tooltip| {
166                                element.tooltip(Tooltip::text(tooltip.clone()))
167                            })
168                            .map(|element| match &context.status {
169                                ContextStatus::Ready => element
170                                    .when_some(
171                                        context.render_hover.as_ref(),
172                                        |element, render_hover| {
173                                            let render_hover = render_hover.clone();
174                                            element.hoverable_tooltip(move |window, cx| {
175                                                render_hover(window, cx)
176                                            })
177                                        },
178                                    )
179                                    .into_any(),
180                                ContextStatus::Loading { message } => element
181                                    .tooltip(ui::Tooltip::text(message.clone()))
182                                    .with_animation(
183                                        "pulsating-ctx-pill",
184                                        Animation::new(Duration::from_secs(2))
185                                            .repeat()
186                                            .with_easing(pulsating_between(0.4, 0.8)),
187                                        |label, delta| label.opacity(delta),
188                                    )
189                                    .into_any_element(),
190                                ContextStatus::Warning { message }
191                                | ContextStatus::Error { message } => element
192                                    .tooltip(ui::Tooltip::text(message.clone()))
193                                    .into_any_element(),
194                            }),
195                    )
196                    .when_some(on_remove.as_ref(), |element, on_remove| {
197                        element.child(
198                            IconButton::new(
199                                context.handle.element_id("remove".into()),
200                                IconName::Close,
201                            )
202                            .icon_size(IconSize::XSmall)
203                            .tooltip(Tooltip::text("Remove Context"))
204                            .on_click({
205                                let on_remove = on_remove.clone();
206                                move |event, window, cx| on_remove(event, window, cx)
207                            }),
208                        )
209                    })
210                    .when_some(on_click.as_ref(), |element, on_click| {
211                        let on_click = on_click.clone();
212                        element.cursor_pointer().on_click(move |event, window, cx| {
213                            on_click(event, window, cx);
214                            cx.stop_propagation();
215                        })
216                    })
217                    .into_any_element()
218            }
219            ContextPill::Suggested {
220                name,
221                icon_path: _,
222                kind: _,
223                focused,
224                on_click,
225            } => base_pill
226                .cursor_pointer()
227                .pr_1()
228                .border_dashed()
229                .map(|pill| {
230                    if *focused {
231                        pill.border_color(color.border_focused)
232                            .bg(color.element_background.opacity(0.5))
233                    } else {
234                        pill.border_color(color.border)
235                    }
236                })
237                .hover(|style| style.bg(color.element_hover.opacity(0.5)))
238                .child(
239                    div().max_w_64().child(
240                        Label::new(name.clone())
241                            .size(LabelSize::Small)
242                            .color(Color::Muted)
243                            .truncate(),
244                    ),
245                )
246                .tooltip(|window, cx| {
247                    Tooltip::with_meta("Suggested Context", None, "Click to add it", window, cx)
248                })
249                .when_some(on_click.as_ref(), |element, on_click| {
250                    let on_click = on_click.clone();
251                    element.on_click(move |event, window, cx| {
252                        on_click(event, window, cx);
253                        cx.stop_propagation();
254                    })
255                })
256                .into_any(),
257        }
258    }
259}
260
261pub enum ContextStatus {
262    Ready,
263    Loading { message: SharedString },
264    Error { message: SharedString },
265    Warning { message: SharedString },
266}
267
268#[derive(RegisterComponent)]
269pub struct AddedContext {
270    pub handle: AgentContextHandle,
271    pub kind: ContextKind,
272    pub name: SharedString,
273    pub parent: Option<SharedString>,
274    pub tooltip: Option<SharedString>,
275    pub icon_path: Option<SharedString>,
276    pub status: ContextStatus,
277    pub render_hover: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
278}
279
280impl AddedContext {
281    pub fn icon(&self) -> Icon {
282        match &self.status {
283            ContextStatus::Warning { .. } => Icon::new(IconName::Warning).color(Color::Warning),
284            ContextStatus::Error { .. } => Icon::new(IconName::XCircle).color(Color::Error),
285            _ => {
286                if let Some(icon_path) = &self.icon_path {
287                    Icon::from_path(icon_path)
288                } else {
289                    Icon::new(self.kind.icon())
290                }
291            }
292        }
293    }
294    /// Creates an `AddedContext` by retrieving relevant details of `AgentContext`. This returns a
295    /// `None` if `DirectoryContext` or `RulesContext` no longer exist.
296    ///
297    /// TODO: `None` cases are unremovable from `ContextStore` and so are a very minor memory leak.
298    pub fn new_pending(
299        handle: AgentContextHandle,
300        prompt_store: Option<&Entity<PromptStore>>,
301        project: &Project,
302        model: Option<&Arc<dyn language_model::LanguageModel>>,
303        cx: &App,
304    ) -> Option<AddedContext> {
305        match handle {
306            AgentContextHandle::File(handle) => {
307                Self::pending_file(handle, project.path_style(cx), cx)
308            }
309            AgentContextHandle::Directory(handle) => Self::pending_directory(handle, project, cx),
310            AgentContextHandle::Symbol(handle) => {
311                Self::pending_symbol(handle, project.path_style(cx), cx)
312            }
313            AgentContextHandle::Selection(handle) => {
314                Self::pending_selection(handle, project.path_style(cx), cx)
315            }
316            AgentContextHandle::FetchedUrl(handle) => Some(Self::fetched_url(handle)),
317            AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)),
318            AgentContextHandle::TextThread(handle) => Some(Self::pending_text_thread(handle, cx)),
319            AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx),
320            AgentContextHandle::Image(handle) => {
321                Some(Self::image(handle, model, project.path_style(cx), cx))
322            }
323        }
324    }
325
326    fn pending_file(
327        handle: FileContextHandle,
328        path_style: PathStyle,
329        cx: &App,
330    ) -> Option<AddedContext> {
331        let full_path = handle
332            .buffer
333            .read(cx)
334            .file()?
335            .full_path(cx)
336            .to_string_lossy()
337            .to_string();
338        Some(Self::file(handle, &full_path, path_style, cx))
339    }
340
341    fn file(
342        handle: FileContextHandle,
343        full_path: &str,
344        path_style: PathStyle,
345        cx: &App,
346    ) -> AddedContext {
347        let (name, parent) = extract_file_name_and_directory_from_full_path(full_path, path_style);
348        AddedContext {
349            kind: ContextKind::File,
350            name,
351            parent,
352            tooltip: Some(SharedString::new(full_path)),
353            icon_path: FileIcons::get_icon(Path::new(full_path), cx),
354            status: ContextStatus::Ready,
355            render_hover: None,
356            handle: AgentContextHandle::File(handle),
357        }
358    }
359
360    fn pending_directory(
361        handle: DirectoryContextHandle,
362        project: &Project,
363        cx: &App,
364    ) -> Option<AddedContext> {
365        let worktree = project.worktree_for_entry(handle.entry_id, cx)?.read(cx);
366        let entry = worktree.entry_for_id(handle.entry_id)?;
367        let full_path = worktree
368            .full_path(&entry.path)
369            .to_string_lossy()
370            .to_string();
371        Some(Self::directory(handle, &full_path, project.path_style(cx)))
372    }
373
374    fn directory(
375        handle: DirectoryContextHandle,
376        full_path: &str,
377        path_style: PathStyle,
378    ) -> AddedContext {
379        let (name, parent) = extract_file_name_and_directory_from_full_path(full_path, path_style);
380        AddedContext {
381            kind: ContextKind::Directory,
382            name,
383            parent,
384            tooltip: Some(SharedString::new(full_path)),
385            icon_path: None,
386            status: ContextStatus::Ready,
387            render_hover: None,
388            handle: AgentContextHandle::Directory(handle),
389        }
390    }
391
392    fn pending_symbol(
393        handle: SymbolContextHandle,
394        path_style: PathStyle,
395        cx: &App,
396    ) -> Option<AddedContext> {
397        let excerpt = ContextFileExcerpt::new(
398            &handle.full_path(cx)?.to_string_lossy(),
399            handle.enclosing_line_range(cx),
400            path_style,
401            cx,
402        );
403        Some(AddedContext {
404            kind: ContextKind::Symbol,
405            name: handle.symbol.clone(),
406            parent: Some(excerpt.file_name_and_range.clone()),
407            tooltip: None,
408            icon_path: None,
409            status: ContextStatus::Ready,
410            render_hover: {
411                let handle = handle.clone();
412                Some(Rc::new(move |_, cx| {
413                    excerpt.hover_view(handle.text(cx), cx).into()
414                }))
415            },
416            handle: AgentContextHandle::Symbol(handle),
417        })
418    }
419
420    fn pending_selection(
421        handle: SelectionContextHandle,
422        path_style: PathStyle,
423        cx: &App,
424    ) -> Option<AddedContext> {
425        let excerpt = ContextFileExcerpt::new(
426            &handle.full_path(cx)?.to_string_lossy(),
427            handle.line_range(cx),
428            path_style,
429            cx,
430        );
431        Some(AddedContext {
432            kind: ContextKind::Selection,
433            name: excerpt.file_name_and_range.clone(),
434            parent: excerpt.parent_name.clone(),
435            tooltip: None,
436            icon_path: excerpt.icon_path.clone(),
437            status: ContextStatus::Ready,
438            render_hover: {
439                let handle = handle.clone();
440                Some(Rc::new(move |_, cx| {
441                    excerpt.hover_view(handle.text(cx), cx).into()
442                }))
443            },
444            handle: AgentContextHandle::Selection(handle),
445        })
446    }
447
448    fn fetched_url(context: FetchedUrlContext) -> AddedContext {
449        AddedContext {
450            kind: ContextKind::FetchedUrl,
451            name: context.url.clone(),
452            parent: None,
453            tooltip: None,
454            icon_path: None,
455            status: ContextStatus::Ready,
456            render_hover: None,
457            handle: AgentContextHandle::FetchedUrl(context),
458        }
459    }
460
461    fn pending_thread(handle: ThreadContextHandle, cx: &App) -> AddedContext {
462        AddedContext {
463            kind: ContextKind::Thread,
464            name: handle.title(cx),
465            parent: None,
466            tooltip: None,
467            icon_path: None,
468            status: if handle.thread.read(cx).is_generating_detailed_summary() {
469                ContextStatus::Loading {
470                    message: "Summarizing…".into(),
471                }
472            } else {
473                ContextStatus::Ready
474            },
475            render_hover: {
476                let thread = handle.thread.clone();
477                Some(Rc::new(move |_, cx| {
478                    let text = thread.read(cx).latest_detailed_summary_or_text();
479                    ContextPillHover::new_text(text, cx).into()
480                }))
481            },
482            handle: AgentContextHandle::Thread(handle),
483        }
484    }
485
486    fn pending_text_thread(handle: TextThreadContextHandle, cx: &App) -> AddedContext {
487        AddedContext {
488            kind: ContextKind::TextThread,
489            name: handle.title(cx),
490            parent: None,
491            tooltip: None,
492            icon_path: None,
493            status: ContextStatus::Ready,
494            render_hover: {
495                let context = handle.context.clone();
496                Some(Rc::new(move |_, cx| {
497                    let text = context.read(cx).to_xml(cx);
498                    ContextPillHover::new_text(text.into(), cx).into()
499                }))
500            },
501            handle: AgentContextHandle::TextThread(handle),
502        }
503    }
504
505    fn pending_rules(
506        handle: RulesContextHandle,
507        prompt_store: Option<&Entity<PromptStore>>,
508        cx: &App,
509    ) -> Option<AddedContext> {
510        let title = prompt_store
511            .as_ref()?
512            .read(cx)
513            .metadata(handle.prompt_id.into())?
514            .title
515            .unwrap_or_else(|| "Unnamed Rule".into());
516        Some(AddedContext {
517            kind: ContextKind::Rules,
518            name: title,
519            parent: None,
520            tooltip: None,
521            icon_path: None,
522            status: ContextStatus::Ready,
523            render_hover: None,
524            handle: AgentContextHandle::Rules(handle),
525        })
526    }
527
528    fn image(
529        context: ImageContext,
530        model: Option<&Arc<dyn language_model::LanguageModel>>,
531        path_style: PathStyle,
532        cx: &App,
533    ) -> AddedContext {
534        let (name, parent, icon_path) = if let Some(full_path) = context.full_path.as_ref() {
535            let (name, parent) =
536                extract_file_name_and_directory_from_full_path(full_path, path_style);
537            let icon_path = FileIcons::get_icon(Path::new(full_path), cx);
538            (name, parent, icon_path)
539        } else {
540            ("Image".into(), None, None)
541        };
542
543        let status = match context.status(model) {
544            ImageStatus::Loading => ContextStatus::Loading {
545                message: "Loading…".into(),
546            },
547            ImageStatus::Error => ContextStatus::Error {
548                message: "Failed to load Image".into(),
549            },
550            ImageStatus::Warning => ContextStatus::Warning {
551                message: format!(
552                    "{} doesn't support attaching Images as Context",
553                    model.map(|m| m.name().0).unwrap_or_else(|| "Model".into())
554                )
555                .into(),
556            },
557            ImageStatus::Ready => ContextStatus::Ready,
558        };
559
560        AddedContext {
561            kind: ContextKind::Image,
562            name,
563            parent,
564            tooltip: None,
565            icon_path,
566            status,
567            render_hover: Some(Rc::new({
568                let image = context.original_image.clone();
569                move |_, cx| {
570                    let image = image.clone();
571                    ContextPillHover::new(cx, move |_, _| {
572                        gpui::img(image.clone())
573                            .max_w_96()
574                            .max_h_96()
575                            .into_any_element()
576                    })
577                    .into()
578                }
579            })),
580            handle: AgentContextHandle::Image(context),
581        }
582    }
583}
584
585fn extract_file_name_and_directory_from_full_path(
586    path: &str,
587    path_style: PathStyle,
588) -> (SharedString, Option<SharedString>) {
589    let (parent, file_name) = path_style.split(path);
590    let parent = parent.and_then(|parent| {
591        let parent = parent.trim_end_matches(path_style.separator());
592        let (_, parent) = path_style.split(parent);
593        if parent.is_empty() {
594            None
595        } else {
596            Some(SharedString::new(parent))
597        }
598    });
599    (SharedString::new(file_name), parent)
600}
601
602#[derive(Debug, Clone)]
603struct ContextFileExcerpt {
604    pub file_name_and_range: SharedString,
605    pub full_path_and_range: SharedString,
606    pub parent_name: Option<SharedString>,
607    pub icon_path: Option<SharedString>,
608}
609
610impl ContextFileExcerpt {
611    pub fn new(full_path: &str, line_range: Range<Point>, path_style: PathStyle, cx: &App) -> Self {
612        let (parent, file_name) = path_style.split(full_path);
613        let line_range_text = format!(" ({}-{})", line_range.start.row + 1, line_range.end.row + 1);
614        let mut full_path_and_range = full_path.to_owned();
615        full_path_and_range.push_str(&line_range_text);
616        let mut file_name_and_range = file_name.to_owned();
617        file_name_and_range.push_str(&line_range_text);
618
619        let parent_name = parent.and_then(|parent| {
620            let parent = parent.trim_end_matches(path_style.separator());
621            let (_, parent) = path_style.split(parent);
622            if parent.is_empty() {
623                None
624            } else {
625                Some(SharedString::new(parent))
626            }
627        });
628
629        let icon_path = FileIcons::get_icon(Path::new(full_path), cx);
630
631        ContextFileExcerpt {
632            file_name_and_range: file_name_and_range.into(),
633            full_path_and_range: full_path_and_range.into(),
634            parent_name,
635            icon_path,
636        }
637    }
638
639    fn hover_view(&self, text: SharedString, cx: &mut App) -> Entity<ContextPillHover> {
640        let icon_path = self.icon_path.clone();
641        let full_path_and_range = self.full_path_and_range.clone();
642        ContextPillHover::new(cx, move |_, cx| {
643            v_flex()
644                .child(
645                    h_flex()
646                        .gap_0p5()
647                        .w_full()
648                        .max_w_full()
649                        .border_b_1()
650                        .border_color(cx.theme().colors().border.opacity(0.6))
651                        .children(
652                            icon_path
653                                .clone()
654                                .map(Icon::from_path)
655                                .map(|icon| icon.color(Color::Muted).size(IconSize::XSmall)),
656                        )
657                        .child(
658                            // TODO: make this truncate on the left.
659                            Label::new(full_path_and_range.clone())
660                                .size(LabelSize::Small)
661                                .ml_1(),
662                        ),
663                )
664                .child(
665                    div()
666                        .id("context-pill-hover-contents")
667                        .overflow_scroll()
668                        .max_w_128()
669                        .max_h_96()
670                        .child(Label::new(text.clone()).buffer_font(cx)),
671                )
672                .into_any_element()
673        })
674    }
675}
676
677struct ContextPillHover {
678    render_hover: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
679}
680
681impl ContextPillHover {
682    fn new(
683        cx: &mut App,
684        render_hover: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
685    ) -> Entity<Self> {
686        cx.new(|_| Self {
687            render_hover: Box::new(render_hover),
688        })
689    }
690
691    fn new_text(content: SharedString, cx: &mut App) -> Entity<Self> {
692        Self::new(cx, move |_, _| {
693            div()
694                .id("context-pill-hover-contents")
695                .overflow_scroll()
696                .max_w_128()
697                .max_h_96()
698                .child(content.clone())
699                .into_any_element()
700        })
701    }
702}
703
704impl Render for ContextPillHover {
705    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
706        tooltip_container(cx, move |this, cx| {
707            this.occlude()
708                .on_mouse_move(|_, _, cx| cx.stop_propagation())
709                .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
710                .child((self.render_hover)(window, cx))
711        })
712    }
713}
714
715impl Component for AddedContext {
716    fn scope() -> ComponentScope {
717        ComponentScope::Agent
718    }
719
720    fn sort_name() -> &'static str {
721        "AddedContext"
722    }
723
724    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
725        let mut next_context_id = ContextId::zero();
726        let image_ready = (
727            "Ready",
728            AddedContext::image(
729                ImageContext {
730                    context_id: next_context_id.post_inc(),
731                    project_path: None,
732                    full_path: None,
733                    original_image: Arc::new(Image::empty()),
734                    image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
735                },
736                None,
737                PathStyle::local(),
738                cx,
739            ),
740        );
741
742        let image_loading = (
743            "Loading",
744            AddedContext::image(
745                ImageContext {
746                    context_id: next_context_id.post_inc(),
747                    project_path: None,
748                    full_path: None,
749                    original_image: Arc::new(Image::empty()),
750                    image_task: cx
751                        .background_spawn(async move {
752                            smol::Timer::after(Duration::from_secs(60 * 5)).await;
753                            Some(LanguageModelImage::empty())
754                        })
755                        .shared(),
756                },
757                None,
758                PathStyle::local(),
759                cx,
760            ),
761        );
762
763        let image_error = (
764            "Error",
765            AddedContext::image(
766                ImageContext {
767                    context_id: next_context_id.post_inc(),
768                    project_path: None,
769                    full_path: None,
770                    original_image: Arc::new(Image::empty()),
771                    image_task: Task::ready(None).shared(),
772                },
773                None,
774                PathStyle::local(),
775                cx,
776            ),
777        );
778
779        Some(
780            v_flex()
781                .gap_6()
782                .children(
783                    vec![image_ready, image_loading, image_error]
784                        .into_iter()
785                        .map(|(text, context)| {
786                            single_example(
787                                text,
788                                ContextPill::added(context, false, false, None).into_any_element(),
789                            )
790                        }),
791                )
792                .into_any(),
793        )
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800    use gpui::App;
801    use language_model::{LanguageModel, fake_provider::FakeLanguageModel};
802    use std::sync::Arc;
803
804    #[gpui::test]
805    fn test_image_context_warning_for_unsupported_model(cx: &mut App) {
806        let model: Arc<dyn LanguageModel> = Arc::new(FakeLanguageModel::default());
807        assert!(!model.supports_images());
808
809        let image_context = ImageContext {
810            context_id: ContextId::zero(),
811            project_path: None,
812            original_image: Arc::new(Image::empty()),
813            image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
814            full_path: None,
815        };
816
817        let added_context =
818            AddedContext::image(image_context, Some(&model), PathStyle::local(), cx);
819
820        assert!(matches!(
821            added_context.status,
822            ContextStatus::Warning { .. }
823        ));
824
825        assert!(matches!(added_context.kind, ContextKind::Image));
826        assert_eq!(added_context.name.as_ref(), "Image");
827        assert!(added_context.parent.is_none());
828        assert!(added_context.icon_path.is_none());
829    }
830
831    #[gpui::test]
832    fn test_image_context_ready_for_no_model(cx: &mut App) {
833        let image_context = ImageContext {
834            context_id: ContextId::zero(),
835            project_path: None,
836            original_image: Arc::new(Image::empty()),
837            image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
838            full_path: None,
839        };
840
841        let added_context = AddedContext::image(image_context, None, PathStyle::local(), cx);
842
843        assert!(
844            matches!(added_context.status, ContextStatus::Ready),
845            "Expected ready status when no model provided"
846        );
847
848        assert!(matches!(added_context.kind, ContextKind::Image));
849        assert_eq!(added_context.name.as_ref(), "Image");
850        assert!(added_context.parent.is_none());
851        assert!(added_context.icon_path.is_none());
852    }
853}