rate_completion_modal.rs

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