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, 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 let excerpt_ids = multibuffer.push_excerpts(
363 new_buffer,
364 vec![ExcerptRange {
365 context: start..end,
366 primary: start..end,
367 }],
368 cx,
369 );
370 multibuffer.add_diff(diff, cx);
371 excerpt_ids.into_iter().next()
372 });
373
374 if let Some((excerpt_id, cursor_position)) =
375 excerpt_id.zip(prediction.cursor_position.as_ref())
376 {
377 let multibuffer_snapshot = editor.buffer().read(cx).snapshot(cx);
378 if let Some(buffer_snapshot) =
379 multibuffer_snapshot.buffer_for_excerpt(excerpt_id)
380 {
381 let cursor_offset = prediction
382 .edit_preview
383 .anchor_to_offset_in_result(cursor_position.anchor)
384 + cursor_position.offset;
385 let cursor_anchor = buffer_snapshot.anchor_after(cursor_offset);
386
387 if let Some(anchor) =
388 multibuffer_snapshot.anchor_in_excerpt(excerpt_id, cursor_anchor)
389 {
390 editor.splice_inlays(
391 &[InlayId::EditPrediction(0)],
392 vec![Inlay::edit_prediction(0, anchor, "▏")],
393 cx,
394 );
395 }
396 }
397 }
398 });
399
400 let mut formatted_inputs = String::new();
401
402 write!(&mut formatted_inputs, "## Events\n\n").unwrap();
403
404 for event in &prediction.inputs.events {
405 formatted_inputs.push_str("```diff\n");
406 zeta_prompt::write_event(&mut formatted_inputs, event.as_ref());
407 formatted_inputs.push_str("```\n\n");
408 }
409
410 write!(&mut formatted_inputs, "## Related files\n\n").unwrap();
411
412 for included_file in prediction.inputs.related_files.iter() {
413 write!(
414 &mut formatted_inputs,
415 "### {}\n\n",
416 included_file.path.display()
417 )
418 .unwrap();
419
420 for excerpt in included_file.excerpts.iter() {
421 write!(
422 &mut formatted_inputs,
423 "```{}\n{}\n```\n",
424 included_file.path.display(),
425 excerpt.text
426 )
427 .unwrap();
428 }
429 }
430
431 write!(&mut formatted_inputs, "## Cursor Excerpt\n\n").unwrap();
432
433 writeln!(
434 &mut formatted_inputs,
435 "```{}\n{}<CURSOR>{}\n```\n",
436 prediction.inputs.cursor_path.display(),
437 &prediction.inputs.cursor_excerpt[..prediction.inputs.cursor_offset_in_excerpt],
438 &prediction.inputs.cursor_excerpt[prediction.inputs.cursor_offset_in_excerpt..],
439 )
440 .unwrap();
441
442 self.active_prediction = Some(ActivePrediction {
443 prediction,
444 feedback_editor: cx.new(|cx| {
445 let mut editor = Editor::multi_line(window, cx);
446 editor.disable_scrollbars_and_minimap(window, cx);
447 editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx);
448 editor.set_show_line_numbers(false, cx);
449 editor.set_show_git_diff_gutter(false, cx);
450 editor.set_show_code_actions(false, cx);
451 editor.set_show_runnables(false, cx);
452 editor.set_show_breakpoints(false, cx);
453 editor.set_show_wrap_guides(false, cx);
454 editor.set_show_indent_guides(false, cx);
455 editor.set_show_edit_predictions(Some(false), window, cx);
456 editor.set_placeholder_text("Add your feedback…", window, cx);
457 editor.set_completion_provider(Some(Rc::new(FeedbackCompletionProvider)));
458 if focus {
459 cx.focus_self(window);
460 }
461 editor
462 }),
463 formatted_inputs: cx.new(|cx| {
464 Markdown::new(
465 formatted_inputs.into(),
466 Some(self.language_registry.clone()),
467 None,
468 cx,
469 )
470 }),
471 });
472 } else {
473 self.active_prediction = None;
474 }
475
476 cx.notify();
477 }
478
479 fn render_view_nav(&self, cx: &Context<Self>) -> impl IntoElement {
480 h_flex()
481 .h_8()
482 .px_1()
483 .border_b_1()
484 .border_color(cx.theme().colors().border)
485 .bg(cx.theme().colors().elevated_surface_background)
486 .gap_1()
487 .child(
488 Button::new(
489 ElementId::Name("suggested-edits".into()),
490 RatePredictionView::SuggestedEdits.name(),
491 )
492 .label_size(LabelSize::Small)
493 .on_click(cx.listener(move |this, _, _window, cx| {
494 this.current_view = RatePredictionView::SuggestedEdits;
495 cx.notify();
496 }))
497 .toggle_state(self.current_view == RatePredictionView::SuggestedEdits),
498 )
499 .child(
500 Button::new(
501 ElementId::Name("raw-input".into()),
502 RatePredictionView::RawInput.name(),
503 )
504 .label_size(LabelSize::Small)
505 .on_click(cx.listener(move |this, _, _window, cx| {
506 this.current_view = RatePredictionView::RawInput;
507 cx.notify();
508 }))
509 .toggle_state(self.current_view == RatePredictionView::RawInput),
510 )
511 }
512
513 fn render_suggested_edits(&self, cx: &mut Context<Self>) -> Option<gpui::Stateful<Div>> {
514 let bg_color = cx.theme().colors().editor_background;
515 Some(
516 div()
517 .id("diff")
518 .p_4()
519 .size_full()
520 .bg(bg_color)
521 .overflow_scroll()
522 .whitespace_nowrap()
523 .child(self.diff_editor.clone()),
524 )
525 }
526
527 fn render_raw_input(
528 &self,
529 window: &mut Window,
530 cx: &mut Context<Self>,
531 ) -> Option<gpui::Stateful<Div>> {
532 let theme_settings = ThemeSettings::get_global(cx);
533 let buffer_font_size = theme_settings.buffer_font_size(cx);
534
535 Some(
536 v_flex()
537 .size_full()
538 .overflow_hidden()
539 .relative()
540 .child(
541 div()
542 .id("raw-input")
543 .py_4()
544 .px_6()
545 .size_full()
546 .bg(cx.theme().colors().editor_background)
547 .overflow_scroll()
548 .child(if let Some(active_prediction) = &self.active_prediction {
549 markdown::MarkdownElement::new(
550 active_prediction.formatted_inputs.clone(),
551 MarkdownStyle {
552 base_text_style: window.text_style(),
553 syntax: cx.theme().syntax().clone(),
554 code_block: StyleRefinement {
555 text: TextStyleRefinement {
556 font_family: Some(
557 theme_settings.buffer_font.family.clone(),
558 ),
559 font_size: Some(buffer_font_size.into()),
560 ..Default::default()
561 },
562 padding: EdgesRefinement {
563 top: Some(DefiniteLength::Absolute(
564 AbsoluteLength::Pixels(px(8.)),
565 )),
566 left: Some(DefiniteLength::Absolute(
567 AbsoluteLength::Pixels(px(8.)),
568 )),
569 right: Some(DefiniteLength::Absolute(
570 AbsoluteLength::Pixels(px(8.)),
571 )),
572 bottom: Some(DefiniteLength::Absolute(
573 AbsoluteLength::Pixels(px(8.)),
574 )),
575 },
576 margin: EdgesRefinement {
577 top: Some(Length::Definite(px(8.).into())),
578 left: Some(Length::Definite(px(0.).into())),
579 right: Some(Length::Definite(px(0.).into())),
580 bottom: Some(Length::Definite(px(12.).into())),
581 },
582 border_style: Some(BorderStyle::Solid),
583 border_widths: EdgesRefinement {
584 top: Some(AbsoluteLength::Pixels(px(1.))),
585 left: Some(AbsoluteLength::Pixels(px(1.))),
586 right: Some(AbsoluteLength::Pixels(px(1.))),
587 bottom: Some(AbsoluteLength::Pixels(px(1.))),
588 },
589 border_color: Some(cx.theme().colors().border_variant),
590 background: Some(
591 cx.theme().colors().editor_background.into(),
592 ),
593 ..Default::default()
594 },
595 ..Default::default()
596 },
597 )
598 .into_any_element()
599 } else {
600 div()
601 .child("No active completion".to_string())
602 .into_any_element()
603 }),
604 )
605 .id("raw-input-view"),
606 )
607 }
608
609 fn render_active_completion(
610 &mut self,
611 window: &mut Window,
612 cx: &mut Context<Self>,
613 ) -> Option<impl IntoElement> {
614 let active_prediction = self.active_prediction.as_ref()?;
615 let completion_id = active_prediction.prediction.id.clone();
616 let focus_handle = &self.focus_handle(cx);
617
618 let border_color = cx.theme().colors().border;
619 let bg_color = cx.theme().colors().editor_background;
620
621 let rated = self.ep_store.read(cx).is_prediction_rated(&completion_id);
622 let feedback_empty = active_prediction
623 .feedback_editor
624 .read(cx)
625 .text(cx)
626 .is_empty();
627
628 let label_container = h_flex().pl_1().gap_1p5();
629
630 Some(
631 v_flex()
632 .size_full()
633 .overflow_hidden()
634 .relative()
635 .child(
636 v_flex()
637 .size_full()
638 .overflow_hidden()
639 .relative()
640 .child(self.render_view_nav(cx))
641 .when_some(
642 match self.current_view {
643 RatePredictionView::SuggestedEdits => {
644 self.render_suggested_edits(cx)
645 }
646 RatePredictionView::RawInput => self.render_raw_input(window, cx),
647 },
648 |this, element| this.child(element),
649 ),
650 )
651 .when(!rated, |this| {
652 let modal = cx.entity().downgrade();
653 let failure_mode_menu =
654 ContextMenu::build(window, cx, move |menu, _window, _cx| {
655 FeedbackCompletionProvider::FAILURE_MODES
656 .iter()
657 .fold(menu, |menu, (key, description)| {
658 let key: SharedString = (*key).into();
659 let description: SharedString = (*description).into();
660 let modal = modal.clone();
661 menu.entry(
662 format!("{} {}", key, description),
663 None,
664 move |window, cx| {
665 if let Some(modal) = modal.upgrade() {
666 modal.update(cx, |this, cx| {
667 if let Some(active) = &this.active_prediction {
668 active.feedback_editor.update(
669 cx,
670 |editor, cx| {
671 editor.set_text(
672 format!("{} {}", key, description),
673 window,
674 cx,
675 );
676 },
677 );
678 }
679 });
680 }
681 },
682 )
683 })
684 });
685
686 this.child(
687 h_flex()
688 .p_2()
689 .gap_2()
690 .border_y_1()
691 .border_color(border_color)
692 .child(
693 DropdownMenu::new(
694 "failure-mode-dropdown",
695 "Issue",
696 failure_mode_menu,
697 )
698 .handle(self.failure_mode_menu_handle.clone())
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(cx);
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(|this, _, _, cx| {
968 if !this.failure_mode_menu_handle.is_deployed() {
969 cx.emit(DismissEvent);
970 }
971 }))
972 }
973}
974
975impl EventEmitter<DismissEvent> for RatePredictionsModal {}
976
977impl Focusable for RatePredictionsModal {
978 fn focus_handle(&self, _cx: &App) -> FocusHandle {
979 self.focus_handle.clone()
980 }
981}
982
983impl ModalView for RatePredictionsModal {}
984
985fn format_time_ago(elapsed: Duration) -> String {
986 let seconds = elapsed.as_secs();
987 if seconds < 120 {
988 "1 minute".to_string()
989 } else if seconds < 3600 {
990 format!("{} minutes", seconds / 60)
991 } else if seconds < 7200 {
992 "1 hour".to_string()
993 } else if seconds < 86400 {
994 format!("{} hours", seconds / 3600)
995 } else if seconds < 172800 {
996 "1 day".to_string()
997 } else {
998 format!("{} days", seconds / 86400)
999 }
1000}
1001
1002struct FeedbackCompletionProvider;
1003
1004impl FeedbackCompletionProvider {
1005 const FAILURE_MODES: &'static [(&'static str, &'static str)] = &[
1006 ("@location", "Unexpected location"),
1007 ("@malformed", "Incomplete, cut off, or syntax error"),
1008 (
1009 "@deleted",
1010 "Deleted code that should be kept (use `@reverted` if it undid a recent edit)",
1011 ),
1012 ("@style", "Wrong coding style or conventions"),
1013 ("@repetitive", "Repeated existing code"),
1014 ("@hallucinated", "Referenced non-existent symbols"),
1015 ("@formatting", "Wrong indentation or structure"),
1016 ("@aggressive", "Changed more than expected"),
1017 ("@conservative", "Too cautious, changed too little"),
1018 ("@context", "Ignored or misunderstood context"),
1019 ("@reverted", "Undid recent edits"),
1020 ("@cursor_position", "Cursor placed in unhelpful position"),
1021 ("@whitespace", "Unwanted whitespace or newline changes"),
1022 ];
1023}
1024
1025impl editor::CompletionProvider for FeedbackCompletionProvider {
1026 fn completions(
1027 &self,
1028 _excerpt_id: editor::ExcerptId,
1029 buffer: &Entity<Buffer>,
1030 buffer_position: language::Anchor,
1031 _trigger: editor::CompletionContext,
1032 _window: &mut Window,
1033 cx: &mut Context<Editor>,
1034 ) -> gpui::Task<anyhow::Result<Vec<CompletionResponse>>> {
1035 let buffer = buffer.read(cx);
1036 let mut count_back = 0;
1037
1038 for char in buffer.reversed_chars_at(buffer_position) {
1039 if char.is_ascii_alphanumeric() || char == '_' || char == '@' {
1040 count_back += 1;
1041 } else {
1042 break;
1043 }
1044 }
1045
1046 let start_anchor = buffer.anchor_before(
1047 buffer_position
1048 .to_offset(&buffer)
1049 .saturating_sub(count_back),
1050 );
1051
1052 let replace_range = start_anchor..buffer_position;
1053 let snapshot = buffer.text_snapshot();
1054 let query: String = snapshot.text_for_range(replace_range.clone()).collect();
1055
1056 if !query.starts_with('@') {
1057 return gpui::Task::ready(Ok(vec![CompletionResponse {
1058 completions: vec![],
1059 display_options: CompletionDisplayOptions {
1060 dynamic_width: true,
1061 },
1062 is_incomplete: false,
1063 }]));
1064 }
1065
1066 let query_lower = query.to_lowercase();
1067
1068 let completions: Vec<Completion> = Self::FAILURE_MODES
1069 .iter()
1070 .filter(|(key, _description)| key.starts_with(&query_lower))
1071 .map(|(key, description)| Completion {
1072 replace_range: replace_range.clone(),
1073 new_text: format!("{} {}", key, description),
1074 label: CodeLabel::plain(format!("{}: {}", key, description), None),
1075 documentation: None,
1076 source: CompletionSource::Custom,
1077 icon_path: None,
1078 match_start: None,
1079 snippet_deduplication_key: None,
1080 insert_text_mode: None,
1081 confirm: None,
1082 })
1083 .collect();
1084
1085 gpui::Task::ready(Ok(vec![CompletionResponse {
1086 completions,
1087 display_options: CompletionDisplayOptions {
1088 dynamic_width: true,
1089 },
1090 is_incomplete: false,
1091 }]))
1092 }
1093
1094 fn is_completion_trigger(
1095 &self,
1096 _buffer: &Entity<Buffer>,
1097 _position: language::Anchor,
1098 text: &str,
1099 _trigger_in_words: bool,
1100 _cx: &mut Context<Editor>,
1101 ) -> bool {
1102 text.chars()
1103 .last()
1104 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_' || c == '@')
1105 }
1106}