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