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};
14use util::paths::PathStyle;
15
16use crate::context::{
17 AgentContextHandle, ContextId, ContextKind, DirectoryContextHandle, FetchedUrlContext,
18 FileContextHandle, ImageContext, ImageStatus, RulesContextHandle, SelectionContextHandle,
19 SymbolContextHandle, TextThreadContextHandle, ThreadContextHandle,
20};
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 .shape(IconButtonShape::Square)
203 .icon_size(IconSize::XSmall)
204 .tooltip(Tooltip::text("Remove Context"))
205 .on_click({
206 let on_remove = on_remove.clone();
207 move |event, window, cx| on_remove(event, window, cx)
208 }),
209 )
210 })
211 .when_some(on_click.as_ref(), |element, on_click| {
212 let on_click = on_click.clone();
213 element.cursor_pointer().on_click(move |event, window, cx| {
214 on_click(event, window, cx);
215 cx.stop_propagation();
216 })
217 })
218 .into_any_element()
219 }
220 ContextPill::Suggested {
221 name,
222 icon_path: _,
223 kind: _,
224 focused,
225 on_click,
226 } => base_pill
227 .cursor_pointer()
228 .pr_1()
229 .border_dashed()
230 .map(|pill| {
231 if *focused {
232 pill.border_color(color.border_focused)
233 .bg(color.element_background.opacity(0.5))
234 } else {
235 pill.border_color(color.border)
236 }
237 })
238 .hover(|style| style.bg(color.element_hover.opacity(0.5)))
239 .child(
240 div().max_w_64().child(
241 Label::new(name.clone())
242 .size(LabelSize::Small)
243 .color(Color::Muted)
244 .truncate(),
245 ),
246 )
247 .tooltip(|window, cx| {
248 Tooltip::with_meta("Suggested Context", None, "Click to add it", window, cx)
249 })
250 .when_some(on_click.as_ref(), |element, on_click| {
251 let on_click = on_click.clone();
252 element.on_click(move |event, window, cx| {
253 on_click(event, window, cx);
254 cx.stop_propagation();
255 })
256 })
257 .into_any(),
258 }
259 }
260}
261
262pub enum ContextStatus {
263 Ready,
264 Loading { message: SharedString },
265 Error { message: SharedString },
266 Warning { message: SharedString },
267}
268
269#[derive(RegisterComponent)]
270pub struct AddedContext {
271 pub handle: AgentContextHandle,
272 pub kind: ContextKind,
273 pub name: SharedString,
274 pub parent: Option<SharedString>,
275 pub tooltip: Option<SharedString>,
276 pub icon_path: Option<SharedString>,
277 pub status: ContextStatus,
278 pub render_hover: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
279}
280
281impl AddedContext {
282 pub fn icon(&self) -> Icon {
283 match &self.status {
284 ContextStatus::Warning { .. } => Icon::new(IconName::Warning).color(Color::Warning),
285 ContextStatus::Error { .. } => Icon::new(IconName::XCircle).color(Color::Error),
286 _ => {
287 if let Some(icon_path) = &self.icon_path {
288 Icon::from_path(icon_path)
289 } else {
290 Icon::new(self.kind.icon())
291 }
292 }
293 }
294 }
295 /// Creates an `AddedContext` by retrieving relevant details of `AgentContext`. This returns a
296 /// `None` if `DirectoryContext` or `RulesContext` no longer exist.
297 ///
298 /// TODO: `None` cases are unremovable from `ContextStore` and so are a very minor memory leak.
299 pub fn new_pending(
300 handle: AgentContextHandle,
301 prompt_store: Option<&Entity<PromptStore>>,
302 project: &Project,
303 model: Option<&Arc<dyn language_model::LanguageModel>>,
304 cx: &App,
305 ) -> Option<AddedContext> {
306 match handle {
307 AgentContextHandle::File(handle) => {
308 Self::pending_file(handle, project.path_style(cx), cx)
309 }
310 AgentContextHandle::Directory(handle) => Self::pending_directory(handle, project, cx),
311 AgentContextHandle::Symbol(handle) => {
312 Self::pending_symbol(handle, project.path_style(cx), cx)
313 }
314 AgentContextHandle::Selection(handle) => {
315 Self::pending_selection(handle, project.path_style(cx), cx)
316 }
317 AgentContextHandle::FetchedUrl(handle) => Some(Self::fetched_url(handle)),
318 AgentContextHandle::Thread(handle) => Some(Self::pending_thread(handle, cx)),
319 AgentContextHandle::TextThread(handle) => Some(Self::pending_text_thread(handle, cx)),
320 AgentContextHandle::Rules(handle) => Self::pending_rules(handle, prompt_store, cx),
321 AgentContextHandle::Image(handle) => {
322 Some(Self::image(handle, model, project.path_style(cx), cx))
323 }
324 }
325 }
326
327 fn pending_file(
328 handle: FileContextHandle,
329 path_style: PathStyle,
330 cx: &App,
331 ) -> Option<AddedContext> {
332 let full_path = handle
333 .buffer
334 .read(cx)
335 .file()?
336 .full_path(cx)
337 .to_string_lossy()
338 .to_string();
339 Some(Self::file(handle, &full_path, path_style, cx))
340 }
341
342 fn file(
343 handle: FileContextHandle,
344 full_path: &str,
345 path_style: PathStyle,
346 cx: &App,
347 ) -> AddedContext {
348 let (name, parent) = extract_file_name_and_directory_from_full_path(full_path, path_style);
349 AddedContext {
350 kind: ContextKind::File,
351 name,
352 parent,
353 tooltip: Some(SharedString::new(full_path)),
354 icon_path: FileIcons::get_icon(Path::new(full_path), cx),
355 status: ContextStatus::Ready,
356 render_hover: None,
357 handle: AgentContextHandle::File(handle),
358 }
359 }
360
361 fn pending_directory(
362 handle: DirectoryContextHandle,
363 project: &Project,
364 cx: &App,
365 ) -> Option<AddedContext> {
366 let worktree = project.worktree_for_entry(handle.entry_id, cx)?.read(cx);
367 let entry = worktree.entry_for_id(handle.entry_id)?;
368 let full_path = worktree
369 .full_path(&entry.path)
370 .to_string_lossy()
371 .to_string();
372 Some(Self::directory(handle, &full_path, project.path_style(cx)))
373 }
374
375 fn directory(
376 handle: DirectoryContextHandle,
377 full_path: &str,
378 path_style: PathStyle,
379 ) -> AddedContext {
380 let (name, parent) = extract_file_name_and_directory_from_full_path(full_path, path_style);
381 AddedContext {
382 kind: ContextKind::Directory,
383 name,
384 parent,
385 tooltip: Some(SharedString::new(full_path)),
386 icon_path: None,
387 status: ContextStatus::Ready,
388 render_hover: None,
389 handle: AgentContextHandle::Directory(handle),
390 }
391 }
392
393 fn pending_symbol(
394 handle: SymbolContextHandle,
395 path_style: PathStyle,
396 cx: &App,
397 ) -> Option<AddedContext> {
398 let excerpt = ContextFileExcerpt::new(
399 &handle.full_path(cx)?.to_string_lossy(),
400 handle.enclosing_line_range(cx),
401 path_style,
402 cx,
403 );
404 Some(AddedContext {
405 kind: ContextKind::Symbol,
406 name: handle.symbol.clone(),
407 parent: Some(excerpt.file_name_and_range.clone()),
408 tooltip: None,
409 icon_path: None,
410 status: ContextStatus::Ready,
411 render_hover: {
412 let handle = handle.clone();
413 Some(Rc::new(move |_, cx| {
414 excerpt.hover_view(handle.text(cx), cx).into()
415 }))
416 },
417 handle: AgentContextHandle::Symbol(handle),
418 })
419 }
420
421 fn pending_selection(
422 handle: SelectionContextHandle,
423 path_style: PathStyle,
424 cx: &App,
425 ) -> Option<AddedContext> {
426 let excerpt = ContextFileExcerpt::new(
427 &handle.full_path(cx)?.to_string_lossy(),
428 handle.line_range(cx),
429 path_style,
430 cx,
431 );
432 Some(AddedContext {
433 kind: ContextKind::Selection,
434 name: excerpt.file_name_and_range.clone(),
435 parent: excerpt.parent_name.clone(),
436 tooltip: None,
437 icon_path: excerpt.icon_path.clone(),
438 status: ContextStatus::Ready,
439 render_hover: {
440 let handle = handle.clone();
441 Some(Rc::new(move |_, cx| {
442 excerpt.hover_view(handle.text(cx), cx).into()
443 }))
444 },
445 handle: AgentContextHandle::Selection(handle),
446 })
447 }
448
449 fn fetched_url(context: FetchedUrlContext) -> AddedContext {
450 AddedContext {
451 kind: ContextKind::FetchedUrl,
452 name: context.url.clone(),
453 parent: None,
454 tooltip: None,
455 icon_path: None,
456 status: ContextStatus::Ready,
457 render_hover: None,
458 handle: AgentContextHandle::FetchedUrl(context),
459 }
460 }
461
462 fn pending_thread(handle: ThreadContextHandle, cx: &App) -> AddedContext {
463 AddedContext {
464 kind: ContextKind::Thread,
465 name: handle.title(cx),
466 parent: None,
467 tooltip: None,
468 icon_path: None,
469 status: if handle.thread.read(cx).is_generating_summary() {
470 ContextStatus::Loading {
471 message: "Summarizing…".into(),
472 }
473 } else {
474 ContextStatus::Ready
475 },
476 render_hover: {
477 let thread = handle.thread.clone();
478 Some(Rc::new(move |_, cx| {
479 let text = thread
480 .update(cx, |thread, cx| thread.summary(cx))
481 .now_or_never()
482 .flatten()
483 .unwrap_or_else(|| SharedString::from(thread.read(cx).to_markdown()));
484 ContextPillHover::new_text(text, cx).into()
485 }))
486 },
487 handle: AgentContextHandle::Thread(handle),
488 }
489 }
490
491 fn pending_text_thread(handle: TextThreadContextHandle, cx: &App) -> AddedContext {
492 AddedContext {
493 kind: ContextKind::TextThread,
494 name: handle.title(cx),
495 parent: None,
496 tooltip: None,
497 icon_path: None,
498 status: ContextStatus::Ready,
499 render_hover: {
500 let context = handle.context.clone();
501 Some(Rc::new(move |_, cx| {
502 let text = context.read(cx).to_xml(cx);
503 ContextPillHover::new_text(text.into(), cx).into()
504 }))
505 },
506 handle: AgentContextHandle::TextThread(handle),
507 }
508 }
509
510 fn pending_rules(
511 handle: RulesContextHandle,
512 prompt_store: Option<&Entity<PromptStore>>,
513 cx: &App,
514 ) -> Option<AddedContext> {
515 let title = prompt_store
516 .as_ref()?
517 .read(cx)
518 .metadata(handle.prompt_id.into())?
519 .title
520 .unwrap_or_else(|| "Unnamed Rule".into());
521 Some(AddedContext {
522 kind: ContextKind::Rules,
523 name: title,
524 parent: None,
525 tooltip: None,
526 icon_path: None,
527 status: ContextStatus::Ready,
528 render_hover: None,
529 handle: AgentContextHandle::Rules(handle),
530 })
531 }
532
533 fn image(
534 context: ImageContext,
535 model: Option<&Arc<dyn language_model::LanguageModel>>,
536 path_style: PathStyle,
537 cx: &App,
538 ) -> AddedContext {
539 let (name, parent, icon_path) = if let Some(full_path) = context.full_path.as_ref() {
540 let (name, parent) =
541 extract_file_name_and_directory_from_full_path(full_path, path_style);
542 let icon_path = FileIcons::get_icon(Path::new(full_path), cx);
543 (name, parent, icon_path)
544 } else {
545 ("Image".into(), None, None)
546 };
547
548 let status = match context.status(model) {
549 ImageStatus::Loading => ContextStatus::Loading {
550 message: "Loading…".into(),
551 },
552 ImageStatus::Error => ContextStatus::Error {
553 message: "Failed to load Image".into(),
554 },
555 ImageStatus::Warning => ContextStatus::Warning {
556 message: format!(
557 "{} doesn't support attaching Images as Context",
558 model.map(|m| m.name().0).unwrap_or_else(|| "Model".into())
559 )
560 .into(),
561 },
562 ImageStatus::Ready => ContextStatus::Ready,
563 };
564
565 AddedContext {
566 kind: ContextKind::Image,
567 name,
568 parent,
569 tooltip: None,
570 icon_path,
571 status,
572 render_hover: Some(Rc::new({
573 let image = context.original_image.clone();
574 move |_, cx| {
575 let image = image.clone();
576 ContextPillHover::new(cx, move |_, _| {
577 gpui::img(image.clone())
578 .max_w_96()
579 .max_h_96()
580 .into_any_element()
581 })
582 .into()
583 }
584 })),
585 handle: AgentContextHandle::Image(context),
586 }
587 }
588}
589
590fn extract_file_name_and_directory_from_full_path(
591 path: &str,
592 path_style: PathStyle,
593) -> (SharedString, Option<SharedString>) {
594 let (parent, file_name) = path_style.split(path);
595 let parent = parent.and_then(|parent| {
596 let parent = parent.trim_end_matches(path_style.separator());
597 let (_, parent) = path_style.split(parent);
598 if parent.is_empty() {
599 None
600 } else {
601 Some(SharedString::new(parent))
602 }
603 });
604 (SharedString::new(file_name), parent)
605}
606
607#[derive(Debug, Clone)]
608struct ContextFileExcerpt {
609 pub file_name_and_range: SharedString,
610 pub full_path_and_range: SharedString,
611 pub parent_name: Option<SharedString>,
612 pub icon_path: Option<SharedString>,
613}
614
615impl ContextFileExcerpt {
616 pub fn new(full_path: &str, line_range: Range<Point>, path_style: PathStyle, cx: &App) -> Self {
617 let (parent, file_name) = path_style.split(full_path);
618 let line_range_text = format!(" ({}-{})", line_range.start.row + 1, line_range.end.row + 1);
619 let mut full_path_and_range = full_path.to_owned();
620 full_path_and_range.push_str(&line_range_text);
621 let mut file_name_and_range = file_name.to_owned();
622 file_name_and_range.push_str(&line_range_text);
623
624 let parent_name = parent.and_then(|parent| {
625 let parent = parent.trim_end_matches(path_style.separator());
626 let (_, parent) = path_style.split(parent);
627 if parent.is_empty() {
628 None
629 } else {
630 Some(SharedString::new(parent))
631 }
632 });
633
634 let icon_path = FileIcons::get_icon(Path::new(full_path), cx);
635
636 ContextFileExcerpt {
637 file_name_and_range: file_name_and_range.into(),
638 full_path_and_range: full_path_and_range.into(),
639 parent_name,
640 icon_path,
641 }
642 }
643
644 fn hover_view(&self, text: SharedString, cx: &mut App) -> Entity<ContextPillHover> {
645 let icon_path = self.icon_path.clone();
646 let full_path_and_range = self.full_path_and_range.clone();
647 ContextPillHover::new(cx, move |_, cx| {
648 v_flex()
649 .child(
650 h_flex()
651 .gap_0p5()
652 .w_full()
653 .max_w_full()
654 .border_b_1()
655 .border_color(cx.theme().colors().border.opacity(0.6))
656 .children(
657 icon_path
658 .clone()
659 .map(Icon::from_path)
660 .map(|icon| icon.color(Color::Muted).size(IconSize::XSmall)),
661 )
662 .child(
663 // TODO: make this truncate on the left.
664 Label::new(full_path_and_range.clone())
665 .size(LabelSize::Small)
666 .ml_1(),
667 ),
668 )
669 .child(
670 div()
671 .id("context-pill-hover-contents")
672 .overflow_scroll()
673 .max_w_128()
674 .max_h_96()
675 .child(Label::new(text.clone()).buffer_font(cx)),
676 )
677 .into_any_element()
678 })
679 }
680}
681
682struct ContextPillHover {
683 render_hover: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
684}
685
686impl ContextPillHover {
687 fn new(
688 cx: &mut App,
689 render_hover: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
690 ) -> Entity<Self> {
691 cx.new(|_| Self {
692 render_hover: Box::new(render_hover),
693 })
694 }
695
696 fn new_text(content: SharedString, cx: &mut App) -> Entity<Self> {
697 Self::new(cx, move |_, _| {
698 div()
699 .id("context-pill-hover-contents")
700 .overflow_scroll()
701 .max_w_128()
702 .max_h_96()
703 .child(content.clone())
704 .into_any_element()
705 })
706 }
707}
708
709impl Render for ContextPillHover {
710 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
711 tooltip_container(cx, move |this, cx| {
712 this.occlude()
713 .on_mouse_move(|_, _, cx| cx.stop_propagation())
714 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
715 .child((self.render_hover)(window, cx))
716 })
717 }
718}
719
720impl Component for AddedContext {
721 fn scope() -> ComponentScope {
722 ComponentScope::Agent
723 }
724
725 fn sort_name() -> &'static str {
726 "AddedContext"
727 }
728
729 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
730 let mut next_context_id = ContextId::zero();
731 let image_ready = (
732 "Ready",
733 AddedContext::image(
734 ImageContext {
735 context_id: next_context_id.post_inc(),
736 project_path: None,
737 full_path: None,
738 original_image: Arc::new(Image::empty()),
739 image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
740 },
741 None,
742 PathStyle::local(),
743 cx,
744 ),
745 );
746
747 let image_loading = (
748 "Loading",
749 AddedContext::image(
750 ImageContext {
751 context_id: next_context_id.post_inc(),
752 project_path: None,
753 full_path: None,
754 original_image: Arc::new(Image::empty()),
755 image_task: cx
756 .background_spawn(async move {
757 smol::Timer::after(Duration::from_secs(60 * 5)).await;
758 Some(LanguageModelImage::empty())
759 })
760 .shared(),
761 },
762 None,
763 PathStyle::local(),
764 cx,
765 ),
766 );
767
768 let image_error = (
769 "Error",
770 AddedContext::image(
771 ImageContext {
772 context_id: next_context_id.post_inc(),
773 project_path: None,
774 full_path: None,
775 original_image: Arc::new(Image::empty()),
776 image_task: Task::ready(None).shared(),
777 },
778 None,
779 PathStyle::local(),
780 cx,
781 ),
782 );
783
784 Some(
785 v_flex()
786 .gap_6()
787 .children(
788 vec![image_ready, image_loading, image_error]
789 .into_iter()
790 .map(|(text, context)| {
791 single_example(
792 text,
793 ContextPill::added(context, false, false, None).into_any_element(),
794 )
795 }),
796 )
797 .into_any(),
798 )
799 }
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805 use gpui::App;
806 use language_model::{LanguageModel, fake_provider::FakeLanguageModel};
807 use std::sync::Arc;
808
809 #[gpui::test]
810 fn test_image_context_warning_for_unsupported_model(cx: &mut App) {
811 let model: Arc<dyn LanguageModel> = Arc::new(FakeLanguageModel::default());
812 assert!(!model.supports_images());
813
814 let image_context = ImageContext {
815 context_id: ContextId::zero(),
816 project_path: None,
817 original_image: Arc::new(Image::empty()),
818 image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
819 full_path: None,
820 };
821
822 let added_context =
823 AddedContext::image(image_context, Some(&model), PathStyle::local(), cx);
824
825 assert!(matches!(
826 added_context.status,
827 ContextStatus::Warning { .. }
828 ));
829
830 assert!(matches!(added_context.kind, ContextKind::Image));
831 assert_eq!(added_context.name.as_ref(), "Image");
832 assert!(added_context.parent.is_none());
833 assert!(added_context.icon_path.is_none());
834 }
835
836 #[gpui::test]
837 fn test_image_context_ready_for_no_model(cx: &mut App) {
838 let image_context = ImageContext {
839 context_id: ContextId::zero(),
840 project_path: None,
841 original_image: Arc::new(Image::empty()),
842 image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
843 full_path: None,
844 };
845
846 let added_context = AddedContext::image(image_context, None, PathStyle::local(), cx);
847
848 assert!(
849 matches!(added_context.status, ContextStatus::Ready),
850 "Expected ready status when no model provided"
851 );
852
853 assert!(matches!(added_context.kind, ContextKind::Image));
854 assert_eq!(added_context.name.as_ref(), "Image");
855 assert!(added_context.parent.is_none());
856 assert!(added_context.icon_path.is_none());
857 }
858}