diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 56b853384f..90a440a644 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1281,13 +1281,6 @@ "ctrl-enter": "menu::Confirm", }, }, - { - "context": "ContextServerToolsModal", - "use_key_equivalents": true, - "bindings": { - "escape": "menu::Cancel", - }, - }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 009490e427..8b4e76dd81 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -288,12 +288,6 @@ "alt-enter": "editor::Newline", }, }, - { - "context": "AgentConfiguration", - "bindings": { - "ctrl--": "pane::GoBack", - }, - }, { "context": "AcpThread > ModeSelector", "bindings": { @@ -1379,13 +1373,6 @@ "cmd-enter": "menu::Confirm", }, }, - { - "context": "ContextServerToolsModal", - "use_key_equivalents": true, - "bindings": { - "escape": "menu::Cancel", - }, - }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 035e470445..08a9274c31 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1303,13 +1303,6 @@ "ctrl-enter": "menu::Confirm", }, }, - { - "context": "ContextServerToolsModal", - "use_key_equivalents": true, - "bindings": { - "escape": "menu::Cancel", - }, - }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 6164fcf72b..ec70074443 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1,1685 +1,6 @@ -mod add_llm_provider_modal; pub mod configure_context_server_modal; -mod configure_context_server_tools_modal; mod manage_profiles_modal; mod tool_picker; -use std::{ops::Range, rc::Rc, sync::Arc}; - -use agent::ContextServerRegistry; -use anyhow::Result; -use cloud_api_types::Plan; -use collections::HashMap; -use context_server::ContextServerId; -use editor::{Editor, MultiBufferOffset, SelectionEffects, scroll::Autoscroll}; -use extension::ExtensionManifest; -use extension_host::ExtensionStore; -use fs::Fs; -use gpui::{ - Action, Anchor, AnyView, App, AsyncWindowContext, Entity, EventEmitter, FocusHandle, Focusable, - ScrollHandle, Subscription, Task, TaskExt, WeakEntity, -}; -use itertools::Itertools; -use language::LanguageRegistry; -use language_model::{ - IconOrSvg, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, - ZED_CLOUD_PROVIDER_ID, -}; -use language_models::AllLanguageModelSettings; -use notifications::status_toast::StatusToast; -use project::{ - agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource}, - context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, -}; -use settings::{Settings, SettingsContent, SettingsStore, update_settings_file}; -use ui::{ - AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ButtonStyle, Chip, ContextMenu, - ContextMenuEntry, Disclosure, Divider, DividerColor, ElevationIndex, LabelSize, PopoverMenu, - Switch, Tooltip, WithScrollbar, prelude::*, -}; -use util::ResultExt as _; -use workspace::{Workspace, create_and_open_local_file}; -use zed_actions::{ExtensionCategoryFilter, OpenBrowser}; - pub(crate) use configure_context_server_modal::ConfigureContextServerModal; -pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal; pub(crate) use manage_profiles_modal::ManageProfilesModal; - -use crate::{ - Agent, - agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider}, - agent_connection_store::{AgentConnectionStatus, AgentConnectionStore}, -}; - -pub struct AgentConfiguration { - fs: Arc, - language_registry: Arc, - agent_server_store: Entity, - agent_connection_store: Entity, - workspace: WeakEntity, - focus_handle: FocusHandle, - configuration_views_by_provider: HashMap, - context_server_store: Entity, - expanded_provider_configurations: HashMap, - context_server_registry: Entity, - _subscriptions: Vec, - scroll_handle: ScrollHandle, -} - -impl AgentConfiguration { - pub fn new( - fs: Arc, - agent_server_store: Entity, - agent_connection_store: Entity, - context_server_store: Entity, - context_server_registry: Entity, - language_registry: Arc, - workspace: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let focus_handle = cx.focus_handle(); - - let subscriptions = vec![ - cx.subscribe_in( - &LanguageModelRegistry::global(cx), - window, - |this, _, event: &language_model::Event, window, cx| match event { - language_model::Event::AddedProvider(provider_id) => { - let provider = LanguageModelRegistry::read_global(cx).provider(provider_id); - if let Some(provider) = provider { - this.add_provider_configuration_view(&provider, window, cx); - } - } - language_model::Event::RemovedProvider(provider_id) => { - this.remove_provider_configuration_view(provider_id); - } - _ => {} - }, - ), - cx.subscribe(&agent_server_store, |_, _, _, cx| cx.notify()), - cx.observe(&agent_connection_store, |_, _, cx| cx.notify()), - cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()), - ]; - - let mut this = Self { - fs, - language_registry, - workspace, - focus_handle, - configuration_views_by_provider: HashMap::default(), - agent_server_store, - agent_connection_store, - context_server_store, - expanded_provider_configurations: HashMap::default(), - context_server_registry, - _subscriptions: subscriptions, - scroll_handle: ScrollHandle::new(), - }; - - this.build_provider_configuration_views(window, cx); - this - } - - fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context) { - let providers = LanguageModelRegistry::read_global(cx).visible_providers(); - for provider in providers { - self.add_provider_configuration_view(&provider, window, cx); - } - } - - fn remove_provider_configuration_view(&mut self, provider_id: &LanguageModelProviderId) { - self.configuration_views_by_provider.remove(provider_id); - self.expanded_provider_configurations.remove(provider_id); - } - - fn add_provider_configuration_view( - &mut self, - provider: &Arc, - window: &mut Window, - cx: &mut Context, - ) { - let configuration_view = provider.configuration_view( - language_model::ConfigurationViewTargetAgent::ZedAgent, - window, - cx, - ); - self.configuration_views_by_provider - .insert(provider.id(), configuration_view); - } -} - -impl Focusable for AgentConfiguration { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -pub enum AssistantConfigurationEvent { - NewThread(Arc), -} - -impl EventEmitter for AgentConfiguration {} - -enum AgentIcon { - Name(IconName), - Path(SharedString), -} - -impl AgentConfiguration { - fn render_section_title( - &mut self, - title: impl Into, - description: impl Into, - menu: AnyElement, - ) -> impl IntoElement { - h_flex() - .p_4() - .pb_0() - .mb_2p5() - .items_start() - .justify_between() - .child( - v_flex() - .w_full() - .gap_0p5() - .child( - h_flex() - .pr_1() - .w_full() - .gap_2() - .justify_between() - .flex_wrap() - .child(Headline::new(title.into())) - .child(menu), - ) - .child(Label::new(description.into()).color(Color::Muted)), - ) - } - - fn render_provider_configuration_block( - &mut self, - provider: &Arc, - cx: &mut Context, - ) -> impl IntoElement + use<> { - let provider_id = provider.id().0; - let provider_name = provider.name().0; - let provider_id_string = SharedString::from(format!("provider-disclosure-{provider_id}")); - - let configuration_view = self - .configuration_views_by_provider - .get(&provider.id()) - .cloned(); - - let is_expanded = self - .expanded_provider_configurations - .get(&provider.id()) - .copied() - .unwrap_or(false); - - let is_zed_provider = provider.id() == ZED_CLOUD_PROVIDER_ID; - let current_plan = if is_zed_provider { - self.workspace - .upgrade() - .and_then(|workspace| workspace.read(cx).user_store().read(cx).plan()) - } else { - None - }; - - let is_signed_in = self - .workspace - .read_with(cx, |workspace, _| { - !workspace.client().status().borrow().is_signed_out() - }) - .unwrap_or(false); - - v_flex() - .min_w_0() - .w_full() - .when(is_expanded, |this| this.mb_2()) - .child( - div() - .px_2() - .child(Divider::horizontal().color(DividerColor::BorderFaded)), - ) - .child( - h_flex() - .map(|this| { - if is_expanded { - this.mt_2().mb_1() - } else { - this.my_2() - } - }) - .w_full() - .justify_between() - .child( - h_flex() - .id(provider_id_string.clone()) - .px_2() - .py_0p5() - .w_full() - .justify_between() - .rounded_sm() - .hover(|hover| hover.bg(cx.theme().colors().element_hover)) - .child( - h_flex() - .w_full() - .gap_1p5() - .child( - match provider.icon() { - IconOrSvg::Svg(path) => Icon::from_external_svg(path), - IconOrSvg::Icon(name) => Icon::new(name), - } - .size(IconSize::Small) - .color(Color::Muted), - ) - .child( - h_flex() - .w_full() - .gap_1() - .child(Label::new(provider_name.clone())) - .map(|this| { - if is_zed_provider && is_signed_in { - this.child( - self.render_zed_plan_info(current_plan, cx), - ) - } else { - this.when( - provider.is_authenticated(cx) - && !is_expanded, - |parent| { - parent.child( - Icon::new(IconName::Check) - .color(Color::Success), - ) - }, - ) - } - }), - ), - ) - .child( - Disclosure::new(provider_id_string, is_expanded) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown), - ) - .on_click(cx.listener({ - let provider_id = provider.id(); - move |this, _event, _window, _cx| { - let is_expanded = this - .expanded_provider_configurations - .entry(provider_id.clone()) - .or_insert(false); - - *is_expanded = !*is_expanded; - } - })), - ), - ) - .child( - v_flex() - .min_w_0() - .w_full() - .px_2() - .gap_1() - .when(is_expanded, |parent| match configuration_view { - Some(configuration_view) => parent.child(configuration_view), - None => parent.child(Label::new(format!( - "No configuration view for {provider_name}", - ))), - }) - .when(is_expanded && provider.is_authenticated(cx), |parent| { - parent.child( - Button::new( - SharedString::from(format!("new-thread-{provider_id}")), - "Start New Thread", - ) - .full_width() - .style(ButtonStyle::Outlined) - .layer(ElevationIndex::ModalSurface) - .start_icon( - Icon::new(IconName::Thread) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let provider = provider.clone(); - move |_this, _event, _window, cx| { - cx.emit(AssistantConfigurationEvent::NewThread( - provider.clone(), - )) - } - })), - ) - }) - .when( - is_expanded && is_removable_provider(&provider.id(), cx), - |this| { - this.child( - Button::new( - SharedString::from(format!("delete-provider-{provider_id}")), - "Remove Provider", - ) - .full_width() - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Trash) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small) - .on_click(cx.listener({ - let provider = provider.clone(); - move |this, _event, window, cx| { - this.delete_provider(provider.clone(), window, cx); - } - })), - ) - }, - ), - ) - } - - fn delete_provider( - &mut self, - provider: Arc, - window: &mut Window, - cx: &mut Context, - ) { - let fs = self.fs.clone(); - let provider_id = provider.id(); - - cx.spawn_in(window, async move |_, cx| { - cx.update(|_window, cx| { - update_settings_file(fs.clone(), cx, { - let provider_id = provider_id.clone(); - move |settings, _| { - remove_compatible_provider(settings, provider_id.0.as_ref()); - } - }); - }) - .log_err(); - - cx.update(|_window, cx| { - LanguageModelRegistry::global(cx).update(cx, { - let provider_id = provider_id.clone(); - move |registry, cx| { - registry.unregister_provider(provider_id, cx); - } - }) - }) - .log_err(); - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - - fn render_provider_configuration_section( - &mut self, - cx: &mut Context, - ) -> impl IntoElement { - let providers = LanguageModelRegistry::read_global(cx).visible_providers(); - - let popover_menu = PopoverMenu::new("add-provider-popover") - .trigger( - Button::new("add-provider", "Add Provider") - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small), - ) - .menu({ - let workspace = self.workspace.clone(); - move |window, cx| { - let open_modal = |provider: LlmCompatibleProvider| { - let workspace = workspace.clone(); - move |window: &mut Window, cx: &mut App| { - workspace - .update(cx, |workspace, cx| { - AddLlmProviderModal::toggle(provider, workspace, window, cx); - }) - .log_err(); - } - }; - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.header("Compatible APIs") - .entry("OpenAI", None, open_modal(LlmCompatibleProvider::OpenAi)) - .entry( - "Anthropic", - None, - open_modal(LlmCompatibleProvider::Anthropic), - ) - })) - } - }) - .anchor(gpui::Anchor::TopRight) - .offset(gpui::Point { - x: px(0.0), - y: px(2.0), - }); - - v_flex() - .min_w_0() - .w_full() - .child(self.render_section_title( - "LLM Providers", - "Add at least one provider to use AI-powered features with Zed's native agent.", - popover_menu.into_any_element(), - )) - .child( - div() - .w_full() - .pl(DynamicSpacing::Base08.rems(cx)) - .pr(DynamicSpacing::Base20.rems(cx)) - .children( - providers.into_iter().map(|provider| { - self.render_provider_configuration_block(&provider, cx) - }), - ), - ) - } - - fn render_zed_plan_info(&self, plan: Option, cx: &mut Context) -> impl IntoElement { - if let Some(plan) = plan { - let free_chip_bg = cx - .theme() - .colors() - .editor_background - .opacity(0.5) - .blend(cx.theme().colors().text_accent.opacity(0.05)); - - let pro_chip_bg = cx - .theme() - .colors() - .editor_background - .opacity(0.5) - .blend(cx.theme().colors().text_accent.opacity(0.2)); - - let (plan_name, label_color, bg_color) = match plan { - Plan::ZedFree => ("Free", Color::Default, free_chip_bg), - Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), - Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), - Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg), - Plan::ZedVip => ("VIP", Color::Accent, pro_chip_bg), - Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg), - }; - - Chip::new(plan_name.to_string()) - .bg_color(bg_color) - .label_color(label_color) - .into_any_element() - } else { - div().into_any_element() - } - } - - fn render_context_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { - let context_server_ids = self.context_server_store.read(cx).server_ids(); - - let add_server_popover = PopoverMenu::new("add-server-popover") - .trigger( - Button::new("add-server", "Add Server") - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small), - ) - .menu({ - move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.entry("Add Custom Server", None, { - |window, cx| { - window.dispatch_action( - crate::AddContextServer::local().boxed_clone(), - cx, - ) - } - }) - .entry("Add Remote Server", None, { - |window, cx| { - window.dispatch_action( - crate::AddContextServer::remote().boxed_clone(), - cx, - ) - } - }) - .entry("Install from Extensions", None, { - |window, cx| { - window.dispatch_action( - zed_actions::Extensions { - category_filter: Some( - ExtensionCategoryFilter::ContextServers, - ), - id: None, - } - .boxed_clone(), - cx, - ) - } - }) - })) - } - }) - .anchor(gpui::Anchor::TopRight) - .offset(gpui::Point { - x: px(0.0), - y: px(2.0), - }); - - v_flex() - .min_w_0() - .border_b_1() - .border_color(cx.theme().colors().border) - .child(self.render_section_title( - "Model Context Protocol (MCP) Servers", - "All MCP servers connected directly or via a Zed extension.", - add_server_popover.into_any_element(), - )) - .child( - v_flex() - .pl_4() - .pb_4() - .pr_5() - .w_full() - .gap_1() - .map(|parent| { - if context_server_ids.is_empty() { - parent.child( - h_flex() - .p_4() - .justify_center() - .border_1() - .border_dashed() - .border_color(cx.theme().colors().border.opacity(0.6)) - .rounded_sm() - .child( - Label::new("No MCP servers added yet.") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - } else { - parent.children(itertools::intersperse_with( - context_server_ids.iter().cloned().map(|context_server_id| { - self.render_context_server(context_server_id, cx) - .into_any_element() - }), - || { - Divider::horizontal() - .color(DividerColor::BorderFaded) - .into_any_element() - }, - )) - } - }), - ) - } - - fn render_context_server( - &self, - context_server_id: ContextServerId, - cx: &Context, - ) -> impl use<> + IntoElement { - let server_status = self - .context_server_store - .read(cx) - .status_for_server(&context_server_id) - .unwrap_or(ContextServerStatus::Stopped); - let server_configuration = self - .context_server_store - .read(cx) - .configuration_for_server(&context_server_id); - - let is_running = matches!(server_status, ContextServerStatus::Running); - let item_id = SharedString::from(context_server_id.0.clone()); - // Servers without a configuration can only be provided by extensions. - let provided_by_extension = server_configuration.as_ref().is_none_or(|config| { - matches!( - config.as_ref(), - ContextServerConfiguration::Extension { .. } - ) - }); - - let display_name = if provided_by_extension { - resolve_extension_for_context_server(&context_server_id, cx) - .map(|(_, manifest)| { - let name = manifest.name.as_str(); - let stripped = name - .strip_suffix(" MCP Server") - .or_else(|| name.strip_suffix(" MCP")) - .or_else(|| name.strip_suffix(" Context Server")) - .unwrap_or(name); - SharedString::from(stripped.to_string()) - }) - .unwrap_or_else(|| item_id.clone()) - } else { - item_id.clone() - }; - - let error = if let ContextServerStatus::Error(error) = server_status.clone() { - Some(error) - } else { - None - }; - let auth_required = matches!(server_status, ContextServerStatus::AuthRequired); - let client_secret_required = matches!( - server_status, - ContextServerStatus::ClientSecretRequired { .. } - ); - let authenticating = matches!(server_status, ContextServerStatus::Authenticating); - let context_server_store = self.context_server_store.clone(); - let workspace = self.workspace.clone(); - let language_registry = self.language_registry.clone(); - - let tool_count = self - .context_server_registry - .read(cx) - .tools_for_server(&context_server_id) - .count(); - - let source = if provided_by_extension { - AiSettingItemSource::Extension - } else { - AiSettingItemSource::Custom - }; - - let status = match server_status { - ContextServerStatus::Starting => AiSettingItemStatus::Starting, - ContextServerStatus::Running => AiSettingItemStatus::Running, - ContextServerStatus::Error(_) => AiSettingItemStatus::Error, - ContextServerStatus::Stopped => AiSettingItemStatus::Stopped, - ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired, - ContextServerStatus::ClientSecretRequired { .. } => { - AiSettingItemStatus::ClientSecretRequired - } - ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating, - }; - - let is_remote = server_configuration - .as_ref() - .map(|config| matches!(config.as_ref(), ContextServerConfiguration::Http { .. })) - .unwrap_or(false); - - let should_show_logout_button = server_configuration.as_ref().is_some_and(|config| { - matches!(config.as_ref(), ContextServerConfiguration::Http { .. }) - && !config.has_static_auth_header() - }); - - let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu") - .trigger_with_tooltip( - IconButton::new("context-server-config-menu", IconName::Settings) - .icon_color(Color::Muted) - .icon_size(IconSize::Small), - Tooltip::text("Configure MCP Server"), - ) - .anchor(Anchor::TopRight) - .menu({ - let fs = self.fs.clone(); - let context_server_id = context_server_id.clone(); - let language_registry = self.language_registry.clone(); - let workspace = self.workspace.clone(); - let context_server_registry = self.context_server_registry.clone(); - let context_server_store = context_server_store.clone(); - - move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.entry("Configure Server", None, { - let context_server_id = context_server_id.clone(); - let language_registry = language_registry.clone(); - let workspace = workspace.clone(); - move |window, cx| { - if is_remote { - crate::agent_configuration::configure_context_server_modal::ConfigureContextServerModal::show_modal_for_existing_server( - context_server_id.clone(), - language_registry.clone(), - workspace.clone(), - window, - cx, - ) - .detach(); - } else { - ConfigureContextServerModal::show_modal_for_existing_server( - context_server_id.clone(), - language_registry.clone(), - workspace.clone(), - window, - cx, - ) - .detach(); - } - } - }).when(tool_count > 0, |this| this.entry("View Tools", None, { - let context_server_id = context_server_id.clone(); - let context_server_registry = context_server_registry.clone(); - let workspace = workspace.clone(); - move |window, cx| { - let context_server_id = context_server_id.clone(); - workspace.update(cx, |workspace, cx| { - ConfigureContextServerToolsModal::toggle( - context_server_id, - context_server_registry.clone(), - workspace, - window, - cx, - ); - }) - .ok(); - } - })) - .when(should_show_logout_button, |this| { - this.entry("Log Out", None, { - let context_server_store = context_server_store.clone(); - let context_server_id = context_server_id.clone(); - move |_window, cx| { - context_server_store.update(cx, |store, cx| { - store.logout_server(&context_server_id, cx).log_err(); - }); - } - }) - }) - .separator() - .entry("Uninstall", None, { - let fs = fs.clone(); - let context_server_id = context_server_id.clone(); - let workspace = workspace.clone(); - move |_, cx| { - let uninstall_extension_task = match ( - provided_by_extension, - resolve_extension_for_context_server(&context_server_id, cx), - ) { - (true, Some((id, manifest))) => { - if extension_only_provides_context_server(manifest.as_ref()) - { - ExtensionStore::global(cx).update(cx, |store, cx| { - store.uninstall_extension(id, cx) - }) - } else { - workspace.update(cx, |workspace, cx| { - show_unable_to_uninstall_extension_with_context_server(workspace, context_server_id.clone(), cx); - }).log_err(); - Task::ready(Ok(())) - } - } - _ => Task::ready(Ok(())), - }; - - cx.spawn({ - let fs = fs.clone(); - let context_server_id = context_server_id.clone(); - async move |cx| { - uninstall_extension_task.await?; - cx.update(|cx| { - update_settings_file( - fs.clone(), - cx, - { - let context_server_id = - context_server_id.clone(); - move |settings, _| { - settings.project - .context_servers - .remove(&context_server_id.0); - } - }, - ) - }); - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - } - }) - })) - } - }); - - let feedback_base_container = - || h_flex().py_1().min_w_0().w_full().gap_1().justify_between(); - - let details: Option = if let Some(error) = error { - Some( - feedback_base_container() - .child( - h_flex() - .pr_4() - .min_w_0() - .w_full() - .gap_2() - .child( - Icon::new(IconName::XCircle) - .size(IconSize::XSmall) - .color(Color::Error), - ) - .child(div().min_w_0().flex_1().child( - Label::new(error).color(Color::Muted).size(LabelSize::Small), - )), - ) - .when(should_show_logout_button, |this| { - this.child( - Button::new("error-logout-server", "Log Out") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click({ - let context_server_store = context_server_store.clone(); - let context_server_id = context_server_id.clone(); - move |_event, _window, cx| { - context_server_store.update(cx, |store, cx| { - store.logout_server(&context_server_id, cx).log_err(); - }); - } - }), - ) - }) - .into_any_element(), - ) - } else if auth_required { - Some( - feedback_base_container() - .child( - h_flex() - .pr_4() - .min_w_0() - .w_full() - .gap_2() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new("Authenticate to connect this server") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - .child( - Button::new("authenticate-server", "Authenticate") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click({ - let context_server_id = context_server_id.clone(); - move |_event, _window, cx| { - context_server_store.update(cx, |store, cx| { - store.authenticate_server(&context_server_id, cx).log_err(); - }); - } - }), - ) - .into_any_element(), - ) - } else if client_secret_required { - Some( - feedback_base_container() - .child( - h_flex() - .pr_4() - .min_w_0() - .w_full() - .gap_2() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new("Enter a client secret to connect this server") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - .child( - Button::new("enter-client-secret", "Enter Client Secret") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click({ - let context_server_id = context_server_id.clone(); - move |_event, window, cx| { - ConfigureContextServerModal::show_modal_for_existing_server( - context_server_id.clone(), - language_registry.clone(), - workspace.clone(), - window, - cx, - ) - .detach(); - } - }), - ) - .into_any_element(), - ) - } else if authenticating { - Some( - h_flex() - .mt_1() - .pr_4() - .min_w_0() - .w_full() - .gap_2() - .child(div().size_3().flex_shrink_0()) - .child( - Label::new("Authenticating…") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .into_any_element(), - ) - } else { - None - }; - - let tool_label = if is_running { - Some(if tool_count == 1 { - SharedString::from("1 tool") - } else { - SharedString::from(format!("{} tools", tool_count)) - }) - } else { - None - }; - - AiSettingItem::new(item_id, display_name, status, source) - .action(context_server_configuration_menu) - .action( - Switch::new("context-server-switch", is_running.into()).on_click({ - let context_server_manager = self.context_server_store.clone(); - let fs = self.fs.clone(); - - move |state, _window, cx| { - let is_enabled = match state { - ToggleState::Unselected | ToggleState::Indeterminate => { - context_server_manager.update(cx, |this, cx| { - this.stop_server(&context_server_id, cx).log_err(); - }); - false - } - ToggleState::Selected => { - context_server_manager.update(cx, |this, cx| { - if let Some(server) = this.get_server(&context_server_id) { - this.start_server(server, cx); - } - }); - true - } - }; - update_settings_file(fs.clone(), cx, { - let context_server_id = context_server_id.clone(); - - move |settings, _| { - settings - .project - .context_servers - .entry(context_server_id.0) - .or_insert_with(|| { - settings::ContextServerSettingsContent::Extension { - enabled: is_enabled, - remote: false, - settings: serde_json::json!({}), - } - }) - .set_enabled(is_enabled); - } - }); - } - }), - ) - .when_some(tool_label, |this, label| this.detail_label(label)) - .when_some(details, |this, details| this.details(details)) - } - - fn render_agent_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { - let agent_server_store = self.agent_server_store.read(cx); - - let agents = agent_server_store - .external_agents() - .cloned() - .collect::>(); - - let agents: Vec<_> = agents - .into_iter() - .map(|name| { - let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) { - AgentIcon::Path(icon_path) - } else { - AgentIcon::Name(IconName::Sparkle) - }; - let display_name = agent_server_store - .agent_display_name(&name) - .unwrap_or_else(|| name.0.clone()); - let source = agent_server_store.agent_source(&name).unwrap_or_default(); - (name, icon, display_name, source) - }) - .sorted_unstable_by_key(|(_, _, display_name, _)| display_name.to_lowercase()) - .collect(); - - let add_agent_popover = PopoverMenu::new("add-agent-server-popover") - .trigger( - Button::new("add-agent", "Add Agent") - .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::Small) - .color(Color::Muted), - ) - .label_size(LabelSize::Small), - ) - .menu({ - move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { - menu.entry("Install from Registry", None, { - |window, cx| { - window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx) - } - }) - .entry("Add Custom Agent", None, { - move |window, cx| { - if let Some(workspace) = Workspace::for_window(window, cx) { - let workspace = workspace.downgrade(); - window - .spawn(cx, async |cx| { - open_new_agent_servers_entry_in_settings_editor( - workspace, cx, - ) - .await - }) - .detach_and_log_err(cx); - } - } - }) - .separator() - .header("Learn More") - .item( - ContextMenuEntry::new("ACP Docs") - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_position(IconPosition::End) - .handler({ - move |window, cx| { - window.dispatch_action( - Box::new(OpenBrowser { - url: "https://agentclientprotocol.com/".into(), - }), - cx, - ); - } - }), - ) - })) - } - }) - .anchor(gpui::Anchor::TopRight) - .offset(gpui::Point { - x: px(0.0), - y: px(2.0), - }); - - v_flex() - .min_w_0() - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - v_flex() - .child(self.render_section_title( - "External Agents", - "All agents connected through the Agent Client Protocol.", - add_agent_popover.into_any_element(), - )) - .child( - v_flex() - .p_4() - .pt_0() - .gap_2() - .children(Itertools::intersperse_with( - agents - .into_iter() - .map(|(name, icon, display_name, source)| { - self.render_agent_server( - icon, - name, - display_name, - source, - cx, - ) - .into_any_element() - }), - || { - Divider::horizontal() - .color(DividerColor::BorderFaded) - .into_any_element() - }, - )), - ), - ) - } - - fn render_agent_server( - &self, - icon: AgentIcon, - id: impl Into, - display_name: impl Into, - source: ExternalAgentSource, - cx: &mut Context, - ) -> impl IntoElement { - let id = id.into(); - let display_name = display_name.into(); - - let icon = match icon { - AgentIcon::Name(icon_name) => Icon::new(icon_name) - .size(IconSize::Small) - .color(Color::Muted), - AgentIcon::Path(icon_path) => Icon::from_external_svg(icon_path) - .size(IconSize::Small) - .color(Color::Muted), - }; - - let source_kind = match source { - ExternalAgentSource::Registry => AiSettingItemSource::Registry, - ExternalAgentSource::Custom => AiSettingItemSource::Custom, - }; - - let agent_server_name = AgentId(id.clone()); - let agent = Agent::Custom { - id: agent_server_name.clone(), - }; - - let (connection_status, running_version) = { - let connection_store = self.agent_connection_store.read(cx); - ( - connection_store.connection_status(&agent, cx), - connection_store.agent_version(&agent, cx), - ) - }; - - let restart_button = matches!( - connection_status, - AgentConnectionStatus::Connected | AgentConnectionStatus::Connecting - ) - .then(|| { - IconButton::new( - SharedString::from(format!("restart-{}", id)), - IconName::RotateCw, - ) - .disabled(connection_status == AgentConnectionStatus::Connecting) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Restart Agent Connection")) - .on_click(cx.listener({ - let agent = agent.clone(); - move |this, _, _window, cx| { - let server: Rc = - Rc::new(agent_servers::CustomAgentServer::new(agent.id())); - this.agent_connection_store.update(cx, |store, cx| { - store.restart_connection(agent.clone(), server, cx); - }); - } - })) - }); - - let uninstall_button = match source { - ExternalAgentSource::Registry => { - let fs = self.fs.clone(); - Some( - IconButton::new( - SharedString::from(format!("uninstall-{}", id)), - IconName::Trash, - ) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Remove Registry Agent")) - .on_click(cx.listener(move |_, _, _window, cx| { - let agent_name = agent_server_name.clone(); - update_settings_file(fs.clone(), cx, move |settings, _| { - let Some(agent_servers) = settings.agent_servers.as_mut() else { - return; - }; - if let Some(entry) = agent_servers.get(agent_name.0.as_ref()) - && matches!( - entry, - settings::CustomAgentServerSettings::Registry { .. } - ) - { - agent_servers.remove(agent_name.0.as_ref()); - } - }); - })), - ) - } - ExternalAgentSource::Custom => { - let fs = self.fs.clone(); - Some( - IconButton::new( - SharedString::from(format!("uninstall-{}", id)), - IconName::Trash, - ) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Remove Custom Agent")) - .on_click(cx.listener(move |_, _, _window, cx| { - let agent_name = agent_server_name.clone(); - update_settings_file(fs.clone(), cx, move |settings, _| { - let Some(agent_servers) = settings.agent_servers.as_mut() else { - return; - }; - if let Some(entry) = agent_servers.get(agent_name.0.as_ref()) - && matches!( - entry, - settings::CustomAgentServerSettings::Custom { .. } - ) - { - agent_servers.remove(agent_name.0.as_ref()); - } - }); - })), - ) - } - }; - - let status = match connection_status { - AgentConnectionStatus::Disconnected => AiSettingItemStatus::Stopped, - AgentConnectionStatus::Connecting => AiSettingItemStatus::Starting, - AgentConnectionStatus::Connected => AiSettingItemStatus::Running, - }; - - AiSettingItem::new(id, display_name, status, source_kind) - .icon(icon) - .when_some(running_version, |this, version| this.detail_label(version)) - .when_some(restart_button, |this, button| this.action(button)) - .when_some(uninstall_button, |this, button| this.action(button)) - } -} - -impl Render for AgentConfiguration { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .id("assistant-configuration") - .key_context("AgentConfiguration") - .track_focus(&self.focus_handle(cx)) - .relative() - .size_full() - .pb_8() - .bg(cx.theme().colors().panel_background) - .child( - div() - .size_full() - .child( - v_flex() - .id("assistant-configuration-content") - .track_scroll(&self.scroll_handle) - .size_full() - .min_w_0() - .overflow_y_scroll() - .child(self.render_agent_servers_section(cx)) - .child(self.render_context_servers_section(cx)) - .child(self.render_provider_configuration_section(cx)), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx), - ) - } -} - -fn extension_only_provides_context_server(manifest: &ExtensionManifest) -> bool { - manifest.context_servers.len() == 1 - && manifest.themes.is_empty() - && manifest.icon_themes.is_empty() - && manifest.languages.is_empty() - && manifest.grammars.is_empty() - && manifest.language_servers.is_empty() - && manifest.slash_commands.is_empty() - && manifest.snippets.is_none() - && manifest.debug_locators.is_empty() -} - -pub(crate) fn resolve_extension_for_context_server( - id: &ContextServerId, - cx: &App, -) -> Option<(Arc, Arc)> { - ExtensionStore::global(cx) - .read(cx) - .installed_extensions() - .iter() - .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0)) - .map(|(id, entry)| (id.clone(), entry.manifest.clone())) -} - -// This notification appears when trying to delete -// an MCP server extension that not only provides -// the server, but other things, too, like language servers and more. -fn show_unable_to_uninstall_extension_with_context_server( - workspace: &mut Workspace, - id: ContextServerId, - cx: &mut App, -) { - let workspace_handle = workspace.weak_handle(); - let context_server_id = id.clone(); - - let status_toast = StatusToast::new( - format!( - "The {} extension provides more than just the MCP server. Proceed to uninstall anyway?", - id.0 - ), - cx, - move |this, _cx| { - let workspace_handle = workspace_handle.clone(); - - this.icon( - Icon::new(IconName::Warning) - .size(IconSize::Small) - .color(Color::Warning), - ) - .dismiss_button(true) - .action("Uninstall", move |_, _cx| { - if let Some((extension_id, _)) = - resolve_extension_for_context_server(&context_server_id, _cx) - { - ExtensionStore::global(_cx).update(_cx, |store, cx| { - store - .uninstall_extension(extension_id, cx) - .detach_and_log_err(cx); - }); - - workspace_handle - .update(_cx, |workspace, cx| { - let fs = workspace.app_state().fs.clone(); - cx.spawn({ - let context_server_id = context_server_id.clone(); - async move |_workspace_handle, cx| { - cx.update(|cx| { - update_settings_file(fs, cx, move |settings, _| { - settings - .project - .context_servers - .remove(&context_server_id.0); - }); - }); - anyhow::Ok(()) - } - }) - .detach_and_log_err(cx); - }) - .log_err(); - } - }) - }, - ); - - workspace.toggle_status_toast(status_toast, cx); -} - -async fn open_new_agent_servers_entry_in_settings_editor( - workspace: WeakEntity, - cx: &mut AsyncWindowContext, -) -> Result<()> { - let settings_editor = workspace - .update_in(cx, |_, window, cx| { - create_and_open_local_file(paths::settings_file(), window, cx, || { - settings::initial_user_settings_content().as_ref().into() - }) - })? - .await? - .downcast::() - .unwrap(); - - settings_editor - .downgrade() - .update_in(cx, |item, window, cx| { - let text = item.buffer().read(cx).snapshot(cx).text(); - - let settings = cx.global::(); - - let mut unique_server_name = None; - let Some(edits) = settings - .edits_for_update(&text, |settings| { - let server_name: Option = (0..u8::MAX) - .map(|i| { - if i == 0 { - "your_agent".to_string() - } else { - format!("your_agent_{}", i) - } - }) - .find(|name| { - !settings - .agent_servers - .as_ref() - .is_some_and(|agent_servers| { - agent_servers.contains_key(name.as_str()) - }) - }); - if let Some(server_name) = server_name { - unique_server_name = Some(SharedString::from(server_name.clone())); - settings.agent_servers.get_or_insert_default().insert( - server_name, - settings::CustomAgentServerSettings::Custom { - path: "path_to_executable".into(), - args: vec![], - env: HashMap::default(), - default_mode: None, - default_config_options: Default::default(), - favorite_config_option_values: Default::default(), - }, - ); - } - }) - .log_err() - else { - return; - }; - - if edits.is_empty() { - return; - } - - let ranges = edits - .iter() - .map(|(range, _)| range.clone()) - .collect::>(); - - item.edit( - edits.into_iter().map(|(range, s)| { - ( - MultiBufferOffset(range.start)..MultiBufferOffset(range.end), - s, - ) - }), - cx, - ); - if let Some((unique_server_name, buffer)) = - unique_server_name.zip(item.buffer().read(cx).as_singleton()) - { - let snapshot = buffer.read(cx).snapshot(); - if let Some(range) = - find_text_in_buffer(&unique_server_name, ranges[0].start, &snapshot) - { - item.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |selections| { - selections.select_ranges(vec![ - MultiBufferOffset(range.start)..MultiBufferOffset(range.end), - ]); - }, - ); - } - } - }) -} - -fn find_text_in_buffer( - text: &str, - start: usize, - snapshot: &language::BufferSnapshot, -) -> Option> { - let chars = text.chars().collect::>(); - - let mut offset = start; - let mut char_offset = 0; - for c in snapshot.chars_at(start) { - if char_offset >= chars.len() { - break; - } - offset += 1; - - if c == chars[char_offset] { - char_offset += 1; - } else { - char_offset = 0; - } - } - - if char_offset == chars.len() { - Some(offset.saturating_sub(chars.len())..offset) - } else { - None - } -} - -// API-compatible providers are user-configured and can be removed, -// whereas built-in providers (like Anthropic, OpenAI, Google, etc.) can't. -// -// If in the future we have more "API-compatible-type" of providers, -// they should be included here as removable providers. -fn is_removable_provider(provider_id: &LanguageModelProviderId, cx: &App) -> bool { - let settings = AllLanguageModelSettings::get_global(cx); - settings - .openai_compatible - .contains_key(provider_id.0.as_ref()) - || settings - .anthropic_compatible - .contains_key(provider_id.0.as_ref()) -} - -fn remove_compatible_provider(settings: &mut SettingsContent, provider_id: &str) { - // Mirrors the OpenAI-wins precedence used at registration time: only the - // entry that is actually registered gets removed. A shadowed - // `anthropic_compatible` entry with the same name takes over instead of - // being silently deleted. - let Some(language_models) = settings.language_models.as_mut() else { - return; - }; - let removed_from_openai = language_models - .openai_compatible - .as_mut() - .and_then(|providers| providers.remove(provider_id)) - .is_some(); - if !removed_from_openai && let Some(providers) = language_models.anthropic_compatible.as_mut() { - providers.remove(provider_id); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use settings::{AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent}; - - fn settings_with_compatible_providers(openai: &[&str], anthropic: &[&str]) -> SettingsContent { - let mut settings = SettingsContent::default(); - let language_models = settings.language_models.get_or_insert_default(); - language_models.openai_compatible = Some( - openai - .iter() - .map(|id| { - ( - Arc::from(*id), - OpenAiCompatibleSettingsContent { - api_url: "https://example.com".to_string(), - available_models: Vec::new(), - custom_headers: None, - }, - ) - }) - .collect(), - ); - language_models.anthropic_compatible = Some( - anthropic - .iter() - .map(|id| { - ( - Arc::from(*id), - AnthropicCompatibleSettingsContent { - api_url: "https://example.com".to_string(), - available_models: Vec::new(), - custom_headers: None, - }, - ) - }) - .collect(), - ); - settings - } - - fn compatible_provider_keys(settings: &SettingsContent) -> (Vec<&str>, Vec<&str>) { - fn keys(providers: Option<&HashMap, T>>) -> Vec<&str> { - providers - .map(|providers| providers.keys().map(|key| key.as_ref()).collect()) - .unwrap_or_default() - } - - let language_models = settings - .language_models - .as_ref() - .expect("language_models settings should exist"); - ( - keys(language_models.openai_compatible.as_ref()), - keys(language_models.anthropic_compatible.as_ref()), - ) - } - - #[test] - fn test_remove_compatible_provider_openai_only() { - let mut settings = settings_with_compatible_providers(&["acme"], &[]); - remove_compatible_provider(&mut settings, "acme"); - let (openai, anthropic) = compatible_provider_keys(&settings); - assert_eq!(openai, Vec::<&str>::new()); - assert_eq!(anthropic, Vec::<&str>::new()); - } - - #[test] - fn test_remove_compatible_provider_anthropic_only() { - let mut settings = settings_with_compatible_providers(&[], &["acme"]); - remove_compatible_provider(&mut settings, "acme"); - let (openai, anthropic) = compatible_provider_keys(&settings); - assert_eq!(openai, Vec::<&str>::new()); - assert_eq!(anthropic, Vec::<&str>::new()); - } - - #[test] - fn test_remove_compatible_provider_collision_removes_only_openai_entry() { - let mut settings = settings_with_compatible_providers(&["acme"], &["acme"]); - - remove_compatible_provider(&mut settings, "acme"); - let (openai, anthropic) = compatible_provider_keys(&settings); - assert_eq!( - openai, - Vec::<&str>::new(), - "the registered (OpenAI-compatible) entry should be removed" - ); - assert_eq!( - anthropic, - vec!["acme"], - "the shadowed anthropic_compatible entry should survive" - ); - - // A second removal deletes the entry that took over. - remove_compatible_provider(&mut settings, "acme"); - let (_, anthropic) = compatible_provider_keys(&settings); - assert_eq!(anthropic, Vec::<&str>::new()); - } - - #[test] - fn test_remove_compatible_provider_leaves_other_providers_untouched() { - let mut settings = settings_with_compatible_providers(&["acme", "globex"], &["initech"]); - remove_compatible_provider(&mut settings, "acme"); - let (openai, anthropic) = compatible_provider_keys(&settings); - assert_eq!(openai, vec!["globex"]); - assert_eq!(anthropic, vec!["initech"]); - } -} diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs deleted file mode 100644 index d1f281bbca..0000000000 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ /dev/null @@ -1,1201 +0,0 @@ -use std::sync::Arc; - -use anyhow::Result; -use fs::Fs; -use gpui::{ - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Render, ScrollHandle, Task, TaskExt, -}; -use itertools::Itertools as _; -use language_model::LanguageModelRegistry; -use language_models::provider::open_ai_compatible::{ - AvailableModel as OpenAiCompatibleAvailableModel, - ModelCapabilities as OpenAiCompatibleModelCapabilities, -}; -use settings::{ - AnthropicCompatibleAvailableModel, AnthropicCompatibleModelCapabilities, - AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, OpenAiReasoningEffort, - update_settings_file, -}; -use ui::{ - Banner, Checkbox, ContextMenu, ContextMenuEntry, DropdownMenu, DropdownStyle, IconPosition, - KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, WithScrollbar, prelude::*, -}; -use ui_input::InputField; -use workspace::{ModalView, Workspace}; - -fn single_line_input( - label: impl Into, - placeholder: &str, - text: Option<&str>, - tab_index: isize, - window: &mut Window, - cx: &mut App, -) -> Entity { - cx.new(|cx| { - let input = InputField::new(window, cx, placeholder) - .label(label) - .tab_index(tab_index) - .tab_stop(true); - - if let Some(text) = text { - input.set_text(text, window, cx); - } - input - }) -} - -#[derive(Clone, Copy)] -pub enum LlmCompatibleProvider { - OpenAi, - Anthropic, -} - -impl LlmCompatibleProvider { - fn name(&self) -> &'static str { - match self { - LlmCompatibleProvider::OpenAi => "OpenAI", - LlmCompatibleProvider::Anthropic => "Anthropic", - } - } - - fn api_url(&self) -> &'static str { - match self { - LlmCompatibleProvider::OpenAi => "https://api.openai.com/v1", - LlmCompatibleProvider::Anthropic => "https://api.anthropic.com", - } - } - - fn description(&self) -> &'static str { - match self { - LlmCompatibleProvider::OpenAi => "This provider will use an OpenAI compatible API.", - LlmCompatibleProvider::Anthropic => { - "This provider will use an Anthropic Messages compatible API." - } - } - } - - fn is_open_ai(&self) -> bool { - matches!(self, LlmCompatibleProvider::OpenAi) - } -} - -struct AddLlmProviderInput { - provider_name: Entity, - api_url: Entity, - api_key: Entity, - models: Vec, -} - -impl AddLlmProviderInput { - fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut App) -> Self { - let provider_name = - single_line_input("Provider Name", provider.name(), None, 1, window, cx); - let api_url = single_line_input("API URL", provider.api_url(), None, 2, window, cx); - let api_key = cx.new(|cx| { - InputField::new( - window, - cx, - "000000000000000000000000000000000000000000000000", - ) - .label("API Key") - .tab_index(3) - .tab_stop(true) - .masked(true) - }); - - Self { - provider_name, - api_url, - api_key, - models: vec![ModelInput::new(0, window, cx)], - } - } - - fn add_model(&mut self, window: &mut Window, cx: &mut App) { - let model_index = self.models.len(); - self.models.push(ModelInput::new(model_index, window, cx)); - } - - fn remove_model(&mut self, index: usize) { - self.models.remove(index); - } -} - -struct ModelCapabilityToggles { - pub supports_tools: ToggleState, - pub supports_images: ToggleState, - pub supports_parallel_tool_calls: ToggleState, - pub supports_prompt_cache_key: ToggleState, - pub supports_chat_completions: ToggleState, - pub supports_thinking: ToggleState, - pub interleaved_reasoning: ToggleState, - pub max_tokens_parameter: ToggleState, -} - -struct ModelInput { - name: Entity, - max_completion_tokens: Entity, - max_output_tokens: Entity, - max_tokens: Entity, - reasoning_effort: OpenAiReasoningEffort, - capabilities: ModelCapabilityToggles, -} - -impl ModelInput { - fn new(model_index: usize, window: &mut Window, cx: &mut App) -> Self { - let base_tab_index = (3 + (model_index * 4)) as isize; - - let model_name = single_line_input( - "Model Name", - "e.g. gpt-5, claude-opus-4, gemini-2.5-pro", - None, - base_tab_index + 1, - window, - cx, - ); - let max_completion_tokens = single_line_input( - "Max Completion Tokens", - "200000", - Some("200000"), - base_tab_index + 2, - window, - cx, - ); - let max_output_tokens = single_line_input( - "Max Output Tokens", - "Max Output Tokens", - Some("32000"), - base_tab_index + 3, - window, - cx, - ); - let max_tokens = single_line_input( - "Max Tokens", - "Max Tokens", - Some("200000"), - base_tab_index + 4, - window, - cx, - ); - - let OpenAiCompatibleModelCapabilities { - tools, - images, - parallel_tool_calls, - prompt_cache_key, - chat_completions, - interleaved_reasoning, - max_tokens_parameter, - .. - } = OpenAiCompatibleModelCapabilities::default(); - - Self { - name: model_name, - max_completion_tokens, - max_output_tokens, - max_tokens, - capabilities: ModelCapabilityToggles { - supports_tools: tools.into(), - supports_images: images.into(), - supports_parallel_tool_calls: parallel_tool_calls.into(), - supports_prompt_cache_key: prompt_cache_key.into(), - supports_chat_completions: chat_completions.into(), - supports_thinking: ToggleState::Unselected, - interleaved_reasoning: interleaved_reasoning.into(), - max_tokens_parameter: max_tokens_parameter.into(), - }, - reasoning_effort: OpenAiReasoningEffort::Medium, - } - } - - fn parse_name(&self, cx: &App) -> Result { - let name = self.name.read(cx).text(cx); - if name.is_empty() { - return Err(SharedString::from("Model Name cannot be empty")); - } - Ok(name) - } - - fn parse_open_ai_compatible( - &self, - cx: &App, - ) -> Result { - Ok(OpenAiCompatibleAvailableModel { - name: self.parse_name(cx)?, - display_name: None, - max_completion_tokens: Some(parse_u64_field( - &self.max_completion_tokens, - "Max Completion Tokens", - cx, - )?), - max_output_tokens: Some(parse_u64_field( - &self.max_output_tokens, - "Max Output Tokens", - cx, - )?), - max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, - reasoning_effort: { - if self.capabilities.supports_thinking.selected() { - Some(self.reasoning_effort) - } else { - None - } - }, - capabilities: OpenAiCompatibleModelCapabilities { - tools: self.capabilities.supports_tools.selected(), - images: self.capabilities.supports_images.selected(), - parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(), - prompt_cache_key: self.capabilities.supports_prompt_cache_key.selected(), - chat_completions: self.capabilities.supports_chat_completions.selected(), - interleaved_reasoning: self.capabilities.supports_thinking.selected() - && self.capabilities.supports_chat_completions.selected() - && self.capabilities.interleaved_reasoning.selected(), - max_tokens_parameter: self.capabilities.supports_chat_completions.selected() - && self.capabilities.max_tokens_parameter.selected(), - }, - }) - } - - fn parse_anthropic_compatible( - &self, - cx: &App, - ) -> Result { - Ok(AnthropicCompatibleAvailableModel { - name: self.parse_name(cx)?, - display_name: None, - max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, - tool_override: None, - max_output_tokens: Some(parse_u64_field( - &self.max_output_tokens, - "Max Output Tokens", - cx, - )?), - default_temperature: None, - extra_beta_headers: Vec::new(), - mode: None, - capabilities: AnthropicCompatibleModelCapabilities { - tools: self.capabilities.supports_tools.selected(), - images: self.capabilities.supports_images.selected(), - prompt_caching: false, - }, - }) - } -} - -fn parse_u64_field( - field: &Entity, - field_name: &str, - cx: &App, -) -> Result { - field - .read(cx) - .text(cx) - .parse::() - .map_err(|_| SharedString::from(format!("{field_name} must be a number"))) -} - -enum ParsedModels { - OpenAi(Vec), - Anthropic(Vec), -} - -impl ParsedModels { - fn model_names(&self) -> impl Iterator { - match self { - ParsedModels::OpenAi(models) => { - itertools::Either::Left(models.iter().map(|model| model.name.as_str())) - } - ParsedModels::Anthropic(models) => { - itertools::Either::Right(models.iter().map(|model| model.name.as_str())) - } - } - } -} - -fn save_provider_to_settings( - provider: LlmCompatibleProvider, - input: &AddLlmProviderInput, - cx: &mut App, -) -> Task> { - let provider_name: Arc = input.provider_name.read(cx).text(cx).into(); - if provider_name.is_empty() { - return Task::ready(Err("Provider Name cannot be empty".into())); - } - - if LanguageModelRegistry::read_global(cx) - .providers() - .iter() - .any(|provider| { - provider.id().0.as_ref() == provider_name.as_ref() - || provider.name().0.as_ref() == provider_name.as_ref() - }) - { - return Task::ready(Err( - "Provider Name is already taken by another provider".into() - )); - } - - let api_url = input.api_url.read(cx).text(cx); - if api_url.is_empty() { - return Task::ready(Err("API URL cannot be empty".into())); - } - - let api_key = input.api_key.read(cx).text(cx); - if api_key.is_empty() { - return Task::ready(Err("API Key cannot be empty".into())); - } - - let models = match provider { - LlmCompatibleProvider::OpenAi => input - .models - .iter() - .map(|model| model.parse_open_ai_compatible(cx)) - .collect::, _>>() - .map(ParsedModels::OpenAi), - LlmCompatibleProvider::Anthropic => input - .models - .iter() - .map(|model| model.parse_anthropic_compatible(cx)) - .collect::, _>>() - .map(ParsedModels::Anthropic), - }; - let models = match models { - Ok(models) => models, - Err(error) => return Task::ready(Err(error)), - }; - - if !models.model_names().all_unique() { - return Task::ready(Err("Model Names must be unique".into())); - } - - let fs = ::global(cx); - let task = cx.write_credentials(&api_url, "Bearer", api_key.as_bytes()); - cx.spawn(async move |cx| { - task.await - .map_err(|_| SharedString::from("Failed to write API key to keychain"))?; - cx.update(|cx| { - update_settings_file(fs, cx, move |settings, _cx| { - let language_models = settings.language_models.get_or_insert_default(); - match models { - ParsedModels::OpenAi(available_models) => { - language_models - .openai_compatible - .get_or_insert_default() - .insert( - provider_name, - OpenAiCompatibleSettingsContent { - api_url, - available_models, - custom_headers: None, - }, - ); - } - ParsedModels::Anthropic(available_models) => { - language_models - .anthropic_compatible - .get_or_insert_default() - .insert( - provider_name, - AnthropicCompatibleSettingsContent { - api_url, - available_models, - custom_headers: None, - }, - ); - } - } - }); - }); - Ok(()) - }) -} - -pub struct AddLlmProviderModal { - provider: LlmCompatibleProvider, - input: AddLlmProviderInput, - scroll_handle: ScrollHandle, - focus_handle: FocusHandle, - last_error: Option, -} - -impl AddLlmProviderModal { - pub fn toggle( - provider: LlmCompatibleProvider, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) { - workspace.toggle_modal(window, cx, |window, cx| Self::new(provider, window, cx)); - } - - fn new(provider: LlmCompatibleProvider, window: &mut Window, cx: &mut Context) -> Self { - Self { - input: AddLlmProviderInput::new(provider, window, cx), - provider, - last_error: None, - focus_handle: cx.focus_handle(), - scroll_handle: ScrollHandle::new(), - } - } - - fn confirm(&mut self, _: &menu::Confirm, _: &mut Window, cx: &mut Context) { - let task = save_provider_to_settings(self.provider, &self.input, cx); - cx.spawn(async move |this, cx| { - let result = task.await; - this.update(cx, |this, cx| match result { - Ok(_) => { - cx.emit(DismissEvent); - } - Err(error) => { - this.last_error = Some(error); - cx.notify(); - } - }) - }) - .detach_and_log_err(cx); - } - - fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn render_model_section( - &self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - v_flex() - .mt_1() - .gap_2() - .child( - h_flex() - .justify_between() - .child(Label::new("Models").size(LabelSize::Small)) - .child( - Button::new("add-model", "Add Model") - .start_icon( - Icon::new(IconName::Plus) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.input.add_model(window, cx); - cx.notify(); - })), - ), - ) - .children( - self.input - .models - .iter() - .enumerate() - .map(|(ix, _)| self.render_model(ix, window, cx)), - ) - } - - fn render_open_ai_reasoning_settings( - &self, - ix: usize, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement + use<> { - let model = &self.input.models[ix]; - let selected_effort = model.reasoning_effort; - let supports_thinking = model.capabilities.supports_thinking; - let supports_chat_completions = model.capabilities.supports_chat_completions; - let interleaved_reasoning = model.capabilities.interleaved_reasoning; - let weak_self = cx.weak_entity(); - - let effort_menu = ContextMenu::build(window, cx, move |mut menu, _window, _cx| { - for effort in OpenAiReasoningEffort::OPENAI_COMPATIBLE_SELECTABLE { - let is_selected = effort == selected_effort; - let weak_self = weak_self.clone(); - menu.push_item( - ContextMenuEntry::new(effort.label()) - .toggleable(IconPosition::End, is_selected) - .handler(move |_window, cx| { - weak_self - .update(cx, |this, cx| { - this.input.models[ix].reasoning_effort = effort; - cx.notify(); - }) - .ok(); - }), - ); - } - - menu - }); - - v_flex() - .gap_1() - .child( - Checkbox::new(("supports-thinking", ix), supports_thinking) - .label("Supports thinking") - .on_click(cx.listener(move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_thinking = *checked; - cx.notify(); - })), - ) - .when(supports_thinking.selected(), |parent| { - parent - .child( - v_flex() - .gap_1() - .child(Label::new("Default reasoning effort").size(LabelSize::Small)) - .child( - DropdownMenu::new( - ElementId::Name( - format!("reasoning-effort-selector-{ix}").into(), - ), - selected_effort.label(), - effort_menu, - ) - .style(DropdownStyle::Outlined) - .trigger_size(ButtonSize::Compact) - .full_width(true) - .aria_label("Default reasoning effort"), - ), - ) - .when(supports_chat_completions.selected(), |parent| { - parent.child( - Checkbox::new(("interleaved-reasoning", ix), interleaved_reasoning) - .label("Preserves thinking in chat history") - .on_click(cx.listener(move |this, checked, _window, cx| { - this.input.models[ix].capabilities.interleaved_reasoning = - *checked; - cx.notify(); - })), - ) - }) - }) - } - - fn render_model( - &self, - ix: usize, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement + use<> { - let has_more_than_one_model = self.input.models.len() > 1; - let is_open_ai = self.provider.is_open_ai(); - let model = &self.input.models[ix]; - - v_flex() - .p_2() - .gap_2() - .rounded_sm() - .border_1() - .border_dashed() - .border_color(cx.theme().colors().border.opacity(0.6)) - .bg(cx.theme().colors().element_active.opacity(0.15)) - .child(model.name.clone()) - .child( - h_flex() - .gap_2() - .when(is_open_ai, |parent| { - parent.child(model.max_completion_tokens.clone()) - }) - .child(model.max_output_tokens.clone()), - ) - .child(model.max_tokens.clone()) - .child( - v_flex() - .gap_1() - .child( - Checkbox::new(("supports-tools", ix), model.capabilities.supports_tools) - .label("Supports tools") - .on_click(cx.listener(move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_tools = *checked; - cx.notify(); - })), - ) - .child( - Checkbox::new(("supports-images", ix), model.capabilities.supports_images) - .label("Supports images") - .on_click(cx.listener(move |this, checked, _window, cx| { - this.input.models[ix].capabilities.supports_images = *checked; - cx.notify(); - })), - ) - .when(is_open_ai, |parent| { - parent - .child( - Checkbox::new( - ("supports-parallel-tool-calls", ix), - model.capabilities.supports_parallel_tool_calls, - ) - .label("Supports parallel_tool_calls") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .supports_parallel_tool_calls = *checked; - cx.notify(); - }, - )), - ) - .child( - Checkbox::new( - ("supports-prompt-cache-key", ix), - model.capabilities.supports_prompt_cache_key, - ) - .label("Supports prompt_cache_key") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .supports_prompt_cache_key = *checked; - cx.notify(); - }, - )), - ) - .child( - Checkbox::new( - ("supports-chat-completions", ix), - model.capabilities.supports_chat_completions, - ) - .label("Supports /chat/completions") - .on_click(cx.listener( - move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .supports_chat_completions = *checked; - cx.notify(); - }, - )), - ) - .when( - model.capabilities.supports_chat_completions.selected(), - |parent| { - parent.child( - Checkbox::new( - ("max-tokens-parameter", ix), - model.capabilities.max_tokens_parameter, - ) - .label("Uses max_tokens for output limit") - .on_click( - cx.listener(move |this, checked, _window, cx| { - this.input.models[ix] - .capabilities - .max_tokens_parameter = *checked; - cx.notify(); - }), - ), - ) - }, - ) - .child(self.render_open_ai_reasoning_settings(ix, window, cx)) - }), - ) - .when(has_more_than_one_model, |this| { - this.child( - Button::new(("remove-model", ix), "Remove Model") - .start_icon( - Icon::new(IconName::Trash) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .label_size(LabelSize::Small) - .style(ButtonStyle::Outlined) - .full_width() - .on_click(cx.listener(move |this, _, _window, cx| { - this.input.remove_model(ix); - cx.notify(); - })), - ) - }) - } - - fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { - window.focus_next(cx); - } - - fn on_tab_prev( - &mut self, - _: &menu::SelectPrevious, - window: &mut Window, - cx: &mut Context, - ) { - window.focus_prev(cx); - } -} - -impl EventEmitter for AddLlmProviderModal {} - -impl Focusable for AddLlmProviderModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl ModalView for AddLlmProviderModal {} - -impl Render for AddLlmProviderModal { - fn render(&mut self, window: &mut ui::Window, cx: &mut ui::Context) -> impl IntoElement { - let focus_handle = self.focus_handle(cx); - - let window_size = window.viewport_size(); - let rem_size = window.rem_size(); - let is_large_window = window_size.height / rem_size > rems_from_px(600.).0; - - let modal_max_height = if is_large_window { - rems_from_px(450.) - } else { - rems_from_px(200.) - }; - - v_flex() - .id("add-llm-provider-modal") - .key_context("AddLlmProviderModal") - .w(rems(34.)) - .elevation_3(cx) - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::on_tab)) - .on_action(cx.listener(Self::on_tab_prev)) - .capture_any_mouse_down(cx.listener(|this, _, window, cx| { - this.focus_handle(cx).focus(window, cx); - })) - .child( - Modal::new("configure-context-server", None) - .header( - ModalHeader::new() - .headline("Add LLM Provider") - .description(self.provider.description()), - ) - .when_some(self.last_error.clone(), |this, error| { - this.section( - Section::new().child( - Banner::new() - .severity(Severity::Warning) - .child(div().text_xs().child(error)), - ), - ) - }) - .child( - div() - .size_full() - .vertical_scrollbar_for(&self.scroll_handle, window, cx) - .child( - v_flex() - .id("modal_content") - .size_full() - .tab_group() - .max_h(modal_max_height) - .pl_3() - .pr_4() - .pb_2() - .gap_2() - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - .child(self.input.provider_name.clone()) - .child(self.input.api_url.clone()) - .child(self.input.api_key.clone()) - .child(self.render_model_section(window, cx)), - ), - ) - .footer( - ModalFooter::new().end_slot( - h_flex() - .gap_1() - .child( - Button::new("cancel", "Cancel") - .key_binding( - KeyBinding::for_action_in( - &menu::Cancel, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _event, window, cx| { - this.cancel(&menu::Cancel, window, cx) - })), - ) - .child( - Button::new("save-server", "Save Provider") - .key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(cx.listener(|this, _event, window, cx| { - this.confirm(&menu::Confirm, window, cx) - })), - ), - ), - ), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use fs::FakeFs; - use gpui::{TestAppContext, VisualTestContext}; - use language_model::{ - LanguageModelProviderId, LanguageModelProviderName, - fake_provider::FakeLanguageModelProvider, - }; - use project::Project; - use settings::SettingsStore; - use util::path; - use workspace::MultiWorkspace; - - #[gpui::test] - async fn test_save_provider_invalid_inputs(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - for provider in [ - LlmCompatibleProvider::OpenAi, - LlmCompatibleProvider::Anthropic, - ] { - assert_eq!( - save_provider_validation_errors(provider, "", "someurl", "somekey", vec![], cx) - .await, - Some("Provider Name cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "", - "somekey", - vec![], - cx - ) - .await, - Some("API URL cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "", - vec![], - cx - ) - .await, - Some("API Key cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "somekey", - vec![("", "200000", "200000", "32000")], - cx, - ) - .await, - Some("Model Name cannot be empty".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "abc", "200000", "32000")], - cx, - ) - .await, - Some("Max Tokens must be a number".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "200000", "200000", "abc")], - cx, - ) - .await, - Some("Max Output Tokens must be a number".into()) - ); - - assert_eq!( - save_provider_validation_errors( - provider, - "someprovider", - "someurl", - "somekey", - vec![ - ("somemodel", "200000", "200000", "32000"), - ("somemodel", "200000", "200000", "32000"), - ], - cx, - ) - .await, - Some("Model Names must be unique".into()) - ); - } - - // Max Completion Tokens is only used by OpenAI-compatible providers. - assert_eq!( - save_provider_validation_errors( - LlmCompatibleProvider::OpenAi, - "someprovider", - "someurl", - "somekey", - vec![("somemodel", "200000", "abc", "32000")], - cx, - ) - .await, - Some("Max Completion Tokens must be a number".into()) - ); - } - - #[gpui::test] - async fn test_save_provider_name_conflict(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|_window, cx| { - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.register_provider( - Arc::new(FakeLanguageModelProvider::new( - LanguageModelProviderId::new("someprovider"), - LanguageModelProviderName::new("Some Provider"), - )), - cx, - ); - }); - }); - - assert_eq!( - save_provider_validation_errors( - LlmCompatibleProvider::OpenAi, - "someprovider", - "someurl", - "someapikey", - vec![("somemodel", "200000", "200000", "32000")], - cx, - ) - .await, - Some("Provider Name is already taken by another provider".into()) - ); - } - - #[gpui::test] - async fn test_model_input_default_capabilities(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|window, cx| { - let model_input = ModelInput::new(0, window, cx); - model_input.name.update(cx, |input, cx| { - input.set_text("somemodel", window, cx); - }); - assert_eq!( - model_input.capabilities.supports_tools, - ToggleState::Selected - ); - assert_eq!( - model_input.capabilities.supports_images, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.supports_parallel_tool_calls, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.supports_prompt_cache_key, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.supports_chat_completions, - ToggleState::Selected - ); - assert_eq!( - model_input.capabilities.supports_thinking, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.interleaved_reasoning, - ToggleState::Unselected - ); - assert_eq!( - model_input.capabilities.max_tokens_parameter, - ToggleState::Unselected - ); - assert_eq!(model_input.reasoning_effort, OpenAiReasoningEffort::Medium); - - let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); - assert!(parsed_model.capabilities.tools); - assert!(!parsed_model.capabilities.images); - assert!(!parsed_model.capabilities.parallel_tool_calls); - assert!(!parsed_model.capabilities.prompt_cache_key); - assert!(parsed_model.capabilities.chat_completions); - assert!(!parsed_model.capabilities.interleaved_reasoning); - assert!(!parsed_model.capabilities.max_tokens_parameter); - assert_eq!(parsed_model.reasoning_effort, None); - }); - } - - #[gpui::test] - async fn test_model_input_deselected_capabilities(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|window, cx| { - let mut model_input = ModelInput::new(0, window, cx); - model_input.name.update(cx, |input, cx| { - input.set_text("somemodel", window, cx); - }); - - model_input.capabilities.supports_tools = ToggleState::Unselected; - model_input.capabilities.supports_images = ToggleState::Unselected; - model_input.capabilities.supports_parallel_tool_calls = ToggleState::Unselected; - model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; - model_input.capabilities.supports_chat_completions = ToggleState::Unselected; - model_input.capabilities.supports_thinking = ToggleState::Unselected; - model_input.capabilities.interleaved_reasoning = ToggleState::Selected; - model_input.capabilities.max_tokens_parameter = ToggleState::Selected; - - let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); - assert!(!parsed_model.capabilities.tools); - assert!(!parsed_model.capabilities.images); - assert!(!parsed_model.capabilities.parallel_tool_calls); - assert!(!parsed_model.capabilities.prompt_cache_key); - assert!(!parsed_model.capabilities.chat_completions); - assert!(!parsed_model.capabilities.interleaved_reasoning); - assert!(!parsed_model.capabilities.max_tokens_parameter); - assert_eq!(parsed_model.reasoning_effort, None); - }); - } - - #[gpui::test] - async fn test_model_input_with_name_and_capabilities(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|window, cx| { - let mut model_input = ModelInput::new(0, window, cx); - model_input.name.update(cx, |input, cx| { - input.set_text("somemodel", window, cx); - }); - - model_input.capabilities.supports_tools = ToggleState::Selected; - model_input.capabilities.supports_images = ToggleState::Unselected; - model_input.capabilities.supports_parallel_tool_calls = ToggleState::Selected; - model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; - model_input.capabilities.supports_chat_completions = ToggleState::Selected; - model_input.capabilities.supports_thinking = ToggleState::Selected; - model_input.capabilities.interleaved_reasoning = ToggleState::Selected; - model_input.capabilities.max_tokens_parameter = ToggleState::Selected; - model_input.reasoning_effort = OpenAiReasoningEffort::XHigh; - - let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); - assert_eq!(parsed_model.name, "somemodel"); - assert!(parsed_model.capabilities.tools); - assert!(!parsed_model.capabilities.images); - assert!(parsed_model.capabilities.parallel_tool_calls); - assert!(!parsed_model.capabilities.prompt_cache_key); - assert!(parsed_model.capabilities.chat_completions); - assert!(parsed_model.capabilities.interleaved_reasoning); - assert!(parsed_model.capabilities.max_tokens_parameter); - assert_eq!( - parsed_model.reasoning_effort, - Some(OpenAiReasoningEffort::XHigh) - ); - }); - } - - #[gpui::test] - async fn test_model_input_parse_anthropic_compatible(cx: &mut TestAppContext) { - let cx = setup_test(cx).await; - - cx.update(|window, cx| { - let mut model_input = ModelInput::new(0, window, cx); - model_input.name.update(cx, |input, cx| { - input.set_text("somemodel", window, cx); - }); - - let parsed_model = model_input.parse_anthropic_compatible(cx).unwrap(); - assert_eq!(parsed_model.name, "somemodel"); - assert_eq!(parsed_model.max_tokens, 200000); - assert_eq!(parsed_model.max_output_tokens, Some(32000)); - assert!(parsed_model.capabilities.tools); - assert!(!parsed_model.capabilities.images); - - model_input.capabilities.supports_tools = ToggleState::Unselected; - model_input.capabilities.supports_images = ToggleState::Selected; - - let parsed_model = model_input.parse_anthropic_compatible(cx).unwrap(); - assert!(!parsed_model.capabilities.tools); - assert!(parsed_model.capabilities.images); - }); - } - - async fn setup_test(cx: &mut TestAppContext) -> &mut VisualTestContext { - cx.update(|cx| { - let store = SettingsStore::test(cx); - cx.set_global(store); - theme_settings::init(theme::LoadThemes::JustBase, cx); - - language_model::init(cx); - editor::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); - - cx - } - - async fn save_provider_validation_errors( - provider: LlmCompatibleProvider, - provider_name: &str, - api_url: &str, - api_key: &str, - models: Vec<(&str, &str, &str, &str)>, - cx: &mut VisualTestContext, - ) -> Option { - fn set_text(input: &Entity, text: &str, window: &mut Window, cx: &mut App) { - input.update(cx, |input, cx| { - input.set_text(text, window, cx); - }); - } - - let task = cx.update(|window, cx| { - let mut input = AddLlmProviderInput::new(provider, window, cx); - set_text(&input.provider_name, provider_name, window, cx); - set_text(&input.api_url, api_url, window, cx); - set_text(&input.api_key, api_key, window, cx); - - for (i, (name, max_tokens, max_completion_tokens, max_output_tokens)) in - models.iter().enumerate() - { - if i >= input.models.len() { - input.models.push(ModelInput::new(i, window, cx)); - } - let model = &mut input.models[i]; - set_text(&model.name, name, window, cx); - set_text(&model.max_tokens, max_tokens, window, cx); - set_text( - &model.max_completion_tokens, - max_completion_tokens, - window, - cx, - ); - set_text(&model.max_output_tokens, max_output_tokens, window, cx); - } - save_provider_to_settings(provider, &input, cx) - }); - - task.await.err() - } -} diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 5ceb13d594..843a35068b 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -3,6 +3,7 @@ use collections::HashMap; use context_server::{ContextServerCommand, ContextServerId}; use editor::{Editor, EditorElement, EditorStyle}; +use extension_host::ExtensionStore; use gpui::{ AsyncWindowContext, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, Subscription, Task, TaskExt, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, @@ -402,7 +403,12 @@ fn resolve_context_server_extension( return Task::ready(None); }; - let extension = crate::agent_configuration::resolve_extension_for_context_server(&id, cx); + let extension = ExtensionStore::global(cx) + .read(cx) + .installed_extensions() + .iter() + .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0)) + .map(|(id, entry)| (id.clone(), entry.manifest.clone())); cx.spawn(async move |cx| { let installation = descriptor .configuration(worktree_store, cx) diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs deleted file mode 100644 index 5115e2f70c..0000000000 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_tools_modal.rs +++ /dev/null @@ -1,175 +0,0 @@ -use agent::ContextServerRegistry; -use collections::HashMap; -use context_server::ContextServerId; -use gpui::{ - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ScrollHandle, Window, prelude::*, -}; -use ui::{Divider, DividerColor, Modal, ModalHeader, WithScrollbar, prelude::*}; -use workspace::{ModalView, Workspace}; - -pub struct ConfigureContextServerToolsModal { - context_server_id: ContextServerId, - context_server_registry: Entity, - focus_handle: FocusHandle, - expanded_tools: HashMap, - scroll_handle: ScrollHandle, -} - -impl ConfigureContextServerToolsModal { - fn new( - context_server_id: ContextServerId, - context_server_registry: Entity, - _window: &mut Window, - cx: &mut Context, - ) -> Self { - Self { - context_server_id, - context_server_registry, - focus_handle: cx.focus_handle(), - expanded_tools: HashMap::default(), - scroll_handle: ScrollHandle::new(), - } - } - - pub fn toggle( - context_server_id: ContextServerId, - context_server_registry: Entity, - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) { - workspace.toggle_modal(window, cx, |window, cx| { - Self::new(context_server_id, context_server_registry, window, cx) - }); - } - - fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent) - } - - fn render_modal_content( - &self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let tools = self - .context_server_registry - .read(cx) - .tools_for_server(&self.context_server_id) - .collect::>(); - - div() - .size_full() - .pb_2() - .child( - v_flex() - .id("modal_content") - .px_2() - .gap_1() - .max_h_128() - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - .children(tools.iter().enumerate().flat_map(|(index, tool)| { - let tool_name = tool.name(); - let is_expanded = self - .expanded_tools - .get(tool_name.as_ref()) - .copied() - .unwrap_or(false); - - let icon = if is_expanded { - IconName::ChevronUp - } else { - IconName::ChevronDown - }; - - let mut items = vec![ - v_flex() - .child( - h_flex() - .id(format!("tool-header-{}", index)) - .py_1() - .pl_1() - .pr_2() - .w_full() - .justify_between() - .rounded_sm() - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .child( - Label::new(tool_name.clone()) - .buffer_font(cx) - .size(LabelSize::Small), - ) - .child( - Icon::new(icon) - .size(IconSize::Small) - .color(Color::Muted), - ) - .on_click(cx.listener({ - move |this, _event, _window, _cx| { - let current = this - .expanded_tools - .get(tool_name.as_ref()) - .copied() - .unwrap_or(false); - this.expanded_tools - .insert(tool_name.clone(), !current); - _cx.notify(); - } - })), - ) - .when(is_expanded, |this| { - this.child( - Label::new(tool.description()).color(Color::Muted).mx_1(), - ) - }) - .into_any_element(), - ]; - - if index < tools.len() - 1 { - items.push( - h_flex() - .w_full() - .child(Divider::horizontal().color(DividerColor::BorderVariant)) - .into_any_element(), - ); - } - - items - })), - ) - .vertical_scrollbar_for(&self.scroll_handle, window, cx) - .into_any_element() - } -} - -impl ModalView for ConfigureContextServerToolsModal {} - -impl Focusable for ConfigureContextServerToolsModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl EventEmitter for ConfigureContextServerToolsModal {} - -impl Render for ConfigureContextServerToolsModal { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .key_context("ContextServerToolsModal") - .occlude() - .elevation_3(cx) - .w(rems(34.)) - .on_action(cx.listener(Self::cancel)) - .track_focus(&self.focus_handle) - .child( - Modal::new("configure-context-server-tools", None::) - .header( - ModalHeader::new() - .headline(format!("Tools from {}", self.context_server_id.0)) - .show_dismiss_button(true), - ) - .child(self.render_modal_content(window, cx)), - ) - } -} diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 6d608958e6..01b380044a 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -20,7 +20,6 @@ use db::kvp::{Dismissable, KeyValueStore}; use itertools::Itertools; use project::{AgentId, ProjectItem}; use serde::{Deserialize, Serialize}; -use settings::{LanguageModelProviderSetting, LanguageModelSelection}; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, @@ -49,7 +48,6 @@ use crate::{ LoadThreadFromClipboard, NewTerminalThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, ResetFastModeWarnings, ResetTrialEndUpsell, ResetTrialUpsell, ShowAllSidebarThreadMetadata, ShowThreadMetadata, ToggleNewThreadMenu, ToggleOptionsMenu, - agent_configuration::{AgentConfiguration, AssistantConfigurationEvent}, conversation_view::{ AcpThreadViewEvent, RootThreadUpdated, ThreadView, reset_fast_mode_warnings, }, @@ -70,9 +68,7 @@ use cloud_api_types::Plan; use collections::HashMap; use editor::{Editor, MultiBuffer}; use extension_host::ExtensionStore; -use feature_flags::{ - AgentSettingsUiFeatureFlag, CreateThreadToolFeatureFlag, FeatureFlagAppExt as _, -}; +use feature_flags::{CreateThreadToolFeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use futures::FutureExt as _; @@ -1124,15 +1120,10 @@ impl From for BaseView { } } -enum OverlayView { - Configuration, -} - enum VisibleSurface<'a> { Uninitialized, AgentThread(&'a Entity), Terminal(&'a Entity), - Configuration(Option<&'a Entity>), } enum WhichFontSize { @@ -1149,14 +1140,6 @@ impl BaseView { } } -impl OverlayView { - pub fn which_font_size_used(&self) -> WhichFontSize { - match self { - OverlayView::Configuration => WhichFontSize::None, - } - } -} - pub struct AgentPanel { workspace: WeakEntity, /// Workspace id is used as a database key @@ -1168,12 +1151,9 @@ pub struct AgentPanel { thread_store: Entity, connection_store: Entity, context_server_registry: Entity, - configuration: Option>, - configuration_subscription: Option, focus_handle: FocusHandle, base_view: BaseView, last_created_entry_kind: AgentPanelEntryKind, - overlay_view: Option, draft_thread: Option>, retained_threads: HashMap>, terminals: HashMap, @@ -1568,15 +1548,12 @@ impl AgentPanel { workspace_id, base_view, last_created_entry_kind: AgentPanelEntryKind::Thread, - overlay_view: None, workspace, user_store, project: project.clone(), fs: fs.clone(), language_registry, connection_store, - configuration: None, - configuration_subscription: None, focus_handle: cx.focus_handle(), context_server_registry, draft_thread: None, @@ -1752,7 +1729,6 @@ impl AgentPanel { pub fn clear_base_view(&mut self, window: &mut Window, cx: &mut Context) { let old_view = std::mem::replace(&mut self.base_view, BaseView::Uninitialized); self.retain_running_thread(old_view, cx); - self.clear_overlay_state(); self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); self.serialize(cx); cx.emit(AgentPanelEvent::ActiveViewChanged); @@ -2928,15 +2904,7 @@ impl AgentPanel { let draft = self.ensure_draft(source, window, cx); if let BaseView::AgentThread { conversation_view } = &self.base_view { if conversation_view.entity_id() == draft.entity_id() { - // If we're already viewing the draft as the base view but an - // overlay (e.g. Settings) is covering it, clear the overlay - // so the user actually sees the draft they asked for. - // Otherwise pressing "New Thread" from the Settings panel is - // a silent no-op because the early return below would leave - // the overlay on top of the draft. - if self.overlay_view.is_some() { - self.clear_overlay(focus, window, cx); - } else if focus { + if focus { self.focus_handle(cx).focus(window, cx); } return; @@ -3302,7 +3270,6 @@ impl AgentPanel { } if self.active_thread_id(cx) == Some(id) { - self.clear_overlay_state(); if activate_draft_after_remove { self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx); } else { @@ -3547,13 +3514,6 @@ impl AgentPanel { }) } - pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context) { - if self.overlay_view.is_some() { - self.clear_overlay(true, window, cx); - cx.notify(); - } - } - pub fn toggle_options_menu( &mut self, _: &ToggleOptionsMenu, @@ -3671,57 +3631,13 @@ impl AgentPanel { } pub(crate) fn open_configuration(&mut self, window: &mut Window, cx: &mut Context) { - // When the agent settings have been moved into the settings UI, the - // panel no longer shows its own configuration overlay. Instead, route to - // the settings UI at the LLM providers page (where the model selector's - // "Configure" button expects to land). - if cx.has_flag::() { - window.dispatch_action( - Box::new(zed_actions::OpenSettingsAt { - path: "llm_providers".to_string(), - target: None, - }), - cx, - ); - return; - } - - if matches!(self.overlay_view, Some(OverlayView::Configuration)) { - self.clear_overlay(true, window, cx); - return; - } - - let agent_server_store = self.project.read(cx).agent_server_store().clone(); - let context_server_store = self.project.read(cx).context_server_store(); - let fs = self.fs.clone(); - - self.configuration = Some(cx.new(|cx| { - AgentConfiguration::new( - fs, - agent_server_store, - self.connection_store.clone(), - context_server_store, - self.context_server_registry.clone(), - self.language_registry.clone(), - self.workspace.clone(), - window, - cx, - ) - })); - - if let Some(configuration) = self.configuration.as_ref() { - self.configuration_subscription = Some(cx.subscribe_in( - configuration, - window, - Self::handle_agent_configuration_event, - )); - } - - self.set_overlay(OverlayView::Configuration, true, window, cx); - - if let Some(configuration) = self.configuration.as_ref() { - configuration.focus_handle(cx).focus(window, cx); - } + window.dispatch_action( + Box::new(zed_actions::OpenSettingsPage { + page: "AI".to_string(), + target: None, + }), + cx, + ); } pub(crate) fn open_active_thread_as_markdown( @@ -4004,53 +3920,6 @@ impl AgentPanel { .detach_and_log_err(cx); } - fn handle_agent_configuration_event( - &mut self, - _entity: &Entity, - event: &AssistantConfigurationEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event { - AssistantConfigurationEvent::NewThread(provider) => { - if LanguageModelRegistry::read_global(cx) - .default_model() - .is_none_or(|model| model.provider.id() != provider.id()) - && let Some(model) = provider.default_model(cx) - { - update_settings_file(self.fs.clone(), cx, move |settings, _| { - let provider = model.provider_id().0.to_string(); - let enable_thinking = model.supports_thinking(); - let effort = model - .default_effort_level() - .map(|effort| effort.value.to_string()); - let model = model.id().0.to_string(); - settings - .agent - .get_or_insert_default() - .set_model(LanguageModelSelection { - provider: LanguageModelProviderSetting(provider), - model, - enable_thinking, - effort, - speed: None, - }) - }); - } - - self.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx); - if let Some((thread, model)) = self - .active_native_agent_thread(cx) - .zip(provider.default_model(cx)) - { - thread.update(cx, |thread, cx| { - thread.set_model(model, cx); - }); - } - } - } - } - pub fn workspace_id(&self) -> Option { self.workspace_id } @@ -4283,8 +4152,6 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - self.clear_overlay_state(); - let old_view = std::mem::replace(&mut self.base_view, new_view); self.retain_running_thread(old_view, cx); @@ -4305,35 +4172,6 @@ impl AgentPanel { cx.emit(AgentPanelEvent::ActiveViewChanged); } - fn set_overlay( - &mut self, - overlay: OverlayView, - focus: bool, - window: &mut Window, - cx: &mut Context, - ) { - self.overlay_view = Some(overlay); - if focus { - self.focus_handle(cx).focus(window, cx); - } - cx.emit(AgentPanelEvent::ActiveViewChanged); - } - - fn clear_overlay(&mut self, focus: bool, window: &mut Window, cx: &mut Context) { - self.clear_overlay_state(); - - if focus { - self.focus_handle(cx).focus(window, cx); - } - cx.emit(AgentPanelEvent::ActiveViewChanged); - } - - fn clear_overlay_state(&mut self) { - self.overlay_view = None; - self.configuration_subscription = None; - self.configuration = None; - } - fn refresh_base_view_subscriptions(&mut self, window: &mut Window, cx: &mut Context) { self._base_view_observation = match &self.base_view { BaseView::AgentThread { conversation_view } => { @@ -4386,14 +4224,6 @@ impl AgentPanel { } fn visible_surface(&self) -> VisibleSurface<'_> { - if let Some(overlay_view) = &self.overlay_view { - return match overlay_view { - OverlayView::Configuration => { - VisibleSurface::Configuration(self.configuration.as_ref()) - } - }; - } - match &self.base_view { BaseView::Uninitialized => VisibleSurface::Uninitialized, BaseView::AgentThread { conversation_view } => { @@ -4407,15 +4237,8 @@ impl AgentPanel { } } - fn is_overlay_open(&self) -> bool { - self.overlay_view.is_some() - } - fn visible_font_size(&self) -> WhichFontSize { - self.overlay_view.as_ref().map_or_else( - || self.base_view.which_font_size_used(), - OverlayView::which_font_size_used, - ) + self.base_view.which_font_size_used() } fn subscribe_to_active_thread_view( @@ -4499,7 +4322,6 @@ impl AgentPanel { // Check if the active view already holds this thread. if let BaseView::AgentThread { conversation_view } = &self.base_view { if conversation_view.read(cx).thread_id == thread_id { - self.clear_overlay_state(); cx.emit(AgentPanelEvent::ActiveViewChanged); return; } @@ -5052,13 +4874,6 @@ impl Focusable for AgentPanel { VisibleSurface::Uninitialized => self.focus_handle.clone(), VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx), VisibleSurface::Terminal(terminal_view) => terminal_view.focus_handle(cx), - VisibleSurface::Configuration(configuration) => { - if let Some(configuration) = configuration { - configuration.focus_handle(cx) - } else { - self.focus_handle.clone() - } - } } } } @@ -5294,10 +5109,7 @@ impl AgentPanel { } fn destination_has_meaningful_state(&self, cx: &App) -> bool { - if self.overlay_view.is_some() - || !self.retained_threads.is_empty() - || !self.terminals.is_empty() - { + if !self.retained_threads.is_empty() || !self.terminals.is_empty() { return true; } @@ -5578,9 +5390,7 @@ impl AgentPanel { Label::new("Terminal").into_any_element() } } - VisibleSurface::Configuration(_) => { - Label::new("Settings").truncate().into_any_element() - } + VisibleSurface::Uninitialized => Label::new("Agent").truncate().into_any_element(), }; @@ -5750,8 +5560,7 @@ impl AgentPanel { if !showing_terminal { menu = menu .header("MCP Servers") - .action("Add Custom Server…", Box::new(AddContextServer::local())) - .action("Add Remote Server…", Box::new(AddContextServer::remote())) + .action("Add Server…", Box::new(AddContextServer::local())) .action( "Install New Servers…", Box::new(zed_actions::Extensions { @@ -5844,21 +5653,6 @@ impl AgentPanel { }) } - fn render_toolbar_back_button(&self, cx: &mut Context) -> impl IntoElement { - let focus_handle = self.focus_handle(cx); - - IconButton::new("go-back", IconName::ArrowLeft) - .icon_size(IconSize::Small) - .on_click(cx.listener(|this, _, window, cx| { - this.go_back(&workspace::GoBack, window, cx); - })) - .tooltip({ - move |_window, cx| { - Tooltip::for_action_in("Go Back", &workspace::GoBack, &focus_handle, cx) - } - }) - } - fn render_no_project_state(&self, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle(cx); @@ -6139,15 +5933,12 @@ impl AgentPanel { }; enum ToolbarMode { - Overlay, Terminal, EmptyThread, ActiveThread, } - let mode = if self.is_overlay_open() { - ToolbarMode::Overlay - } else if matches!(self.base_view, BaseView::Terminal { .. }) { + let mode = if matches!(self.base_view, BaseView::Terminal { .. }) { ToolbarMode::Terminal } else if self.active_thread_has_messages(cx) { ToolbarMode::ActiveThread @@ -6231,11 +6022,7 @@ impl AgentPanel { .overflow_hidden() .gap(DynamicSpacing::Base04.rems(cx)) .pl(DynamicSpacing::Base04.rems(cx)) - .child(if matches!(mode, ToolbarMode::Overlay) { - self.render_toolbar_back_button(cx).into_any_element() - } else { - selected_agent.into_any_element() - }) + .child(selected_agent.into_any_element()) .child(match empty_thread_title { Some(title) => title, None => self.render_title_view(window, cx), @@ -6574,7 +6361,6 @@ impl Render for AgentPanel { })) .on_action(cx.listener(Self::open_active_thread_as_markdown)) .on_action(cx.listener(Self::manage_skills)) - .on_action(cx.listener(Self::go_back)) .on_action(cx.listener(Self::toggle_options_menu)) .on_action(cx.listener(Self::increase_font_size)) .on_action(cx.listener(Self::decrease_font_size)) @@ -6607,9 +6393,6 @@ impl Render for AgentPanel { VisibleSurface::Terminal(terminal_view) => parent .child(terminal_view.clone()) .child(self.render_drag_target(cx)), - VisibleSurface::Configuration(configuration) => { - parent.children(configuration.cloned()) - } }) .children(self.render_trial_end_upsell(window, cx)); @@ -9537,63 +9320,6 @@ mod tests { }); } - #[gpui::test] - async fn test_new_thread_dismisses_settings_overlay(cx: &mut TestAppContext) { - let (panel, mut cx) = setup_panel(cx).await; - - // Put the panel on its ephemeral new-draft view so the base view - // already contains the draft that `NewThread` would activate. - panel.update_in(&mut cx, |panel, window, cx| { - panel.activate_new_thread(true, AgentThreadSource::AgentPanel, window, cx); - }); - cx.run_until_parked(); - - panel.read_with(&cx, |panel, cx| { - assert!( - panel.active_view_is_new_draft(cx), - "precondition: base view should be the ephemeral draft" - ); - assert!(!panel.is_overlay_open()); - }); - - // Simulate the Settings overlay being open on top of the draft. - // We don't go through `open_configuration` here because it would - // build provider configuration views, which call into - // `LanguageModelProvider::configuration_view` — unimplemented for - // the fake provider used in tests. The bug being exercised lives - // entirely in the overlay/base-view bookkeeping, so toggling the - // overlay flag directly is sufficient. - panel.update_in(&mut cx, |panel, window, cx| { - panel.set_overlay(OverlayView::Configuration, true, window, cx); - }); - cx.run_until_parked(); - - panel.read_with(&cx, |panel, _cx| { - assert!( - panel.is_overlay_open(), - "precondition: Settings overlay should be open" - ); - }); - - // Dispatching `NewThread` while Settings is open must dismiss the - // overlay so the user actually sees the new thread. Previously - // this was a silent no-op: `activate_draft` early-returned without - // clearing the overlay because the base view already held the - // draft. - panel.update_in(&mut cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); - }); - cx.run_until_parked(); - - panel.read_with(&cx, |panel, cx| { - assert!( - !panel.is_overlay_open(), - "Settings overlay should be dismissed when invoking NewThread" - ); - assert!(panel.active_view_is_new_draft(cx)); - }); - } - #[gpui::test] async fn test_terminal_title_omits_placeholder_title(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; @@ -10273,62 +9999,6 @@ mod tests { ); } - #[gpui::test] - async fn test_terminal_bell_notifies_when_configuration_overlay_covers_terminal( - cx: &mut TestAppContext, - ) { - let (panel, mut cx) = setup_visible_panel(cx).await; - let terminal_id = panel - .update_in(&mut cx, |panel, window, cx| { - panel.insert_test_terminal("Claude", true, window, cx) - }) - .expect("test terminal should be inserted"); - cx.run_until_parked(); - - panel.update_in(&mut cx, |panel, window, cx| { - panel.set_overlay(OverlayView::Configuration, true, window, cx); - }); - panel.update(&mut cx, |panel, cx| { - panel.emit_test_terminal_bell(terminal_id, cx); - }); - cx.run_until_parked(); - - panel.read_with(&cx, |panel, cx| { - let terminal = panel - .terminals(cx) - .into_iter() - .find(|terminal| terminal.id == terminal_id) - .expect("terminal should remain in the panel"); - assert!(terminal.has_notification); - }); - cx.windows() - .iter() - .find_map(|window| window.downcast::()) - .expect("covered terminal bell should show a notification"); - } - - #[gpui::test] - async fn test_thread_notification_shows_when_configuration_overlay_covers_thread( - cx: &mut TestAppContext, - ) { - let (panel, mut cx) = setup_visible_panel(cx).await; - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Default response".into()), - )]); - open_thread_with_connection(&panel, connection, &mut cx); - - panel.update_in(&mut cx, |panel, window, cx| { - panel.set_overlay(OverlayView::Configuration, true, window, cx); - }); - send_message(&panel, &mut cx); - - cx.windows() - .iter() - .find_map(|window| window.downcast::()) - .expect("covered thread should show a notification"); - } - #[gpui::test] async fn test_terminal_bell_marks_without_popup_when_sidebar_open(cx: &mut TestAppContext) { let (panel, mut cx) = setup_visible_panel(cx).await; diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index 28f8b25e0f..4eb73b6d82 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -466,6 +466,10 @@ pub struct ConfigurationView { copilot_status: Option, is_authenticated: Box bool + 'static>, edit_prediction: bool, + /// When `true`, renders a compact control suitable for an inline settings + /// row: no explanatory labels (those live in the row's left column) and + /// content-sized buttons instead of full-width ones. + compact: bool, _subscription: Option, } @@ -487,6 +491,7 @@ impl ConfigurationView { copilot_status: copilot.as_ref().map(|copilot| copilot.0.read(cx).status()), is_authenticated: Box::new(is_authenticated), edit_prediction: matches!(mode, ConfigurationMode::EditPrediction), + compact: false, _subscription: copilot.as_ref().map(|copilot| { cx.observe(&copilot.0, |this, model, cx| { this.copilot_status = Some(model.read(cx).status()); @@ -495,6 +500,14 @@ impl ConfigurationView { }), } } + + /// Renders the view compactly for an inline settings row (no labels, + /// content-sized buttons). The explanatory copy is expected to be shown + /// elsewhere (e.g. the row's left column). + pub fn compact(mut self) -> Self { + self.compact = true; + self + } } impl ConfigurationView { @@ -536,34 +549,34 @@ impl ConfigurationView { edit_prediction: bool, ) -> impl IntoElement { Button::new("loading_button", label) - .full_width() + .map(|this| { + if edit_prediction || self.compact { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) .disabled(true) .loading(true) .style(ButtonStyle::Outlined) - .when(edit_prediction, |this| this.size(ButtonSize::Medium)) } 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" + "Sign In" }; Button::new("sign_in", label) .map(|this| { - if edit_prediction { + if edit_prediction || self.compact { this.size(ButtonSize::Medium) } else { this.full_width() } }) .style(ButtonStyle::Outlined) - .start_icon( - Icon::new(IconName::Github) - .size(IconSize::Small) - .color(Color::Muted), - ) .when(edit_prediction, |this| this.tab_index(0isize)) .on_click(|_, window, cx| { let app_state = AppState::global(cx); @@ -582,7 +595,7 @@ impl ConfigurationView { Button::new("reinstall_and_sign_in", label) .map(|this| { - if edit_prediction { + if edit_prediction || self.compact { this.size(ButtonSize::Medium) } else { this.full_width() @@ -656,31 +669,32 @@ impl ConfigurationView { 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() + let (label, button) = if let Some(msg) = self.loading_message() { + ( + start_label, + 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() + ( + ERROR_LABEL, + 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() + ( + no_status_label, + 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() - } + ( + start_label, + self.render_sign_in_button(false).into_any_element(), + ) + }; + + v_flex() + .gap_2() + .when(!self.compact, |this| this.child(Label::new(label))) + .child(button) } } @@ -689,13 +703,15 @@ impl Render for ConfigurationView { let is_authenticated = &self.is_authenticated; if is_authenticated(cx) { - return ConfiguredApiCard::new("Authorized") + let sign_out = |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| { + if let Some(auth) = GlobalCopilotAuth::try_global(cx) { + initiate_sign_out(auth.0.clone(), window, cx); + } + }; + + return ConfiguredApiCard::new("copilot-authorized", "Authorized") .button_label("Sign Out") - .on_click(|_, window, cx| { - if let Some(auth) = GlobalCopilotAuth::try_global(cx) { - initiate_sign_out(auth.0.clone(), window, cx); - } - }) + .on_click(sign_out) .into_any_element(); } diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 2552eacf10..31096cc38b 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -111,17 +111,13 @@ impl FeatureFlag for AgentThreadWorktreeLabelFlag { } register_feature_flag!(AgentThreadWorktreeLabelFlag); -/// Moves LLM provider and MCP server configuration out of the dedicated agent -/// panel page and into the settings UI. When enabled, the agent panel no longer -/// shows its configuration overlay and the settings UI exposes the "LLM -/// Providers" and "MCP Servers" sub-pages instead. -pub struct AgentSettingsUiFeatureFlag; +pub struct AutoWatchFeatureFlag; -impl FeatureFlag for AgentSettingsUiFeatureFlag { - const NAME: &'static str = "agent-settings-ui"; +impl FeatureFlag for AutoWatchFeatureFlag { + const NAME: &'static str = "auto-watch-screens"; type Value = PresenceFlag; } -register_feature_flag!(AgentSettingsUiFeatureFlag); +register_feature_flag!(AutoWatchFeatureFlag); /// Wraps agent-run terminal commands in an OS-level sandbox where supported /// (currently macOS Seatbelt only). When off, terminal commands run with the diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index df911d1b77..7a8aa00f92 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -371,6 +371,22 @@ pub trait LanguageModelProvider: 'static { ProviderConfigurationView::SubPage(self.configuration_view(target_agent, window, cx)) } + fn inline_title(&self, _cx: &App) -> Option { + None + } + + fn inline_description(&self, _cx: &App) -> Option { + None + } + + fn api_key_configuration(&self, _cx: &App) -> Option { + None + } + + fn set_api_key(&self, _key: String, _cx: &mut App) -> Task> { + Task::ready(Ok(())) + } + /// Copy shown the first time a user enables fast mode for a model from /// this provider. Returning `None` skips the confirmation prompt and lets /// the toggle apply silently. @@ -382,13 +398,31 @@ pub trait LanguageModelProvider: 'static { /// How a provider's configuration UI prefers to be presented by the settings UI. #[derive(Clone)] pub enum ProviderConfigurationView { - /// A compact control suitable for rendering inline in a list row, such as a - /// single API-key field. - Inline(AnyView), - /// A richer view that should be shown on its own dedicated sub-page. + Inline { view: AnyView }, SubPage(AnyView), } +/// A live snapshot of a single-API-key provider's credential state, used by the +/// settings UI to render the provider's "API Key" section. +#[derive(Clone)] +pub struct ApiKeyConfiguration { + pub has_key: bool, + pub is_from_env_var: bool, + pub env_var_name: SharedString, + pub api_key_url: SharedString, +} + +/// The subtitle rendered beneath a provider's name when its configuration is +/// shown inline. +#[derive(Clone)] +pub enum InlineDescription { + /// A clickable "Where to find key" link pointing at the given URL, for + /// API-key based providers. + ApiKeyUrl(SharedString), + /// Plain descriptive text, e.g. explaining a sign-in based provider. + Text(SharedString), +} + /// Provider-specific copy shown the first time a user enables fast mode. #[derive(Debug, Clone)] pub struct FastModeConfirmation { diff --git a/crates/language_models/src/api_key_editor.rs b/crates/language_models/src/api_key_editor.rs deleted file mode 100644 index d6a9182a66..0000000000 --- a/crates/language_models/src/api_key_editor.rs +++ /dev/null @@ -1,155 +0,0 @@ -use std::rc::Rc; - -use anyhow::Result; -use gpui::{App, Context, Entity, Subscription, Task, Window}; -use language_model::ApiKeyState; -use ui::{Tooltip, prelude::*}; -use ui_input::InputField; - -/// The current credential state of a single-API-key provider, as reported by the -/// provider when constructing an [`ApiKeyEditor`]. -pub enum ApiKeyStatus { - /// No key is configured; show the input field. - Unset, - /// A key is configured via the UI; show a "configured" row with a reset. - Configured, - /// The key comes from an environment variable and can't be edited here. - FromEnvVar(SharedString), -} - -/// Maps a provider's [`ApiKeyState`] to the [`ApiKeyStatus`] the editor renders. -/// Shared so the API-key providers don't each duplicate this mapping. -pub fn api_key_status(state: &ApiKeyState) -> ApiKeyStatus { - if state.is_from_env_var() { - ApiKeyStatus::FromEnvVar(state.env_var_name().clone()) - } else if state.has_key() { - ApiKeyStatus::Configured - } else { - ApiKeyStatus::Unset - } -} - -/// A compact, reusable control for editing a provider's single API key, intended -/// to be returned from `LanguageModelProvider::configuration_view_v2` as an -/// inline control. -/// -/// It is deliberately provider-agnostic: the provider supplies closures that -/// read the current [`ApiKeyStatus`] and store/clear the key against its own -/// state, so all credential knowledge stays in the provider. -pub struct ApiKeyEditor { - input: Entity, - api_key_url: SharedString, - status: Rc ApiKeyStatus>, - set_key: Rc Task>>, - reset_key: Rc Task>>, - _subscription: Subscription, -} - -impl ApiKeyEditor { - pub fn new( - state: Entity, - api_key_url: impl Into, - placeholder: &str, - status: impl Fn(&S, &App) -> ApiKeyStatus + 'static, - set_key: impl Fn(&Entity, String, &mut App) -> Task> + 'static, - reset_key: impl Fn(&Entity, &mut App) -> Task> + 'static, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let input = cx.new(|cx| { - InputField::new(window, cx, placeholder) - .masked(true) - .tab_index(0) - }); - let subscription = cx.observe(&state, |_, _, cx| cx.notify()); - - let status_state = state.clone(); - let set_state = state.clone(); - Self { - input, - api_key_url: api_key_url.into(), - status: Rc::new(move |cx| status(status_state.read(cx), cx)), - set_key: Rc::new(move |key, cx| set_key(&set_state, key, cx)), - reset_key: Rc::new(move |cx| reset_key(&state, cx)), - _subscription: subscription, - } - } - - fn save(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - let key = self.input.read(cx).text(cx).trim().to_string(); - if key.is_empty() { - return; - } - self.input - .update(cx, |input, cx| input.set_text("", window, cx)); - (self.set_key.clone())(key, cx).detach_and_log_err(cx); - } - - fn reset(&mut self, cx: &mut Context) { - (self.reset_key.clone())(cx).detach_and_log_err(cx); - } - - fn render_where_to_find_key(&self) -> impl IntoElement { - let url = self.api_key_url.clone(); - let click_url = url.to_string(); - h_flex() - .id("where-to-find-key") - .gap_0p5() - .cursor_pointer() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new("Where to find key") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .tooltip(Tooltip::text(format!("Create an API key at {url}"))) - .on_click(move |_, _window, cx| cx.open_url(&click_url)) - } -} - -impl Render for ApiKeyEditor { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - match (self.status)(cx) { - ApiKeyStatus::FromEnvVar(env_var_name) => Label::new(format!("Set via {env_var_name}")) - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element(), - ApiKeyStatus::Configured => h_flex() - .gap_2() - .items_center() - .child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - .child( - Label::new("Configured") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Button::new("reset-api-key", "Reset") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .tab_index(0isize) - .on_click(cx.listener(|this, _, _window, cx| this.reset(cx))), - ) - .into_any_element(), - ApiKeyStatus::Unset => v_flex() - .w_full() - .gap_1() - .child(self.render_where_to_find_key()) - .child( - div() - .w_full() - .on_action(cx.listener(Self::save)) - .child(self.input.clone()), - ) - .into_any_element(), - } - } -} diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 1133ee2d47..a9ec2b028f 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -10,12 +10,10 @@ use language_model::{ }; use provider::deepseek::DeepSeekLanguageModelProvider; -mod api_key_editor; pub mod extension; pub mod provider; mod settings; -pub use crate::api_key_editor::{ApiKeyEditor, ApiKeyStatus, api_key_status}; pub use crate::extension::init_proxy as init_extension_proxy; use crate::provider::anthropic::AnthropicLanguageModelProvider; diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 46ef381f31..1841a9619d 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -8,12 +8,12 @@ use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ApiKeyState, AuthenticateError, - ConfigurationViewTargetAgent, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel, - LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - ProviderConfigurationView, RateLimiter, env_var, + ANTHROPIC_PROVIDER_ID, ANTHROPIC_PROVIDER_NAME, ApiKeyConfiguration, ApiKeyState, + AuthenticateError, ConfigurationViewTargetAgent, EnvVar, FastModeConfirmation, IconOrSvg, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, + LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, + env_var, }; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; @@ -290,28 +290,19 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { .update(cx, |state, cx| state.set_api_key(None, cx)) } - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://console.anthropic.com/settings/keys", - "sk-ant-...", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + fn api_key_configuration(&self, cx: &App) -> Option { + let state = self.state.read(cx); + Some(ApiKeyConfiguration { + has_key: state.api_key_state.has_key(), + is_from_env_var: state.api_key_state.is_from_env_var(), + env_var_name: state.api_key_state.env_var_name().clone(), + api_key_url: "https://console.anthropic.com/settings/keys".into(), + }) + } + + fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + self.state + .update(cx, |state, cx| state.set_api_key(Some(key), cx)) } fn fast_mode_confirmation(&self, _cx: &App) -> Option { @@ -702,7 +693,7 @@ impl Render for ConfigurationView { ) .into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("anthropic-reset-key", 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| { diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 755d23f52a..4d843009f7 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -32,11 +32,12 @@ use gpui::{ use gpui_tokio::Tokio; use http_client::HttpClient; use language_model::{ - AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, - LanguageModelToolUse, MessageContent, RateLimiter, Role, TokenUsage, env_var, + AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role, + TokenUsage, env_var, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -460,6 +461,12 @@ impl LanguageModelProvider for BedrockLanguageModelProvider { IconOrSvg::Icon(IconName::AiBedrock) } + fn inline_description(&self, _cx: &App) -> Option { + Some(InlineDescription::Text( + "To use Zed's agent with Bedrock, set a custom authentication strategy in your settings or use static credentials.".into(), + )) + } + fn default_model(&self, _cx: &App) -> Option> { Some(self.create_language_model(bedrock::Model::default())) } @@ -1492,10 +1499,6 @@ impl ConfigurationView { .detach_and_log_err(cx); } - fn should_render_editor(&self, cx: &Context) -> bool { - self.state.read(cx).is_authenticated() - } - fn on_tab(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { window.focus_next(cx); } @@ -1577,13 +1580,15 @@ impl Render for ConfigurationView { None }; - if self.should_render_editor(cx) { - return ConfiguredApiCard::new(configured_label) + let credentials_control = if self.state.read(cx).is_authenticated() { + ConfiguredApiCard::new("bedrock-reset", configured_label) .disabled(env_var_set || is_settings_derived) .on_click(cx.listener(|this, _, window, cx| this.reset_credentials(window, cx))) .when_some(tooltip_label, |this, label| this.tooltip_label(label)) - .into_any_element(); - } + .into_any_element() + } else { + self.render_static_credentials_ui().into_any_element() + }; v_flex() .min_w_0() @@ -1592,15 +1597,29 @@ impl Render for ConfigurationView { .on_action(cx.listener(Self::on_tab)) .on_action(cx.listener(Self::on_tab_prev)) .on_action(cx.listener(ConfigurationView::save_credentials)) - .child(Label::new("To use Zed's agent with Bedrock, you can set a custom authentication strategy through your settings file or use static credentials.")) - .child(Label::new("But first, to access models on AWS, you need to:").mt_1()) + .gap_1() + .child(Headline::new("Amazon Bedrock").size(HeadlineSize::Small)) + .child( + Label::new( + "To use Zed's agent with Bedrock, you can set a custom authentication strategy through your settings file or use static credentials.", + ) + .color(Color::Muted), + ) + .child( + Label::new("But first, to access models on AWS, you need to:") + .mt_1() + .color(Color::Muted), + ) .child( List::new() .child( ListBulletItem::new("") - .child(Label::new( - "Grant permissions to the strategy you'll use according to the:", - )) + .child( + Label::new( + "Grant permissions to the strategy you'll use according to the:", + ) + .color(Color::Muted), + ) .child(ButtonLink::new( "Prerequisites", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html", @@ -1608,33 +1627,32 @@ impl Render for ConfigurationView { ) .child( ListBulletItem::new("") - .child(Label::new("Select the models you would like access to:")) + .child( + Label::new("Select the models you would like access to:") + .color(Color::Muted), + ) .child(ButtonLink::new( "Bedrock Model Catalog", "https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/model-catalog", )), ), ) - .child(self.render_static_credentials_ui()) + .child(credentials_control) .into_any() } } impl ConfigurationView { fn render_static_credentials_ui(&self) -> impl IntoElement { - let section_header = |title: SharedString| { - h_flex() - .gap_2() - .child(Label::new(title).size(LabelSize::Default)) - .child(Divider::horizontal()) - }; - let list_item = List::new() .child( ListBulletItem::new("") - .child(Label::new( - "For access keys: Create an IAM user in the AWS console with programmatic access", - )) + .child( + Label::new( + "For access keys: Create an IAM user in the AWS console with programmatic access", + ) + .color(Color::Muted), + ) .child(ButtonLink::new( "IAM Console", "https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users", @@ -1642,7 +1660,10 @@ impl ConfigurationView { ) .child( ListBulletItem::new("") - .child(Label::new("For Bedrock API Keys: Generate an API key from the")) + .child( + Label::new("For Bedrock API Keys: Generate an API key from the") + .color(Color::Muted), + ) .child(ButtonLink::new( "Bedrock Console", "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-use.html", @@ -1650,28 +1671,42 @@ impl ConfigurationView { ) .child( ListBulletItem::new("") - .child(Label::new("Attach the necessary Bedrock permissions to")) + .child( + Label::new("Attach the necessary Bedrock permissions to") + .color(Color::Muted), + ) .child(ButtonLink::new( "this user", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html", )), ) - .child(ListBulletItem::new( - "Enter either access keys OR a Bedrock API Key below (not both)", - )); + .child( + ListBulletItem::new( + "Enter either access keys OR a Bedrock API Key below (not both)", + ) + .label_color(Color::Muted), + ); v_flex() .my_2() .tab_group() .gap_1p5() - .child(section_header("Static Credentials".into())) - .child(Label::new( - "This method uses your AWS access key ID and secret access key, or a Bedrock API Key.", - )) + .child(Divider::horizontal()) + .child(Label::new("Static Credentials").mt_2()) + .child( + Label::new( + "This method uses your AWS access key ID and secret access key, or a Bedrock API Key.", + ) + .color(Color::Muted), + ) .child(list_item) - .child(self.access_key_id_editor.clone()) - .child(self.secret_access_key_editor.clone()) - .child(self.session_token_editor.clone()) + .child( + v_flex() + .gap_1() + .child(self.access_key_id_editor.clone()) + .child(self.secret_access_key_editor.clone()) + .child(self.session_token_editor.clone()), + ) .child( Label::new(format!( "You can also set the {}, {} and {} environment variables (or {} for Bedrock API Key authentication) and restart Zed.", @@ -1695,7 +1730,8 @@ impl ConfigurationView { .mt_1() .mb_2p5(), ) - .child(section_header("Using the an API key".into())) + .child(Divider::horizontal()) + .child(Label::new("Using the API key").mt_2().mb_1()) .child(self.bearer_token_editor.clone()) .child( Label::new(format!( diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index cdd4bb6c8a..926f99eae1 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -11,9 +11,10 @@ use futures::StreamExt; use futures::future::BoxFuture; use gpui::{AnyElement, AnyView, App, AppContext, Context, Entity, Subscription, Task, TaskExt}; use language_model::{ - AuthenticateError, FastModeConfirmation, IconOrSvg, LanguageModel, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - ProviderConfigurationView, ZED_CLOUD_PROVIDER_ID, ZED_CLOUD_PROVIDER_NAME, + AuthenticateError, FastModeConfirmation, IconOrSvg, InlineDescription, LanguageModel, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, ProviderConfigurationView, ZED_CLOUD_PROVIDER_ID, + ZED_CLOUD_PROVIDER_NAME, }; use language_models_cloud::{CloudLlmTokenProvider, CloudModelProvider}; use rand::{Rng as _, SeedableRng as _, rngs::StdRng}; @@ -365,20 +366,55 @@ impl LanguageModelProvider for CloudLanguageModelProvider { _: &mut Window, cx: &mut App, ) -> AnyView { - cx.new(|_| ConfigurationView::new(self.state.clone())) + cx.new(|_| ConfigurationView::new(self.state.clone(), false)) .into() } fn configuration_view_v2( &self, - target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, + _target_agent: language_model::ConfigurationViewTargetAgent, + _window: &mut Window, cx: &mut App, ) -> ProviderConfigurationView { - // The Zed sign-in/plan control is small enough that sending users to a - // dedicated sub-page just to reach it would be annoying, so render it - // inline even though it isn't an API-key field. - ProviderConfigurationView::Inline(self.configuration_view(target_agent, window, cx)) + ProviderConfigurationView::Inline { + view: cx + .new(|_| ConfigurationView::new(self.state.clone(), true)) + .into(), + } + } + + fn inline_description(&self, cx: &App) -> Option { + let state = self.state.read(cx); + let user_store = state.user_store.read(cx); + let is_zed_model_provider_enabled = user_store + .current_organization_configuration() + .map_or(true, |config| config.is_zed_model_provider_enabled); + + Some(InlineDescription::Text( + zed_ai_description( + !state.is_signed_out(cx), + user_store.plan(), + is_zed_model_provider_enabled, + user_store.trial_started_at().is_none(), + ) + .into(), + )) + } + + fn inline_title(&self, cx: &App) -> Option { + let state = self.state.read(cx); + if state.is_signed_out(cx) { + return None; + } + let plan_name = match state.user_store.read(cx).plan()? { + Plan::ZedPro => "Pro", + Plan::ZedProTrial => "Pro Trial", + Plan::ZedStudent => "Student", + Plan::ZedBusiness => "Business", + Plan::ZedVip => "VIP", + Plan::ZedFree => return None, + }; + Some(format!("Subscribed to {plan_name}").into()) } fn reset_credentials(&self, _cx: &mut App) -> Task> { @@ -413,63 +449,86 @@ struct ZedAiConfiguration { is_zed_model_provider_enabled: bool, eligible_for_trial: bool, account_too_young: bool, + compact: bool, sign_in_callback: Arc, } +fn zed_ai_description( + is_connected: bool, + plan: Option, + is_zed_model_provider_enabled: bool, + eligible_for_trial: bool, +) -> &'static str { + if !is_connected { + return "Sign in to have access to Zed's complete agentic experience with hosted models."; + } + + match plan { + Some(Plan::ZedPro) => { + "You have access to Zed's hosted models through your Pro subscription." + } + Some(Plan::ZedProTrial) => "You have access to Zed's hosted models through your Pro trial.", + Some(Plan::ZedStudent) => { + "You have access to Zed's hosted models through your Student subscription." + } + Some(Plan::ZedBusiness) => { + if is_zed_model_provider_enabled { + "You have access to Zed's hosted models through your organization." + } else { + "Zed's hosted models are disabled by your organization's configuration." + } + } + Some(Plan::ZedVip) => { + "You have access to Zed's hosted models through your VIP subscription." + } + Some(Plan::ZedFree) | None => { + if eligible_for_trial { + "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." + } else { + "Subscribe for access to Zed's hosted models." + } + } + } +} + impl RenderOnce for ZedAiConfiguration { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let (subscription_text, has_paid_plan) = match self.plan { - Some(Plan::ZedPro) => ( - "You have access to Zed's hosted models through your Pro subscription.", - true, - ), - Some(Plan::ZedProTrial) => ( - "You have access to Zed's hosted models through your Pro trial.", - false, - ), - Some(Plan::ZedStudent) => ( - "You have access to Zed's hosted models through your Student subscription.", - true, - ), - Some(Plan::ZedBusiness) => ( - if self.is_zed_model_provider_enabled { - "You have access to Zed's hosted models through your organization." - } else { - "Zed's hosted models are disabled by your organization's configuration." - }, - true, - ), - Some(Plan::ZedVip) => ( - "You have access to Zed's hosted models through your VIP subscription.", - true, - ), + let has_paid_plan = matches!( + self.plan, + Some(Plan::ZedPro | Plan::ZedStudent | Plan::ZedBusiness | Plan::ZedVip) + ); - Some(Plan::ZedFree) | None => ( - if self.eligible_for_trial { - "Subscribe for access to Zed's hosted models. Start with a 14 day free trial." - } else { - "Subscribe for access to Zed's hosted models." - }, - false, - ), - }; + let description = zed_ai_description( + self.is_connected, + self.plan, + self.is_zed_model_provider_enabled, + self.eligible_for_trial, + ); let manage_subscription_buttons = if has_paid_plan { Button::new("manage_settings", "Manage Subscription") - .full_width() - .label_size(LabelSize::Small) + .when(!self.compact, |this| { + this.full_width().label_size(LabelSize::Small) + }) + .when(self.compact, |this| this.size(ButtonSize::Medium)) .style(ButtonStyle::Tinted(TintColor::Accent)) .on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx))) .into_any_element() } else if self.plan.is_none() || self.eligible_for_trial { Button::new("start_trial", "Start 14-day Free Pro Trial") - .full_width() + .when(!self.compact, |this| { + this.full_width().label_size(LabelSize::Small) + }) + .when(self.compact, |this| this.size(ButtonSize::Medium)) .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) .on_click(|_, _, cx| cx.open_url(&zed_urls::start_trial_url(cx))) .into_any_element() } else { Button::new("upgrade", "Upgrade to Pro") - .full_width() + .when(!self.compact, |this| { + this.full_width().label_size(LabelSize::Small) + }) + .when(self.compact, |this| this.size(ButtonSize::Medium)) .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))) .into_any_element() @@ -478,11 +537,15 @@ impl RenderOnce for ZedAiConfiguration { if !self.is_connected { return v_flex() .gap_2() - .child(Label::new("Sign in to have access to Zed's complete agentic experience with hosted models.")) + .when(!self.compact, |this| this.child(Label::new(description))) .child( Button::new("sign_in", "Sign In to use Zed AI") - .start_icon(Icon::new(IconName::Github).size(IconSize::Small).color(Color::Muted)) - .full_width() + .start_icon( + Icon::new(IconName::Github) + .size(IconSize::Small) + .color(Color::Muted), + ) + .when(!self.compact, |this| this.full_width()) .on_click({ let callback = self.sign_in_callback.clone(); move |_, window, cx| (callback)(window, cx) @@ -490,30 +553,35 @@ impl RenderOnce for ZedAiConfiguration { ); } - v_flex().gap_2().w_full().map(|this| { - if self.account_too_young { - this.child(YoungAccountBanner).child( - Button::new("upgrade", "Upgrade to Pro") - .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) - .full_width() - .on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))), - ) - } else { - this.text_sm() - .child(subscription_text) - .child(manage_subscription_buttons) - } - }) + v_flex() + .gap_2() + .when(!self.compact, |this| this.w_full()) + .map(|this| { + if self.account_too_young { + this.child(YoungAccountBanner).child( + Button::new("upgrade", "Upgrade to Pro") + .style(ui::ButtonStyle::Tinted(ui::TintColor::Accent)) + .when(!self.compact, |this| this.full_width()) + .on_click(|_, _, cx| { + cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)) + }), + ) + } else { + this.when(!self.compact, |this| this.text_sm().child(description)) + .child(manage_subscription_buttons) + } + }) } } struct ConfigurationView { state: Entity, + compact: bool, sign_in_callback: Arc, } impl ConfigurationView { - fn new(state: Entity) -> Self { + fn new(state: Entity, compact: bool) -> Self { let sign_in_callback = Arc::new({ let state = state.clone(); move |_window: &mut Window, cx: &mut App| { @@ -525,6 +593,7 @@ impl ConfigurationView { Self { state, + compact, sign_in_callback, } } @@ -545,6 +614,7 @@ impl Render for ConfigurationView { is_zed_model_provider_enabled, eligible_for_trial: user_store.trial_started_at().is_none(), account_too_young: user_store.account_too_young(), + compact: self.compact, sign_in_callback: self.sign_in_callback.clone(), } } @@ -904,6 +974,7 @@ impl Component for ZedAiConfiguration { is_zed_model_provider_enabled: config.is_zed_model_provider_enabled, eligible_for_trial: config.eligible_for_trial, account_too_young: false, + compact: false, sign_in_callback: Arc::new(|_, _| {}), } .into_any_element() diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 0d3b38d311..94bc00e72d 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -199,13 +199,48 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { fn configuration_view_v2( &self, - target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, + _target_agent: language_model::ConfigurationViewTargetAgent, + _window: &mut Window, cx: &mut App, ) -> ProviderConfigurationView { - // GitHub Copilot's control is just a sign-in button, so render it inline - // rather than behind a sub-page. - ProviderConfigurationView::Inline(self.configuration_view(target_agent, window, cx)) + // GitHub Copilot's control is just a sign-in/out button, so render it + // inline rather than behind a sub-page. The explanatory copy is surfaced + // via `inline_description` (the row's left column), so the view itself + // renders compactly. + ProviderConfigurationView::Inline { + view: cx + .new(|cx| { + copilot_ui::ConfigurationView::new( + |cx| { + CopilotChat::global(cx) + .map(|m| m.read(cx).is_authenticated()) + .unwrap_or(false) + }, + copilot_ui::ConfigurationMode::Chat, + cx, + ) + .compact() + }) + .into(), + } + } + + fn inline_title(&self, cx: &App) -> Option { + if self.state.read(cx).is_authenticated(cx) { + None + } else { + Some("Configure Copilot".into()) + } + } + + fn inline_description(&self, cx: &App) -> Option { + if self.state.read(cx).is_authenticated(cx) { + None + } else { + Some(language_model::InlineDescription::Text( + "Requires an active GitHub Copilot subscription.".into(), + )) + } } fn reset_credentials(&self, _cx: &mut App) -> Task> { diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index 48096f1684..943b3861e3 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -8,12 +8,12 @@ use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - ProviderConfigurationView, RateLimiter, Role, StopReason, TokenUsage, env_var, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, + 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}; @@ -212,28 +212,19 @@ impl LanguageModelProvider for DeepSeekLanguageModelProvider { .update(cx, |state, cx| state.set_api_key(None, cx)) } - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://platform.deepseek.com/api_keys", - "Paste your DeepSeek API key", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + fn api_key_configuration(&self, cx: &App) -> Option { + let state = self.state.read(cx); + Some(ApiKeyConfiguration { + has_key: state.api_key_state.has_key(), + is_from_env_var: state.api_key_state.is_from_env_var(), + env_var_name: state.api_key_state.env_var_name().clone(), + api_key_url: "https://platform.deepseek.com/api_keys".into(), + }) + } + + fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + self.state + .update(cx, |state, cx| state.set_api_key(Some(key), cx)) } } @@ -780,7 +771,7 @@ impl Render for ConfigurationView { ) .into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("deepseek-reset-key", configured_card_label) .disabled(env_var_set) .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) .into_any_element() diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 7ea54a51ba..8a289fd2e0 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -7,9 +7,9 @@ pub use google_ai::completion::{GoogleEventMapper, into_google}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat, - ProviderConfigurationView, + ApiKeyConfiguration, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelToolChoice, + LanguageModelToolSchemaFormat, }; use language_model::{ GOOGLE_PROVIDER_ID, GOOGLE_PROVIDER_NAME, IconOrSvg, LanguageModel, LanguageModelEffortLevel, @@ -237,28 +237,19 @@ impl LanguageModelProvider for GoogleLanguageModelProvider { .update(cx, |state, cx| state.set_api_key(None, cx)) } - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://aistudio.google.com/app/apikey", - "AIza...", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + fn api_key_configuration(&self, cx: &App) -> Option { + let state = self.state.read(cx); + Some(ApiKeyConfiguration { + has_key: state.api_key_state.has_key(), + is_from_env_var: state.api_key_state.is_from_env_var(), + env_var_name: state.api_key_state.env_var_name().clone(), + api_key_url: "https://aistudio.google.com/app/apikey".into(), + }) + } + + fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + self.state + .update(cx, |state, cx| state.set_api_key(Some(key), cx)) } } @@ -526,7 +517,7 @@ impl Render for ConfigurationView { ) .into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("google-reset-key", 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| { diff --git a/crates/language_models/src/provider/llama_cpp.rs b/crates/language_models/src/provider/llama_cpp.rs index 921a2ae63e..ed207d4b47 100644 --- a/crates/language_models/src/provider/llama_cpp.rs +++ b/crates/language_models/src/provider/llama_cpp.rs @@ -4,15 +4,16 @@ use credentials_provider::CredentialsProvider; use fs::Fs; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task, TaskExt}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::util::parse_tool_arguments; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, - LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role, + StopReason, TokenUsage, env_var, }; use llama_cpp::{ LLAMA_CPP_API_URL, ModelEntry, Props, get_models, get_props, stream_chat_completion, @@ -25,8 +26,7 @@ use std::sync::LazyLock; use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; use std::time::Duration; use ui::{ - ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip, - prelude::*, + ButtonLike, ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, Tooltip, prelude::*, }; use ui_input::InputField; use util::ResultExt; @@ -562,6 +562,12 @@ impl LanguageModelProvider for LlamaCppLanguageModelProvider { None } + fn inline_description(&self, _cx: &App) -> Option { + Some(InlineDescription::Text( + "Run local models on your machine with LlamaCpp.".into(), + )) + } + fn provided_models(&self, cx: &App) -> Vec> { let settings = LlamaCppLanguageModelProvider::settings(cx); let effective = compute_effective_models(&self.state.read(cx).fetched_models, settings); @@ -1327,31 +1333,42 @@ impl ConfigurationView { fn render_instructions(cx: &App) -> Div { v_flex() .gap_2() - .child(Label::new( - "Run open models locally with llama.cpp's built-in server, or connect to a \ + .child( + Label::new( + "Run open models locally with llama.cpp's built-in server, or connect to a \ remote llama.cpp server.", - )) - .child(Label::new("To use a local llama.cpp server:")) + ) + .color(Color::Muted), + ) + .child(Label::new("To use a local llama.cpp server:").color(Color::Muted)) .child( List::new() .child( ListBulletItem::new("") - .child(Label::new("Install llama.cpp from")) + .child(Label::new("Install llama.cpp from").color(Color::Muted)) .child(ButtonLink::new("llama.app", LLAMA_CPP_DOWNLOAD_URL)), ) .child( ListBulletItem::new("") - .child(Label::new("Start the server in router mode:")) + .child( + Label::new("Start the server in router mode:").color(Color::Muted), + ) .child(Label::new("llama serve").inline_code(cx)), ) - .child(ListBulletItem::new( - "Click 'Connect' below to start using llama.cpp in Zed", - )), + .child( + ListBulletItem::new( + "Click 'Connect' below to start using llama.cpp in Zed", + ) + .label_color(Color::Muted), + ), ) - .child(Label::new( - "Alternatively, you can connect to a remote llama.cpp server by specifying its \ + .child( + Label::new( + "Alternatively, you can connect to a remote llama.cpp server by specifying its \ URL and API key (set with --api-key, may not be required):", - )) + ) + .color(Color::Muted), + ) } fn render_api_key_editor(&self, cx: &Context) -> impl IntoElement { @@ -1363,20 +1380,10 @@ impl ConfigurationView { "API key configured".to_string() }; - if !state.api_key_state.has_key() { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() + let api_key_control = if !state.api_key_state.has_key() { + self.api_key_editor.clone().into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("llama-cpp-reset-key", 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| { @@ -1385,7 +1392,20 @@ impl ConfigurationView { )) }) .into_any_element() - } + }; + + v_flex() + .on_action(cx.listener(Self::save_api_key)) + .child(api_key_control) + .gap_1p5() + .mb_2() + .child( + Label::new(format!( + "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) } fn render_context_window_editor(&self, cx: &Context) -> Div { @@ -1394,26 +1414,26 @@ impl ConfigurationView { if custom_context_window_set { h_flex() - .p_3() + .p_1() .justify_between() .rounded_md() .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().background.opacity(0.5)) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(v_flex().gap_1().child(Label::new(format!( + .child(Label::new(format!( "Context Window: {}", settings.context_window.unwrap_or_default() - )))), + ))), ) .child( Button::new("reset-context-window", "Reset") + .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) - .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, window, cx| { this.reset_context_window(window, cx) @@ -1428,8 +1448,9 @@ impl ConfigurationView { }), ) .child(self.context_window_editor.clone()) + .gap_1p5() .child( - Label::new("Default: discovered from the server") + Label::new("Default: Discovered from the server") .size(LabelSize::Small) .color(Color::Muted), ) @@ -1442,23 +1463,23 @@ impl ConfigurationView { if custom_api_url_set { h_flex() - .p_3() + .p_1() .justify_between() .rounded_md() .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().background.opacity(0.5)) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(v_flex().gap_1().child(Label::new(api_url))), + .child(Label::new(api_url)), ) .child( Button::new("reset-api-url", "Reset API URL") + .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) - .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)), ), @@ -1469,7 +1490,7 @@ impl ConfigurationView { this.save_api_url(cx); cx.notify(); })) - .gap_2() + .gap_1p5() .child(self.api_url_editor.clone()) } } @@ -1481,12 +1502,15 @@ impl Render for ConfigurationView { v_flex() .gap_2() + .child(Headline::new("llama.cpp").size(HeadlineSize::Small)) .child(Self::render_instructions(cx)) .child(self.render_api_url_editor(cx)) .child(self.render_context_window_editor(cx)) .child(self.render_api_key_editor(cx)) + .child(Divider::horizontal()) .child( h_flex() + .pt_2() .w_full() .justify_between() .gap_2() @@ -1498,7 +1522,8 @@ impl Render for ConfigurationView { if is_authenticated { this.child( Button::new("llama-cpp-webui", "Open WebUI") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1513,7 +1538,8 @@ impl Render for ConfigurationView { ) .child( Button::new("llama-cpp-site", "llama.cpp") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1527,7 +1553,8 @@ impl Render for ConfigurationView { } else { this.child( Button::new("download_llama_cpp_button", "Get llama.cpp") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1542,7 +1569,8 @@ impl Render for ConfigurationView { }) .child( Button::new("view-models", "Browse GGUF Models") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1555,17 +1583,16 @@ impl Render for ConfigurationView { if is_authenticated { this.child( ButtonLike::new("connected") - .disabled(true) - .cursor_style(CursorStyle::Arrow) + .size(ButtonSize::Medium) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new("Connected")) - .into_any_element(), + .child(Label::new("Connected")), ) .child( IconButton::new("refresh-models", IconName::RotateCcw) + .icon_size(IconSize::Small) .tooltip(Tooltip::text("Refresh Models")) .on_click(cx.listener(|this, _, window, cx| { this.state.update(cx, |state, _| { @@ -1578,6 +1605,8 @@ impl Render for ConfigurationView { } else { this.child( Button::new("retry_llama_cpp_models", "Connect") + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .start_icon( Icon::new(IconName::PlayOutlined).size(IconSize::XSmall), ) diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index b7b0761256..362db21070 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -3,7 +3,7 @@ use credentials_provider::CredentialsProvider; use fs::Fs; use futures::Stream; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; -use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Subscription, Task, TaskExt}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, @@ -11,8 +11,9 @@ use language_model::{ LanguageModelToolUse, MessageContent, StopReason, TokenUsage, env_var, }; use language_model::{ - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, RateLimiter, Role, + InlineDescription, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, RateLimiter, Role, }; use lmstudio::{LMSTUDIO_API_URL, ModelType, get_models}; @@ -24,9 +25,7 @@ use std::{ collections::{BTreeMap, HashMap}, sync::Arc, }; -use ui::{ - ButtonLike, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip, prelude::*, -}; +use ui::{ButtonLike, ConfiguredApiCard, Divider, List, ListBulletItem, Tooltip, prelude::*}; use ui_input::InputField; use crate::AllLanguageModelSettings; @@ -253,6 +252,12 @@ impl LanguageModelProvider for LmStudioLanguageModelProvider { IconOrSvg::Icon(IconName::AiLmStudio) } + fn inline_description(&self, _cx: &App) -> Option { + Some(InlineDescription::Text( + "Run local LLMs like Llama, Phi, and Qwen with LM Studio.".into(), + )) + } + fn default_model(&self, _: &App) -> Option> { // We shouldn't try to select default model, because it might lead to a load call for an unloaded model. // In a constrained environment where user might not have enough resources it'll be a bad UX to select something @@ -833,28 +838,8 @@ impl ConfigurationView { let custom_api_url_set = api_url != LMSTUDIO_API_URL; if custom_api_url_set { - h_flex() - .p_3() - .justify_between() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) - .child( - h_flex() - .gap_2() - .child(Icon::new(IconName::Check).color(Color::Success)) - .child(v_flex().gap_1().child(Label::new(api_url))), - ) - .child( - Button::new("reset-api-url", "Reset API URL") - .label_size(LabelSize::Small) - .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) - .layer(ElevationIndex::ModalSurface) - .on_click( - cx.listener(|this, _, _window, cx| this.reset_api_url(_window, cx)), - ), - ) + ConfiguredApiCard::new("reset-api-url", api_url) + .on_click(cx.listener(|this, _, _window, cx| this.reset_api_url(_window, cx))) .into_any_element() } else { v_flex() @@ -862,7 +847,6 @@ impl ConfigurationView { this.save_api_url(cx); cx.notify(); })) - .gap_2() .child(self.api_url_editor.clone()) .into_any_element() } @@ -877,20 +861,10 @@ impl ConfigurationView { "API key configured".to_string() }; - if !state.api_key_state.has_key() { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() + let api_key_control = if !state.api_key_state.has_key() { + self.api_key_editor.clone().into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("lmstudio-reset-key", 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| { @@ -899,7 +873,20 @@ impl ConfigurationView { )) }) .into_any_element() - } + }; + + v_flex() + .on_action(cx.listener(Self::save_api_key)) + .child(api_key_control) + .gap_1p5() + .mb_2() + .child( + Label::new(format!( + "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) } } @@ -912,39 +899,45 @@ impl Render for ConfigurationView { .child( v_flex() .gap_1() - .child(Label::new("Run local LLMs like Llama, Phi, and Qwen.")) + .child(Headline::new("LM Studio").size(HeadlineSize::Small)) + .child( + Label::new("Run local LLMs like Llama, Phi, and Qwen.").color(Color::Muted), + ) .child( List::new() .child(ListBulletItem::new( "LM Studio needs to be running with at least one model downloaded.", - )) + ).label_color(Color::Muted)) .child( ListBulletItem::new("") - .child(Label::new("To get your first model, try running")) - .child(Label::new("lms get qwen2.5-coder-7b").inline_code(cx)), + .child(Label::new("To get your first model, try running").color(Color::Muted)) + .child(Label::new("lms get qwen2.5-coder-7b").inline_code(cx).color(Color::Muted).ml_1()), ), ) .child(Label::new( "Alternatively, you can connect to an LM Studio server by specifying its \ URL and API key (may not be required):", - )), + ).color(Color::Muted)), ) .child(self.render_api_url_editor(cx)) .child(self.render_api_key_editor(cx)) + .child(Divider::horizontal()) .child( h_flex() + .pt_2() .w_full() .justify_between() - .gap_2() + .gap_1() .child( h_flex() .w_full() - .gap_2() + .gap_1() .map(|this| { if is_authenticated { this.child( Button::new("lmstudio-site", "LM Studio") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::Small) @@ -961,7 +954,8 @@ impl Render for ConfigurationView { "download_lmstudio_button", "Download LM Studio", ) - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::Small) @@ -976,7 +970,8 @@ impl Render for ConfigurationView { }) .child( Button::new("view-models", "Model Catalog") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::Small) @@ -991,18 +986,17 @@ impl Render for ConfigurationView { if is_authenticated { this.child( ButtonLike::new("connected") - .disabled(true) - .cursor_style(CursorStyle::Arrow) + .size(ButtonSize::Medium) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) .child(Label::new("Connected")) - .into_any_element(), ) .child( IconButton::new("refresh-models", IconName::RotateCcw) .tooltip(Tooltip::text("Refresh Models")) + .icon_size(IconSize::Small) .on_click(cx.listener(|this, _, _window, cx| { this.state.update(cx, |state, _| { state.available_models.clear(); @@ -1014,6 +1008,8 @@ impl Render for ConfigurationView { } else { this.child( Button::new("retry_lmstudio_models", "Connect") + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .start_icon( Icon::new(IconName::PlayFilled).size(IconSize::XSmall), ) diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 24afcb669b..de9f385c8e 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -6,12 +6,12 @@ use futures::{FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, TaskExt, Window}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, - LanguageModelToolUse, MessageContent, ProviderConfigurationView, RateLimiter, Role, StopReason, - TokenUsage, env_var, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, RateLimiter, Role, + StopReason, TokenUsage, env_var, }; pub use mistral::{MISTRAL_API_URL, StreamResponse}; pub use settings::MistralAvailableModel as AvailableModel; @@ -237,28 +237,19 @@ impl LanguageModelProvider for MistralLanguageModelProvider { .update(cx, |state, cx| state.set_api_key(None, cx)) } - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://console.mistral.ai/api-keys", - "Paste your Mistral API key", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + fn api_key_configuration(&self, cx: &App) -> Option { + let state = self.state.read(cx); + Some(ApiKeyConfiguration { + has_key: state.api_key_state.has_key(), + is_from_env_var: state.api_key_state.is_from_env_var(), + env_var_name: state.api_key_state.env_var_name().clone(), + api_key_url: "https://console.mistral.ai/api-keys".into(), + }) + } + + fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + self.state + .update(cx, |state, cx| state.set_api_key(Some(key), cx)) } } @@ -898,7 +889,7 @@ impl Render for ConfigurationView { .size_full() .gap_1() .child( - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("mistral-reset-key", 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| { diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 43a14c33e8..7bbc2ec18c 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -4,12 +4,12 @@ use credentials_provider::CredentialsProvider; use fs::Fs; use futures::{FutureExt, StreamExt, future::BoxFuture, stream::BoxStream}; use futures::{Stream, TryFutureExt, stream}; -use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task, TaskExt}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, DisabledReason, EnvVar, IconOrSvg, LanguageModel, - LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + ApiKeyState, AuthenticateError, DisabledReason, EnvVar, IconOrSvg, InlineDescription, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, + LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, @@ -25,8 +25,7 @@ use std::pin::Pin; use std::sync::Arc; use std::sync::LazyLock; use ui::{ - ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, List, ListBulletItem, Tooltip, - prelude::*, + ButtonLike, ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, Tooltip, prelude::*, }; use ui_input::InputField; @@ -277,6 +276,12 @@ impl LanguageModelProvider for OllamaLanguageModelProvider { IconOrSvg::Icon(IconName::AiOllama) } + fn inline_description(&self, _cx: &App) -> Option { + Some(InlineDescription::Text( + "Run local models on your machine with Ollama.".into(), + )) + } + fn default_model(&self, _: &App) -> Option> { // We shouldn't try to select default model, because it might lead to a load call for an unloaded model. // In a constrained environment where user might not have enough resources it'll be a bad UX to select something @@ -832,31 +837,43 @@ impl ConfigurationView { fn render_instructions(cx: &App) -> Div { v_flex() .gap_2() - .child(Label::new( - "Run LLMs locally on your machine with Ollama, or connect to an Ollama server. \ + .child( + Label::new( + "Run LLMs locally on your machine with Ollama, or connect to an Ollama server. \ Can provide access to Llama, Mistral, Gemma, and hundreds of other models.", - )) - .child(Label::new("To use local Ollama:")) + ) + .color(Color::Muted), + ) + .child(Label::new("To use local Ollama:").color(Color::Muted)) .child( List::new() .child( ListBulletItem::new("") - .child(Label::new("Download and install Ollama from")) + .child( + Label::new("Download and install Ollama from").color(Color::Muted), + ) .child(ButtonLink::new("ollama.com", "https://ollama.com/download")), ) .child( ListBulletItem::new("") - .child(Label::new("Start Ollama and download a model:")) + .child( + Label::new("Start Ollama and download a model:") + .color(Color::Muted), + ) .child(Label::new("ollama run gpt-oss:20b").inline_code(cx)), ) - .child(ListBulletItem::new( - "Click 'Connect' below to start using Ollama in Zed", - )), + .child( + ListBulletItem::new("Click 'Connect' below to start using Ollama in Zed") + .label_color(Color::Muted), + ), ) - .child(Label::new( - "Alternatively, you can connect to an Ollama server by specifying its \ + .child( + Label::new( + "Alternatively, you can connect to an Ollama server by specifying its \ URL and API key (may not be required):", - )) + ) + .color(Color::Muted), + ) } fn render_api_key_editor(&self, cx: &Context) -> impl IntoElement { @@ -868,27 +885,30 @@ impl ConfigurationView { "API key configured".to_string() }; - if !state.api_key_state.has_key() { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(self.api_key_editor.clone()) - .child( - Label::new( - format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed.") - ) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() + let api_key_control = if !state.api_key_state.has_key() { + self.api_key_editor.clone().into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("ollama-reset-key", 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 {API_KEY_ENV_VAR_NAME} environment variable.")) }) .into_any_element() - } + }; + + v_flex() + .on_action(cx.listener(Self::save_api_key)) + .child(api_key_control) + .gap_1p5() + .mb_2() + .child( + Label::new( + format!("You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed.") + ) + .size(LabelSize::Small) + .color(Color::Muted), + ) } fn render_context_window_editor(&self, cx: &Context) -> Div { @@ -897,26 +917,26 @@ impl ConfigurationView { if custom_context_window_set { h_flex() - .p_3() + .p_1() .justify_between() .rounded_md() .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().background.opacity(0.5)) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(v_flex().gap_1().child(Label::new(format!( + .child(Label::new(format!( "Context Window: {}", settings.context_window.unwrap() - )))), + ))), ) .child( Button::new("reset-context-window", "Reset") + .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) - .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, window, cx| { this.reset_context_window(window, cx) @@ -931,6 +951,7 @@ impl ConfigurationView { }), ) .child(self.context_window_editor.clone()) + .gap_1p5() .child( Label::new("Default: Model specific") .size(LabelSize::Small) @@ -945,23 +966,23 @@ impl ConfigurationView { if custom_api_url_set { h_flex() - .p_3() + .p_1() .justify_between() .rounded_md() .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().background.opacity(0.5)) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(v_flex().gap_1().child(Label::new(api_url))), + .child(Label::new(api_url)), ) .child( Button::new("reset-api-url", "Reset API URL") + .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) .start_icon(Icon::new(IconName::Undo).size(IconSize::Small)) - .layer(ElevationIndex::ModalSurface) .on_click( cx.listener(|this, _, window, cx| this.reset_api_url(window, cx)), ), @@ -984,15 +1005,18 @@ impl Render for ConfigurationView { v_flex() .gap_2() + .child(Headline::new("Ollama").size(HeadlineSize::Small)) .child(Self::render_instructions(cx)) .child(self.render_api_url_editor(cx)) .child(self.render_context_window_editor(cx)) .child(self.render_api_key_editor(cx)) + .child(Divider::horizontal()) .child( h_flex() + .pt_2() .w_full() .justify_between() - .gap_2() + .gap_1() .child( h_flex() .w_full() @@ -1001,7 +1025,8 @@ impl Render for ConfigurationView { if is_authenticated { this.child( Button::new("ollama-site", "Ollama") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1013,7 +1038,8 @@ impl Render for ConfigurationView { } else { this.child( Button::new("download_ollama_button", "Download Ollama") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1028,7 +1054,8 @@ impl Render for ConfigurationView { }) .child( Button::new("view-models", "View All Models") - .style(ButtonStyle::Subtle) + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) .end_icon( Icon::new(IconName::ArrowUpRight) .size(IconSize::XSmall) @@ -1041,17 +1068,16 @@ impl Render for ConfigurationView { if is_authenticated { this.child( ButtonLike::new("connected") - .disabled(true) - .cursor_style(CursorStyle::Arrow) + .size(ButtonSize::Medium) .child( h_flex() - .gap_2() + .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new("Connected")) - .into_any_element(), + .child(Label::new("Connected")), ) .child( IconButton::new("refresh-models", IconName::RotateCcw) + .icon_size(IconSize::Small) .tooltip(Tooltip::text("Refresh Models")) .on_click(cx.listener(|this, _, window, cx| { this.state.update(cx, |state, _| { @@ -1064,6 +1090,8 @@ impl Render for ConfigurationView { } else { this.child( Button::new("retry_ollama_models", "Connect") + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .start_icon( Icon::new(IconName::PlayOutlined).size(IconSize::XSmall), ) diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index a67a8741fc..87be0e2751 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -5,11 +5,11 @@ use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg, LanguageModel, - LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, ProviderConfigurationView, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, FastModeConfirmation, IconOrSvg, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, OPEN_AI_PROVIDER_ID, OPEN_AI_PROVIDER_NAME, RateLimiter, env_var, }; use menu; @@ -219,28 +219,19 @@ impl LanguageModelProvider for OpenAiLanguageModelProvider { .update(cx, |state, cx| state.set_api_key(None, cx)) } - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://platform.openai.com/api-keys", - "sk-...", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + fn api_key_configuration(&self, cx: &App) -> Option { + let state = self.state.read(cx); + Some(ApiKeyConfiguration { + has_key: state.api_key_state.has_key(), + is_from_env_var: state.api_key_state.is_from_env_var(), + env_var_name: state.api_key_state.env_var_name().clone(), + api_key_url: "https://platform.openai.com/api-keys".into(), + }) + } + + fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + self.state + .update(cx, |state, cx| state.set_api_key(Some(key), cx)) } fn fast_mode_confirmation(&self, _cx: &App) -> Option { @@ -728,7 +719,7 @@ impl Render for ConfigurationView { ) .into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("openai-reset-key", 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| { diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 979022a887..f41e04972d 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -5,12 +5,12 @@ use futures::{FutureExt, Stream, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, - LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, ProviderConfigurationView, - RateLimiter, Role, StopReason, TokenUsage, env_var, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, 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, @@ -274,28 +274,19 @@ impl LanguageModelProvider for OpenRouterLanguageModelProvider { .update(cx, |state, cx| state.set_api_key(None, cx)) } - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://openrouter.ai/keys", - "sk-or-...", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + fn api_key_configuration(&self, cx: &App) -> Option { + let state = self.state.read(cx); + Some(ApiKeyConfiguration { + has_key: state.api_key_state.has_key(), + is_from_env_var: state.api_key_state.is_from_env_var(), + env_var_name: state.api_key_state.env_var_name().clone(), + api_key_url: "https://openrouter.ai/keys".into(), + }) + } + + fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + self.state + .update(cx, |state, cx| state.set_api_key(Some(key), cx)) } } @@ -1019,7 +1010,7 @@ impl Render for ConfigurationView { ) .into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("openrouter-reset-key", 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| { diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index f63268e60e..2b09032ca5 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -9,11 +9,11 @@ use http_client::{ http::{HeaderName, HeaderValue}, }; use language_model::{ - AuthenticateError, FastModeConfirmation, IconOrSvg, LanguageModel, + AuthenticateError, FastModeConfirmation, IconOrSvg, InlineDescription, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, RateLimiter, + LanguageModelToolChoice, ProviderConfigurationView, RateLimiter, }; use open_ai::{ReasoningEffort, responses::stream_response}; use rand::RngCore as _; @@ -31,6 +31,9 @@ const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("opena const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("ChatGPT Subscription"); +const SUBSCRIPTION_DESCRIPTION: &str = + "Sign in with your ChatGPT Plus or Pro subscription to use OpenAI models in Zed's agent."; + const CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; const OPENAI_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize"; @@ -248,8 +251,48 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { ) -> AnyView { let state = self.state.clone(); let http_client = self.http_client.clone(); - cx.new(|_cx| ConfigurationView { state, http_client }) - .into() + cx.new(|_cx| ConfigurationView { + state, + http_client, + compact: false, + }) + .into() + } + + fn configuration_view_v2( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + _window: &mut Window, + cx: &mut App, + ) -> ProviderConfigurationView { + let state = self.state.clone(); + let http_client = self.http_client.clone(); + + ProviderConfigurationView::Inline { + view: cx + .new(|_cx| ConfigurationView { + state, + http_client, + compact: true, + }) + .into(), + } + } + + fn inline_title(&self, cx: &App) -> Option { + if self.state.read(cx).is_authenticated() { + None + } else { + Some("Configure ChatGPT".into()) + } + } + + fn inline_description(&self, cx: &App) -> Option { + if self.state.read(cx).is_authenticated() { + None + } else { + Some(InlineDescription::Text(SUBSCRIPTION_DESCRIPTION.into())) + } } fn reset_credentials(&self, cx: &mut App) -> Task> { @@ -1043,6 +1086,9 @@ fn do_sign_out(state: &gpui::WeakEntity, cx: &mut App) -> Task struct ConfigurationView { state: Entity, http_client: Arc, + /// When `true`, the description is rendered elsewhere (the settings row's + /// left column), so it's omitted here to avoid duplication. + compact: bool, } impl Render for ConfigurationView { @@ -1059,7 +1105,7 @@ impl Render for ConfigurationView { return v_flex() .child( - ConfiguredApiCard::new(SharedString::from(label)) + ConfiguredApiCard::new("openai-subscribed-sign-out", SharedString::from(label)) .button_label("Sign Out") .on_click(cx.listener(move |_this, _, _window, cx| { do_sign_out(&weak_state, cx).detach_and_log_err(cx); @@ -1076,27 +1122,21 @@ impl Render for ConfigurationView { let button_label = if is_signing_in { "Signing in…" } else { - "Sign in to use ChatGPT Subscription" + "Sign In" }; v_flex() .gap_2() - .child(Label::new( - "Sign in with your ChatGPT Plus or Pro subscription to use OpenAI models in Zed's agent.", - )) + .when(!self.compact, |this| { + this.child(Label::new(SUBSCRIPTION_DESCRIPTION)) + }) .child( Button::new("sign-in", button_label) - .full_width() + .when(!self.compact, |this| this.full_width()) .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .loading(is_signing_in) .disabled(is_signing_in) - .when(!is_signing_in, |this| { - this.start_icon( - Icon::new(IconName::AiOpenAi) - .size(IconSize::Small) - .color(Color::Muted), - ) - }) .on_click(move |_, _window, cx| { do_sign_in(&provider_state, &http_client, cx); }), diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 9a5dbcdfcc..f75c71e425 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -6,11 +6,11 @@ use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, TaskExt, Window}; use http_client::{AsyncBody, CustomHeaders, HttpClient, http}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, - ReasoningEffort, env_var, + ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, InlineDescription, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, RateLimiter, ReasoningEffort, env_var, }; use opencode::{ApiProtocol, OPENCODE_API_URL, OpenCodeSubscription}; pub use settings::OpenCodeAvailableModel as AvailableModel; @@ -18,7 +18,7 @@ use settings::{Settings, SettingsStore, update_settings_file}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::{ - Banner, ButtonLink, ConfiguredApiCard, List, ListBulletItem, Severity, Switch, + Banner, ButtonLink, ConfiguredApiCard, Divider, List, ListBulletItem, Severity, Switch, SwitchLabelPosition, ToggleState, prelude::*, }; use ui_input::InputField; @@ -200,6 +200,12 @@ impl LanguageModelProvider for OpenCodeLanguageModelProvider { IconOrSvg::Icon(IconName::AiOpenCode) } + fn inline_description(&self, _cx: &App) -> Option { + Some(InlineDescription::Text( + "To use OpenCode models in Zed, you need an API key.".into(), + )) + } + fn default_model(&self, cx: &App) -> Option> { if Self::subscription_enabled(OpenCodeSubscription::Go, cx) { // If both Go and Zen are enabled, prefer Go since it's not pay-as-you-go @@ -859,37 +865,12 @@ impl Render for ConfigurationView { } }; - let api_key_section = if self.should_render_editor(cx) { - v_flex() - .on_action(cx.listener(Self::save_api_key)) - .child(Label::new( - "To use OpenCode models in Zed, you need an API key:", - )) - .child( - List::new() - .child( - ListBulletItem::new("") - .child(Label::new("Sign in and get your key at")) - .child(ButtonLink::new( - "OpenCode Console", - "https://opencode.ai/auth", - )), - ) - .child(ListBulletItem::new( - "Paste your API key below and hit enter to start using OpenCode", - )), - ) - .child(self.api_key_editor.clone()) - .child( - Label::new(format!( - "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() + let is_editing = self.should_render_editor(cx); + + let api_key_control = if is_editing { + self.api_key_editor.clone().into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("opencode-reset-key", configured_card_label) .disabled(env_var_set) .when(env_var_set, |this| { this.tooltip_label(format!( @@ -900,8 +881,39 @@ impl Render for ConfigurationView { .into_any_element() }; + let api_key_section = v_flex() + .on_action(cx.listener(Self::save_api_key)) + .child(Label::new( + "To use OpenCode models in Zed, you need an API key:", + ).color(Color::Muted)) + .child( + List::new() + .child( + ListBulletItem::new("") + .child(Label::new("Sign in and get your key at").color(Color::Muted)) + .child(ButtonLink::new( + "OpenCode Console", + "https://opencode.ai/auth", + )), + ) + .when(is_editing, |this| { + this.child(ListBulletItem::new( + "Paste your API key below and hit enter to start using OpenCode", + ).label_color(Color::Muted)) + }), + ) + .child(api_key_control) + .child( + Label::new(format!( + "You can also set the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed." + )) + .size(LabelSize::Small) + .color(Color::Muted).mt_1p5(), + ) + .into_any_element(); + if self.load_credentials_task.is_some() { - div().child(Label::new("Loading credentials...")).into_any() + Label::new("Loading Credentials…").into_any_element() } else { let settings = OpenCodeLanguageModelProvider::settings(cx); let show_zen = settings.show_zen_models; @@ -909,12 +921,13 @@ impl Render for ConfigurationView { let show_free = settings.show_free_models; let subscription_toggles = v_flex() - .gap_1() - .child(Label::new("Subscriptions:").color(Color::Muted)) + .gap_2() + .child(Label::new("Subscriptions")) .child( Switch::new("opencode-show-zen-models", show_zen.into()) - .label("Show Zen models") - .label_position(SwitchLabelPosition::End) + .full_width(true) + .label("Show Zen Models") + .label_position(SwitchLabelPosition::Start) .on_click(cx.listener(|this, state, window, cx| { this.set_subscription_enabled( OpenCodeSubscription::Zen, @@ -924,10 +937,12 @@ impl Render for ConfigurationView { ); })), ) + .child(Divider::horizontal_dashed()) .child( Switch::new("opencode-show-go-models", show_go.into()) + .full_width(true) .label("Show Go models") - .label_position(SwitchLabelPosition::End) + .label_position(SwitchLabelPosition::Start) .on_click(cx.listener(|this, state, window, cx| { this.set_subscription_enabled( OpenCodeSubscription::Go, @@ -937,10 +952,12 @@ impl Render for ConfigurationView { ); })), ) + .child(Divider::horizontal_dashed()) .child( Switch::new("opencode-show-free-models", show_free.into()) + .full_width(true) .label("Show Free models") - .label_position(SwitchLabelPosition::End) + .label_position(SwitchLabelPosition::Start) .on_click(cx.listener(|this, state, window, cx| { this.set_subscription_enabled( OpenCodeSubscription::Free, @@ -961,8 +978,10 @@ impl Render for ConfigurationView { v_flex() .size_full() - .gap_2() + .gap_2p5() + .child(Headline::new("OpenCode").size(HeadlineSize::Small)) .child(api_key_section) + .child(Divider::horizontal()) .child(subscription_toggles) .children(no_subscriptions_warning) .into_any() diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index d319538bc4..dcceffe22e 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -7,11 +7,11 @@ use http_client::{ AsyncBody, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, http, }; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, - ProviderConfigurationView, RateLimiter, env_var, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolSchemaFormat, RateLimiter, env_var, }; use open_ai::ResponseStreamEvent; use serde::Deserialize; @@ -261,28 +261,21 @@ impl LanguageModelProvider for VercelAiGatewayLanguageModelProvider { .update(cx, |state, cx| state.set_api_key(None, cx)) } - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway", - "Paste your Vercel AI Gateway API key", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + fn api_key_configuration(&self, cx: &App) -> Option { + let state = self.state.read(cx); + Some(ApiKeyConfiguration { + has_key: state.api_key_state.has_key(), + is_from_env_var: state.api_key_state.is_from_env_var(), + env_var_name: state.api_key_state.env_var_name().clone(), + api_key_url: + "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=Go+to+AI+Gateway" + .into(), + }) + } + + fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + self.state + .update(cx, |state, cx| state.set_api_key(Some(key), cx)) } } @@ -743,7 +736,7 @@ impl Render for ConfigurationView { ) .into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("vercel-reset-key", configured_card_label) .disabled(env_var_set) .when(env_var_set, |this| { this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 1d21464c5f..92121f2e5f 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -5,11 +5,11 @@ use futures::{FutureExt, StreamExt, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, TaskExt, Window}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ - ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, - LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolSchemaFormat, ProviderConfigurationView, RateLimiter, env_var, + ApiKeyConfiguration, ApiKeyState, AuthenticateError, EnvVar, IconOrSvg, LanguageModel, + LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelEffortLevel, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, env_var, }; use open_ai::ResponseStreamEvent; pub use settings::XaiAvailableModel as AvailableModel; @@ -208,28 +208,19 @@ impl LanguageModelProvider for XAiLanguageModelProvider { .update(cx, |state, cx| state.set_api_key(None, cx)) } - fn configuration_view_v2( - &self, - _target_agent: language_model::ConfigurationViewTargetAgent, - window: &mut Window, - cx: &mut App, - ) -> ProviderConfigurationView { - let state = self.state.clone(); - ProviderConfigurationView::Inline( - cx.new(|cx| { - crate::ApiKeyEditor::new( - state, - "https://console.x.ai/team/default/api-keys", - "xai-...", - |state, _cx| crate::api_key_status(&state.api_key_state), - |state, key, cx| state.update(cx, |state, cx| state.set_api_key(Some(key), cx)), - |state, cx| state.update(cx, |state, cx| state.set_api_key(None, cx)), - window, - cx, - ) - }) - .into(), - ) + fn api_key_configuration(&self, cx: &App) -> Option { + let state = self.state.read(cx); + Some(ApiKeyConfiguration { + has_key: state.api_key_state.has_key(), + is_from_env_var: state.api_key_state.is_from_env_var(), + env_var_name: state.api_key_state.env_var_name().clone(), + api_key_url: "https://console.x.ai/team/default/api-keys".into(), + }) + } + + fn set_api_key(&self, key: String, cx: &mut App) -> Task> { + self.state + .update(cx, |state, cx| state.set_api_key(Some(key), cx)) } } @@ -632,7 +623,7 @@ impl Render for ConfigurationView { ) .into_any_element() } else { - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new("xai-reset-key", configured_card_label) .disabled(env_var_set) .when(env_var_set, |this| { this.tooltip_label(format!("To reset your API key, unset the {API_KEY_ENV_VAR_NAME} environment variable.")) diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 4d1101d718..34ee444072 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -7943,7 +7943,7 @@ fn collaboration_page() -> SettingsPage { } fn ai_page(cx: &App) -> SettingsPage { - fn general_section() -> [SettingsPageItem; 3] { + fn general_section() -> [SettingsPageItem; 6] { [ SettingsPageItem::SectionHeader("General"), SettingsPageItem::SettingItem(SettingItem { @@ -7974,24 +7974,11 @@ fn ai_page(cx: &App) -> SettingsPage { metadata: None, files: USER, }), - ] - } - - fn agent_configuration_section(cx: &App) -> Box<[SettingsPageItem]> { - use feature_flags::FeatureFlagAppExt as _; - - // The LLM provider and MCP server pages are gated behind a feature flag - // while their configuration is being moved out of the agent panel. - let agent_settings_ui_enabled = cx.has_flag::(); - - let mut items = vec![SettingsPageItem::SectionHeader("Agent Configuration")]; - - if agent_settings_ui_enabled { - items.push(SettingsPageItem::SubPageLink(SubPageLink { + SettingsPageItem::SubPageLink(SubPageLink { title: "LLM Providers".into(), r#type: Default::default(), json_path: Some("llm_providers"), - description: Some("Configure API keys and settings for LLM providers.".into()), + description: Some("Configure natively-included model providers.".into()), search_aliases: &[ "ai", "amazon", @@ -8021,8 +8008,52 @@ fn ai_page(cx: &App) -> SettingsPage { in_json: false, files: USER, render: render_llm_providers_page, - })); - } + }), + SettingsPageItem::SubPageLink(SubPageLink { + title: "External Agents".into(), + r#type: Default::default(), + json_path: Some("agent_servers"), + description: Some( + "View, add, and remove agents connected through the Agent Client Protocol." + .into(), + ), + search_aliases: &[ + "acp", + "agent client protocol", + "amp", + "claude agent", + "claude code", + "codex", + "copilot cli", + "cursor", + "external agent", + "factory droid", + "github copilot", + "grok build", + "junie", + "opencode", + ], + in_json: false, + files: USER, + render: render_external_agents_page, + }), + SettingsPageItem::SubPageLink(SubPageLink { + title: "MCP Servers".into(), + r#type: Default::default(), + json_path: Some("context_servers"), + description: Some( + "View, add, configure, and remove Model Context Protocol servers.".into(), + ), + search_aliases: &["context server", "mcp", "model context protocol"], + in_json: false, + files: USER, + render: render_mcp_servers_page, + }), + ] + } + + fn agent_configuration_section(_cx: &App) -> Box<[SettingsPageItem]> { + let mut items = vec![SettingsPageItem::SectionHeader("Agent Configuration")]; items.extend([ SettingsPageItem::SubPageLink(SubPageLink { @@ -8068,49 +8099,6 @@ fn ai_page(cx: &App) -> SettingsPage { }), ]); - if agent_settings_ui_enabled { - items.push(SettingsPageItem::SubPageLink(SubPageLink { - title: "MCP Servers".into(), - r#type: Default::default(), - json_path: Some("context_servers"), - description: Some( - "View, add, configure, and remove Model Context Protocol servers.".into(), - ), - search_aliases: &["context server", "mcp", "model context protocol"], - in_json: false, - files: USER, - render: render_mcp_servers_page, - })); - items.push(SettingsPageItem::SubPageLink(SubPageLink { - title: "External Agents".into(), - r#type: Default::default(), - json_path: Some("agent_servers"), - description: Some( - "View, add, and remove agents connected through the Agent Client Protocol." - .into(), - ), - search_aliases: &[ - "acp", - "agent client protocol", - "amp", - "claude agent", - "claude code", - "codex", - "copilot cli", - "cursor", - "external agent", - "factory droid", - "github copilot", - "grok build", - "junie", - "opencode", - ], - in_json: false, - files: USER, - render: render_external_agents_page, - })); - } - items.extend([ SettingsPageItem::SettingItem(SettingItem { title: "Single File Review", @@ -8467,28 +8455,6 @@ fn ai_page(cx: &App) -> SettingsPage { items.into_boxed_slice() } - fn context_servers_section() -> [SettingsPageItem; 2] { - [ - SettingsPageItem::SectionHeader("Context Servers"), - SettingsPageItem::SettingItem(SettingItem { - title: "Context Server Timeout", - description: "Default timeout in seconds for context server tool calls. Can be overridden per-server in context_servers configuration.", - field: Box::new(SettingField { - organization_override: None, - json_path: Some("context_server_timeout"), - pick: |settings_content| { - settings_content.project.context_server_timeout.as_ref() - }, - write: |settings_content, value, _| { - settings_content.project.context_server_timeout = value; - }, - }), - metadata: None, - files: USER | PROJECT, - }), - ] - } - fn edit_prediction_display_sub_section() -> [SettingsPageItem; 1] { [SettingsPageItem::SettingItem(SettingItem { title: "Display Mode", @@ -8519,30 +8485,16 @@ fn ai_page(cx: &App) -> SettingsPage { })] } - use feature_flags::FeatureFlagAppExt as _; - - // When the agent settings UI is enabled, the context server timeout is shown - // inside the MCP Servers sub-page. Otherwise it remains a standalone section - // here so it stays reachable. - let agent_settings_ui_enabled = cx.has_flag::(); - - let mut items = concat_sections!( - @vec, - general_section(), - agent_configuration_section(cx), - ); - if !agent_settings_ui_enabled { - items.extend(context_servers_section()); - } - items.extend(concat_sections!( - @vec, - edit_prediction_language_settings_section(), - edit_prediction_display_sub_section(), - )); - SettingsPage { title: "AI", - items: items.into(), + items: concat_sections!( + @vec, + general_section(), + agent_configuration_section(cx), + edit_prediction_language_settings_section(), + edit_prediction_display_sub_section(), + ) + .into(), } } diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs index 1287f36f06..407343365e 100644 --- a/crates/settings_ui/src/pages.rs +++ b/crates/settings_ui/src/pages.rs @@ -15,10 +15,14 @@ pub(crate) use audio_input_output_setup::{ }; pub(crate) use audio_test_window::open_audio_test_window; pub(crate) use edit_prediction_provider_setup::render_edit_prediction_setup_page; -pub(crate) use external_agents_page::{CustomAgentForm, render_external_agents_page}; +pub(crate) use external_agents_page::{ + CustomAgentForm, render_add_agent_popover, render_external_agents_page, +}; pub(crate) use feature_flags::render_feature_flags_page; pub(crate) use llm_providers_page::render_llm_providers_page; -pub(crate) use mcp_servers_page::{McpServerForm, render_mcp_servers_page}; +pub(crate) use mcp_servers_page::{ + McpServerForm, render_add_server_popover, render_mcp_servers_page, +}; pub(crate) use sandbox_settings::render_sandbox_settings_page; pub use skill_creator::SkillCreatorOpenMode; pub(crate) use skill_creator::{ diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs index 0f7b9d512f..3f756a876a 100644 --- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -230,10 +230,11 @@ fn render_api_key_provider( }; 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 = match docs { ApiKeyDocs::Custom { message } => div().min_w_0().w_full().child( Label::new(message) @@ -251,7 +252,7 @@ fn render_api_key_provider( .color(Color::Muted), ) .child( - ButtonLink::new(button_link_label, dashboard_url) + ButtonLink::new(format!("{title} dashboard"), dashboard_url) .no_icon(true) .label_size(LabelSize::Small) .label_color(Color::Muted), @@ -262,6 +263,7 @@ fn render_api_key_provider( .color(Color::Muted), ), }; + let configured_card_label = if is_from_env_var { "API Key Set in Environment Variable" } else { @@ -270,7 +272,7 @@ fn render_api_key_provider( let container = if has_key { base_container.child(header).child( - ConfiguredApiCard::new(configured_card_label) + ConfiguredApiCard::new(format!("{title}-reset-key"), configured_card_label) .button_label("Reset Key") .button_tab_index(0) .disabled(is_from_env_var) @@ -298,6 +300,7 @@ fn render_api_key_provider( .w_full() .min_w_0() .max_w_1_2() + .gap_0p5() .child(Label::new("API Key")) .child(description) .when_some(env_var_name, |this, env_var_name| { diff --git a/crates/settings_ui/src/pages/external_agents_page.rs b/crates/settings_ui/src/pages/external_agents_page.rs index f05937680e..b4b4350619 100644 --- a/crates/settings_ui/src/pages/external_agents_page.rs +++ b/crates/settings_ui/src/pages/external_agents_page.rs @@ -14,7 +14,7 @@ use settings::{ }; use ui::{ AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ContextMenu, ContextMenuEntry, - Divider, DividerColor, PopoverMenu, Tooltip, prelude::*, + Divider, PopoverMenu, Tooltip, prelude::*, }; use util::ResultExt as _; use workspace::{MultiWorkspace, Workspace, create_and_open_local_file}; @@ -25,7 +25,7 @@ use crate::SettingsWindow; pub(crate) fn render_external_agents_page( settings_window: &SettingsWindow, scroll_handle: &ScrollHandle, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) -> AnyElement { let agent_server_store = get_agent_server_store(settings_window, cx); @@ -41,8 +41,6 @@ pub(crate) fn render_external_agents_page( render_no_project_state(cx) }; - let add_agent_popover = render_add_agent_popover(settings_window, window, cx); - v_flex() .id("external-agents-page") .size_full() @@ -51,22 +49,11 @@ pub(crate) fn render_external_agents_page( .pb_16() .track_scroll(scroll_handle) .overflow_y_scroll() + .child(Label::new("External Agents")) .child( - h_flex() - .w_full() - .justify_between() - .items_center() - .mb_4() - .child( - v_flex() - .child(Label::new("External Agents").size(LabelSize::Large)) - .child( - Label::new("All agents connected through the Agent Client Protocol.") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child(add_agent_popover), + Label::new("Agents connected through the Agent Client Protocol.") + .size(LabelSize::Small) + .color(Color::Muted), ) .child(agent_list) .into_any_element() @@ -165,11 +152,7 @@ fn render_agent_list(agents: Vec, cx: &mut Context) -> agents.into_iter().map(|(id, icon, display_name, source)| { render_agent(id, icon, display_name, source, cx).into_any_element() }), - || { - Divider::horizontal() - .color(DividerColor::BorderFaded) - .into_any_element() - }, + || Divider::horizontal().into_any_element(), )) .into_any_element() } @@ -198,22 +181,20 @@ fn render_agent( // Only custom agents are editable here; registry agents are managed via the // ACP registry and only support removal. let configure_button = (source == ExternalAgentSource::Custom).then(|| { - IconButton::new( - SharedString::from(format!("configure-{}", id_string)), - IconName::Settings, - ) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tab_index(0isize) - .tooltip(Tooltip::text("Configure Agent")) - .on_click(cx.listener({ - let id = id.clone(); - move |this, _event, window, cx| { - let existing = - custom_agent_settings(&id, cx).map(|settings| (id.clone(), settings)); - open_custom_agent_form(this, existing, window, cx); - } - })) + IconButton::new(format!("configure-{}", id_string), IconName::Settings) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .size(ButtonSize::Medium) + .tab_index(0isize) + .tooltip(Tooltip::text("Configure Agent")) + .on_click(cx.listener({ + let id = id.clone(); + move |this, _event, window, cx| { + let existing = + custom_agent_settings(&id, cx).map(|settings| (id.clone(), settings)); + open_custom_agent_form(this, existing, window, cx); + } + })) }); let remove_tooltip = match source { @@ -221,17 +202,15 @@ fn render_agent( ExternalAgentSource::Custom => "Remove Custom Agent", }; - let remove_button = IconButton::new( - SharedString::from(format!("uninstall-{}", id_string)), - IconName::Trash, - ) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tab_index(0isize) - .tooltip(Tooltip::text(remove_tooltip)) - .on_click(move |_event, _window, cx| { - remove_agent(&id, source, cx); - }); + let remove_button = IconButton::new(format!("uninstall-{}", id_string), IconName::Trash) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .size(ButtonSize::Medium) + .tab_index(0isize) + .tooltip(Tooltip::text(remove_tooltip)) + .on_click(move |_event, _window, cx| { + remove_agent(&id, source, cx); + }); // The connection status of an external agent is tracked per agent-panel // session (via the agent panel's `AgentConnectionStore`), which isn't @@ -273,7 +252,7 @@ fn remove_agent(id: &AgentId, source: ExternalAgentSource, cx: &mut App) { }); } -fn render_add_agent_popover( +pub(crate) fn render_add_agent_popover( settings_window: &SettingsWindow, window: &mut Window, cx: &mut Context, diff --git a/crates/settings_ui/src/pages/llm_providers_page.rs b/crates/settings_ui/src/pages/llm_providers_page.rs index d663dbe7d9..cb0b0a95b9 100644 --- a/crates/settings_ui/src/pages/llm_providers_page.rs +++ b/crates/settings_ui/src/pages/llm_providers_page.rs @@ -1,13 +1,15 @@ use std::sync::Arc; -use gpui::{ScrollHandle, prelude::*}; +use gpui::{AnyView, ScrollHandle, prelude::*}; use language_model::{ - ConfigurationViewTargetAgent, IconOrSvg, LanguageModelProvider, LanguageModelProviderId, - LanguageModelRegistry, ProviderConfigurationView, + ApiKeyConfiguration, ConfigurationViewTargetAgent, IconOrSvg, InlineDescription, + LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, + ProviderConfigurationView, }; -use ui::{Divider, DividerColor, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, Divider, DividerColor, prelude::*}; use crate::SettingsWindow; +use crate::components::SettingsInputField; pub(crate) fn render_llm_providers_page( settings_window: &SettingsWindow, @@ -20,7 +22,6 @@ pub(crate) fn render_llm_providers_page( v_flex() .id("llm-providers-page") .size_full() - .pt_2p5() .px_8() .pb_16() .track_scroll(scroll_handle) @@ -28,94 +29,272 @@ pub(crate) fn render_llm_providers_page( .children( providers .iter() - .map(|provider| render_provider_row(settings_window, provider, window, cx)) + .enumerate() + .map(|(index, provider)| { + render_provider_section(settings_window, provider, index == 0, window, cx) + }) .collect::>(), ) .into_any_element() } -fn render_provider_row( +fn render_provider_section( settings_window: &SettingsWindow, provider: &Arc, + is_first: bool, window: &mut Window, cx: &mut Context, ) -> AnyElement { let provider_id = provider.id(); let provider_name = provider.name().0; - let is_authenticated = provider.is_authenticated(cx); - let icon = match provider.icon() { - IconOrSvg::Svg(path) => Icon::from_external_svg(path), - IconOrSvg::Icon(name) => Icon::new(name), - } - .size(IconSize::Small) - .color(Color::Muted); - - let left = h_flex() - .flex_none() - .gap_1p5() - .child(icon) - .child(Label::new(provider_name)) - .when(is_authenticated, |this| { - this.child( - Icon::new(IconName::Check) - .size(IconSize::Small) - .color(Color::Success), - ) - }); - - // The provider tells us how it wants to be presented: a compact inline - // control, or a richer view that belongs on its own sub-page. - let control = + let body = if let Some(config) = provider.api_key_configuration(cx) { + render_api_key_providers_item(provider, provider_name.clone(), config) + } else { match get_or_create_configuration_view(settings_window, &provider_id, provider, window, cx) { - ProviderConfigurationView::Inline(view) => v_flex() - .min_w_0() - .w_full() - .max_w(rems(24.)) - .child(view) - .into_any_element(), - ProviderConfigurationView::SubPage(_) => render_configure_button(&provider_id, cx), - }; + ProviderConfigurationView::Inline { view } => render_inline_body(provider, view, cx), + ProviderConfigurationView::SubPage(_) => render_subpage_item(provider, cx), + } + }; v_flex() .min_w_0() + .map(|s| if is_first { s.pt_4() } else { s.pt_8() }) + .gap_1p5() + .child(render_provider_header(provider_name, provider.icon(), cx)) + .child(body) + .into_any_element() +} + +/// An icon + name header with a faded divider, mirroring `SettingsSectionHeader` +/// but able to render providers' external SVG icons. +fn render_provider_header( + provider_name: SharedString, + icon: IconOrSvg, + cx: &mut Context, +) -> impl IntoElement { + let icon = match icon { + IconOrSvg::Svg(path) => Icon::from_external_svg(path), + IconOrSvg::Icon(name) => Icon::new(name), + } + .color(Color::Muted); + + v_flex() .w_full() + .gap_1p5() .child( - div() - .px_2() - .child(Divider::horizontal().color(DividerColor::BorderFaded)), + h_flex().gap_1p5().child(icon).child( + Label::new(provider_name) + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx), + ), + ) + .child(Divider::horizontal().color(DividerColor::BorderFaded)) +} + +fn render_api_key_providers_item( + provider: &Arc, + provider_name: SharedString, + config: ApiKeyConfiguration, +) -> AnyElement { + let ApiKeyConfiguration { + has_key, + is_from_env_var, + env_var_name, + api_key_url, + } = config; + + if has_key { + let configured_label = if is_from_env_var { + "API Key Set in Environment Variable" + } else { + "API Key Configured" + }; + let button_id = format!("reset-api-key-{}", provider.id().0); + let provider = provider.clone(); + let env_var_name_for_tooltip = env_var_name; + + return ConfiguredApiCard::new(button_id, configured_label) + .button_label("Reset Key") + .button_tab_index(0) + .disabled(is_from_env_var) + .when(is_from_env_var, |this| { + this.tooltip_label(format!( + "To reset your API key, unset the {env_var_name_for_tooltip} environment variable." + )) + }) + .on_click(move |_, _, cx| { + provider.reset_credentials(cx).detach_and_log_err(cx); + }) + .into_any_element(); + } + + let input_id = format!("{}-api-key-input", provider.id().0); + let aria_label = format!("{provider_name} API Key"); + let provider = provider.clone(); + + h_flex() + .pt_2p5() + .w_full() + .min_w_0() + .gap_4() + .justify_between() + .child( + v_flex() + .w_full() + .min_w_0() + .max_w_1_2() + .gap_0p5() + .child(Label::new("API Key")) + .child( + h_flex() + .w_full() + .min_w_0() + .flex_wrap() + .gap_0p5() + .child( + Label::new("Visit the") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + ButtonLink::new(format!("{provider_name} dashboard"), api_key_url) + .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), + ), + ) + .child( + Label::new(format!( + "Or set the {env_var_name} env var and restart Zed for it to take effect." + )) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), ) .child( - h_flex() - .w_full() - .py_2() - .px_2() - .gap_6() - .justify_between() - .items_start() - .child(left) - .child(control), + SettingsInputField::new(input_id) + .tab_index(0) + .with_placeholder("xxxxxxxxxxxxxxxxxxxx") + .aria_label(aria_label) + .on_confirm(move |api_key, _window, cx| { + if let Some(key) = api_key.filter(|key| !key.is_empty()) { + provider.set_api_key(key, cx).detach_and_log_err(cx); + } + }), ) .into_any_element() } -fn render_configure_button( - provider_id: &LanguageModelProviderId, +fn render_inline_body( + provider: &Arc, + view: AnyView, cx: &mut Context, ) -> AnyElement { - let provider_id = provider_id.clone(); - Button::new( - SharedString::from(format!("configure-{}", provider_id.0)), - "Configure", - ) - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .tab_index(0isize) - .on_click(cx.listener(move |this, _, window, cx| { - open_provider_configuration(this, provider_id.clone(), window, cx); - })) - .into_any_element() + let provider_name = provider.name().0; + let title = provider.inline_title(cx); + let description = provider.inline_description(cx); + + if title.is_none() && description.is_none() { + return v_flex() + .pt_1() + .w_full() + .min_w_0() + .child(view) + .into_any_element(); + } + + h_flex() + .pt_2p5() + .w_full() + .min_w_0() + .gap_4() + .justify_between() + .child( + v_flex() + .w_full() + .min_w_0() + .max_w_1_2() + .when_some(title, |this, title| this.child(Label::new(title))) + .when_some(description, |this, description| { + this.child(render_inline_description(provider_name, description)) + }), + ) + .child(h_flex().flex_none().child(view)) + .into_any_element() +} + +fn render_subpage_item( + provider: &Arc, + cx: &mut Context, +) -> AnyElement { + let provider_id = provider.id(); + let provider_name = provider.name().0; + let description = provider.inline_description(cx); + + h_flex() + .pt_2p5() + .w_full() + .min_w_0() + .gap_4() + .justify_between() + .child( + v_flex() + .w_full() + .min_w_0() + .max_w_1_2() + .gap_0p5() + .child(Label::new("Configure Provider")) + .when_some(description, |this, description| { + this.child(render_inline_description(provider_name, description)) + }), + ) + .child( + Button::new(format!("configure-{}", provider_id.0), "Configure") + .style(ButtonStyle::OutlinedGhost) + .size(ButtonSize::Medium) + .end_icon( + Icon::new(IconName::ChevronRight) + .size(IconSize::Small) + .color(Color::Muted), + ) + .tab_index(0isize) + .on_click(cx.listener(move |this, _, window, cx| { + open_provider_configuration(this, provider_id.clone(), window, cx); + })), + ) + .into_any_element() +} + +fn render_inline_description( + provider_name: SharedString, + description: InlineDescription, +) -> AnyElement { + match description { + InlineDescription::ApiKeyUrl(url) => h_flex() + .gap_0p5() + .child( + Label::new("To find an API key, visit the") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + ButtonLink::new(format!("{provider_name} dashboard."), url) + .label_size(LabelSize::Small), + ) + .into_any_element(), + InlineDescription::Text(text) => Label::new(text) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element(), + } } fn open_provider_configuration( @@ -164,7 +343,8 @@ fn render_provider_config_sub_page( window, cx, ) { - ProviderConfigurationView::Inline(view) | ProviderConfigurationView::SubPage(view) => view, + ProviderConfigurationView::Inline { view, .. } + | ProviderConfigurationView::SubPage(view) => view, }; v_flex() diff --git a/crates/settings_ui/src/pages/mcp_servers_page.rs b/crates/settings_ui/src/pages/mcp_servers_page.rs index bebf1e795c..f06e6b33be 100644 --- a/crates/settings_ui/src/pages/mcp_servers_page.rs +++ b/crates/settings_ui/src/pages/mcp_servers_page.rs @@ -11,8 +11,8 @@ use project::context_server_store::{ use project::project_settings::ContextServerSettings; use settings::{ContextServerCommand, ContextServerSettingsContent, OAuthClientSettings}; use ui::{ - AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ContextMenu, Divider, DividerColor, - PopoverMenu, Switch, ToggleState, Tooltip, prelude::*, + AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ContextMenu, Divider, PopoverMenu, + Switch, ToggleState, Tooltip, prelude::*, }; use util::ResultExt as _; @@ -41,34 +41,29 @@ pub(crate) fn render_mcp_servers_page( }; let timeout_setting = render_context_server_timeout(settings_window, window, cx); - let add_server_popover = render_add_server_popover(settings_window, window, cx); v_flex() .id("mcp-servers-page") + .track_scroll(scroll_handle) .size_full() .pt_2p5() .pb_16() - .track_scroll(scroll_handle) .overflow_y_scroll() .child( - h_flex() + v_flex() .w_full() .px_8() - .justify_between() - .items_center() - .mb_4() + .gap_2() .child( - v_flex() - .child(Label::new("MCP Servers").size(LabelSize::Large)) - .child( - Label::new("Manage Model Context Protocol servers connected directly or via extensions.") - .size(LabelSize::Small) - .color(Color::Muted), - ), + v_flex().child(Label::new("Configured Servers")).child( + Label::new("Manage servers connected directly or via extensions.") + .size(LabelSize::Small) + .color(Color::Muted), + ), ) - .child(add_server_popover), + .child(server_list) + .child(Divider::horizontal()), ) - .child(div().px_8().child(server_list)) .child(timeout_setting) .into_any_element() } @@ -151,11 +146,7 @@ fn render_server_list( server_ids .iter() .map(|server_id| render_context_server(server_id, store, cx).into_any_element()), - || { - Divider::horizontal() - .color(DividerColor::BorderFaded) - .into_any_element() - }, + || Divider::horizontal_dashed().into_any_element(), )) .into_any_element() } @@ -213,22 +204,18 @@ fn render_context_server( None }; - // Build gear menu. Pre-fill "Configure Server" from the raw configured - // settings (not the resolved runtime configuration) so the form is editable - // even when the settings contain invalid data (e.g. an unparsable URL) or - // the server is disabled / not yet started. let server_settings = store .read(cx) .settings_for_server(context_server_id) .cloned(); - let gear_menu = render_gear_menu( - context_server_id, - store, - cx.entity().downgrade(), - server_settings.clone(), - provided_by_extension, - should_show_logout, - ); + let configure_button = (!provided_by_extension).then(|| { + render_configure_button( + context_server_id, + cx.entity().downgrade(), + server_settings.clone(), + ) + }); + let uninstall_button = render_uninstall_button(context_server_id, provided_by_extension); // Build toggle switch let toggle_switch = render_toggle_switch(context_server_id, store, is_running); @@ -241,7 +228,8 @@ fn render_context_server( }; AiSettingItem::new(item_id, display_name, status, source) - .action(gear_menu) + .when_some(configure_button, |this, button| this.action(button)) + .action(uninstall_button) .action(toggle_switch) .when_some(tool_label, |this, label| this.detail_label(label)) .when_some(details, |this, details| this.details(details)) @@ -278,85 +266,51 @@ fn resolve_extension_display_name(id: &ContextServerId, cx: &App) -> Option, settings_window: WeakEntity, server_settings: Option, - provided_by_extension: bool, - should_show_logout: bool, ) -> impl IntoElement { let context_server_id = context_server_id.clone(); - let store = store.clone(); - PopoverMenu::new(SharedString::from(format!( - "mcp-gear-{}", - context_server_id.0 - ))) - .trigger_with_tooltip( - IconButton::new( - SharedString::from(format!("mcp-gear-btn-{}", context_server_id.0)), - IconName::Settings, - ) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tab_index(0isize), - Tooltip::text("Configure MCP Server"), + IconButton::new( + format!("mcp-configure-btn-{}", context_server_id.0), + IconName::Settings, ) - .anchor(gpui::Anchor::TopRight) - .menu({ - move |window, cx| { - let context_server_id = context_server_id.clone(); - let store = store.clone(); - let settings_window = settings_window.clone(); - let server_settings = server_settings.clone(); + .icon_size(IconSize::Small) + .tab_index(0isize) + .tooltip(Tooltip::text("Configure MCP Server")) + .on_click(move |_event, window, cx| { + let transport = match &server_settings { + Some(ContextServerSettings::Http { .. }) => McpTransport::Http, + _ => McpTransport::Stdio, + }; + let existing = server_settings + .clone() + .map(|settings| (context_server_id.clone(), settings)); + settings_window + .update(cx, |this, cx| { + open_mcp_server_form(this, transport, existing, window, cx); + }) + .log_err(); + }) +} - Some(ContextMenu::build(window, cx, move |menu, _window, _cx| { - menu.when(!provided_by_extension, |this| { - this.entry("Configure Server", None, { - let settings_window = settings_window.clone(); - let context_server_id = context_server_id.clone(); - let server_settings = server_settings.clone(); - move |window, cx| { - let transport = match &server_settings { - Some(ContextServerSettings::Http { .. }) => McpTransport::Http, - _ => McpTransport::Stdio, - }; - let existing = server_settings - .clone() - .map(|settings| (context_server_id.clone(), settings)); - settings_window - .update(cx, |this, cx| { - open_mcp_server_form(this, transport, existing, window, cx); - }) - .log_err(); - } - }) - }) - .when(should_show_logout, |this| { - this.entry("Log Out", None, { - let store = store.clone(); - let context_server_id = context_server_id.clone(); - move |_window, cx| { - store.update(cx, |s, cx| { - s.logout_server(&context_server_id, cx).log_err(); - }); - } - }) - }) - // Only show a divider when there is an entry above "Uninstall". - // Extension servers have neither "Configure Server" nor "Log Out". - .when(!provided_by_extension || should_show_logout, |this| { - this.separator() - }) - .entry("Uninstall", None, { - let context_server_id = context_server_id.clone(); - move |_, cx| { - uninstall_server(&context_server_id, provided_by_extension, cx); - } - }) - })) - } +fn render_uninstall_button( + context_server_id: &ContextServerId, + provided_by_extension: bool, +) -> impl IntoElement { + let context_server_id = context_server_id.clone(); + + IconButton::new( + format!("mcp-uninstall-btn-{}", context_server_id.0), + IconName::Trash, + ) + .icon_size(IconSize::Small) + .tab_index(0isize) + .tooltip(Tooltip::text("Uninstall MCP Server")) + .on_click(move |_event, _window, cx| { + uninstall_server(&context_server_id, provided_by_extension, cx); }) } @@ -432,7 +386,6 @@ fn render_status_details( feedback_base() .child( h_flex() - .pr_4() .min_w_0() .w_full() .gap_2() @@ -475,7 +428,6 @@ fn render_status_details( feedback_base() .child( h_flex() - .pr_4() .min_w_0() .w_full() .gap_2() @@ -509,7 +461,6 @@ fn render_status_details( feedback_base() .child( h_flex() - .pr_4() .min_w_0() .w_full() .gap_2() @@ -529,7 +480,6 @@ fn render_status_details( ContextServerStatus::Authenticating => Some( h_flex() .mt_1() - .pr_4() .min_w_0() .w_full() .gap_2() @@ -541,11 +491,32 @@ fn render_status_details( ) .into_any_element(), ), + ContextServerStatus::Running if should_show_logout => { + let store = store.clone(); + let context_server_id = context_server_id.clone(); + Some( + h_flex() + .py_1() + .w_full() + .justify_end() + .child( + Button::new("running-logout", "Log Out") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(move |_event, _window, cx| { + store.update(cx, |s, cx| { + s.logout_server(&context_server_id, cx).log_err(); + }); + }), + ) + .into_any_element(), + ) + } _ => None, } } -fn render_add_server_popover( +pub(crate) fn render_add_server_popover( settings_window: &SettingsWindow, window: &mut Window, cx: &mut Context, diff --git a/crates/settings_ui/src/pages/sandbox_settings.rs b/crates/settings_ui/src/pages/sandbox_settings.rs index d5ee3c5490..9ae15eb738 100644 --- a/crates/settings_ui/src/pages/sandbox_settings.rs +++ b/crates/settings_ui/src/pages/sandbox_settings.rs @@ -10,8 +10,6 @@ use util::ResultExt as _; use crate::SettingsWindow; use crate::components::{SettingsInputField, SettingsSectionHeader}; -const SANDBOX_DISCLAIMER: &str = "Customize how the sandbox for the agents tool should behave."; - const DOMAINS_DESCRIPTION: &str = "Each entry is an exact domain (github.com) or a leading-*. subdomain wildcard (*.npmjs.org). IP addresses and local domains are not allowed."; const WRITE_PATHS_DESCRIPTION: &str = @@ -58,17 +56,9 @@ pub(crate) fn render_sandbox_settings_page( .pt_2p5() .px_8() .pb_16() - .gap_4() + .gap_6() .overflow_y_scroll() .track_scroll(scroll_handle) - .child( - Banner::new().child( - Label::new(SANDBOX_DISCLAIMER) - .size(LabelSize::Small) - .color(Color::Muted) - .mt_0p5(), - ), - ) .child( SwitchField::new( "sandbox-enabled", @@ -102,7 +92,7 @@ pub(crate) fn render_sandbox_settings_page( }) .child( v_flex() - .gap_3() + .gap_4() .child(SettingsSectionHeader::new("Network").no_padding(true)) .child( SwitchField::new( @@ -130,7 +120,7 @@ pub(crate) fn render_sandbox_settings_page( .child(Divider::horizontal()) .child( v_flex() - .gap_3() + .gap_4() .child(SettingsSectionHeader::new("Git").no_padding(true)) .child( SwitchField::new( @@ -151,14 +141,14 @@ pub(crate) fn render_sandbox_settings_page( .child(Divider::horizontal()) .child( v_flex() - .gap_3() - .child(SettingsSectionHeader::new("Filesystem").no_padding(true)) + .gap_4() + .child(SettingsSectionHeader::new("File System").no_padding(true)) .child( SwitchField::new( "sandbox-allow-fs-write-all", - Some("Allow All Filesystem Writes"), + Some("Allow All File System Writes"), Some( - "Let sandboxed commands write anywhere on the filesystem without prompting." + "Let sandboxed commands write anywhere on the file system without prompting." .into(), ), permissions.allow_fs_write_all, diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 991789eb5a..b371fb68cc 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -51,7 +51,7 @@ use workspace::{ }; use zed_actions::{ AGENT_SKILLS_SETTINGS_PATH, OpenProjectSettings, OpenSettings, OpenSettingsAt, - OpenSettingsAtTarget, + OpenSettingsAtTarget, OpenSettingsPage, }; use crate::components::{ @@ -450,6 +450,15 @@ pub fn init(cx: &mut App) { cx, ); }) + .register_action(|_, action: &OpenSettingsPage, window, cx| { + let window_handle = window.window_handle().downcast::(); + open_settings_editor_to_page( + &action.page, + action.target.as_ref().map(SettingsFileTarget::from), + window_handle, + cx, + ); + }) .register_action(|_, _: &OpenSettings, window, cx| { let window_handle = window.window_handle().downcast::(); open_settings_editor(None, None, window_handle, cx); @@ -669,30 +678,71 @@ pub fn open_settings_editor( ); } +fn select_settings_file_target( + target_file: SettingsFileTarget, + settings_window: &mut SettingsWindow, + window: &mut Window, + cx: &mut Context, +) { + let file_index = settings_window + .files + .iter() + .position(|(file, _)| match target_file { + SettingsFileTarget::User => matches!(file, SettingsUiFile::User), + SettingsFileTarget::Project(worktree_id) => file.worktree_id() == Some(worktree_id), + }); + if let Some(file_index) = file_index { + settings_window.change_file(file_index, window, cx); + } +} + +fn open_settings_editor_to_page( + page: &str, + target_file: Option, + workspace_handle: Option>, + cx: &mut App, +) { + let page = page.to_string(); + open_settings_editor_with(workspace_handle, cx, move |settings_window, window, cx| { + if let Some(target_file) = target_file { + select_settings_file_target(target_file, settings_window, window, cx); + } + + settings_window.opening_link = false; + settings_window.search_bar.update(cx, |editor, cx| { + editor.set_text(String::new(), window, cx); + }); + for page_filter in &mut settings_window.filter_table { + page_filter.fill(true); + } + settings_window.has_query = false; + settings_window.filter_matches_to_file(); + + let Some(navbar_entry_index) = settings_window + .navbar_entries + .iter() + .position(|entry| entry.is_root && entry.title.eq_ignore_ascii_case(&page)) + else { + log::error!("settings page not found: {page}"); + return; + }; + + settings_window.open_and_scroll_to_navbar_entry( + navbar_entry_index, + None, + false, + window, + cx, + ); + }); +} + fn open_settings_editor_at_target( path: Option<&str>, target_file: Option, workspace_handle: Option>, cx: &mut App, ) { - fn select_target_file( - target_file: SettingsFileTarget, - settings_window: &mut SettingsWindow, - window: &mut Window, - cx: &mut Context, - ) { - let file_index = settings_window - .files - .iter() - .position(|(file, _)| match target_file { - SettingsFileTarget::User => matches!(file, SettingsUiFile::User), - SettingsFileTarget::Project(worktree_id) => file.worktree_id() == Some(worktree_id), - }); - if let Some(file_index) = file_index { - settings_window.change_file(file_index, window, cx); - } - } - /// Assumes a settings GUI window is already open fn open_path( path: &str, @@ -740,7 +790,7 @@ fn open_settings_editor_at_target( let path = path.map(ToOwned::to_owned); open_settings_editor_with(workspace_handle, cx, move |settings_window, window, cx| { if let Some(target_file) = target_file { - select_target_file(target_file, settings_window, window, cx); + select_settings_file_target(target_file, settings_window, window, cx); } if let Some(path) = path { open_path(&path, settings_window, window, cx); @@ -1772,6 +1822,12 @@ impl SettingsWindow { }) .detach(); + let language_model_registry = language_model::LanguageModelRegistry::global(cx); + cx.subscribe(&language_model_registry, |_, _, _event, cx| { + cx.notify(); + }) + .detach(); + cx.on_window_closed(|cx, _window_id| { if let Some(existing_window) = cx .windows() @@ -3651,6 +3707,9 @@ impl SettingsWindow { if let Some(current_sub_page) = self.sub_page_stack.last() { let is_skills_page = current_sub_page.link.json_path == Some(AGENT_SKILLS_SETTINGS_PATH); + let is_external_agents_page = current_sub_page.link.json_path == Some("agent_servers"); + let is_mcp_servers_page = current_sub_page.link.json_path == Some("context_servers"); + page_header = h_flex() .w_full() .min_w_0() @@ -3701,6 +3760,12 @@ impl SettingsWindow { ); })), ) + }) + .when(is_external_agents_page, |this| { + this.child(pages::render_add_agent_popover(self, window, cx)) + }) + .when(is_mcp_servers_page, |this| { + this.child(pages::render_add_server_popover(self, window, cx)) }), ) .into_any_element(); @@ -5227,6 +5292,7 @@ pub mod test { theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); menu::init(); + language_model::init(cx); } fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow { diff --git a/crates/ui/src/components/ai/ai_setting_item.rs b/crates/ui/src/components/ai/ai_setting_item.rs index 372ff75597..f2b9abdd01 100644 --- a/crates/ui/src/components/ai/ai_setting_item.rs +++ b/crates/ui/src/components/ai/ai_setting_item.rs @@ -21,9 +21,9 @@ impl AiSettingItemStatus { Self::Starting => "Server is starting.", Self::Running => "Server is active.", Self::Error => "Server has an error.", - Self::AuthRequired => "Authentication required.", - Self::ClientSecretRequired => "Client secret required.", - Self::Authenticating => "Waiting for authorization…", + Self::AuthRequired => "Authentication Required.", + Self::ClientSecretRequired => "Client Secret Required.", + Self::Authenticating => "Waiting for Authorization…", } } @@ -198,6 +198,7 @@ impl RenderOnce for AiSettingItem { v_flex() .id(id) .min_w_0() + .py_2() .child( h_flex() .min_w_0() diff --git a/crates/ui/src/components/ai/configured_api_card.rs b/crates/ui/src/components/ai/configured_api_card.rs index 9e89acc496..d2ccc18d0e 100644 --- a/crates/ui/src/components/ai/configured_api_card.rs +++ b/crates/ui/src/components/ai/configured_api_card.rs @@ -1,8 +1,9 @@ use crate::{Tooltip, prelude::*}; -use gpui::{ClickEvent, IntoElement, ParentElement, SharedString}; +use gpui::{ClickEvent, ElementId, IntoElement, ParentElement, SharedString}; #[derive(IntoElement, RegisterComponent)] pub struct ConfiguredApiCard { + id: ElementId, label: SharedString, button_label: Option, button_tab_index: Option, @@ -12,8 +13,9 @@ pub struct ConfiguredApiCard { } impl ConfiguredApiCard { - pub fn new(label: impl Into) -> Self { + pub fn new(id: impl Into, label: impl Into) -> Self { Self { + id: id.into(), label: label.into(), button_label: None, button_tab_index: None, @@ -52,79 +54,21 @@ impl ConfiguredApiCard { } } -impl Component for ConfiguredApiCard { - fn scope() -> ComponentScope { - ComponentScope::Agent - } - - fn description() -> &'static str { - "A card used in AI provider settings to indicate that an API has been \ - configured, with an optional action button to manage or reconfigure it." - } - - fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { - let container = || { - v_flex() - .w_72() - .p_2() - .gap_2() - .border_1() - .border_color(cx.theme().colors().border_variant) - .bg(cx.theme().colors().panel_background) - }; - - let examples = vec![ - single_example( - "Default", - container() - .child(ConfiguredApiCard::new("API key is configured")) - .into_any_element(), - ), - single_example( - "Custom Button Label", - container() - .child( - ConfiguredApiCard::new("OpenAI API key configured") - .button_label("Remove Key"), - ) - .into_any_element(), - ), - single_example( - "With Tooltip", - container() - .child( - ConfiguredApiCard::new("Anthropic API key configured") - .tooltip_label("Click to reset your API key"), - ) - .into_any_element(), - ), - single_example( - "Disabled", - container() - .child(ConfiguredApiCard::new("API key is configured").disabled(true)) - .into_any_element(), - ), - ]; - - example_group(examples).into_any_element() - } -} - impl RenderOnce for ConfiguredApiCard { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let button_label = self.button_label.unwrap_or("Reset Key".into()); - let button_id = SharedString::new(format!("id-{}", button_label)); + let button_id = self.id; h_flex() .min_w_0() .mt_0p5() .p_1() + .flex_wrap() .justify_between() .rounded_md() - .flex_wrap() .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().background) + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().background.opacity(0.5)) .child( h_flex() .min_w_0() @@ -154,3 +98,63 @@ impl RenderOnce for ConfiguredApiCard { ) } } + +impl Component for ConfiguredApiCard { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn description() -> &'static str { + "A card used in AI provider settings to indicate that an API has been \ + configured, with an optional action button to manage or reconfigure it." + } + + fn preview(_window: &mut Window, cx: &mut App) -> AnyElement { + let container = || { + v_flex() + .w_72() + .p_2() + .gap_2() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) + }; + + let examples = vec![ + single_example( + "Default", + container() + .child(ConfiguredApiCard::new("default", "API key is configured")) + .into_any_element(), + ), + single_example( + "Custom Button Label", + container() + .child( + ConfiguredApiCard::new("custom-button-label", "OpenAI API key configured") + .button_label("Remove Key"), + ) + .into_any_element(), + ), + single_example( + "With Tooltip", + container() + .child( + ConfiguredApiCard::new("with-tooltip", "Anthropic API key configured") + .tooltip_label("Click to reset your API key"), + ) + .into_any_element(), + ), + single_example( + "Disabled", + container() + .child( + ConfiguredApiCard::new("disabled", "API key is configured").disabled(true), + ) + .into_any_element(), + ), + ]; + + example_group(examples).into_any_element() + } +} diff --git a/crates/ui_input/src/input_field.rs b/crates/ui_input/src/input_field.rs index e1c5138475..eadadbfa8a 100644 --- a/crates/ui_input/src/input_field.rs +++ b/crates/ui_input/src/input_field.rs @@ -177,11 +177,7 @@ impl Render for InputField { .w_full() .gap_1() .when_some(self.label.clone(), |this, label| { - this.child( - Label::new(label) - .size(self.label_size) - .color(Color::Default), - ) + this.child(Label::new(label).size(self.label_size)) }) .child( h_flex() diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 0fe1b3fba0..9d47f27240 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -149,6 +149,18 @@ pub struct OpenSettingsAt { pub target: Option, } +#[derive(PartialEq, Clone, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = zed)] +#[serde(deny_unknown_fields)] +pub struct OpenSettingsPage { + /// A settings page title (e.g. `AI`). + pub page: String, + /// The settings file to select before opening `page`. When omitted, the + /// existing settings file selection is preserved. + #[serde(default)] + pub target: Option, +} + /// `OpenSettingsAt` path of the agent skills page in the settings UI. pub const AGENT_SKILLS_SETTINGS_PATH: &str = "agent.skills"; diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md index 3919ebc24d..6c29cc6dd1 100644 --- a/docs/src/ai/agent-settings.md +++ b/docs/src/ai/agent-settings.md @@ -1,19 +1,20 @@ --- title: Agent Settings - Zed -description: Map the Agent Settings panel to Zed AI setup pages for LLM providers, External Agents, MCP servers, and related settings. +description: Map the AI settings pages to Zed AI setup for LLM providers, External Agents, MCP servers, and related settings. --- # Agent Settings -Agent Settings is the in-panel configuration view for model providers, External Agents, and MCP servers. Open it with {#action agent::OpenSettings} or from the top-right menu in the [Agent Panel](./agent-panel.md). +Agent Settings live in the **AI** section of the Settings Editor, which configures model providers, External Agents, and MCP servers. +Open it with {#action agent::OpenSettings} (also available from the top-right menu in the [Agent Panel](./agent-panel.md)), which takes you straight to the AI page. +You can also reach the same page with {#action zed::OpenSettings} and selecting **AI** in the sidebar. -Agent Settings is different from the Settings Editor. +Within the AI page, LLM Providers, External Agents, and MCP Servers each open as their own sub-page under the **General** section. -| Surface | Opens with | Use it for | -| -------------------- | ------------------------------- | ------------------------------------------------------------------------------------ | -| Agent Settings panel | {#action agent::OpenSettings} | LLM providers, External Agents, MCP servers | -| Settings Editor | {#action zed::OpenSettings} | General Zed settings, `disable_ai`, tool permissions, edit prediction provider setup | -| Settings file | {#action zed::OpenSettingsFile} | Direct JSON edits and settings not exposed in UI | +| Surface | Opens with | Use it for | +| --------------- | ------------------------------- | --------------------------------------------------------------------------- | +| Settings Editor | {#action agent::OpenSettings} | AI settings: LLM providers, External Agents, MCP servers, and related pages | +| Settings file | {#action zed::OpenSettingsFile} | Direct JSON edits and settings not exposed in UI | For general settings mechanics, see [Configuring Zed](../configuring-zed.md). @@ -95,23 +96,25 @@ For setup details and support boundaries, see [External Agents](./external-agent ## MCP Servers {#mcp-servers} -The `Model Context Protocol (MCP) Servers` section configures MCP servers connected to Zed. +The `MCP Servers` page configures Model Context Protocol servers connected to Zed. -Use `Add Server` to: +Use `Add Server` (in the page header) to: -- `Add Custom Server` +- `Add Local Server` +- `Add Remote Server` - `Install from Extensions` -Each configured server can expose actions such as: +Each configured server row exposes: -- `Configure Server` -- `View Tools` +- a `Configure` (gear) button to edit the server +- an `Uninstall` (trash) button to remove it +- a toggle to enable or disable it For MCP setup, auth, server status, and agent-path boundaries, see [MCP](./mcp.md). ## Related Configuration {#related-configuration} -Some AI settings are not configured in the Agent Settings panel: +Some AI settings are not configured on the AI settings pages: | Task | Go to | | ------------------------------------------------------------ | ---------------------------------------------- | diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md index dd418c3221..b8ed6aa5ad 100644 --- a/docs/src/ai/external-agents.md +++ b/docs/src/ai/external-agents.md @@ -17,7 +17,7 @@ For Zed-hosted models and Zed-managed AI features, see [AI Privacy](./privacy-an The ACP Registry is the primary way to install common External Agents in Zed. -Open the registry with {#action zed::AcpRegistry}, or open [Agent Settings](./agent-settings.md) with {#action agent::OpenSettings}, click `Add Agent`, and choose `Install from Registry`. +Open the registry with {#action zed::AcpRegistry}, or open [Agent Settings](./agent-settings.md) with {#action agent::OpenSettings}, go to the **External Agents** page, click `Add Agent`, and choose `Install from Registry`. After installation, the agent appears in the new-thread menu in the Agent Panel and Threads Sidebar. @@ -130,7 +130,7 @@ Zed LLM provider API keys saved in the local keychain are not automatically the Use custom agents when you are developing an ACP-compatible agent or need to run an agent that is not in the registry. -Open [Agent Settings](./agent-settings.md), click `Add Agent`, and choose `Add Custom Agent`. Zed opens your settings file with an `agent_servers` entry. +Open [Agent Settings](./agent-settings.md), go to the **External Agents** page, click `Add Agent`, and choose `Add Custom Agent`. Zed opens your settings file with an `agent_servers` entry. ```json [settings] { diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md index 30317dfad9..83c456be0b 100644 --- a/docs/src/ai/mcp.md +++ b/docs/src/ai/mcp.md @@ -35,7 +35,7 @@ Many MCP servers are available as extensions. Find them via: 1. [the Zed website](https://zed.dev/extensions?filter=context-servers) 2. in the app, open the Command Palette and run the {#action zed::Extensions} action -3. in the app, go to the Agent Panel's top-right menu and look for the "Install New Servers…" menu item under the "MCP Servers" section +3. in the app, open **Settings → AI → MCP Servers**, click `Add Server`, and choose `Install from Extensions` Popular servers available as an extension include: @@ -72,7 +72,7 @@ You can connect both local and remote MCP servers easily utilizing the MCP serve } ``` -Alternatively, you can also open the modal by accessing the Agent Panel's Settings view (also accessible via the {#action agent::OpenSettings} action) and clicking the "Add Custom Server" button there. +Alternatively, you can open the modal from **Settings → AI → MCP Servers** (also accessible via the {#action agent::OpenSettings} action, then selecting `MCP Servers`) and clicking the `Add Server` button in the page header, then choosing `Add Local Server` or `Add Remote Server`. > Note: When a remote MCP server has no configured `"Authorization"` header, Zed will prompt you to authenticate yourself against the MCP server using the standard MCP OAuth flow. @@ -87,7 +87,7 @@ For example, the GitHub MCP extension requires you to add a [Personal Access Tok In the case of custom servers, make sure you check the provider documentation to determine what type of command, arguments, and environment variables need to be added to the JSON. -To check if your MCP server is properly configured, go to the Agent Panel's settings view and watch the indicator dot next to its name. +To check if your MCP server is properly configured, open **Settings → AI → MCP Servers** and watch the indicator dot next to its name. If it's running correctly, the indicator will be green and its tooltip will say "Server is active". If not, other colors and tooltip messages will indicate what is happening. diff --git a/docs/src/ai/use-a-gateway.md b/docs/src/ai/use-a-gateway.md index 400b1a2576..e010cfe815 100644 --- a/docs/src/ai/use-a-gateway.md +++ b/docs/src/ai/use-a-gateway.md @@ -20,7 +20,7 @@ Use OpenRouter when you want to route Zed AI features through OpenRouter. 1. Visit [OpenRouter](https://openrouter.ai) and create an account. 2. Generate an API key from your [OpenRouter keys page](https://openrouter.ai/keys). -3. Open Agent Settings with {#action agent::OpenSettings} and go to the OpenRouter section. +3. Open **Settings → AI → LLM Providers** with {#action agent::OpenSettings} and find the OpenRouter row. 4. Enter your OpenRouter API key. Zed also reads `OPENROUTER_API_KEY` from the local Zed process environment. @@ -104,7 +104,7 @@ Supported fields include `order`, `allow_fallbacks`, `require_parameters`, `data Use Vercel AI Gateway when you want to route Zed AI features through Vercel. 1. Create an API key from your Vercel AI Gateway keys page. -2. Open Agent Settings with {#action agent::OpenSettings} and go to the Vercel AI Gateway section. +2. Open **Settings → AI → LLM Providers** with {#action agent::OpenSettings} and find the Vercel AI Gateway row. 3. Enter your Vercel AI Gateway API key. Zed also reads `VERCEL_AI_GATEWAY_API_KEY` from the local Zed process environment. diff --git a/docs/src/ai/use-api-access.md b/docs/src/ai/use-api-access.md index 52b2eaf960..0ddf85ba33 100644 --- a/docs/src/ai/use-api-access.md +++ b/docs/src/ai/use-api-access.md @@ -41,7 +41,7 @@ agent paths and model access paths. ## API Keys and Environment Variables {#api-keys} -Most API-access providers can be configured in Zed's Agent Settings panel with {#action agent::OpenSettings}. Keys saved through Zed are stored in the system keychain, not in `settings.json`. +Most API-access providers can be configured on the **Settings → AI → LLM Providers** page with {#action agent::OpenSettings}. Keys saved through Zed are stored in the system keychain, not in `settings.json`. Zed also reads provider-specific environment variables. Non-empty environment variables take precedence over keychain values. If a key comes from an environment variable, unset the variable and restart Zed to stop using it.