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