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