1use buffer_diff::BufferDiff;
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), 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 language = new_buffer_snapshot.language().cloned();
327 let diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot.text, cx));
328 diff.update(cx, |diff, cx| {
329 let update = diff.update_diff(
330 new_buffer_snapshot.text.clone(),
331 Some(old_buffer_snapshot.text().into()),
332 true,
333 language,
334 cx,
335 );
336 cx.spawn(async move |diff, cx| {
337 let update = update.await;
338 diff.update(cx, |diff, cx| {
339 diff.set_snapshot(update, &new_buffer_snapshot.text, cx);
340 })
341 })
342 .detach();
343 });
344
345 editor.disable_header_for_buffer(new_buffer_id, cx);
346 editor.buffer().update(cx, |multibuffer, cx| {
347 multibuffer.clear(cx);
348 multibuffer.push_excerpts(
349 new_buffer,
350 vec![ExcerptRange {
351 context: start..end,
352 primary: start..end,
353 }],
354 cx,
355 );
356 multibuffer.add_diff(diff, cx);
357 });
358 });
359
360 let mut formatted_inputs = String::new();
361
362 write!(&mut formatted_inputs, "## Events\n\n").unwrap();
363
364 for event in &prediction.inputs.events {
365 formatted_inputs.push_str("```diff\n");
366 zeta_prompt::write_event(&mut formatted_inputs, event.as_ref());
367 formatted_inputs.push_str("```\n\n");
368 }
369
370 write!(&mut formatted_inputs, "## Related files\n\n").unwrap();
371
372 for included_file in prediction.inputs.related_files.as_ref() {
373 write!(
374 &mut formatted_inputs,
375 "### {}\n\n",
376 included_file.path.display()
377 )
378 .unwrap();
379
380 for excerpt in included_file.excerpts.iter() {
381 write!(
382 &mut formatted_inputs,
383 "```{}\n{}\n```\n",
384 included_file.path.display(),
385 excerpt.text
386 )
387 .unwrap();
388 }
389 }
390
391 write!(&mut formatted_inputs, "## Cursor Excerpt\n\n").unwrap();
392
393 writeln!(
394 &mut formatted_inputs,
395 "```{}\n{}<CURSOR>{}\n```\n",
396 prediction.inputs.cursor_path.display(),
397 &prediction.inputs.cursor_excerpt[..prediction.inputs.cursor_offset_in_excerpt],
398 &prediction.inputs.cursor_excerpt[prediction.inputs.cursor_offset_in_excerpt..],
399 )
400 .unwrap();
401
402 self.active_prediction = Some(ActivePrediction {
403 prediction,
404 feedback_editor: cx.new(|cx| {
405 let mut editor = Editor::multi_line(window, cx);
406 editor.disable_scrollbars_and_minimap(window, cx);
407 editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx);
408 editor.set_show_line_numbers(false, cx);
409 editor.set_show_git_diff_gutter(false, cx);
410 editor.set_show_code_actions(false, cx);
411 editor.set_show_runnables(false, cx);
412 editor.set_show_breakpoints(false, cx);
413 editor.set_show_wrap_guides(false, cx);
414 editor.set_show_indent_guides(false, cx);
415 editor.set_show_edit_predictions(Some(false), window, cx);
416 editor.set_placeholder_text("Add your feedback…", window, cx);
417 if focus {
418 cx.focus_self(window);
419 }
420 editor
421 }),
422 formatted_inputs: cx.new(|cx| {
423 Markdown::new(
424 formatted_inputs.into(),
425 Some(self.language_registry.clone()),
426 None,
427 cx,
428 )
429 }),
430 });
431 } else {
432 self.active_prediction = None;
433 }
434
435 cx.notify();
436 }
437
438 fn render_view_nav(&self, cx: &Context<Self>) -> impl IntoElement {
439 h_flex()
440 .h_8()
441 .px_1()
442 .border_b_1()
443 .border_color(cx.theme().colors().border)
444 .bg(cx.theme().colors().elevated_surface_background)
445 .gap_1()
446 .child(
447 Button::new(
448 ElementId::Name("suggested-edits".into()),
449 RatePredictionView::SuggestedEdits.name(),
450 )
451 .label_size(LabelSize::Small)
452 .on_click(cx.listener(move |this, _, _window, cx| {
453 this.current_view = RatePredictionView::SuggestedEdits;
454 cx.notify();
455 }))
456 .toggle_state(self.current_view == RatePredictionView::SuggestedEdits),
457 )
458 .child(
459 Button::new(
460 ElementId::Name("raw-input".into()),
461 RatePredictionView::RawInput.name(),
462 )
463 .label_size(LabelSize::Small)
464 .on_click(cx.listener(move |this, _, _window, cx| {
465 this.current_view = RatePredictionView::RawInput;
466 cx.notify();
467 }))
468 .toggle_state(self.current_view == RatePredictionView::RawInput),
469 )
470 }
471
472 fn render_suggested_edits(&self, cx: &mut Context<Self>) -> Option<gpui::Stateful<Div>> {
473 let bg_color = cx.theme().colors().editor_background;
474 Some(
475 div()
476 .id("diff")
477 .p_4()
478 .size_full()
479 .bg(bg_color)
480 .overflow_scroll()
481 .whitespace_nowrap()
482 .child(self.diff_editor.clone()),
483 )
484 }
485
486 fn render_raw_input(
487 &self,
488 window: &mut Window,
489 cx: &mut Context<Self>,
490 ) -> Option<gpui::Stateful<Div>> {
491 let theme_settings = ThemeSettings::get_global(cx);
492 let buffer_font_size = theme_settings.buffer_font_size(cx);
493
494 Some(
495 v_flex()
496 .size_full()
497 .overflow_hidden()
498 .relative()
499 .child(
500 div()
501 .id("raw-input")
502 .py_4()
503 .px_6()
504 .size_full()
505 .bg(cx.theme().colors().editor_background)
506 .overflow_scroll()
507 .child(if let Some(active_prediction) = &self.active_prediction {
508 markdown::MarkdownElement::new(
509 active_prediction.formatted_inputs.clone(),
510 MarkdownStyle {
511 base_text_style: window.text_style(),
512 syntax: cx.theme().syntax().clone(),
513 code_block: StyleRefinement {
514 text: TextStyleRefinement {
515 font_family: Some(
516 theme_settings.buffer_font.family.clone(),
517 ),
518 font_size: Some(buffer_font_size.into()),
519 ..Default::default()
520 },
521 padding: EdgesRefinement {
522 top: Some(DefiniteLength::Absolute(
523 AbsoluteLength::Pixels(px(8.)),
524 )),
525 left: Some(DefiniteLength::Absolute(
526 AbsoluteLength::Pixels(px(8.)),
527 )),
528 right: Some(DefiniteLength::Absolute(
529 AbsoluteLength::Pixels(px(8.)),
530 )),
531 bottom: Some(DefiniteLength::Absolute(
532 AbsoluteLength::Pixels(px(8.)),
533 )),
534 },
535 margin: EdgesRefinement {
536 top: Some(Length::Definite(px(8.).into())),
537 left: Some(Length::Definite(px(0.).into())),
538 right: Some(Length::Definite(px(0.).into())),
539 bottom: Some(Length::Definite(px(12.).into())),
540 },
541 border_style: Some(BorderStyle::Solid),
542 border_widths: EdgesRefinement {
543 top: Some(AbsoluteLength::Pixels(px(1.))),
544 left: Some(AbsoluteLength::Pixels(px(1.))),
545 right: Some(AbsoluteLength::Pixels(px(1.))),
546 bottom: Some(AbsoluteLength::Pixels(px(1.))),
547 },
548 border_color: Some(cx.theme().colors().border_variant),
549 background: Some(
550 cx.theme().colors().editor_background.into(),
551 ),
552 ..Default::default()
553 },
554 ..Default::default()
555 },
556 )
557 .into_any_element()
558 } else {
559 div()
560 .child("No active completion".to_string())
561 .into_any_element()
562 }),
563 )
564 .id("raw-input-view"),
565 )
566 }
567
568 fn render_active_completion(
569 &mut self,
570 window: &mut Window,
571 cx: &mut Context<Self>,
572 ) -> Option<impl IntoElement> {
573 let active_prediction = self.active_prediction.as_ref()?;
574 let completion_id = active_prediction.prediction.id.clone();
575 let focus_handle = &self.focus_handle(cx);
576
577 let border_color = cx.theme().colors().border;
578 let bg_color = cx.theme().colors().editor_background;
579
580 let rated = self.ep_store.read(cx).is_prediction_rated(&completion_id);
581 let feedback_empty = active_prediction
582 .feedback_editor
583 .read(cx)
584 .text(cx)
585 .is_empty();
586
587 let label_container = h_flex().pl_1().gap_1p5();
588
589 Some(
590 v_flex()
591 .size_full()
592 .overflow_hidden()
593 .relative()
594 .child(
595 v_flex()
596 .size_full()
597 .overflow_hidden()
598 .relative()
599 .child(self.render_view_nav(cx))
600 .when_some(
601 match self.current_view {
602 RatePredictionView::SuggestedEdits => {
603 self.render_suggested_edits(cx)
604 }
605 RatePredictionView::RawInput => self.render_raw_input(window, cx),
606 },
607 |this, element| this.child(element),
608 ),
609 )
610 .when(!rated, |this| {
611 this.child(
612 h_flex()
613 .p_2()
614 .gap_2()
615 .border_y_1()
616 .border_color(border_color)
617 .child(
618 Icon::new(IconName::Info)
619 .size(IconSize::XSmall)
620 .color(Color::Muted),
621 )
622 .child(
623 div().w_full().pr_2().flex_wrap().child(
624 Label::new(concat!(
625 "Explain why this completion is good or bad. ",
626 "If it's negative, describe what you expected instead."
627 ))
628 .size(LabelSize::Small)
629 .color(Color::Muted),
630 ),
631 ),
632 )
633 })
634 .when(!rated, |this| {
635 this.child(
636 div()
637 .h_40()
638 .pt_1()
639 .bg(bg_color)
640 .child(active_prediction.feedback_editor.clone()),
641 )
642 })
643 .child(
644 h_flex()
645 .p_1()
646 .h_8()
647 .max_h_8()
648 .border_t_1()
649 .border_color(border_color)
650 .max_w_full()
651 .justify_between()
652 .children(if rated {
653 Some(
654 label_container
655 .child(
656 Icon::new(IconName::Check)
657 .size(IconSize::Small)
658 .color(Color::Success),
659 )
660 .child(Label::new("Rated completion.").color(Color::Muted)),
661 )
662 } else if active_prediction.prediction.edits.is_empty() {
663 Some(
664 label_container
665 .child(
666 Icon::new(IconName::Warning)
667 .size(IconSize::Small)
668 .color(Color::Warning),
669 )
670 .child(Label::new("No edits produced.").color(Color::Muted)),
671 )
672 } else {
673 Some(label_container)
674 })
675 .child(
676 h_flex()
677 .gap_1()
678 .child(
679 Button::new("bad", "Bad Prediction")
680 .icon(IconName::ThumbsDown)
681 .icon_size(IconSize::Small)
682 .icon_position(IconPosition::Start)
683 .disabled(rated || feedback_empty)
684 .when(feedback_empty, |this| {
685 this.tooltip(Tooltip::text(
686 "Explain what's bad about it before reporting it",
687 ))
688 })
689 .key_binding(KeyBinding::for_action_in(
690 &ThumbsDownActivePrediction,
691 focus_handle,
692 cx,
693 ))
694 .on_click(cx.listener(move |this, _, window, cx| {
695 if this.active_prediction.is_some() {
696 this.thumbs_down_active(
697 &ThumbsDownActivePrediction,
698 window,
699 cx,
700 );
701 }
702 })),
703 )
704 .child(
705 Button::new("good", "Good Prediction")
706 .icon(IconName::ThumbsUp)
707 .icon_size(IconSize::Small)
708 .icon_position(IconPosition::Start)
709 .disabled(rated)
710 .key_binding(KeyBinding::for_action_in(
711 &ThumbsUpActivePrediction,
712 focus_handle,
713 cx,
714 ))
715 .on_click(cx.listener(move |this, _, window, cx| {
716 if this.active_prediction.is_some() {
717 this.thumbs_up_active(
718 &ThumbsUpActivePrediction,
719 window,
720 cx,
721 );
722 }
723 })),
724 ),
725 ),
726 ),
727 )
728 }
729
730 fn render_shown_completions(&self, cx: &Context<Self>) -> impl Iterator<Item = ListItem> {
731 self.ep_store
732 .read(cx)
733 .shown_predictions()
734 .cloned()
735 .enumerate()
736 .map(|(index, completion)| {
737 let selected = self
738 .active_prediction
739 .as_ref()
740 .is_some_and(|selected| selected.prediction.id == completion.id);
741 let rated = self.ep_store.read(cx).is_prediction_rated(&completion.id);
742
743 let (icon_name, icon_color, tooltip_text) =
744 match (rated, completion.edits.is_empty()) {
745 (true, _) => (IconName::Check, Color::Success, "Rated Prediction"),
746 (false, true) => (IconName::File, Color::Muted, "No Edits Produced"),
747 (false, false) => (IconName::FileDiff, Color::Accent, "Edits Available"),
748 };
749
750 let file = completion.buffer.read(cx).file();
751 let file_name = file
752 .as_ref()
753 .map_or(SharedString::new_static("untitled"), |file| {
754 file.file_name(cx).to_string().into()
755 });
756 let file_path = file.map(|file| file.path().as_unix_str().to_string());
757
758 ListItem::new(completion.id.clone())
759 .inset(true)
760 .spacing(ListItemSpacing::Sparse)
761 .focused(index == self.selected_index)
762 .toggle_state(selected)
763 .child(
764 h_flex()
765 .id("completion-content")
766 .gap_3()
767 .child(Icon::new(icon_name).color(icon_color).size(IconSize::Small))
768 .child(
769 v_flex()
770 .child(
771 h_flex()
772 .gap_1()
773 .child(Label::new(file_name).size(LabelSize::Small))
774 .when_some(file_path, |this, p| {
775 this.child(
776 Label::new(p)
777 .size(LabelSize::Small)
778 .color(Color::Muted),
779 )
780 }),
781 )
782 .child(
783 Label::new(format!(
784 "{} ago, {:.2?}",
785 format_time_ago(
786 completion.response_received_at.elapsed()
787 ),
788 completion.latency()
789 ))
790 .color(Color::Muted)
791 .size(LabelSize::XSmall),
792 ),
793 ),
794 )
795 .tooltip(Tooltip::text(tooltip_text))
796 .on_click(cx.listener(move |this, _, window, cx| {
797 this.select_completion(Some(completion.clone()), true, window, cx);
798 }))
799 })
800 }
801}
802
803impl Render for RatePredictionsModal {
804 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
805 let border_color = cx.theme().colors().border;
806
807 h_flex()
808 .key_context("RatePredictionModal")
809 .track_focus(&self.focus_handle)
810 .on_action(cx.listener(Self::dismiss))
811 .on_action(cx.listener(Self::confirm))
812 .on_action(cx.listener(Self::select_previous))
813 .on_action(cx.listener(Self::select_prev_edit))
814 .on_action(cx.listener(Self::select_next))
815 .on_action(cx.listener(Self::select_next_edit))
816 .on_action(cx.listener(Self::select_first))
817 .on_action(cx.listener(Self::select_last))
818 .on_action(cx.listener(Self::thumbs_up_active))
819 .on_action(cx.listener(Self::thumbs_down_active))
820 .on_action(cx.listener(Self::focus_completions))
821 .on_action(cx.listener(Self::preview_completion))
822 .bg(cx.theme().colors().elevated_surface_background)
823 .border_1()
824 .border_color(border_color)
825 .w(window.viewport_size().width - px(320.))
826 .h(window.viewport_size().height - px(300.))
827 .rounded_lg()
828 .shadow_lg()
829 .child(
830 v_flex()
831 .w_72()
832 .h_full()
833 .border_r_1()
834 .border_color(border_color)
835 .flex_shrink_0()
836 .overflow_hidden()
837 .child(
838 h_flex()
839 .h_8()
840 .px_2()
841 .justify_between()
842 .border_b_1()
843 .border_color(border_color)
844 .child(Icon::new(IconName::ZedPredict).size(IconSize::Small))
845 .child(
846 Label::new("From most recent to oldest")
847 .color(Color::Muted)
848 .size(LabelSize::Small),
849 ),
850 )
851 .child(
852 div()
853 .id("completion_list")
854 .p_0p5()
855 .h_full()
856 .overflow_y_scroll()
857 .child(
858 List::new()
859 .empty_message(
860 div()
861 .p_2()
862 .child(
863 Label::new(concat!(
864 "No completions yet. ",
865 "Use the editor to generate some, ",
866 "and make sure to rate them!"
867 ))
868 .color(Color::Muted),
869 )
870 .into_any_element(),
871 )
872 .children(self.render_shown_completions(cx)),
873 ),
874 ),
875 )
876 .children(self.render_active_completion(window, cx))
877 .on_mouse_down_out(cx.listener(|_, _, _, cx| cx.emit(DismissEvent)))
878 }
879}
880
881impl EventEmitter<DismissEvent> for RatePredictionsModal {}
882
883impl Focusable for RatePredictionsModal {
884 fn focus_handle(&self, _cx: &App) -> FocusHandle {
885 self.focus_handle.clone()
886 }
887}
888
889impl ModalView for RatePredictionsModal {}
890
891fn format_time_ago(elapsed: Duration) -> String {
892 let seconds = elapsed.as_secs();
893 if seconds < 120 {
894 "1 minute".to_string()
895 } else if seconds < 3600 {
896 format!("{} minutes", seconds / 60)
897 } else if seconds < 7200 {
898 "1 hour".to_string()
899 } else if seconds < 86400 {
900 format!("{} hours", seconds / 3600)
901 } else if seconds < 172800 {
902 "1 day".to_string()
903 } else {
904 format!("{} days", seconds / 86400)
905 }
906}