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