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