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