Update Copilot UI (#3904)

Nate Butler created

Updates Zed2 Copilot UI

- Introduces the `Headline` component for rendering large text
- Styles the 3 Copilot prompt states

![CleanShot 2024-01-08 at 13 19
09@2x](https://github.com/zed-industries/zed/assets/1714999/4b58b85f-23aa-4c22-9ca6-d0abd232cf18)
![CleanShot 2024-01-08 at 13 18
55@2x](https://github.com/zed-industries/zed/assets/1714999/0bf71e1d-63b5-4fbb-8672-e42f8602516d)


Release Notes:

- Updated the connect Copilot modal UI.

Change summary

Cargo.lock                              |   6 
Cargo.toml                              |   2 
crates/copilot/Cargo.toml               |   1 
crates/copilot/src/copilot.rs           |   3 
crates/copilot/src/sign_in.rs           | 211 ---------------------------
crates/copilot_ui/Cargo.toml            |   5 
crates/copilot_ui/src/copilot_button.rs |  14 +
crates/copilot_ui/src/copilot_ui.rs     |   5 
crates/copilot_ui/src/sign_in.rs        | 183 +++++++++++++++++++++++
crates/ui/src/prelude.rs                |   1 
crates/ui/src/styles/typography.rs      |  72 +++++++++
crates/zed/Cargo.toml                   |   2 
crates/zed/src/zed.rs                   |   3 
13 files changed, 279 insertions(+), 229 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1688,12 +1688,11 @@ dependencies = [
  "settings",
  "smol",
  "theme",
- "ui",
  "util",
 ]
 
 [[package]]
-name = "copilot_button"
+name = "copilot_ui"
 version = "0.1.0"
 dependencies = [
  "anyhow",
@@ -1706,6 +1705,7 @@ dependencies = [
  "settings",
  "smol",
  "theme",
+ "ui",
  "util",
  "workspace",
  "zed_actions",
@@ -9547,7 +9547,7 @@ dependencies = [
  "collections",
  "command_palette",
  "copilot",
- "copilot_button",
+ "copilot_ui",
  "ctor",
  "db",
  "diagnostics",

Cargo.toml 🔗

@@ -16,7 +16,7 @@ members = [
     "crates/collections",
     "crates/command_palette",
     "crates/copilot",
-    "crates/copilot_button",
+    "crates/copilot_ui",
     "crates/db",
     "crates/refineable",
     "crates/refineable/derive_refineable",

crates/copilot/Cargo.toml 🔗

@@ -28,7 +28,6 @@ theme = { path = "../theme" }
 lsp = { path = "../lsp" }
 node_runtime = { path = "../node_runtime"}
 util = { path = "../util" }
-ui = { path = "../ui" }
 async-compression.workspace = true
 async-tar = "0.4.2"
 anyhow.workspace = true

crates/copilot/src/copilot.rs 🔗

@@ -1,6 +1,4 @@
 pub mod request;
-mod sign_in;
-
 use anyhow::{anyhow, Context as _, Result};
 use async_compression::futures::bufread::GzipDecoder;
 use async_tar::Archive;
@@ -98,7 +96,6 @@ pub fn init(
     })
     .detach();
 
-    sign_in::init(cx);
     cx.on_action(|_: &SignIn, cx| {
         if let Some(copilot) = Copilot::global(cx) {
             copilot

crates/copilot/src/sign_in.rs 🔗

@@ -1,211 +0,0 @@
-use crate::{request::PromptUserDeviceFlow, Copilot, Status};
-use gpui::{
-    div, size, AppContext, Bounds, ClipboardItem, Element, GlobalPixels, InteractiveElement,
-    IntoElement, ParentElement, Point, Render, Styled, ViewContext, VisualContext, WindowBounds,
-    WindowHandle, WindowKind, WindowOptions,
-};
-use theme::ActiveTheme;
-use ui::{prelude::*, Button, Icon, IconElement, Label};
-
-const COPILOT_SIGN_UP_URL: &'static str = "https://github.com/features/copilot";
-
-pub fn init(cx: &mut AppContext) {
-    if let Some(copilot) = Copilot::global(cx) {
-        let mut verification_window: Option<WindowHandle<CopilotCodeVerification>> = None;
-        cx.observe(&copilot, move |copilot, cx| {
-            let status = copilot.read(cx).status();
-
-            match &status {
-                crate::Status::SigningIn { prompt } => {
-                    if let Some(window) = verification_window.as_mut() {
-                        let updated = window
-                            .update(cx, |verification, cx| {
-                                verification.set_status(status.clone(), cx);
-                                cx.activate_window();
-                            })
-                            .is_ok();
-                        if !updated {
-                            verification_window = Some(create_copilot_auth_window(cx, &status));
-                        }
-                    } else if let Some(_prompt) = prompt {
-                        verification_window = Some(create_copilot_auth_window(cx, &status));
-                    }
-                }
-                Status::Authorized | Status::Unauthorized => {
-                    if let Some(window) = verification_window.as_ref() {
-                        window
-                            .update(cx, |verification, cx| {
-                                verification.set_status(status, cx);
-                                cx.activate(true);
-                                cx.activate_window();
-                            })
-                            .ok();
-                    }
-                }
-                _ => {
-                    if let Some(code_verification) = verification_window.take() {
-                        code_verification
-                            .update(cx, |_, cx| cx.remove_window())
-                            .ok();
-                    }
-                }
-            }
-        })
-        .detach();
-    }
-}
-
-fn create_copilot_auth_window(
-    cx: &mut AppContext,
-    status: &Status,
-) -> WindowHandle<CopilotCodeVerification> {
-    let window_size = size(GlobalPixels::from(280.), GlobalPixels::from(280.));
-    let window_options = WindowOptions {
-        bounds: WindowBounds::Fixed(Bounds::new(Point::default(), window_size)),
-        titlebar: None,
-        center: true,
-        focus: true,
-        show: true,
-        kind: WindowKind::PopUp,
-        is_movable: true,
-        display_id: None,
-    };
-    let window = cx.open_window(window_options, |cx| {
-        cx.new_view(|_| CopilotCodeVerification::new(status.clone()))
-    });
-    window
-}
-
-pub struct CopilotCodeVerification {
-    status: Status,
-    connect_clicked: bool,
-}
-
-impl CopilotCodeVerification {
-    pub fn new(status: Status) -> Self {
-        Self {
-            status,
-            connect_clicked: false,
-        }
-    }
-
-    pub fn set_status(&mut self, status: Status, cx: &mut ViewContext<Self>) {
-        self.status = status;
-        cx.notify();
-    }
-
-    fn render_device_code(
-        data: &PromptUserDeviceFlow,
-        cx: &mut ViewContext<Self>,
-    ) -> impl IntoElement {
-        let copied = cx
-            .read_from_clipboard()
-            .map(|item| item.text() == &data.user_code)
-            .unwrap_or(false);
-        h_stack()
-            .cursor_pointer()
-            .justify_between()
-            .on_mouse_down(gpui::MouseButton::Left, {
-                let user_code = data.user_code.clone();
-                move |_, cx| {
-                    cx.write_to_clipboard(ClipboardItem::new(user_code.clone()));
-                    cx.notify();
-                }
-            })
-            .child(Label::new(data.user_code.clone()))
-            .child(div())
-            .child(Label::new(if copied { "Copied!" } else { "Copy" }))
-    }
-
-    fn render_prompting_modal(
-        connect_clicked: bool,
-        data: &PromptUserDeviceFlow,
-        cx: &mut ViewContext<Self>,
-    ) -> impl Element {
-        let connect_button_label = if connect_clicked {
-            "Waiting for connection..."
-        } else {
-            "Connect to Github"
-        };
-        v_stack()
-            .flex_1()
-            .items_center()
-            .justify_between()
-            .w_full()
-            .child(Label::new(
-                "Enable Copilot by connecting your existing license",
-            ))
-            .child(Self::render_device_code(data, cx))
-            .child(
-                Label::new("Paste this code into GitHub after clicking the button below.")
-                    .size(ui::LabelSize::Small),
-            )
-            .child(
-                Button::new("connect-button", connect_button_label).on_click({
-                    let verification_uri = data.verification_uri.clone();
-                    cx.listener(move |this, _, cx| {
-                        cx.open_url(&verification_uri);
-                        this.connect_clicked = true;
-                    })
-                }),
-            )
-    }
-    fn render_enabled_modal() -> impl Element {
-        v_stack()
-            .child(Label::new("Copilot Enabled!"))
-            .child(Label::new(
-                "You can update your settings or sign out from the Copilot menu in the status bar.",
-            ))
-            .child(
-                Button::new("copilot-enabled-done-button", "Done")
-                    .on_click(|_, cx| cx.remove_window()),
-            )
-    }
-
-    fn render_unauthorized_modal() -> impl Element {
-        v_stack()
-            .child(Label::new(
-                "Enable Copilot by connecting your existing license.",
-            ))
-            .child(
-                Label::new("You must have an active Copilot license to use it in Zed.")
-                    .color(Color::Warning),
-            )
-            .child(
-                Button::new("copilot-subscribe-button", "Subscibe on Github").on_click(|_, cx| {
-                    cx.remove_window();
-                    cx.open_url(COPILOT_SIGN_UP_URL)
-                }),
-            )
-    }
-}
-
-impl Render for CopilotCodeVerification {
-    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let prompt = match &self.status {
-            Status::SigningIn {
-                prompt: Some(prompt),
-            } => Self::render_prompting_modal(self.connect_clicked, &prompt, cx).into_any_element(),
-            Status::Unauthorized => {
-                self.connect_clicked = false;
-                Self::render_unauthorized_modal().into_any_element()
-            }
-            Status::Authorized => {
-                self.connect_clicked = false;
-                Self::render_enabled_modal().into_any_element()
-            }
-            _ => div().into_any_element(),
-        };
-        div()
-            .id("copilot code verification")
-            .flex()
-            .flex_col()
-            .size_full()
-            .items_center()
-            .p_10()
-            .bg(cx.theme().colors().element_background)
-            .child(ui::Label::new("Connect Copilot to Zed"))
-            .child(IconElement::new(Icon::ZedXCopilot))
-            .child(prompt)
-    }
-}

crates/copilot_button/Cargo.toml → crates/copilot_ui/Cargo.toml 🔗

@@ -1,11 +1,11 @@
 [package]
-name = "copilot_button"
+name = "copilot_ui"
 version = "0.1.0"
 edition = "2021"
 publish = false
 
 [lib]
-path = "src/copilot_button.rs"
+path = "src/copilot_ui.rs"
 doctest = false
 
 [dependencies]
@@ -17,6 +17,7 @@ gpui = { path = "../gpui" }
 language = { path = "../language" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
+ui = { path = "../ui" }
 util = { path = "../util" }
 workspace = {path = "../workspace" }
 anyhow.workspace = true

crates/copilot_button/src/copilot_button.rs → crates/copilot_ui/src/copilot_button.rs 🔗

@@ -1,3 +1,4 @@
+use crate::sign_in::CopilotCodeVerification;
 use anyhow::Result;
 use copilot::{Copilot, SignOut, Status};
 use editor::{scroll::autoscroll::Autoscroll, Editor};
@@ -331,7 +332,9 @@ fn initiate_sign_in(cx: &mut WindowContext) {
         return;
     };
     let status = copilot.read(cx).status();
-
+    let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
+        return;
+    };
     match status {
         Status::Starting { task } => {
             let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
@@ -370,9 +373,12 @@ fn initiate_sign_in(cx: &mut WindowContext) {
             .detach();
         }
         _ => {
-            copilot
-                .update(cx, |copilot, cx| copilot.sign_in(cx))
-                .detach_and_log_err(cx);
+            copilot.update(cx, |this, cx| this.sign_in(cx)).detach();
+            workspace
+                .update(cx, |this, cx| {
+                    this.toggle_modal(cx, |cx| CopilotCodeVerification::new(&copilot, cx));
+                })
+                .ok();
         }
     }
 }

crates/copilot_ui/src/sign_in.rs 🔗

@@ -0,0 +1,183 @@
+use copilot::{request::PromptUserDeviceFlow, Copilot, Status};
+use gpui::{
+    div, svg, AppContext, ClipboardItem, DismissEvent, Element, EventEmitter, FocusHandle,
+    FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Render, Styled,
+    Subscription, ViewContext,
+};
+use ui::{prelude::*, Button, Icon, Label};
+use workspace::ModalView;
+
+const COPILOT_SIGN_UP_URL: &'static str = "https://github.com/features/copilot";
+
+pub struct CopilotCodeVerification {
+    status: Status,
+    connect_clicked: bool,
+    focus_handle: FocusHandle,
+    _subscription: Subscription,
+}
+
+impl FocusableView for CopilotCodeVerification {
+    fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl EventEmitter<DismissEvent> for CopilotCodeVerification {}
+impl ModalView for CopilotCodeVerification {}
+
+impl CopilotCodeVerification {
+    pub(crate) fn new(copilot: &Model<Copilot>, cx: &mut ViewContext<Self>) -> Self {
+        let status = copilot.read(cx).status();
+        Self {
+            status,
+            connect_clicked: false,
+            focus_handle: cx.focus_handle(),
+            _subscription: cx.observe(copilot, |this, copilot, cx| {
+                let status = copilot.read(cx).status();
+                match status {
+                    Status::Authorized | Status::Unauthorized | Status::SigningIn { .. } => {
+                        this.set_status(status, cx)
+                    }
+                    _ => cx.emit(DismissEvent),
+                }
+            }),
+        }
+    }
+
+    pub fn set_status(&mut self, status: Status, cx: &mut ViewContext<Self>) {
+        self.status = status;
+        cx.notify();
+    }
+
+    fn render_device_code(
+        data: &PromptUserDeviceFlow,
+        cx: &mut ViewContext<Self>,
+    ) -> impl IntoElement {
+        let copied = cx
+            .read_from_clipboard()
+            .map(|item| item.text() == &data.user_code)
+            .unwrap_or(false);
+        h_stack()
+            .w_full()
+            .p_1()
+            .border()
+            .border_muted(cx)
+            .rounded_md()
+            .cursor_pointer()
+            .justify_between()
+            .on_mouse_down(gpui::MouseButton::Left, {
+                let user_code = data.user_code.clone();
+                move |_, cx| {
+                    cx.write_to_clipboard(ClipboardItem::new(user_code.clone()));
+                    cx.notify();
+                }
+            })
+            .child(div().flex_1().child(Label::new(data.user_code.clone())))
+            .child(div().flex_none().px_1().child(Label::new(if copied {
+                "Copied!"
+            } else {
+                "Copy"
+            })))
+    }
+
+    fn render_prompting_modal(
+        connect_clicked: bool,
+        data: &PromptUserDeviceFlow,
+        cx: &mut ViewContext<Self>,
+    ) -> impl Element {
+        let connect_button_label = if connect_clicked {
+            "Waiting for connection..."
+        } else {
+            "Connect to Github"
+        };
+        v_stack()
+            .flex_1()
+            .gap_2()
+            .items_center()
+            .child(Headline::new("Use Github Copilot in Zed.").size(HeadlineSize::Large))
+            .child(
+                Label::new("Using Copilot requres an active subscription on Github.")
+                    .color(Color::Muted),
+            )
+            .child(Self::render_device_code(data, cx))
+            .child(
+                Label::new("Paste this code into GitHub after clicking the button below.")
+                    .size(ui::LabelSize::Small),
+            )
+            .child(
+                Button::new("connect-button", connect_button_label)
+                    .on_click({
+                        let verification_uri = data.verification_uri.clone();
+                        cx.listener(move |this, _, cx| {
+                            cx.open_url(&verification_uri);
+                            this.connect_clicked = true;
+                        })
+                    })
+                    .full_width()
+                    .style(ButtonStyle::Filled),
+            )
+    }
+    fn render_enabled_modal(cx: &mut ViewContext<Self>) -> impl Element {
+        v_stack()
+            .gap_2()
+            .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large))
+            .child(Label::new(
+                "You can update your settings or sign out from the Copilot menu in the status bar.",
+            ))
+            .child(
+                Button::new("copilot-enabled-done-button", "Done")
+                    .full_width()
+                    .on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))),
+            )
+    }
+
+    fn render_unauthorized_modal() -> impl Element {
+        v_stack()
+            .child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large))
+
+            .child(Label::new(
+                "You can enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.",
+            ).color(Color::Warning))
+            .child(
+                Button::new("copilot-subscribe-button", "Subscibe on Github")
+                    .full_width()
+                    .on_click(|_, cx| cx.open_url(COPILOT_SIGN_UP_URL)),
+            )
+    }
+}
+
+impl Render for CopilotCodeVerification {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let prompt = match &self.status {
+            Status::SigningIn {
+                prompt: Some(prompt),
+            } => Self::render_prompting_modal(self.connect_clicked, &prompt, cx).into_any_element(),
+            Status::Unauthorized => {
+                self.connect_clicked = false;
+                Self::render_unauthorized_modal().into_any_element()
+            }
+            Status::Authorized => {
+                self.connect_clicked = false;
+                Self::render_enabled_modal(cx).into_any_element()
+            }
+            _ => div().into_any_element(),
+        };
+
+        v_stack()
+            .id("copilot code verification")
+            .elevation_3(cx)
+            .w_96()
+            .items_center()
+            .p_4()
+            .gap_2()
+            .child(
+                svg()
+                    .w_32()
+                    .h_16()
+                    .flex_none()
+                    .path(Icon::ZedXCopilot.path())
+                    .text_color(cx.theme().colors().icon),
+            )
+            .child(prompt)
+    }
+}

crates/ui/src/prelude.rs 🔗

@@ -14,6 +14,7 @@ pub use crate::visible_on_hover::*;
 pub use crate::{h_stack, v_stack};
 pub use crate::{Button, ButtonSize, ButtonStyle, IconButton, SelectableButton};
 pub use crate::{ButtonCommon, Color, StyledExt};
+pub use crate::{Headline, HeadlineSize};
 pub use crate::{Icon, IconElement, IconPosition, IconSize};
 pub use crate::{Label, LabelCommon, LabelSize, LineHeightStyle};
 pub use theme::ActiveTheme;

crates/ui/src/styles/typography.rs 🔗

@@ -1,4 +1,8 @@
-use gpui::{rems, Rems};
+use gpui::{
+    div, rems, IntoElement, ParentElement, Rems, RenderOnce, SharedString, Styled, WindowContext,
+};
+use settings::Settings;
+use theme::{ActiveTheme, ThemeSettings};
 
 #[derive(Debug, Default, Clone)]
 pub enum UiTextSize {
@@ -33,3 +37,69 @@ impl UiTextSize {
         }
     }
 }
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
+pub enum HeadlineSize {
+    XSmall,
+    Small,
+    #[default]
+    Medium,
+    Large,
+    XLarge,
+}
+
+impl HeadlineSize {
+    pub fn size(self) -> Rems {
+        match self {
+            // Based on the Major Second scale
+            Self::XSmall => rems(0.88),
+            Self::Small => rems(1.0),
+            Self::Medium => rems(1.125),
+            Self::Large => rems(1.27),
+            Self::XLarge => rems(1.43),
+        }
+    }
+
+    pub fn line_height(self) -> Rems {
+        match self {
+            Self::XSmall => rems(1.6),
+            Self::Small => rems(1.6),
+            Self::Medium => rems(1.6),
+            Self::Large => rems(1.6),
+            Self::XLarge => rems(1.6),
+        }
+    }
+}
+
+#[derive(IntoElement)]
+pub struct Headline {
+    size: HeadlineSize,
+    text: SharedString,
+}
+
+impl RenderOnce for Headline {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
+
+        div()
+            .font(ui_font)
+            .line_height(self.size.line_height())
+            .text_size(self.size.size())
+            .text_color(cx.theme().colors().text)
+            .child(self.text)
+    }
+}
+
+impl Headline {
+    pub fn new(text: impl Into<SharedString>) -> Self {
+        Self {
+            size: HeadlineSize::default(),
+            text: text.into(),
+        }
+    }
+
+    pub fn size(mut self, size: HeadlineSize) -> Self {
+        self.size = size;
+        self
+    }
+}

crates/zed/Cargo.toml 🔗

@@ -30,7 +30,7 @@ command_palette = { path = "../command_palette" }
 client = { path = "../client" }
 # clock = { path = "../clock" }
 copilot = { path = "../copilot" }
-copilot_button = { path = "../copilot_button" }
+copilot_ui = { path = "../copilot_ui" }
 diagnostics = { path = "../diagnostics" }
 db = { path = "../db" }
 editor = { path = "../editor" }

crates/zed/src/zed.rs 🔗

@@ -120,8 +120,7 @@ pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut AppContext) {
         //         cx.add_view(|cx| CollabTitlebarItem::new(workspace, &workspace_handle, cx));
         //     workspace.set_titlebar_item(collab_titlebar_item.into_any(), cx);
 
-        let copilot =
-            cx.new_view(|cx| copilot_button::CopilotButton::new(app_state.fs.clone(), cx));
+        let copilot = cx.new_view(|cx| copilot_ui::CopilotButton::new(app_state.fs.clone(), cx));
         let diagnostic_summary =
             cx.new_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx));
         let activity_indicator =