1use anyhow::Result;
2use client::UserStore;
3use copilot::{Copilot, Status};
4use editor::{actions::ShowInlineCompletion, scroll::Autoscroll, Editor};
5use feature_flags::{
6 FeatureFlagAppExt, PredictEditsFeatureFlag, PredictEditsRateCompletionsFeatureFlag,
7};
8use fs::Fs;
9use gpui::{
10 actions, div, pulsating_between, Action, Animation, AnimationExt, App, AsyncWindowContext,
11 Corner, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render, Subscription,
12 WeakEntity,
13};
14use language::{
15 language_settings::{
16 self, all_language_settings, AllLanguageSettings, InlineCompletionProvider,
17 },
18 File, Language,
19};
20use settings::{update_settings_file, Settings, SettingsStore};
21use std::{path::Path, sync::Arc, time::Duration};
22use supermaven::{AccountStatus, Supermaven};
23use ui::{prelude::*, ButtonLike, Color, Icon, IconWithIndicator, Indicator, PopoverMenuHandle};
24use workspace::{
25 create_and_open_local_file,
26 item::ItemHandle,
27 notifications::NotificationId,
28 ui::{
29 ButtonCommon, Clickable, ContextMenu, IconButton, IconName, IconSize, PopoverMenu, Tooltip,
30 },
31 StatusItemView, Toast, Workspace,
32};
33use zed_actions::OpenBrowser;
34use zed_predict_tos::ZedPredictTos;
35use zeta::RateCompletionModal;
36
37actions!(zeta, [RateCompletions]);
38actions!(inline_completion, [ToggleMenu]);
39
40const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
41
42struct CopilotErrorToast;
43
44pub struct InlineCompletionButton {
45 editor_subscription: Option<(Subscription, usize)>,
46 editor_enabled: Option<bool>,
47 editor_focus_handle: Option<FocusHandle>,
48 language: Option<Arc<Language>>,
49 file: Option<Arc<dyn File>>,
50 inline_completion_provider: Option<Arc<dyn inline_completion::InlineCompletionProviderHandle>>,
51 fs: Arc<dyn Fs>,
52 workspace: WeakEntity<Workspace>,
53 user_store: Entity<UserStore>,
54 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
55}
56
57enum SupermavenButtonStatus {
58 Ready,
59 Errored(String),
60 NeedsActivation(String),
61 Initializing,
62}
63
64impl Render for InlineCompletionButton {
65 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
66 let all_language_settings = all_language_settings(None, cx);
67
68 match all_language_settings.inline_completions.provider {
69 InlineCompletionProvider::None => div(),
70
71 InlineCompletionProvider::Copilot => {
72 let Some(copilot) = Copilot::global(cx) else {
73 return div();
74 };
75 let status = copilot.read(cx).status();
76
77 let enabled = self.editor_enabled.unwrap_or_else(|| {
78 all_language_settings.inline_completions_enabled(None, None, cx)
79 });
80
81 let icon = match status {
82 Status::Error(_) => IconName::CopilotError,
83 Status::Authorized => {
84 if enabled {
85 IconName::Copilot
86 } else {
87 IconName::CopilotDisabled
88 }
89 }
90 _ => IconName::CopilotInit,
91 };
92
93 if let Status::Error(e) = status {
94 return div().child(
95 IconButton::new("copilot-error", icon)
96 .icon_size(IconSize::Small)
97 .on_click(cx.listener(move |_, _, window, cx| {
98 if let Some(workspace) = window.root::<Workspace>().flatten() {
99 workspace.update(cx, |workspace, cx| {
100 workspace.show_toast(
101 Toast::new(
102 NotificationId::unique::<CopilotErrorToast>(),
103 format!("Copilot can't be started: {}", e),
104 )
105 .on_click(
106 "Reinstall Copilot",
107 |_, cx| {
108 if let Some(copilot) = Copilot::global(cx) {
109 copilot
110 .update(cx, |copilot, cx| {
111 copilot.reinstall(cx)
112 })
113 .detach();
114 }
115 },
116 ),
117 cx,
118 );
119 });
120 }
121 }))
122 .tooltip(|window, cx| {
123 Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx)
124 }),
125 );
126 }
127 let this = cx.entity().clone();
128
129 div().child(
130 PopoverMenu::new("copilot")
131 .menu(move |window, cx| {
132 Some(match status {
133 Status::Authorized => this.update(cx, |this, cx| {
134 this.build_copilot_context_menu(window, cx)
135 }),
136 _ => this.update(cx, |this, cx| {
137 this.build_copilot_start_menu(window, cx)
138 }),
139 })
140 })
141 .anchor(Corner::BottomRight)
142 .trigger(IconButton::new("copilot-icon", icon).tooltip(|window, cx| {
143 Tooltip::for_action("GitHub Copilot", &ToggleMenu, window, cx)
144 }))
145 .with_handle(self.popover_menu_handle.clone()),
146 )
147 }
148
149 InlineCompletionProvider::Supermaven => {
150 let Some(supermaven) = Supermaven::global(cx) else {
151 return div();
152 };
153
154 let supermaven = supermaven.read(cx);
155
156 let status = match supermaven {
157 Supermaven::Starting => SupermavenButtonStatus::Initializing,
158 Supermaven::FailedDownload { error } => {
159 SupermavenButtonStatus::Errored(error.to_string())
160 }
161 Supermaven::Spawned(agent) => {
162 let account_status = agent.account_status.clone();
163 match account_status {
164 AccountStatus::NeedsActivation { activate_url } => {
165 SupermavenButtonStatus::NeedsActivation(activate_url.clone())
166 }
167 AccountStatus::Unknown => SupermavenButtonStatus::Initializing,
168 AccountStatus::Ready => SupermavenButtonStatus::Ready,
169 }
170 }
171 Supermaven::Error { error } => {
172 SupermavenButtonStatus::Errored(error.to_string())
173 }
174 };
175
176 let icon = status.to_icon();
177 let tooltip_text = status.to_tooltip();
178 let has_menu = status.has_menu();
179 let this = cx.entity().clone();
180 let fs = self.fs.clone();
181
182 return div().child(
183 PopoverMenu::new("supermaven")
184 .menu(move |window, cx| match &status {
185 SupermavenButtonStatus::NeedsActivation(activate_url) => {
186 Some(ContextMenu::build(window, cx, |menu, _, _| {
187 let fs = fs.clone();
188 let activate_url = activate_url.clone();
189 menu.entry("Sign In", None, move |_, cx| {
190 cx.open_url(activate_url.as_str())
191 })
192 .entry(
193 "Use Copilot",
194 None,
195 move |_, cx| {
196 set_completion_provider(
197 fs.clone(),
198 cx,
199 InlineCompletionProvider::Copilot,
200 )
201 },
202 )
203 }))
204 }
205 SupermavenButtonStatus::Ready => Some(this.update(cx, |this, cx| {
206 this.build_supermaven_context_menu(window, cx)
207 })),
208 _ => None,
209 })
210 .anchor(Corner::BottomRight)
211 .trigger(IconButton::new("supermaven-icon", icon).tooltip(
212 move |window, cx| {
213 if has_menu {
214 Tooltip::for_action(
215 tooltip_text.clone(),
216 &ToggleMenu,
217 window,
218 cx,
219 )
220 } else {
221 Tooltip::text(tooltip_text.clone())(window, cx)
222 }
223 },
224 ))
225 .with_handle(self.popover_menu_handle.clone()),
226 );
227 }
228
229 InlineCompletionProvider::Zed => {
230 if !cx.has_flag::<PredictEditsFeatureFlag>() {
231 return div();
232 }
233
234 if !self
235 .user_store
236 .read(cx)
237 .current_user_has_accepted_terms()
238 .unwrap_or(false)
239 {
240 let workspace = self.workspace.clone();
241 let user_store = self.user_store.clone();
242
243 return div().child(
244 ButtonLike::new("zeta-pending-tos-icon")
245 .child(
246 IconWithIndicator::new(
247 Icon::new(IconName::ZedPredict),
248 Some(Indicator::dot().color(Color::Error)),
249 )
250 .indicator_border_color(Some(
251 cx.theme().colors().status_bar_background,
252 ))
253 .into_any_element(),
254 )
255 .tooltip(|window, cx| {
256 Tooltip::with_meta(
257 "Edit Predictions",
258 None,
259 "Read Terms of Service",
260 window,
261 cx,
262 )
263 })
264 .on_click(cx.listener(move |_, _, window, cx| {
265 let user_store = user_store.clone();
266
267 if let Some(workspace) = workspace.upgrade() {
268 ZedPredictTos::toggle(workspace, user_store, window, cx);
269 }
270 })),
271 );
272 }
273
274 let this = cx.entity().clone();
275 let button = IconButton::new("zeta", IconName::ZedPredict).when(
276 !self.popover_menu_handle.is_deployed(),
277 |button| {
278 button.tooltip(|window, cx| {
279 Tooltip::for_action("Edit Prediction", &ToggleMenu, window, cx)
280 })
281 },
282 );
283
284 let is_refreshing = self
285 .inline_completion_provider
286 .as_ref()
287 .map_or(false, |provider| provider.is_refreshing(cx));
288
289 let mut popover_menu = PopoverMenu::new("zeta")
290 .menu(move |window, cx| {
291 Some(this.update(cx, |this, cx| this.build_zeta_context_menu(window, cx)))
292 })
293 .anchor(Corner::BottomRight)
294 .with_handle(self.popover_menu_handle.clone());
295
296 if is_refreshing {
297 popover_menu = popover_menu.trigger(
298 button.with_animation(
299 "pulsating-label",
300 Animation::new(Duration::from_secs(2))
301 .repeat()
302 .with_easing(pulsating_between(0.2, 1.0)),
303 |icon_button, delta| icon_button.alpha(delta),
304 ),
305 );
306 } else {
307 popover_menu = popover_menu.trigger(button);
308 }
309
310 div().child(popover_menu.into_any_element())
311 }
312 }
313 }
314}
315
316impl InlineCompletionButton {
317 pub fn new(
318 workspace: WeakEntity<Workspace>,
319 fs: Arc<dyn Fs>,
320 user_store: Entity<UserStore>,
321 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
322 cx: &mut Context<Self>,
323 ) -> Self {
324 if let Some(copilot) = Copilot::global(cx) {
325 cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
326 }
327
328 cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
329 .detach();
330
331 Self {
332 editor_subscription: None,
333 editor_enabled: None,
334 editor_focus_handle: None,
335 language: None,
336 file: None,
337 inline_completion_provider: None,
338 popover_menu_handle,
339 workspace,
340 fs,
341 user_store,
342 }
343 }
344
345 pub fn build_copilot_start_menu(
346 &mut self,
347 window: &mut Window,
348 cx: &mut Context<Self>,
349 ) -> Entity<ContextMenu> {
350 let fs = self.fs.clone();
351 ContextMenu::build(window, cx, |menu, _, _| {
352 menu.entry("Sign In", None, copilot::initiate_sign_in)
353 .entry("Disable Copilot", None, {
354 let fs = fs.clone();
355 move |_window, cx| hide_copilot(fs.clone(), cx)
356 })
357 .entry("Use Supermaven", None, {
358 let fs = fs.clone();
359 move |_window, cx| {
360 set_completion_provider(
361 fs.clone(),
362 cx,
363 InlineCompletionProvider::Supermaven,
364 )
365 }
366 })
367 })
368 }
369
370 // Predict Edits at Cursor – alt-tab
371 // Automatically Predict:
372 // ✓ PATH
373 // ✓ Rust
374 // ✓ All Files
375 pub fn build_language_settings_menu(&self, mut menu: ContextMenu, cx: &mut App) -> ContextMenu {
376 let fs = self.fs.clone();
377
378 menu = menu.header("Predict Edits For:");
379
380 if let Some(language) = self.language.clone() {
381 let fs = fs.clone();
382 let language_enabled =
383 language_settings::language_settings(Some(language.name()), None, cx)
384 .show_inline_completions;
385
386 menu = menu.toggleable_entry(
387 language.name(),
388 language_enabled,
389 IconPosition::Start,
390 None,
391 move |_, cx| {
392 toggle_inline_completions_for_language(language.clone(), fs.clone(), cx)
393 },
394 );
395 }
396
397 let settings = AllLanguageSettings::get_global(cx);
398 if let Some(file) = &self.file {
399 let path = file.path().clone();
400 let path_enabled = settings.inline_completions_enabled_for_path(&path);
401
402 menu = menu.toggleable_entry(
403 "This File",
404 path_enabled,
405 IconPosition::Start,
406 None,
407 move |window, cx| {
408 if let Some(workspace) = window.root().flatten() {
409 let workspace = workspace.downgrade();
410 window
411 .spawn(cx, |cx| {
412 configure_disabled_globs(
413 workspace,
414 path_enabled.then_some(path.clone()),
415 cx,
416 )
417 })
418 .detach_and_log_err(cx);
419 }
420 },
421 );
422 }
423
424 let globally_enabled = settings.inline_completions_enabled(None, None, cx);
425 menu = menu.toggleable_entry(
426 "All Files",
427 globally_enabled,
428 IconPosition::Start,
429 None,
430 move |_, cx| toggle_inline_completions_globally(fs.clone(), cx),
431 );
432
433 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
434 menu = menu
435 .separator()
436 .entry(
437 "Predict Edit at Cursor",
438 Some(Box::new(ShowInlineCompletion)),
439 {
440 let editor_focus_handle = editor_focus_handle.clone();
441
442 move |window, cx| {
443 editor_focus_handle.dispatch_action(&ShowInlineCompletion, window, cx);
444 }
445 },
446 )
447 .context(editor_focus_handle);
448 }
449
450 menu
451 }
452
453 fn build_copilot_context_menu(
454 &self,
455 window: &mut Window,
456 cx: &mut Context<Self>,
457 ) -> Entity<ContextMenu> {
458 ContextMenu::build(window, cx, |menu, _, cx| {
459 self.build_language_settings_menu(menu, cx)
460 .separator()
461 .link(
462 "Go to Copilot Settings",
463 OpenBrowser {
464 url: COPILOT_SETTINGS_URL.to_string(),
465 }
466 .boxed_clone(),
467 )
468 .action("Sign Out", copilot::SignOut.boxed_clone())
469 })
470 }
471
472 fn build_supermaven_context_menu(
473 &self,
474 window: &mut Window,
475 cx: &mut Context<Self>,
476 ) -> Entity<ContextMenu> {
477 ContextMenu::build(window, cx, |menu, _, cx| {
478 self.build_language_settings_menu(menu, cx)
479 .separator()
480 .action("Sign Out", supermaven::SignOut.boxed_clone())
481 })
482 }
483
484 fn build_zeta_context_menu(
485 &self,
486 window: &mut Window,
487 cx: &mut Context<Self>,
488 ) -> Entity<ContextMenu> {
489 let workspace = self.workspace.clone();
490 ContextMenu::build(window, cx, |menu, _window, cx| {
491 self.build_language_settings_menu(menu, cx).when(
492 cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>(),
493 |this| {
494 this.entry(
495 "Rate Completions",
496 Some(RateCompletions.boxed_clone()),
497 move |window, cx| {
498 workspace
499 .update(cx, |workspace, cx| {
500 RateCompletionModal::toggle(workspace, window, cx)
501 })
502 .ok();
503 },
504 )
505 },
506 )
507 })
508 }
509
510 pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
511 let editor = editor.read(cx);
512 let snapshot = editor.buffer().read(cx).snapshot(cx);
513 let suggestion_anchor = editor.selections.newest_anchor().start;
514 let language = snapshot.language_at(suggestion_anchor);
515 let file = snapshot.file_at(suggestion_anchor).cloned();
516 self.editor_enabled = {
517 let file = file.as_ref();
518 Some(
519 file.map(|file| !file.is_private()).unwrap_or(true)
520 && all_language_settings(file, cx).inline_completions_enabled(
521 language,
522 file.map(|file| file.path().as_ref()),
523 cx,
524 ),
525 )
526 };
527 self.inline_completion_provider = editor.inline_completion_provider();
528 self.language = language.cloned();
529 self.file = file;
530 self.editor_focus_handle = Some(editor.focus_handle(cx));
531
532 cx.notify();
533 }
534
535 pub fn toggle_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
536 self.popover_menu_handle.toggle(window, cx);
537 }
538}
539
540impl StatusItemView for InlineCompletionButton {
541 fn set_active_pane_item(
542 &mut self,
543 item: Option<&dyn ItemHandle>,
544 _: &mut Window,
545 cx: &mut Context<Self>,
546 ) {
547 if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
548 self.editor_subscription = Some((
549 cx.observe(&editor, Self::update_enabled),
550 editor.entity_id().as_u64() as usize,
551 ));
552 self.update_enabled(editor, cx);
553 } else {
554 self.language = None;
555 self.editor_subscription = None;
556 self.editor_enabled = None;
557 }
558 cx.notify();
559 }
560}
561
562impl SupermavenButtonStatus {
563 fn to_icon(&self) -> IconName {
564 match self {
565 SupermavenButtonStatus::Ready => IconName::Supermaven,
566 SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
567 SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
568 SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
569 }
570 }
571
572 fn to_tooltip(&self) -> String {
573 match self {
574 SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
575 SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
576 SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
577 SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
578 }
579 }
580
581 fn has_menu(&self) -> bool {
582 match self {
583 SupermavenButtonStatus::Ready | SupermavenButtonStatus::NeedsActivation(_) => true,
584 SupermavenButtonStatus::Errored(_) | SupermavenButtonStatus::Initializing => false,
585 }
586 }
587}
588
589async fn configure_disabled_globs(
590 workspace: WeakEntity<Workspace>,
591 path_to_disable: Option<Arc<Path>>,
592 mut cx: AsyncWindowContext,
593) -> Result<()> {
594 let settings_editor = workspace
595 .update_in(&mut cx, |_, window, cx| {
596 create_and_open_local_file(paths::settings_file(), window, cx, || {
597 settings::initial_user_settings_content().as_ref().into()
598 })
599 })?
600 .await?
601 .downcast::<Editor>()
602 .unwrap();
603
604 settings_editor
605 .downgrade()
606 .update_in(&mut cx, |item, window, cx| {
607 let text = item.buffer().read(cx).snapshot(cx).text();
608
609 let settings = cx.global::<SettingsStore>();
610 let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
611 let copilot = file.inline_completions.get_or_insert_with(Default::default);
612 let globs = copilot.disabled_globs.get_or_insert_with(|| {
613 settings
614 .get::<AllLanguageSettings>(None)
615 .inline_completions
616 .disabled_globs
617 .iter()
618 .map(|glob| glob.glob().to_string())
619 .collect()
620 });
621
622 if let Some(path_to_disable) = &path_to_disable {
623 globs.push(path_to_disable.to_string_lossy().into_owned());
624 } else {
625 globs.clear();
626 }
627 });
628
629 if !edits.is_empty() {
630 item.change_selections(Some(Autoscroll::newest()), window, cx, |selections| {
631 selections.select_ranges(edits.iter().map(|e| e.0.clone()));
632 });
633
634 // When *enabling* a path, don't actually perform an edit, just select the range.
635 if path_to_disable.is_some() {
636 item.edit(edits.iter().cloned(), cx);
637 }
638 }
639 })?;
640
641 anyhow::Ok(())
642}
643
644fn toggle_inline_completions_globally(fs: Arc<dyn Fs>, cx: &mut App) {
645 let show_inline_completions =
646 all_language_settings(None, cx).inline_completions_enabled(None, None, cx);
647 update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
648 file.defaults.show_inline_completions = Some(!show_inline_completions)
649 });
650}
651
652fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: InlineCompletionProvider) {
653 update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
654 file.features
655 .get_or_insert(Default::default())
656 .inline_completion_provider = Some(provider);
657 });
658}
659
660fn toggle_inline_completions_for_language(language: Arc<Language>, fs: Arc<dyn Fs>, cx: &mut App) {
661 let show_inline_completions =
662 all_language_settings(None, cx).inline_completions_enabled(Some(&language), None, cx);
663 update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
664 file.languages
665 .entry(language.name())
666 .or_default()
667 .show_inline_completions = Some(!show_inline_completions);
668 });
669}
670
671fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
672 update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
673 file.features
674 .get_or_insert(Default::default())
675 .inline_completion_provider = Some(InlineCompletionProvider::None);
676 });
677}