rate_completion_modal.rs

  1use crate::{CompletionDiffElement, InlineCompletion, InlineCompletionRating, Zeta};
  2use command_palette_hooks::CommandPaletteFilter;
  3use editor::Editor;
  4use feature_flags::{FeatureFlagAppExt as _, PredictEditsRateCompletionsFeatureFlag};
  5use gpui::{actions, prelude::*, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable};
  6use language::language_settings;
  7use std::{any::TypeId, time::Duration};
  8use ui::{prelude::*, KeyBinding, List, ListItem, ListItemSpacing, Tooltip};
  9use workspace::{ModalView, Workspace};
 10
 11actions!(
 12    zeta,
 13    [
 14        RateCompletions,
 15        ThumbsUpActiveCompletion,
 16        ThumbsDownActiveCompletion,
 17        NextEdit,
 18        PreviousEdit,
 19        FocusCompletions,
 20        PreviewCompletion,
 21    ]
 22);
 23
 24pub fn init(cx: &mut App) {
 25    cx.observe_new(move |workspace: &mut Workspace, _, _cx| {
 26        workspace.register_action(|workspace, _: &RateCompletions, window, cx| {
 27            if cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>() {
 28                RateCompletionModal::toggle(workspace, window, cx);
 29            }
 30        });
 31    })
 32    .detach();
 33
 34    feature_gate_predict_edits_rating_actions(cx);
 35}
 36
 37fn feature_gate_predict_edits_rating_actions(cx: &mut App) {
 38    let rate_completion_action_types = [TypeId::of::<RateCompletions>()];
 39
 40    CommandPaletteFilter::update_global(cx, |filter, _cx| {
 41        filter.hide_action_types(&rate_completion_action_types);
 42    });
 43
 44    cx.observe_flag::<PredictEditsRateCompletionsFeatureFlag, _>(move |is_enabled, cx| {
 45        if is_enabled {
 46            CommandPaletteFilter::update_global(cx, |filter, _cx| {
 47                filter.show_action_types(rate_completion_action_types.iter());
 48            });
 49        } else {
 50            CommandPaletteFilter::update_global(cx, |filter, _cx| {
 51                filter.hide_action_types(&rate_completion_action_types);
 52            });
 53        }
 54    })
 55    .detach();
 56}
 57
 58pub struct RateCompletionModal {
 59    zeta: Entity<Zeta>,
 60    active_completion: Option<ActiveCompletion>,
 61    selected_index: usize,
 62    focus_handle: FocusHandle,
 63    _subscription: gpui::Subscription,
 64    current_view: RateCompletionView,
 65}
 66
 67struct ActiveCompletion {
 68    completion: InlineCompletion,
 69    feedback_editor: Entity<Editor>,
 70}
 71
 72#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
 73enum RateCompletionView {
 74    SuggestedEdits,
 75    RawInput,
 76}
 77
 78impl RateCompletionView {
 79    pub fn name(&self) -> &'static str {
 80        match self {
 81            Self::SuggestedEdits => "Suggested Edits",
 82            Self::RawInput => "Recorded Events & Input",
 83        }
 84    }
 85}
 86
 87impl RateCompletionModal {
 88    pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
 89        if let Some(zeta) = Zeta::global(cx) {
 90            workspace.toggle_modal(window, cx, |_window, cx| RateCompletionModal::new(zeta, cx));
 91        }
 92    }
 93
 94    pub fn new(zeta: Entity<Zeta>, cx: &mut Context<Self>) -> Self {
 95        let subscription = cx.observe(&zeta, |_, _, cx| cx.notify());
 96
 97        Self {
 98            zeta,
 99            selected_index: 0,
100            focus_handle: cx.focus_handle(),
101            active_completion: None,
102            _subscription: subscription,
103            current_view: RateCompletionView::SuggestedEdits,
104        }
105    }
106
107    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
108        cx.emit(DismissEvent);
109    }
110
111    fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context<Self>) {
112        self.selected_index += 1;
113        self.selected_index = usize::min(
114            self.selected_index,
115            self.zeta.read(cx).shown_completions().count(),
116        );
117        cx.notify();
118    }
119
120    fn select_prev(&mut self, _: &menu::SelectPrev, _: &mut Window, cx: &mut Context<Self>) {
121        self.selected_index = self.selected_index.saturating_sub(1);
122        cx.notify();
123    }
124
125    fn select_next_edit(&mut self, _: &NextEdit, _: &mut Window, cx: &mut Context<Self>) {
126        let next_index = self
127            .zeta
128            .read(cx)
129            .shown_completions()
130            .skip(self.selected_index)
131            .enumerate()
132            .skip(1) // Skip straight to the next item
133            .find(|(_, completion)| !completion.edits.is_empty())
134            .map(|(ix, _)| ix + self.selected_index);
135
136        if let Some(next_index) = next_index {
137            self.selected_index = next_index;
138            cx.notify();
139        }
140    }
141
142    fn select_prev_edit(&mut self, _: &PreviousEdit, _: &mut Window, cx: &mut Context<Self>) {
143        let zeta = self.zeta.read(cx);
144        let completions_len = zeta.shown_completions_len();
145
146        let prev_index = self
147            .zeta
148            .read(cx)
149            .shown_completions()
150            .rev()
151            .skip((completions_len - 1) - self.selected_index)
152            .enumerate()
153            .skip(1) // Skip straight to the previous item
154            .find(|(_, completion)| !completion.edits.is_empty())
155            .map(|(ix, _)| self.selected_index - ix);
156
157        if let Some(prev_index) = prev_index {
158            self.selected_index = prev_index;
159            cx.notify();
160        }
161        cx.notify();
162    }
163
164    fn select_first(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context<Self>) {
165        self.selected_index = 0;
166        cx.notify();
167    }
168
169    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
170        self.selected_index = self.zeta.read(cx).shown_completions_len() - 1;
171        cx.notify();
172    }
173
174    pub fn thumbs_up_active(
175        &mut self,
176        _: &ThumbsUpActiveCompletion,
177        window: &mut Window,
178        cx: &mut Context<Self>,
179    ) {
180        self.zeta.update(cx, |zeta, cx| {
181            if let Some(active) = &self.active_completion {
182                zeta.rate_completion(
183                    &active.completion,
184                    InlineCompletionRating::Positive,
185                    active.feedback_editor.read(cx).text(cx),
186                    cx,
187                );
188            }
189        });
190
191        let current_completion = self
192            .active_completion
193            .as_ref()
194            .map(|completion| completion.completion.clone());
195        self.select_completion(current_completion, false, window, cx);
196        self.select_next_edit(&Default::default(), window, cx);
197        self.confirm(&Default::default(), window, cx);
198
199        cx.notify();
200    }
201
202    pub fn thumbs_down_active(
203        &mut self,
204        _: &ThumbsDownActiveCompletion,
205        window: &mut Window,
206        cx: &mut Context<Self>,
207    ) {
208        if let Some(active) = &self.active_completion {
209            if active.feedback_editor.read(cx).text(cx).is_empty() {
210                return;
211            }
212
213            self.zeta.update(cx, |zeta, cx| {
214                zeta.rate_completion(
215                    &active.completion,
216                    InlineCompletionRating::Negative,
217                    active.feedback_editor.read(cx).text(cx),
218                    cx,
219                );
220            });
221        }
222
223        let current_completion = self
224            .active_completion
225            .as_ref()
226            .map(|completion| completion.completion.clone());
227        self.select_completion(current_completion, false, window, cx);
228        self.select_next_edit(&Default::default(), window, cx);
229        self.confirm(&Default::default(), window, cx);
230
231        cx.notify();
232    }
233
234    fn focus_completions(
235        &mut self,
236        _: &FocusCompletions,
237        window: &mut Window,
238        cx: &mut Context<Self>,
239    ) {
240        cx.focus_self(window);
241        cx.notify();
242    }
243
244    fn preview_completion(
245        &mut self,
246        _: &PreviewCompletion,
247        window: &mut Window,
248        cx: &mut Context<Self>,
249    ) {
250        let completion = self
251            .zeta
252            .read(cx)
253            .shown_completions()
254            .skip(self.selected_index)
255            .take(1)
256            .next()
257            .cloned();
258
259        self.select_completion(completion, false, window, cx);
260    }
261
262    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
263        let completion = self
264            .zeta
265            .read(cx)
266            .shown_completions()
267            .skip(self.selected_index)
268            .take(1)
269            .next()
270            .cloned();
271
272        self.select_completion(completion, true, window, cx);
273    }
274
275    pub fn select_completion(
276        &mut self,
277        completion: Option<InlineCompletion>,
278        focus: bool,
279        window: &mut Window,
280        cx: &mut Context<Self>,
281    ) {
282        // Avoid resetting completion rating if it's already selected.
283        if let Some(completion) = completion.as_ref() {
284            self.selected_index = self
285                .zeta
286                .read(cx)
287                .shown_completions()
288                .enumerate()
289                .find(|(_, completion_b)| completion.id == completion_b.id)
290                .map(|(ix, _)| ix)
291                .unwrap_or(self.selected_index);
292            cx.notify();
293
294            if let Some(prev_completion) = self.active_completion.as_ref() {
295                if completion.id == prev_completion.completion.id {
296                    if focus {
297                        window.focus(&prev_completion.feedback_editor.focus_handle(cx));
298                    }
299                    return;
300                }
301            }
302        }
303
304        self.active_completion = completion.map(|completion| ActiveCompletion {
305            completion,
306            feedback_editor: cx.new(|cx| {
307                let mut editor = Editor::multi_line(window, cx);
308                editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx);
309                editor.set_show_line_numbers(false, cx);
310                editor.set_show_scrollbars(false, cx);
311                editor.set_show_git_diff_gutter(false, cx);
312                editor.set_show_code_actions(false, cx);
313                editor.set_show_runnables(false, cx);
314                editor.set_show_wrap_guides(false, cx);
315                editor.set_show_indent_guides(false, cx);
316                editor.set_show_inline_completions(Some(false), window, cx);
317                editor.set_placeholder_text("Add your feedback…", cx);
318                if focus {
319                    cx.focus_self(window);
320                }
321                editor
322            }),
323        });
324        cx.notify();
325    }
326
327    fn render_view_nav(&self, cx: &Context<Self>) -> impl IntoElement {
328        h_flex()
329            .h_8()
330            .px_1()
331            .border_b_1()
332            .border_color(cx.theme().colors().border)
333            .bg(cx.theme().colors().elevated_surface_background)
334            .gap_1()
335            .child(
336                Button::new(
337                    ElementId::Name("suggested-edits".into()),
338                    RateCompletionView::SuggestedEdits.name(),
339                )
340                .label_size(LabelSize::Small)
341                .on_click(cx.listener(move |this, _, _window, cx| {
342                    this.current_view = RateCompletionView::SuggestedEdits;
343                    cx.notify();
344                }))
345                .toggle_state(self.current_view == RateCompletionView::SuggestedEdits),
346            )
347            .child(
348                Button::new(
349                    ElementId::Name("raw-input".into()),
350                    RateCompletionView::RawInput.name(),
351                )
352                .label_size(LabelSize::Small)
353                .on_click(cx.listener(move |this, _, _window, cx| {
354                    this.current_view = RateCompletionView::RawInput;
355                    cx.notify();
356                }))
357                .toggle_state(self.current_view == RateCompletionView::RawInput),
358            )
359    }
360
361    fn render_suggested_edits(&self, cx: &mut Context<Self>) -> Option<gpui::Stateful<Div>> {
362        let active_completion = self.active_completion.as_ref()?;
363        let bg_color = cx.theme().colors().editor_background;
364
365        Some(
366            div()
367                .id("diff")
368                .p_4()
369                .size_full()
370                .bg(bg_color)
371                .overflow_scroll()
372                .whitespace_nowrap()
373                .child(CompletionDiffElement::new(
374                    &active_completion.completion,
375                    cx,
376                )),
377        )
378    }
379
380    fn render_raw_input(&self, cx: &mut Context<Self>) -> Option<gpui::Stateful<Div>> {
381        Some(
382            v_flex()
383                .size_full()
384                .overflow_hidden()
385                .relative()
386                .child(
387                    div()
388                        .id("raw-input")
389                        .py_4()
390                        .px_6()
391                        .size_full()
392                        .bg(cx.theme().colors().editor_background)
393                        .overflow_scroll()
394                        .child(if let Some(active_completion) = &self.active_completion {
395                            format!(
396                                "{}\n{}",
397                                active_completion.completion.input_events,
398                                active_completion.completion.input_excerpt
399                            )
400                        } else {
401                            "No active completion".to_string()
402                        }),
403                )
404                .id("raw-input-view"),
405        )
406    }
407
408    fn render_active_completion(
409        &mut self,
410        window: &mut Window,
411        cx: &mut Context<Self>,
412    ) -> Option<impl IntoElement> {
413        let active_completion = self.active_completion.as_ref()?;
414        let completion_id = active_completion.completion.id;
415        let focus_handle = &self.focus_handle(cx);
416
417        let border_color = cx.theme().colors().border;
418        let bg_color = cx.theme().colors().editor_background;
419
420        let rated = self.zeta.read(cx).is_completion_rated(completion_id);
421        let feedback_empty = active_completion
422            .feedback_editor
423            .read(cx)
424            .text(cx)
425            .is_empty();
426
427        let label_container = h_flex().pl_1().gap_1p5();
428
429        Some(
430            v_flex()
431                .size_full()
432                .overflow_hidden()
433                .relative()
434                .child(
435                    v_flex()
436                        .size_full()
437                        .overflow_hidden()
438                        .relative()
439                        .child(self.render_view_nav(cx))
440                        .when_some(match self.current_view {
441                            RateCompletionView::SuggestedEdits => self.render_suggested_edits(cx),
442                            RateCompletionView::RawInput => self.render_raw_input(cx),
443                        }, |this, element| this.child(element))
444                )
445                .when(!rated, |this| {
446                    this.child(
447                        h_flex()
448                            .p_2()
449                            .gap_2()
450                            .border_y_1()
451                            .border_color(border_color)
452                            .child(
453                                Icon::new(IconName::Info)
454                                    .size(IconSize::XSmall)
455                                    .color(Color::Muted)
456                            )
457                            .child(
458                                div()
459                                    .w_full()
460                                    .pr_2()
461                                    .flex_wrap()
462                                    .child(
463                                        Label::new("Explain why this completion is good or bad. If it's negative, describe what you expected instead.")
464                                            .size(LabelSize::Small)
465                                            .color(Color::Muted)
466                                    )
467                            )
468                    )
469                })
470                .when(!rated, |this| {
471                    this.child(
472                        div()
473                            .h_40()
474                            .pt_1()
475                            .bg(bg_color)
476                            .child(active_completion.feedback_editor.clone())
477                    )
478                })
479                .child(
480                    h_flex()
481                        .p_1()
482                        .h_8()
483                        .max_h_8()
484                        .border_t_1()
485                        .border_color(border_color)
486                        .max_w_full()
487                        .justify_between()
488                        .children(if rated {
489                            Some(
490                                label_container
491                                    .child(
492                                        Icon::new(IconName::Check)
493                                            .size(IconSize::Small)
494                                            .color(Color::Success),
495                                    )
496                                    .child(Label::new("Rated completion.").color(Color::Muted)),
497                            )
498                        } else if active_completion.completion.edits.is_empty() {
499                            Some(
500                                label_container
501                                    .child(
502                                        Icon::new(IconName::Warning)
503                                            .size(IconSize::Small)
504                                            .color(Color::Warning),
505                                    )
506                                    .child(Label::new("No edits produced.").color(Color::Muted)),
507                            )
508                        } else {
509                            Some(label_container)
510                        })
511                        .child(
512                            h_flex()
513                                .gap_1()
514                                .child(
515                                    Button::new("bad", "Bad Completion")
516                                        .icon(IconName::ThumbsDown)
517                                        .icon_size(IconSize::Small)
518                                        .icon_position(IconPosition::Start)
519                                        .disabled(rated || feedback_empty)
520                                        .when(feedback_empty, |this| {
521                                            this.tooltip(Tooltip::text("Explain what's bad about it before reporting it"))
522                                        })
523                                        .key_binding(KeyBinding::for_action_in(
524                                            &ThumbsDownActiveCompletion,
525                                            focus_handle,
526                                            window,
527                                        ))
528                                        .on_click(cx.listener(move |this, _, window, cx| {
529                                            this.thumbs_down_active(
530                                                &ThumbsDownActiveCompletion,
531                                                window, cx,
532                                            );
533                                        })),
534                                )
535                                .child(
536                                    Button::new("good", "Good Completion")
537                                        .icon(IconName::ThumbsUp)
538                                        .icon_size(IconSize::Small)
539                                        .icon_position(IconPosition::Start)
540                                        .disabled(rated)
541                                        .key_binding(KeyBinding::for_action_in(
542                                            &ThumbsUpActiveCompletion,
543                                            focus_handle,
544                                            window,
545                                        ))
546                                        .on_click(cx.listener(move |this, _, window, cx| {
547                                            this.thumbs_up_active(&ThumbsUpActiveCompletion, window, cx);
548                                        })),
549                                ),
550                        ),
551                ),
552        )
553    }
554}
555
556impl Render for RateCompletionModal {
557    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
558        let border_color = cx.theme().colors().border;
559
560        h_flex()
561            .key_context("RateCompletionModal")
562            .track_focus(&self.focus_handle)
563            .on_action(cx.listener(Self::dismiss))
564            .on_action(cx.listener(Self::confirm))
565            .on_action(cx.listener(Self::select_prev))
566            .on_action(cx.listener(Self::select_prev_edit))
567            .on_action(cx.listener(Self::select_next))
568            .on_action(cx.listener(Self::select_next_edit))
569            .on_action(cx.listener(Self::select_first))
570            .on_action(cx.listener(Self::select_last))
571            .on_action(cx.listener(Self::thumbs_up_active))
572            .on_action(cx.listener(Self::thumbs_down_active))
573            .on_action(cx.listener(Self::focus_completions))
574            .on_action(cx.listener(Self::preview_completion))
575            .bg(cx.theme().colors().elevated_surface_background)
576            .border_1()
577            .border_color(border_color)
578            .w(window.viewport_size().width - px(320.))
579            .h(window.viewport_size().height - px(300.))
580            .rounded_lg()
581            .shadow_lg()
582            .child(
583                v_flex()
584                    .w_72()
585                    .h_full()
586                    .border_r_1()
587                    .border_color(border_color)
588                    .flex_shrink_0()
589                    .overflow_hidden()
590                    .child(
591                        h_flex()
592                            .h_8()
593                            .px_2()
594                            .justify_between()
595                            .border_b_1()
596                            .border_color(border_color)
597                            .child(
598                                Icon::new(IconName::ZedPredict)
599                                    .size(IconSize::Small)
600                            )
601                            .child(
602                                Label::new("From most recent to oldest")
603                                    .color(Color::Muted)
604                                    .size(LabelSize::Small),
605                            )
606                    )
607                    .child(
608                        div()
609                            .id("completion_list")
610                            .p_0p5()
611                            .h_full()
612                            .overflow_y_scroll()
613                            .child(
614                                List::new()
615                                    .empty_message(
616                                        div()
617                                            .p_2()
618                                            .child(
619                                                Label::new("No completions yet. Use the editor to generate some, and make sure to rate them!")
620                                                    .color(Color::Muted),
621                                            )
622                                            .into_any_element(),
623                                    )
624                                    .children(self.zeta.read(cx).shown_completions().cloned().enumerate().map(
625                                        |(index, completion)| {
626                                            let selected =
627                                                self.active_completion.as_ref().map_or(false, |selected| {
628                                                    selected.completion.id == completion.id
629                                                });
630                                            let rated =
631                                                self.zeta.read(cx).is_completion_rated(completion.id);
632
633                                            let (icon_name, icon_color, tooltip_text) = match (rated, completion.edits.is_empty()) {
634                                                (true, _) => (IconName::Check, Color::Success, "Rated Completion"),
635                                                (false, true) => (IconName::File, Color::Muted, "No Edits Produced"),
636                                                (false, false) => (IconName::FileDiff, Color::Accent, "Edits Available"),
637                                            };
638
639                                            let file_name = completion.path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or("untitled".to_string());
640                                            let file_path = completion.path.parent().map(|p| p.to_string_lossy().to_string());
641
642                                            ListItem::new(completion.id)
643                                                .inset(true)
644                                                .spacing(ListItemSpacing::Sparse)
645                                                .focused(index == self.selected_index)
646                                                .toggle_state(selected)
647                                                .child(
648                                                    h_flex()
649                                                        .id("completion-content")
650                                                        .gap_3()
651                                                        .child(
652                                                            Icon::new(icon_name)
653                                                                .color(icon_color)
654                                                                .size(IconSize::Small)
655                                                        )
656                                                        .child(
657                                                            v_flex()
658                                                                .child(
659                                                                    h_flex().gap_1()
660                                                                        .child(Label::new(file_name).size(LabelSize::Small))
661                                                                        .when_some(file_path, |this, p| this.child(Label::new(p).size(LabelSize::Small).color(Color::Muted)))
662                                                                )
663                                                                .child(Label::new(format!("{} ago, {:.2?}", format_time_ago(completion.response_received_at.elapsed()), completion.latency()))
664                                                                    .color(Color::Muted)
665                                                                    .size(LabelSize::XSmall)
666                                                                )
667                                                        )
668                                                )
669                                                .tooltip(Tooltip::text(tooltip_text))
670                                                .on_click(cx.listener(move |this, _, window, cx| {
671                                                    this.select_completion(Some(completion.clone()), true, window, cx);
672                                                }))
673                                        },
674                                    )),
675                            )
676                    ),
677            )
678            .children(self.render_active_completion(window, cx))
679            .on_mouse_down_out(cx.listener(|_, _, _, cx| cx.emit(DismissEvent)))
680    }
681}
682
683impl EventEmitter<DismissEvent> for RateCompletionModal {}
684
685impl Focusable for RateCompletionModal {
686    fn focus_handle(&self, _cx: &App) -> FocusHandle {
687        self.focus_handle.clone()
688    }
689}
690
691impl ModalView for RateCompletionModal {}
692
693fn format_time_ago(elapsed: Duration) -> String {
694    let seconds = elapsed.as_secs();
695    if seconds < 120 {
696        "1 minute".to_string()
697    } else if seconds < 3600 {
698        format!("{} minutes", seconds / 60)
699    } else if seconds < 7200 {
700        "1 hour".to_string()
701    } else if seconds < 86400 {
702        format!("{} hours", seconds / 3600)
703    } else if seconds < 172800 {
704        "1 day".to_string()
705    } else {
706        format!("{} days", seconds / 86400)
707    }
708}