Enable configuring edit prediction providers through the settings UI (#44505)

- Edit prediction providers can now be configured through the settings
UI
- Cleaned up the status bar menu to only show _configured_ providers
- Added to the status bar icon button tooltip the name of the active
provider
- Only display the data collection functionality under "Privacy" for the
Zed models
- Moved the Codestral edit prediction provider out of the Mistral
section in the agent panel into the settings UI
- Refined and improved UI and states for configuring GitHub Copilot as
both an agent and edit prediction provider

#### Todos before merge:

- [x] UI: Unify with settings UI style and tidy it all up
- [x] Unify Copilot modal `impl`s to use separate window
- [x] Remove stop light icons from GitHub modal
- [x] Make dismiss events work on GitHub modal
- [ ] Investigate workarounds to tell if Copilot authenticated even when
LSP not running


Release Notes:

- settings_ui: Added a section for configuring edit prediction providers
under AI > Edit Predictions, including Codestral and GitHub Copilot.
Once you've updated you can use the following link to open it:
zed://settings/edit_predictions.providers

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
This commit is contained in:
Danilo Leal
2025-12-13 13:06:30 -03:00
committed by GitHub
parent 56daba28d4
commit 0283bfb049
55 changed files with 1910 additions and 1578 deletions

8
Cargo.lock generated
View File

@@ -5111,7 +5111,6 @@ dependencies = [
"cloud_llm_client",
"collections",
"copilot",
"credentials_provider",
"ctor",
"db",
"edit_prediction_context",
@@ -5275,7 +5274,6 @@ dependencies = [
"text",
"theme",
"ui",
"ui_input",
"util",
"workspace",
"zed_actions",
@@ -8802,6 +8800,7 @@ dependencies = [
"cloud_api_types",
"cloud_llm_client",
"collections",
"credentials_provider",
"futures 0.3.31",
"gpui",
"http_client",
@@ -8820,6 +8819,7 @@ dependencies = [
"telemetry_events",
"thiserror 2.0.17",
"util",
"zed_env_vars",
]
[[package]]
@@ -8876,7 +8876,6 @@ dependencies = [
"util",
"vercel",
"x_ai",
"zed_env_vars",
]
[[package]]
@@ -14778,6 +14777,8 @@ dependencies = [
"assets",
"bm25",
"client",
"copilot",
"edit_prediction",
"editor",
"feature_flags",
"fs",
@@ -14786,6 +14787,7 @@ dependencies = [
"gpui",
"heck 0.5.0",
"language",
"language_models",
"log",
"menu",
"node_runtime",

View File

@@ -1410,8 +1410,9 @@
"proxy_no_verify": null,
},
"codestral": {
"model": null,
"max_tokens": null,
"api_url": "https://codestral.mistral.ai",
"model": "codestral-latest",
"max_tokens": 150,
},
// Whether edit predictions are enabled when editing text threads in the agent panel.
// This setting has no effect if globally disabled.

View File

@@ -34,9 +34,9 @@ use project::{
};
use settings::{Settings, SettingsStore, update_settings_file};
use ui::{
Button, ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure,
Divider, DividerColor, ElevationIndex, IconName, IconPosition, IconSize, Indicator, LabelSize,
PopoverMenu, Switch, Tooltip, WithScrollbar, prelude::*,
ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider,
DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip,
WithScrollbar, prelude::*,
};
use util::ResultExt as _;
use workspace::{Workspace, create_and_open_local_file};

View File

@@ -4,7 +4,7 @@ pub mod copilot_responses;
pub mod request;
mod sign_in;
use crate::sign_in::initiate_sign_in_within_workspace;
use crate::sign_in::initiate_sign_out;
use ::fs::Fs;
use anyhow::{Context as _, Result, anyhow};
use collections::{HashMap, HashSet};
@@ -28,12 +28,10 @@ use project::DisableAiSettings;
use request::StatusNotification;
use semver::Version;
use serde_json::json;
use settings::Settings;
use settings::SettingsStore;
use sign_in::{reinstall_and_sign_in_within_workspace, sign_out_within_workspace};
use std::collections::hash_map::Entry;
use settings::{Settings, SettingsStore};
use std::{
any::TypeId,
collections::hash_map::Entry,
env,
ffi::OsString,
mem,
@@ -42,12 +40,14 @@ use std::{
sync::Arc,
};
use sum_tree::Dimensions;
use util::rel_path::RelPath;
use util::{ResultExt, fs::remove_matching};
use util::{ResultExt, fs::remove_matching, rel_path::RelPath};
use workspace::Workspace;
pub use crate::copilot_edit_prediction_delegate::CopilotEditPredictionDelegate;
pub use crate::sign_in::{CopilotCodeVerification, initiate_sign_in, reinstall_and_sign_in};
pub use crate::sign_in::{
ConfigurationMode, ConfigurationView, CopilotCodeVerification, initiate_sign_in,
reinstall_and_sign_in,
};
actions!(
copilot,
@@ -98,21 +98,14 @@ pub fn init(
.detach();
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
workspace.register_action(|workspace, _: &SignIn, window, cx| {
if let Some(copilot) = Copilot::global(cx) {
let is_reinstall = false;
initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx);
}
workspace.register_action(|_, _: &SignIn, window, cx| {
initiate_sign_in(window, cx);
});
workspace.register_action(|workspace, _: &Reinstall, window, cx| {
if let Some(copilot) = Copilot::global(cx) {
reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx);
}
workspace.register_action(|_, _: &Reinstall, window, cx| {
reinstall_and_sign_in(window, cx);
});
workspace.register_action(|workspace, _: &SignOut, _window, cx| {
if let Some(copilot) = Copilot::global(cx) {
sign_out_within_workspace(workspace, copilot, cx);
}
workspace.register_action(|_, _: &SignOut, window, cx| {
initiate_sign_out(window, cx);
});
})
.detach();
@@ -375,7 +368,7 @@ impl Copilot {
}
}
fn start_copilot(
pub fn start_copilot(
&mut self,
check_edit_prediction_provider: bool,
awaiting_sign_in_after_start: bool,
@@ -563,6 +556,14 @@ impl Copilot {
let server = start_language_server.await;
this.update(cx, |this, cx| {
cx.notify();
if env::var("ZED_FORCE_COPILOT_ERROR").is_ok() {
this.server = CopilotServer::Error(
"Forced error for testing (ZED_FORCE_COPILOT_ERROR)".into(),
);
return;
}
match server {
Ok((server, status)) => {
this.server = CopilotServer::Running(RunningCopilotServer {
@@ -584,7 +585,17 @@ impl Copilot {
.ok();
}
pub(crate) fn sign_in(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
pub fn is_authenticated(&self) -> bool {
return matches!(
self.server,
CopilotServer::Running(RunningCopilotServer {
sign_in_status: SignInStatus::Authorized,
..
})
);
}
pub fn sign_in(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
if let CopilotServer::Running(server) = &mut self.server {
let task = match &server.sign_in_status {
SignInStatus::Authorized => Task::ready(Ok(())).shared(),

View File

@@ -1,160 +1,151 @@
use crate::{Copilot, Status, request::PromptUserDeviceFlow};
use anyhow::Context as _;
use gpui::{
Animation, AnimationExt, App, ClipboardItem, Context, DismissEvent, Element, Entity,
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseDownEvent,
ParentElement, Render, Styled, Subscription, Transformation, Window, div, percentage, svg,
App, ClipboardItem, Context, DismissEvent, Element, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, MouseDownEvent, ParentElement, Render, Styled,
Subscription, Window, WindowBounds, WindowOptions, div, point,
};
use std::time::Duration;
use ui::{Button, Label, Vector, VectorName, prelude::*};
use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*};
use util::ResultExt as _;
use workspace::notifications::NotificationId;
use workspace::{ModalView, Toast, Workspace};
use workspace::{Toast, Workspace, notifications::NotificationId};
const COPILOT_SIGN_UP_URL: &str = "https://github.com/features/copilot";
const ERROR_LABEL: &str =
"Copilot had issues starting. You can try reinstalling it and signing in again.";
struct CopilotStatusToast;
pub fn initiate_sign_in(window: &mut Window, cx: &mut App) {
let is_reinstall = false;
initiate_sign_in_impl(is_reinstall, window, cx)
}
pub fn initiate_sign_out(window: &mut Window, cx: &mut App) {
let Some(copilot) = Copilot::global(cx) else {
return;
};
let Some(workspace) = window.root::<Workspace>().flatten() else {
return;
};
workspace.update(cx, |workspace, cx| {
let is_reinstall = false;
initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx)
});
copilot_toast(Some("Signing out of Copilot…"), window, cx);
let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx));
window
.spawn(cx, async move |cx| match sign_out_task.await {
Ok(()) => {
cx.update(|window, cx| copilot_toast(Some("Signed out of Copilot"), window, cx))
}
Err(err) => cx.update(|window, cx| {
if let Some(workspace) = window.root::<Workspace>().flatten() {
workspace.update(cx, |workspace, cx| {
workspace.show_error(&err, cx);
})
} else {
log::error!("{:?}", err);
}
}),
})
.detach();
}
pub fn reinstall_and_sign_in(window: &mut Window, cx: &mut App) {
let Some(copilot) = Copilot::global(cx) else {
return;
};
let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx));
let is_reinstall = true;
initiate_sign_in_impl(is_reinstall, window, cx);
}
fn open_copilot_code_verification_window(copilot: &Entity<Copilot>, window: &Window, cx: &mut App) {
let current_window_center = window.bounds().center();
let height = px(450.);
let width = px(350.);
let window_bounds = WindowBounds::Windowed(gpui::bounds(
current_window_center - point(height / 2.0, width / 2.0),
gpui::size(height, width),
));
cx.open_window(
WindowOptions {
kind: gpui::WindowKind::PopUp,
window_bounds: Some(window_bounds),
is_resizable: false,
is_movable: true,
titlebar: Some(gpui::TitlebarOptions {
appears_transparent: true,
..Default::default()
}),
..Default::default()
},
|window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)),
)
.context("Failed to open Copilot code verification window")
.log_err();
}
fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) {
const NOTIFICATION_ID: NotificationId = NotificationId::unique::<CopilotStatusToast>();
let Some(workspace) = window.root::<Workspace>().flatten() else {
return;
};
workspace.update(cx, |workspace, cx| {
reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx);
workspace.update(cx, |workspace, cx| match message {
Some(message) => workspace.show_toast(Toast::new(NOTIFICATION_ID, message), cx),
None => workspace.dismiss_toast(&NOTIFICATION_ID, cx),
});
}
pub fn reinstall_and_sign_in_within_workspace(
workspace: &mut Workspace,
copilot: Entity<Copilot>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx));
let is_reinstall = true;
initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx);
}
pub fn initiate_sign_in_within_workspace(
workspace: &mut Workspace,
copilot: Entity<Copilot>,
is_reinstall: bool,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
pub fn initiate_sign_in_impl(is_reinstall: bool, window: &mut Window, cx: &mut App) {
let Some(copilot) = Copilot::global(cx) else {
return;
};
if matches!(copilot.read(cx).status(), Status::Disabled) {
copilot.update(cx, |copilot, cx| copilot.start_copilot(false, true, cx));
}
match copilot.read(cx).status() {
Status::Starting { task } => {
workspace.show_toast(
Toast::new(
NotificationId::unique::<CopilotStatusToast>(),
if is_reinstall {
"Copilot is reinstalling..."
} else {
"Copilot is starting..."
},
),
copilot_toast(
Some(if is_reinstall {
"Copilot is reinstalling…"
} else {
"Copilot is starting"
}),
window,
cx,
);
cx.spawn_in(window, async move |workspace, cx| {
task.await;
if let Some(copilot) = cx.update(|_window, cx| Copilot::global(cx)).ok().flatten() {
workspace
.update_in(cx, |workspace, window, cx| {
match copilot.read(cx).status() {
Status::Authorized => workspace.show_toast(
Toast::new(
NotificationId::unique::<CopilotStatusToast>(),
"Copilot has started.",
),
cx,
),
_ => {
workspace.dismiss_toast(
&NotificationId::unique::<CopilotStatusToast>(),
cx,
);
copilot
.update(cx, |copilot, cx| copilot.sign_in(cx))
.detach_and_log_err(cx);
workspace.toggle_modal(window, cx, |_, cx| {
CopilotCodeVerification::new(&copilot, cx)
});
}
window
.spawn(cx, async move |cx| {
task.await;
cx.update(|window, cx| {
let Some(copilot) = Copilot::global(cx) else {
return;
};
match copilot.read(cx).status() {
Status::Authorized => {
copilot_toast(Some("Copilot has started."), window, cx)
}
})
.log_err();
}
})
.detach();
_ => {
copilot_toast(None, window, cx);
copilot
.update(cx, |copilot, cx| copilot.sign_in(cx))
.detach_and_log_err(cx);
open_copilot_code_verification_window(&copilot, window, cx);
}
}
})
.log_err();
})
.detach();
}
_ => {
copilot
.update(cx, |copilot, cx| copilot.sign_in(cx))
.detach();
workspace.toggle_modal(window, cx, |_, cx| {
CopilotCodeVerification::new(&copilot, cx)
});
open_copilot_code_verification_window(&copilot, window, cx);
}
}
}
pub fn sign_out_within_workspace(
workspace: &mut Workspace,
copilot: Entity<Copilot>,
cx: &mut Context<Workspace>,
) {
workspace.show_toast(
Toast::new(
NotificationId::unique::<CopilotStatusToast>(),
"Signing out of Copilot...",
),
cx,
);
let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx));
cx.spawn(async move |workspace, cx| match sign_out_task.await {
Ok(()) => {
workspace
.update(cx, |workspace, cx| {
workspace.show_toast(
Toast::new(
NotificationId::unique::<CopilotStatusToast>(),
"Signed out of Copilot.",
),
cx,
)
})
.ok();
}
Err(err) => {
workspace
.update(cx, |workspace, cx| {
workspace.show_error(&err, cx);
})
.ok();
}
})
.detach();
}
pub struct CopilotCodeVerification {
status: Status,
connect_clicked: bool,
@@ -170,23 +161,27 @@ impl Focusable for CopilotCodeVerification {
}
impl EventEmitter<DismissEvent> for CopilotCodeVerification {}
impl ModalView for CopilotCodeVerification {
fn on_before_dismiss(
&mut self,
_: &mut Window,
cx: &mut Context<Self>,
) -> workspace::DismissDecision {
self.copilot.update(cx, |copilot, cx| {
if matches!(copilot.status(), Status::SigningIn { .. }) {
copilot.sign_out(cx).detach_and_log_err(cx);
}
});
workspace::DismissDecision::Dismiss(true)
}
}
impl CopilotCodeVerification {
pub fn new(copilot: &Entity<Copilot>, cx: &mut Context<Self>) -> Self {
pub fn new(copilot: &Entity<Copilot>, window: &mut Window, cx: &mut Context<Self>) -> Self {
window.on_window_should_close(cx, |window, cx| {
if let Some(this) = window.root::<CopilotCodeVerification>().flatten() {
this.update(cx, |this, cx| {
this.before_dismiss(cx);
});
}
true
});
cx.subscribe_in(
&cx.entity(),
window,
|this, _, _: &DismissEvent, window, cx| {
window.remove_window();
this.before_dismiss(cx);
},
)
.detach();
let status = copilot.read(cx).status();
Self {
status,
@@ -215,45 +210,45 @@ impl CopilotCodeVerification {
.read_from_clipboard()
.map(|item| item.text().as_ref() == Some(&data.user_code))
.unwrap_or(false);
h_flex()
.w_full()
.p_1()
.border_1()
.border_muted(cx)
.rounded_sm()
.cursor_pointer()
.justify_between()
.on_mouse_down(gpui::MouseButton::Left, {
ButtonLike::new("copy-button")
.full_width()
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
.size(ButtonSize::Medium)
.child(
h_flex()
.w_full()
.p_1()
.justify_between()
.child(Label::new(data.user_code.clone()))
.child(Label::new(if copied { "Copied!" } else { "Copy" })),
)
.on_click({
let user_code = data.user_code.clone();
move |_, window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(user_code.clone()));
window.refresh();
}
})
.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 Context<Self>,
) -> impl Element {
let connect_button_label = if connect_clicked {
"Waiting for connection..."
"Waiting for connection"
} else {
"Connect to GitHub"
};
v_flex()
.flex_1()
.gap_2()
.gap_2p5()
.items_center()
.child(Headline::new("Use GitHub Copilot in Zed.").size(HeadlineSize::Large))
.text_center()
.child(Headline::new("Use GitHub Copilot in Zed").size(HeadlineSize::Large))
.child(
Label::new("Using Copilot requires an active subscription on GitHub.")
.color(Color::Muted),
@@ -261,83 +256,119 @@ impl CopilotCodeVerification {
.child(Self::render_device_code(data, cx))
.child(
Label::new("Paste this code into GitHub after clicking the button below.")
.size(ui::LabelSize::Small),
.color(Color::Muted),
)
.child(
Button::new("connect-button", connect_button_label)
.on_click({
let verification_uri = data.verification_uri.clone();
cx.listener(move |this, _, _window, cx| {
cx.open_url(&verification_uri);
this.connect_clicked = true;
})
})
.full_width()
.style(ButtonStyle::Filled),
)
.child(
Button::new("copilot-enable-cancel-button", "Cancel")
.full_width()
.on_click(cx.listener(|_, _, _, cx| {
cx.emit(DismissEvent);
})),
v_flex()
.w_full()
.gap_1()
.child(
Button::new("connect-button", connect_button_label)
.full_width()
.style(ButtonStyle::Outlined)
.size(ButtonSize::Medium)
.on_click({
let verification_uri = data.verification_uri.clone();
cx.listener(move |this, _, _window, cx| {
cx.open_url(&verification_uri);
this.connect_clicked = true;
})
}),
)
.child(
Button::new("copilot-enable-cancel-button", "Cancel")
.full_width()
.size(ButtonSize::Medium)
.on_click(cx.listener(|_, _, _, cx| {
cx.emit(DismissEvent);
})),
),
)
}
fn render_enabled_modal(cx: &mut Context<Self>) -> impl Element {
v_flex()
.gap_2()
.text_center()
.justify_center()
.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(Label::new("You're all set to use GitHub Copilot.").color(Color::Muted))
.child(
Button::new("copilot-enabled-done-button", "Done")
.full_width()
.style(ButtonStyle::Outlined)
.size(ButtonSize::Medium)
.on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
)
}
fn render_unauthorized_modal(cx: &mut Context<Self>) -> impl Element {
v_flex()
.child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large))
let description = "Enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.";
.child(Label::new(
"You can enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.",
).color(Color::Warning))
v_flex()
.gap_2()
.text_center()
.justify_center()
.child(
Headline::new("You must have an active GitHub Copilot subscription.")
.size(HeadlineSize::Large),
)
.child(Label::new(description).color(Color::Warning))
.child(
Button::new("copilot-subscribe-button", "Subscribe on GitHub")
.full_width()
.style(ButtonStyle::Outlined)
.size(ButtonSize::Medium)
.on_click(|_, _, cx| cx.open_url(COPILOT_SIGN_UP_URL)),
)
.child(
Button::new("copilot-subscribe-cancel-button", "Cancel")
.full_width()
.size(ButtonSize::Medium)
.on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
)
}
fn render_loading(window: &mut Window, _: &mut Context<Self>) -> impl Element {
let loading_icon = svg()
.size_8()
.path(IconName::ArrowCircle.path())
.text_color(window.text_style().color)
.with_animation(
"icon_circle_arrow",
Animation::new(Duration::from_secs(2)).repeat(),
|svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))),
);
fn render_error_modal(_cx: &mut Context<Self>) -> impl Element {
v_flex()
.gap_2()
.text_center()
.justify_center()
.child(Headline::new("An Error Happened").size(HeadlineSize::Large))
.child(Label::new(ERROR_LABEL).color(Color::Muted))
.child(
Button::new("copilot-subscribe-button", "Reinstall Copilot and Sign In")
.full_width()
.style(ButtonStyle::Outlined)
.size(ButtonSize::Medium)
.icon(IconName::Download)
.icon_color(Color::Muted)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.on_click(|_, window, cx| reinstall_and_sign_in(window, cx)),
)
}
h_flex().justify_center().child(loading_icon)
fn before_dismiss(
&mut self,
cx: &mut Context<'_, CopilotCodeVerification>,
) -> workspace::DismissDecision {
self.copilot.update(cx, |copilot, cx| {
if matches!(copilot.status(), Status::SigningIn { .. }) {
copilot.sign_out(cx).detach_and_log_err(cx);
}
});
workspace::DismissDecision::Dismiss(true)
}
}
impl Render for CopilotCodeVerification {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let prompt = match &self.status {
Status::SigningIn { prompt: None } => {
Self::render_loading(window, cx).into_any_element()
}
Status::SigningIn { prompt: None } => Icon::new(IconName::ArrowCircle)
.color(Color::Muted)
.with_rotate_animation(2)
.into_any_element(),
Status::SigningIn {
prompt: Some(prompt),
} => Self::render_prompting_modal(self.connect_clicked, prompt, cx).into_any_element(),
@@ -349,17 +380,20 @@ impl Render for CopilotCodeVerification {
self.connect_clicked = false;
Self::render_enabled_modal(cx).into_any_element()
}
Status::Error(..) => Self::render_error_modal(cx).into_any_element(),
_ => div().into_any_element(),
};
v_flex()
.id("copilot code verification")
.id("copilot_code_verification")
.track_focus(&self.focus_handle(cx))
.elevation_3(cx)
.w_96()
.items_center()
.p_4()
.size_full()
.px_4()
.py_8()
.gap_2()
.items_center()
.justify_center()
.elevation_3(cx)
.on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
cx.emit(DismissEvent);
}))
@@ -373,3 +407,243 @@ impl Render for CopilotCodeVerification {
.child(prompt)
}
}
pub struct ConfigurationView {
copilot_status: Option<Status>,
is_authenticated: fn(cx: &App) -> bool,
edit_prediction: bool,
_subscription: Option<Subscription>,
}
pub enum ConfigurationMode {
Chat,
EditPrediction,
}
impl ConfigurationView {
pub fn new(
is_authenticated: fn(cx: &App) -> bool,
mode: ConfigurationMode,
cx: &mut Context<Self>,
) -> Self {
let copilot = Copilot::global(cx);
Self {
copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()),
is_authenticated,
edit_prediction: matches!(mode, ConfigurationMode::EditPrediction),
_subscription: copilot.as_ref().map(|copilot| {
cx.observe(copilot, |this, model, cx| {
this.copilot_status = Some(model.read(cx).status());
cx.notify();
})
}),
}
}
}
impl ConfigurationView {
fn is_starting(&self) -> bool {
matches!(&self.copilot_status, Some(Status::Starting { .. }))
}
fn is_signing_in(&self) -> bool {
matches!(
&self.copilot_status,
Some(Status::SigningIn { .. })
| Some(Status::SignedOut {
awaiting_signing_in: true
})
)
}
fn is_error(&self) -> bool {
matches!(&self.copilot_status, Some(Status::Error(_)))
}
fn has_no_status(&self) -> bool {
self.copilot_status.is_none()
}
fn loading_message(&self) -> Option<SharedString> {
if self.is_starting() {
Some("Starting Copilot…".into())
} else if self.is_signing_in() {
Some("Signing into Copilot…".into())
} else {
None
}
}
fn render_loading_button(
&self,
label: impl Into<SharedString>,
edit_prediction: bool,
) -> impl IntoElement {
ButtonLike::new("loading_button")
.disabled(true)
.style(ButtonStyle::Outlined)
.when(edit_prediction, |this| this.size(ButtonSize::Medium))
.child(
h_flex()
.w_full()
.gap_1()
.justify_center()
.child(
Icon::new(IconName::ArrowCircle)
.size(IconSize::Small)
.color(Color::Muted)
.with_rotate_animation(4),
)
.child(Label::new(label)),
)
}
fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement {
let label = if edit_prediction {
"Sign in to GitHub"
} else {
"Sign in to use GitHub Copilot"
};
Button::new("sign_in", label)
.map(|this| {
if edit_prediction {
this.size(ButtonSize::Medium)
} else {
this.full_width()
}
})
.style(ButtonStyle::Outlined)
.icon(IconName::Github)
.icon_color(Color::Muted)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.on_click(|_, window, cx| initiate_sign_in(window, cx))
}
fn render_reinstall_button(&self, edit_prediction: bool) -> impl IntoElement {
let label = if edit_prediction {
"Reinstall and Sign in"
} else {
"Reinstall Copilot and Sign in"
};
Button::new("reinstall_and_sign_in", label)
.map(|this| {
if edit_prediction {
this.size(ButtonSize::Medium)
} else {
this.full_width()
}
})
.style(ButtonStyle::Outlined)
.icon(IconName::Download)
.icon_color(Color::Muted)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.on_click(|_, window, cx| reinstall_and_sign_in(window, cx))
}
fn render_for_edit_prediction(&self) -> impl IntoElement {
let container = |description: SharedString, action: AnyElement| {
h_flex()
.pt_2p5()
.w_full()
.justify_between()
.child(
v_flex()
.w_full()
.max_w_1_2()
.child(Label::new("Authenticate To Use"))
.child(
Label::new(description)
.color(Color::Muted)
.size(LabelSize::Small),
),
)
.child(action)
};
let start_label = "To use Copilot for edit predictions, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot subscription.".into();
let no_status_label = "Copilot requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different edit predictions provider.".into();
if let Some(msg) = self.loading_message() {
container(
start_label,
self.render_loading_button(msg, true).into_any_element(),
)
.into_any_element()
} else if self.is_error() {
container(
ERROR_LABEL.into(),
self.render_reinstall_button(true).into_any_element(),
)
.into_any_element()
} else if self.has_no_status() {
container(
no_status_label,
self.render_sign_in_button(true).into_any_element(),
)
.into_any_element()
} else {
container(
start_label,
self.render_sign_in_button(true).into_any_element(),
)
.into_any_element()
}
}
fn render_for_chat(&self) -> impl IntoElement {
let start_label = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription.";
let no_status_label = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different LLM provider.";
if let Some(msg) = self.loading_message() {
v_flex()
.gap_2()
.child(Label::new(start_label))
.child(self.render_loading_button(msg, false))
.into_any_element()
} else if self.is_error() {
v_flex()
.gap_2()
.child(Label::new(ERROR_LABEL))
.child(self.render_reinstall_button(false))
.into_any_element()
} else if self.has_no_status() {
v_flex()
.gap_2()
.child(Label::new(no_status_label))
.child(self.render_sign_in_button(false))
.into_any_element()
} else {
v_flex()
.gap_2()
.child(Label::new(start_label))
.child(self.render_sign_in_button(false))
.into_any_element()
}
}
}
impl Render for ConfigurationView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_authenticated = self.is_authenticated;
if is_authenticated(cx) {
return ConfiguredApiCard::new("Authorized")
.button_label("Sign Out")
.on_click(|_, window, cx| {
initiate_sign_out(window, cx);
})
.into_any_element();
}
if self.edit_prediction {
self.render_for_edit_prediction().into_any_element()
} else {
self.render_for_chat().into_any_element()
}
}
}

View File

@@ -23,7 +23,6 @@ client.workspace = true
cloud_llm_client.workspace = true
collections.workspace = true
copilot.workspace = true
credentials_provider.workspace = true
db.workspace = true
edit_prediction_types.workspace = true
edit_prediction_context.workspace = true

View File

@@ -72,6 +72,7 @@ pub use crate::prediction::EditPrediction;
pub use crate::prediction::EditPredictionId;
use crate::prediction::EditPredictionResult;
pub use crate::sweep_ai::SweepAi;
pub use language_model::ApiKeyState;
pub use telemetry_events::EditPredictionRating;
pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate;
@@ -536,22 +537,12 @@ impl EditPredictionStore {
self.edit_prediction_model = model;
}
pub fn has_sweep_api_token(&self) -> bool {
self.sweep_ai
.api_token
.clone()
.now_or_never()
.flatten()
.is_some()
pub fn has_sweep_api_token(&self, cx: &App) -> bool {
self.sweep_ai.api_token.read(cx).has_key()
}
pub fn has_mercury_api_token(&self) -> bool {
self.mercury
.api_token
.clone()
.now_or_never()
.flatten()
.is_some()
pub fn has_mercury_api_token(&self, cx: &App) -> bool {
self.mercury.api_token.read(cx).has_key()
}
#[cfg(feature = "cli-support")]

View File

@@ -1,40 +1,34 @@
use anyhow::{Context as _, Result};
use credentials_provider::CredentialsProvider;
use futures::{AsyncReadExt as _, FutureExt, future::Shared};
use gpui::{
App, AppContext as _, Task,
http_client::{self, AsyncBody, Method},
};
use language::{OffsetRangeExt as _, ToOffset, ToPoint as _};
use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant};
use zeta_prompt::ZetaPromptInput;
use crate::{
DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput,
EditPredictionStartedDebugEvent, open_ai_response::text_from_response,
prediction::EditPredictionResult,
};
use anyhow::{Context as _, Result};
use futures::AsyncReadExt as _;
use gpui::{
App, AppContext as _, Entity, SharedString, Task,
http_client::{self, AsyncBody, Method},
};
use language::{OffsetRangeExt as _, ToOffset, ToPoint as _};
use language_model::{ApiKeyState, EnvVar, env_var};
use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant};
use zeta_prompt::ZetaPromptInput;
const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions";
const MAX_CONTEXT_TOKENS: usize = 150;
const MAX_REWRITE_TOKENS: usize = 350;
pub struct Mercury {
pub api_token: Shared<Task<Option<String>>>,
pub api_token: Entity<ApiKeyState>,
}
impl Mercury {
pub fn new(cx: &App) -> Self {
pub fn new(cx: &mut App) -> Self {
Mercury {
api_token: load_api_token(cx).shared(),
api_token: mercury_api_token(cx),
}
}
pub fn set_api_token(&mut self, api_token: Option<String>, cx: &mut App) -> Task<Result<()>> {
self.api_token = Task::ready(api_token.clone()).shared();
store_api_token_in_keychain(api_token, cx)
}
pub(crate) fn request_prediction(
&self,
EditPredictionModelInput {
@@ -48,7 +42,10 @@ impl Mercury {
}: EditPredictionModelInput,
cx: &mut App,
) -> Task<Result<Option<EditPredictionResult>>> {
let Some(api_token) = self.api_token.clone().now_or_never().flatten() else {
self.api_token.update(cx, |key_state, cx| {
_ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx);
});
let Some(api_token) = self.api_token.read(cx).key(&MERCURY_CREDENTIALS_URL) else {
return Task::ready(Ok(None));
};
let full_path: Arc<Path> = snapshot
@@ -299,45 +296,16 @@ fn push_delimited(prompt: &mut String, delimiters: Range<&str>, cb: impl FnOnce(
prompt.push_str(delimiters.end);
}
pub const MERCURY_CREDENTIALS_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions";
pub const MERCURY_CREDENTIALS_URL: SharedString =
SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions");
pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token";
pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("MERCURY_AI_TOKEN");
pub static MERCURY_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
pub fn load_api_token(cx: &App) -> Task<Option<String>> {
if let Some(api_token) = std::env::var("MERCURY_AI_TOKEN")
.ok()
.filter(|value| !value.is_empty())
{
return Task::ready(Some(api_token));
}
let credentials_provider = <dyn CredentialsProvider>::global(cx);
cx.spawn(async move |cx| {
let (_, credentials) = credentials_provider
.read_credentials(MERCURY_CREDENTIALS_URL, &cx)
.await
.ok()??;
String::from_utf8(credentials).ok()
})
}
fn store_api_token_in_keychain(api_token: Option<String>, cx: &App) -> Task<Result<()>> {
let credentials_provider = <dyn CredentialsProvider>::global(cx);
cx.spawn(async move |cx| {
if let Some(api_token) = api_token {
credentials_provider
.write_credentials(
MERCURY_CREDENTIALS_URL,
MERCURY_CREDENTIALS_USERNAME,
api_token.as_bytes(),
cx,
)
.await
.context("Failed to save Mercury API token to system keychain")
} else {
credentials_provider
.delete_credentials(MERCURY_CREDENTIALS_URL, cx)
.await
.context("Failed to delete Mercury API token from system keychain")
}
})
pub fn mercury_api_token(cx: &mut App) -> Entity<ApiKeyState> {
MERCURY_API_KEY
.get_or_init(|| {
cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone()))
})
.clone()
}

View File

@@ -1,11 +1,11 @@
use anyhow::{Context as _, Result};
use credentials_provider::CredentialsProvider;
use futures::{AsyncReadExt as _, FutureExt, future::Shared};
use anyhow::Result;
use futures::AsyncReadExt as _;
use gpui::{
App, AppContext as _, Task,
App, AppContext as _, Entity, SharedString, Task,
http_client::{self, AsyncBody, Method},
};
use language::{Point, ToOffset as _};
use language_model::{ApiKeyState, EnvVar, env_var};
use lsp::DiagnosticSeverity;
use serde::{Deserialize, Serialize};
use std::{
@@ -20,30 +20,28 @@ use crate::{EditPredictionId, EditPredictionModelInput, prediction::EditPredicti
const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete";
pub struct SweepAi {
pub api_token: Shared<Task<Option<String>>>,
pub api_token: Entity<ApiKeyState>,
pub debug_info: Arc<str>,
}
impl SweepAi {
pub fn new(cx: &App) -> Self {
pub fn new(cx: &mut App) -> Self {
SweepAi {
api_token: load_api_token(cx).shared(),
api_token: sweep_api_token(cx),
debug_info: debug_info(cx),
}
}
pub fn set_api_token(&mut self, api_token: Option<String>, cx: &mut App) -> Task<Result<()>> {
self.api_token = Task::ready(api_token.clone()).shared();
store_api_token_in_keychain(api_token, cx)
}
pub fn request_prediction_with_sweep(
&self,
inputs: EditPredictionModelInput,
cx: &mut App,
) -> Task<Result<Option<EditPredictionResult>>> {
let debug_info = self.debug_info.clone();
let Some(api_token) = self.api_token.clone().now_or_never().flatten() else {
self.api_token.update(cx, |key_state, cx| {
_ = key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx);
});
let Some(api_token) = self.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else {
return Task::ready(Ok(None));
};
let full_path: Arc<Path> = inputs
@@ -270,47 +268,18 @@ impl SweepAi {
}
}
pub const SWEEP_CREDENTIALS_URL: &str = "https://autocomplete.sweep.dev";
pub const SWEEP_CREDENTIALS_URL: SharedString =
SharedString::new_static("https://autocomplete.sweep.dev");
pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token";
pub static SWEEP_AI_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("SWEEP_AI_TOKEN");
pub static SWEEP_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
pub fn load_api_token(cx: &App) -> Task<Option<String>> {
if let Some(api_token) = std::env::var("SWEEP_AI_TOKEN")
.ok()
.filter(|value| !value.is_empty())
{
return Task::ready(Some(api_token));
}
let credentials_provider = <dyn CredentialsProvider>::global(cx);
cx.spawn(async move |cx| {
let (_, credentials) = credentials_provider
.read_credentials(SWEEP_CREDENTIALS_URL, &cx)
.await
.ok()??;
String::from_utf8(credentials).ok()
})
}
fn store_api_token_in_keychain(api_token: Option<String>, cx: &App) -> Task<Result<()>> {
let credentials_provider = <dyn CredentialsProvider>::global(cx);
cx.spawn(async move |cx| {
if let Some(api_token) = api_token {
credentials_provider
.write_credentials(
SWEEP_CREDENTIALS_URL,
SWEEP_CREDENTIALS_USERNAME,
api_token.as_bytes(),
cx,
)
.await
.context("Failed to save Sweep API token to system keychain")
} else {
credentials_provider
.delete_credentials(SWEEP_CREDENTIALS_URL, cx)
.await
.context("Failed to delete Sweep API token from system keychain")
}
})
pub fn sweep_api_token(cx: &mut App) -> Entity<ApiKeyState> {
SWEEP_API_KEY
.get_or_init(|| {
cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone()))
})
.clone()
}
#[derive(Debug, Clone, Serialize)]

View File

@@ -100,7 +100,7 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
) -> bool {
let store = self.store.read(cx);
if store.edit_prediction_model == EditPredictionModel::Sweep {
store.has_sweep_api_token()
store.has_sweep_api_token(cx)
} else {
true
}

View File

@@ -20,8 +20,8 @@ cloud_llm_client.workspace = true
codestral.workspace = true
command_palette_hooks.workspace = true
copilot.workspace = true
edit_prediction.workspace = true
edit_prediction_types.workspace = true
edit_prediction.workspace = true
editor.workspace = true
feature_flags.workspace = true
fs.workspace = true
@@ -41,7 +41,6 @@ telemetry.workspace = true
text.workspace = true
theme.workspace = true
ui.workspace = true
ui_input.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true

View File

@@ -3,7 +3,9 @@ use client::{Client, UserStore, zed_urls};
use cloud_llm_client::UsageLimit;
use codestral::CodestralEditPredictionDelegate;
use copilot::{Copilot, Status};
use edit_prediction::{MercuryFeatureFlag, SweepFeatureFlag, Zeta2FeatureFlag};
use edit_prediction::{
EditPredictionStore, MercuryFeatureFlag, SweepFeatureFlag, Zeta2FeatureFlag,
};
use edit_prediction_types::EditPredictionDelegateHandle;
use editor::{
Editor, MultiBufferOffset, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll,
@@ -42,12 +44,9 @@ use workspace::{
StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle,
notifications::NotificationId,
};
use zed_actions::OpenBrowser;
use zed_actions::{OpenBrowser, OpenSettingsAt};
use crate::{
ExternalProviderApiKeyModal, RatePredictions,
rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag,
};
use crate::{RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag};
actions!(
edit_prediction,
@@ -248,45 +247,21 @@ impl Render for EditPredictionButton {
EditPredictionProvider::Codestral => {
let enabled = self.editor_enabled.unwrap_or(true);
let has_api_key = CodestralEditPredictionDelegate::has_api_key(cx);
let fs = self.fs.clone();
let this = cx.weak_entity();
let tooltip_meta = if has_api_key {
"Powered by Codestral"
} else {
"Missing API key for Codestral"
};
div().child(
PopoverMenu::new("codestral")
.menu(move |window, cx| {
if has_api_key {
this.update(cx, |this, cx| {
this.build_codestral_context_menu(window, cx)
})
.ok()
} else {
Some(ContextMenu::build(window, cx, |menu, _, _| {
let fs = fs.clone();
menu.entry(
"Configure Codestral API Key",
None,
move |window, cx| {
window.dispatch_action(
zed_actions::agent::OpenSettings.boxed_clone(),
cx,
);
},
)
.separator()
.entry(
"Use Zed AI instead",
None,
move |_, cx| {
set_completion_provider(
fs.clone(),
cx,
EditPredictionProvider::Zed,
)
},
)
}))
}
this.update(cx, |this, cx| {
this.build_codestral_context_menu(window, cx)
})
.ok()
})
.anchor(Corner::BottomRight)
.trigger_with_tooltip(
@@ -304,7 +279,14 @@ impl Render for EditPredictionButton {
cx.theme().colors().status_bar_background,
))
}),
move |_window, cx| Tooltip::for_action("Codestral", &ToggleMenu, cx),
move |_window, cx| {
Tooltip::with_meta(
"Edit Prediction",
Some(&ToggleMenu),
tooltip_meta,
cx,
)
},
)
.with_handle(self.popover_menu_handle.clone()),
)
@@ -313,6 +295,7 @@ impl Render for EditPredictionButton {
let enabled = self.editor_enabled.unwrap_or(true);
let ep_icon;
let tooltip_meta;
let mut missing_token = false;
match provider {
@@ -320,15 +303,25 @@ impl Render for EditPredictionButton {
EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
) => {
ep_icon = IconName::SweepAi;
tooltip_meta = if missing_token {
"Missing API key for Sweep"
} else {
"Powered by Sweep"
};
missing_token = edit_prediction::EditPredictionStore::try_global(cx)
.is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token());
.is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx));
}
EditPredictionProvider::Experimental(
EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
) => {
ep_icon = IconName::Inception;
missing_token = edit_prediction::EditPredictionStore::try_global(cx)
.is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token());
.is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx));
tooltip_meta = if missing_token {
"Missing API key for Mercury"
} else {
"Powered by Mercury"
};
}
_ => {
ep_icon = if enabled {
@@ -336,6 +329,7 @@ impl Render for EditPredictionButton {
} else {
IconName::ZedPredictDisabled
};
tooltip_meta = "Powered by Zeta"
}
};
@@ -400,33 +394,26 @@ impl Render for EditPredictionButton {
})
.when(!self.popover_menu_handle.is_deployed(), |element| {
let user = user.clone();
element.tooltip(move |_window, cx| {
if enabled {
let description = if enabled {
if show_editor_predictions {
Tooltip::for_action("Edit Prediction", &ToggleMenu, cx)
tooltip_meta
} else if user.is_none() {
Tooltip::with_meta(
"Edit Prediction",
Some(&ToggleMenu),
"Sign In To Use",
cx,
)
"Sign In To Use"
} else {
Tooltip::with_meta(
"Edit Prediction",
Some(&ToggleMenu),
"Hidden For This File",
cx,
)
"Hidden For This File"
}
} else {
Tooltip::with_meta(
"Edit Prediction",
Some(&ToggleMenu),
"Disabled For This File",
cx,
)
}
"Disabled For This File"
};
Tooltip::with_meta(
"Edit Prediction",
Some(&ToggleMenu),
description,
cx,
)
})
});
@@ -519,6 +506,12 @@ impl EditPredictionButton {
providers.push(EditPredictionProvider::Zed);
if cx.has_flag::<Zeta2FeatureFlag>() {
providers.push(EditPredictionProvider::Experimental(
EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
));
}
if let Some(copilot) = Copilot::global(cx) {
if matches!(copilot.read(cx).status(), Status::Authorized) {
providers.push(EditPredictionProvider::Copilot);
@@ -537,24 +530,28 @@ impl EditPredictionButton {
providers.push(EditPredictionProvider::Codestral);
}
if cx.has_flag::<SweepFeatureFlag>() {
let ep_store = EditPredictionStore::try_global(cx);
if cx.has_flag::<SweepFeatureFlag>()
&& ep_store
.as_ref()
.is_some_and(|ep_store| ep_store.read(cx).has_sweep_api_token(cx))
{
providers.push(EditPredictionProvider::Experimental(
EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
));
}
if cx.has_flag::<MercuryFeatureFlag>() {
if cx.has_flag::<MercuryFeatureFlag>()
&& ep_store
.as_ref()
.is_some_and(|ep_store| ep_store.read(cx).has_mercury_api_token(cx))
{
providers.push(EditPredictionProvider::Experimental(
EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
));
}
if cx.has_flag::<Zeta2FeatureFlag>() {
providers.push(EditPredictionProvider::Experimental(
EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
));
}
providers
}
@@ -562,13 +559,10 @@ impl EditPredictionButton {
&self,
mut menu: ContextMenu,
current_provider: EditPredictionProvider,
cx: &App,
cx: &mut App,
) -> ContextMenu {
let available_providers = self.get_available_providers(cx);
const ZED_AI_CALLOUT: &str =
"Zed's edit prediction is powered by Zeta, an open-source, dataset mode.";
let providers: Vec<_> = available_providers
.into_iter()
.filter(|p| *p != EditPredictionProvider::None)
@@ -581,153 +575,32 @@ impl EditPredictionButton {
let is_current = provider == current_provider;
let fs = self.fs.clone();
menu = match provider {
EditPredictionProvider::Zed => menu.item(
ContextMenuEntry::new("Zed AI")
.toggleable(IconPosition::Start, is_current)
.documentation_aside(
DocumentationSide::Left,
DocumentationEdge::Bottom,
|_| Label::new(ZED_AI_CALLOUT).into_any_element(),
)
.handler(move |_, cx| {
set_completion_provider(fs.clone(), cx, provider);
}),
),
EditPredictionProvider::Copilot => menu.item(
ContextMenuEntry::new("GitHub Copilot")
.toggleable(IconPosition::Start, is_current)
.handler(move |_, cx| {
set_completion_provider(fs.clone(), cx, provider);
}),
),
EditPredictionProvider::Supermaven => menu.item(
ContextMenuEntry::new("Supermaven")
.toggleable(IconPosition::Start, is_current)
.handler(move |_, cx| {
set_completion_provider(fs.clone(), cx, provider);
}),
),
EditPredictionProvider::Codestral => menu.item(
ContextMenuEntry::new("Codestral")
.toggleable(IconPosition::Start, is_current)
.handler(move |_, cx| {
set_completion_provider(fs.clone(), cx, provider);
}),
),
let name = match provider {
EditPredictionProvider::Zed => "Zed AI",
EditPredictionProvider::Copilot => "GitHub Copilot",
EditPredictionProvider::Supermaven => "Supermaven",
EditPredictionProvider::Codestral => "Codestral",
EditPredictionProvider::Experimental(
EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
) => {
let has_api_token = edit_prediction::EditPredictionStore::try_global(cx)
.map_or(false, |ep_store| ep_store.read(cx).has_sweep_api_token());
let should_open_modal = !has_api_token || is_current;
let entry = if has_api_token {
ContextMenuEntry::new("Sweep")
.toggleable(IconPosition::Start, is_current)
} else {
ContextMenuEntry::new("Sweep")
.icon(IconName::XCircle)
.icon_color(Color::Error)
.documentation_aside(
DocumentationSide::Left,
DocumentationEdge::Bottom,
|_| {
Label::new("Click to configure your Sweep API token")
.into_any_element()
},
)
};
let entry = entry.handler(move |window, cx| {
if should_open_modal {
if let Some(workspace) = window.root::<Workspace>().flatten() {
workspace.update(cx, |workspace, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
ExternalProviderApiKeyModal::new(
window,
cx,
|api_key, store, cx| {
store
.sweep_ai
.set_api_token(api_key, cx)
.detach_and_log_err(cx);
},
)
});
});
};
} else {
set_completion_provider(fs.clone(), cx, provider);
}
});
menu.item(entry)
}
) => "Sweep",
EditPredictionProvider::Experimental(
EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
) => {
let has_api_token = edit_prediction::EditPredictionStore::try_global(cx)
.map_or(false, |ep_store| ep_store.read(cx).has_mercury_api_token());
let should_open_modal = !has_api_token || is_current;
let entry = if has_api_token {
ContextMenuEntry::new("Mercury")
.toggleable(IconPosition::Start, is_current)
} else {
ContextMenuEntry::new("Mercury")
.icon(IconName::XCircle)
.icon_color(Color::Error)
.documentation_aside(
DocumentationSide::Left,
DocumentationEdge::Bottom,
|_| {
Label::new("Click to configure your Mercury API token")
.into_any_element()
},
)
};
let entry = entry.handler(move |window, cx| {
if should_open_modal {
if let Some(workspace) = window.root::<Workspace>().flatten() {
workspace.update(cx, |workspace, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
ExternalProviderApiKeyModal::new(
window,
cx,
|api_key, store, cx| {
store
.mercury
.set_api_token(api_key, cx)
.detach_and_log_err(cx);
},
)
});
});
};
} else {
set_completion_provider(fs.clone(), cx, provider);
}
});
menu.item(entry)
}
) => "Mercury",
EditPredictionProvider::Experimental(
EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
) => menu.item(
ContextMenuEntry::new("Zeta2")
.toggleable(IconPosition::Start, is_current)
.handler(move |_, cx| {
set_completion_provider(fs.clone(), cx, provider);
}),
),
) => "Zeta2",
EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => {
continue;
}
};
menu = menu.item(
ContextMenuEntry::new(name)
.toggleable(IconPosition::Start, is_current)
.handler(move |_, cx| {
set_completion_provider(fs.clone(), cx, provider);
}),
)
}
}
@@ -832,14 +705,7 @@ impl EditPredictionButton {
let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
if matches!(
provider,
EditPredictionProvider::Zed
| EditPredictionProvider::Copilot
| EditPredictionProvider::Supermaven
| EditPredictionProvider::Codestral
) {
menu = menu
menu = menu
.separator()
.header("Display Modes")
.item(
@@ -868,104 +734,111 @@ impl EditPredictionButton {
}
}),
);
}
menu = menu.separator().header("Privacy");
if let Some(provider) = &self.edit_prediction_provider {
let data_collection = provider.data_collection_state(cx);
if matches!(
provider,
EditPredictionProvider::Zed
| EditPredictionProvider::Experimental(
EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
)
) {
if let Some(provider) = &self.edit_prediction_provider {
let data_collection = provider.data_collection_state(cx);
if data_collection.is_supported() {
let provider = provider.clone();
let enabled = data_collection.is_enabled();
let is_open_source = data_collection.is_project_open_source();
let is_collecting = data_collection.is_enabled();
let (icon_name, icon_color) = if is_open_source && is_collecting {
(IconName::Check, Color::Success)
} else {
(IconName::Check, Color::Accent)
};
if data_collection.is_supported() {
let provider = provider.clone();
let enabled = data_collection.is_enabled();
let is_open_source = data_collection.is_project_open_source();
let is_collecting = data_collection.is_enabled();
let (icon_name, icon_color) = if is_open_source && is_collecting {
(IconName::Check, Color::Success)
} else {
(IconName::Check, Color::Accent)
};
menu = menu.item(
ContextMenuEntry::new("Training Data Collection")
.toggleable(IconPosition::Start, data_collection.is_enabled())
.icon(icon_name)
.icon_color(icon_color)
.documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| {
let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
(true, true) => (
"Project identified as open source, and you're sharing data.",
Color::Default,
IconName::Check,
Color::Success,
),
(true, false) => (
"Project identified as open source, but you're not sharing data.",
Color::Muted,
IconName::Close,
Color::Muted,
),
(false, true) => (
"Project not identified as open source. No data captured.",
Color::Muted,
IconName::Close,
Color::Muted,
),
(false, false) => (
"Project not identified as open source, and setting turned off.",
Color::Muted,
IconName::Close,
Color::Muted,
),
};
v_flex()
.gap_2()
.child(
Label::new(indoc!{
"Help us improve our open dataset model by sharing data from open source repositories. \
Zed must detect a license file in your repo for this setting to take effect. \
Files with sensitive data and secrets are excluded by default."
})
)
.child(
h_flex()
.items_start()
.pt_2()
.pr_1()
.flex_1()
.gap_1p5()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
.child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
)
.into_any_element()
})
.handler(move |_, cx| {
provider.toggle_data_collection(cx);
if !enabled {
telemetry::event!(
"Data Collection Enabled",
source = "Edit Prediction Status Menu"
);
} else {
telemetry::event!(
"Data Collection Disabled",
source = "Edit Prediction Status Menu"
);
}
})
);
if is_collecting && !is_open_source {
menu = menu.item(
ContextMenuEntry::new("No data captured.")
.disabled(true)
.icon(IconName::Close)
.icon_color(Color::Error)
.icon_size(IconSize::Small),
ContextMenuEntry::new("Training Data Collection")
.toggleable(IconPosition::Start, data_collection.is_enabled())
.icon(icon_name)
.icon_color(icon_color)
.documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| {
let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
(true, true) => (
"Project identified as open source, and you're sharing data.",
Color::Default,
IconName::Check,
Color::Success,
),
(true, false) => (
"Project identified as open source, but you're not sharing data.",
Color::Muted,
IconName::Close,
Color::Muted,
),
(false, true) => (
"Project not identified as open source. No data captured.",
Color::Muted,
IconName::Close,
Color::Muted,
),
(false, false) => (
"Project not identified as open source, and setting turned off.",
Color::Muted,
IconName::Close,
Color::Muted,
),
};
v_flex()
.gap_2()
.child(
Label::new(indoc!{
"Help us improve our open dataset model by sharing data from open source repositories. \
Zed must detect a license file in your repo for this setting to take effect. \
Files with sensitive data and secrets are excluded by default."
})
)
.child(
h_flex()
.items_start()
.pt_2()
.pr_1()
.flex_1()
.gap_1p5()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
.child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
)
.into_any_element()
})
.handler(move |_, cx| {
provider.toggle_data_collection(cx);
if !enabled {
telemetry::event!(
"Data Collection Enabled",
source = "Edit Prediction Status Menu"
);
} else {
telemetry::event!(
"Data Collection Disabled",
source = "Edit Prediction Status Menu"
);
}
})
);
if is_collecting && !is_open_source {
menu = menu.item(
ContextMenuEntry::new("No data captured.")
.disabled(true)
.icon(IconName::Close)
.icon_color(Color::Error)
.icon_size(IconSize::Small),
);
}
}
}
}
@@ -1087,10 +960,7 @@ impl EditPredictionButton {
let menu =
self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
menu.separator()
.entry("Configure Codestral API Key", None, move |window, cx| {
window.dispatch_action(zed_actions::agent::OpenSettings.boxed_clone(), cx);
})
menu
})
}
@@ -1210,6 +1080,22 @@ impl EditPredictionButton {
}
menu = self.add_provider_switching_section(menu, provider, cx);
menu = menu.separator().item(
ContextMenuEntry::new("Configure Providers")
.icon(IconName::Settings)
.icon_position(IconPosition::Start)
.icon_color(Color::Muted)
.handler(move |window, cx| {
window.dispatch_action(
OpenSettingsAt {
path: "edit_predictions.providers".to_string(),
}
.boxed_clone(),
cx,
);
}),
);
menu
})
}

View File

@@ -1,6 +1,5 @@
mod edit_prediction_button;
mod edit_prediction_context_view;
mod external_provider_api_token_modal;
mod rate_prediction_modal;
use std::any::{Any as _, TypeId};
@@ -17,7 +16,6 @@ use ui::{App, prelude::*};
use workspace::{SplitDirection, Workspace};
pub use edit_prediction_button::{EditPredictionButton, ToggleMenu};
pub use external_provider_api_token_modal::ExternalProviderApiKeyModal;
use crate::rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag;

View File

@@ -1,86 +0,0 @@
use edit_prediction::EditPredictionStore;
use gpui::{
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render,
};
use ui::{Button, ButtonStyle, Clickable, Headline, HeadlineSize, prelude::*};
use ui_input::InputField;
use workspace::ModalView;
pub struct ExternalProviderApiKeyModal {
api_key_input: Entity<InputField>,
focus_handle: FocusHandle,
on_confirm: Box<dyn Fn(Option<String>, &mut EditPredictionStore, &mut App)>,
}
impl ExternalProviderApiKeyModal {
pub fn new(
window: &mut Window,
cx: &mut Context<Self>,
on_confirm: impl Fn(Option<String>, &mut EditPredictionStore, &mut App) + 'static,
) -> Self {
let api_key_input = cx.new(|cx| InputField::new(window, cx, "Enter your API key"));
Self {
api_key_input,
focus_handle: cx.focus_handle(),
on_confirm: Box::new(on_confirm),
}
}
fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
let api_key = self.api_key_input.read(cx).text(cx);
let api_key = (!api_key.trim().is_empty()).then_some(api_key);
if let Some(ep_store) = EditPredictionStore::try_global(cx) {
ep_store.update(cx, |ep_store, cx| (self.on_confirm)(api_key, ep_store, cx))
}
cx.emit(DismissEvent);
}
}
impl EventEmitter<DismissEvent> for ExternalProviderApiKeyModal {}
impl ModalView for ExternalProviderApiKeyModal {}
impl Focusable for ExternalProviderApiKeyModal {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ExternalProviderApiKeyModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("ExternalApiKeyModal")
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(Self::confirm))
.elevation_2(cx)
.w(px(400.))
.p_4()
.gap_3()
.child(Headline::new("API Token").size(HeadlineSize::Small))
.child(self.api_key_input.clone())
.child(
h_flex()
.justify_end()
.gap_2()
.child(Button::new("cancel", "Cancel").on_click(cx.listener(
|_, _, _window, cx| {
cx.emit(DismissEvent);
},
)))
.child(
Button::new("save", "Save")
.style(ButtonStyle::Filled)
.on_click(cx.listener(|this, _, window, cx| {
this.confirm(&menu::Confirm, window, cx);
})),
),
)
}
}

View File

@@ -18,6 +18,7 @@ test-support = []
[dependencies]
anthropic = { workspace = true, features = ["schemars"] }
anyhow.workspace = true
credentials_provider.workspace = true
base64.workspace = true
client.workspace = true
cloud_api_types.workspace = true
@@ -41,6 +42,7 @@ smol.workspace = true
telemetry_events.workspace = true
thiserror.workspace = true
util.workspace = true
zed_env_vars.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }

View File

@@ -2,7 +2,6 @@ use anyhow::{Result, anyhow};
use credentials_provider::CredentialsProvider;
use futures::{FutureExt, future};
use gpui::{AsyncApp, Context, SharedString, Task};
use language_model::AuthenticateError;
use std::{
fmt::{Display, Formatter},
sync::Arc,
@@ -10,13 +9,16 @@ use std::{
use util::ResultExt as _;
use zed_env_vars::EnvVar;
use crate::AuthenticateError;
/// Manages a single API key for a language model provider. API keys either come from environment
/// variables or the system keychain.
///
/// Keys from the system keychain are associated with a provider URL, and this ensures that they are
/// only used with that URL.
pub struct ApiKeyState {
url: SharedString,
pub url: SharedString,
env_var: EnvVar,
load_status: LoadStatus,
load_task: Option<future::Shared<Task<()>>>,
}
@@ -35,9 +37,10 @@ pub struct ApiKey {
}
impl ApiKeyState {
pub fn new(url: SharedString) -> Self {
pub fn new(url: SharedString, env_var: EnvVar) -> Self {
Self {
url,
env_var,
load_status: LoadStatus::NotPresent,
load_task: None,
}
@@ -47,6 +50,10 @@ impl ApiKeyState {
matches!(self.load_status, LoadStatus::Loaded { .. })
}
pub fn env_var_name(&self) -> &SharedString {
&self.env_var.name
}
pub fn is_from_env_var(&self) -> bool {
match &self.load_status {
LoadStatus::Loaded(ApiKey {
@@ -136,14 +143,13 @@ impl ApiKeyState {
pub fn handle_url_change<Ent: 'static>(
&mut self,
url: SharedString,
env_var: &EnvVar,
get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static,
cx: &mut Context<Ent>,
) {
if url != self.url {
if !self.is_from_env_var() {
// loading will continue even though this result task is dropped
let _task = self.load_if_needed(url, env_var, get_this, cx);
let _task = self.load_if_needed(url, get_this, cx);
}
}
}
@@ -156,7 +162,6 @@ impl ApiKeyState {
pub fn load_if_needed<Ent: 'static>(
&mut self,
url: SharedString,
env_var: &EnvVar,
get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static,
cx: &mut Context<Ent>,
) -> Task<Result<(), AuthenticateError>> {
@@ -166,10 +171,10 @@ impl ApiKeyState {
return Task::ready(Ok(()));
}
if let Some(key) = &env_var.value
if let Some(key) = &self.env_var.value
&& !key.is_empty()
{
let api_key = ApiKey::from_env(env_var.name.clone(), key);
let api_key = ApiKey::from_env(self.env_var.name.clone(), key);
self.url = url;
self.load_status = LoadStatus::Loaded(api_key);
self.load_task = None;

View File

@@ -1,3 +1,4 @@
mod api_key;
mod model;
mod rate_limiter;
mod registry;
@@ -30,6 +31,7 @@ use std::{fmt, io};
use thiserror::Error;
use util::serde::is_default;
pub use crate::api_key::{ApiKey, ApiKeyState};
pub use crate::model::*;
pub use crate::rate_limiter::*;
pub use crate::registry::*;
@@ -37,6 +39,7 @@ pub use crate::request::*;
pub use crate::role::*;
pub use crate::telemetry::*;
pub use crate::tool_schema::LanguageModelToolSchemaFormat;
pub use zed_env_vars::{EnvVar, env_var};
pub const ANTHROPIC_PROVIDER_ID: LanguageModelProviderId =
LanguageModelProviderId::new("anthropic");

View File

@@ -60,7 +60,6 @@ ui_input.workspace = true
util.workspace = true
vercel = { workspace = true, features = ["schemars"] }
x_ai = { workspace = true, features = ["schemars"] }
zed_env_vars.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }

View File

@@ -7,10 +7,8 @@ use gpui::{App, Context, Entity};
use language_model::{LanguageModelProviderId, LanguageModelRegistry};
use provider::deepseek::DeepSeekLanguageModelProvider;
mod api_key;
pub mod provider;
mod settings;
pub mod ui;
use crate::provider::anthropic::AnthropicLanguageModelProvider;
use crate::provider::bedrock::BedrockLanguageModelProvider;

View File

@@ -8,25 +8,21 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::B
use gpui::{AnyView, App, AsyncApp, Context, Entity, Task};
use http_client::HttpClient;
use language_model::{
AuthenticateError, ConfigurationViewTargetAgent, LanguageModel,
LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelId,
LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
LanguageModelToolResultContent, MessageContent, RateLimiter, Role,
ApiKeyState, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModel,
LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
RateLimiter, Role, StopReason, env_var,
};
use language_model::{LanguageModelCompletionEvent, LanguageModelToolUse, StopReason};
use settings::{Settings, SettingsStore};
use std::pin::Pin;
use std::str::FromStr;
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
use ui::{List, prelude::*};
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use zed_env_vars::{EnvVar, env_var};
use crate::api_key::ApiKeyState;
use crate::ui::{ConfiguredApiCard, InstructionListItem};
pub use settings::AnthropicAvailableModel as AvailableModel;
@@ -65,12 +61,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = AnthropicLanguageModelProvider::api_url(cx);
self.api_key_state.load_if_needed(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
)
self.api_key_state
.load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -79,17 +71,13 @@ impl AnthropicLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
this.api_key_state.handle_url_change(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
);
this.api_key_state
.handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx)),
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
});
@@ -937,14 +925,12 @@ impl Render for ConfigurationView {
.child(
List::new()
.child(
InstructionListItem::new(
"Create one by visiting",
Some("Anthropic's settings"),
Some("https://console.anthropic.com/settings/keys")
)
ListBulletItem::new("")
.child(Label::new("Create one by visiting"))
.child(ButtonLink::new("Anthropic's settings", "https://console.anthropic.com/settings/keys"))
)
.child(
InstructionListItem::text_only("Paste your API key below and hit enter to start using the agent")
ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
)
)
.child(self.api_key_editor.clone())
@@ -953,7 +939,8 @@ impl Render for ConfigurationView {
format!("You can also assign the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."),
)
.size(LabelSize::Small)
.color(Color::Muted),
.color(Color::Muted)
.mt_0p5(),
)
.into_any_element()
} else {

View File

@@ -2,7 +2,6 @@ use std::pin::Pin;
use std::str::FromStr;
use std::sync::Arc;
use crate::ui::{ConfiguredApiCard, InstructionListItem};
use anyhow::{Context as _, Result, anyhow};
use aws_config::stalled_stream_protection::StalledStreamProtectionConfig;
use aws_config::{BehaviorVersion, Region};
@@ -44,7 +43,7 @@ use serde_json::Value;
use settings::{BedrockAvailableModel as AvailableModel, Settings, SettingsStore};
use smol::lock::OnceCell;
use strum::{EnumIter, IntoEnumIterator, IntoStaticStr};
use ui::{List, prelude::*};
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
@@ -1250,18 +1249,14 @@ impl Render for ConfigurationView {
.child(
List::new()
.child(
InstructionListItem::new(
"Grant permissions to the strategy you'll use according to the:",
Some("Prerequisites"),
Some("https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"),
)
ListBulletItem::new("")
.child(Label::new("Grant permissions to the strategy you'll use according to the:"))
.child(ButtonLink::new("Prerequisites", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"))
)
.child(
InstructionListItem::new(
"Select the models you would like access to:",
Some("Bedrock Model Catalog"),
Some("https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess"),
)
ListBulletItem::new("")
.child(Label::new("Select the models you would like access to:"))
.child(ButtonLink::new("Bedrock Model Catalog", "https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess"))
)
)
.child(self.render_static_credentials_ui())
@@ -1302,22 +1297,22 @@ impl ConfigurationView {
)
.child(
List::new()
.child(InstructionListItem::new(
"Create an IAM user in the AWS console with programmatic access",
Some("IAM Console"),
Some("https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users"),
))
.child(InstructionListItem::new(
"Attach the necessary Bedrock permissions to this ",
Some("user"),
Some("https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"),
))
.child(InstructionListItem::text_only(
"Copy the access key ID and secret access key when provided",
))
.child(InstructionListItem::text_only(
"Enter these credentials below",
)),
.child(
ListBulletItem::new("")
.child(Label::new("Create an IAM user in the AWS console with programmatic access"))
.child(ButtonLink::new("IAM Console", "https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users"))
)
.child(
ListBulletItem::new("")
.child(Label::new("Attach the necessary Bedrock permissions to this"))
.child(ButtonLink::new("user", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"))
)
.child(
ListBulletItem::new("Copy the access key ID and secret access key when provided")
)
.child(
ListBulletItem::new("Enter these credentials below")
)
)
.child(self.access_key_id_editor.clone())
.child(self.secret_access_key_editor.clone())

View File

@@ -14,7 +14,7 @@ use copilot::{Copilot, Status};
use futures::future::BoxFuture;
use futures::stream::BoxStream;
use futures::{FutureExt, Stream, StreamExt};
use gpui::{Action, AnyView, App, AsyncApp, Entity, Render, Subscription, Task, svg};
use gpui::{AnyView, App, AsyncApp, Entity, Subscription, Task};
use http_client::StatusCode;
use language::language_settings::all_language_settings;
use language_model::{
@@ -26,11 +26,9 @@ use language_model::{
StopReason, TokenUsage,
};
use settings::SettingsStore;
use ui::{CommonAnimationExt, prelude::*};
use ui::prelude::*;
use util::debug_panic;
use crate::ui::ConfiguredApiCard;
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat");
const PROVIDER_NAME: LanguageModelProviderName =
LanguageModelProviderName::new("GitHub Copilot Chat");
@@ -179,8 +177,18 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
_: &mut Window,
cx: &mut App,
) -> AnyView {
let state = self.state.clone();
cx.new(|cx| ConfigurationView::new(state, cx)).into()
cx.new(|cx| {
copilot::ConfigurationView::new(
|cx| {
CopilotChat::global(cx)
.map(|m| m.read(cx).is_authenticated())
.unwrap_or(false)
},
copilot::ConfigurationMode::Chat,
cx,
)
})
.into()
}
fn reset_credentials(&self, _cx: &mut App) -> Task<Result<()>> {
@@ -1474,92 +1482,3 @@ mod tests {
);
}
}
struct ConfigurationView {
copilot_status: Option<copilot::Status>,
state: Entity<State>,
_subscription: Option<Subscription>,
}
impl ConfigurationView {
pub fn new(state: Entity<State>, cx: &mut Context<Self>) -> Self {
let copilot = Copilot::global(cx);
Self {
copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()),
state,
_subscription: copilot.as_ref().map(|copilot| {
cx.observe(copilot, |this, model, cx| {
this.copilot_status = Some(model.read(cx).status());
cx.notify();
})
}),
}
}
}
impl Render for ConfigurationView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.state.read(cx).is_authenticated(cx) {
ConfiguredApiCard::new("Authorized")
.button_label("Sign Out")
.on_click(|_, window, cx| {
window.dispatch_action(copilot::SignOut.boxed_clone(), cx);
})
.into_any_element()
} else {
let loading_icon = Icon::new(IconName::ArrowCircle).with_rotate_animation(4);
const ERROR_LABEL: &str = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different Assistant provider.";
match &self.copilot_status {
Some(status) => match status {
Status::Starting { task: _ } => h_flex()
.gap_2()
.child(loading_icon)
.child(Label::new("Starting Copilot…"))
.into_any_element(),
Status::SigningIn { prompt: _ }
| Status::SignedOut {
awaiting_signing_in: true,
} => h_flex()
.gap_2()
.child(loading_icon)
.child(Label::new("Signing into Copilot…"))
.into_any_element(),
Status::Error(_) => {
const LABEL: &str = "Copilot had issues starting. Please try restarting it. If the issue persists, try reinstalling Copilot.";
v_flex()
.gap_6()
.child(Label::new(LABEL))
.child(svg().size_8().path(IconName::CopilotError.path()))
.into_any_element()
}
_ => {
const LABEL: &str = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription.";
v_flex()
.gap_2()
.child(Label::new(LABEL))
.child(
Button::new("sign_in", "Sign in to use GitHub Copilot")
.full_width()
.style(ButtonStyle::Outlined)
.icon_color(Color::Muted)
.icon(IconName::Github)
.icon_position(IconPosition::Start)
.icon_size(IconSize::Small)
.on_click(|_, window, cx| {
copilot::initiate_sign_in(window, cx)
}),
)
.into_any_element()
}
},
None => v_flex()
.gap_6()
.child(Label::new(ERROR_LABEL))
.into_any_element(),
}
}
}
}

View File

@@ -7,11 +7,11 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture, stream::BoxStream
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
use http_client::HttpClient;
use language_model::{
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
RateLimiter, Role, StopReason, TokenUsage,
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
};
pub use settings::DeepseekAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore};
@@ -19,13 +19,9 @@ use std::pin::Pin;
use std::str::FromStr;
use std::sync::{Arc, LazyLock};
use ui::{List, prelude::*};
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use zed_env_vars::{EnvVar, env_var};
use crate::ui::ConfiguredApiCard;
use crate::{api_key::ApiKeyState, ui::InstructionListItem};
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("deepseek");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("DeepSeek");
@@ -67,12 +63,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = DeepSeekLanguageModelProvider::api_url(cx);
self.api_key_state.load_if_needed(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
)
self.api_key_state
.load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -81,17 +73,13 @@ impl DeepSeekLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
this.api_key_state.handle_url_change(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
);
this.api_key_state
.handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx)),
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
});
@@ -632,12 +620,15 @@ impl Render for ConfigurationView {
.child(Label::new("To use DeepSeek in Zed, you need an API key:"))
.child(
List::new()
.child(InstructionListItem::new(
"Get your API key from the",
Some("DeepSeek console"),
Some("https://platform.deepseek.com/api_keys"),
))
.child(InstructionListItem::text_only(
.child(
ListBulletItem::new("")
.child(Label::new("Get your API key from the"))
.child(ButtonLink::new(
"DeepSeek console",
"https://platform.deepseek.com/api_keys",
)),
)
.child(ListBulletItem::new(
"Paste your API key below and hit enter to start using the assistant",
)),
)

View File

@@ -9,7 +9,7 @@ use google_ai::{
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
use http_client::HttpClient;
use language_model::{
AuthenticateError, ConfigurationViewTargetAgent, LanguageModelCompletionError,
AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat,
LanguageModelToolUse, LanguageModelToolUseId, MessageContent, StopReason,
};
@@ -28,14 +28,11 @@ use std::sync::{
atomic::{self, AtomicU64},
};
use strum::IntoEnumIterator;
use ui::{List, prelude::*};
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use zed_env_vars::EnvVar;
use crate::api_key::ApiKey;
use crate::api_key::ApiKeyState;
use crate::ui::{ConfiguredApiCard, InstructionListItem};
use language_model::{ApiKey, ApiKeyState};
const PROVIDER_ID: LanguageModelProviderId = language_model::GOOGLE_PROVIDER_ID;
const PROVIDER_NAME: LanguageModelProviderName = language_model::GOOGLE_PROVIDER_NAME;
@@ -87,12 +84,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = GoogleLanguageModelProvider::api_url(cx);
self.api_key_state.load_if_needed(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
)
self.api_key_state
.load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -101,17 +94,13 @@ impl GoogleLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
this.api_key_state.handle_url_change(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
);
this.api_key_state
.handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx)),
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
});
@@ -873,14 +862,14 @@ impl Render for ConfigurationView {
})))
.child(
List::new()
.child(InstructionListItem::new(
"Create one by visiting",
Some("Google AI's console"),
Some("https://aistudio.google.com/app/apikey"),
))
.child(InstructionListItem::text_only(
"Paste your API key below and hit enter to start using the assistant",
)),
.child(
ListBulletItem::new("")
.child(Label::new("Create one by visiting"))
.child(ButtonLink::new("Google AI's console", "https://aistudio.google.com/app/apikey"))
)
.child(
ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
)
)
.child(self.api_key_editor.clone())
.child(

View File

@@ -20,11 +20,10 @@ use settings::{Settings, SettingsStore};
use std::pin::Pin;
use std::str::FromStr;
use std::{collections::BTreeMap, sync::Arc};
use ui::{ButtonLike, Indicator, List, prelude::*};
use ui::{ButtonLike, Indicator, InlineCode, List, ListBulletItem, prelude::*};
use util::ResultExt;
use crate::AllLanguageModelSettings;
use crate::ui::InstructionListItem;
const LMSTUDIO_DOWNLOAD_URL: &str = "https://lmstudio.ai/download";
const LMSTUDIO_CATALOG_URL: &str = "https://lmstudio.ai/models";
@@ -686,12 +685,14 @@ impl Render for ConfigurationView {
.child(
v_flex().gap_1().child(Label::new(lmstudio_intro)).child(
List::new()
.child(InstructionListItem::text_only(
.child(ListBulletItem::new(
"LM Studio needs to be running with at least one model downloaded.",
))
.child(InstructionListItem::text_only(
"To get your first model, try running `lms get qwen2.5-coder-7b`",
)),
.child(
ListBulletItem::new("")
.child(Label::new("To get your first model, try running"))
.child(InlineCode::new("lms get qwen2.5-coder-7b")),
),
),
)
.child(

View File

@@ -1,31 +1,27 @@
use anyhow::{Result, anyhow};
use collections::BTreeMap;
use fs::Fs;
use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::BoxStream};
use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window};
use http_client::HttpClient;
use language_model::{
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
RateLimiter, Role, StopReason, TokenUsage,
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
};
use mistral::{CODESTRAL_API_URL, MISTRAL_API_URL, StreamResponse};
pub use mistral::{CODESTRAL_API_URL, MISTRAL_API_URL, StreamResponse};
pub use settings::MistralAvailableModel as AvailableModel;
use settings::{EditPredictionProvider, Settings, SettingsStore, update_settings_file};
use settings::{Settings, SettingsStore};
use std::collections::HashMap;
use std::pin::Pin;
use std::str::FromStr;
use std::sync::{Arc, LazyLock};
use std::sync::{Arc, LazyLock, OnceLock};
use strum::IntoEnumIterator;
use ui::{List, prelude::*};
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use zed_env_vars::{EnvVar, env_var};
use crate::ui::ConfiguredApiCard;
use crate::{api_key::ApiKeyState, ui::InstructionListItem};
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("mistral");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Mistral");
@@ -35,6 +31,7 @@ static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
const CODESTRAL_API_KEY_ENV_VAR_NAME: &str = "CODESTRAL_API_KEY";
static CODESTRAL_API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(CODESTRAL_API_KEY_ENV_VAR_NAME);
static CODESTRAL_API_KEY: OnceLock<Entity<ApiKeyState>> = OnceLock::new();
#[derive(Default, Clone, Debug, PartialEq)]
pub struct MistralSettings {
@@ -44,12 +41,22 @@ pub struct MistralSettings {
pub struct MistralLanguageModelProvider {
http_client: Arc<dyn HttpClient>,
state: Entity<State>,
pub state: Entity<State>,
}
pub struct State {
api_key_state: ApiKeyState,
codestral_api_key_state: ApiKeyState,
codestral_api_key_state: Entity<ApiKeyState>,
}
pub fn codestral_api_key(cx: &mut App) -> Entity<ApiKeyState> {
return CODESTRAL_API_KEY
.get_or_init(|| {
cx.new(|_| {
ApiKeyState::new(CODESTRAL_API_URL.into(), CODESTRAL_API_KEY_ENV_VAR.clone())
})
})
.clone();
}
impl State {
@@ -63,39 +70,19 @@ impl State {
.store(api_url, api_key, |this| &mut this.api_key_state, cx)
}
fn set_codestral_api_key(
&mut self,
api_key: Option<String>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.codestral_api_key_state.store(
CODESTRAL_API_URL.into(),
api_key,
|this| &mut this.codestral_api_key_state,
cx,
)
}
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = MistralLanguageModelProvider::api_url(cx);
self.api_key_state.load_if_needed(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
)
self.api_key_state
.load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
fn authenticate_codestral(
&mut self,
cx: &mut Context<Self>,
) -> Task<Result<(), AuthenticateError>> {
self.codestral_api_key_state.load_if_needed(
CODESTRAL_API_URL.into(),
&CODESTRAL_API_KEY_ENV_VAR,
|this| &mut this.codestral_api_key_state,
cx,
)
self.codestral_api_key_state.update(cx, |state, cx| {
state.load_if_needed(CODESTRAL_API_URL.into(), |state| state, cx)
})
}
}
@@ -116,18 +103,14 @@ impl MistralLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
this.api_key_state.handle_url_change(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
);
this.api_key_state
.handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx)),
codestral_api_key_state: ApiKeyState::new(CODESTRAL_API_URL.into()),
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
codestral_api_key_state: codestral_api_key(cx),
}
});
@@ -142,7 +125,11 @@ impl MistralLanguageModelProvider {
}
pub fn codestral_api_key(&self, url: &str, cx: &App) -> Option<Arc<str>> {
self.state.read(cx).codestral_api_key_state.key(url)
self.state
.read(cx)
.codestral_api_key_state
.read(cx)
.key(url)
}
fn create_language_model(&self, model: mistral::Model) -> Arc<dyn LanguageModel> {
@@ -159,7 +146,7 @@ impl MistralLanguageModelProvider {
&crate::AllLanguageModelSettings::get_global(cx).mistral
}
fn api_url(cx: &App) -> SharedString {
pub fn api_url(cx: &App) -> SharedString {
let api_url = &Self::settings(cx).api_url;
if api_url.is_empty() {
mistral::MISTRAL_API_URL.into()
@@ -747,7 +734,6 @@ struct RawToolCall {
struct ConfigurationView {
api_key_editor: Entity<InputField>,
codestral_api_key_editor: Entity<InputField>,
state: Entity<State>,
load_credentials_task: Option<Task<()>>,
}
@@ -756,8 +742,6 @@ impl ConfigurationView {
fn new(state: Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let api_key_editor =
cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"));
let codestral_api_key_editor =
cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"));
cx.observe(&state, |_, _, cx| {
cx.notify();
@@ -774,12 +758,6 @@ impl ConfigurationView {
// We don't log an error, because "not signed in" is also an error.
let _ = task.await;
}
if let Some(task) = state
.update(cx, |state, cx| state.authenticate_codestral(cx))
.log_err()
{
let _ = task.await;
}
this.update(cx, |this, cx| {
this.load_credentials_task = None;
@@ -791,7 +769,6 @@ impl ConfigurationView {
Self {
api_key_editor,
codestral_api_key_editor,
state,
load_credentials_task,
}
@@ -829,110 +806,9 @@ impl ConfigurationView {
.detach_and_log_err(cx);
}
fn save_codestral_api_key(
&mut self,
_: &menu::Confirm,
window: &mut Window,
cx: &mut Context<Self>,
) {
let api_key = self
.codestral_api_key_editor
.read(cx)
.text(cx)
.trim()
.to_string();
if api_key.is_empty() {
return;
}
// url changes can cause the editor to be displayed again
self.codestral_api_key_editor
.update(cx, |editor, cx| editor.set_text("", window, cx));
let state = self.state.clone();
cx.spawn_in(window, async move |_, cx| {
state
.update(cx, |state, cx| {
state.set_codestral_api_key(Some(api_key), cx)
})?
.await?;
cx.update(|_window, cx| {
set_edit_prediction_provider(EditPredictionProvider::Codestral, cx)
})
})
.detach_and_log_err(cx);
}
fn reset_codestral_api_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.codestral_api_key_editor
.update(cx, |editor, cx| editor.set_text("", window, cx));
let state = self.state.clone();
cx.spawn_in(window, async move |_, cx| {
state
.update(cx, |state, cx| state.set_codestral_api_key(None, cx))?
.await?;
cx.update(|_window, cx| set_edit_prediction_provider(EditPredictionProvider::Zed, cx))
})
.detach_and_log_err(cx);
}
fn should_render_api_key_editor(&self, cx: &mut Context<Self>) -> bool {
!self.state.read(cx).is_authenticated()
}
fn render_codestral_api_key_editor(&mut self, cx: &mut Context<Self>) -> AnyElement {
let key_state = &self.state.read(cx).codestral_api_key_state;
let should_show_editor = !key_state.has_key();
let env_var_set = key_state.is_from_env_var();
let configured_card_label = if env_var_set {
format!("API key set in {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable")
} else {
"Codestral API key configured".to_string()
};
if should_show_editor {
v_flex()
.id("codestral")
.size_full()
.mt_2()
.on_action(cx.listener(Self::save_codestral_api_key))
.child(Label::new(
"To use Codestral as an edit prediction provider, \
you need to add a Codestral-specific API key. Follow these steps:",
))
.child(
List::new()
.child(InstructionListItem::new(
"Create one by visiting",
Some("the Codestral section of Mistral's console"),
Some("https://console.mistral.ai/codestral"),
))
.child(InstructionListItem::text_only("Paste your API key below and hit enter")),
)
.child(self.codestral_api_key_editor.clone())
.child(
Label::new(
format!("You can also assign the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable and restart Zed."),
)
.size(LabelSize::Small).color(Color::Muted),
).into_any()
} else {
ConfiguredApiCard::new(configured_card_label)
.disabled(env_var_set)
.on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
.when(env_var_set, |this| {
this.tooltip_label(format!(
"To reset your API key, \
unset the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable."
))
})
.on_click(
cx.listener(|this, _, window, cx| this.reset_codestral_api_key(window, cx)),
)
.into_any_element()
}
}
}
impl Render for ConfigurationView {
@@ -958,17 +834,17 @@ impl Render for ConfigurationView {
.child(Label::new("To use Zed's agent with Mistral, you need to add an API key. Follow these steps:"))
.child(
List::new()
.child(InstructionListItem::new(
"Create one by visiting",
Some("Mistral's console"),
Some("https://console.mistral.ai/api-keys"),
))
.child(InstructionListItem::text_only(
"Ensure your Mistral account has credits",
))
.child(InstructionListItem::text_only(
"Paste your API key below and hit enter to start using the assistant",
)),
.child(
ListBulletItem::new("")
.child(Label::new("Create one by visiting"))
.child(ButtonLink::new("Mistral's console", "https://console.mistral.ai/api-keys"))
)
.child(
ListBulletItem::new("Ensure your Mistral account has credits")
)
.child(
ListBulletItem::new("Paste your API key below and hit enter to start using the assistant")
),
)
.child(self.api_key_editor.clone())
.child(
@@ -977,7 +853,6 @@ impl Render for ConfigurationView {
)
.size(LabelSize::Small).color(Color::Muted),
)
.child(self.render_codestral_api_key_editor(cx))
.into_any()
} else {
v_flex()
@@ -994,24 +869,11 @@ impl Render for ConfigurationView {
))
}),
)
.child(self.render_codestral_api_key_editor(cx))
.into_any()
}
}
}
fn set_edit_prediction_provider(provider: EditPredictionProvider, cx: &mut App) {
let fs = <dyn Fs>::global(cx);
update_settings_file(fs, cx, move |settings, _| {
settings
.project
.all_languages
.features
.get_or_insert_default()
.edit_prediction_provider = Some(provider);
});
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -5,11 +5,11 @@ use futures::{Stream, TryFutureExt, stream};
use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task};
use http_client::HttpClient;
use language_model::{
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse,
LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage,
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse,
LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
};
use menu;
use ollama::{
@@ -22,13 +22,13 @@ use std::pin::Pin;
use std::sync::LazyLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::{collections::HashMap, sync::Arc};
use ui::{ButtonLike, ElevationIndex, List, Tooltip, prelude::*};
use ui::{
ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, InlineCode, List, ListBulletItem,
Tooltip, prelude::*,
};
use ui_input::InputField;
use zed_env_vars::{EnvVar, env_var};
use crate::AllLanguageModelSettings;
use crate::api_key::ApiKeyState;
use crate::ui::{ConfiguredApiCard, InstructionListItem};
const OLLAMA_DOWNLOAD_URL: &str = "https://ollama.com/download";
const OLLAMA_LIBRARY_URL: &str = "https://ollama.com/library";
@@ -80,12 +80,9 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = OllamaLanguageModelProvider::api_url(cx);
let task = self.api_key_state.load_if_needed(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
);
let task = self
.api_key_state
.load_if_needed(api_url, |this| &mut this.api_key_state, cx);
// Always try to fetch models - if no API key is needed (local Ollama), it will work
// If API key is needed and provided, it will work
@@ -185,7 +182,7 @@ impl OllamaLanguageModelProvider {
http_client,
fetched_models: Default::default(),
fetch_model_task: None,
api_key_state: ApiKeyState::new(Self::api_url(cx)),
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
}),
};
@@ -733,15 +730,17 @@ impl ConfigurationView {
.child(Label::new("To use local Ollama:"))
.child(
List::new()
.child(InstructionListItem::new(
"Download and install Ollama from",
Some("ollama.com"),
Some("https://ollama.com/download"),
))
.child(InstructionListItem::text_only(
"Start Ollama and download a model: `ollama run gpt-oss:20b`",
))
.child(InstructionListItem::text_only(
.child(
ListBulletItem::new("")
.child(Label::new("Download and install Ollama from"))
.child(ButtonLink::new("ollama.com", "https://ollama.com/download")),
)
.child(
ListBulletItem::new("")
.child(Label::new("Start Ollama and download a model:"))
.child(InlineCode::new("ollama run gpt-oss:20b")),
)
.child(ListBulletItem::new(
"Click 'Connect' below to start using Ollama in Zed",
)),
)

View File

@@ -5,11 +5,11 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
use http_client::HttpClient;
use language_model::{
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
RateLimiter, Role, StopReason, TokenUsage,
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
};
use menu;
use open_ai::{
@@ -20,13 +20,9 @@ use std::pin::Pin;
use std::str::FromStr as _;
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
use ui::{List, prelude::*};
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use zed_env_vars::{EnvVar, env_var};
use crate::ui::ConfiguredApiCard;
use crate::{api_key::ApiKeyState, ui::InstructionListItem};
const PROVIDER_ID: LanguageModelProviderId = language_model::OPEN_AI_PROVIDER_ID;
const PROVIDER_NAME: LanguageModelProviderName = language_model::OPEN_AI_PROVIDER_NAME;
@@ -62,12 +58,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = OpenAiLanguageModelProvider::api_url(cx);
self.api_key_state.load_if_needed(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
)
self.api_key_state
.load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -76,17 +68,13 @@ impl OpenAiLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
this.api_key_state.handle_url_change(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
);
this.api_key_state
.handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx)),
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
});
@@ -790,17 +778,17 @@ impl Render for ConfigurationView {
.child(Label::new("To use Zed's agent with OpenAI, you need to add an API key. Follow these steps:"))
.child(
List::new()
.child(InstructionListItem::new(
"Create one by visiting",
Some("OpenAI's console"),
Some("https://platform.openai.com/api-keys"),
))
.child(InstructionListItem::text_only(
"Ensure your OpenAI account has credits",
))
.child(InstructionListItem::text_only(
"Paste your API key below and hit enter to start using the assistant",
)),
.child(
ListBulletItem::new("")
.child(Label::new("Create one by visiting"))
.child(ButtonLink::new("OpenAI's console", "https://platform.openai.com/api-keys"))
)
.child(
ListBulletItem::new("Ensure your OpenAI account has credits")
)
.child(
ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
),
)
.child(self.api_key_editor.clone())
.child(

View File

@@ -4,10 +4,10 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
use http_client::HttpClient;
use language_model::{
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
};
use menu;
use open_ai::{ResponseStreamEvent, stream_completion};
@@ -16,9 +16,7 @@ use std::sync::Arc;
use ui::{ElevationIndex, Tooltip, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use zed_env_vars::EnvVar;
use crate::api_key::ApiKeyState;
use crate::provider::open_ai::{OpenAiEventMapper, into_open_ai};
pub use settings::OpenAiCompatibleAvailableModel as AvailableModel;
pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities;
@@ -38,7 +36,6 @@ pub struct OpenAiCompatibleLanguageModelProvider {
pub struct State {
id: Arc<str>,
api_key_env_var: EnvVar,
api_key_state: ApiKeyState,
settings: OpenAiCompatibleSettings,
}
@@ -56,12 +53,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = SharedString::new(self.settings.api_url.clone());
self.api_key_state.load_if_needed(
api_url,
&self.api_key_env_var,
|this| &mut this.api_key_state,
cx,
)
self.api_key_state
.load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -83,7 +76,6 @@ impl OpenAiCompatibleLanguageModelProvider {
let api_url = SharedString::new(settings.api_url.as_str());
this.api_key_state.handle_url_change(
api_url,
&this.api_key_env_var,
|this| &mut this.api_key_state,
cx,
);
@@ -95,8 +87,10 @@ impl OpenAiCompatibleLanguageModelProvider {
let settings = resolve_settings(&id, cx).cloned().unwrap_or_default();
State {
id: id.clone(),
api_key_env_var: EnvVar::new(api_key_env_var_name),
api_key_state: ApiKeyState::new(SharedString::new(settings.api_url.as_str())),
api_key_state: ApiKeyState::new(
SharedString::new(settings.api_url.as_str()),
EnvVar::new(api_key_env_var_name),
),
settings,
}
});
@@ -437,7 +431,7 @@ impl Render for ConfigurationView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let state = self.state.read(cx);
let env_var_set = state.api_key_state.is_from_env_var();
let env_var_name = &state.api_key_env_var.name;
let env_var_name = state.api_key_state.env_var_name();
let api_key_section = if self.should_render_editor(cx) {
v_flex()

View File

@@ -4,11 +4,12 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task};
use http_client::HttpClient;
use language_model::{
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage,
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role,
StopReason, TokenUsage, env_var,
};
use open_router::{
Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ResponseStreamEvent, list_models,
@@ -17,13 +18,9 @@ use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsSto
use std::pin::Pin;
use std::str::FromStr as _;
use std::sync::{Arc, LazyLock};
use ui::{List, prelude::*};
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use zed_env_vars::{EnvVar, env_var};
use crate::ui::ConfiguredApiCard;
use crate::{api_key::ApiKeyState, ui::InstructionListItem};
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openrouter");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenRouter");
@@ -62,12 +59,9 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = OpenRouterLanguageModelProvider::api_url(cx);
let task = self.api_key_state.load_if_needed(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
);
let task = self
.api_key_state
.load_if_needed(api_url, |this| &mut this.api_key_state, cx);
cx.spawn(async move |this, cx| {
let result = task.await;
@@ -135,7 +129,7 @@ impl OpenRouterLanguageModelProvider {
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx)),
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
http_client: http_client.clone(),
available_models: Vec::new(),
fetch_models_task: None,
@@ -830,17 +824,15 @@ impl Render for ConfigurationView {
.child(Label::new("To use Zed's agent with OpenRouter, you need to add an API key. Follow these steps:"))
.child(
List::new()
.child(InstructionListItem::new(
"Create an API key by visiting",
Some("OpenRouter's console"),
Some("https://openrouter.ai/keys"),
))
.child(InstructionListItem::text_only(
"Ensure your OpenRouter account has credits",
))
.child(InstructionListItem::text_only(
"Paste your API key below and hit enter to start using the assistant",
)),
.child(
ListBulletItem::new("")
.child(Label::new("Create an API key by visiting"))
.child(ButtonLink::new("OpenRouter's console", "https://openrouter.ai/keys"))
)
.child(ListBulletItem::new("Ensure your OpenRouter account has credits")
)
.child(ListBulletItem::new("Paste your API key below and hit enter to start using the assistant")
),
)
.child(self.api_key_editor.clone())
.child(

View File

@@ -4,26 +4,20 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
use http_client::HttpClient;
use language_model::{
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelToolChoice, RateLimiter, Role,
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelToolChoice, RateLimiter, Role, env_var,
};
use open_ai::ResponseStreamEvent;
pub use settings::VercelAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore};
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
use ui::{List, prelude::*};
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use vercel::{Model, VERCEL_API_URL};
use zed_env_vars::{EnvVar, env_var};
use crate::{
api_key::ApiKeyState,
ui::{ConfiguredApiCard, InstructionListItem},
};
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("vercel");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Vercel");
@@ -59,12 +53,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = VercelLanguageModelProvider::api_url(cx);
self.api_key_state.load_if_needed(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
)
self.api_key_state
.load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -73,17 +63,13 @@ impl VercelLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
this.api_key_state.handle_url_change(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
);
this.api_key_state
.handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx)),
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
});
@@ -472,14 +458,14 @@ impl Render for ConfigurationView {
.child(Label::new("To use Zed's agent with Vercel v0, you need to add an API key. Follow these steps:"))
.child(
List::new()
.child(InstructionListItem::new(
"Create one by visiting",
Some("Vercel v0's console"),
Some("https://v0.dev/chat/settings/keys"),
))
.child(InstructionListItem::text_only(
"Paste your API key below and hit enter to start using the agent",
)),
.child(
ListBulletItem::new("")
.child(Label::new("Create one by visiting"))
.child(ButtonLink::new("Vercel v0's console", "https://v0.dev/chat/settings/keys"))
)
.child(
ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
),
)
.child(self.api_key_editor.clone())
.child(

View File

@@ -4,26 +4,21 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window};
use http_client::HttpClient;
use language_model::{
AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, Role,
ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
Role, env_var,
};
use open_ai::ResponseStreamEvent;
pub use settings::XaiAvailableModel as AvailableModel;
use settings::{Settings, SettingsStore};
use std::sync::{Arc, LazyLock};
use strum::IntoEnumIterator;
use ui::{List, prelude::*};
use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
use ui_input::InputField;
use util::ResultExt;
use x_ai::{Model, XAI_API_URL};
use zed_env_vars::{EnvVar, env_var};
use crate::{
api_key::ApiKeyState,
ui::{ConfiguredApiCard, InstructionListItem},
};
const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai");
const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("xAI");
@@ -59,12 +54,8 @@ impl State {
fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
let api_url = XAiLanguageModelProvider::api_url(cx);
self.api_key_state.load_if_needed(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
)
self.api_key_state
.load_if_needed(api_url, |this| &mut this.api_key_state, cx)
}
}
@@ -73,17 +64,13 @@ impl XAiLanguageModelProvider {
let state = cx.new(|cx| {
cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
let api_url = Self::api_url(cx);
this.api_key_state.handle_url_change(
api_url,
&API_KEY_ENV_VAR,
|this| &mut this.api_key_state,
cx,
);
this.api_key_state
.handle_url_change(api_url, |this| &mut this.api_key_state, cx);
cx.notify();
})
.detach();
State {
api_key_state: ApiKeyState::new(Self::api_url(cx)),
api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
}
});
@@ -474,14 +461,14 @@ impl Render for ConfigurationView {
.child(Label::new("To use Zed's agent with xAI, you need to add an API key. Follow these steps:"))
.child(
List::new()
.child(InstructionListItem::new(
"Create one by visiting",
Some("xAI console"),
Some("https://console.x.ai/team/default/api-keys"),
))
.child(InstructionListItem::text_only(
"Paste your API key below and hit enter to start using the agent",
)),
.child(
ListBulletItem::new("")
.child(Label::new("Create one by visiting"))
.child(ButtonLink::new("xAI console", "https://console.x.ai/team/default/api-keys"))
)
.child(
ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
),
)
.child(self.api_key_editor.clone())
.child(

View File

@@ -1,4 +0,0 @@
pub mod configured_api_card;
pub mod instruction_list_item;
pub use configured_api_card::ConfiguredApiCard;
pub use instruction_list_item::InstructionListItem;

View File

@@ -1,69 +0,0 @@
use gpui::{AnyElement, IntoElement, ParentElement, SharedString};
use ui::{ListItem, prelude::*};
/// A reusable list item component for adding LLM provider configuration instructions
pub struct InstructionListItem {
label: SharedString,
button_label: Option<SharedString>,
button_link: Option<String>,
}
impl InstructionListItem {
pub fn new(
label: impl Into<SharedString>,
button_label: Option<impl Into<SharedString>>,
button_link: Option<impl Into<String>>,
) -> Self {
Self {
label: label.into(),
button_label: button_label.map(|l| l.into()),
button_link: button_link.map(|l| l.into()),
}
}
pub fn text_only(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
button_label: None,
button_link: None,
}
}
}
impl IntoElement for InstructionListItem {
type Element = AnyElement;
fn into_element(self) -> Self::Element {
let item_content = if let (Some(button_label), Some(button_link)) =
(self.button_label, self.button_link)
{
let link = button_link;
let unique_id = SharedString::from(format!("{}-button", self.label));
h_flex()
.flex_wrap()
.child(Label::new(self.label))
.child(
Button::new(unique_id, button_label)
.style(ButtonStyle::Subtle)
.icon(IconName::ArrowUpRight)
.icon_size(IconSize::Small)
.icon_color(Color::Muted)
.on_click(move |_, _window, cx| cx.open_url(&link)),
)
.into_any_element()
} else {
Label::new(self.label).into_any_element()
};
ListItem::new("list-item")
.selectable(false)
.start_slot(
Icon::new(IconName::Dash)
.size(IconSize::XSmall)
.color(Color::Hidden),
)
.child(div().w_full().child(item_content))
.into_any_element()
}
}

View File

@@ -186,22 +186,20 @@ pub struct CopilotSettingsContent {
pub enterprise_uri: Option<String>,
}
#[with_fallible_options]
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
pub struct CodestralSettingsContent {
/// Model to use for completions.
///
/// Default: "codestral-latest"
#[serde(default)]
pub model: Option<String>,
/// Maximum tokens to generate.
///
/// Default: 150
#[serde(default)]
pub max_tokens: Option<u32>,
/// Api URL to use for completions.
///
/// Default: "https://codestral.mistral.ai"
#[serde(default)]
pub api_url: Option<String>,
}

View File

@@ -18,6 +18,9 @@ test-support = []
[dependencies]
anyhow.workspace = true
bm25 = "2.3.2"
copilot.workspace = true
edit_prediction.workspace = true
language_models.workspace = true
editor.workspace = true
feature_flags.workspace = true
fs.workspace = true
@@ -38,8 +41,8 @@ strum.workspace = true
telemetry.workspace = true
theme.workspace = true
title_bar.workspace = true
ui.workspace = true
ui_input.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true

View File

@@ -2,10 +2,12 @@ mod dropdown;
mod font_picker;
mod icon_theme_picker;
mod input_field;
mod section_items;
mod theme_picker;
pub use dropdown::*;
pub use font_picker::font_picker;
pub use icon_theme_picker::icon_theme_picker;
pub use input_field::*;
pub use section_items::*;
pub use theme_picker::theme_picker;

View File

@@ -13,6 +13,7 @@ pub struct SettingsInputField {
tab_index: Option<isize>,
}
// TODO: Update the `ui_input::InputField` to use `window.use_state` and `RenceOnce` and remove this component
impl SettingsInputField {
pub fn new() -> Self {
Self {

View File

@@ -0,0 +1,56 @@
use gpui::{IntoElement, ParentElement, Styled};
use ui::{Divider, DividerColor, prelude::*};
#[derive(IntoElement)]
pub struct SettingsSectionHeader {
icon: Option<IconName>,
label: SharedString,
no_padding: bool,
}
impl SettingsSectionHeader {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
icon: None,
no_padding: false,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn no_padding(mut self, no_padding: bool) -> Self {
self.no_padding = no_padding;
self
}
}
impl RenderOnce for SettingsSectionHeader {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let label = Label::new(self.label)
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx);
v_flex()
.w_full()
.when(!self.no_padding, |this| this.px_8())
.gap_1p5()
.map(|this| {
if self.icon.is_some() {
this.child(
h_flex()
.gap_1p5()
.child(Icon::new(self.icon.unwrap()).color(Color::Muted))
.child(label),
)
} else {
this.child(label)
}
})
.child(Divider::horizontal().color(DividerColor::BorderFaded))
}
}

View File

@@ -2330,8 +2330,12 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
// Note that `crates/json_schema_store` solves the same problem, there is probably a way to unify the two
items.push(SettingsPageItem::SectionHeader(LANGUAGES_SECTION_HEADER));
items.extend(all_language_names(cx).into_iter().map(|language_name| {
let link = format!("languages.{language_name}");
SettingsPageItem::SubPageLink(SubPageLink {
title: language_name,
description: None,
json_path: Some(link.leak()),
in_json: true,
files: USER | PROJECT,
render: Arc::new(|this, window, cx| {
this.render_sub_page_items(
@@ -6013,7 +6017,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "In Text Threads",
title: "Display In Text Threads",
description: "Whether edit predictions are enabled when editing text threads in the agent panel.",
field: Box::new(SettingField {
json_path: Some("edit_prediction.in_text_threads"),
@@ -6027,42 +6031,6 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Copilot Provider",
description: "Use GitHub Copilot as your edit prediction provider.",
field: Box::new(
SettingField {
json_path: Some("edit_prediction.copilot_provider"),
pick: |settings_content| {
settings_content.project.all_languages.edit_predictions.as_ref()?.copilot.as_ref()
},
write: |settings_content, value| {
settings_content.project.all_languages.edit_predictions.get_or_insert_default().copilot = value;
},
}
.unimplemented(),
),
metadata: None,
files: USER | PROJECT,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Codestral Provider",
description: "Use Mistral's Codestral as your edit prediction provider.",
field: Box::new(
SettingField {
json_path: Some("edit_prediction.codestral_provider"),
pick: |settings_content| {
settings_content.project.all_languages.edit_predictions.as_ref()?.codestral.as_ref()
},
write: |settings_content, value| {
settings_content.project.all_languages.edit_predictions.get_or_insert_default().codestral = value;
},
}
.unimplemented(),
),
metadata: None,
files: USER | PROJECT,
}),
]
);
items
@@ -7485,9 +7453,23 @@ fn non_editor_language_settings_data() -> Vec<SettingsPageItem> {
fn edit_prediction_language_settings_section() -> Vec<SettingsPageItem> {
vec![
SettingsPageItem::SectionHeader("Edit Predictions"),
SettingsPageItem::SubPageLink(SubPageLink {
title: "Configure Providers".into(),
json_path: Some("edit_predictions.providers"),
description: Some("Set up different edit prediction providers in complement to Zed's built-in Zeta model.".into()),
in_json: false,
files: USER,
render: Arc::new(|_, window, cx| {
let settings_window = cx.entity();
let page = window.use_state(cx, |_, _| {
crate::pages::EditPredictionSetupPage::new(settings_window)
});
page.into_any_element()
}),
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Show Edit Predictions",
description: "Controls whether edit predictions are shown immediately or manually by triggering `editor::showeditprediction` (false).",
description: "Controls whether edit predictions are shown immediately or manually.",
field: Box::new(SettingField {
json_path: Some("languages.$(language).show_edit_predictions"),
pick: |settings_content| {
@@ -7505,7 +7487,7 @@ fn edit_prediction_language_settings_section() -> Vec<SettingsPageItem> {
files: USER | PROJECT,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Edit Predictions Disabled In",
title: "Disable in Language Scopes",
description: "Controls whether edit predictions are shown in the given language scopes.",
field: Box::new(
SettingField {

View File

@@ -0,0 +1,2 @@
mod edit_prediction_provider_setup;
pub use edit_prediction_provider_setup::EditPredictionSetupPage;

View File

@@ -0,0 +1,365 @@
use edit_prediction::{
ApiKeyState, Zeta2FeatureFlag,
mercury::{MERCURY_CREDENTIALS_URL, mercury_api_token},
sweep_ai::{SWEEP_CREDENTIALS_URL, sweep_api_token},
};
use feature_flags::FeatureFlagAppExt as _;
use gpui::{Entity, ScrollHandle, prelude::*};
use language_models::provider::mistral::{CODESTRAL_API_URL, codestral_api_key};
use ui::{ButtonLink, ConfiguredApiCard, WithScrollbar, prelude::*};
use crate::{
SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER,
components::{SettingsInputField, SettingsSectionHeader},
};
pub struct EditPredictionSetupPage {
settings_window: Entity<SettingsWindow>,
scroll_handle: ScrollHandle,
}
impl EditPredictionSetupPage {
pub fn new(settings_window: Entity<SettingsWindow>) -> Self {
Self {
settings_window,
scroll_handle: ScrollHandle::new(),
}
}
}
impl Render for EditPredictionSetupPage {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let settings_window = self.settings_window.clone();
let providers = [
Some(render_github_copilot_provider(window, cx).into_any_element()),
cx.has_flag::<Zeta2FeatureFlag>().then(|| {
render_api_key_provider(
IconName::Inception,
"Mercury",
"https://platform.inceptionlabs.ai/dashboard/api-keys".into(),
mercury_api_token(cx),
|_cx| MERCURY_CREDENTIALS_URL,
None,
window,
cx,
)
.into_any_element()
}),
cx.has_flag::<Zeta2FeatureFlag>().then(|| {
render_api_key_provider(
IconName::SweepAi,
"Sweep",
"https://app.sweep.dev/".into(),
sweep_api_token(cx),
|_cx| SWEEP_CREDENTIALS_URL,
None,
window,
cx,
)
.into_any_element()
}),
Some(
render_api_key_provider(
IconName::AiMistral,
"Codestral",
"https://console.mistral.ai/codestral".into(),
codestral_api_key(cx),
|cx| language_models::MistralLanguageModelProvider::api_url(cx),
Some(settings_window.update(cx, |settings_window, cx| {
let codestral_settings = codestral_settings();
settings_window
.render_sub_page_items_section(
codestral_settings.iter().enumerate(),
None,
window,
cx,
)
.into_any_element()
})),
window,
cx,
)
.into_any_element(),
),
];
div()
.size_full()
.vertical_scrollbar_for(&self.scroll_handle, window, cx)
.child(
v_flex()
.id("ep-setup-page")
.min_w_0()
.size_full()
.px_8()
.pb_16()
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
.children(providers.into_iter().flatten()),
)
}
}
fn render_api_key_provider(
icon: IconName,
title: &'static str,
link: SharedString,
api_key_state: Entity<ApiKeyState>,
current_url: fn(&mut App) -> SharedString,
additional_fields: Option<AnyElement>,
window: &mut Window,
cx: &mut Context<EditPredictionSetupPage>,
) -> impl IntoElement {
let weak_page = cx.weak_entity();
_ = window.use_keyed_state(title, cx, |_, cx| {
let task = api_key_state.update(cx, |key_state, cx| {
key_state.load_if_needed(current_url(cx), |state| state, cx)
});
cx.spawn(async move |_, cx| {
task.await.ok();
weak_page
.update(cx, |_, cx| {
cx.notify();
})
.ok();
})
});
let (has_key, env_var_name, is_from_env_var) = api_key_state.read_with(cx, |state, _| {
(
state.has_key(),
Some(state.env_var_name().clone()),
state.is_from_env_var(),
)
});
let write_key = move |api_key: Option<String>, cx: &mut App| {
api_key_state
.update(cx, |key_state, cx| {
let url = current_url(cx);
key_state.store(url, api_key, |key_state| key_state, cx)
})
.detach_and_log_err(cx);
};
let base_container = v_flex().id(title).min_w_0().pt_8().gap_1p5();
let header = SettingsSectionHeader::new(title)
.icon(icon)
.no_padding(true);
let button_link_label = format!("{} dashboard", title);
let description = h_flex()
.min_w_0()
.gap_0p5()
.child(
Label::new("Visit the")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
ButtonLink::new(button_link_label, link)
.no_icon(true)
.label_size(LabelSize::Small)
.label_color(Color::Muted),
)
.child(
Label::new("to generate an API key.")
.size(LabelSize::Small)
.color(Color::Muted),
);
let configured_card_label = if is_from_env_var {
"API Key Set in Environment Variable"
} else {
"API Key Configured"
};
let container = if has_key {
base_container.child(header).child(
ConfiguredApiCard::new(configured_card_label)
.button_label("Reset Key")
.button_tab_index(0)
.disabled(is_from_env_var)
.when_some(env_var_name, |this, env_var_name| {
this.when(is_from_env_var, |this| {
this.tooltip_label(format!(
"To reset your API key, unset the {} environment variable.",
env_var_name
))
})
})
.on_click(move |_, _, cx| {
write_key(None, cx);
}),
)
} else {
base_container.child(header).child(
h_flex()
.pt_2p5()
.w_full()
.justify_between()
.child(
v_flex()
.w_full()
.max_w_1_2()
.child(Label::new("API Key"))
.child(description)
.when_some(env_var_name, |this, env_var_name| {
this.child({
let label = format!(
"Or set the {} env var and restart Zed.",
env_var_name.as_ref()
);
Label::new(label).size(LabelSize::Small).color(Color::Muted)
})
}),
)
.child(
SettingsInputField::new()
.tab_index(0)
.with_placeholder("xxxxxxxxxxxxxxxxxxxx")
.on_confirm(move |api_key, cx| {
write_key(api_key.filter(|key| !key.is_empty()), cx);
}),
),
)
};
container.when_some(additional_fields, |this, additional_fields| {
this.child(
div()
.map(|this| if has_key { this.mt_1() } else { this.mt_4() })
.px_neg_8()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(additional_fields),
)
})
}
fn codestral_settings() -> Box<[SettingsPageItem]> {
Box::new([
SettingsPageItem::SettingItem(SettingItem {
title: "API URL",
description: "The API URL to use for Codestral.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.codestral
.as_ref()?
.api_url
.as_ref()
},
write: |settings, value| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.codestral
.get_or_insert_default()
.api_url = value;
},
json_path: Some("edit_predictions.codestral.api_url"),
}),
metadata: Some(Box::new(SettingsFieldMetadata {
placeholder: Some(CODESTRAL_API_URL),
..Default::default()
})),
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Max Tokens",
description: "The maximum number of tokens to generate.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.codestral
.as_ref()?
.max_tokens
.as_ref()
},
write: |settings, value| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.codestral
.get_or_insert_default()
.max_tokens = value;
},
json_path: Some("edit_predictions.codestral.max_tokens"),
}),
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Model",
description: "The Codestral model id to use.",
field: Box::new(SettingField {
pick: |settings| {
settings
.project
.all_languages
.edit_predictions
.as_ref()?
.codestral
.as_ref()?
.model
.as_ref()
},
write: |settings, value| {
settings
.project
.all_languages
.edit_predictions
.get_or_insert_default()
.codestral
.get_or_insert_default()
.model = value;
},
json_path: Some("edit_predictions.codestral.model"),
}),
metadata: Some(Box::new(SettingsFieldMetadata {
placeholder: Some("codestral-latest"),
..Default::default()
})),
files: USER,
}),
])
}
pub(crate) fn render_github_copilot_provider(
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let configuration_view = window.use_state(cx, |_, cx| {
copilot::ConfigurationView::new(
|cx| {
copilot::Copilot::global(cx)
.is_some_and(|copilot| copilot.read(cx).is_authenticated())
},
copilot::ConfigurationMode::EditPrediction,
cx,
)
});
v_flex()
.id("github-copilot")
.min_w_0()
.gap_1p5()
.child(
SettingsSectionHeader::new("GitHub Copilot")
.icon(IconName::Copilot)
.no_padding(true),
)
.child(configuration_view)
}

View File

@@ -1,5 +1,6 @@
mod components;
mod page_data;
mod pages;
use anyhow::Result;
use editor::{Editor, EditorEvent};
@@ -28,9 +29,8 @@ use std::{
};
use title_bar::platform_title_bar::PlatformTitleBar;
use ui::{
Banner, ContextMenu, Divider, DividerColor, DropdownMenu, DropdownStyle, IconButtonShape,
KeyBinding, KeybindingHint, PopoverMenu, Switch, Tooltip, TreeViewItem, WithScrollbar,
prelude::*,
Banner, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, KeyBinding,
KeybindingHint, PopoverMenu, Switch, Tooltip, TreeViewItem, WithScrollbar, prelude::*,
};
use ui_input::{NumberField, NumberFieldType};
use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
@@ -38,7 +38,8 @@ use workspace::{AppState, OpenOptions, OpenVisible, Workspace, client_side_decor
use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt};
use crate::components::{
EnumVariantDropdown, SettingsInputField, font_picker, icon_theme_picker, theme_picker,
EnumVariantDropdown, SettingsInputField, SettingsSectionHeader, font_picker, icon_theme_picker,
theme_picker,
};
const NAVBAR_CONTAINER_TAB_INDEX: isize = 0;
@@ -613,7 +614,10 @@ pub fn open_settings_editor(
app_id: Some(app_id.to_owned()),
window_decorations: Some(window_decorations),
window_min_size: Some(gpui::Size {
width: px(360.0),
// Don't make the settings window thinner than this,
// otherwise, it gets unusable. Users with smaller res monitors
// can customize the height, but not the width.
width: px(900.0),
height: px(240.0),
}),
window_bounds: Some(WindowBounds::centered(scaled_bounds, cx)),
@@ -834,18 +838,9 @@ impl SettingsPageItem {
};
match self {
SettingsPageItem::SectionHeader(header) => v_flex()
.w_full()
.px_8()
.gap_1p5()
.child(
Label::new(SharedString::new_static(header))
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx),
)
.child(Divider::horizontal().color(DividerColor::BorderFaded))
.into_any_element(),
SettingsPageItem::SectionHeader(header) => {
SettingsSectionHeader::new(SharedString::new_static(header)).into_any_element()
}
SettingsPageItem::SettingItem(setting_item) => {
let (field_with_padding, _) =
render_setting_item_inner(setting_item, true, false, cx);
@@ -869,9 +864,20 @@ impl SettingsPageItem {
.map(apply_padding)
.child(
v_flex()
.relative()
.w_full()
.max_w_1_2()
.child(Label::new(sub_page_link.title.clone())),
.child(Label::new(sub_page_link.title.clone()))
.when_some(
sub_page_link.description.as_ref(),
|this, description| {
this.child(
Label::new(description.clone())
.size(LabelSize::Small)
.color(Color::Muted),
)
},
),
)
.child(
Button::new(
@@ -909,7 +915,13 @@ impl SettingsPageItem {
this.push_sub_page(sub_page_link.clone(), header, cx)
})
}),
),
)
.child(render_settings_item_link(
sub_page_link.title.clone(),
sub_page_link.json_path,
false,
cx,
)),
)
.when(!is_last, |this| this.child(Divider::horizontal()))
.into_any_element(),
@@ -983,20 +995,6 @@ fn render_settings_item(
let (found_in_file, _) = setting_item.field.file_set_in(file.clone(), cx);
let file_set_in = SettingsUiFile::from_settings(found_in_file.clone());
let clipboard_has_link = cx
.read_from_clipboard()
.and_then(|entry| entry.text())
.map_or(false, |maybe_url| {
setting_item.field.json_path().is_some()
&& maybe_url.strip_prefix("zed://settings/") == setting_item.field.json_path()
});
let (link_icon, link_icon_color) = if clipboard_has_link {
(IconName::Check, Color::Success)
} else {
(IconName::Link, Color::Muted)
};
h_flex()
.id(setting_item.title)
.min_w_0()
@@ -1056,42 +1054,62 @@ fn render_settings_item(
)
.child(control)
.when(sub_page_stack().is_empty(), |this| {
// Intentionally using the description to make the icon button
// unique because some items share the same title (e.g., "Font Size")
let icon_button_id =
SharedString::new(format!("copy-link-btn-{}", setting_item.description));
this.child(
div()
.absolute()
.top(rems_from_px(18.))
.map(|this| {
if sub_field {
this.visible_on_hover("setting-sub-item")
.left(rems_from_px(-8.5))
} else {
this.visible_on_hover("setting-item")
.left(rems_from_px(-22.))
}
})
.child({
IconButton::new(icon_button_id, link_icon)
.icon_color(link_icon_color)
.icon_size(IconSize::Small)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::text("Copy Link"))
.when_some(setting_item.field.json_path(), |this, path| {
this.on_click(cx.listener(move |_, _, _, cx| {
let link = format!("zed://settings/{}", path);
cx.write_to_clipboard(ClipboardItem::new_string(link));
cx.notify();
}))
})
}),
)
this.child(render_settings_item_link(
setting_item.description,
setting_item.field.json_path(),
sub_field,
cx,
))
})
}
fn render_settings_item_link(
id: impl Into<ElementId>,
json_path: Option<&'static str>,
sub_field: bool,
cx: &mut Context<'_, SettingsWindow>,
) -> impl IntoElement {
let clipboard_has_link = cx
.read_from_clipboard()
.and_then(|entry| entry.text())
.map_or(false, |maybe_url| {
json_path.is_some() && maybe_url.strip_prefix("zed://settings/") == json_path
});
let (link_icon, link_icon_color) = if clipboard_has_link {
(IconName::Check, Color::Success)
} else {
(IconName::Link, Color::Muted)
};
div()
.absolute()
.top(rems_from_px(18.))
.map(|this| {
if sub_field {
this.visible_on_hover("setting-sub-item")
.left(rems_from_px(-8.5))
} else {
this.visible_on_hover("setting-item")
.left(rems_from_px(-22.))
}
})
.child(
IconButton::new((id.into(), "copy-link-btn"), link_icon)
.icon_color(link_icon_color)
.icon_size(IconSize::Small)
.shape(IconButtonShape::Square)
.tooltip(Tooltip::text("Copy Link"))
.when_some(json_path, |this, path| {
this.on_click(cx.listener(move |_, _, _, cx| {
let link = format!("zed://settings/{}", path);
cx.write_to_clipboard(ClipboardItem::new_string(link));
cx.notify();
}))
}),
)
}
struct SettingItem {
title: &'static str,
description: &'static str,
@@ -1175,6 +1193,12 @@ impl PartialEq for SettingItem {
#[derive(Clone)]
struct SubPageLink {
title: SharedString,
description: Option<SharedString>,
/// See [`SettingField.json_path`]
json_path: Option<&'static str>,
/// Whether or not the settings in this sub page are configurable in settings.json
/// Removes the "Edit in settings.json" button from the page.
in_json: bool,
files: FileMask,
render: Arc<
dyn Fn(&mut SettingsWindow, &mut Window, &mut Context<SettingsWindow>) -> AnyElement
@@ -1835,6 +1859,7 @@ impl SettingsWindow {
header_str = *header;
}
SettingsPageItem::SubPageLink(sub_page_link) => {
json_path = sub_page_link.json_path;
documents.push(bm25::Document {
id: key_index,
contents: [page.title, header_str, sub_page_link.title.as_ref()]
@@ -2758,19 +2783,49 @@ impl SettingsWindow {
page_content
}
fn render_sub_page_items<'a, Items: Iterator<Item = (usize, &'a SettingsPageItem)>>(
fn render_sub_page_items<'a, Items>(
&self,
items: Items,
page_index: Option<usize>,
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) -> impl IntoElement {
let mut page_content = v_flex()
) -> impl IntoElement
where
Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
{
let page_content = v_flex()
.id("settings-ui-page")
.size_full()
.overflow_y_scroll()
.track_scroll(&self.sub_page_scroll_handle);
self.render_sub_page_items_in(page_content, items, page_index, window, cx)
}
fn render_sub_page_items_section<'a, Items>(
&self,
items: Items,
page_index: Option<usize>,
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) -> impl IntoElement
where
Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
{
let page_content = v_flex().id("settings-ui-sub-page-section").size_full();
self.render_sub_page_items_in(page_content, items, page_index, window, cx)
}
fn render_sub_page_items_in<'a, Items>(
&self,
mut page_content: Stateful<Div>,
items: Items,
page_index: Option<usize>,
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) -> impl IntoElement
where
Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
{
let items: Vec<_> = items.collect();
let items_len = items.len();
let mut section_header = None;
@@ -2871,18 +2926,25 @@ impl SettingsWindow {
)
.child(self.render_sub_page_breadcrumbs()),
)
.child(
Button::new("open-in-settings-file", "Edit in settings.json")
.tab_index(0_isize)
.style(ButtonStyle::OutlinedGhost)
.tooltip(Tooltip::for_action_title_in(
"Edit in settings.json",
&OpenCurrentFile,
&self.focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.open_current_settings_file(window, cx);
})),
.when(
sub_page_stack()
.last()
.is_none_or(|sub_page| sub_page.link.in_json),
|this| {
this.child(
Button::new("open-in-settings-file", "Edit in settings.json")
.tab_index(0_isize)
.style(ButtonStyle::OutlinedGhost)
.tooltip(Tooltip::for_action_title_in(
"Edit in settings.json",
&OpenCurrentFile,
&self.focus_handle,
))
.on_click(cx.listener(|this, _, window, cx| {
this.open_current_settings_file(window, cx);
})),
)
},
)
.into_any_element();

View File

@@ -1,3 +1,4 @@
mod ai;
mod avatar;
mod banner;
mod button;
@@ -16,6 +17,7 @@ mod icon;
mod image;
mod indent_guides;
mod indicator;
mod inline_code;
mod keybinding;
mod keybinding_hint;
mod label;
@@ -43,6 +45,7 @@ mod tree_view_item;
#[cfg(feature = "stories")]
mod stories;
pub use ai::*;
pub use avatar::*;
pub use banner::*;
pub use button::*;
@@ -61,6 +64,7 @@ pub use icon::*;
pub use image::*;
pub use indent_guides::*;
pub use indicator::*;
pub use inline_code::*;
pub use keybinding::*;
pub use keybinding_hint::*;
pub use label::*;

View File

@@ -0,0 +1,3 @@
mod configured_api_card;
pub use configured_api_card::*;

View File

@@ -1,10 +1,11 @@
use crate::{Tooltip, prelude::*};
use gpui::{ClickEvent, IntoElement, ParentElement, SharedString};
use ui::{Tooltip, prelude::*};
#[derive(IntoElement)]
pub struct ConfiguredApiCard {
label: SharedString,
button_label: Option<SharedString>,
button_tab_index: Option<isize>,
tooltip_label: Option<SharedString>,
disabled: bool,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
@@ -15,6 +16,7 @@ impl ConfiguredApiCard {
Self {
label: label.into(),
button_label: None,
button_tab_index: None,
tooltip_label: None,
disabled: false,
on_click: None,
@@ -43,6 +45,11 @@ impl ConfiguredApiCard {
self.disabled = disabled;
self
}
pub fn button_tab_index(mut self, tab_index: isize) -> Self {
self.button_tab_index = Some(tab_index);
self
}
}
impl RenderOnce for ConfiguredApiCard {
@@ -51,23 +58,27 @@ impl RenderOnce for ConfiguredApiCard {
let button_id = SharedString::new(format!("id-{}", button_label));
h_flex()
.min_w_0()
.mt_0p5()
.p_1()
.justify_between()
.rounded_md()
.flex_wrap()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().background)
.child(
h_flex()
.flex_1()
.min_w_0()
.gap_1()
.child(Icon::new(IconName::Check).color(Color::Success))
.child(Label::new(self.label).truncate()),
.child(Label::new(self.label)),
)
.child(
Button::new(button_id, button_label)
.when_some(self.button_tab_index, |elem, tab_index| {
elem.tab_index(tab_index)
})
.label_size(LabelSize::Small)
.icon(IconName::Undo)
.icon_size(IconSize::Small)

View File

@@ -1,12 +1,14 @@
mod button;
mod button_icon;
mod button_like;
mod button_link;
mod icon_button;
mod split_button;
mod toggle_button;
pub use button::*;
pub use button_like::*;
pub use button_link::*;
pub use icon_button::*;
pub use split_button::*;
pub use toggle_button::*;

View File

@@ -0,0 +1,102 @@
use gpui::{IntoElement, Window, prelude::*};
use crate::{ButtonLike, prelude::*};
/// A button that takes an underline to look like a regular web link.
/// It also contains an arrow icon to communicate the link takes you out of Zed.
///
/// # Usage Example
///
/// ```
/// use ui::ButtonLink;
///
/// let button_link = ButtonLink::new("Click me", "https://example.com");
/// ```
#[derive(IntoElement, RegisterComponent)]
pub struct ButtonLink {
label: SharedString,
label_size: LabelSize,
label_color: Color,
link: String,
no_icon: bool,
}
impl ButtonLink {
pub fn new(label: impl Into<SharedString>, link: impl Into<String>) -> Self {
Self {
link: link.into(),
label: label.into(),
label_size: LabelSize::Default,
label_color: Color::Default,
no_icon: false,
}
}
pub fn no_icon(mut self, no_icon: bool) -> Self {
self.no_icon = no_icon;
self
}
pub fn label_size(mut self, label_size: LabelSize) -> Self {
self.label_size = label_size;
self
}
pub fn label_color(mut self, label_color: Color) -> Self {
self.label_color = label_color;
self
}
}
impl RenderOnce for ButtonLink {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let id = format!("{}-{}", self.label, self.link);
ButtonLike::new(id)
.size(ButtonSize::None)
.child(
h_flex()
.gap_0p5()
.child(
Label::new(self.label)
.size(self.label_size)
.color(self.label_color)
.underline(),
)
.when(!self.no_icon, |this| {
this.child(
Icon::new(IconName::ArrowUpRight)
.size(IconSize::Small)
.color(Color::Muted),
)
}),
)
.on_click(move |_, _, cx| cx.open_url(&self.link))
.into_any_element()
}
}
impl Component for ButtonLink {
fn scope() -> ComponentScope {
ComponentScope::Navigation
}
fn description() -> Option<&'static str> {
Some("A button that opens a URL.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.child(
example_group(vec![single_example(
"Simple",
ButtonLink::new("zed.dev", "https://zed.dev").into_any_element(),
)])
.vertical(),
)
.into_any_element(),
)
}
}

View File

@@ -144,12 +144,18 @@ impl Divider {
impl RenderOnce for Divider {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let base = match self.direction {
DividerDirection::Horizontal => {
div().h_px().w_full().when(self.inset, |this| this.mx_1p5())
}
DividerDirection::Vertical => {
div().w_px().h_full().when(self.inset, |this| this.my_1p5())
}
DividerDirection::Horizontal => div()
.min_w_0()
.flex_none()
.h_px()
.w_full()
.when(self.inset, |this| this.mx_1p5()),
DividerDirection::Vertical => div()
.min_w_0()
.flex_none()
.w_px()
.h_full()
.when(self.inset, |this| this.my_1p5()),
};
match self.style {

View File

@@ -0,0 +1,64 @@
use crate::prelude::*;
use gpui::{AnyElement, IntoElement, ParentElement, Styled};
/// InlineCode mimics the way inline code is rendered when wrapped in backticks in Markdown.
///
/// # Usage Example
///
/// ```
/// use ui::InlineCode;
///
/// let InlineCode = InlineCode::new("<div>hey</div>");
/// ```
#[derive(IntoElement, RegisterComponent)]
pub struct InlineCode {
label: SharedString,
label_size: LabelSize,
}
impl InlineCode {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
label_size: LabelSize::Default,
}
}
/// Sets the size of the label.
pub fn label_size(mut self, size: LabelSize) -> Self {
self.label_size = size;
self
}
}
impl RenderOnce for InlineCode {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.min_w_0()
.px_0p5()
.overflow_hidden()
.bg(cx.theme().colors().text.opacity(0.05))
.child(Label::new(self.label).size(self.label_size).buffer_font(cx))
}
}
impl Component for InlineCode {
fn scope() -> ComponentScope {
ComponentScope::DataDisplay
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.child(
example_group(vec![single_example(
"Simple",
InlineCode::new("zed.dev").into_any_element(),
)])
.vertical(),
)
.into_any_element(),
)
}
}

View File

@@ -227,7 +227,7 @@ impl RenderOnce for LabelLike {
.get_or_insert_with(Default::default)
.underline = Some(UnderlineStyle {
thickness: px(1.),
color: None,
color: Some(cx.theme().colors().text_muted.opacity(0.4)),
wavy: false,
});
this

View File

@@ -1,18 +1,33 @@
use crate::{ListItem, prelude::*};
use component::{Component, ComponentScope, example_group_with_title, single_example};
use crate::{ButtonLink, ListItem, prelude::*};
use component::{Component, ComponentScope, example_group, single_example};
use gpui::{IntoElement, ParentElement, SharedString};
#[derive(IntoElement, RegisterComponent)]
pub struct ListBulletItem {
label: SharedString,
label_color: Option<Color>,
children: Vec<AnyElement>,
}
impl ListBulletItem {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
label_color: None,
children: Vec::new(),
}
}
pub fn label_color(mut self, color: Color) -> Self {
self.label_color = Some(color);
self
}
}
impl ParentElement for ListBulletItem {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for ListBulletItem {
@@ -34,7 +49,18 @@ impl RenderOnce for ListBulletItem {
.color(Color::Hidden),
),
)
.child(div().w_full().min_w_0().child(Label::new(self.label))),
.map(|this| {
if !self.children.is_empty() {
this.child(h_flex().gap_0p5().flex_wrap().children(self.children))
} else {
this.child(
div().w_full().min_w_0().child(
Label::new(self.label)
.color(self.label_color.unwrap_or(Color::Default)),
),
)
}
}),
)
.into_any_element()
}
@@ -46,37 +72,43 @@ impl Component for ListBulletItem {
}
fn description() -> Option<&'static str> {
Some("A list item with a bullet point indicator for unordered lists.")
Some("A list item with a dash indicator for unordered lists.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let basic_examples = vec![
single_example(
"Simple",
ListBulletItem::new("First bullet item").into_any_element(),
),
single_example(
"Multiple Lines",
v_flex()
.child(ListBulletItem::new("First item"))
.child(ListBulletItem::new("Second item"))
.child(ListBulletItem::new("Third item"))
.into_any_element(),
),
single_example(
"Long Text",
ListBulletItem::new(
"A longer bullet item that demonstrates text wrapping behavior",
)
.into_any_element(),
),
single_example(
"With Link",
ListBulletItem::new("")
.child(Label::new("Create a Zed account by"))
.child(ButtonLink::new("visiting the website", "https://zed.dev"))
.into_any_element(),
),
];
Some(
v_flex()
.gap_6()
.child(example_group_with_title(
"Bullet Items",
vec![
single_example(
"Simple",
ListBulletItem::new("First bullet item").into_any_element(),
),
single_example(
"Multiple Lines",
v_flex()
.child(ListBulletItem::new("First item"))
.child(ListBulletItem::new("Second item"))
.child(ListBulletItem::new("Third item"))
.into_any_element(),
),
single_example(
"Long Text",
ListBulletItem::new(
"A longer bullet item that demonstrates text wrapping behavior",
)
.into_any_element(),
),
],
))
.child(example_group(basic_examples).vertical())
.into_any_element(),
)
}

View File

@@ -41,7 +41,7 @@ pub enum NotificationId {
impl NotificationId {
/// Returns a unique [`NotificationId`] for the given type.
pub fn unique<T: 'static>() -> Self {
pub const fn unique<T: 'static>() -> Self {
Self::Unique(TypeId::of::<T>())
}

View File

@@ -5,6 +5,7 @@ use std::sync::LazyLock;
/// When true, Zed will use in-memory databases instead of persistent storage.
pub static ZED_STATELESS: LazyLock<bool> = bool_env_var!("ZED_STATELESS");
#[derive(Clone)]
pub struct EnvVar {
pub name: SharedString,
/// Value of the environment variable. Also `None` when set to an empty string.
@@ -30,7 +31,7 @@ impl EnvVar {
#[macro_export]
macro_rules! env_var {
($name:expr) => {
LazyLock::new(|| $crate::EnvVar::new(($name).into()))
::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()))
};
}
@@ -39,6 +40,6 @@ macro_rules! env_var {
#[macro_export]
macro_rules! bool_env_var {
($name:expr) => {
LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some())
::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some())
};
}