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