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}