1use buffer_diff::BufferDiff;
2use edit_prediction::{EditPrediction, EditPredictionRating, EditPredictionStore};
3use editor::{Editor, ExcerptRange, Inlay, 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::{Buffer, CodeLabel, LanguageRegistry, Point, ToOffset, language_settings};
10use markdown::{Markdown, MarkdownStyle};
11use project::{
12 Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, InlayId,
13};
14use settings::Settings as _;
15use std::rc::Rc;
16use std::{fmt::Write, sync::Arc, time::Duration};
17use theme::ThemeSettings;
18use ui::{
19 ContextMenu, DropdownMenu, KeyBinding, List, ListItem, ListItemSpacing, Tooltip, prelude::*,
20};
21use workspace::{ModalView, Workspace};
22
23actions!(
24 zeta,
25 [
26 /// Rates the active completion with a thumbs up.
27 ThumbsUpActivePrediction,
28 /// Rates the active completion with a thumbs down.
29 ThumbsDownActivePrediction,
30 /// Navigates to the next edit in the completion history.
31 NextEdit,
32 /// Navigates to the previous edit in the completion history.
33 PreviousEdit,
34 /// Focuses on the completions list.
35 FocusPredictions,
36 /// Previews the selected completion.
37 PreviewPrediction,
38 ]
39);
40
41pub struct PredictEditsRatePredictionsFeatureFlag;
42
43impl FeatureFlag for PredictEditsRatePredictionsFeatureFlag {
44 const NAME: &'static str = "predict-edits-rate-completions";
45}
46
47pub struct RatePredictionsModal {
48 ep_store: Entity<EditPredictionStore>,
49 language_registry: Arc<LanguageRegistry>,
50 active_prediction: Option<ActivePrediction>,
51 selected_index: usize,
52 diff_editor: Entity<Editor>,
53 focus_handle: FocusHandle,
54 _subscription: gpui::Subscription,
55 current_view: RatePredictionView,
56}
57
58struct ActivePrediction {
59 prediction: EditPrediction,
60 feedback_editor: Entity<Editor>,
61 formatted_inputs: Entity<Markdown>,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
65enum RatePredictionView {
66 SuggestedEdits,
67 RawInput,
68}
69
70impl RatePredictionView {
71 pub fn name(&self) -> &'static str {
72 match self {
73 Self::SuggestedEdits => "Suggested Edits",
74 Self::RawInput => "Recorded Events & Input",
75 }
76 }
77}
78
79impl RatePredictionsModal {
80 pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
81 if let Some(ep_store) = EditPredictionStore::try_global(cx) {
82 let language_registry = workspace.app_state().languages.clone();
83 workspace.toggle_modal(window, cx, |window, cx| {
84 RatePredictionsModal::new(ep_store, language_registry, window, cx)
85 });
86
87 telemetry::event!("Rate Prediction Modal Open", source = "Edit Prediction");
88 }
89 }
90
91 pub fn new(
92 ep_store: Entity<EditPredictionStore>,
93 language_registry: Arc<LanguageRegistry>,
94 window: &mut Window,
95 cx: &mut Context<Self>,
96 ) -> Self {
97 let subscription = cx.observe(&ep_store, |_, _, cx| cx.notify());
98
99 Self {
100 ep_store,
101 language_registry,
102 selected_index: 0,
103 focus_handle: cx.focus_handle(),
104 active_prediction: None,
105 _subscription: subscription,
106 diff_editor: cx.new(|cx| {
107 let multibuffer = cx.new(|_| MultiBuffer::new(language::Capability::ReadOnly));
108 let mut editor = Editor::for_multibuffer(multibuffer, None, window, cx);
109 editor.disable_inline_diagnostics();
110 editor.set_expand_all_diff_hunks(cx);
111 editor.set_show_git_diff_gutter(false, cx);
112 editor
113 }),
114 current_view: RatePredictionView::SuggestedEdits,
115 }
116 }
117
118 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
119 cx.emit(DismissEvent);
120 }
121
122 fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context<Self>) {
123 self.selected_index += 1;
124 self.selected_index = usize::min(
125 self.selected_index,
126 self.ep_store.read(cx).shown_predictions().count(),
127 );
128 cx.notify();
129 }
130
131 fn select_previous(
132 &mut self,
133 _: &menu::SelectPrevious,
134 _: &mut Window,
135 cx: &mut Context<Self>,
136 ) {
137 self.selected_index = self.selected_index.saturating_sub(1);
138 cx.notify();
139 }
140
141 fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context<Self>) {
142 let next_index = self
143 .ep_store
144 .read(cx)
145 .shown_predictions()
146 .skip(self.selected_index)
147 .enumerate()
148 .skip(1) // Skip straight to the next item
149 .find(|(_, completion)| !completion.edits.is_empty())
150 .map(|(ix, _)| ix + self.selected_index);
151
152 if let Some(next_index) = next_index {
153 self.selected_index = next_index;
154 cx.notify();
155 }
156 }
157
158 fn select_prev_edit(&mut self, _: &PreviousEdit, _: &mut Window, cx: &mut Context<Self>) {
159 let ep_store = self.ep_store.read(cx);
160 let completions_len = ep_store.shown_completions_len();
161
162 let prev_index = self
163 .ep_store
164 .read(cx)
165 .shown_predictions()
166 .rev()
167 .skip((completions_len - 1) - self.selected_index)
168 .enumerate()
169 .skip(1) // Skip straight to the previous item
170 .find(|(_, completion)| !completion.edits.is_empty())
171 .map(|(ix, _)| self.selected_index - ix);
172
173 if let Some(prev_index) = prev_index {
174 self.selected_index = prev_index;
175 cx.notify();
176 }
177 cx.notify();
178 }
179
180 fn select_first(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context<Self>) {
181 self.selected_index = 0;
182 cx.notify();
183 }
184
185 fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
186 self.selected_index = self.ep_store.read(cx).shown_completions_len() - 1;
187 cx.notify();
188 }
189
190 pub fn thumbs_up_active(
191 &mut self,
192 _: &ThumbsUpActivePrediction,
193 window: &mut Window,
194 cx: &mut Context<Self>,
195 ) {
196 self.ep_store.update(cx, |ep_store, cx| {
197 if let Some(active) = &self.active_prediction {
198 ep_store.rate_prediction(
199 &active.prediction,
200 EditPredictionRating::Positive,
201 active.feedback_editor.read(cx).text(cx),
202 cx,
203 );
204 }
205 });
206
207 let current_completion = self
208 .active_prediction
209 .as_ref()
210 .map(|completion| completion.prediction.clone());
211 self.select_completion(current_completion, false, window, cx);
212 self.select_next_edit(&Default::default(), window, cx);
213 self.confirm(&Default::default(), window, cx);
214
215 cx.notify();
216 }
217
218 pub fn thumbs_down_active(
219 &mut self,
220 _: &ThumbsDownActivePrediction,
221 window: &mut Window,
222 cx: &mut Context<Self>,
223 ) {
224 if let Some(active) = &self.active_prediction {
225 if active.feedback_editor.read(cx).text(cx).is_empty() {
226 return;
227 }
228
229 self.ep_store.update(cx, |ep_store, cx| {
230 ep_store.rate_prediction(
231 &active.prediction,
232 EditPredictionRating::Negative,
233 active.feedback_editor.read(cx).text(cx),
234 cx,
235 );
236 });
237 }
238
239 let current_completion = self
240 .active_prediction
241 .as_ref()
242 .map(|completion| completion.prediction.clone());
243 self.select_completion(current_completion, false, window, cx);
244 self.select_next_edit(&Default::default(), window, cx);
245 self.confirm(&Default::default(), window, cx);
246
247 cx.notify();
248 }
249
250 fn focus_completions(
251 &mut self,
252 _: &FocusPredictions,
253 window: &mut Window,
254 cx: &mut Context<Self>,
255 ) {
256 cx.focus_self(window);
257 cx.notify();
258 }
259
260 fn preview_completion(
261 &mut self,
262 _: &PreviewPrediction,
263 window: &mut Window,
264 cx: &mut Context<Self>,
265 ) {
266 let completion = self
267 .ep_store
268 .read(cx)
269 .shown_predictions()
270 .skip(self.selected_index)
271 .take(1)
272 .next()
273 .cloned();
274
275 self.select_completion(completion, false, window, cx);
276 }
277
278 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
279 let completion = self
280 .ep_store
281 .read(cx)
282 .shown_predictions()
283 .skip(self.selected_index)
284 .take(1)
285 .next()
286 .cloned();
287
288 self.select_completion(completion, true, window, cx);
289 }
290
291 pub fn select_completion(
292 &mut self,
293 prediction: Option<EditPrediction>,
294 focus: bool,
295 window: &mut Window,
296 cx: &mut Context<Self>,
297 ) {
298 // Avoid resetting completion rating if it's already selected.
299 if let Some(prediction) = prediction {
300 self.selected_index = self
301 .ep_store
302 .read(cx)
303 .shown_predictions()
304 .enumerate()
305 .find(|(_, completion_b)| prediction.id == completion_b.id)
306 .map(|(ix, _)| ix)
307 .unwrap_or(self.selected_index);
308 cx.notify();
309
310 if let Some(prev_prediction) = self.active_prediction.as_ref()
311 && prediction.id == prev_prediction.prediction.id
312 {
313 if focus {
314 window.focus(&prev_prediction.feedback_editor.focus_handle(cx), cx);
315 }
316 return;
317 }
318
319 self.diff_editor.update(cx, |editor, cx| {
320 let new_buffer = prediction.edit_preview.build_result_buffer(cx);
321 let new_buffer_snapshot = new_buffer.read(cx).snapshot();
322 let old_buffer_snapshot = prediction.snapshot.clone();
323 let new_buffer_id = new_buffer_snapshot.remote_id();
324
325 let range = prediction
326 .edit_preview
327 .compute_visible_range(&prediction.edits)
328 .unwrap_or(Point::zero()..Point::zero());
329 let start = Point::new(range.start.row.saturating_sub(5), 0);
330 let end = Point::new(range.end.row + 5, 0).min(new_buffer_snapshot.max_point());
331
332 let language = new_buffer_snapshot.language().cloned();
333 let diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot.text, cx));
334 diff.update(cx, |diff, cx| {
335 let update = diff.update_diff(
336 new_buffer_snapshot.text.clone(),
337 Some(old_buffer_snapshot.text().into()),
338 Some(true),
339 language,
340 cx,
341 );
342 cx.spawn(async move |diff, cx| {
343 let update = update.await;
344 if let Some(task) = diff
345 .update(cx, |diff, cx| {
346 diff.set_snapshot(update, &new_buffer_snapshot.text, cx)
347 })
348 .ok()
349 {
350 task.await;
351 }
352 })
353 .detach();
354 });
355
356 editor.disable_header_for_buffer(new_buffer_id, cx);
357 let excerpt_id = editor.buffer().update(cx, |multibuffer, cx| {
358 multibuffer.clear(cx);
359 let excerpt_ids = multibuffer.push_excerpts(
360 new_buffer,
361 vec![ExcerptRange {
362 context: start..end,
363 primary: start..end,
364 }],
365 cx,
366 );
367 multibuffer.add_diff(diff, cx);
368 excerpt_ids.into_iter().next()
369 });
370
371 if let Some((excerpt_id, cursor_position)) =
372 excerpt_id.zip(prediction.cursor_position.as_ref())
373 {
374 let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx);
375 if let Some(buffer_snapshot) =
376 multibuffer_snapshot.buffer_for_excerpt(excerpt_id)
377 {
378 let cursor_offset = prediction
379 .edit_preview
380 .anchor_to_offset_in_result(cursor_position.anchor)
381 + cursor_position.offset;
382 let cursor_anchor = buffer_snapshot.anchor_after(cursor_offset);
383
384 if let Some(anchor) =
385 multibuffer_snapshot.anchor_in_excerpt(excerpt_id, cursor_anchor)
386 {
387 editor.splice_inlays(
388 &[InlayId::EditPrediction(0)],
389 vec![Inlay::edit_prediction(0, anchor, "▏")],
390 cx,
391 );
392 }
393 }
394 }
395 });
396
397 let mut formatted_inputs = String::new();
398
399 write!(&mut formatted_inputs, "## Events\n\n").unwrap();
400
401 for event in &prediction.inputs.events {
402 formatted_inputs.push_str("```diff\n");
403 zeta_prompt::write_event(&mut formatted_inputs, event.as_ref());
404 formatted_inputs.push_str("```\n\n");
405 }
406
407 write!(&mut formatted_inputs, "## Related files\n\n").unwrap();
408
409 for included_file in prediction.inputs.related_files.iter() {
410 write!(
411 &mut formatted_inputs,
412 "### {}\n\n",
413 included_file.path.display()
414 )
415 .unwrap();
416
417 for excerpt in included_file.excerpts.iter() {
418 write!(
419 &mut formatted_inputs,
420 "```{}\n{}\n```\n",
421 included_file.path.display(),
422 excerpt.text
423 )
424 .unwrap();
425 }
426 }
427
428 write!(&mut formatted_inputs, "## Cursor Excerpt\n\n").unwrap();
429
430 writeln!(
431 &mut formatted_inputs,
432 "```{}\n{}<CURSOR>{}\n```\n",
433 prediction.inputs.cursor_path.display(),
434 &prediction.inputs.cursor_excerpt[..prediction.inputs.cursor_offset_in_excerpt],
435 &prediction.inputs.cursor_excerpt[prediction.inputs.cursor_offset_in_excerpt..],
436 )
437 .unwrap();
438
439 self.active_prediction = Some(ActivePrediction {
440 prediction,
441 feedback_editor: cx.new(|cx| {
442 let mut editor = Editor::multi_line(window, cx);
443 editor.disable_scrollbars_and_minimap(window, cx);
444 editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx);
445 editor.set_show_line_numbers(false, cx);
446 editor.set_show_git_diff_gutter(false, cx);
447 editor.set_show_code_actions(false, cx);
448 editor.set_show_runnables(false, cx);
449 editor.set_show_breakpoints(false, cx);
450 editor.set_show_wrap_guides(false, cx);
451 editor.set_show_indent_guides(false, cx);
452 editor.set_show_edit_predictions(Some(false), window, cx);
453 editor.set_placeholder_text("Add your feedback…", window, cx);
454 editor.set_completion_provider(Some(Rc::new(FeedbackCompletionProvider)));
455 if focus {
456 cx.focus_self(window);
457 }
458 editor
459 }),
460 formatted_inputs: cx.new(|cx| {
461 Markdown::new(
462 formatted_inputs.into(),
463 Some(self.language_registry.clone()),
464 None,
465 cx,
466 )
467 }),
468 });
469 } else {
470 self.active_prediction = None;
471 }
472
473 cx.notify();
474 }
475
476 fn render_view_nav(&self, cx: &Context<Self>) -> impl IntoElement {
477 h_flex()
478 .h_8()
479 .px_1()
480 .border_b_1()
481 .border_color(cx.theme().colors().border)
482 .bg(cx.theme().colors().elevated_surface_background)
483 .gap_1()
484 .child(
485 Button::new(
486 ElementId::Name("suggested-edits".into()),
487 RatePredictionView::SuggestedEdits.name(),
488 )
489 .label_size(LabelSize::Small)
490 .on_click(cx.listener(move |this, _, _window, cx| {
491 this.current_view = RatePredictionView::SuggestedEdits;
492 cx.notify();
493 }))
494 .toggle_state(self.current_view == RatePredictionView::SuggestedEdits),
495 )
496 .child(
497 Button::new(
498 ElementId::Name("raw-input".into()),
499 RatePredictionView::RawInput.name(),
500 )
501 .label_size(LabelSize::Small)
502 .on_click(cx.listener(move |this, _, _window, cx| {
503 this.current_view = RatePredictionView::RawInput;
504 cx.notify();
505 }))
506 .toggle_state(self.current_view == RatePredictionView::RawInput),
507 )
508 }
509
510 fn render_suggested_edits(&self, cx: &mut Context<Self>) -> Option<gpui::Stateful<Div>> {
511 let bg_color = cx.theme().colors().editor_background;
512 Some(
513 div()
514 .id("diff")
515 .p_4()
516 .size_full()
517 .bg(bg_color)
518 .overflow_scroll()
519 .whitespace_nowrap()
520 .child(self.diff_editor.clone()),
521 )
522 }
523
524 fn render_raw_input(
525 &self,
526 window: &mut Window,
527 cx: &mut Context<Self>,
528 ) -> Option<gpui::Stateful<Div>> {
529 let theme_settings = ThemeSettings::get_global(cx);
530 let buffer_font_size = theme_settings.buffer_font_size(cx);
531
532 Some(
533 v_flex()
534 .size_full()
535 .overflow_hidden()
536 .relative()
537 .child(
538 div()
539 .id("raw-input")
540 .py_4()
541 .px_6()
542 .size_full()
543 .bg(cx.theme().colors().editor_background)
544 .overflow_scroll()
545 .child(if let Some(active_prediction) = &self.active_prediction {
546 markdown::MarkdownElement::new(
547 active_prediction.formatted_inputs.clone(),
548 MarkdownStyle {
549 base_text_style: window.text_style(),
550 syntax: cx.theme().syntax().clone(),
551 code_block: StyleRefinement {
552 text: TextStyleRefinement {
553 font_family: Some(
554 theme_settings.buffer_font.family.clone(),
555 ),
556 font_size: Some(buffer_font_size.into()),
557 ..Default::default()
558 },
559 padding: EdgesRefinement {
560 top: Some(DefiniteLength::Absolute(
561 AbsoluteLength::Pixels(px(8.)),
562 )),
563 left: Some(DefiniteLength::Absolute(
564 AbsoluteLength::Pixels(px(8.)),
565 )),
566 right: Some(DefiniteLength::Absolute(
567 AbsoluteLength::Pixels(px(8.)),
568 )),
569 bottom: Some(DefiniteLength::Absolute(
570 AbsoluteLength::Pixels(px(8.)),
571 )),
572 },
573 margin: EdgesRefinement {
574 top: Some(Length::Definite(px(8.).into())),
575 left: Some(Length::Definite(px(0.).into())),
576 right: Some(Length::Definite(px(0.).into())),
577 bottom: Some(Length::Definite(px(12.).into())),
578 },
579 border_style: Some(BorderStyle::Solid),
580 border_widths: EdgesRefinement {
581 top: Some(AbsoluteLength::Pixels(px(1.))),
582 left: Some(AbsoluteLength::Pixels(px(1.))),
583 right: Some(AbsoluteLength::Pixels(px(1.))),
584 bottom: Some(AbsoluteLength::Pixels(px(1.))),
585 },
586 border_color: Some(cx.theme().colors().border_variant),
587 background: Some(
588 cx.theme().colors().editor_background.into(),
589 ),
590 ..Default::default()
591 },
592 ..Default::default()
593 },
594 )
595 .into_any_element()
596 } else {
597 div()
598 .child("No active completion".to_string())
599 .into_any_element()
600 }),
601 )
602 .id("raw-input-view"),
603 )
604 }
605
606 fn render_active_completion(
607 &mut self,
608 window: &mut Window,
609 cx: &mut Context<Self>,
610 ) -> Option<impl IntoElement> {
611 let active_prediction = self.active_prediction.as_ref()?;
612 let completion_id = active_prediction.prediction.id.clone();
613 let focus_handle = &self.focus_handle(cx);
614
615 let border_color = cx.theme().colors().border;
616 let bg_color = cx.theme().colors().editor_background;
617
618 let rated = self.ep_store.read(cx).is_prediction_rated(&completion_id);
619 let feedback_empty = active_prediction
620 .feedback_editor
621 .read(cx)
622 .text(cx)
623 .is_empty();
624
625 let label_container = h_flex().pl_1().gap_1p5();
626
627 Some(
628 v_flex()
629 .size_full()
630 .overflow_hidden()
631 .relative()
632 .child(
633 v_flex()
634 .size_full()
635 .overflow_hidden()
636 .relative()
637 .child(self.render_view_nav(cx))
638 .when_some(
639 match self.current_view {
640 RatePredictionView::SuggestedEdits => {
641 self.render_suggested_edits(cx)
642 }
643 RatePredictionView::RawInput => self.render_raw_input(window, cx),
644 },
645 |this, element| this.child(element),
646 ),
647 )
648 .when(!rated, |this| {
649 let modal = cx.entity().downgrade();
650 let failure_mode_menu =
651 ContextMenu::build(window, cx, move |menu, _window, _cx| {
652 FeedbackCompletionProvider::FAILURE_MODES
653 .iter()
654 .fold(menu, |menu, (_key, description)| {
655 let description: SharedString = (*description).into();
656 let modal = modal.clone();
657 menu.entry(
658 description.clone(),
659 None,
660 move |window, cx| {
661 if let Some(modal) = modal.upgrade() {
662 modal.update(cx, |this, cx| {
663 if let Some(active) = &this.active_prediction {
664 active.feedback_editor.update(
665 cx,
666 |editor, cx| {
667 editor.set_text(
668 description.clone(),
669 window,
670 cx,
671 );
672 },
673 );
674 }
675 this.thumbs_down_active(
676 &ThumbsDownActivePrediction,
677 window,
678 cx,
679 );
680 });
681 }
682 },
683 )
684 })
685 });
686
687 this.child(
688 h_flex()
689 .p_2()
690 .gap_2()
691 .border_y_1()
692 .border_color(border_color)
693 .child(
694 DropdownMenu::new(
695 "failure-mode-dropdown",
696 "Issue",
697 failure_mode_menu,
698 )
699 .style(ui::DropdownStyle::Outlined)
700 .trigger_size(ButtonSize::Compact),
701 )
702 .child(
703 h_flex()
704 .gap_2()
705 .child(
706 Icon::new(IconName::Info)
707 .size(IconSize::XSmall)
708 .color(Color::Muted),
709 )
710 .child(
711 div().flex_wrap().child(
712 Label::new(concat!(
713 "Explain why this completion is good or bad. ",
714 "If it's negative, describe what you expected instead."
715 ))
716 .size(LabelSize::Small)
717 .color(Color::Muted),
718 ),
719 ),
720 ),
721 )
722 })
723 .when(!rated, |this| {
724 this.child(
725 div()
726 .h_40()
727 .pt_1()
728 .bg(bg_color)
729 .child(active_prediction.feedback_editor.clone()),
730 )
731 })
732 .child(
733 h_flex()
734 .p_1()
735 .h_8()
736 .max_h_8()
737 .border_t_1()
738 .border_color(border_color)
739 .max_w_full()
740 .justify_between()
741 .children(if rated {
742 Some(
743 label_container
744 .child(
745 Icon::new(IconName::Check)
746 .size(IconSize::Small)
747 .color(Color::Success),
748 )
749 .child(Label::new("Rated completion.").color(Color::Muted)),
750 )
751 } else if active_prediction.prediction.edits.is_empty() {
752 Some(
753 label_container
754 .child(
755 Icon::new(IconName::Warning)
756 .size(IconSize::Small)
757 .color(Color::Warning),
758 )
759 .child(Label::new("No edits produced.").color(Color::Muted)),
760 )
761 } else {
762 Some(label_container)
763 })
764 .child(
765 h_flex()
766 .gap_1()
767 .child(
768 Button::new("bad", "Bad Prediction")
769 .icon(IconName::ThumbsDown)
770 .icon_size(IconSize::Small)
771 .icon_position(IconPosition::Start)
772 .disabled(rated || feedback_empty)
773 .when(feedback_empty, |this| {
774 this.tooltip(Tooltip::text(
775 "Explain what's bad about it before reporting it",
776 ))
777 })
778 .key_binding(KeyBinding::for_action_in(
779 &ThumbsDownActivePrediction,
780 focus_handle,
781 cx,
782 ))
783 .on_click(cx.listener(move |this, _, window, cx| {
784 if this.active_prediction.is_some() {
785 this.thumbs_down_active(
786 &ThumbsDownActivePrediction,
787 window,
788 cx,
789 );
790 }
791 })),
792 )
793 .child(
794 Button::new("good", "Good Prediction")
795 .icon(IconName::ThumbsUp)
796 .icon_size(IconSize::Small)
797 .icon_position(IconPosition::Start)
798 .disabled(rated)
799 .key_binding(KeyBinding::for_action_in(
800 &ThumbsUpActivePrediction,
801 focus_handle,
802 cx,
803 ))
804 .on_click(cx.listener(move |this, _, window, cx| {
805 if this.active_prediction.is_some() {
806 this.thumbs_up_active(
807 &ThumbsUpActivePrediction,
808 window,
809 cx,
810 );
811 }
812 })),
813 ),
814 ),
815 ),
816 )
817 }
818
819 fn render_shown_completions(&self, cx: &Context<Self>) -> impl Iterator<Item = ListItem> {
820 self.ep_store
821 .read(cx)
822 .shown_predictions()
823 .cloned()
824 .enumerate()
825 .map(|(index, completion)| {
826 let selected = self
827 .active_prediction
828 .as_ref()
829 .is_some_and(|selected| selected.prediction.id == completion.id);
830 let rated = self.ep_store.read(cx).is_prediction_rated(&completion.id);
831
832 let (icon_name, icon_color, tooltip_text) =
833 match (rated, completion.edits.is_empty()) {
834 (true, _) => (IconName::Check, Color::Success, "Rated Prediction"),
835 (false, true) => (IconName::File, Color::Muted, "No Edits Produced"),
836 (false, false) => (IconName::FileDiff, Color::Accent, "Edits Available"),
837 };
838
839 let file = completion.buffer.read(cx).file();
840 let file_name = file
841 .as_ref()
842 .map_or(SharedString::new_static("untitled"), |file| {
843 file.file_name(cx).to_string().into()
844 });
845 let file_path = file.map(|file| file.path().as_unix_str().to_string());
846
847 ListItem::new(completion.id.clone())
848 .inset(true)
849 .spacing(ListItemSpacing::Sparse)
850 .focused(index == self.selected_index)
851 .toggle_state(selected)
852 .child(
853 h_flex()
854 .id("completion-content")
855 .gap_3()
856 .child(Icon::new(icon_name).color(icon_color).size(IconSize::Small))
857 .child(
858 v_flex()
859 .child(
860 h_flex()
861 .gap_1()
862 .child(Label::new(file_name).size(LabelSize::Small))
863 .when_some(file_path, |this, p| {
864 this.child(
865 Label::new(p)
866 .size(LabelSize::Small)
867 .color(Color::Muted),
868 )
869 }),
870 )
871 .child(
872 Label::new(format!(
873 "{} ago, {:.2?}",
874 format_time_ago(
875 completion.response_received_at.elapsed()
876 ),
877 completion.latency()
878 ))
879 .color(Color::Muted)
880 .size(LabelSize::XSmall),
881 ),
882 ),
883 )
884 .tooltip(Tooltip::text(tooltip_text))
885 .on_click(cx.listener(move |this, _, window, cx| {
886 this.select_completion(Some(completion.clone()), true, window, cx);
887 }))
888 })
889 }
890}
891
892impl Render for RatePredictionsModal {
893 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
894 let border_color = cx.theme().colors().border;
895
896 h_flex()
897 .key_context("RatePredictionModal")
898 .track_focus(&self.focus_handle)
899 .on_action(cx.listener(Self::dismiss))
900 .on_action(cx.listener(Self::confirm))
901 .on_action(cx.listener(Self::select_previous))
902 .on_action(cx.listener(Self::select_prev_edit))
903 .on_action(cx.listener(Self::select_next))
904 .on_action(cx.listener(Self::select_next_edit))
905 .on_action(cx.listener(Self::select_first))
906 .on_action(cx.listener(Self::select_last))
907 .on_action(cx.listener(Self::thumbs_up_active))
908 .on_action(cx.listener(Self::thumbs_down_active))
909 .on_action(cx.listener(Self::focus_completions))
910 .on_action(cx.listener(Self::preview_completion))
911 .bg(cx.theme().colors().elevated_surface_background)
912 .border_1()
913 .border_color(border_color)
914 .w(window.viewport_size().width - px(320.))
915 .h(window.viewport_size().height - px(300.))
916 .rounded_lg()
917 .shadow_lg()
918 .child(
919 v_flex()
920 .w_72()
921 .h_full()
922 .border_r_1()
923 .border_color(border_color)
924 .flex_shrink_0()
925 .overflow_hidden()
926 .child({
927 let icons = self.ep_store.read(cx).icons();
928 h_flex()
929 .h_8()
930 .px_2()
931 .justify_between()
932 .border_b_1()
933 .border_color(border_color)
934 .child(Icon::new(icons.base).size(IconSize::Small))
935 .child(
936 Label::new("From most recent to oldest")
937 .color(Color::Muted)
938 .size(LabelSize::Small),
939 )
940 })
941 .child(
942 div()
943 .id("completion_list")
944 .p_0p5()
945 .h_full()
946 .overflow_y_scroll()
947 .child(
948 List::new()
949 .empty_message(
950 div()
951 .p_2()
952 .child(
953 Label::new(concat!(
954 "No completions yet. ",
955 "Use the editor to generate some, ",
956 "and make sure to rate them!"
957 ))
958 .color(Color::Muted),
959 )
960 .into_any_element(),
961 )
962 .children(self.render_shown_completions(cx)),
963 ),
964 ),
965 )
966 .children(self.render_active_completion(window, cx))
967 .on_mouse_down_out(cx.listener(|_, _, _, cx| cx.emit(DismissEvent)))
968 }
969}
970
971impl EventEmitter<DismissEvent> for RatePredictionsModal {}
972
973impl Focusable for RatePredictionsModal {
974 fn focus_handle(&self, _cx: &App) -> FocusHandle {
975 self.focus_handle.clone()
976 }
977}
978
979impl ModalView for RatePredictionsModal {}
980
981fn format_time_ago(elapsed: Duration) -> String {
982 let seconds = elapsed.as_secs();
983 if seconds < 120 {
984 "1 minute".to_string()
985 } else if seconds < 3600 {
986 format!("{} minutes", seconds / 60)
987 } else if seconds < 7200 {
988 "1 hour".to_string()
989 } else if seconds < 86400 {
990 format!("{} hours", seconds / 3600)
991 } else if seconds < 172800 {
992 "1 day".to_string()
993 } else {
994 format!("{} days", seconds / 86400)
995 }
996}
997
998struct FeedbackCompletionProvider;
999
1000impl FeedbackCompletionProvider {
1001 const FAILURE_MODES: &'static [(&'static str, &'static str)] = &[
1002 (
1003 "bad_location",
1004 "Made a prediction somewhere other than expected",
1005 ),
1006 ("incomplete", "Prediction was incomplete or cut off"),
1007 (
1008 "deleted",
1009 "Prediction deleted code that should have been kept. Prefer `reverted` if it reverted an edit",
1010 ),
1011 (
1012 "bad_style",
1013 "Prediction used wrong coding style or conventions",
1014 ),
1015 (
1016 "repetitive",
1017 "Prediction repeated existing code unnecessarily",
1018 ),
1019 (
1020 "hallucinated",
1021 "Prediction referenced non-existent variables/functions",
1022 ),
1023 ("wrong_indent", "Prediction had incorrect indentation"),
1024 ("syntax_error", "Introduced a syntax error"),
1025 (
1026 "too_aggressive",
1027 "Prediction made more changes than expected",
1028 ),
1029 (
1030 "too_conservative",
1031 "Prediction was overly cautious/conservative",
1032 ),
1033 (
1034 "no_context",
1035 "Misunderstood or did not use contextual information",
1036 ),
1037 ("reverted", "Reverted recent edits"),
1038 (
1039 "bad_cursor_position",
1040 "The prediction moved the cursor to an unhelpful position",
1041 ),
1042 ];
1043}
1044
1045impl editor::CompletionProvider for FeedbackCompletionProvider {
1046 fn completions(
1047 &self,
1048 _excerpt_id: editor::ExcerptId,
1049 buffer: &Entity<Buffer>,
1050 buffer_position: language::Anchor,
1051 _trigger: editor::CompletionContext,
1052 _window: &mut Window,
1053 cx: &mut Context<Editor>,
1054 ) -> gpui::Task<anyhow::Result<Vec<CompletionResponse>>> {
1055 let buffer = buffer.read(cx);
1056 let mut count_back = 0;
1057
1058 for char in buffer.reversed_chars_at(buffer_position) {
1059 if char.is_ascii_alphanumeric() || char == '_' {
1060 count_back += 1;
1061 } else {
1062 break;
1063 }
1064 }
1065
1066 let start_anchor = buffer.anchor_before(
1067 buffer_position
1068 .to_offset(&buffer)
1069 .saturating_sub(count_back),
1070 );
1071
1072 let replace_range = start_anchor..buffer_position;
1073 let snapshot = buffer.text_snapshot();
1074 let query: String = snapshot.text_for_range(replace_range.clone()).collect();
1075
1076 if query.len() < 3 {
1077 return gpui::Task::ready(Ok(vec![CompletionResponse {
1078 completions: vec![],
1079 display_options: CompletionDisplayOptions {
1080 dynamic_width: true,
1081 },
1082 is_incomplete: false,
1083 }]));
1084 }
1085
1086 let query_lower = query.to_lowercase();
1087
1088 let completions: Vec<Completion> = Self::FAILURE_MODES
1089 .iter()
1090 .filter(|(key, _description)| key.starts_with(&query_lower))
1091 .map(|(key, description)| Completion {
1092 replace_range: replace_range.clone(),
1093 new_text: description.to_string(),
1094 label: CodeLabel::plain(format!("{}: {}", key, description), None),
1095 documentation: None,
1096 source: CompletionSource::Custom,
1097 icon_path: None,
1098 match_start: None,
1099 snippet_deduplication_key: None,
1100 insert_text_mode: None,
1101 confirm: None,
1102 })
1103 .collect();
1104
1105 gpui::Task::ready(Ok(vec![CompletionResponse {
1106 completions,
1107 display_options: CompletionDisplayOptions {
1108 dynamic_width: true,
1109 },
1110 is_incomplete: false,
1111 }]))
1112 }
1113
1114 fn is_completion_trigger(
1115 &self,
1116 _buffer: &Entity<Buffer>,
1117 _position: language::Anchor,
1118 text: &str,
1119 _trigger_in_words: bool,
1120 _cx: &mut Context<Editor>,
1121 ) -> bool {
1122 text.chars()
1123 .last()
1124 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
1125 }
1126}