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}