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