context_pill.rs

  1use std::sync::Arc;
  2use std::{rc::Rc, time::Duration};
  3
  4use file_icons::FileIcons;
  5use futures::FutureExt;
  6use gpui::{Animation, AnimationExt as _, Image, MouseButton, pulsating_between};
  7use gpui::{ClickEvent, Task};
  8use language_model::LanguageModelImage;
  9use ui::{IconButtonShape, Tooltip, prelude::*, tooltip_container};
 10
 11use crate::context::{AssistantContext, ContextId, ContextKind, ImageContext};
 12
 13#[derive(IntoElement)]
 14pub enum ContextPill {
 15    Added {
 16        context: AddedContext,
 17        dupe_name: bool,
 18        focused: bool,
 19        on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
 20        on_remove: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
 21    },
 22    Suggested {
 23        name: SharedString,
 24        icon_path: Option<SharedString>,
 25        kind: ContextKind,
 26        focused: bool,
 27        on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
 28    },
 29}
 30
 31impl ContextPill {
 32    pub fn added(
 33        context: AddedContext,
 34        dupe_name: bool,
 35        focused: bool,
 36        on_remove: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
 37    ) -> Self {
 38        Self::Added {
 39            context,
 40            dupe_name,
 41            on_remove,
 42            focused,
 43            on_click: None,
 44        }
 45    }
 46
 47    pub fn suggested(
 48        name: SharedString,
 49        icon_path: Option<SharedString>,
 50        kind: ContextKind,
 51        focused: bool,
 52    ) -> Self {
 53        Self::Suggested {
 54            name,
 55            icon_path,
 56            kind,
 57            focused,
 58            on_click: None,
 59        }
 60    }
 61
 62    pub fn on_click(mut self, listener: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>) -> Self {
 63        match &mut self {
 64            ContextPill::Added { on_click, .. } => {
 65                *on_click = Some(listener);
 66            }
 67            ContextPill::Suggested { on_click, .. } => {
 68                *on_click = Some(listener);
 69            }
 70        }
 71        self
 72    }
 73
 74    pub fn id(&self) -> ElementId {
 75        match self {
 76            Self::Added { context, .. } => {
 77                ElementId::NamedInteger("context-pill".into(), context.id.0)
 78            }
 79            Self::Suggested { .. } => "suggested-context-pill".into(),
 80        }
 81    }
 82
 83    pub fn icon(&self) -> Icon {
 84        match self {
 85            Self::Suggested {
 86                icon_path: Some(icon_path),
 87                ..
 88            }
 89            | Self::Added {
 90                context:
 91                    AddedContext {
 92                        icon_path: Some(icon_path),
 93                        ..
 94                    },
 95                ..
 96            } => Icon::from_path(icon_path),
 97            Self::Suggested { kind, .. }
 98            | Self::Added {
 99                context: AddedContext { kind, .. },
100                ..
101            } => Icon::new(kind.icon()),
102        }
103    }
104}
105
106impl RenderOnce for ContextPill {
107    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
108        let color = cx.theme().colors();
109
110        let base_pill = h_flex()
111            .id(self.id())
112            .pl_1()
113            .pb(px(1.))
114            .border_1()
115            .rounded_sm()
116            .gap_1()
117            .child(self.icon().size(IconSize::XSmall).color(Color::Muted));
118
119        match &self {
120            ContextPill::Added {
121                context,
122                dupe_name,
123                on_remove,
124                focused,
125                on_click,
126            } => {
127                let status_is_error = matches!(context.status, ContextStatus::Error { .. });
128
129                base_pill
130                    .pr(if on_remove.is_some() { px(2.) } else { px(4.) })
131                    .map(|pill| {
132                        if status_is_error {
133                            pill.bg(cx.theme().status().error_background)
134                                .border_color(cx.theme().status().error_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_preview.as_ref(),
172                                        |element, render_preview| {
173                                            element.hoverable_tooltip({
174                                                let render_preview = render_preview.clone();
175                                                move |_, cx| {
176                                                    cx.new(|_| ContextPillPreview {
177                                                        render_preview: render_preview.clone(),
178                                                    })
179                                                    .into()
180                                                }
181                                            })
182                                        },
183                                    )
184                                    .into_any(),
185                                ContextStatus::Loading { message } => element
186                                    .tooltip(ui::Tooltip::text(message.clone()))
187                                    .with_animation(
188                                        "pulsating-ctx-pill",
189                                        Animation::new(Duration::from_secs(2))
190                                            .repeat()
191                                            .with_easing(pulsating_between(0.4, 0.8)),
192                                        |label, delta| label.opacity(delta),
193                                    )
194                                    .into_any_element(),
195                                ContextStatus::Error { message } => element
196                                    .tooltip(ui::Tooltip::text(message.clone()))
197                                    .into_any_element(),
198                            }),
199                    )
200                    .when_some(on_remove.as_ref(), |element, on_remove| {
201                        element.child(
202                            IconButton::new(("remove", context.id.0), IconName::Close)
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
215                            .cursor_pointer()
216                            .on_click(move |event, window, cx| on_click(event, window, cx))
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| on_click(event, window, cx))
253                })
254                .into_any(),
255        }
256    }
257}
258
259pub enum ContextStatus {
260    Ready,
261    Loading { message: SharedString },
262    Error { message: SharedString },
263}
264
265#[derive(RegisterComponent)]
266pub struct AddedContext {
267    pub id: ContextId,
268    pub kind: ContextKind,
269    pub name: SharedString,
270    pub parent: Option<SharedString>,
271    pub tooltip: Option<SharedString>,
272    pub icon_path: Option<SharedString>,
273    pub status: ContextStatus,
274    pub render_preview: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
275}
276
277impl AddedContext {
278    pub fn new(context: &AssistantContext, cx: &App) -> AddedContext {
279        match context {
280            AssistantContext::File(file_context) => {
281                let full_path = file_context.context_buffer.full_path(cx);
282                let full_path_string: SharedString =
283                    full_path.to_string_lossy().into_owned().into();
284                let name = full_path
285                    .file_name()
286                    .map(|n| n.to_string_lossy().into_owned().into())
287                    .unwrap_or_else(|| full_path_string.clone());
288                let parent = full_path
289                    .parent()
290                    .and_then(|p| p.file_name())
291                    .map(|n| n.to_string_lossy().into_owned().into());
292                AddedContext {
293                    id: file_context.id,
294                    kind: ContextKind::File,
295                    name,
296                    parent,
297                    tooltip: Some(full_path_string),
298                    icon_path: FileIcons::get_icon(&full_path, cx),
299                    status: ContextStatus::Ready,
300                    render_preview: None,
301                }
302            }
303
304            AssistantContext::Directory(directory_context) => {
305                let worktree = directory_context.worktree.read(cx);
306                // If the directory no longer exists, use its last known path.
307                let full_path = worktree
308                    .entry_for_id(directory_context.entry_id)
309                    .map_or_else(
310                        || directory_context.last_path.clone(),
311                        |entry| worktree.full_path(&entry.path).into(),
312                    );
313                let full_path_string: SharedString =
314                    full_path.to_string_lossy().into_owned().into();
315                let name = full_path
316                    .file_name()
317                    .map(|n| n.to_string_lossy().into_owned().into())
318                    .unwrap_or_else(|| full_path_string.clone());
319                let parent = full_path
320                    .parent()
321                    .and_then(|p| p.file_name())
322                    .map(|n| n.to_string_lossy().into_owned().into());
323                AddedContext {
324                    id: directory_context.id,
325                    kind: ContextKind::Directory,
326                    name,
327                    parent,
328                    tooltip: Some(full_path_string),
329                    icon_path: None,
330                    status: ContextStatus::Ready,
331                    render_preview: None,
332                }
333            }
334
335            AssistantContext::Symbol(symbol_context) => AddedContext {
336                id: symbol_context.id,
337                kind: ContextKind::Symbol,
338                name: symbol_context.context_symbol.id.name.clone(),
339                parent: None,
340                tooltip: None,
341                icon_path: None,
342                status: ContextStatus::Ready,
343                render_preview: None,
344            },
345
346            AssistantContext::Selection(selection_context) => {
347                let full_path = selection_context.context_buffer.full_path(cx);
348                let mut full_path_string = full_path.to_string_lossy().into_owned();
349                let mut name = full_path
350                    .file_name()
351                    .map(|n| n.to_string_lossy().into_owned())
352                    .unwrap_or_else(|| full_path_string.clone());
353
354                let line_range_text = format!(
355                    " ({}-{})",
356                    selection_context.line_range.start.row + 1,
357                    selection_context.line_range.end.row + 1
358                );
359
360                full_path_string.push_str(&line_range_text);
361                name.push_str(&line_range_text);
362
363                let parent = full_path
364                    .parent()
365                    .and_then(|p| p.file_name())
366                    .map(|n| n.to_string_lossy().into_owned().into());
367
368                AddedContext {
369                    id: selection_context.id,
370                    kind: ContextKind::Selection,
371                    name: name.into(),
372                    parent,
373                    tooltip: None,
374                    icon_path: FileIcons::get_icon(&full_path, cx),
375                    status: ContextStatus::Ready,
376                    render_preview: Some(Rc::new({
377                        let content = selection_context.context_buffer.text.clone();
378                        move |_, cx| {
379                            div()
380                                .id("context-pill-selection-preview")
381                                .overflow_scroll()
382                                .max_w_128()
383                                .max_h_96()
384                                .child(Label::new(content.clone()).buffer_font(cx))
385                                .into_any_element()
386                        }
387                    })),
388                }
389            }
390
391            AssistantContext::FetchedUrl(fetched_url_context) => AddedContext {
392                id: fetched_url_context.id,
393                kind: ContextKind::FetchedUrl,
394                name: fetched_url_context.url.clone(),
395                parent: None,
396                tooltip: None,
397                icon_path: None,
398                status: ContextStatus::Ready,
399                render_preview: None,
400            },
401
402            AssistantContext::Thread(thread_context) => AddedContext {
403                id: thread_context.id,
404                kind: ContextKind::Thread,
405                name: thread_context.summary(cx),
406                parent: None,
407                tooltip: None,
408                icon_path: None,
409                status: if thread_context
410                    .thread
411                    .read(cx)
412                    .is_generating_detailed_summary()
413                {
414                    ContextStatus::Loading {
415                        message: "Summarizing…".into(),
416                    }
417                } else {
418                    ContextStatus::Ready
419                },
420                render_preview: None,
421            },
422
423            AssistantContext::Rules(user_rules_context) => AddedContext {
424                id: user_rules_context.id,
425                kind: ContextKind::Rules,
426                name: user_rules_context.title.clone(),
427                parent: None,
428                tooltip: None,
429                icon_path: None,
430                status: ContextStatus::Ready,
431                render_preview: None,
432            },
433
434            AssistantContext::Image(image_context) => AddedContext {
435                id: image_context.id,
436                kind: ContextKind::Image,
437                name: "Image".into(),
438                parent: None,
439                tooltip: None,
440                icon_path: None,
441                status: if image_context.is_loading() {
442                    ContextStatus::Loading {
443                        message: "Loading…".into(),
444                    }
445                } else if image_context.is_error() {
446                    ContextStatus::Error {
447                        message: "Failed to load image".into(),
448                    }
449                } else {
450                    ContextStatus::Ready
451                },
452                render_preview: Some(Rc::new({
453                    let image = image_context.original_image.clone();
454                    move |_, _| {
455                        gpui::img(image.clone())
456                            .max_w_96()
457                            .max_h_96()
458                            .into_any_element()
459                    }
460                })),
461            },
462        }
463    }
464}
465
466struct ContextPillPreview {
467    render_preview: Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>,
468}
469
470impl Render for ContextPillPreview {
471    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
472        tooltip_container(window, cx, move |this, window, cx| {
473            this.occlude()
474                .on_mouse_move(|_, _, cx| cx.stop_propagation())
475                .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
476                .child((self.render_preview)(window, cx))
477        })
478    }
479}
480
481impl Component for AddedContext {
482    fn scope() -> ComponentScope {
483        ComponentScope::Agent
484    }
485
486    fn sort_name() -> &'static str {
487        "AddedContext"
488    }
489
490    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
491        let image_ready = (
492            "Ready",
493            AddedContext::new(
494                &AssistantContext::Image(ImageContext {
495                    id: ContextId(0),
496                    original_image: Arc::new(Image::empty()),
497                    image_task: Task::ready(Some(LanguageModelImage::empty())).shared(),
498                }),
499                cx,
500            ),
501        );
502
503        let image_loading = (
504            "Loading",
505            AddedContext::new(
506                &AssistantContext::Image(ImageContext {
507                    id: ContextId(1),
508                    original_image: Arc::new(Image::empty()),
509                    image_task: cx
510                        .background_spawn(async move {
511                            smol::Timer::after(Duration::from_secs(60 * 5)).await;
512                            Some(LanguageModelImage::empty())
513                        })
514                        .shared(),
515                }),
516                cx,
517            ),
518        );
519
520        let image_error = (
521            "Error",
522            AddedContext::new(
523                &AssistantContext::Image(ImageContext {
524                    id: ContextId(2),
525                    original_image: Arc::new(Image::empty()),
526                    image_task: Task::ready(None).shared(),
527                }),
528                cx,
529            ),
530        );
531
532        Some(
533            v_flex()
534                .gap_6()
535                .children(
536                    vec![image_ready, image_loading, image_error]
537                        .into_iter()
538                        .map(|(text, context)| {
539                            single_example(
540                                text,
541                                ContextPill::added(context, false, false, None).into_any_element(),
542                            )
543                        }),
544                )
545                .into_any(),
546        )
547    }
548}