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