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}