diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index d1e10a10..09855648 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -1040,7 +1040,7 @@ impl LLMPreferences { }; provider_entries.push(( name, - OpenAIProviderKind::OpenAICompatible, + OpenAIProviderKind::LiteLLM, true, base_url, api_key, @@ -1057,7 +1057,14 @@ impl LLMPreferences { .iter() .filter_map(|provider| { let missing_credentials = match provider.kind { - OpenAIProviderKind::OpenAICompatible => provider.base_url.trim().is_empty(), + OpenAIProviderKind::OpenAI => { + provider.base_url.trim().is_empty() + || provider + .api_key + .as_deref() + .is_none_or(|key| key.trim().is_empty()) + } + OpenAIProviderKind::LiteLLM => provider.base_url.trim().is_empty(), OpenAIProviderKind::Anthropic | OpenAIProviderKind::Gemini => provider .api_key .as_deref() @@ -1143,7 +1150,10 @@ impl LLMPreferences { max_input_tokens: Some(openai_model_context_size(model)), max_output_tokens: model.max_output_tokens, use_rig: model.use_rig - || !matches!(provider_kind, OpenAIProviderKind::OpenAICompatible), + || !matches!( + provider_kind, + OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM + ), supports_system_messages: model.supports_system_messages(), }; self.openai_provider_routing @@ -1531,6 +1541,7 @@ impl LLMPreferences { return; } + let provider_kind = provider.kind; let requested_base_url = provider.base_url; let api_key = provider.api_key.filter(|key| !key.is_empty()); let request_base_url = requested_base_url.clone(); @@ -1543,10 +1554,12 @@ impl LLMPreferences { .build() .unwrap_or_default(); - if let Some(models) = - fetch_from_litellm_model_info(base, api_key.as_deref(), &client).await - { - return models; + if provider_kind == OpenAIProviderKind::LiteLLM { + if let Some(models) = + fetch_from_litellm_model_info(base, api_key.as_deref(), &client).await + { + return models; + } } fetch_from_openai_models(base, api_key.as_deref(), &client).await @@ -1580,7 +1593,7 @@ impl LLMPreferences { /// Discovers models for a provider draft without persisting or injecting it. /// - /// The provider setup modal uses this to keep configuration changes atomic + /// The provider setup view uses this to keep configuration changes atomic /// until the user clicks Save. #[cfg(not(target_family = "wasm"))] pub(crate) async fn discover_openai_provider_models( @@ -1624,7 +1637,9 @@ impl LLMPreferences { )?; Some(vertex_ai_model_catalog()) } - OpenAIProviderKind::OpenAICompatible | OpenAIProviderKind::ChatGPTSubscription => None, + OpenAIProviderKind::OpenAI + | OpenAIProviderKind::LiteLLM + | OpenAIProviderKind::ChatGPTSubscription => None, }; if let Some(models) = native_models { @@ -1645,19 +1660,25 @@ impl LLMPreferences { .map_err(|error| format!("Could not create the provider client: {error}"))?; let api_key = provider.api_key.as_deref().filter(|key| !key.is_empty()); - let models = if let Some(models) = - fetch_from_litellm_model_info(&base_url, api_key, &client).await - { - models + let models = if provider.kind == OpenAIProviderKind::LiteLLM { + if let Some(models) = fetch_from_litellm_model_info(&base_url, api_key, &client).await { + models + } else { + fetch_from_openai_models(&base_url, api_key, &client).await + } } else { fetch_from_openai_models(&base_url, api_key, &client).await }; if models.is_empty() { - return Err( - "The provider responded, but no models were found at /model/info or /models." - .to_string(), - ); + let endpoint_description = if provider.kind == OpenAIProviderKind::LiteLLM { + "/model/info or /models" + } else { + "/models" + }; + return Err(format!( + "The provider responded, but no models were found at {endpoint_description}." + )); } Ok(models) diff --git a/app/src/ai/runtime/rig.rs b/app/src/ai/runtime/rig.rs index ac49776d..1e125cc1 100644 --- a/app/src/ai/runtime/rig.rs +++ b/app/src/ai/runtime/rig.rs @@ -41,7 +41,7 @@ pub(crate) fn rig_openai_response_stream( let prepared = prepare_rig_turn(&config, params, supported_tools, supported_cli_agent_tools); let model_id = prepared.request.model.as_str().to_string(); match config.kind { - OpenAIProviderKind::OpenAICompatible => { + OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM => { let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig { base_url: config.base_url, api_key: config.api_key, diff --git a/app/src/ai/runtime/rig_request_tests.rs b/app/src/ai/runtime/rig_request_tests.rs index 8bf9ae11..fb1711f0 100644 --- a/app/src/ai/runtime/rig_request_tests.rs +++ b/app/src/ai/runtime/rig_request_tests.rs @@ -20,7 +20,7 @@ use crate::ai::skills::SkillDescriptor; fn config() -> OpenAIClientConfig { OpenAIClientConfig { - kind: crate::settings::OpenAIProviderKind::OpenAICompatible, + kind: crate::settings::OpenAIProviderKind::LiteLLM, base_url: "http://localhost:4000/v1".to_string(), api_key: None, project_id: None, diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index b9d76657..ea4c7b14 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -978,10 +978,13 @@ impl ModelCapabilityOverride { )] #[serde(rename_all = "snake_case")] pub enum OpenAIProviderKind { - /// A regular OpenAI-compatible `/chat/completions` endpoint. - #[serde(alias = "openai")] + /// OpenAI's native Chat Completions API. + #[serde(rename = "openai", alias = "open_ai")] + OpenAI, + /// A LiteLLM endpoint using the OpenAI-compatible API plus LiteLLM metadata APIs. + #[serde(rename = "litellm", alias = "openai_compatible")] #[default] - OpenAICompatible, + LiteLLM, /// The ChatGPT subscription backend, authenticated with ChatGPT OAuth. ChatGPTSubscription, /// Anthropic's native Messages API. @@ -993,14 +996,12 @@ pub enum OpenAIProviderKind { VertexAI, } -/// Configuration for a single OpenAI-compatible provider endpoint. +/// Configuration for a single direct model provider endpoint. /// -/// Multiple providers can be configured simultaneously (e.g. LiteLLM for cloud models, -/// Ollama for local models, etc.). Each provider has its own endpoint, credentials, and model list. +/// Multiple providers can be configured simultaneously. Each provider has its own endpoint, +/// credentials, and model list. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] -#[schemars( - description = "Configuration for an OpenAI-compatible provider endpoint (e.g. LiteLLM, Ollama, vLLM)." -)] +#[schemars(description = "Configuration for a direct model provider endpoint.")] pub struct OpenAIProviderConfig { #[serde(default)] #[schemars(description = "Provider protocol and authentication kind.")] @@ -1114,7 +1115,7 @@ pub(crate) fn default_chatgpt_provider() -> OpenAIProviderConfig { fn default_openai_providers() -> Vec { vec![ OpenAIProviderConfig { - kind: OpenAIProviderKind::OpenAICompatible, + kind: OpenAIProviderKind::LiteLLM, enabled: true, name: "LiteLLM (ai.ryserve.net)".to_string(), base_url: INITIAL_LITELLM_BASE_URL.to_string(), diff --git a/app/src/settings/ai_tests.rs b/app/src/settings/ai_tests.rs index 21b0c215..cd5fd3ee 100644 --- a/app/src/settings/ai_tests.rs +++ b/app/src/settings/ai_tests.rs @@ -351,7 +351,7 @@ fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() { assert_eq!(providers.len(), 2); let provider = &providers[0]; - assert_eq!(provider.kind, OpenAIProviderKind::OpenAICompatible); + assert_eq!(provider.kind, OpenAIProviderKind::LiteLLM); assert_eq!(provider.base_url, INITIAL_LITELLM_BASE_URL); assert_eq!(provider.api_key, None); assert_eq!(provider.models.len(), 1); @@ -414,13 +414,6 @@ fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() { .map(str::to_string) .collect::>() ); - - let instant = chatgpt - .models - .iter() - .find(|model| model.model_id == "gpt-5.3-instant") - .expect("GPT-5.3 Instant should be in the ChatGPT catalog"); - assert!(instant.reasoning_efforts.is_empty()); } #[test] @@ -446,9 +439,29 @@ fn native_provider_settings_roundtrip_with_vertex_configuration() { "models": [] })) .expect("Legacy provider settings should remain compatible"); - assert_eq!(legacy.kind, OpenAIProviderKind::OpenAICompatible); + assert_eq!(legacy.kind, OpenAIProviderKind::LiteLLM); assert_eq!(legacy.project_id, None); assert_eq!(legacy.location, None); + + let legacy_openai_compatible: OpenAIProviderConfig = + serde_json::from_value(serde_json::json!({ + "kind": "openai_compatible", + "name": "Legacy LiteLLM provider", + "base_url": "http://localhost:4000/v1", + "models": [] + })) + .expect("Legacy OpenAI-compatible provider settings should deserialize"); + assert_eq!(legacy_openai_compatible.kind, OpenAIProviderKind::LiteLLM); + + let native_openai: OpenAIProviderConfig = serde_json::from_value(serde_json::json!({ + "kind": "openai", + "name": "OpenAI", + "base_url": "https://api.openai.com/v1", + "api_key": "sk-test", + "models": [] + })) + .expect("Native OpenAI provider settings should deserialize"); + assert_eq!(native_openai.kind, OpenAIProviderKind::OpenAI); } #[test] diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index f9b0e69d..b32d4fa4 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -1,3 +1,9 @@ +use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + use ::ai::api_keys::ApiKeyManager; use chrono::{DateTime, Local}; use enum_iterator::all; @@ -28,17 +34,17 @@ use galaxyui::{ ViewHandle, }; use itertools::Itertools; +use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine}; use pathfinder_geometry::vector::vec2f; use regex::Regex; use settings::{Setting, ToggleableSetting}; use strum::IntoEnumIterator; use super::execution_profile_view::{ExecutionProfileView, ExecutionProfileViewEvent}; -use super::provider_setup_modal::{ - AcpProviderDraft, BedrockProviderDraft, ProviderSetupModalBody, ProviderSetupModalBodyEvent, - ProviderSetupModalState, +use super::provider_setup_view::{ + AcpProviderDraft, BedrockProviderDraft, ProviderSetupProviderType, ProviderSetupView, + ProviderSetupViewEvent, }; -use super::set_default_model_modal::{SetDefaultModelModalBody, SetDefaultModelModalBodyEvent}; use super::settings_page::{ build_sub_header, build_toggle_element, render_body_item_label, render_body_item_label_with_icon, render_custom_size_header, render_dropdown_item, @@ -74,93 +80,49 @@ use crate::ai::llms::{ }; use crate::ai::mcp::TemplatableMCPServerManager; use crate::ai::paths::host_native_absolute_path; +use crate::appearance::{Appearance, AppearanceEvent}; use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent}; use crate::cloud_object::GenericStringObjectFormat::Json; use crate::cloud_object::{JsonObjectType, ObjectType}; use crate::editor::{ - EditorOptions, InteractionState, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, - TextColors, + EditorOptions, EditorView, Event as EditorEvent, InteractionState, + PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextColors, TextOptions, }; -use crate::modal::{Modal, ModalEvent, ModalViewState}; -use crate::settings::ai::OpenAIProviderKind; -use crate::settings::{ - AIAutoDetectionEnabled, AICommandDenylist, AISettingsChangedEvent, AcpEnabled, - AgentModeCodingPermissionsType, AgentModeCommandExecutionDenylist, - AgentModeCommandExecutionPredicate, AgentModeQuerySuggestionsEnabled, BedrockAutoLogin, - BedrockEnabled, CodeSettings, CodebaseContextEnabled, CrosscheckEnabled, FileBasedMcpEnabled, - GitOperationsAutogenEnabled, IncludeAgentCommandsInHistory, InputSettings, - IntelligentAutosuggestionsEnabled, LongRunningCommandSubmissionMode, MemoryEnabled, - NLDInTerminalEnabled, NaturalLanguageAutosuggestionsEnabled, OpenAIEnabled, - OpenAIProviderConfig, OrchestrationMessageDisplayMode, PromptSubmissionMode, - RuleSuggestionsEnabled, SharedBlockTitleGenerationEnabled, ShouldRenderCLIAgentToolbar, - ShouldRenderUseAgentToolbarForUserCommands, ShowAgentTips, ShowConversationHistory, - ShowHintText, ThinkingDisplayMode, VoiceInputEnabled, WarpDriveContextEnabled, -}; -use crate::terminal::session_settings::{SessionSettings, SessionSettingsChangedEvent}; -use crate::terminal::CLIAgent; -use crate::view_components::action_button::{ - ActionButton, ButtonSize, DangerSecondaryTheme, SecondaryTheme, -}; -use crate::view_components::{ - render_warning_box, FilterableDropdown, SubmittableTextInput, SubmittableTextInputEvent, - WarningBoxConfig, -}; -use crate::workspace::ToastStack; -use crate::workspaces::user_workspaces::UserWorkspacesEvent; - -/// Identifies which subpage of the AI settings the user is viewing. -/// When `None`, the page shows all widgets (legacy/full view). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum AISubpage { - /// The main Galaxy Agent page: global AI toggle + Active AI + Input + Other sections. - WarpAgent, - /// Agent profiles and permissions. - Profiles, - /// Knowledge / Rules settings. - Knowledge, - /// Third-party CLI agent settings. - ThirdPartyCLIAgents, - /// Unified model and provider configuration. - Models, - /// Experimental features. - Experiments, -} - -impl AISubpage { - pub fn from_section(section: SettingsSection) -> Option { - match section { - SettingsSection::WarpAgent => Some(Self::WarpAgent), - SettingsSection::AgentProfiles => Some(Self::Profiles), - SettingsSection::Knowledge => Some(Self::Knowledge), - SettingsSection::ThirdPartyCLIAgents => Some(Self::ThirdPartyCLIAgents), - SettingsSection::Models => Some(Self::Models), - SettingsSection::Experiments => Some(Self::Experiments), - // AgentMCPServers renders the standalone MCPServers page, not an AI subpage. - _ => None, - } - } -} -use std::borrow::Cow; -use std::cell::RefCell; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::LazyLock; - -use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine}; - -use crate::appearance::{Appearance, AppearanceEvent}; -use crate::editor::{EditorView, Event as EditorEvent, TextOptions}; use crate::menu::{MenuItem, MenuItemFields}; use crate::server::telemetry::{ AgentModeAutoDetectionSettingOrigin, AutonomySettingToggleSource, ToggleCodeSuggestionsSettingSource, }; -use crate::settings::{AISettings, VoiceInputToggleKey}; +use crate::settings::ai::OpenAIProviderKind; +use crate::settings::{ + AIAutoDetectionEnabled, AICommandDenylist, AISettings, AISettingsChangedEvent, AcpEnabled, + AgentModeCodingPermissionsType, AgentModeCommandExecutionDenylist, + AgentModeCommandExecutionPredicate, AgentModeQuerySuggestionsEnabled, BedrockAutoLogin, + BedrockEnabled, BedrockModelConfig, CodeSettings, CodebaseContextEnabled, CrosscheckEnabled, + FileBasedMcpEnabled, GitOperationsAutogenEnabled, IncludeAgentCommandsInHistory, InputSettings, + IntelligentAutosuggestionsEnabled, LongRunningCommandSubmissionMode, MemoryEnabled, + NLDInTerminalEnabled, NaturalLanguageAutosuggestionsEnabled, OpenAIEnabled, OpenAIModelConfig, + OpenAIProviderConfig, OrchestrationMessageDisplayMode, PromptSubmissionMode, + RuleSuggestionsEnabled, SharedBlockTitleGenerationEnabled, ShouldRenderCLIAgentToolbar, + ShouldRenderUseAgentToolbarForUserCommands, ShowAgentTips, ShowConversationHistory, + ShowHintText, ThinkingDisplayMode, VoiceInputEnabled, VoiceInputToggleKey, + WarpDriveContextEnabled, +}; +use crate::terminal::session_settings::{SessionSettings, SessionSettingsChangedEvent}; +use crate::terminal::CLIAgent; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; use crate::util::bindings; +use crate::view_components::action_button::{ + ActionButton, ButtonSize, DangerSecondaryTheme, SecondaryTheme, +}; use crate::view_components::dropdown::DropdownAction; -use crate::view_components::{Dropdown, DropdownItem}; +use crate::view_components::{ + render_warning_box, Dropdown, DropdownItem, FilterableDropdown, SubmittableTextInput, + SubmittableTextInputEvent, WarningBoxConfig, +}; +use crate::workspace::ToastStack; +use crate::workspaces::user_workspaces::UserWorkspacesEvent; use crate::workspaces::workspace::{AdminEnablementSetting, CustomerType}; use crate::{ report_error, report_if_error, send_telemetry_from_ctx, TelemetryEvent, UserWorkspaces, @@ -185,6 +147,59 @@ const GIT_OPERATIONS_AUTOGEN_DESCRIPTION: &str = "Let AI generate commit messages and pull request titles and descriptions."; const WISPR_FLOW_URL: &str = "https://wisprflow.ai/"; +/// Identifies which subpage of the AI settings the user is viewing. +/// When `None`, the page shows all widgets (legacy/full view). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AISubpage { + /// The main Galaxy Agent page: global AI toggle + Active AI + Input + Other sections. + WarpAgent, + /// Agent profiles and permissions. + Profiles, + /// Knowledge / Rules settings. + Knowledge, + /// Third-party CLI agent settings. + ThirdPartyCLIAgents, + /// Unified model and provider configuration. + Models, + /// OpenAI provider settings. + ProviderOpenAI, + /// LiteLLM provider settings. + ProviderLiteLLM, + /// ChatGPT subscription provider settings. + ProviderChatGPTSubscription, + /// AWS Bedrock provider settings. + ProviderBedrock, + /// ACP provider settings. + ProviderACP, + /// Experimental features. + Experiments, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct InlineProviderSetupState { + provider_type: ProviderSetupProviderType, +} + +impl AISubpage { + pub fn from_section(section: SettingsSection) -> Option { + match section { + SettingsSection::WarpAgent => Some(Self::WarpAgent), + SettingsSection::AgentProfiles => Some(Self::Profiles), + SettingsSection::Knowledge => Some(Self::Knowledge), + SettingsSection::ThirdPartyCLIAgents => Some(Self::ThirdPartyCLIAgents), + SettingsSection::Models => Some(Self::Models), + SettingsSection::ProviderOpenAI => Some(Self::ProviderOpenAI), + SettingsSection::ProviderLiteLLM => Some(Self::ProviderLiteLLM), + SettingsSection::ProviderChatGPTSubscription => Some(Self::ProviderChatGPTSubscription), + SettingsSection::ProviderBedrock => Some(Self::ProviderBedrock), + SettingsSection::ProviderACP => Some(Self::ProviderACP), + SettingsSection::Experiments => Some(Self::Experiments), + // AgentMCPServers renders the standalone MCPServers page, not an AI subpage. + _ => None, + } + } +} + pub fn init_actions_from_parent_view( app: &mut AppContext, context: &ContextPredicate, @@ -689,9 +704,8 @@ pub struct AISettingsPageView { // Profile views profile_views: Vec>, add_profile_button: ViewHandle, - provider_setup_modal_state: ProviderSetupModalState, - #[cfg(not(target_family = "wasm"))] - provider_setup_modal_body: ViewHandle, + provider_setup_body: ViewHandle, + inline_provider_setup: Option, // Custom model router views (gated on FeatureFlag::CustomModelRouters) #[cfg(feature = "local_fs")] @@ -715,7 +729,7 @@ impl AISettingsPageView { Ok(config) => config, Err(error) => { log::warn!("Could not resolve ACP launch configuration for discovery: {error}"); - self.provider_setup_modal_body.update(ctx, |body, ctx| { + self.provider_setup_body.update(ctx, |body, ctx| { body.finish_acp_discovery(Err(error), Vec::new(), ctx); }); return; @@ -731,7 +745,7 @@ impl AISettingsPageView { agent_id: String, ctx: &mut ViewContext, ) { - let provider_setup_modal_body = self.provider_setup_modal_body.clone(); + let provider_setup_body = self.provider_setup_body.clone(); /* * The settings borrow must end before updating the runtime singleton. */ @@ -744,7 +758,7 @@ impl AISettingsPageView { Err(error) => { log::warn!("Could not start ACP discovery: {error}"); let error_text = error.to_string(); - provider_setup_modal_body.update(ctx, |body, ctx| { + provider_setup_body.update(ctx, |body, ctx| { body.finish_acp_discovery(Err(error_text), Vec::new(), ctx); }); return; @@ -783,7 +797,7 @@ impl AISettingsPageView { crate::ai::acp::AcpRuntimeModel::handle(ctx).update(ctx, |runtime, ctx| { runtime.finish_discovery_success(option_count, ctx); }); - provider_setup_modal_body.update(ctx, |body, ctx| { + provider_setup_body.update(ctx, |body, ctx| { body.finish_acp_discovery(Ok(()), config_options, ctx); }); } @@ -805,7 +819,7 @@ impl AISettingsPageView { crate::ai::acp::AcpRuntimeModel::handle(ctx).update(ctx, |runtime, ctx| { runtime.finish_discovery_failure(error_text.clone(), ctx); }); - provider_setup_modal_body.update(ctx, |body, ctx| { + provider_setup_body.update(ctx, |body, ctx| { body.finish_acp_discovery(Err(error_text), Vec::new(), ctx); }); } @@ -1144,7 +1158,7 @@ impl AISettingsPageView { settings.add_cli_agent_footer_enabled_command(command, ctx); }); } - SubmittableTextInputEvent::Escape => ctx.emit(AISettingsPageEvent::FocusModal), + SubmittableTextInputEvent::Escape => ctx.emit(AISettingsPageEvent::FocusSearch), }, ); @@ -1778,43 +1792,21 @@ impl AISettingsPageView { button.set_disabled(!is_any_ai_enabled, ctx); }); - let provider_setup_body = ctx.add_typed_action_view(ProviderSetupModalBody::new); + let provider_setup_body = ctx.add_typed_action_view(ProviderSetupView::new); ctx.subscribe_to_view(&provider_setup_body, |me, _, event, ctx| match event { - ProviderSetupModalBodyEvent::Close => me.close_provider_setup_modal(ctx), - ProviderSetupModalBodyEvent::RequestAcpDiscovery(draft) => { + ProviderSetupViewEvent::Close => me.clear_inline_provider_setup(ctx), + ProviderSetupViewEvent::RequestAcpDiscovery(draft) => { #[cfg(not(target_family = "wasm"))] me.refresh_acp_discovery_for_draft(draft, ctx); } - ProviderSetupModalBodyEvent::SaveOpenAI { + ProviderSetupViewEvent::SaveOpenAI { editing_index, provider, } => me.save_provider_setup(*editing_index, provider.clone(), ctx), - ProviderSetupModalBodyEvent::SaveBedrock(draft) => { + ProviderSetupViewEvent::SaveBedrock(draft) => { me.save_bedrock_provider(draft.clone(), ctx) } - ProviderSetupModalBodyEvent::SaveAcp(draft) => me.save_acp_provider(draft.clone(), ctx), - }); - let provider_setup_modal_view = ctx.add_typed_action_view(|ctx| { - Modal::new( - Some("Add model provider".to_string()), - provider_setup_body.clone(), - ctx, - ) - .with_modal_style(UiComponentStyles { - width: Some(900.), - height: Some(700.), - ..Default::default() - }) - .with_body_style(UiComponentStyles { - height: Some(630.), - ..Default::default() - }) - .with_dismiss_on_click() - }); - ctx.subscribe_to_view(&provider_setup_modal_view, |me, _, event, ctx| { - if matches!(event, ModalEvent::Close) { - me.close_provider_setup_modal(ctx); - } + ProviderSetupViewEvent::SaveAcp(draft) => me.save_acp_provider(draft.clone(), ctx), }); let agent_toolbar_inline_editor = ctx.add_typed_action_view(|ctx| { @@ -1921,9 +1913,8 @@ impl AISettingsPageView { conversation_layout_dropdown, profile_views, add_profile_button, - provider_setup_modal_state: ModalViewState::new(provider_setup_modal_view), - #[cfg(not(target_family = "wasm"))] - provider_setup_modal_body: provider_setup_body, + provider_setup_body, + inline_provider_setup: None, #[cfg(feature = "local_fs")] router_views, #[cfg(feature = "local_fs")] @@ -1944,52 +1935,48 @@ impl AISettingsPageView { ctx.notify(); } - pub fn get_modal_content(&self, _app: &AppContext) -> Option> { - self.provider_setup_modal_state - .is_open() - .then(|| self.provider_setup_modal_state.render()) + fn provider_setup_type_for_kind(kind: OpenAIProviderKind) -> ProviderSetupProviderType { + match kind { + OpenAIProviderKind::OpenAI => ProviderSetupProviderType::OpenAI, + OpenAIProviderKind::LiteLLM => ProviderSetupProviderType::LiteLLM, + OpenAIProviderKind::ChatGPTSubscription => { + ProviderSetupProviderType::ChatGPTSubscription + } + OpenAIProviderKind::Anthropic => ProviderSetupProviderType::Anthropic, + OpenAIProviderKind::Gemini => ProviderSetupProviderType::Gemini, + OpenAIProviderKind::VertexAI => ProviderSetupProviderType::VertexAI, + } } - fn open_provider_setup_modal( + fn begin_inline_provider_create( &mut self, - editing_index: Option, + provider_type: ProviderSetupProviderType, ctx: &mut ViewContext, ) { - let body = self - .provider_setup_modal_state - .view - .as_ref(ctx) - .body() - .clone(); - body.update(ctx, |body, ctx| match editing_index { - Some(index) => { - let Some(provider) = AISettings::as_ref(ctx) - .openai_providers - .value() - .get(index) - .cloned() - else { - return; - }; - body.begin_edit(index, provider, ctx); - } - None => body.begin_create(ctx), - }); - self.provider_setup_modal_state.open(); - self.provider_setup_modal_state - .view - .update(ctx, |modal, ctx| { - modal.set_title(Some(if editing_index.is_some() { - "Edit model provider".to_string() - } else { - "Add model provider".to_string() - })); - ctx.notify(); - }); - ctx.emit(AISettingsPageEvent::ShowModal); + self.provider_setup_body + .update(ctx, |body, ctx| body.begin_create(provider_type, ctx)); + self.inline_provider_setup = Some(InlineProviderSetupState { provider_type }); + ctx.notify(); } - fn open_bedrock_setup_modal(&mut self, ctx: &mut ViewContext) { + fn begin_inline_provider_edit(&mut self, provider_index: usize, ctx: &mut ViewContext) { + let Some(provider) = AISettings::as_ref(ctx) + .openai_providers + .value() + .get(provider_index) + .cloned() + else { + return; + }; + let provider_type = Self::provider_setup_type_for_kind(provider.kind); + self.provider_setup_body.update(ctx, |body, ctx| { + body.begin_edit(provider_index, provider, ctx) + }); + self.inline_provider_setup = Some(InlineProviderSetupState { provider_type }); + ctx.notify(); + } + + fn begin_inline_bedrock_setup(&mut self, ctx: &mut ViewContext) { let settings = AISettings::as_ref(ctx); let draft = BedrockProviderDraft { name: settings.bedrock_connection_name.value().clone(), @@ -2003,58 +1990,17 @@ impl AISettingsPageView { secret_access_key: settings.bedrock_secret_access_key.value().clone(), models: settings.bedrock_models.value().clone(), }; - let body = self - .provider_setup_modal_state - .view - .as_ref(ctx) - .body() - .clone(); - body.update(ctx, |body, ctx| body.begin_edit_bedrock(draft.clone(), ctx)); - self.provider_setup_modal_state.open(); - self.provider_setup_modal_state - .view - .update(ctx, |modal, ctx| { - modal.set_title(Some("Edit AWS Bedrock provider".to_string())); - ctx.notify(); - }); - ctx.emit(AISettingsPageEvent::ShowModal); + self.provider_setup_body + .update(ctx, |body, ctx| body.begin_edit_bedrock(draft.clone(), ctx)); + self.inline_provider_setup = Some(InlineProviderSetupState { + provider_type: ProviderSetupProviderType::Bedrock, + }); + ctx.notify(); } - fn open_acp_setup_modal(&mut self, ctx: &mut ViewContext) { - let settings = AISettings::as_ref(ctx); - let draft = AcpProviderDraft { - name: settings.acp_connection_name.value().clone(), - agent_id: settings.acp_agent_id.value().clone(), - command: settings.acp_agent_command.value().clone(), - args: settings.acp_agent_args.value().clone(), - config_options: settings - .acp_agents - .value() - .iter() - .find(|agent| agent.id.eq_ignore_ascii_case(settings.acp_agent_id.value())) - .map(|agent| agent.config_options.clone()) - .unwrap_or_default(), - }; - let body = self - .provider_setup_modal_state - .view - .as_ref(ctx) - .body() - .clone(); - body.update(ctx, |body, ctx| body.begin_edit_acp(draft.clone(), ctx)); - self.provider_setup_modal_state.open(); - self.provider_setup_modal_state - .view - .update(ctx, |modal, ctx| { - modal.set_title(Some("Edit ACP provider".to_string())); - ctx.notify(); - }); - ctx.emit(AISettingsPageEvent::ShowModal); - } - - fn close_provider_setup_modal(&mut self, ctx: &mut ViewContext) { - self.provider_setup_modal_state.close(); - ctx.emit(AISettingsPageEvent::HideModal); + fn clear_inline_provider_setup(&mut self, ctx: &mut ViewContext) { + self.inline_provider_setup = None; + self.rebuild_active_subpage(ctx); } fn save_provider_setup( @@ -2076,8 +2022,7 @@ impl AISettingsPageView { } report_if_error!(settings.openai_providers.set_value(providers, ctx)); }); - self.close_provider_setup_modal(ctx); - self.rebuild_active_subpage(ctx); + self.clear_inline_provider_setup(ctx); } fn save_bedrock_provider(&mut self, draft: BedrockProviderDraft, ctx: &mut ViewContext) { @@ -2104,8 +2049,7 @@ impl AISettingsPageView { report_if_error!(settings.bedrock_connection_name.set_value(draft.name, ctx)); report_if_error!(settings.bedrock_models.set_value(draft.models, ctx)); }); - self.close_provider_setup_modal(ctx); - self.rebuild_active_subpage(ctx); + self.clear_inline_provider_setup(ctx); } fn save_acp_provider(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext) { @@ -2116,8 +2060,7 @@ impl AISettingsPageView { report_if_error!(settings.acp_agent_args.set_value(draft.args, ctx)); report_if_error!(settings.acp_connection_name.set_value(draft.name, ctx)); }); - self.close_provider_setup_modal(ctx); - self.rebuild_active_subpage(ctx); + self.clear_inline_provider_setup(ctx); } /// Set the active subpage and rebuild the widget list to show only relevant widgets. @@ -2249,10 +2192,39 @@ impl AISettingsPageView { } Some(AISubpage::Models) => { widgets.push(Box::new(ModelsOverviewWidget)); - widgets.push(Box::new(OpenAIProviderSettingsWidget::new(ctx))); let title: Option<&str> = None; return (PageType::new_uncategorized(widgets, title), None); } + Some(AISubpage::ProviderOpenAI) => { + widgets.push(Box::new(ProviderSettingsWidget::new( + ctx, + ProviderSetupProviderType::OpenAI, + ))); + } + Some(AISubpage::ProviderLiteLLM) => { + widgets.push(Box::new(ProviderSettingsWidget::new( + ctx, + ProviderSetupProviderType::LiteLLM, + ))); + } + Some(AISubpage::ProviderChatGPTSubscription) => { + widgets.push(Box::new(ProviderSettingsWidget::new( + ctx, + ProviderSetupProviderType::ChatGPTSubscription, + ))); + } + Some(AISubpage::ProviderBedrock) => { + widgets.push(Box::new(ProviderSettingsWidget::new( + ctx, + ProviderSetupProviderType::Bedrock, + ))); + } + Some(AISubpage::ProviderACP) => { + widgets.push(Box::new(ProviderSettingsWidget::new( + ctx, + ProviderSetupProviderType::Acp, + ))); + } Some(AISubpage::Experiments) => { widgets.push(Box::new(ExperimentsWidget::default())); } @@ -2300,11 +2272,11 @@ impl AISettingsPageView { } self.sync_context_window_editor(ctx, true); if let EditorEvent::Enter = event { - ctx.emit(AISettingsPageEvent::FocusModal); + ctx.emit(AISettingsPageEvent::FocusSearch); } ctx.notify(); } - EditorEvent::Escape => ctx.emit(AISettingsPageEvent::FocusModal), + EditorEvent::Escape => ctx.emit(AISettingsPageEvent::FocusSearch), _ => {} } } @@ -2387,7 +2359,7 @@ impl AISettingsPageView { } }) } - EditorEvent::Escape => ctx.emit(AISettingsPageEvent::FocusModal), + EditorEvent::Escape => ctx.emit(AISettingsPageEvent::FocusSearch), _ => {} } } @@ -2930,7 +2902,7 @@ impl View for AISettingsPageView { #[allow(clippy::large_enum_variant)] pub enum AISettingsPageEvent { - FocusModal, + FocusSearch, OpenAIFactCollection, OpenMCPServerCollection, #[cfg(feature = "local_fs")] @@ -2939,8 +2911,6 @@ pub enum AISettingsPageEvent { OpenCustomRouterFile(PathBuf), OpenExecutionProfileEditor(ClientProfileId), SignupAnonymousUser, - ShowModal, - HideModal, } impl Entity for AISettingsPageView { @@ -3013,14 +2983,12 @@ pub enum AISettingsPageAction { CopyChatGPTDeviceCode, ToggleAcpEnabled, FetchOpenAIProviderModels(usize), - AddOpenAIProvider, + AddOpenAIProvider(ProviderSetupProviderType), EditOpenAIProvider(usize), ToggleOpenAIProviderEnabled(usize), RemoveOpenAIProvider(usize), EditBedrockProvider, RemoveBedrockProvider, - EditAcpProvider, - RemoveAcpProvider, ToggleFileBasedMcp, ToggleIncludeAgentCommandsInHistory, ToggleAgentAttribution, @@ -3752,11 +3720,11 @@ impl TypedActionView for AISettingsPageView { AISettingsPageAction::FetchOpenAIProviderModels(provider_index) => { self.fetch_openai_provider_models(*provider_index, ctx); } - AISettingsPageAction::AddOpenAIProvider => { - self.open_provider_setup_modal(None, ctx); + AISettingsPageAction::AddOpenAIProvider(provider_type) => { + self.begin_inline_provider_create(*provider_type, ctx); } AISettingsPageAction::EditOpenAIProvider(provider_index) => { - self.open_provider_setup_modal(Some(*provider_index), ctx); + self.begin_inline_provider_edit(*provider_index, ctx); } AISettingsPageAction::ToggleOpenAIProviderEnabled(provider_index) => { AISettings::handle(ctx).update(ctx, |settings, ctx| { @@ -3778,7 +3746,7 @@ impl TypedActionView for AISettingsPageView { self.rebuild_active_subpage(ctx); } AISettingsPageAction::EditBedrockProvider => { - self.open_bedrock_setup_modal(ctx); + self.begin_inline_bedrock_setup(ctx); } AISettingsPageAction::RemoveBedrockProvider => { AISettings::handle(ctx).update(ctx, |settings, ctx| { @@ -3806,22 +3774,6 @@ impl TypedActionView for AISettingsPageView { }); self.rebuild_active_subpage(ctx); } - AISettingsPageAction::EditAcpProvider => { - self.open_acp_setup_modal(ctx); - } - AISettingsPageAction::RemoveAcpProvider => { - AISettings::handle(ctx).update(ctx, |settings, ctx| { - report_if_error!(settings.acp_enabled.set_value(false, ctx)); - report_if_error!(settings.acp_agent_id.set_value("codex".to_string(), ctx)); - report_if_error!(settings.acp_agent_command.set_value(String::new(), ctx)); - report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx)); - report_if_error!(settings - .acp_connection_name - .set_value("ACP agent runtime".to_string(), ctx,)); - report_if_error!(settings.acp_agents.set_value(Vec::new(), ctx)); - }); - self.rebuild_active_subpage(ctx); - } AISettingsPageAction::ToggleFileBasedMcp => { AISettings::handle(ctx).update(ctx, |settings, ctx| { report_if_error!(settings.file_based_mcp_enabled.toggle_and_save_value(ctx)); @@ -7130,7 +7082,31 @@ impl SettingsWidget for ModelsOverviewWidget { app: &AppContext, ) -> Box { let settings = AISettings::as_ref(app); - let endpoint_count = settings.openai_providers.value().len(); + let openai_count = settings + .openai_providers + .value() + .iter() + .filter(|provider| { + ModelProviderSection::for_provider(provider) == ModelProviderSection::OpenAI + }) + .count(); + let litellm_count = settings + .openai_providers + .value() + .iter() + .filter(|provider| { + ModelProviderSection::for_provider(provider) == ModelProviderSection::LiteLLM + }) + .count(); + let chatgpt_count = settings + .openai_providers + .value() + .iter() + .filter(|provider| { + ModelProviderSection::for_provider(provider) + == ModelProviderSection::ChatGPTSubscription + }) + .count(); let endpoint_model_count = settings .openai_providers .value() @@ -7144,13 +7120,13 @@ impl SettingsWidget for ModelsOverviewWidget { .with_spacing(8.) .with_child(build_sub_header(appearance, "Models", None).finish()) .with_child(render_ai_setting_description( - "Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI-compatible, Anthropic, Gemini, Vertex AI, and Bedrock models run through Rig. ACP coding agents use the same Galaxy runtime boundary while retaining their own model, login, session, and tool loop.", + "Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI, LiteLLM, ChatGPT subscription, and Bedrock connections are configured here. ACP coding agents are shown read-only because the agent runtime controls their model, login, session, and tool loop.", true, app, )) .with_child(render_ai_setting_description( format!( - "{endpoint_count} configured provider(s) with {endpoint_model_count} model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled agent runtime(s)." + "{openai_count} OpenAI provider(s); {litellm_count} LiteLLM provider(s); {chatgpt_count} ChatGPT subscription provider(s); {endpoint_model_count} direct model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled ACP runtime(s)." ), true, app, @@ -7159,26 +7135,51 @@ impl SettingsWidget for ModelsOverviewWidget { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModelProviderSection { + OpenAI, + LiteLLM, + ChatGPTSubscription, +} + +impl ModelProviderSection { + fn for_provider(provider: &OpenAIProviderConfig) -> Self { + match provider.kind { + OpenAIProviderKind::OpenAI + | OpenAIProviderKind::Anthropic + | OpenAIProviderKind::Gemini + | OpenAIProviderKind::VertexAI => Self::OpenAI, + OpenAIProviderKind::LiteLLM => Self::LiteLLM, + OpenAIProviderKind::ChatGPTSubscription => Self::ChatGPTSubscription, + } + } +} + struct OpenAIProviderCardState { enabled_switch: SwitchStateHandle, edit_button: ViewHandle, remove_button: ViewHandle, } -struct OpenAIProviderSettingsWidget { +struct ProviderSettingsWidget { + provider_type: ProviderSetupProviderType, enabled_toggle: SwitchStateHandle, bedrock_enabled_toggle: SwitchStateHandle, - acp_enabled_toggle: SwitchStateHandle, + add_openai_provider_button: ViewHandle, + add_litellm_provider_button: ViewHandle, + add_chatgpt_provider_button: ViewHandle, + bedrock_add_button: ViewHandle, + acp_add_button: ViewHandle, bedrock_edit_button: ViewHandle, bedrock_remove_button: ViewHandle, - acp_edit_button: ViewHandle, - acp_remove_button: ViewHandle, - add_provider_button: ViewHandle, provider_cards: Vec, } -impl OpenAIProviderSettingsWidget { - fn new(ctx: &mut ViewContext<::View>) -> Self { +impl ProviderSettingsWidget { + fn new( + ctx: &mut ViewContext<::View>, + provider_type: ProviderSetupProviderType, + ) -> Self { let providers = AISettings::as_ref(ctx).openai_providers.value().clone(); let provider_cards = providers .iter() @@ -7199,11 +7200,47 @@ impl OpenAIProviderSettingsWidget { }), }) .collect(); - let add_provider_button = ctx.add_typed_action_view(|_| { + let add_openai_provider_button = ctx.add_typed_action_view(|_| { ActionButton::new("Add provider", SecondaryTheme) .with_icon(Icon::Plus) .on_click(|ctx| { - ctx.dispatch_typed_action(AISettingsPageAction::AddOpenAIProvider); + ctx.dispatch_typed_action(AISettingsPageAction::AddOpenAIProvider( + ProviderSetupProviderType::OpenAI, + )); + }) + }); + let add_litellm_provider_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Add provider", SecondaryTheme) + .with_icon(Icon::Plus) + .on_click(|ctx| { + ctx.dispatch_typed_action(AISettingsPageAction::AddOpenAIProvider( + ProviderSetupProviderType::LiteLLM, + )); + }) + }); + let add_chatgpt_provider_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Add provider", SecondaryTheme) + .with_icon(Icon::Plus) + .on_click(|ctx| { + ctx.dispatch_typed_action(AISettingsPageAction::AddOpenAIProvider( + ProviderSetupProviderType::ChatGPTSubscription, + )); + }) + }); + let bedrock_add_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Add provider", SecondaryTheme) + .with_icon(Icon::Plus) + .on_click(|ctx| { + ctx.dispatch_typed_action(AISettingsPageAction::EditBedrockProvider); + }) + }); + let acp_add_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Add provider", SecondaryTheme) + .with_icon(Icon::Plus) + .on_click(|ctx| { + ctx.dispatch_typed_action(AISettingsPageAction::AddOpenAIProvider( + ProviderSetupProviderType::Acp, + )); }) }); let bedrock_edit_button = ctx.add_typed_action_view(|_| { @@ -7216,26 +7253,18 @@ impl OpenAIProviderSettingsWidget { ctx.dispatch_typed_action(AISettingsPageAction::RemoveBedrockProvider); }) }); - let acp_edit_button = ctx.add_typed_action_view(|_| { - ActionButton::new("Edit", SecondaryTheme).on_click(|ctx| { - ctx.dispatch_typed_action(AISettingsPageAction::EditAcpProvider); - }) - }); - let acp_remove_button = ctx.add_typed_action_view(|_| { - ActionButton::new("Delete", DangerSecondaryTheme).on_click(|ctx| { - ctx.dispatch_typed_action(AISettingsPageAction::RemoveAcpProvider); - }) - }); Self { + provider_type, enabled_toggle: SwitchStateHandle::default(), bedrock_enabled_toggle: SwitchStateHandle::default(), - acp_enabled_toggle: SwitchStateHandle::default(), + add_openai_provider_button, + add_litellm_provider_button, + add_chatgpt_provider_button, + bedrock_add_button, + acp_add_button, bedrock_edit_button, bedrock_remove_button, - acp_edit_button, - acp_remove_button, - add_provider_button, provider_cards, } } @@ -7258,7 +7287,8 @@ impl OpenAIProviderSettingsWidget { fn provider_type(provider: &OpenAIProviderConfig) -> &'static str { match provider.kind { - OpenAIProviderKind::OpenAICompatible => "OpenAI-compatible API", + OpenAIProviderKind::OpenAI => "OpenAI", + OpenAIProviderKind::LiteLLM => "LiteLLM", OpenAIProviderKind::ChatGPTSubscription => "ChatGPT subscription", OpenAIProviderKind::Anthropic => "Anthropic", OpenAIProviderKind::Gemini => "Google Gemini", @@ -7266,6 +7296,197 @@ impl OpenAIProviderSettingsWidget { } } + fn format_openai_model_details(model: &OpenAIModelConfig) -> String { + let mut details = vec![ + if model.enabled { "Enabled" } else { "Disabled" }.to_string(), + format!("Context: {}", model.context_size), + ]; + + if let Some(max_input_tokens) = model.max_input_tokens { + details.push(format!("Max input: {max_input_tokens}")); + } + if let Some(max_output_tokens) = model.max_output_tokens { + details.push(format!("Max output: {max_output_tokens}")); + } + if model.effective_vision_supported() { + details.push("Images".to_string()); + } + if model.use_rig { + details.push("Rig".to_string()); + } + if !model.reasoning_efforts.is_empty() { + details.push(format!("Reasoning: {}", model.reasoning_efforts.join(", "))); + } + if !model.capability_overrides.is_empty() { + let overrides = model + .capability_overrides + .iter() + .sorted_by_key(|(key, _)| key.as_str()) + .map(|(key, value)| format!("{}: {}", key.replace('_', " "), value.label())) + .join(", "); + details.push(format!("Overrides: {overrides}")); + } + + details.join(" · ") + } + + fn render_model_row( + display_name: String, + model_id: String, + details: String, + appearance: &Appearance, + ) -> Box { + Container::new( + Flex::column() + .with_spacing(3.) + .with_child( + Text::new(display_name, appearance.ui_font_family(), CONTENT_FONT_SIZE) + .with_color(appearance.theme().active_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Semibold)) + .finish(), + ) + .with_child( + Text::new(model_id, appearance.monospace_font_family(), 10.) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ) + .with_child( + Text::new(details, appearance.ui_font_family(), 11.) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ) + .finish(), + ) + .with_padding(Padding::uniform(10.)) + .with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline())) + .finish() + } + + fn render_model_catalog( + rows: Vec>, + empty_message: &'static str, + appearance: &Appearance, + ) -> Box { + if rows.is_empty() { + return Text::new( + empty_message, + appearance.ui_font_family(), + CONTENT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(); + } + + Container::new(Flex::column().with_children(rows).finish()) + .with_background(appearance.theme().surface_2()) + .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) + .finish() + } + + fn render_openai_model_catalog( + provider: &OpenAIProviderConfig, + appearance: &Appearance, + ) -> Box { + let rows = provider + .models + .iter() + .map(|model| { + Self::render_model_row( + model.display_name.clone(), + model.model_id.clone(), + Self::format_openai_model_details(model), + appearance, + ) + }) + .collect::>(); + Self::render_model_catalog( + rows, + "No models configured for this connection.", + appearance, + ) + } + + fn render_bedrock_model_catalog( + models: &[BedrockModelConfig], + appearance: &Appearance, + ) -> Box { + let rows = models + .iter() + .map(|model| { + let mut details = Vec::new(); + if model.vision_supported { + details.push("Images".to_string()); + } + if model.use_rig { + details.push("Rig".to_string()); + } + Self::render_model_row( + model.display_name.clone(), + model.model_id.clone(), + if details.is_empty() { + "Available".to_string() + } else { + details.join(" · ") + }, + appearance, + ) + }) + .collect::>(); + Self::render_model_catalog(rows, "No Bedrock models discovered yet.", appearance) + } + + fn render_acp_model_catalog(appearance: &Appearance, app: &AppContext) -> Box { + let settings = AISettings::as_ref(app); + let selected_agent_id = settings.acp_agent_id.value(); + let Some(agent) = settings + .acp_agents + .value() + .iter() + .find(|agent| agent.id.eq_ignore_ascii_case(selected_agent_id)) + else { + return Text::new( + "No ACP model or mode catalog has been discovered yet.", + appearance.ui_font_family(), + CONTENT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(); + }; + + let rows = agent + .config_options + .iter() + .filter(|option| { + matches!( + option.category.as_deref(), + Some("model") | Some("thought_level") | Some("mode") + ) + }) + .map(|option| { + let details = if option.options.is_empty() { + option.current_value.to_string() + } else { + option + .options + .iter() + .map(|value| value.name.as_str()) + .join(", ") + }; + Self::render_model_row(option.name.clone(), option.id.clone(), details, appearance) + }) + .collect::>(); + Self::render_model_catalog( + rows, + "No ACP model or mode catalog has been discovered yet.", + appearance, + ) + } + fn render_provider_card( &self, provider_index: usize, @@ -7356,6 +7577,7 @@ impl OpenAIProviderSettingsWidget { .with_child(header) .with_child(summary) .with_child(enablement) + .with_child(Self::render_openai_model_catalog(provider, appearance)) .finish(), ) .with_padding(Padding::uniform(16.)) @@ -7365,24 +7587,22 @@ impl OpenAIProviderSettingsWidget { .finish() } - #[allow(clippy::too_many_arguments)] - fn render_builtin_provider_card( + fn render_bedrock_provider_card( &self, title: &str, description: &'static str, enabled: bool, - toggle: SwitchStateHandle, - action: AISettingsPageAction, - edit_button: &ViewHandle, - remove_button: &ViewHandle, appearance: &Appearance, + app: &AppContext, ) -> Box { let toggle = appearance .ui_builder() - .switch(toggle) + .switch(self.bedrock_enabled_toggle.clone()) .check(enabled) .build() - .on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone())) + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(AISettingsPageAction::ToggleBedrockEnabled); + }) .finish(); let header = Flex::row() .with_main_axis_size(MainAxisSize::Max) @@ -7412,8 +7632,8 @@ impl OpenAIProviderSettingsWidget { .with_child( Flex::row() .with_spacing(8.) - .with_child(ChildView::new(edit_button).finish()) - .with_child(ChildView::new(remove_button).finish()) + .with_child(ChildView::new(&self.bedrock_edit_button).finish()) + .with_child(ChildView::new(&self.bedrock_remove_button).finish()) .finish(), ) .finish(); @@ -7437,6 +7657,10 @@ impl OpenAIProviderSettingsWidget { ) .finish(), ) + .with_child(Self::render_bedrock_model_catalog( + AISettings::as_ref(app).bedrock_models.value(), + appearance, + )) .finish(), ) .with_padding(Padding::uniform(16.)) @@ -7445,13 +7669,210 @@ impl OpenAIProviderSettingsWidget { .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .finish() } + + fn render_acp_provider_card( + &self, + title: &str, + description: &'static str, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + let settings = AISettings::as_ref(app); + let status = format!("Read-only · Agent: {}", settings.acp_agent_id.value()); + let header = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + Flex::column() + .with_spacing(4.) + .with_child( + Text::new( + title.to_string(), + appearance.ui_font_family(), + appearance.header_font_size(), + ) + .with_color(appearance.theme().active_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Bold)) + .finish(), + ) + .with_child( + Text::new(description, appearance.ui_font_family(), CONTENT_FONT_SIZE) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ) + .finish(), + ) + .with_child( + Text::new(status, appearance.ui_font_family(), CONTENT_FONT_SIZE) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .finish(), + ) + .finish(); + + Container::new( + Flex::column() + .with_spacing(12.) + .with_child(header) + .with_child(Self::render_acp_model_catalog(appearance, app)) + .finish(), + ) + .with_padding(Padding::uniform(16.)) + .with_background(appearance.theme().surface_1()) + .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) + .finish() + } + + fn render_inline_setup(appearance: &Appearance, view: &AISettingsPageView) -> Box { + Container::new(ChildView::new(&view.provider_setup_body).finish()) + .with_padding(Padding::uniform(16.)) + .with_background(appearance.theme().surface_1()) + .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) + .finish() + } + + fn model_section_for_provider_type( + provider_type: ProviderSetupProviderType, + ) -> Option { + match provider_type { + ProviderSetupProviderType::OpenAI => Some(ModelProviderSection::OpenAI), + ProviderSetupProviderType::LiteLLM => Some(ModelProviderSection::LiteLLM), + ProviderSetupProviderType::ChatGPTSubscription => { + Some(ModelProviderSection::ChatGPTSubscription) + } + ProviderSetupProviderType::Anthropic + | ProviderSetupProviderType::Gemini + | ProviderSetupProviderType::VertexAI + | ProviderSetupProviderType::Bedrock + | ProviderSetupProviderType::Acp => None, + } + } + + fn provider_page_title(provider_type: ProviderSetupProviderType) -> &'static str { + match provider_type { + ProviderSetupProviderType::OpenAI => "OpenAI", + ProviderSetupProviderType::LiteLLM => "LiteLLM", + ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT Subscription", + ProviderSetupProviderType::Bedrock => "Bedrock", + ProviderSetupProviderType::Acp => "ACP", + ProviderSetupProviderType::Anthropic => "Anthropic", + ProviderSetupProviderType::Gemini => "Google Gemini", + ProviderSetupProviderType::VertexAI => "Google Vertex AI", + } + } + + fn provider_page_description(provider_type: ProviderSetupProviderType) -> &'static str { + match provider_type { + ProviderSetupProviderType::OpenAI => { + "Connect directly to OpenAI-compatible OpenAI API endpoints and configure the models available from each connection." + } + ProviderSetupProviderType::LiteLLM => { + "Connect LiteLLM endpoints and use LiteLLM model metadata APIs during model discovery." + } + ProviderSetupProviderType::ChatGPTSubscription => { + "Use your ChatGPT subscription through the supported authentication flow." + } + ProviderSetupProviderType::Bedrock => { + "Use AWS Bedrock credentials and discovered Bedrock foundation models." + } + ProviderSetupProviderType::Acp => { + "Add an ACP agent connection here. After it is added, Settings shows it read-only because the agent controls its model, login, session, and tool loop." + } + ProviderSetupProviderType::Anthropic => "Connect directly to Anthropic.", + ProviderSetupProviderType::Gemini => "Connect directly to Google Gemini.", + ProviderSetupProviderType::VertexAI => "Connect directly to Google Vertex AI.", + } + } + + fn empty_message_for_type(provider_type: ProviderSetupProviderType) -> &'static str { + match provider_type { + ProviderSetupProviderType::OpenAI => "No OpenAI provider configured.", + ProviderSetupProviderType::LiteLLM => "No LiteLLM provider configured.", + ProviderSetupProviderType::ChatGPTSubscription => { + "No ChatGPT subscription provider configured." + } + ProviderSetupProviderType::Bedrock => "No Bedrock provider configured.", + ProviderSetupProviderType::Acp => "No ACP agent connection configured.", + ProviderSetupProviderType::Anthropic => "No Anthropic provider configured.", + ProviderSetupProviderType::Gemini => "No Google Gemini provider configured.", + ProviderSetupProviderType::VertexAI => "No Google Vertex AI provider configured.", + } + } + + fn add_button_for_type( + &self, + provider_type: ProviderSetupProviderType, + ) -> Option<&ViewHandle> { + match provider_type { + ProviderSetupProviderType::OpenAI => Some(&self.add_openai_provider_button), + ProviderSetupProviderType::LiteLLM => Some(&self.add_litellm_provider_button), + ProviderSetupProviderType::ChatGPTSubscription => { + Some(&self.add_chatgpt_provider_button) + } + ProviderSetupProviderType::Bedrock => Some(&self.bedrock_add_button), + ProviderSetupProviderType::Acp => Some(&self.acp_add_button), + ProviderSetupProviderType::Anthropic + | ProviderSetupProviderType::Gemini + | ProviderSetupProviderType::VertexAI => None, + } + } + + fn render_provider_section( + title: &'static str, + description: &'static str, + add_button: Option<&ViewHandle>, + cards: Vec>, + empty_message: &'static str, + appearance: &Appearance, + ) -> Box { + let mut header = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(build_sub_header(appearance, title, None).finish()); + if let Some(button) = add_button { + header = header.with_child(ChildView::new(button).finish()); + } + + let mut section = Flex::column() + .with_spacing(10.) + .with_child(header.finish()) + .with_child( + Text::new(description, appearance.ui_font_family(), CONTENT_FONT_SIZE) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ); + + if cards.is_empty() { + section = section.with_child( + Text::new( + empty_message, + appearance.ui_font_family(), + CONTENT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ); + } else { + for card in cards { + section.add_child(card); + } + } + + section.finish() + } } -impl SettingsWidget for OpenAIProviderSettingsWidget { +impl SettingsWidget for ProviderSettingsWidget { type View = AISettingsPageView; fn search_terms(&self) -> &str { - "openai chatgpt pro subscription litellm custom provider endpoint api key models" + "openai chatgpt pro subscription litellm custom provider endpoint api key models acp agent client protocol" } fn should_render(&self, _app: &AppContext) -> bool { @@ -7460,84 +7881,135 @@ impl SettingsWidget for OpenAIProviderSettingsWidget { fn render( &self, - _view: &Self::View, + view: &Self::View, appearance: &Appearance, app: &AppContext, ) -> Box { let settings = AISettings::as_ref(app); - let is_enabled = *settings.openai_enabled.value(); let providers = settings.openai_providers.value(); - let mut column = Flex::column().with_spacing(16.); - column.add_child( - Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child(build_sub_header(appearance, "Model providers", None).finish()) - .with_child(ChildView::new(&self.add_provider_button).finish()) - .finish(), - ); - column.add_child(render_ai_setting_toggle::( - "Enable model providers", - AISettingsPageAction::ToggleOpenAIEnabled, - is_enabled, - true, - self.enabled_toggle.clone(), - &RefCell::new(HashMap::new()), - app, - )); - column.add_child(render_ai_setting_description( - "Connect a ChatGPT subscription, OpenAI-compatible endpoint, Anthropic, Gemini, Vertex AI, AWS Bedrock account, or ACP agent runtime. Each provider can be enabled independently.", - true, - app, - )); + let mut column = Flex::column().with_spacing(18.); - let has_configured_builtin_provider = !settings.bedrock_models.value().is_empty() - || (cfg!(unix) - && FeatureFlag::AgentClientProtocol.is_enabled() - && *settings.acp_enabled.value()); + let is_setup_visible = view + .inline_provider_setup + .is_some_and(|state| state.provider_type == self.provider_type); - if !settings.bedrock_models.value().is_empty() { - column.add_child(self.render_builtin_provider_card( - settings.bedrock_connection_name.value().as_str(), - "Use AWS credentials to access Bedrock foundation models directly.", - *settings.bedrock_enabled.value(), - self.bedrock_enabled_toggle.clone(), - AISettingsPageAction::ToggleBedrockEnabled, - &self.bedrock_edit_button, - &self.bedrock_remove_button, - appearance, - )); - } - if cfg!(unix) - && FeatureFlag::AgentClientProtocol.is_enabled() - && *settings.acp_enabled.value() - { - column.add_child(self.render_builtin_provider_card( - settings.acp_connection_name.value().as_str(), - "Use a local session-oriented agent that owns its model and authentication.", - *settings.acp_enabled.value(), - self.acp_enabled_toggle.clone(), - AISettingsPageAction::ToggleAcpEnabled, - &self.acp_edit_button, - &self.acp_remove_button, - appearance, - )); - } + match self.provider_type { + ProviderSetupProviderType::OpenAI + | ProviderSetupProviderType::LiteLLM + | ProviderSetupProviderType::ChatGPTSubscription => { + column.add_child(render_ai_setting_toggle::( + "Enable direct providers", + AISettingsPageAction::ToggleOpenAIEnabled, + *settings.openai_enabled.value(), + true, + self.enabled_toggle.clone(), + &RefCell::new(HashMap::new()), + app, + )); - if providers.is_empty() && !has_configured_builtin_provider { - column.add_child(render_ai_setting_description( - "No providers configured. Select Add provider to get started.", - is_enabled, - app, - )); - } else { - for (index, provider) in providers.iter().enumerate() { - column.add_child(Self::render_provider_card( - self, index, provider, appearance, + if is_setup_visible { + column.add_child(Self::render_inline_setup(appearance, view)); + } + + let cards = Self::model_section_for_provider_type(self.provider_type) + .map(|section| { + providers + .iter() + .enumerate() + .filter(|(_, provider)| { + ModelProviderSection::for_provider(provider) == section + }) + .map(|(index, provider)| { + self.render_provider_card(index, provider, appearance) + }) + .collect::>() + }) + .unwrap_or_default(); + + column.add_child(Self::render_provider_section( + Self::provider_page_title(self.provider_type), + Self::provider_page_description(self.provider_type), + if is_setup_visible { + None + } else { + self.add_button_for_type(self.provider_type) + }, + cards, + Self::empty_message_for_type(self.provider_type), + appearance, )); } + ProviderSetupProviderType::Bedrock => { + if is_setup_visible { + column.add_child(Self::render_inline_setup(appearance, view)); + } + + let bedrock_cards = if settings.bedrock_models.value().is_empty() { + Vec::new() + } else { + vec![self.render_bedrock_provider_card( + settings.bedrock_connection_name.value().as_str(), + "Use AWS credentials to access Bedrock foundation models directly.", + *settings.bedrock_enabled.value(), + appearance, + app, + )] + }; + let bedrock_add_button = + if !is_setup_visible && settings.bedrock_models.value().is_empty() { + Some(&self.bedrock_add_button) + } else { + None + }; + column.add_child(Self::render_provider_section( + "Bedrock", + "Use AWS Bedrock credentials and discovered Bedrock foundation models.", + bedrock_add_button, + bedrock_cards, + "No Bedrock provider configured.", + appearance, + )); + } + ProviderSetupProviderType::Acp => { + if is_setup_visible { + column.add_child(Self::render_inline_setup(appearance, view)); + } + + let acp_supported = cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled(); + let acp_cards = if acp_supported && *settings.acp_enabled.value() { + vec![self.render_acp_provider_card( + settings.acp_connection_name.value().as_str(), + "Use a local session-oriented agent that owns its model, login, session, and tool loop.", + appearance, + app, + )] + } else { + Vec::new() + }; + let acp_add_button = + if acp_supported && !is_setup_visible && !*settings.acp_enabled.value() { + Some(&self.acp_add_button) + } else { + None + }; + let empty_message = if acp_supported { + "No ACP agent connection configured." + } else { + "ACP providers are not available on this platform." + }; + column.add_child(Self::render_provider_section( + "ACP", + "Add an ACP agent connection here. After it is added, Settings shows it read-only because the agent controls its model, login, session, and tool loop.", + acp_add_button, + acp_cards, + empty_message, + appearance, + )); + } + ProviderSetupProviderType::Anthropic + | ProviderSetupProviderType::Gemini + | ProviderSetupProviderType::VertexAI => {} } column.finish() diff --git a/app/src/settings_view/mod.rs b/app/src/settings_view/mod.rs index 31b07e90..75bffd57 100644 --- a/app/src/settings_view/mod.rs +++ b/app/src/settings_view/mod.rs @@ -86,9 +86,8 @@ mod platform; mod platform_page; mod privacy; mod privacy_page; -mod provider_setup_modal; +mod provider_setup_view; mod scripting_page; -mod set_default_model_modal; mod settings_file_footer; pub(crate) mod settings_page; mod tab_menu; @@ -248,6 +247,12 @@ pub enum SettingsSection { ThirdPartyCLIAgents, Models, Experiments, + // ── Providers umbrella subpages ── + ProviderOpenAI, + ProviderLiteLLM, + ProviderChatGPTSubscription, + ProviderBedrock, + ProviderACP, /// Internal backing-page identifier for CodeSettingsPageView. Multiple subpages /// (CodeIndexing, EditorAndCodeReview) share this single backing page, /// so this variant is needed as the key in `settings_pages`. @@ -276,6 +281,11 @@ impl Display for SettingsSection { SettingsSection::ThirdPartyCLIAgents => write!(f, "Third party CLI agents"), SettingsSection::Models => write!(f, "Models"), SettingsSection::Experiments => write!(f, "Experiments"), + SettingsSection::ProviderOpenAI => write!(f, "OpenAI"), + SettingsSection::ProviderLiteLLM => write!(f, "LiteLLM"), + SettingsSection::ProviderChatGPTSubscription => write!(f, "ChatGPT Subscription"), + SettingsSection::ProviderBedrock => write!(f, "Bedrock"), + SettingsSection::ProviderACP => write!(f, "ACP"), SettingsSection::Warpify => write!(f, "Wormhole"), SettingsSection::CodeIndexing => write!(f, "Indexing and projects"), SettingsSection::EditorAndCodeReview => write!(f, "Editor and Code Review"), @@ -287,7 +297,7 @@ impl Display for SettingsSection { impl SettingsSection { /// Returns true if this section is a subpage under any umbrella. pub fn is_subpage(&self) -> bool { - self.is_ai_subpage() || self.is_code_subpage() + self.is_ai_subpage() || self.is_provider_subpage() || self.is_code_subpage() } /// Returns true if this section is a subpage under the "Agents" umbrella. @@ -304,6 +314,36 @@ impl SettingsSection { ) } + /// Returns true if this section is a subpage under the "Providers" umbrella. + pub fn is_provider_subpage(&self) -> bool { + matches!( + self, + Self::ProviderOpenAI + | Self::ProviderLiteLLM + | Self::ProviderChatGPTSubscription + | Self::ProviderBedrock + | Self::ProviderACP + ) + } + + /// Returns true if this section renders through the AI settings backing page. + pub fn is_ai_backed_subpage(&self) -> bool { + matches!( + self, + Self::WarpAgent + | Self::AgentProfiles + | Self::Knowledge + | Self::ThirdPartyCLIAgents + | Self::Models + | Self::Experiments + | Self::ProviderOpenAI + | Self::ProviderLiteLLM + | Self::ProviderChatGPTSubscription + | Self::ProviderBedrock + | Self::ProviderACP + ) + } + /// Returns true if this section is a subpage under the "Code" umbrella. pub fn is_code_subpage(&self) -> bool { matches!(self, Self::CodeIndexing | Self::EditorAndCodeReview) @@ -315,8 +355,8 @@ impl SettingsSection { match self { // AgentMCPServers renders the standalone MCPServers page directly. Self::AgentMCPServers => Self::MCPServers, - // All other AI subpages render within the AI page. - s if s.is_ai_subpage() => Self::AI, + // AI and provider subpages render within the AI page. + s if s.is_ai_backed_subpage() => Self::AI, // Code subpages render within the Code page. s if s.is_code_subpage() => Self::Code, other => *other, @@ -327,7 +367,6 @@ impl SettingsSection { pub fn ai_subpages() -> &'static [Self] { &[ Self::WarpAgent, - Self::Models, Self::AgentProfiles, Self::AgentMCPServers, Self::Knowledge, @@ -336,6 +375,17 @@ impl SettingsSection { ] } + /// The ordered list of provider subpage sections shown under the Providers umbrella. + pub fn provider_subpages() -> &'static [Self] { + &[ + Self::ProviderOpenAI, + Self::ProviderLiteLLM, + Self::ProviderChatGPTSubscription, + Self::ProviderBedrock, + Self::ProviderACP, + ] + } + /// The ordered list of Code subpage sections shown under the Code umbrella. pub fn code_subpages() -> &'static [Self] { &[Self::CodeIndexing, Self::EditorAndCodeReview] @@ -364,14 +414,12 @@ impl FromStr for SettingsSection { "MCP servers" | "AgentMCPServers" => Ok(Self::AgentMCPServers), "Knowledge" => Ok(Self::Knowledge), "Third party CLI agents" | "ThirdPartyCLIAgents" => Ok(Self::ThirdPartyCLIAgents), - "Models" - | "AWS Bedrock" - | "Bedrock" - | "OpenAI / LiteLLM" - | "OpenAI" - | "Agent runtimes" - | "Agent Client Protocol" - | "ACP" => Ok(Self::Models), + "Models" | "Agent runtimes" => Ok(Self::ProviderOpenAI), + "AWS Bedrock" | "Bedrock" => Ok(Self::ProviderBedrock), + "OpenAI / LiteLLM" | "LiteLLM" => Ok(Self::ProviderLiteLLM), + "OpenAI" => Ok(Self::ProviderOpenAI), + "ChatGPT Subscription" | "ChatGPTSubscription" => Ok(Self::ProviderChatGPTSubscription), + "Agent Client Protocol" | "ACP" => Ok(Self::ProviderACP), "Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing), "Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview), "Experiments" => Ok(Self::Experiments), @@ -1235,6 +1283,10 @@ impl SettingsView { "Agents", SettingsSection::ai_subpages().to_vec(), )), + SettingsNavItem::Umbrella(SettingsUmbrella::new( + "Providers", + SettingsSection::provider_subpages().to_vec(), + )), SettingsNavItem::Umbrella(SettingsUmbrella::new( "Code", vec![ @@ -1367,7 +1419,10 @@ impl SettingsView { // For each AI subpage, temporarily switch to that subpage's // widget set and run the filter to get a subpage-specific result. self.subpage_filter.clear(); - for &subpage_section in SettingsSection::ai_subpages() { + for &subpage_section in SettingsSection::ai_subpages() + .iter() + .chain(SettingsSection::provider_subpages().iter()) + { if subpage_section == SettingsSection::AgentMCPServers { // AgentMCPServers has its own backing page; handled below. continue; @@ -1434,7 +1489,7 @@ impl SettingsView { // Restore the active subpage after filtering. if is_search_active { let current = self.current_settings_page; - if current.is_ai_subpage() && current != SettingsSection::AgentMCPServers { + if current.is_ai_backed_subpage() { if let Some(subpage) = AISubpage::from_section(current) { self.ai_page_handle.update(ctx, |view, ctx| { view.set_active_subpage(Some(subpage), ctx); @@ -1737,7 +1792,7 @@ impl SettingsView { fn handle_ai_page_event(&mut self, event: &AISettingsPageEvent, ctx: &mut ViewContext) { match event { - AISettingsPageEvent::FocusModal => ctx.focus(&self.search_editor), + AISettingsPageEvent::FocusSearch => ctx.focus(&self.search_editor), AISettingsPageEvent::OpenAIFactCollection => { ctx.emit(SettingsViewEvent::OpenAIFactCollection) } @@ -1758,10 +1813,6 @@ impl SettingsView { AISettingsPageEvent::SignupAnonymousUser => { ctx.emit(SettingsViewEvent::SignupAnonymousUser) } - AISettingsPageEvent::ShowModal | AISettingsPageEvent::HideModal => { - // Modal rendering is handled in get_modal_content_for_page - ctx.notify(); - } } } @@ -1853,8 +1904,8 @@ impl SettingsView { // When navigating to a subpage, update the backing page's active subpage mode // and auto-expand the umbrella containing it. if section.is_subpage() { - // AI subpages: update the AI page's subpage mode. - if section.is_ai_subpage() && section != SettingsSection::AgentMCPServers { + // AI-backed subpages: update the AI page's subpage mode. + if section.is_ai_backed_subpage() { let subpage = AISubpage::from_section(section); self.ai_page_handle.update(ctx, |view, ctx| { view.set_active_subpage(subpage, ctx); @@ -2113,9 +2164,6 @@ impl SettingsView { SettingsPageViewHandle::MCPServers(view) => { view.read(app, |view, _| view.get_modal_content(app)) } - SettingsPageViewHandle::AI(view) => { - view.read(app, |view, _| view.get_modal_content(app)) - } _ => None, } } diff --git a/app/src/settings_view/mod_tests.rs b/app/src/settings_view/mod_tests.rs index 50219161..40b3a63b 100644 --- a/app/src/settings_view/mod_tests.rs +++ b/app/src/settings_view/mod_tests.rs @@ -33,6 +33,30 @@ fn ai_subpages_are_classified_and_map_to_their_backing_pages() { } } +#[test] +fn provider_subpages_are_classified_and_map_to_ai_backing_page() { + assert_eq!( + SettingsSection::provider_subpages(), + &[ + SettingsSection::ProviderOpenAI, + SettingsSection::ProviderLiteLLM, + SettingsSection::ProviderChatGPTSubscription, + SettingsSection::ProviderBedrock, + SettingsSection::ProviderACP, + ] + ); + for section in SettingsSection::provider_subpages() { + assert!(section.is_provider_subpage()); + assert!(section.is_subpage()); + assert_eq!( + section.parent_page_section(), + SettingsSection::AI, + "{section:?} should use the AI backing page" + ); + } + assert!(!SettingsSection::AI.is_provider_subpage()); +} + #[test] fn code_subpages_are_classified_and_map_to_code() { assert_eq!( @@ -86,8 +110,15 @@ fn current_settings_display_names_round_trip() { SettingsSection::ThirdPartyCLIAgents, "Third party CLI agents", ), - (SettingsSection::Models, "Models"), (SettingsSection::Experiments, "Experiments"), + (SettingsSection::ProviderOpenAI, "OpenAI"), + (SettingsSection::ProviderLiteLLM, "LiteLLM"), + ( + SettingsSection::ProviderChatGPTSubscription, + "ChatGPT Subscription", + ), + (SettingsSection::ProviderBedrock, "Bedrock"), + (SettingsSection::ProviderACP, "ACP"), (SettingsSection::CodeIndexing, "Indexing and projects"), ( SettingsSection::EditorAndCodeReview, @@ -110,13 +141,23 @@ fn legacy_settings_names_remain_parseable() { ("AgentProfiles", SettingsSection::AgentProfiles), ("AgentMCPServers", SettingsSection::AgentMCPServers), ("ThirdPartyCLIAgents", SettingsSection::ThirdPartyCLIAgents), - ("AWS Bedrock", SettingsSection::Models), - ("Bedrock", SettingsSection::Models), - ("OpenAI / LiteLLM", SettingsSection::Models), - ("OpenAI", SettingsSection::Models), - ("Agent runtimes", SettingsSection::Models), - ("Agent Client Protocol", SettingsSection::Models), - ("ACP", SettingsSection::Models), + ("AWS Bedrock", SettingsSection::ProviderBedrock), + ("Bedrock", SettingsSection::ProviderBedrock), + ("OpenAI / LiteLLM", SettingsSection::ProviderLiteLLM), + ("LiteLLM", SettingsSection::ProviderLiteLLM), + ("OpenAI", SettingsSection::ProviderOpenAI), + ("Models", SettingsSection::ProviderOpenAI), + ("Agent runtimes", SettingsSection::ProviderOpenAI), + ( + "ChatGPTSubscription", + SettingsSection::ProviderChatGPTSubscription, + ), + ( + "ChatGPT Subscription", + SettingsSection::ProviderChatGPTSubscription, + ), + ("Agent Client Protocol", SettingsSection::ProviderACP), + ("ACP", SettingsSection::ProviderACP), ("CodeIndexing", SettingsSection::CodeIndexing), ("EditorAndCodeReview", SettingsSection::EditorAndCodeReview), ] { @@ -139,6 +180,10 @@ fn realistic_nav_items() -> Vec { "Agents", SettingsSection::ai_subpages().to_vec(), )), + SettingsNavItem::Umbrella(SettingsUmbrella::new( + "Providers", + SettingsSection::provider_subpages().to_vec(), + )), SettingsNavItem::Umbrella(SettingsUmbrella::new( "Code", SettingsSection::code_subpages().to_vec(), @@ -166,7 +211,7 @@ fn collapsed_umbrellas_each_form_one_navigation_stop() { let nav_items = realistic_nav_items(); let stops = build_nav_stops(&nav_items, |_| true); - assert_eq!(stops.len(), 10); + assert_eq!(stops.len(), 11); assert_eq!( stops[0], NavStop::CollapsedUmbrella { @@ -179,12 +224,20 @@ fn collapsed_umbrellas_each_form_one_navigation_stop() { stops[1], NavStop::CollapsedUmbrella { nav_index: 1, + first_subpage: SettingsSection::ProviderOpenAI, + last_subpage: SettingsSection::ProviderACP, + } + ); + assert_eq!( + stops[2], + NavStop::CollapsedUmbrella { + nav_index: 2, first_subpage: SettingsSection::CodeIndexing, last_subpage: SettingsSection::EditorAndCodeReview, } ); - assert_eq!(stops[2], NavStop::Section(SettingsSection::Appearance)); - assert_eq!(stops[9], NavStop::Section(SettingsSection::Scripting)); + assert_eq!(stops[3], NavStop::Section(SettingsSection::Appearance)); + assert_eq!(stops[10], NavStop::Section(SettingsSection::Scripting)); } #[test] @@ -207,8 +260,8 @@ fn expanded_umbrella_has_one_stop_per_visible_subpage() { stops[expected_ai_sections.len()], NavStop::CollapsedUmbrella { nav_index: 1, - first_subpage: SettingsSection::CodeIndexing, - last_subpage: SettingsSection::EditorAndCodeReview, + first_subpage: SettingsSection::ProviderOpenAI, + last_subpage: SettingsSection::ProviderACP, } ); } @@ -244,6 +297,9 @@ fn umbrella_without_visible_subpages_is_skipped() { assert!(stops .iter() .any(|stop| matches!(stop, NavStop::CollapsedUmbrella { nav_index: 1, .. }))); + assert!(stops + .iter() + .any(|stop| matches!(stop, NavStop::CollapsedUmbrella { nav_index: 2, .. }))); } #[test] @@ -263,16 +319,20 @@ fn current_stop_matches_sections_and_collapsed_umbrella_children() { assert_eq!( current_stop_index(&stops, &nav_items, SettingsSection::Appearance), - Some(2) + Some(3) ); assert_eq!( current_stop_index(&stops, &nav_items, SettingsSection::Knowledge), Some(0) ); assert_eq!( - current_stop_index(&stops, &nav_items, SettingsSection::CodeIndexing), + current_stop_index(&stops, &nav_items, SettingsSection::ProviderLiteLLM), Some(1) ); + assert_eq!( + current_stop_index(&stops, &nav_items, SettingsSection::CodeIndexing), + Some(2) + ); } #[test] @@ -355,6 +415,6 @@ fn cycling_leaves_an_expanded_umbrella_after_its_last_subpage() { SettingsSection::Experiments, CycleDirection::Down, ), - SettingsSection::CodeIndexing + SettingsSection::ProviderOpenAI ); } diff --git a/app/src/settings_view/provider_setup_modal.rs b/app/src/settings_view/provider_setup_view.rs similarity index 83% rename from app/src/settings_view/provider_setup_modal.rs rename to app/src/settings_view/provider_setup_view.rs index 43400f46..44a30bad 100644 --- a/app/src/settings_view/provider_setup_modal.rs +++ b/app/src/settings_view/provider_setup_view.rs @@ -2,10 +2,11 @@ use galaxy_cli::agent::Harness; use galaxy_core::ui::theme::Fill; use galaxyui::elements::{ Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, - CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, MainAxisAlignment, MainAxisSize, - MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text, + CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, Hoverable, MainAxisAlignment, + MainAxisSize, MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text, }; use galaxyui::fonts::{Properties, Weight}; +use galaxyui::platform::Cursor; use galaxyui::text_layout::ClipConfig; use galaxyui::ui_components::button::ButtonVariant; use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; @@ -23,7 +24,6 @@ use crate::appearance::Appearance; use crate::editor::{ EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions, }; -use crate::modal::{Modal, ModalViewState}; use crate::settings::ai::{ AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride, OpenAIModelConfig, OpenAIProviderConfig, OpenAIProviderKind, @@ -33,14 +33,12 @@ use crate::view_components::action_button::{ ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme, }; -const MODAL_WIDTH: f32 = 900.; -const MODAL_HEIGHT: f32 = 700.; -const BODY_HEIGHT: f32 = 630.; +const SETUP_WIDTH: f32 = 900.; const INPUT_FONT_SIZE: f32 = 12.; const MODEL_LOGO_SIZE: f32 = 20.; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ProviderSetupStep { +pub(crate) enum ProviderSetupStep { ProviderType, Configure, Discover, @@ -49,8 +47,9 @@ enum ProviderSetupStep { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ProviderSetupProviderType { + OpenAI, + LiteLLM, ChatGPTSubscription, - OpenAICompatible, Anthropic, Gemini, VertexAI, @@ -59,41 +58,26 @@ pub enum ProviderSetupProviderType { } const PROVIDER_TYPE_OPTIONS: &[(ProviderSetupProviderType, &str, &str)] = &[ + ( + ProviderSetupProviderType::OpenAI, + "OpenAI", + "Connect directly to OpenAI's API with an API key.", + ), + ( + ProviderSetupProviderType::LiteLLM, + "LiteLLM", + "Connect LiteLLM and use its richer model metadata APIs during discovery.", + ), ( ProviderSetupProviderType::ChatGPTSubscription, "ChatGPT subscription", "Use your ChatGPT Plus or Pro subscription with native OAuth.", ), - ( - ProviderSetupProviderType::OpenAICompatible, - "OpenAI-compatible API", - "Connect LiteLLM, Ollama, vLLM, or another compatible endpoint.", - ), - ( - ProviderSetupProviderType::Anthropic, - "Anthropic", - "Connect directly to Anthropic's native Messages API with an API key.", - ), - ( - ProviderSetupProviderType::Gemini, - "Google Gemini", - "Connect directly to Google's Gemini API with an API key.", - ), - ( - ProviderSetupProviderType::VertexAI, - "Google Vertex AI", - "Use Google Cloud Application Default Credentials for Vertex-hosted Gemini models.", - ), ( ProviderSetupProviderType::Bedrock, "AWS Bedrock", "Use the AWS Bedrock credentials and model configuration already managed by Galaxy.", ), - ( - ProviderSetupProviderType::Acp, - "ACP agent runtime", - "Use a session-oriented ACP agent that owns its model and authentication.", - ), ]; #[derive(Clone, Debug)] @@ -165,7 +149,7 @@ impl CapabilityKey { } } -pub enum ProviderSetupModalBodyEvent { +pub enum ProviderSetupViewEvent { Close, RequestAcpDiscovery(AcpProviderDraft), SaveOpenAI { @@ -177,8 +161,9 @@ pub enum ProviderSetupModalBodyEvent { } #[derive(Clone, Debug, PartialEq)] -pub enum ProviderSetupModalBodyAction { +pub enum ProviderSetupViewAction { SelectProvider(ProviderSetupProviderType), + JumpToStep(ProviderSetupStep), Next, Back, Cancel, @@ -193,12 +178,11 @@ pub enum ProviderSetupModalBodyAction { SelectAcpAgent(String), } -pub type ProviderSetupModalState = ModalViewState>; - -pub struct ProviderSetupModalBody { +pub struct ProviderSetupView { step: ProviderSetupStep, editing_index: Option, provider_type: ProviderSetupProviderType, + provider_type_locked: bool, draft_name: String, draft_base_url: String, draft_api_key: Option, @@ -233,12 +217,13 @@ pub struct ProviderSetupModalBody { model_context_editors: Vec>, provider_type_scroll_state: ClippedScrollStateHandle, models_scroll_state: ClippedScrollStateHandle, + step_tab_mouse_states: Vec, back_button: ViewHandle, cancel_button: ViewHandle, next_button: ViewHandle, } -impl ProviderSetupModalBody { +impl ProviderSetupView { pub fn new(ctx: &mut ViewContext) -> Self { let provider_type_buttons = PROVIDER_TYPE_OPTIONS .iter() @@ -249,9 +234,9 @@ impl ProviderSetupModalBody { ActionButton::new(label, NakedTheme) .with_full_width(true) .on_click(move |ctx| { - ctx.dispatch_typed_action( - ProviderSetupModalBodyAction::SelectProvider(kind), - ); + ctx.dispatch_typed_action(ProviderSetupViewAction::SelectProvider( + kind, + )); }) }) }) @@ -281,9 +266,9 @@ impl ProviderSetupModalBody { .on_click({ let id = id.clone(); move |ctx| { - ctx.dispatch_typed_action( - ProviderSetupModalBodyAction::SelectAcpAgent(id.clone()), - ); + ctx.dispatch_typed_action(ProviderSetupViewAction::SelectAcpAgent( + id.clone(), + )); } }) }) @@ -293,7 +278,7 @@ impl ProviderSetupModalBody { ActionButton::new("Custom", NakedTheme) .with_full_width(true) .on_click(|ctx| { - ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectAcpAgent( + ctx.dispatch_typed_action(ProviderSetupViewAction::SelectAcpAgent( "custom".to_owned(), )); }) @@ -308,9 +293,7 @@ impl ProviderSetupModalBody { .map(|method| { ctx.add_typed_action_view(move |_| { ActionButton::new(method.display_name(), NakedTheme).on_click(move |ctx| { - ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectBedrockAuth( - method, - )); + ctx.dispatch_typed_action(ProviderSetupViewAction::SelectBedrockAuth(method)); }) }) }) @@ -397,24 +380,25 @@ impl ProviderSetupModalBody { let back_button = ctx.add_typed_action_view(|_| { ActionButton::new("Back", NakedTheme).on_click(|ctx| { - ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Back); + ctx.dispatch_typed_action(ProviderSetupViewAction::Back); }) }); let cancel_button = ctx.add_typed_action_view(|_| { ActionButton::new("Cancel", NakedTheme).on_click(|ctx| { - ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Cancel); + ctx.dispatch_typed_action(ProviderSetupViewAction::Cancel); }) }); let next_button = ctx.add_typed_action_view(|_| { ActionButton::new("Next", PrimaryTheme).on_click(|ctx| { - ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Next); + ctx.dispatch_typed_action(ProviderSetupViewAction::Next); }) }); Self { step: ProviderSetupStep::ProviderType, editing_index: None, - provider_type: ProviderSetupProviderType::OpenAICompatible, + provider_type: ProviderSetupProviderType::LiteLLM, + provider_type_locked: false, draft_name: String::new(), draft_base_url: String::new(), draft_api_key: None, @@ -466,6 +450,7 @@ impl ProviderSetupModalBody { model_context_editors: Vec::new(), provider_type_scroll_state: ClippedScrollStateHandle::default(), models_scroll_state: ClippedScrollStateHandle::default(), + step_tab_mouse_states: (0..4).map(|_| MouseStateHandle::default()).collect(), back_button, cancel_button, next_button, @@ -499,12 +484,43 @@ impl ProviderSetupModalBody { }) } - pub fn begin_create(&mut self, ctx: &mut ViewContext) { - self.step = ProviderSetupStep::ProviderType; + fn default_name(provider_type: ProviderSetupProviderType) -> &'static str { + match provider_type { + ProviderSetupProviderType::OpenAI => "OpenAI", + ProviderSetupProviderType::LiteLLM => "LiteLLM", + ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT Subscription", + ProviderSetupProviderType::Anthropic => "Anthropic", + ProviderSetupProviderType::Gemini => "Google Gemini", + ProviderSetupProviderType::VertexAI => "Google Vertex AI", + ProviderSetupProviderType::Bedrock => "AWS Bedrock", + ProviderSetupProviderType::Acp => "ACP agent runtime", + } + } + + fn default_base_url(provider_type: ProviderSetupProviderType) -> &'static str { + match provider_type { + ProviderSetupProviderType::OpenAI => "https://api.openai.com/v1", + ProviderSetupProviderType::LiteLLM + | ProviderSetupProviderType::ChatGPTSubscription + | ProviderSetupProviderType::Anthropic + | ProviderSetupProviderType::Gemini + | ProviderSetupProviderType::VertexAI + | ProviderSetupProviderType::Bedrock + | ProviderSetupProviderType::Acp => "", + } + } + + pub fn begin_create( + &mut self, + provider_type: ProviderSetupProviderType, + ctx: &mut ViewContext, + ) { + self.step = ProviderSetupStep::Configure; self.editing_index = None; - self.provider_type = ProviderSetupProviderType::OpenAICompatible; - self.draft_name.clear(); - self.draft_base_url.clear(); + self.provider_type = provider_type; + self.provider_type_locked = true; + self.draft_name = Self::default_name(provider_type).to_string(); + self.draft_base_url = Self::default_base_url(provider_type).to_string(); self.draft_api_key = None; self.draft_project_id.clear(); self.draft_location = "global".to_string(); @@ -549,11 +565,13 @@ impl ProviderSetupModalBody { // send the user through credentials or model discovery again. self.step = ProviderSetupStep::Models; self.editing_index = Some(editing_index); + self.provider_type_locked = true; self.provider_type = match provider.kind { + OpenAIProviderKind::OpenAI => ProviderSetupProviderType::OpenAI, + OpenAIProviderKind::LiteLLM => ProviderSetupProviderType::LiteLLM, OpenAIProviderKind::ChatGPTSubscription => { ProviderSetupProviderType::ChatGPTSubscription } - OpenAIProviderKind::OpenAICompatible => ProviderSetupProviderType::OpenAICompatible, OpenAIProviderKind::Anthropic => ProviderSetupProviderType::Anthropic, OpenAIProviderKind::Gemini => ProviderSetupProviderType::Gemini, OpenAIProviderKind::VertexAI => ProviderSetupProviderType::VertexAI, @@ -578,6 +596,7 @@ impl ProviderSetupModalBody { pub fn begin_edit_bedrock(&mut self, draft: BedrockProviderDraft, ctx: &mut ViewContext) { self.step = ProviderSetupStep::Configure; self.editing_index = None; + self.provider_type_locked = true; self.provider_type = ProviderSetupProviderType::Bedrock; self.draft_name = draft.name.clone(); self.draft_bedrock = draft; @@ -599,6 +618,7 @@ impl ProviderSetupModalBody { ProviderSetupStep::Models }; self.editing_index = None; + self.provider_type_locked = true; self.provider_type = ProviderSetupProviderType::Acp; self.draft_name = draft.name.clone(); self.draft_acp = draft; @@ -744,7 +764,7 @@ impl ProviderSetupModalBody { .with_size(ButtonSize::XSmall) .on_click(move |ctx| { ctx.dispatch_typed_action( - ProviderSetupModalBodyAction::CycleModelCapability(index, key), + ProviderSetupViewAction::CycleModelCapability(index, key), ); }) }) @@ -793,27 +813,7 @@ impl ProviderSetupModalBody { fn update_next_button(&self, ctx: &mut ViewContext) { let (label, disabled) = match self.step { ProviderSetupStep::ProviderType => ("Next", false), - ProviderSetupStep::Configure => { - let disabled = match self.provider_type { - ProviderSetupProviderType::OpenAICompatible => { - self.draft_base_url.trim().is_empty() - } - ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini => { - self.draft_api_key - .as_deref() - .is_none_or(|key| key.trim().is_empty()) - } - ProviderSetupProviderType::VertexAI => self.draft_project_id.trim().is_empty(), - ProviderSetupProviderType::Acp => { - self.draft_acp.agent_id.trim().is_empty() - || (self.draft_acp.agent_id.eq_ignore_ascii_case("custom") - && self.draft_acp.command.trim().is_empty()) - } - ProviderSetupProviderType::ChatGPTSubscription - | ProviderSetupProviderType::Bedrock => false, - }; - ("Next", disabled) - } + ProviderSetupStep::Configure => ("Next", !self.is_configure_valid()), ProviderSetupStep::Discover => ( if matches!(self.discovery_state, DiscoveryState::Failed(_)) { "Retry" @@ -823,7 +823,8 @@ impl ProviderSetupModalBody { !matches!(self.discovery_state, DiscoveryState::Failed(_)), ), ProviderSetupStep::Models => match self.provider_type { - ProviderSetupProviderType::OpenAICompatible + ProviderSetupProviderType::OpenAI + | ProviderSetupProviderType::LiteLLM | ProviderSetupProviderType::ChatGPTSubscription | ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini @@ -851,12 +852,14 @@ impl ProviderSetupModalBody { fn draft_provider(&self) -> OpenAIProviderConfig { OpenAIProviderConfig { kind: match self.provider_type { + ProviderSetupProviderType::OpenAI => OpenAIProviderKind::OpenAI, + ProviderSetupProviderType::LiteLLM => OpenAIProviderKind::LiteLLM, ProviderSetupProviderType::ChatGPTSubscription => { OpenAIProviderKind::ChatGPTSubscription } - ProviderSetupProviderType::OpenAICompatible - | ProviderSetupProviderType::Bedrock - | ProviderSetupProviderType::Acp => OpenAIProviderKind::OpenAICompatible, + ProviderSetupProviderType::Bedrock | ProviderSetupProviderType::Acp => { + OpenAIProviderKind::LiteLLM + } ProviderSetupProviderType::Anthropic => OpenAIProviderKind::Anthropic, ProviderSetupProviderType::Gemini => OpenAIProviderKind::Gemini, ProviderSetupProviderType::VertexAI => OpenAIProviderKind::VertexAI, @@ -876,7 +879,8 @@ impl ProviderSetupModalBody { }, api_key: matches!( self.provider_type, - ProviderSetupProviderType::OpenAICompatible + ProviderSetupProviderType::OpenAI + | ProviderSetupProviderType::LiteLLM | ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini ) @@ -903,6 +907,38 @@ impl ProviderSetupModalBody { } } + fn jump_to_step(&mut self, step: ProviderSetupStep, ctx: &mut ViewContext) { + if !self.can_jump_to_step(step) { + return; + } + + match step { + ProviderSetupStep::ProviderType => { + self.step = ProviderSetupStep::ProviderType; + self.discovery_state = DiscoveryState::Idle; + self.update_next_button(ctx); + ctx.notify(); + } + ProviderSetupStep::Configure => { + self.step = ProviderSetupStep::Configure; + self.discovery_state = DiscoveryState::Idle; + self.update_next_button(ctx); + ctx.notify(); + } + ProviderSetupStep::Discover => { + self.begin_discovery(ctx); + } + ProviderSetupStep::Models => { + self.step = ProviderSetupStep::Models; + self.discovery_state = DiscoveryState::Idle; + self.sync_model_switches(ctx); + self.update_next_button(ctx); + ctx.focus(&self.name_editor); + ctx.notify(); + } + } + } + fn begin_discovery(&mut self, ctx: &mut ViewContext) { self.step = ProviderSetupStep::Discover; self.discovery_state = DiscoveryState::Loading; @@ -948,12 +984,13 @@ impl ProviderSetupModalBody { return; } ProviderSetupProviderType::Acp => { - ctx.emit(ProviderSetupModalBodyEvent::RequestAcpDiscovery( + ctx.emit(ProviderSetupViewEvent::RequestAcpDiscovery( self.draft_acp.clone(), )); return; } - ProviderSetupProviderType::OpenAICompatible + ProviderSetupProviderType::OpenAI + | ProviderSetupProviderType::LiteLLM | ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => {} @@ -1158,7 +1195,7 @@ impl ProviderSetupModalBody { .with_text_label("Connect ChatGPT".to_owned()) .build() .on_click(|ctx, _, _| { - ctx.dispatch_typed_action(ProviderSetupModalBodyAction::ConnectChatGPT); + ctx.dispatch_typed_action(ProviderSetupViewAction::ConnectChatGPT); }) .finish(), ); @@ -1210,12 +1247,26 @@ impl ProviderSetupModalBody { ProviderSetupProviderType::ChatGPTSubscription => { children.push(self.render_chatgpt_auth(appearance, app)); } - ProviderSetupProviderType::OpenAICompatible => { + ProviderSetupProviderType::OpenAI => { children.push(self.render_input(appearance, "Base URL", &self.base_url_editor)); children.push(self.render_input(appearance, "API key", &self.api_key_editor)); children.push( Text::new( - "The API key is stored locally and is never synced to the cloud.", + "The API key is stored locally and is never synced to the cloud. Models will be discovered from OpenAI's /models endpoint.", + appearance.ui_font_family(), + INPUT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ); + } + ProviderSetupProviderType::LiteLLM => { + children.push(self.render_input(appearance, "Base URL", &self.base_url_editor)); + children.push(self.render_input(appearance, "API key", &self.api_key_editor)); + children.push( + Text::new( + "The API key is stored locally and is never synced to the cloud. LiteLLM model discovery uses /model/info for rich metadata, then falls back to /models.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) @@ -1301,7 +1352,7 @@ impl ProviderSetupModalBody { .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action( - ProviderSetupModalBodyAction::ToggleBedrockAutoLogin, + ProviderSetupViewAction::ToggleBedrockAutoLogin, ); }) .finish(), @@ -1334,7 +1385,7 @@ impl ProviderSetupModalBody { .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action( - ProviderSetupModalBodyAction::ToggleBedrockCrossRegion, + ProviderSetupViewAction::ToggleBedrockCrossRegion, ); }) .finish(), @@ -1462,7 +1513,7 @@ impl ProviderSetupModalBody { .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) .finish(), ) - .with_width(MODAL_WIDTH - 56.) + .with_width(SETUP_WIDTH - 56.) .with_max_height(430.) .finish() } @@ -1470,7 +1521,8 @@ impl ProviderSetupModalBody { fn model_logo(&self) -> (Icon, ColorU) { match self.provider_type { ProviderSetupProviderType::ChatGPTSubscription - | ProviderSetupProviderType::OpenAICompatible => { + | ProviderSetupProviderType::OpenAI + | ProviderSetupProviderType::LiteLLM => { (Icon::OpenAILogo, crate::terminal::cli_agent::OPENAI_COLOR) } ProviderSetupProviderType::Anthropic => { @@ -1636,9 +1688,9 @@ impl ProviderSetupModalBody { .check(model.enabled) .build() .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action( - ProviderSetupModalBodyAction::ToggleModel(index), - ); + ctx.dispatch_typed_action(ProviderSetupViewAction::ToggleModel( + index, + )); }) .finish(), ) @@ -1828,7 +1880,7 @@ impl ProviderSetupModalBody { .with_main_axis_alignment(MainAxisAlignment::End) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(8.); - if self.step != ProviderSetupStep::ProviderType { + if self.can_go_back() { footer = footer.with_child(ChildView::new(&self.back_button).finish()); } footer = footer.with_child(ChildView::new(&self.cancel_button).finish()); @@ -1840,44 +1892,178 @@ impl ProviderSetupModalBody { footer.finish() } + fn can_go_back(&self) -> bool { + match self.step { + ProviderSetupStep::ProviderType => false, + ProviderSetupStep::Configure => !self.provider_type_locked, + ProviderSetupStep::Discover | ProviderSetupStep::Models => true, + } + } + + fn is_configure_valid(&self) -> bool { + match self.provider_type { + ProviderSetupProviderType::OpenAI => { + !self.draft_base_url.trim().is_empty() + && self + .draft_api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()) + } + ProviderSetupProviderType::LiteLLM => !self.draft_base_url.trim().is_empty(), + ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini => self + .draft_api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()), + ProviderSetupProviderType::VertexAI => !self.draft_project_id.trim().is_empty(), + ProviderSetupProviderType::Acp => { + !self.draft_acp.agent_id.trim().is_empty() + && (!self.draft_acp.agent_id.eq_ignore_ascii_case("custom") + || !self.draft_acp.command.trim().is_empty()) + } + ProviderSetupProviderType::ChatGPTSubscription | ProviderSetupProviderType::Bedrock => { + true + } + } + } + + fn has_model_catalog(&self) -> bool { + match self.provider_type { + ProviderSetupProviderType::OpenAI + | ProviderSetupProviderType::LiteLLM + | ProviderSetupProviderType::ChatGPTSubscription + | ProviderSetupProviderType::Anthropic + | ProviderSetupProviderType::Gemini + | ProviderSetupProviderType::VertexAI => !self.draft_models.is_empty(), + ProviderSetupProviderType::Bedrock => !self.draft_bedrock.models.is_empty(), + ProviderSetupProviderType::Acp => !self.draft_acp.config_options.is_empty(), + } + } + + fn step_tab_index(step: ProviderSetupStep) -> usize { + match step { + ProviderSetupStep::ProviderType => 0, + ProviderSetupStep::Configure => 1, + ProviderSetupStep::Discover => 2, + ProviderSetupStep::Models => 3, + } + } + + fn can_jump_to_step(&self, step: ProviderSetupStep) -> bool { + if self.step == step { + return false; + } + + match step { + ProviderSetupStep::ProviderType => !self.provider_type_locked, + ProviderSetupStep::Configure => true, + ProviderSetupStep::Discover => { + !matches!(self.discovery_state, DiscoveryState::Loading) + && self.is_configure_valid() + } + ProviderSetupStep::Models => self.has_model_catalog(), + } + } + + fn render_step_tab( + &self, + step: ProviderSetupStep, + label: &'static str, + appearance: &Appearance, + ) -> Box { + let active = self.step == step; + let enabled = self.can_jump_to_step(step); + let Some(mouse_state) = self + .step_tab_mouse_states + .get(Self::step_tab_index(step)) + .cloned() + else { + return Text::new(label, appearance.ui_font_family(), INPUT_FONT_SIZE) + .with_color(appearance.theme().disabled_ui_text_color().into()) + .finish(); + }; + + let tab = Hoverable::new(mouse_state, move |mouse_state| { + let theme = appearance.theme(); + let text_color = if active { + theme.accent() + } else if enabled { + theme.nonactive_ui_text_color() + } else { + theme.disabled_ui_text_color() + }; + let mut container = Container::new( + Text::new_inline( + label.to_string(), + appearance.ui_font_family(), + INPUT_FONT_SIZE, + ) + .with_color(text_color.into()) + .with_style(Properties::default().weight(if active { + Weight::Bold + } else { + Weight::Normal + })) + .finish(), + ) + .with_horizontal_padding(10.) + .with_vertical_padding(6.) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))); + + if active { + container = container.with_background(theme.surface_overlay_1()); + } else if enabled && mouse_state.is_hovered() { + container = container.with_background(theme.surface_overlay_2()); + } + + container.finish() + }); + + if enabled { + tab.on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(ProviderSetupViewAction::JumpToStep(step)); + }) + .with_cursor(Cursor::PointingHand) + .finish() + } else { + tab.finish() + } + } + fn render_step_indicator(&self, appearance: &Appearance) -> Box { - let steps = [ + let locked_steps = [ + (ProviderSetupStep::Configure, "Configure"), + (ProviderSetupStep::Discover, "Test"), + (ProviderSetupStep::Models, "Models"), + ]; + let selectable_steps = [ (ProviderSetupStep::ProviderType, "Provider"), (ProviderSetupStep::Configure, "Configure"), (ProviderSetupStep::Discover, "Test"), (ProviderSetupStep::Models, "Models"), ]; + let steps: &[(ProviderSetupStep, &str)] = if self.provider_type_locked { + &locked_steps + } else { + &selectable_steps + }; Flex::row() .with_spacing(10.) - .with_children(steps.into_iter().map(|(step, label)| { - let active = self.step == step; - Text::new(label, appearance.ui_font_family(), INPUT_FONT_SIZE) - .with_color( - if active { - appearance.theme().accent() - } else { - appearance.theme().nonactive_ui_text_color() - } - .into(), - ) - .with_style(Properties::default().weight(if active { - Weight::Bold - } else { - Weight::Normal - })) - .finish() - })) + .with_children( + steps + .iter() + .map(|(step, label)| self.render_step_tab(*step, label, appearance)), + ) .finish() } } -impl Entity for ProviderSetupModalBody { - type Event = ProviderSetupModalBodyEvent; +impl Entity for ProviderSetupView { + type Event = ProviderSetupViewEvent; } -impl View for ProviderSetupModalBody { +impl View for ProviderSetupView { fn ui_name() -> &'static str { - "ProviderSetupModalBody" + "ProviderSetupView" } fn render(&self, app: &AppContext) -> Box { @@ -1897,27 +2083,30 @@ impl View for ProviderSetupModalBody { } } -impl TypedActionView for ProviderSetupModalBody { - type Action = ProviderSetupModalBodyAction; +impl TypedActionView for ProviderSetupView { + type Action = ProviderSetupViewAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { - ProviderSetupModalBodyAction::SelectProvider(kind) => { + ProviderSetupViewAction::SelectProvider(kind) => { if self.provider_type != *kind { self.draft_models.clear(); self.discovery_state = DiscoveryState::Idle; } self.provider_type = *kind; - if *kind == ProviderSetupProviderType::ChatGPTSubscription { - self.draft_base_url.clear(); + self.draft_name = Self::default_name(*kind).to_string(); + self.draft_base_url = Self::default_base_url(*kind).to_string(); + if matches!(*kind, ProviderSetupProviderType::ChatGPTSubscription) { self.draft_api_key = None; } + self.sync_editors(ctx); self.sync_provider_type_buttons(ctx); self.sync_bedrock_auth_buttons(ctx); self.update_next_button(ctx); ctx.notify(); } - ProviderSetupModalBodyAction::Next => match self.step { + ProviderSetupViewAction::JumpToStep(step) => self.jump_to_step(*step, ctx), + ProviderSetupViewAction::Next => match self.step { ProviderSetupStep::ProviderType => { self.step = ProviderSetupStep::Configure; self.update_next_button(ctx); @@ -1930,7 +2119,8 @@ impl TypedActionView for ProviderSetupModalBody { } } ProviderSetupStep::Models => match self.provider_type { - ProviderSetupProviderType::OpenAICompatible + ProviderSetupProviderType::OpenAI + | ProviderSetupProviderType::LiteLLM | ProviderSetupProviderType::ChatGPTSubscription | ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini @@ -1940,7 +2130,7 @@ impl TypedActionView for ProviderSetupModalBody { { return; } - ctx.emit(ProviderSetupModalBodyEvent::SaveOpenAI { + ctx.emit(ProviderSetupViewEvent::SaveOpenAI { editing_index: self.editing_index, provider: self.draft_provider(), }); @@ -1952,7 +2142,7 @@ impl TypedActionView for ProviderSetupModalBody { } let mut draft = self.draft_bedrock.clone(); draft.name = self.draft_name.trim().to_string(); - ctx.emit(ProviderSetupModalBodyEvent::SaveBedrock(draft)); + ctx.emit(ProviderSetupViewEvent::SaveBedrock(draft)); } ProviderSetupProviderType::Acp => { if self.draft_name.trim().is_empty() { @@ -1960,13 +2150,16 @@ impl TypedActionView for ProviderSetupModalBody { } let mut draft = self.draft_acp.clone(); draft.name = self.draft_name.trim().to_string(); - ctx.emit(ProviderSetupModalBodyEvent::SaveAcp(draft)); + ctx.emit(ProviderSetupViewEvent::SaveAcp(draft)); } }, }, - ProviderSetupModalBodyAction::Back => match self.step { + ProviderSetupViewAction::Back => match self.step { ProviderSetupStep::ProviderType => {} ProviderSetupStep::Configure => { + if self.provider_type_locked { + return; + } self.step = ProviderSetupStep::ProviderType; self.update_next_button(ctx); ctx.notify(); @@ -1983,10 +2176,10 @@ impl TypedActionView for ProviderSetupModalBody { ctx.notify(); } }, - ProviderSetupModalBodyAction::Cancel => { - ctx.emit(ProviderSetupModalBodyEvent::Close); + ProviderSetupViewAction::Cancel => { + ctx.emit(ProviderSetupViewEvent::Close); } - ProviderSetupModalBodyAction::SelectAcpAgent(agent_id) => { + ProviderSetupViewAction::SelectAcpAgent(agent_id) => { self.draft_acp.agent_id = agent_id.clone(); if !agent_id.eq_ignore_ascii_case("custom") { self.draft_acp.command.clear(); @@ -1997,14 +2190,14 @@ impl TypedActionView for ProviderSetupModalBody { self.update_next_button(ctx); ctx.notify(); } - ProviderSetupModalBodyAction::ToggleModel(index) => { + ProviderSetupViewAction::ToggleModel(index) => { if let Some(model) = self.draft_models.get_mut(*index) { model.enabled = !model.enabled; self.update_next_button(ctx); ctx.notify(); } } - ProviderSetupModalBodyAction::CycleModelCapability(index, capability) => { + ProviderSetupViewAction::CycleModelCapability(index, capability) => { if let Some(model) = self.draft_models.get_mut(*index) { let key = capability.setting_key().to_string(); let next = model.capability_override(&key).next(); @@ -2014,28 +2207,28 @@ impl TypedActionView for ProviderSetupModalBody { ctx.notify(); } } - ProviderSetupModalBodyAction::ConnectChatGPT => { + ProviderSetupViewAction::ConnectChatGPT => { #[cfg(not(target_family = "wasm"))] ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx)); } - ProviderSetupModalBodyAction::OpenChatGPTDevicePage => { + ProviderSetupViewAction::OpenChatGPTDevicePage => { // No-op: device-code flow removed in favor of browser OAuth. } - ProviderSetupModalBodyAction::CopyChatGPTDeviceCode => { + ProviderSetupViewAction::CopyChatGPTDeviceCode => { // No-op: device-code flow removed in favor of browser OAuth. } - ProviderSetupModalBodyAction::SelectBedrockAuth(method) => { + ProviderSetupViewAction::SelectBedrockAuth(method) => { self.draft_bedrock.auth_method = *method; self.sync_bedrock_auth_buttons(ctx); self.update_next_button(ctx); ctx.notify(); } - ProviderSetupModalBodyAction::ToggleBedrockCrossRegion => { + ProviderSetupViewAction::ToggleBedrockCrossRegion => { self.draft_bedrock.cross_region_inference = !self.draft_bedrock.cross_region_inference; ctx.notify(); } - ProviderSetupModalBodyAction::ToggleBedrockAutoLogin => { + ProviderSetupViewAction::ToggleBedrockAutoLogin => { self.draft_bedrock.auto_login = !self.draft_bedrock.auto_login; ctx.notify(); } @@ -2045,7 +2238,8 @@ impl TypedActionView for ProviderSetupModalBody { fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str { match kind { - ProviderSetupProviderType::OpenAICompatible => "OpenAI-compatible API", + ProviderSetupProviderType::OpenAI => "OpenAI", + ProviderSetupProviderType::LiteLLM => "LiteLLM", ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT subscription", ProviderSetupProviderType::Anthropic => "Anthropic", ProviderSetupProviderType::Gemini => "Google Gemini", diff --git a/app/src/settings_view/set_default_model_modal.rs b/app/src/settings_view/set_default_model_modal.rs deleted file mode 100644 index 42b91986..00000000 --- a/app/src/settings_view/set_default_model_modal.rs +++ /dev/null @@ -1,213 +0,0 @@ -use warpui::elements::{ - ChildView, Container, CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Flex, - MainAxisAlignment, MainAxisSize, ParentElement, Text, -}; -use warpui::{ - AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, - WeakViewHandle, -}; - -use crate::ai::llms::LLMId; -use crate::appearance::Appearance; -use crate::view_components::action_button::{ActionButton, NakedTheme, PrimaryTheme}; -use crate::view_components::{DropdownItem, FilterableDropdown, FilterableDropdownEvent}; - -/// Width shared by the model dropdown's top bar and open menu so long model -/// names stay readable inside the modal. -const MODEL_DROPDOWN_WIDTH: f32 = 400.; -/// The body's `ui_font_size`-based default reads too small in the modal, so the -/// description uses an explicit, slightly larger size. -const DESCRIPTION_FONT_SIZE: f32 = 14.; - -pub enum SetDefaultModelModalBodyEvent { - /// The user dismissed the prompt without choosing a model. - Close, - /// The user committed `LLMId` as their new default Agent Mode model. - SetDefault(LLMId), -} - -#[derive(Debug, Clone, PartialEq)] -pub enum SetDefaultModelModalBodyAction { - /// Carries the index into `model_choices` of the picked model. - SelectModel(usize), - Save, - Cancel, -} - -/// Body of the "change your default model" prompt that appears after a BYO API -/// key or custom endpoint is saved. It is hosted inside a [`crate::modal::Modal`], -/// which supplies the title, close button, and backdrop. -pub struct SetDefaultModelModalBody { - description: String, - /// `(model id, label)` pairs offered in the dropdown. The id flows back out - /// through [`SetDefaultModelModalBodyEvent::SetDefault`] on save. - model_choices: Vec<(LLMId, String)>, - selected_index: usize, - model_dropdown: ViewHandle>, - cancel_button: ViewHandle, - save_button: ViewHandle, - self_handle: WeakViewHandle, -} - -impl SetDefaultModelModalBody { - pub fn new(ctx: &mut ViewContext) -> Self { - let model_dropdown = ctx.add_typed_action_view(|ctx| { - let mut dropdown = FilterableDropdown::new(ctx); - dropdown.set_top_bar_max_width(MODEL_DROPDOWN_WIDTH); - dropdown.set_menu_width(MODEL_DROPDOWN_WIDTH, ctx); - dropdown - }); - // When the dropdown closes (selection or dismiss), return focus to the - // body so Escape closes the modal rather than no-op'ing on the hidden - // filter input. - ctx.subscribe_to_view(&model_dropdown, |_, _, event, ctx| { - if let FilterableDropdownEvent::Close = event { - ctx.focus_self(); - } - }); - - let cancel_button = ctx.add_typed_action_view(|_| { - ActionButton::new("Not now", NakedTheme).on_click(|ctx| { - ctx.dispatch_typed_action(SetDefaultModelModalBodyAction::Cancel); - }) - }); - - let save_button = ctx.add_typed_action_view(|_| { - ActionButton::new("Change default model", PrimaryTheme).on_click(|ctx| { - ctx.dispatch_typed_action(SetDefaultModelModalBodyAction::Save); - }) - }); - - Self { - description: String::new(), - model_choices: Vec::new(), - selected_index: 0, - model_dropdown, - cancel_button, - save_button, - self_handle: ctx.handle(), - } - } - - /// Populates the prompt for a freshly added credential and focuses the body - /// so Escape closes the modal. The first model is pre-selected so the user - /// can accept without opening the dropdown. - pub fn set_choices( - &mut self, - description: String, - model_choices: Vec<(LLMId, String)>, - ctx: &mut ViewContext, - ) { - self.description = description; - self.model_choices = model_choices; - self.selected_index = 0; - - let items = self - .model_choices - .iter() - .enumerate() - .map(|(index, (_, label))| { - DropdownItem::new( - label.clone(), - SetDefaultModelModalBodyAction::SelectModel(index), - ) - }) - .collect(); - self.model_dropdown.update(ctx, |dropdown, ctx| { - dropdown.set_items(items, ctx); - dropdown.set_selected_by_index(0, ctx); - }); - ctx.focus_self(); - ctx.notify(); - } -} - -impl Entity for SetDefaultModelModalBody { - type Event = SetDefaultModelModalBodyEvent; -} - -impl View for SetDefaultModelModalBody { - fn ui_name() -> &'static str { - "SetDefaultModelModalBody" - } - - fn render(&self, app: &AppContext) -> Box { - let appearance = Appearance::as_ref(app); - let theme = appearance.theme(); - - let description = Container::new( - Text::new( - self.description.clone(), - appearance.ui_font_family(), - DESCRIPTION_FONT_SIZE, - ) - .with_color(theme.nonactive_ui_text_color().into()) - .soft_wrap(true) - .finish(), - ) - .with_margin_bottom(20.) - .finish(); - - let dropdown = Container::new(ChildView::new(&self.model_dropdown).finish()) - .with_margin_bottom(24.) - .finish(); - - let buttons_row = Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::End) - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child(ChildView::new(&self.cancel_button).finish()) - .with_child( - Container::new(ChildView::new(&self.save_button).finish()) - .with_margin_left(12.) - .finish(), - ) - .finish(); - - let content = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_child(description) - .with_child(dropdown) - .with_child(buttons_row) - .finish(); - - // Close the modal on Escape when the body itself is focused. While the - // dropdown is open it owns Escape (to close itself); on close it hands - // focus back to the body via the `Close` subscription above. - let self_handle = self.self_handle.clone(); - EventHandler::new(content) - .on_keydown(move |ctx, app, keystroke| { - let body_focused = self_handle - .upgrade(app) - .is_some_and(|handle| handle.is_focused(app)); - if body_focused && keystroke.is_unmodified_key("escape") { - ctx.dispatch_typed_action(SetDefaultModelModalBodyAction::Cancel); - DispatchEventResult::StopPropagation - } else { - DispatchEventResult::PropagateToParent - } - }) - .finish() - } -} - -impl TypedActionView for SetDefaultModelModalBody { - type Action = SetDefaultModelModalBodyAction; - - fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { - match action { - SetDefaultModelModalBodyAction::SelectModel(index) => { - self.selected_index = *index; - ctx.notify(); - } - SetDefaultModelModalBodyAction::Save => { - if let Some((id, _)) = self.model_choices.get(self.selected_index) { - ctx.emit(SetDefaultModelModalBodyEvent::SetDefault(id.clone())); - } - } - SetDefaultModelModalBodyAction::Cancel => { - ctx.emit(SetDefaultModelModalBodyEvent::Close); - } - } - } -}