rate_prediction_modal.rs

  1use buffer_diff::{BufferDiff, BufferDiffSnapshot};
  2use edit_prediction::{EditPrediction, EditPredictionRating, EditPredictionStore};
  3use editor::{Editor, ExcerptRange, MultiBuffer};
  4use feature_flags::FeatureFlag;
  5use gpui::{
  6    App, BorderStyle, DismissEvent, EdgesRefinement, Entity, EventEmitter, FocusHandle, Focusable,
  7    Length, StyleRefinement, TextStyleRefinement, Window, actions, prelude::*,
  8};
  9use language::{LanguageRegistry, Point, language_settings};
 10use markdown::{Markdown, MarkdownStyle};
 11use settings::Settings as _;
 12use std::{fmt::Write, sync::Arc, time::Duration};
 13use theme::ThemeSettings;
 14use ui::{KeyBinding, List, ListItem, ListItemSpacing, Tooltip, prelude::*};
 15use workspace::{ModalView, Workspace};
 16
 17actions!(
 18    zeta,
 19    [
 20        /// Rates the active completion with a thumbs up.
 21        ThumbsUpActivePrediction,
 22        /// Rates the active completion with a thumbs down.
 23        ThumbsDownActivePrediction,
 24        /// Navigates to the next edit in the completion history.
 25        NextEdit,
 26        /// Navigates to the previous edit in the completion history.
 27        PreviousEdit,
 28        /// Focuses on the completions list.
 29        FocusPredictions,
 30        /// Previews the selected completion.
 31        PreviewPrediction,
 32    ]
 33);
 34
 35pub struct PredictEditsRatePredictionsFeatureFlag;
 36
 37impl FeatureFlag for PredictEditsRatePredictionsFeatureFlag {
 38    const NAME: &'static str = "predict-edits-rate-completions";
 39}
 40
 41pub struct RatePredictionsModal {
 42    ep_store: Entity<EditPredictionStore>,
 43    language_registry: Arc<LanguageRegistry>,
 44    active_prediction: Option<ActivePrediction>,
 45    selected_index: usize,
 46    diff_editor: Entity<Editor>,
 47    focus_handle: FocusHandle,
 48    _subscription: gpui::Subscription,
 49    current_view: RatePredictionView,
 50}
 51
 52struct ActivePrediction {
 53    prediction: EditPrediction,
 54    feedback_editor: Entity<Editor>,
 55    formatted_inputs: Entity<Markdown>,
 56}
 57
 58#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
 59enum RatePredictionView {
 60    SuggestedEdits,
 61    RawInput,
 62}
 63
 64impl RatePredictionView {
 65    pub fn name(&self) -> &'static str {
 66        match self {
 67            Self::SuggestedEdits => "Suggested Edits",
 68            Self::RawInput => "Recorded Events & Input",
 69        }
 70    }
 71}
 72
 73impl RatePredictionsModal {
 74    pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
 75        if let Some(ep_store) = EditPredictionStore::try_global(cx) {
 76            let language_registry = workspace.app_state().languages.clone();
 77            workspace.toggle_modal(window, cx, |window, cx| {
 78                RatePredictionsModal::new(ep_store, language_registry, window, cx)
 79            });
 80
 81            telemetry::event!("Rate Prediction Modal Open", source = "Edit Prediction");
 82        }
 83    }
 84
 85    pub fn new(
 86        ep_store: Entity<EditPredictionStore>,
 87        language_registry: Arc<LanguageRegistry>,
 88        window: &mut Window,
 89        cx: &mut Context<Self>,
 90    ) -> Self {
 91        let subscription = cx.observe(&ep_store, |_, _, cx| cx.notify());
 92
 93        Self {
 94            ep_store,
 95            language_registry,
 96            selected_index: 0,
 97            focus_handle: cx.focus_handle(),
 98            active_prediction: None,
 99            _subscription: subscription,
100            diff_editor: cx.new(|cx| {
101                let multibuffer = cx.new(|_| MultiBuffer::new(language::Capability::ReadOnly));
102                let mut editor = Editor::for_multibuffer(multibuffer, None, window, cx);
103                editor.disable_inline_diagnostics();
104                editor.set_expand_all_diff_hunks(cx);
105                editor.set_show_git_diff_gutter(false, cx);
106                editor
107            }),
108            current_view: RatePredictionView::SuggestedEdits,
109        }
110    }
111
112    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
113        cx.emit(DismissEvent);
114    }
115
116    fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context<Self>) {
117        self.selected_index += 1;
118        self.selected_index = usize::min(
119            self.selected_index,
120            self.ep_store.read(cx).shown_predictions().count(),
121        );
122        cx.notify();
123    }
124
125    fn select_previous(
126        &mut self,
127        _: &menu::SelectPrevious,
128        _: &mut Window,
129        cx: &mut Context<Self>,
130    ) {
131        self.selected_index = self.selected_index.saturating_sub(1);
132        cx.notify();
133    }
134
135    fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context<Self>) {
136        let next_index = self
137            .ep_store
138            .read(cx)
139            .shown_predictions()
140            .skip(self.selected_index)
141            .enumerate()
142            .skip(1) // Skip straight to the next item
143            .find(|(_, completion)| !completion.edits.is_empty())
144            .map(|(ix, _)| ix + self.selected_index);
145
146        if let Some(next_index) = next_index {
147            self.selected_index = next_index;
148            cx.notify();
149        }
150    }
151
152    fn select_prev_edit(&mut self, _: &PreviousEdit, _: &mut Window, cx: &mut Context<Self>) {
153        let ep_store = self.ep_store.read(cx);
154        let completions_len = ep_store.shown_completions_len();
155
156        let prev_index = self
157            .ep_store
158            .read(cx)
159            .shown_predictions()
160            .rev()
161            .skip((completions_len - 1) - self.selected_index)
162            .enumerate()
163            .skip(1) // Skip straight to the previous item
164            .find(|(_, completion)| !completion.edits.is_empty())
165            .map(|(ix, _)| self.selected_index - ix);
166
167        if let Some(prev_index) = prev_index {
168            self.selected_index = prev_index;
169            cx.notify();
170        }
171        cx.notify();
172    }
173
174    fn select_first(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context<Self>) {
175        self.selected_index = 0;
176        cx.notify();
177    }
178
179    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
180        self.selected_index = self.ep_store.read(cx).shown_completions_len() - 1;
181        cx.notify();
182    }
183
184    pub fn thumbs_up_active(
185        &mut self,
186        _: &ThumbsUpActivePrediction,
187        window: &mut Window,
188        cx: &mut Context<Self>,
189    ) {
190        self.ep_store.update(cx, |ep_store, cx| {
191            if let Some(active) = &self.active_prediction {
192                ep_store.rate_prediction(
193                    &active.prediction,
194                    EditPredictionRating::Positive,
195                    active.feedback_editor.read(cx).text(cx),
196                    cx,
197                );
198            }
199        });
200
201        let current_completion = self
202            .active_prediction
203            .as_ref()
204            .map(|completion| completion.prediction.clone());
205        self.select_completion(current_completion, false, window, cx);
206        self.select_next_edit(&Default::default(), window, cx);
207        self.confirm(&Default::default(), window, cx);
208
209        cx.notify();
210    }
211
212    pub fn thumbs_down_active(
213        &mut self,
214        _: &ThumbsDownActivePrediction,
215        window: &mut Window,
216        cx: &mut Context<Self>,
217    ) {
218        if let Some(active) = &self.active_prediction {
219            if active.feedback_editor.read(cx).text(cx).is_empty() {
220                return;
221            }
222
223            self.ep_store.update(cx, |ep_store, cx| {
224                ep_store.rate_prediction(
225                    &active.prediction,
226                    EditPredictionRating::Negative,
227                    active.feedback_editor.read(cx).text(cx),
228                    cx,
229                );
230            });
231        }
232
233        let current_completion = self
234            .active_prediction
235            .as_ref()
236            .map(|completion| completion.prediction.clone());
237        self.select_completion(current_completion, false, window, cx);
238        self.select_next_edit(&Default::default(), window, cx);
239        self.confirm(&Default::default(), window, cx);
240
241        cx.notify();
242    }
243
244    fn focus_completions(
245        &mut self,
246        _: &FocusPredictions,
247        window: &mut Window,
248        cx: &mut Context<Self>,
249    ) {
250        cx.focus_self(window);
251        cx.notify();
252    }
253
254    fn preview_completion(
255        &mut self,
256        _: &PreviewPrediction,
257        window: &mut Window,
258        cx: &mut Context<Self>,
259    ) {
260        let completion = self
261            .ep_store
262            .read(cx)
263            .shown_predictions()
264            .skip(self.selected_index)
265            .take(1)
266            .next()
267            .cloned();
268
269        self.select_completion(completion, false, window, cx);
270    }
271
272    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
273        let completion = self
274            .ep_store
275            .read(cx)
276            .shown_predictions()
277            .skip(self.selected_index)
278            .take(1)
279            .next()
280            .cloned();
281
282        self.select_completion(completion, true, window, cx);
283    }
284
285    pub fn select_completion(
286        &mut self,
287        prediction: Option<EditPrediction>,
288        focus: bool,
289        window: &mut Window,
290        cx: &mut Context<Self>,
291    ) {
292        // Avoid resetting completion rating if it's already selected.
293        if let Some(prediction) = prediction {
294            self.selected_index = self
295                .ep_store
296                .read(cx)
297                .shown_predictions()
298                .enumerate()
299                .find(|(_, completion_b)| prediction.id == completion_b.id)
300                .map(|(ix, _)| ix)
301                .unwrap_or(self.selected_index);
302            cx.notify();
303
304            if let Some(prev_prediction) = self.active_prediction.as_ref()
305                && prediction.id == prev_prediction.prediction.id
306            {
307                if focus {
308                    window.focus(&prev_prediction.feedback_editor.focus_handle(cx));
309                }
310                return;
311            }
312
313            self.diff_editor.update(cx, |editor, cx| {
314                let new_buffer = prediction.edit_preview.build_result_buffer(cx);
315                let new_buffer_snapshot = new_buffer.read(cx).snapshot();
316                let old_buffer_snapshot = prediction.snapshot.clone();
317                let new_buffer_id = new_buffer_snapshot.remote_id();
318
319                let range = prediction
320                    .edit_preview
321                    .compute_visible_range(&prediction.edits)
322                    .unwrap_or(Point::zero()..Point::zero());
323                let start = Point::new(range.start.row.saturating_sub(5), 0);
324                let end = Point::new(range.end.row + 5, 0).min(new_buffer_snapshot.max_point());
325
326                let diff = cx.new::<BufferDiff>(|cx| {
327                    let diff_snapshot = BufferDiffSnapshot::new_with_base_buffer(
328                        new_buffer_snapshot.text.clone(),
329                        Some(old_buffer_snapshot.text().into()),
330                        old_buffer_snapshot.clone(),
331                        cx,
332                    );
333                    let diff = BufferDiff::new(&new_buffer_snapshot, cx);
334                    cx.spawn(async move |diff, cx| {
335                        let diff_snapshot = diff_snapshot.await;
336                        diff.update(cx, |diff, cx| {
337                            diff.set_snapshot(diff_snapshot, &new_buffer_snapshot.text, cx);
338                        })
339                    })
340                    .detach();
341                    diff
342                });
343
344                editor.disable_header_for_buffer(new_buffer_id, cx);
345                editor.buffer().update(cx, |multibuffer, cx| {
346                    multibuffer.clear(cx);
347                    multibuffer.push_excerpts(
348                        new_buffer,
349                        vec![ExcerptRange {
350                            context: start..end,
351                            primary: start..end,
352                        }],
353                        cx,
354                    );
355                    multibuffer.add_diff(diff, cx);
356                });
357            });
358
359            let mut formatted_inputs = String::new();
360
361            write!(&mut formatted_inputs, "## Events\n\n").unwrap();
362
363            for event in &prediction.inputs.events {
364                formatted_inputs.push_str("```diff\n");
365                zeta_prompt::write_event(&mut formatted_inputs, event.as_ref());
366                formatted_inputs.push_str("```\n\n");
367            }
368
369            write!(&mut formatted_inputs, "## Related files\n\n").unwrap();
370
371            for included_file in prediction.inputs.related_files.as_ref() {
372                write!(
373                    &mut formatted_inputs,
374                    "### {}\n\n",
375                    included_file.path.display()
376                )
377                .unwrap();
378
379                for excerpt in included_file.excerpts.iter() {
380                    write!(
381                        &mut formatted_inputs,
382                        "```{}\n{}\n```\n",
383                        included_file.path.display(),
384                        excerpt.text
385                    )
386                    .unwrap();
387                }
388            }
389
390            write!(&mut formatted_inputs, "## Cursor Excerpt\n\n").unwrap();
391
392            writeln!(
393                &mut formatted_inputs,
394                "```{}\n{}<CURSOR>{}\n```\n",
395                prediction.inputs.cursor_path.display(),
396                &prediction.inputs.cursor_excerpt[..prediction.inputs.cursor_offset_in_excerpt],
397                &prediction.inputs.cursor_excerpt[prediction.inputs.cursor_offset_in_excerpt..],
398            )
399            .unwrap();
400
401            self.active_prediction = Some(ActivePrediction {
402                prediction,
403                feedback_editor: cx.new(|cx| {
404                    let mut editor = Editor::multi_line(window, cx);
405                    editor.disable_scrollbars_and_minimap(window, cx);
406                    editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx);
407                    editor.set_show_line_numbers(false, cx);
408                    editor.set_show_git_diff_gutter(false, cx);
409                    editor.set_show_code_actions(false, cx);
410                    editor.set_show_runnables(false, cx);
411                    editor.set_show_breakpoints(false, cx);
412                    editor.set_show_wrap_guides(false, cx);
413                    editor.set_show_indent_guides(false, cx);
414                    editor.set_show_edit_predictions(Some(false), window, cx);
415                    editor.set_placeholder_text("Add your feedback…", window, cx);
416                    if focus {
417                        cx.focus_self(window);
418                    }
419                    editor
420                }),
421                formatted_inputs: cx.new(|cx| {
422                    Markdown::new(
423                        formatted_inputs.into(),
424                        Some(self.language_registry.clone()),
425                        None,
426                        cx,
427                    )
428                }),
429            });
430        } else {
431            self.active_prediction = None;
432        }
433
434        cx.notify();
435    }
436
437    fn render_view_nav(&self, cx: &Context<Self>) -> impl IntoElement {
438        h_flex()
439            .h_8()
440            .px_1()
441            .border_b_1()
442            .border_color(cx.theme().colors().border)
443            .bg(cx.theme().colors().elevated_surface_background)
444            .gap_1()
445            .child(
446                Button::new(
447                    ElementId::Name("suggested-edits".into()),
448                    RatePredictionView::SuggestedEdits.name(),
449                )
450                .label_size(LabelSize::Small)
451                .on_click(cx.listener(move |this, _, _window, cx| {
452                    this.current_view = RatePredictionView::SuggestedEdits;
453                    cx.notify();
454                }))
455                .toggle_state(self.current_view == RatePredictionView::SuggestedEdits),
456            )
457            .child(
458                Button::new(
459                    ElementId::Name("raw-input".into()),
460                    RatePredictionView::RawInput.name(),
461                )
462                .label_size(LabelSize::Small)
463                .on_click(cx.listener(move |this, _, _window, cx| {
464                    this.current_view = RatePredictionView::RawInput;
465                    cx.notify();
466                }))
467                .toggle_state(self.current_view == RatePredictionView::RawInput),
468            )
469    }
470
471    fn render_suggested_edits(&self, cx: &mut Context<Self>) -> Option<gpui::Stateful<Div>> {
472        let bg_color = cx.theme().colors().editor_background;
473        Some(
474            div()
475                .id("diff")
476                .p_4()
477                .size_full()
478                .bg(bg_color)
479                .overflow_scroll()
480                .whitespace_nowrap()
481                .child(self.diff_editor.clone()),
482        )
483    }
484
485    fn render_raw_input(
486        &self,
487        window: &mut Window,
488        cx: &mut Context<Self>,
489    ) -> Option<gpui::Stateful<Div>> {
490        let theme_settings = ThemeSettings::get_global(cx);
491        let buffer_font_size = theme_settings.buffer_font_size(cx);
492
493        Some(
494            v_flex()
495                .size_full()
496                .overflow_hidden()
497                .relative()
498                .child(
499                    div()
500                        .id("raw-input")
501                        .py_4()
502                        .px_6()
503                        .size_full()
504                        .bg(cx.theme().colors().editor_background)
505                        .overflow_scroll()
506                        .child(if let Some(active_prediction) = &self.active_prediction {
507                            markdown::MarkdownElement::new(
508                                active_prediction.formatted_inputs.clone(),
509                                MarkdownStyle {
510                                    base_text_style: window.text_style(),
511                                    syntax: cx.theme().syntax().clone(),
512                                    code_block: StyleRefinement {
513                                        text: TextStyleRefinement {
514                                            font_family: Some(
515                                                theme_settings.buffer_font.family.clone(),
516                                            ),
517                                            font_size: Some(buffer_font_size.into()),
518                                            ..Default::default()
519                                        },
520                                        padding: EdgesRefinement {
521                                            top: Some(DefiniteLength::Absolute(
522                                                AbsoluteLength::Pixels(px(8.)),
523                                            )),
524                                            left: Some(DefiniteLength::Absolute(
525                                                AbsoluteLength::Pixels(px(8.)),
526                                            )),
527                                            right: Some(DefiniteLength::Absolute(
528                                                AbsoluteLength::Pixels(px(8.)),
529                                            )),
530                                            bottom: Some(DefiniteLength::Absolute(
531                                                AbsoluteLength::Pixels(px(8.)),
532                                            )),
533                                        },
534                                        margin: EdgesRefinement {
535                                            top: Some(Length::Definite(px(8.).into())),
536                                            left: Some(Length::Definite(px(0.).into())),
537                                            right: Some(Length::Definite(px(0.).into())),
538                                            bottom: Some(Length::Definite(px(12.).into())),
539                                        },
540                                        border_style: Some(BorderStyle::Solid),
541                                        border_widths: EdgesRefinement {
542                                            top: Some(AbsoluteLength::Pixels(px(1.))),
543                                            left: Some(AbsoluteLength::Pixels(px(1.))),
544                                            right: Some(AbsoluteLength::Pixels(px(1.))),
545                                            bottom: Some(AbsoluteLength::Pixels(px(1.))),
546                                        },
547                                        border_color: Some(cx.theme().colors().border_variant),
548                                        background: Some(
549                                            cx.theme().colors().editor_background.into(),
550                                        ),
551                                        ..Default::default()
552                                    },
553                                    ..Default::default()
554                                },
555                            )
556                            .into_any_element()
557                        } else {
558                            div()
559                                .child("No active completion".to_string())
560                                .into_any_element()
561                        }),
562                )
563                .id("raw-input-view"),
564        )
565    }
566
567    fn render_active_completion(
568        &mut self,
569        window: &mut Window,
570        cx: &mut Context<Self>,
571    ) -> Option<impl IntoElement> {
572        let active_prediction = self.active_prediction.as_ref()?;
573        let completion_id = active_prediction.prediction.id.clone();
574        let focus_handle = &self.focus_handle(cx);
575
576        let border_color = cx.theme().colors().border;
577        let bg_color = cx.theme().colors().editor_background;
578
579        let rated = self.ep_store.read(cx).is_prediction_rated(&completion_id);
580        let feedback_empty = active_prediction
581            .feedback_editor
582            .read(cx)
583            .text(cx)
584            .is_empty();
585
586        let label_container = h_flex().pl_1().gap_1p5();
587
588        Some(
589            v_flex()
590                .size_full()
591                .overflow_hidden()
592                .relative()
593                .child(
594                    v_flex()
595                        .size_full()
596                        .overflow_hidden()
597                        .relative()
598                        .child(self.render_view_nav(cx))
599                        .when_some(
600                            match self.current_view {
601                                RatePredictionView::SuggestedEdits => {
602                                    self.render_suggested_edits(cx)
603                                }
604                                RatePredictionView::RawInput => self.render_raw_input(window, cx),
605                            },
606                            |this, element| this.child(element),
607                        ),
608                )
609                .when(!rated, |this| {
610                    this.child(
611                        h_flex()
612                            .p_2()
613                            .gap_2()
614                            .border_y_1()
615                            .border_color(border_color)
616                            .child(
617                                Icon::new(IconName::Info)
618                                    .size(IconSize::XSmall)
619                                    .color(Color::Muted),
620                            )
621                            .child(
622                                div().w_full().pr_2().flex_wrap().child(
623                                    Label::new(concat!(
624                                        "Explain why this completion is good or bad. ",
625                                        "If it's negative, describe what you expected instead."
626                                    ))
627                                    .size(LabelSize::Small)
628                                    .color(Color::Muted),
629                                ),
630                            ),
631                    )
632                })
633                .when(!rated, |this| {
634                    this.child(
635                        div()
636                            .h_40()
637                            .pt_1()
638                            .bg(bg_color)
639                            .child(active_prediction.feedback_editor.clone()),
640                    )
641                })
642                .child(
643                    h_flex()
644                        .p_1()
645                        .h_8()
646                        .max_h_8()
647                        .border_t_1()
648                        .border_color(border_color)
649                        .max_w_full()
650                        .justify_between()
651                        .children(if rated {
652                            Some(
653                                label_container
654                                    .child(
655                                        Icon::new(IconName::Check)
656                                            .size(IconSize::Small)
657                                            .color(Color::Success),
658                                    )
659                                    .child(Label::new("Rated completion.").color(Color::Muted)),
660                            )
661                        } else if active_prediction.prediction.edits.is_empty() {
662                            Some(
663                                label_container
664                                    .child(
665                                        Icon::new(IconName::Warning)
666                                            .size(IconSize::Small)
667                                            .color(Color::Warning),
668                                    )
669                                    .child(Label::new("No edits produced.").color(Color::Muted)),
670                            )
671                        } else {
672                            Some(label_container)
673                        })
674                        .child(
675                            h_flex()
676                                .gap_1()
677                                .child(
678                                    Button::new("bad", "Bad Prediction")
679                                        .icon(IconName::ThumbsDown)
680                                        .icon_size(IconSize::Small)
681                                        .icon_position(IconPosition::Start)
682                                        .disabled(rated || feedback_empty)
683                                        .when(feedback_empty, |this| {
684                                            this.tooltip(Tooltip::text(
685                                                "Explain what's bad about it before reporting it",
686                                            ))
687                                        })
688                                        .key_binding(KeyBinding::for_action_in(
689                                            &ThumbsDownActivePrediction,
690                                            focus_handle,
691                                            cx,
692                                        ))
693                                        .on_click(cx.listener(move |this, _, window, cx| {
694                                            if this.active_prediction.is_some() {
695                                                this.thumbs_down_active(
696                                                    &ThumbsDownActivePrediction,
697                                                    window,
698                                                    cx,
699                                                );
700                                            }
701                                        })),
702                                )
703                                .child(
704                                    Button::new("good", "Good Prediction")
705                                        .icon(IconName::ThumbsUp)
706                                        .icon_size(IconSize::Small)
707                                        .icon_position(IconPosition::Start)
708                                        .disabled(rated)
709                                        .key_binding(KeyBinding::for_action_in(
710                                            &ThumbsUpActivePrediction,
711                                            focus_handle,
712                                            cx,
713                                        ))
714                                        .on_click(cx.listener(move |this, _, window, cx| {
715                                            if this.active_prediction.is_some() {
716                                                this.thumbs_up_active(
717                                                    &ThumbsUpActivePrediction,
718                                                    window,
719                                                    cx,
720                                                );
721                                            }
722                                        })),
723                                ),
724                        ),
725                ),
726        )
727    }
728
729    fn render_shown_completions(&self, cx: &Context<Self>) -> impl Iterator<Item = ListItem> {
730        self.ep_store
731            .read(cx)
732            .shown_predictions()
733            .cloned()
734            .enumerate()
735            .map(|(index, completion)| {
736                let selected = self
737                    .active_prediction
738                    .as_ref()
739                    .is_some_and(|selected| selected.prediction.id == completion.id);
740                let rated = self.ep_store.read(cx).is_prediction_rated(&completion.id);
741
742                let (icon_name, icon_color, tooltip_text) =
743                    match (rated, completion.edits.is_empty()) {
744                        (true, _) => (IconName::Check, Color::Success, "Rated Prediction"),
745                        (false, true) => (IconName::File, Color::Muted, "No Edits Produced"),
746                        (false, false) => (IconName::FileDiff, Color::Accent, "Edits Available"),
747                    };
748
749                let file = completion.buffer.read(cx).file();
750                let file_name = file
751                    .as_ref()
752                    .map_or(SharedString::new_static("untitled"), |file| {
753                        file.file_name(cx).to_string().into()
754                    });
755                let file_path = file.map(|file| file.path().as_unix_str().to_string());
756
757                ListItem::new(completion.id.clone())
758                    .inset(true)
759                    .spacing(ListItemSpacing::Sparse)
760                    .focused(index == self.selected_index)
761                    .toggle_state(selected)
762                    .child(
763                        h_flex()
764                            .id("completion-content")
765                            .gap_3()
766                            .child(Icon::new(icon_name).color(icon_color).size(IconSize::Small))
767                            .child(
768                                v_flex()
769                                    .child(
770                                        h_flex()
771                                            .gap_1()
772                                            .child(Label::new(file_name).size(LabelSize::Small))
773                                            .when_some(file_path, |this, p| {
774                                                this.child(
775                                                    Label::new(p)
776                                                        .size(LabelSize::Small)
777                                                        .color(Color::Muted),
778                                                )
779                                            }),
780                                    )
781                                    .child(
782                                        Label::new(format!(
783                                            "{} ago, {:.2?}",
784                                            format_time_ago(
785                                                completion.response_received_at.elapsed()
786                                            ),
787                                            completion.latency()
788                                        ))
789                                        .color(Color::Muted)
790                                        .size(LabelSize::XSmall),
791                                    ),
792                            ),
793                    )
794                    .tooltip(Tooltip::text(tooltip_text))
795                    .on_click(cx.listener(move |this, _, window, cx| {
796                        this.select_completion(Some(completion.clone()), true, window, cx);
797                    }))
798            })
799    }
800}
801
802impl Render for RatePredictionsModal {
803    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
804        let border_color = cx.theme().colors().border;
805
806        h_flex()
807            .key_context("RatePredictionModal")
808            .track_focus(&self.focus_handle)
809            .on_action(cx.listener(Self::dismiss))
810            .on_action(cx.listener(Self::confirm))
811            .on_action(cx.listener(Self::select_previous))
812            .on_action(cx.listener(Self::select_prev_edit))
813            .on_action(cx.listener(Self::select_next))
814            .on_action(cx.listener(Self::select_next_edit))
815            .on_action(cx.listener(Self::select_first))
816            .on_action(cx.listener(Self::select_last))
817            .on_action(cx.listener(Self::thumbs_up_active))
818            .on_action(cx.listener(Self::thumbs_down_active))
819            .on_action(cx.listener(Self::focus_completions))
820            .on_action(cx.listener(Self::preview_completion))
821            .bg(cx.theme().colors().elevated_surface_background)
822            .border_1()
823            .border_color(border_color)
824            .w(window.viewport_size().width - px(320.))
825            .h(window.viewport_size().height - px(300.))
826            .rounded_lg()
827            .shadow_lg()
828            .child(
829                v_flex()
830                    .w_72()
831                    .h_full()
832                    .border_r_1()
833                    .border_color(border_color)
834                    .flex_shrink_0()
835                    .overflow_hidden()
836                    .child(
837                        h_flex()
838                            .h_8()
839                            .px_2()
840                            .justify_between()
841                            .border_b_1()
842                            .border_color(border_color)
843                            .child(Icon::new(IconName::ZedPredict).size(IconSize::Small))
844                            .child(
845                                Label::new("From most recent to oldest")
846                                    .color(Color::Muted)
847                                    .size(LabelSize::Small),
848                            ),
849                    )
850                    .child(
851                        div()
852                            .id("completion_list")
853                            .p_0p5()
854                            .h_full()
855                            .overflow_y_scroll()
856                            .child(
857                                List::new()
858                                    .empty_message(
859                                        div()
860                                            .p_2()
861                                            .child(
862                                                Label::new(concat!(
863                                                    "No completions yet. ",
864                                                    "Use the editor to generate some, ",
865                                                    "and make sure to rate them!"
866                                                ))
867                                                .color(Color::Muted),
868                                            )
869                                            .into_any_element(),
870                                    )
871                                    .children(self.render_shown_completions(cx)),
872                            ),
873                    ),
874            )
875            .children(self.render_active_completion(window, cx))
876            .on_mouse_down_out(cx.listener(|_, _, _, cx| cx.emit(DismissEvent)))
877    }
878}
879
880impl EventEmitter<DismissEvent> for RatePredictionsModal {}
881
882impl Focusable for RatePredictionsModal {
883    fn focus_handle(&self, _cx: &App) -> FocusHandle {
884        self.focus_handle.clone()
885    }
886}
887
888impl ModalView for RatePredictionsModal {}
889
890fn format_time_ago(elapsed: Duration) -> String {
891    let seconds = elapsed.as_secs();
892    if seconds < 120 {
893        "1 minute".to_string()
894    } else if seconds < 3600 {
895        format!("{} minutes", seconds / 60)
896    } else if seconds < 7200 {
897        "1 hour".to_string()
898    } else if seconds < 86400 {
899        format!("{} hours", seconds / 3600)
900    } else if seconds < 172800 {
901        "1 day".to_string()
902    } else {
903        format!("{} days", seconds / 86400)
904    }
905}