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, }; use galaxyui::fonts::{Properties, Weight}; use galaxyui::text_layout::ClipConfig; use galaxyui::ui_components::button::ButtonVariant; use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use galaxyui::ui_components::switch::SwitchStateHandle; use galaxyui::{ AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; use pathfinder_color::ColorU; #[cfg(not(target_family = "wasm"))] use crate::ai::chatgpt_auth::{ChatGPTAuthModel, ChatGPTAuthModelEvent, ChatGPTAuthState}; use crate::ai::harness_display; use crate::ai::llms::{merge_discovered_provider_models, LLMPreferences}; 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, }; use crate::ui_components::icons::Icon; 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 INPUT_FONT_SIZE: f32 = 12.; const MODEL_LOGO_SIZE: f32 = 20.; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ProviderSetupStep { ProviderType, Configure, Discover, Models, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ProviderSetupProviderType { ChatGPTSubscription, OpenAICompatible, Anthropic, Gemini, VertexAI, Bedrock, Acp, } const PROVIDER_TYPE_OPTIONS: &[(ProviderSetupProviderType, &str, &str)] = &[ ( 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)] pub struct BedrockProviderDraft { pub name: String, pub auth_method: BedrockAuthMethod, pub profile: String, pub region: String, pub cross_region_inference: bool, pub auto_login: bool, pub auth_refresh_command: String, pub access_key_id: String, pub secret_access_key: String, pub models: Vec, } #[derive(Clone, Debug)] pub struct AcpProviderDraft { pub name: String, pub agent_id: String, pub command: String, pub args: Vec, pub config_options: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] enum DiscoveryState { Idle, Loading, Failed(String), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CapabilityKey { Vision, Files, Audio, Tools, SystemMessages, } impl CapabilityKey { const ALL: [Self; 5] = [ Self::Vision, Self::Files, Self::Audio, Self::Tools, Self::SystemMessages, ]; fn label(self) -> &'static str { match self { Self::Vision => "Images", Self::Files => "Files", Self::Audio => "Audio", Self::Tools => "Tools", Self::SystemMessages => "System", } } fn setting_key(self) -> &'static str { match self { Self::Vision => "vision", Self::Files => "files", Self::Audio => "audio", Self::Tools => "tools", Self::SystemMessages => "system_messages", } } } pub enum ProviderSetupModalBodyEvent { Close, RequestAcpDiscovery(AcpProviderDraft), SaveOpenAI { editing_index: Option, provider: OpenAIProviderConfig, }, SaveBedrock(BedrockProviderDraft), SaveAcp(AcpProviderDraft), } #[derive(Clone, Debug, PartialEq)] pub enum ProviderSetupModalBodyAction { SelectProvider(ProviderSetupProviderType), Next, Back, Cancel, ToggleModel(usize), CycleModelCapability(usize, CapabilityKey), ConnectChatGPT, OpenChatGPTDevicePage, CopyChatGPTDeviceCode, SelectBedrockAuth(BedrockAuthMethod), ToggleBedrockCrossRegion, ToggleBedrockAutoLogin, SelectAcpAgent(String), } pub type ProviderSetupModalState = ModalViewState>; pub struct ProviderSetupModalBody { step: ProviderSetupStep, editing_index: Option, provider_type: ProviderSetupProviderType, draft_name: String, draft_base_url: String, draft_api_key: Option, draft_project_id: String, draft_location: String, draft_models: Vec, draft_bedrock: BedrockProviderDraft, draft_acp: AcpProviderDraft, discovery_state: DiscoveryState, provider_type_buttons: Vec>, acp_agent_buttons: Vec>, name_editor: ViewHandle, base_url_editor: ViewHandle, api_key_editor: ViewHandle, project_id_editor: ViewHandle, location_editor: ViewHandle, bedrock_profile_editor: ViewHandle, bedrock_region_editor: ViewHandle, bedrock_refresh_command_editor: ViewHandle, bedrock_access_key_editor: ViewHandle, bedrock_secret_key_editor: ViewHandle, acp_agent_id_editor: ViewHandle, acp_command_editor: ViewHandle, acp_args_editor: ViewHandle, chatgpt_connect_mouse_state: MouseStateHandle, bedrock_auth_buttons: Vec>, bedrock_cross_region_toggle: SwitchStateHandle, bedrock_auto_login_toggle: SwitchStateHandle, model_switches: Vec, model_capability_switches: Vec<[SwitchStateHandle; 2]>, model_capability_buttons: Vec>>, model_context_editors: Vec>, provider_type_scroll_state: ClippedScrollStateHandle, models_scroll_state: ClippedScrollStateHandle, back_button: ViewHandle, cancel_button: ViewHandle, next_button: ViewHandle, } impl ProviderSetupModalBody { pub fn new(ctx: &mut ViewContext) -> Self { let provider_type_buttons = PROVIDER_TYPE_OPTIONS .iter() .map(|(kind, label, _)| { let kind = *kind; let label = *label; ctx.add_typed_action_view(move |_| { ActionButton::new(label, NakedTheme) .with_full_width(true) .on_click(move |ctx| { ctx.dispatch_typed_action( ProviderSetupModalBodyAction::SelectProvider(kind), ); }) }) }) .collect(); let name_editor = Self::create_editor("Connection name", false, ctx); let base_url_editor = Self::create_editor("https://api.example.com/v1", false, ctx); let api_key_editor = Self::create_editor("sk-... (optional)", true, ctx); let project_id_editor = Self::create_editor("my-google-cloud-project", false, ctx); let location_editor = Self::create_editor("global", false, ctx); let bedrock_profile_editor = Self::create_editor("default", false, ctx); let bedrock_region_editor = Self::create_editor("us-east-1", false, ctx); let bedrock_refresh_command_editor = Self::create_editor("aws sso login", false, ctx); let bedrock_access_key_editor = Self::create_editor("AKIA...", false, ctx); let bedrock_secret_key_editor = Self::create_editor("Secret access key", true, ctx); let acp_agent_id_editor = Self::create_editor("codex", false, ctx); let acp_command_editor = Self::create_editor("Optional executable", false, ctx); let acp_args_editor = Self::create_editor(r#"["arg1", "arg2"]"#, false, ctx); let mut acp_agent_buttons = galaxy_acp::known_acp_agents() .iter() .map(|agent| { let id = agent.id.to_owned(); ctx.add_typed_action_view(move |_| { ActionButton::new(agent.name, NakedTheme) .with_full_width(true) .on_click({ let id = id.clone(); move |ctx| { ctx.dispatch_typed_action( ProviderSetupModalBodyAction::SelectAcpAgent(id.clone()), ); } }) }) }) .collect::>(); acp_agent_buttons.push(ctx.add_typed_action_view(|_| { ActionButton::new("Custom", NakedTheme) .with_full_width(true) .on_click(|ctx| { ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectAcpAgent( "custom".to_owned(), )); }) })); let bedrock_auth_buttons = [ BedrockAuthMethod::Profile, BedrockAuthMethod::Sso, BedrockAuthMethod::StaticKeys, ] .into_iter() .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, )); }) }) }) .collect(); ctx.subscribe_to_view(&name_editor, |me, editor, event, ctx| { if matches!(event, EditorEvent::Edited(_)) { me.draft_name = editor.as_ref(ctx).buffer_text(ctx); me.update_next_button(ctx); ctx.notify(); } }); ctx.subscribe_to_view(&base_url_editor, |me, editor, event, ctx| { if matches!(event, EditorEvent::Edited(_)) { me.draft_base_url = editor.as_ref(ctx).buffer_text(ctx); me.update_next_button(ctx); ctx.notify(); } }); ctx.subscribe_to_view(&api_key_editor, |me, editor, event, ctx| { if matches!(event, EditorEvent::Edited(_)) { let value = editor.as_ref(ctx).buffer_text(ctx); me.draft_api_key = (!value.trim().is_empty()).then_some(value); ctx.notify(); } }); ctx.subscribe_to_view(&project_id_editor, |me, editor, event, ctx| { if matches!(event, EditorEvent::Edited(_)) { me.draft_project_id = editor.as_ref(ctx).buffer_text(ctx); me.update_next_button(ctx); ctx.notify(); } }); ctx.subscribe_to_view(&location_editor, |me, editor, event, ctx| { if matches!(event, EditorEvent::Edited(_)) { me.draft_location = editor.as_ref(ctx).buffer_text(ctx); ctx.notify(); } }); for (editor, update) in [ (bedrock_profile_editor.clone(), 0), (bedrock_region_editor.clone(), 1), (bedrock_refresh_command_editor.clone(), 2), (bedrock_access_key_editor.clone(), 3), (bedrock_secret_key_editor.clone(), 4), (acp_agent_id_editor.clone(), 5), (acp_command_editor.clone(), 6), (acp_args_editor.clone(), 7), ] { ctx.subscribe_to_view(&editor, move |me, editor, event, ctx| { if matches!(event, EditorEvent::Edited(_)) { let value = editor.as_ref(ctx).buffer_text(ctx); match update { 0 => me.draft_bedrock.profile = value, 1 => me.draft_bedrock.region = value, 2 => me.draft_bedrock.auth_refresh_command = value, 3 => me.draft_bedrock.access_key_id = value, 4 => me.draft_bedrock.secret_access_key = value, 5 => me.draft_acp.agent_id = value, 6 => me.draft_acp.command = value, 7 => { if let Ok(args) = serde_json::from_str::>(&value) { me.draft_acp.args = args; } } _ => unreachable!(), } me.update_next_button(ctx); ctx.notify(); } }); } #[cfg(not(target_family = "wasm"))] ctx.subscribe_to_model(&ChatGPTAuthModel::handle(ctx), |me, _, event, ctx| { if matches!(event, ChatGPTAuthModelEvent::StateChanged) { if me.step == ProviderSetupStep::Discover { me.try_discover_chatgpt(ctx); } me.update_next_button(ctx); ctx.notify(); } }); let back_button = ctx.add_typed_action_view(|_| { ActionButton::new("Back", NakedTheme).on_click(|ctx| { ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Back); }) }); let cancel_button = ctx.add_typed_action_view(|_| { ActionButton::new("Cancel", NakedTheme).on_click(|ctx| { ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Cancel); }) }); let next_button = ctx.add_typed_action_view(|_| { ActionButton::new("Next", PrimaryTheme).on_click(|ctx| { ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Next); }) }); Self { step: ProviderSetupStep::ProviderType, editing_index: None, provider_type: ProviderSetupProviderType::OpenAICompatible, draft_name: String::new(), draft_base_url: String::new(), draft_api_key: None, draft_project_id: String::new(), draft_location: "global".to_string(), draft_models: Vec::new(), draft_bedrock: BedrockProviderDraft { name: String::new(), auth_method: BedrockAuthMethod::default(), profile: "default".to_string(), region: String::new(), cross_region_inference: true, auto_login: true, auth_refresh_command: "aws sso login".to_string(), access_key_id: String::new(), secret_access_key: String::new(), models: Vec::new(), }, draft_acp: AcpProviderDraft { name: String::new(), agent_id: "codex".to_string(), command: String::new(), args: Vec::new(), config_options: Vec::new(), }, discovery_state: DiscoveryState::Idle, provider_type_buttons, acp_agent_buttons, name_editor, base_url_editor, api_key_editor, project_id_editor, location_editor, bedrock_profile_editor, bedrock_region_editor, bedrock_refresh_command_editor, bedrock_access_key_editor, bedrock_secret_key_editor, acp_agent_id_editor, acp_command_editor, acp_args_editor, chatgpt_connect_mouse_state: MouseStateHandle::default(), bedrock_auth_buttons, bedrock_cross_region_toggle: SwitchStateHandle::default(), bedrock_auto_login_toggle: SwitchStateHandle::default(), model_switches: Vec::new(), model_capability_switches: Vec::new(), model_capability_buttons: Vec::new(), model_context_editors: Vec::new(), provider_type_scroll_state: ClippedScrollStateHandle::default(), models_scroll_state: ClippedScrollStateHandle::default(), back_button, cancel_button, next_button, } } fn create_editor( placeholder: &'static str, is_password: bool, ctx: &mut ViewContext, ) -> ViewHandle { ctx.add_typed_action_view(move |ctx| { let appearance = Appearance::as_ref(ctx); let options = SingleLineEditorOptions { is_password, text: TextOptions { font_size_override: Some(appearance.ui_font_size()), font_family_override: Some(appearance.monospace_font_family()), text_colors_override: Some(TextColors { default_color: appearance.theme().active_ui_text_color(), disabled_color: appearance.theme().disabled_ui_text_color(), hint_color: appearance.theme().disabled_ui_text_color(), }), ..Default::default() }, ..Default::default() }; let mut editor = EditorView::single_line(options, ctx); editor.set_placeholder_text(placeholder, ctx); editor }) } pub fn begin_create(&mut self, ctx: &mut ViewContext) { self.step = ProviderSetupStep::ProviderType; self.editing_index = None; self.provider_type = ProviderSetupProviderType::OpenAICompatible; self.draft_name.clear(); self.draft_base_url.clear(); self.draft_api_key = None; self.draft_project_id.clear(); self.draft_location = "global".to_string(); self.draft_models.clear(); self.draft_bedrock = BedrockProviderDraft { name: String::new(), auth_method: BedrockAuthMethod::default(), profile: "default".to_string(), region: String::new(), cross_region_inference: true, auto_login: true, auth_refresh_command: "aws sso login".to_string(), access_key_id: String::new(), secret_access_key: String::new(), models: Vec::new(), }; self.draft_acp = AcpProviderDraft { name: String::new(), agent_id: "codex".to_string(), command: String::new(), args: Vec::new(), config_options: Vec::new(), }; self.discovery_state = DiscoveryState::Idle; self.sync_editors(ctx); self.sync_provider_type_buttons(ctx); self.sync_acp_agent_buttons(ctx); self.sync_bedrock_auth_buttons(ctx); self.sync_model_switches(ctx); self.update_next_button(ctx); ctx.focus_self(); ctx.notify(); } pub fn begin_edit( &mut self, editing_index: usize, provider: OpenAIProviderConfig, ctx: &mut ViewContext, ) { // Editing an existing provider is a local catalog operation. Do not // send the user through credentials or model discovery again. self.step = ProviderSetupStep::Models; self.editing_index = Some(editing_index); self.provider_type = match provider.kind { OpenAIProviderKind::ChatGPTSubscription => { ProviderSetupProviderType::ChatGPTSubscription } OpenAIProviderKind::OpenAICompatible => ProviderSetupProviderType::OpenAICompatible, OpenAIProviderKind::Anthropic => ProviderSetupProviderType::Anthropic, OpenAIProviderKind::Gemini => ProviderSetupProviderType::Gemini, OpenAIProviderKind::VertexAI => ProviderSetupProviderType::VertexAI, }; self.draft_name = provider.name; self.draft_base_url = provider.base_url; self.draft_api_key = provider.api_key; self.draft_project_id = provider.project_id.unwrap_or_default(); self.draft_location = provider.location.unwrap_or_else(|| "global".to_string()); self.draft_models = provider.models; self.discovery_state = DiscoveryState::Idle; self.sync_editors(ctx); self.sync_provider_type_buttons(ctx); self.sync_acp_agent_buttons(ctx); self.sync_bedrock_auth_buttons(ctx); self.sync_model_switches(ctx); self.update_next_button(ctx); ctx.focus_self(); ctx.notify(); } pub fn begin_edit_bedrock(&mut self, draft: BedrockProviderDraft, ctx: &mut ViewContext) { self.step = ProviderSetupStep::Configure; self.editing_index = None; self.provider_type = ProviderSetupProviderType::Bedrock; self.draft_name = draft.name.clone(); self.draft_bedrock = draft; self.draft_models.clear(); self.discovery_state = DiscoveryState::Idle; self.sync_editors(ctx); self.sync_provider_type_buttons(ctx); self.sync_bedrock_auth_buttons(ctx); self.sync_model_switches(ctx); self.update_next_button(ctx); ctx.focus_self(); ctx.notify(); } pub fn begin_edit_acp(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext) { self.step = if draft.config_options.is_empty() { ProviderSetupStep::Configure } else { ProviderSetupStep::Models }; self.editing_index = None; self.provider_type = ProviderSetupProviderType::Acp; self.draft_name = draft.name.clone(); self.draft_acp = draft; self.draft_models.clear(); self.discovery_state = DiscoveryState::Idle; self.sync_editors(ctx); self.sync_provider_type_buttons(ctx); self.sync_bedrock_auth_buttons(ctx); self.sync_model_switches(ctx); self.update_next_button(ctx); ctx.focus_self(); ctx.notify(); } pub fn finish_acp_discovery( &mut self, result: Result<(), String>, config_options: Vec, ctx: &mut ViewContext, ) { match result { Ok(()) => { self.draft_acp.config_options = config_options; self.discovery_state = DiscoveryState::Idle; self.step = ProviderSetupStep::Models; ctx.focus(&self.name_editor); } Err(error) => { self.discovery_state = DiscoveryState::Failed(error); } } self.update_next_button(ctx); ctx.notify(); } fn sync_editors(&self, ctx: &mut ViewContext) { self.name_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_name, ctx); }); self.base_url_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_base_url, ctx); }); self.api_key_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(self.draft_api_key.as_deref().unwrap_or_default(), ctx); }); self.project_id_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_project_id, ctx); }); self.location_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_location, ctx); }); self.bedrock_profile_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_bedrock.profile, ctx); }); self.bedrock_region_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_bedrock.region, ctx); }); self.bedrock_refresh_command_editor .update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_bedrock.auth_refresh_command, ctx); }); self.bedrock_access_key_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_bedrock.access_key_id, ctx); }); self.bedrock_secret_key_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_bedrock.secret_access_key, ctx); }); self.acp_agent_id_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_acp.agent_id, ctx); }); self.acp_command_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_acp.command, ctx); }); self.acp_args_editor.update(ctx, |editor, ctx| { let args = serde_json::to_string(&self.draft_acp.args).unwrap_or_else(|_| "[]".to_string()); editor.system_reset_buffer_text(&args, ctx); }); } fn sync_provider_type_buttons(&self, ctx: &mut ViewContext) { for ((button_kind, _, _), button) in PROVIDER_TYPE_OPTIONS .iter() .zip(self.provider_type_buttons.iter()) { button.update(ctx, |button, ctx| { button.set_active(*button_kind == self.provider_type, ctx); }); } } fn sync_acp_agent_buttons(&self, ctx: &mut ViewContext) { let selected = self.draft_acp.agent_id.trim(); for (agent, button) in galaxy_acp::known_acp_agents() .iter() .zip(self.acp_agent_buttons.iter()) { button.update(ctx, |button, ctx| { button.set_active(agent.id.eq_ignore_ascii_case(selected), ctx); }); } if let Some(button) = self.acp_agent_buttons.last() { button.update(ctx, |button, ctx| { button.set_active(selected.eq_ignore_ascii_case("custom"), ctx); }); } } fn sync_bedrock_auth_buttons(&self, ctx: &mut ViewContext) { for (index, button) in self.bedrock_auth_buttons.iter().enumerate() { let method = match index { 0 => BedrockAuthMethod::Profile, 1 => BedrockAuthMethod::Sso, 2 => BedrockAuthMethod::StaticKeys, _ => continue, }; button.update(ctx, |button, ctx| { button.set_active(method == self.draft_bedrock.auth_method, ctx); }); } } fn sync_model_switches(&mut self, ctx: &mut ViewContext) { while self.model_switches.len() < self.draft_models.len() { self.model_switches.push(SwitchStateHandle::default()); } self.model_switches.truncate(self.draft_models.len()); while self.model_capability_switches.len() < self.draft_models.len() { self.model_capability_switches .push([SwitchStateHandle::default(), SwitchStateHandle::default()]); } self.model_capability_switches .truncate(self.draft_models.len()); while self.model_capability_buttons.len() < self.draft_models.len() { let index = self.model_capability_buttons.len(); let buttons = CapabilityKey::ALL .into_iter() .map(|key| { ctx.add_typed_action_view(move |_| { ActionButton::new(format!("{}: Auto", key.label()), NakedTheme) .with_size(ButtonSize::XSmall) .on_click(move |ctx| { ctx.dispatch_typed_action( ProviderSetupModalBodyAction::CycleModelCapability(index, key), ); }) }) }) .collect(); self.model_capability_buttons.push(buttons); } self.model_capability_buttons .truncate(self.draft_models.len()); while self.model_context_editors.len() < self.draft_models.len() { let index = self.model_context_editors.len(); let editor = Self::create_editor("Context window", false, ctx); ctx.subscribe_to_view(&editor, move |me, editor, event, ctx| { if matches!(event, EditorEvent::Edited(_)) { if let Some(model) = me.draft_models.get_mut(index) { if let Ok(context_size) = editor.as_ref(ctx).buffer_text(ctx).parse() { model.context_size = context_size; model.max_input_tokens = Some(context_size); } } me.update_next_button(ctx); ctx.notify(); } }); self.model_context_editors.push(editor); } self.model_context_editors.truncate(self.draft_models.len()); for (index, model) in self.draft_models.iter().enumerate() { for (button, key) in self.model_capability_buttons[index] .iter() .zip(CapabilityKey::ALL) { let state = model.capability_override(key.setting_key()); button.update(ctx, |button, ctx| { button.set_label(format!("{}: {}", key.label(), state.label()), ctx); }); } self.model_context_editors[index].update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&model.context_size.to_string(), ctx); }); } } 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::Discover => ( if matches!(self.discovery_state, DiscoveryState::Failed(_)) { "Retry" } else { "Testing..." }, !matches!(self.discovery_state, DiscoveryState::Failed(_)), ), ProviderSetupStep::Models => match self.provider_type { ProviderSetupProviderType::OpenAICompatible | ProviderSetupProviderType::ChatGPTSubscription | ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => ( "Save", self.draft_name.trim().is_empty() || !self.draft_models.iter().any(|model| model.enabled), ), ProviderSetupProviderType::Bedrock => ( "Save", self.draft_name.trim().is_empty() || self.draft_bedrock.models.is_empty(), ), ProviderSetupProviderType::Acp => ("Save", self.draft_name.trim().is_empty()), }, }; self.next_button.update(ctx, |button, ctx| { button.set_label(label, ctx); button.set_disabled(disabled, ctx); }); self.back_button.update(ctx, |button, ctx| { button.set_disabled(self.step == ProviderSetupStep::ProviderType, ctx); }); } fn draft_provider(&self) -> OpenAIProviderConfig { OpenAIProviderConfig { kind: match self.provider_type { ProviderSetupProviderType::ChatGPTSubscription => { OpenAIProviderKind::ChatGPTSubscription } ProviderSetupProviderType::OpenAICompatible | ProviderSetupProviderType::Bedrock | ProviderSetupProviderType::Acp => OpenAIProviderKind::OpenAICompatible, ProviderSetupProviderType::Anthropic => OpenAIProviderKind::Anthropic, ProviderSetupProviderType::Gemini => OpenAIProviderKind::Gemini, ProviderSetupProviderType::VertexAI => OpenAIProviderKind::VertexAI, }, enabled: true, name: self.draft_name.trim().to_string(), base_url: if matches!( self.provider_type, ProviderSetupProviderType::ChatGPTSubscription | ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI ) { String::new() } else { self.draft_base_url.trim().trim_end_matches('/').to_string() }, api_key: matches!( self.provider_type, ProviderSetupProviderType::OpenAICompatible | ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini ) .then(|| { self.draft_api_key .as_deref() .filter(|key| !key.trim().is_empty()) .map(str::to_string) }) .flatten(), project_id: matches!(self.provider_type, ProviderSetupProviderType::VertexAI) .then(|| self.draft_project_id.trim().to_string()), location: matches!(self.provider_type, ProviderSetupProviderType::VertexAI).then( || { let location = self.draft_location.trim(); if location.is_empty() { "global".to_string() } else { location.to_string() } }, ), models: self.draft_models.clone(), } } fn begin_discovery(&mut self, ctx: &mut ViewContext) { self.step = ProviderSetupStep::Discover; self.discovery_state = DiscoveryState::Loading; self.update_next_button(ctx); ctx.notify(); match self.provider_type { ProviderSetupProviderType::ChatGPTSubscription => { self.try_discover_chatgpt(ctx); return; } ProviderSetupProviderType::Bedrock => { let config = crate::ai::bedrock::client::BedrockClientConfig { auth_method: self.draft_bedrock.auth_method, profile: self.draft_bedrock.profile.clone(), region: self.draft_bedrock.region.clone(), access_key_id: self.draft_bedrock.access_key_id.clone(), secret_access_key: self.draft_bedrock.secret_access_key.clone(), session_token: None, cross_region_inference: self.draft_bedrock.cross_region_inference, use_rig: false, }; ctx.spawn( async move { crate::ai::bedrock::discovery::discover_available_models(config).await }, move |me, result, ctx| match result { Ok(models) => { me.draft_bedrock.models = models; me.discovery_state = DiscoveryState::Idle; me.step = ProviderSetupStep::Models; me.update_next_button(ctx); ctx.focus(&me.name_editor); ctx.notify(); } Err(error) => { me.discovery_state = DiscoveryState::Failed(error); me.update_next_button(ctx); ctx.notify(); } }, ); return; } ProviderSetupProviderType::Acp => { ctx.emit(ProviderSetupModalBodyEvent::RequestAcpDiscovery( self.draft_acp.clone(), )); return; } ProviderSetupProviderType::OpenAICompatible | ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => {} } let provider = self.draft_provider(); let existing_models = self.draft_models.clone(); ctx.spawn( async move { LLMPreferences::discover_openai_provider_models(provider).await }, move |me, result, ctx| match result { Ok(models) => { me.draft_models = merge_discovered_provider_models(&existing_models, models); me.discovery_state = DiscoveryState::Idle; me.step = ProviderSetupStep::Models; me.sync_model_switches(ctx); me.update_next_button(ctx); ctx.focus(&me.name_editor); ctx.notify(); } Err(error) => { me.discovery_state = DiscoveryState::Failed(error); me.update_next_button(ctx); ctx.notify(); } }, ); } #[cfg(not(target_family = "wasm"))] fn try_discover_chatgpt(&mut self, ctx: &mut ViewContext) { if !matches!( ChatGPTAuthModel::as_ref(ctx).state(), ChatGPTAuthState::Connected ) { self.discovery_state = DiscoveryState::Failed( "Connect your ChatGPT subscription before continuing.".to_string(), ); self.update_next_button(ctx); ctx.notify(); return; } if self.draft_models.is_empty() { self.draft_models = crate::settings::ai::default_chatgpt_provider().models; } self.discovery_state = DiscoveryState::Idle; self.step = ProviderSetupStep::Models; self.sync_model_switches(ctx); self.update_next_button(ctx); ctx.focus(&self.name_editor); ctx.notify(); } #[cfg(target_family = "wasm")] fn try_discover_chatgpt(&mut self, ctx: &mut ViewContext) { self.discovery_state = DiscoveryState::Failed( "ChatGPT subscription providers are available in the desktop app.".to_string(), ); self.update_next_button(ctx); ctx.notify(); } fn render_label(appearance: &Appearance, label: &str) -> Box { Text::new_inline( label.to_string(), appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().active_ui_text_color().into()) .with_style(Properties::default().weight(Weight::Bold)) .finish() } fn render_input( &self, appearance: &Appearance, label: &str, editor: &ViewHandle, ) -> Box { let input = appearance .ui_builder() .text_input(editor.clone()) .with_style(UiComponentStyles { padding: Some(Coords { top: 10., bottom: 10., left: 12., right: 12., }), background: Some(appearance.theme().surface_1().into()), ..Default::default() }) .build() .finish(); Flex::column() .with_spacing(6.) .with_child(Self::render_label(appearance, label)) .with_child(input) .finish() } fn render_provider_type(&self, appearance: &Appearance) -> Box { let description = Text::new( "Choose how Galaxy should connect to this provider.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .finish(); let cards = PROVIDER_TYPE_OPTIONS .iter() .enumerate() .map(|(index, (_, _, description))| { let button = ChildView::new(&self.provider_type_buttons[index]).finish(); Container::new( Flex::column() .with_spacing(8.) .with_child(button) .with_child( Text::new(*description, appearance.ui_font_family(), INPUT_FONT_SIZE) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ) .finish(), ) .with_padding(Padding::uniform(12.)) .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) .finish() }) .collect::>(); let list = Flex::column() .with_spacing(10.) .with_children(cards) .finish(); let scrollable = ClippedScrollable::vertical( self.provider_type_scroll_state.clone(), list, ScrollbarWidth::Auto, appearance.theme().nonactive_ui_detail().into(), appearance.theme().active_ui_detail().into(), appearance.theme().surface_1().into(), ) .with_overlayed_scrollbar() .finish(); Flex::column() .with_spacing(16.) .with_child(description) .with_child( ConstrainedBox::new(scrollable) .with_max_height(360.) .finish(), ) .finish() } #[cfg(not(target_family = "wasm"))] fn render_chatgpt_auth( &self, appearance: &Appearance, app: &AppContext, ) -> Box { let state = ChatGPTAuthModel::as_ref(app).state(); let mut children = vec![Self::render_label(appearance, "ChatGPT authorization")]; let description = match &state { ChatGPTAuthState::NotConnected => "Connect your ChatGPT subscription to continue.", ChatGPTAuthState::AwaitingBrowser => "Waiting for ChatGPT sign-in in your browser...", ChatGPTAuthState::ExchangingToken => "Completing sign-in...", ChatGPTAuthState::Connected => "ChatGPT subscription connected.", ChatGPTAuthState::Failed(_) => "ChatGPT connection failed.", }; children.push( Text::new(description, appearance.ui_font_family(), INPUT_FONT_SIZE) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ); if let ChatGPTAuthState::Failed(error) = &state { children.push( Text::new(error.clone(), appearance.monospace_font_family(), 11.) .with_color(appearance.theme().ui_error_color()) .soft_wrap(true) .finish(), ); } if matches!( state, ChatGPTAuthState::NotConnected | ChatGPTAuthState::Failed(_) ) { children.push( appearance .ui_builder() .button( ButtonVariant::Secondary, self.chatgpt_connect_mouse_state.clone(), ) .with_text_label("Connect ChatGPT".to_owned()) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(ProviderSetupModalBodyAction::ConnectChatGPT); }) .finish(), ); } Flex::column() .with_spacing(10.) .with_children(children) .finish() } #[cfg(target_family = "wasm")] fn render_chatgpt_auth( &self, appearance: &Appearance, _app: &AppContext, ) -> Box { Flex::column() .with_spacing(8.) .with_child(Self::render_label(appearance, "ChatGPT authorization")) .with_child( Text::new( "ChatGPT subscription providers are available in the desktop app.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ) .finish() } fn render_configure( &self, appearance: &Appearance, app: &AppContext, ) -> Box { let mut children = vec![Text::new( format!("Configure {}", provider_type_label(self.provider_type)), 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()]; match self.provider_type { ProviderSetupProviderType::ChatGPTSubscription => { children.push(self.render_chatgpt_auth(appearance, app)); } ProviderSetupProviderType::OpenAICompatible => { 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.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ); } ProviderSetupProviderType::Anthropic => { children.push(self.render_input(appearance, "API key", &self.api_key_editor)); children.push( Text::new( "The key is stored locally and is never synced to the cloud. Models will be discovered from Anthropic after the connection test.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ); } ProviderSetupProviderType::Gemini => { children.push(self.render_input(appearance, "API key", &self.api_key_editor)); children.push( Text::new( "The key is stored locally and is never synced to the cloud. Models will be discovered from Google's Gemini API after the connection test.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ); } ProviderSetupProviderType::VertexAI => { children.push(self.render_input( appearance, "Google Cloud project ID", &self.project_id_editor, )); children.push(self.render_input(appearance, "Location", &self.location_editor)); children.push( Text::new( "Vertex AI uses Google Application Default Credentials. Run `gcloud auth application-default login` before testing the connection.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ); } ProviderSetupProviderType::Bedrock => { children.push(Self::render_label(appearance, "Authentication method")); children.push( Flex::row() .with_spacing(8.) .with_children( self.bedrock_auth_buttons .iter() .map(|button| ChildView::new(button).finish()), ) .finish(), ); match self.draft_bedrock.auth_method { BedrockAuthMethod::Profile | BedrockAuthMethod::Sso => { children.push(self.render_input( appearance, "AWS profile", &self.bedrock_profile_editor, )); if self.draft_bedrock.auth_method == BedrockAuthMethod::Sso { children.push(self.render_input( appearance, "Login command", &self.bedrock_refresh_command_editor, )); children.push( appearance .ui_builder() .switch(self.bedrock_auto_login_toggle.clone()) .check(self.draft_bedrock.auto_login) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action( ProviderSetupModalBodyAction::ToggleBedrockAutoLogin, ); }) .finish(), ); } } BedrockAuthMethod::StaticKeys => { children.push(self.render_input( appearance, "Access key ID", &self.bedrock_access_key_editor, )); children.push(self.render_input( appearance, "Secret access key", &self.bedrock_secret_key_editor, )); } } children.push(self.render_input( appearance, "AWS region", &self.bedrock_region_editor, )); children.push( appearance .ui_builder() .switch(self.bedrock_cross_region_toggle.clone()) .check(self.draft_bedrock.cross_region_inference) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action( ProviderSetupModalBodyAction::ToggleBedrockCrossRegion, ); }) .finish(), ); } ProviderSetupProviderType::Acp => { children.push( Text::new("ACP client", appearance.ui_font_family(), INPUT_FONT_SIZE) .with_color(appearance.theme().active_ui_text_color().into()) .with_style(Properties::default().weight(Weight::Bold)) .finish(), ); children.extend( self.acp_agent_buttons .iter() .map(|button| ChildView::new(button).finish()), ); if self.draft_acp.agent_id.eq_ignore_ascii_case("custom") { children.push(self.render_input( appearance, "Executable", &self.acp_command_editor, )); children.push(self.render_input( appearance, "Arguments (JSON array)", &self.acp_args_editor, )); } children.push( Text::new( "Known clients use their local executable. If the client is not installed, Galaxy will show a launch error. Choose Custom for another ACP-compatible command.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ); } } Flex::column() .with_spacing(16.) .with_children(children) .finish() } fn render_discovery(&self, appearance: &Appearance) -> Box { let (message, color) = match &self.discovery_state { DiscoveryState::Idle | DiscoveryState::Loading => ( "Testing the connection and discovering available models...".to_string(), appearance.theme().active_ui_text_color().into_solid(), ), DiscoveryState::Failed(error) => (error.clone(), appearance.theme().ui_error_color()), }; Flex::column() .with_spacing(12.) .with_child( Text::new( "Test connection", 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(if matches!(self.discovery_state, DiscoveryState::Loading) { Flex::row() .with_spacing(8.) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( ConstrainedBox::new( Icon::Loading .to_galaxyui_icon(appearance.theme().active_ui_text_color()) .finish(), ) .with_width(16.) .with_height(16.) .finish(), ) .with_child( Text::new(message, appearance.ui_font_family(), INPUT_FONT_SIZE) .with_color(color) .soft_wrap(true) .finish(), ) .finish() } else { Text::new(message, appearance.ui_font_family(), INPUT_FONT_SIZE) .with_color(color) .soft_wrap(true) .finish() }) .finish() } fn render_model_table( &self, appearance: &Appearance, rows: Vec>, spacing: f32, ) -> Box { let list = Flex::column() .with_spacing(spacing) .with_children(rows) .finish(); let scrollable = ClippedScrollable::vertical( self.models_scroll_state.clone(), list, ScrollbarWidth::Auto, appearance.theme().nonactive_ui_detail().into(), appearance.theme().active_ui_detail().into(), appearance.theme().surface_1().into(), ) .with_overlayed_scrollbar() .finish(); ConstrainedBox::new( Container::new(scrollable) .with_padding(Padding::uniform(12.)) .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(6.))) .finish(), ) .with_width(MODAL_WIDTH - 56.) .with_max_height(430.) .finish() } fn model_logo(&self) -> (Icon, ColorU) { match self.provider_type { ProviderSetupProviderType::ChatGPTSubscription | ProviderSetupProviderType::OpenAICompatible => { (Icon::OpenAILogo, crate::terminal::cli_agent::OPENAI_COLOR) } ProviderSetupProviderType::Anthropic => { (Icon::ClaudeLogo, crate::ai::blocklist::CLAUDE_ORANGE) } ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => { (Icon::GeminiLogo, crate::terminal::cli_agent::GEMINI_BLUE) } ProviderSetupProviderType::Bedrock => { (Icon::BedrockLogo, ColorU::new(255, 153, 0, 255)) } ProviderSetupProviderType::Acp => { let agent = self.draft_acp.agent_id.to_ascii_lowercase(); let harness = if agent.contains("claude") { Harness::Claude } else if agent.contains("gemini") { Harness::Gemini } else if agent.contains("codex") { Harness::Codex } else { Harness::Unknown }; ( harness_display::icon_for(harness), harness_display::brand_color(harness) .unwrap_or(ColorU::new(128, 128, 128, 255)), ) } } } fn render_model_logo(&self) -> Box { let (icon, color) = self.model_logo(); ConstrainedBox::new(icon.to_galaxyui_icon(Fill::Solid(color)).finish()) .with_width(MODEL_LOGO_SIZE) .with_height(MODEL_LOGO_SIZE) .finish() } fn render_model_table_header(&self, appearance: &Appearance) -> Box { let header = |label: &str| { Text::new(label.to_owned(), appearance.ui_font_family(), 11.) .with_color(appearance.theme().nonactive_ui_text_color().into()) .with_style(Properties::default().weight(Weight::Semibold)) .finish() }; Container::new( Flex::row() .with_spacing(12.) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(ConstrainedBox::new(header("Use")).with_width(52.).finish()) .with_child( ConstrainedBox::new(header("Model")) .with_width(250.) .finish(), ) .with_child( ConstrainedBox::new(header("Context")) .with_width(140.) .finish(), ) .with_child( ConstrainedBox::new(header("Capabilities")) .with_width(330.) .finish(), ) .finish(), ) .with_padding(Padding::uniform(10.).with_vertical(9.)) .with_background(appearance.theme().surface_2()) .with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline())) .finish() } fn render_model_capabilities( &self, appearance: &Appearance, index: usize, ) -> Box { let buttons = &self.model_capability_buttons[index]; let first_row = buttons[..3] .iter() .map(|button| ChildView::new(button).finish()) .collect::>(); let second_row = buttons[3..] .iter() .map(|button| ChildView::new(button).finish()) .collect::>(); Flex::column() .with_spacing(6.) .with_child( Flex::row() .with_spacing(8.) .with_children(first_row) .finish(), ) .with_child( Flex::row() .with_spacing(8.) .with_children(second_row) .finish(), ) .finish() } fn render_openai_model_row( &self, appearance: &Appearance, index: usize, model: &OpenAIModelConfig, ) -> Box { let model_info = Flex::column() .with_spacing(4.) .with_child( Text::new_inline(model.display_name.clone(), appearance.ui_font_family(), 12.) .with_color(appearance.theme().active_ui_text_color().into()) .with_clip(ClipConfig::end()) .finish(), ) .with_child( Text::new_inline( model.model_id.clone(), appearance.monospace_font_family(), 10., ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .with_clip(ClipConfig::end()) .finish(), ) .finish(); let model_info = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(10.) .with_child(self.render_model_logo()) .with_child(model_info) .finish(); let context_input = appearance .ui_builder() .text_input(self.model_context_editors[index].clone()) .with_style(UiComponentStyles { padding: Some(Coords { top: 8., bottom: 8., left: 8., right: 8., }), background: Some(appearance.theme().surface_1().into()), ..Default::default() }) .build() .finish(); Container::new( Flex::row() .with_spacing(12.) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( ConstrainedBox::new( appearance .ui_builder() .switch(self.model_switches[index].clone()) .check(model.enabled) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action( ProviderSetupModalBodyAction::ToggleModel(index), ); }) .finish(), ) .with_width(52.) .finish(), ) .with_child(ConstrainedBox::new(model_info).with_width(250.).finish()) .with_child(ConstrainedBox::new(context_input).with_width(140.).finish()) .with_child( ConstrainedBox::new(self.render_model_capabilities(appearance, index)) .with_width(330.) .finish(), ) .finish(), ) .with_padding(Padding::uniform(12.).with_vertical(10.)) .with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline())) .finish() } fn render_models(&self, appearance: &Appearance) -> Box { if self.provider_type == ProviderSetupProviderType::Bedrock { let rows = self .draft_bedrock .models .iter() .map(|model| { Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(10.) .with_child(self.render_model_logo()) .with_child( Flex::column() .with_spacing(2.) .with_child( Text::new( model.display_name.clone(), appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().active_ui_text_color().into()) .finish(), ) .with_child( Text::new( model.model_id.clone(), appearance.monospace_font_family(), 10., ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ) .finish(), ) .finish() }) .collect::>(); return Flex::column() .with_spacing(12.) .with_child(self.render_input(appearance, "Connection name", &self.name_editor)) .with_child( Text::new( "These models passed AWS Bedrock availability checks. Model selection is managed by discovery and cannot be edited manually.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ) .with_child(self.render_model_table(appearance, rows, 10.)) .finish(); } if self.provider_type == ProviderSetupProviderType::Acp { let option_rows = self .draft_acp .config_options .iter() .filter(|option| { matches!( option.category.as_deref(), Some("model") | Some("thought_level") | Some("mode") ) }) .map(|option| { let values = option .options .iter() .map(|value| value.name.as_str()) .collect::>() .join(", "); Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(10.) .with_child(self.render_model_logo()) .with_child( Flex::column() .with_spacing(2.) .with_child( Text::new( option.name.clone(), appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().active_ui_text_color().into()) .with_style(Properties::default().weight(Weight::Bold)) .finish(), ) .with_child( Text::new( if values.is_empty() { option.current_value.to_string() } else { values }, appearance.monospace_font_family(), 10., ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ) .finish(), ) .finish() }) .collect::>(); let catalog = if option_rows.is_empty() { Text::new( "No model or mode catalog has been discovered yet. Continue to test the ACP agent.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish() } else { self.render_model_table(appearance, option_rows, 10.) }; return Flex::column() .with_spacing(12.) .with_child(self.render_input(appearance, "Connection name", &self.name_editor)) .with_child( Text::new( "ACP-discovered models and modes are exposed as selectable combinations in Galaxy's model picker.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ) .with_child(catalog) .finish(); } let mut rows = vec![self.render_model_table_header(appearance)]; rows.extend( self.draft_models .iter() .enumerate() .map(|(index, model)| self.render_openai_model_row(appearance, index, model)), ); let table = self.render_model_table(appearance, rows, 0.); Flex::column() .with_spacing(12.) .with_child(self.render_input(appearance, "Connection name", &self.name_editor)) .with_child( Text::new( "Enable the models Galaxy should offer. Context is the maximum input window. Capabilities use Auto by default and can be overridden per model.", appearance.ui_font_family(), INPUT_FONT_SIZE, ) .with_color(appearance.theme().nonactive_ui_text_color().into()) .soft_wrap(true) .finish(), ) .with_child(table) .finish() } fn render_footer(&self) -> Box { let mut footer = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::End) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_spacing(8.); if self.step != ProviderSetupStep::ProviderType { footer = footer.with_child(ChildView::new(&self.back_button).finish()); } footer = footer.with_child(ChildView::new(&self.cancel_button).finish()); if !matches!(self.step, ProviderSetupStep::Discover) || matches!(self.discovery_state, DiscoveryState::Failed(_)) { footer = footer.with_child(ChildView::new(&self.next_button).finish()); } footer.finish() } fn render_step_indicator(&self, appearance: &Appearance) -> Box { let steps = [ (ProviderSetupStep::ProviderType, "Provider"), (ProviderSetupStep::Configure, "Configure"), (ProviderSetupStep::Discover, "Test"), (ProviderSetupStep::Models, "Models"), ]; 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() })) .finish() } } impl Entity for ProviderSetupModalBody { type Event = ProviderSetupModalBodyEvent; } impl View for ProviderSetupModalBody { fn ui_name() -> &'static str { "ProviderSetupModalBody" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let content = match self.step { ProviderSetupStep::ProviderType => self.render_provider_type(appearance), ProviderSetupStep::Configure => self.render_configure(appearance, app), ProviderSetupStep::Discover => self.render_discovery(appearance), ProviderSetupStep::Models => self.render_models(appearance), }; Flex::column() .with_spacing(20.) .with_child(self.render_step_indicator(appearance)) .with_child(content) .with_child(self.render_footer()) .finish() } } impl TypedActionView for ProviderSetupModalBody { type Action = ProviderSetupModalBodyAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { ProviderSetupModalBodyAction::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_api_key = None; } self.sync_provider_type_buttons(ctx); self.sync_bedrock_auth_buttons(ctx); self.update_next_button(ctx); ctx.notify(); } ProviderSetupModalBodyAction::Next => match self.step { ProviderSetupStep::ProviderType => { self.step = ProviderSetupStep::Configure; self.update_next_button(ctx); ctx.notify(); } ProviderSetupStep::Configure => self.begin_discovery(ctx), ProviderSetupStep::Discover => { if matches!(self.discovery_state, DiscoveryState::Failed(_)) { self.begin_discovery(ctx); } } ProviderSetupStep::Models => match self.provider_type { ProviderSetupProviderType::OpenAICompatible | ProviderSetupProviderType::ChatGPTSubscription | ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => { if self.draft_name.trim().is_empty() || !self.draft_models.iter().any(|model| model.enabled) { return; } ctx.emit(ProviderSetupModalBodyEvent::SaveOpenAI { editing_index: self.editing_index, provider: self.draft_provider(), }); } ProviderSetupProviderType::Bedrock => { if self.draft_name.trim().is_empty() || self.draft_bedrock.models.is_empty() { return; } let mut draft = self.draft_bedrock.clone(); draft.name = self.draft_name.trim().to_string(); ctx.emit(ProviderSetupModalBodyEvent::SaveBedrock(draft)); } ProviderSetupProviderType::Acp => { if self.draft_name.trim().is_empty() { return; } let mut draft = self.draft_acp.clone(); draft.name = self.draft_name.trim().to_string(); ctx.emit(ProviderSetupModalBodyEvent::SaveAcp(draft)); } }, }, ProviderSetupModalBodyAction::Back => match self.step { ProviderSetupStep::ProviderType => {} ProviderSetupStep::Configure => { self.step = ProviderSetupStep::ProviderType; self.update_next_button(ctx); ctx.notify(); } ProviderSetupStep::Discover => { self.step = ProviderSetupStep::Configure; self.discovery_state = DiscoveryState::Idle; self.update_next_button(ctx); ctx.notify(); } ProviderSetupStep::Models => { self.step = ProviderSetupStep::Configure; self.update_next_button(ctx); ctx.notify(); } }, ProviderSetupModalBodyAction::Cancel => { ctx.emit(ProviderSetupModalBodyEvent::Close); } ProviderSetupModalBodyAction::SelectAcpAgent(agent_id) => { self.draft_acp.agent_id = agent_id.clone(); if !agent_id.eq_ignore_ascii_case("custom") { self.draft_acp.command.clear(); self.draft_acp.args.clear(); } self.sync_editors(ctx); self.sync_acp_agent_buttons(ctx); self.update_next_button(ctx); ctx.notify(); } ProviderSetupModalBodyAction::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) => { if let Some(model) = self.draft_models.get_mut(*index) { let key = capability.setting_key().to_string(); let next = model.capability_override(&key).next(); model.capability_overrides.insert(key, next); self.update_next_button(ctx); self.sync_model_switches(ctx); ctx.notify(); } } ProviderSetupModalBodyAction::ConnectChatGPT => { #[cfg(not(target_family = "wasm"))] ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx)); } ProviderSetupModalBodyAction::OpenChatGPTDevicePage => { // No-op: device-code flow removed in favor of browser OAuth. } ProviderSetupModalBodyAction::CopyChatGPTDeviceCode => { // No-op: device-code flow removed in favor of browser OAuth. } ProviderSetupModalBodyAction::SelectBedrockAuth(method) => { self.draft_bedrock.auth_method = *method; self.sync_bedrock_auth_buttons(ctx); self.update_next_button(ctx); ctx.notify(); } ProviderSetupModalBodyAction::ToggleBedrockCrossRegion => { self.draft_bedrock.cross_region_inference = !self.draft_bedrock.cross_region_inference; ctx.notify(); } ProviderSetupModalBodyAction::ToggleBedrockAutoLogin => { self.draft_bedrock.auto_login = !self.draft_bedrock.auto_login; ctx.notify(); } } } } fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str { match kind { ProviderSetupProviderType::OpenAICompatible => "OpenAI-compatible API", 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", } }