Files
galaxy/app/src/settings_view/provider_setup_view.rs
T

2725 lines
110 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use galaxy_cli::agent::Harness;
use galaxy_core::ui::theme::Fill;
use galaxyui::elements::{
Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
MainAxisSize, MouseStateHandle, Padding, ParentElement, Radius, RowBackground, ScrollbarWidth,
Table, TableColumnWidth, TableConfig, TableHeader, TableStateHandle, TableVerticalSizing, 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};
use galaxyui::ui_components::switch::SwitchStateHandle;
use galaxyui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use pathfinder_color::ColorU;
pub(super) fn model_catalog_table(
state: TableStateHandle,
row_count: usize,
render_row: impl Fn(usize, &AppContext) -> Vec<Box<dyn Element>> + 'static,
appearance: &Appearance,
) -> Box<dyn Element> {
let headers = ["Use", "Model", "Context", "Capabilities"]
.into_iter()
.enumerate()
.map(|(index, label)| {
TableHeader::new(
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(),
)
.with_width(if index == 3 {
TableColumnWidth::Flex(1.)
} else {
TableColumnWidth::Intrinsic
})
})
.collect();
state.invalidate_all_intrinsic_widths();
Table::new(state, 0., 0.)
.with_headers(headers)
.with_row_count(row_count)
.with_row_render_fn(render_row)
.with_config(TableConfig {
border_width: 1.,
border_color: appearance.theme().outline().into(),
outer_border: true,
column_dividers: false,
row_dividers: true,
cell_padding: 12.,
header_background: appearance.theme().surface_2().into(),
row_background: RowBackground::uniform(appearance.theme().surface_1().into()),
fixed_header: false,
vertical_sizing: TableVerticalSizing::ExpandToContent,
measure_body_cells_for_intrinsic_widths: true,
})
.finish()
}
#[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, replace_litellm_catalog, LLMPreferences};
use crate::appearance::Appearance;
use crate::editor::{
EditOrigin, EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions,
};
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,
};
use crate::view_components::{Dropdown, DropdownItem};
const INPUT_FONT_SIZE: f32 = 12.;
const MODEL_LOGO_SIZE: f32 = 20.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ProviderSetupStep {
ProviderType,
Configure,
Discover,
Models,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderSetupProviderType {
OpenAI,
LiteLLM,
ChatGPTSubscription,
Anthropic,
Gemini,
VertexAI,
Bedrock,
Acp,
}
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::Anthropic,
"Anthropic / Claude subscription",
"Use an Anthropic API key or a Claude Code authorization token with Galaxy’s own tools.",
),
(
ProviderSetupProviderType::Bedrock,
"AWS Bedrock",
"Use the AWS Bedrock credentials and model configuration already managed by Galaxy.",
),
];
#[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<BedrockModelConfig>,
}
#[derive(Clone, Debug)]
pub struct AcpProviderDraft {
pub name: String,
pub agent_id: String,
pub command: String,
pub args: Vec<String>,
pub config_options: Vec<AcpConfigOptionSettings>,
}
#[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 ProviderSetupViewEvent {
Close,
RequestAcpDiscovery(AcpProviderDraft),
SaveOpenAI {
editing_index: Option<usize>,
provider: OpenAIProviderConfig,
},
SaveBedrock(BedrockProviderDraft),
SaveAcp(AcpProviderDraft),
}
#[derive(Clone, Debug, PartialEq)]
pub enum ProviderSetupViewAction {
SelectProvider(ProviderSetupProviderType),
JumpToStep(ProviderSetupStep),
Next,
Back,
Cancel,
ToggleModel(usize),
CycleModelCapability(usize, CapabilityKey),
ConnectChatGPT,
ConnectClaude,
SignOutClaude,
CancelClaudeSignIn,
SelectBedrockAuth(BedrockAuthMethod),
SelectBedrockProfile(String),
ToggleBedrockCrossRegion,
ToggleBedrockAutoLogin,
SelectAcpAgent(String),
}
pub struct ProviderSetupView {
step: ProviderSetupStep,
editing_index: Option<usize>,
provider_type: ProviderSetupProviderType,
provider_type_locked: bool,
draft_name: String,
draft_base_url: String,
draft_api_key: Option<String>,
draft_project_id: String,
draft_location: String,
draft_models: Vec<OpenAIModelConfig>,
draft_bedrock: BedrockProviderDraft,
draft_acp: AcpProviderDraft,
discovery_state: DiscoveryState,
acp_setup_status: String,
acp_discovery_generation: u64,
acp_setup_abort: Option<futures::future::AbortHandle>,
provider_type_buttons: Vec<ViewHandle<ActionButton>>,
acp_agent_buttons: Vec<ViewHandle<ActionButton>>,
name_editor: ViewHandle<EditorView>,
base_url_editor: ViewHandle<EditorView>,
api_key_editor: ViewHandle<EditorView>,
project_id_editor: ViewHandle<EditorView>,
location_editor: ViewHandle<EditorView>,
bedrock_profile_dropdown: ViewHandle<Dropdown<ProviderSetupViewAction>>,
bedrock_region_request: usize,
bedrock_region_editor: ViewHandle<EditorView>,
bedrock_refresh_command_editor: ViewHandle<EditorView>,
bedrock_access_key_editor: ViewHandle<EditorView>,
bedrock_secret_key_editor: ViewHandle<EditorView>,
acp_agent_id_editor: ViewHandle<EditorView>,
acp_command_editor: ViewHandle<EditorView>,
acp_args_editor: ViewHandle<EditorView>,
chatgpt_connect_mouse_state: MouseStateHandle,
claude_connect_mouse_state: MouseStateHandle,
claude_signin_abort: Option<futures::future::AbortHandle>,
claude_signin_generation: u64,
claude_signin_status: String,
claude_credentials_available: bool,
bedrock_auth_buttons: Vec<ViewHandle<ActionButton>>,
bedrock_cross_region_toggle: SwitchStateHandle,
bedrock_auto_login_toggle: SwitchStateHandle,
model_switches: Vec<SwitchStateHandle>,
bedrock_model_switches: Vec<SwitchStateHandle>,
model_capability_switches: Vec<[SwitchStateHandle; 2]>,
model_capability_buttons: Vec<Vec<ViewHandle<ActionButton>>>,
model_context_editors: Vec<ViewHandle<EditorView>>,
provider_type_scroll_state: ClippedScrollStateHandle,
models_scroll_state: ClippedScrollStateHandle,
model_table_state: TableStateHandle,
step_tab_mouse_states: Vec<MouseStateHandle>,
back_button: ViewHandle<ActionButton>,
cancel_button: ViewHandle<ActionButton>,
next_button: ViewHandle<ActionButton>,
}
impl ProviderSetupView {
pub fn new(ctx: &mut ViewContext<Self>) -> 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(ProviderSetupViewAction::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_dropdown = ctx.add_typed_action_view(Dropdown::new);
let bedrock_region_editor =
Self::create_editor("Region from AWS profile (or enter an override)", 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("claude-acp", 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 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(ProviderSetupViewAction::SelectAcpAgent(
id.clone(),
));
}
})
})
})
.collect::<Vec<_>>();
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(ProviderSetupViewAction::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(origin) if *origin != EditOrigin::SystemEdit) {
let value = editor.as_ref(ctx).buffer_text(ctx);
me.cancel_claude_signin();
me.draft_api_key = (!value.trim().is_empty()).then_some(value);
me.claude_credentials_available = false;
if me.provider_type == ProviderSetupProviderType::Anthropic {
me.draft_models.clear();
}
me.update_next_button(ctx);
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_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 {
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::<Vec<String>>(&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(ProviderSetupViewAction::Back);
})
});
let cancel_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Cancel", NakedTheme).on_click(|ctx| {
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(ProviderSetupViewAction::Next);
})
});
Self {
step: ProviderSetupStep::ProviderType,
editing_index: None,
provider_type: ProviderSetupProviderType::LiteLLM,
provider_type_locked: false,
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: "claude-acp".to_string(),
command: String::new(),
args: Vec::new(),
config_options: Vec::new(),
},
discovery_state: DiscoveryState::Idle,
acp_setup_status: String::new(),
acp_discovery_generation: 0,
acp_setup_abort: None,
provider_type_buttons,
acp_agent_buttons,
name_editor,
base_url_editor,
api_key_editor,
project_id_editor,
location_editor,
bedrock_profile_dropdown,
bedrock_region_request: 0,
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(),
claude_connect_mouse_state: MouseStateHandle::default(),
claude_signin_abort: None,
claude_signin_generation: 0,
claude_signin_status: String::new(),
claude_credentials_available: false,
bedrock_auth_buttons,
bedrock_cross_region_toggle: SwitchStateHandle::default(),
bedrock_auto_login_toggle: SwitchStateHandle::default(),
model_switches: Vec::new(),
bedrock_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(),
model_table_state: TableStateHandle::new(0, |_, _| Vec::new()),
step_tab_mouse_states: (0..4).map(|_| MouseStateHandle::default()).collect(),
back_button,
cancel_button,
next_button,
}
}
fn create_editor(
placeholder: &'static str,
is_password: bool,
ctx: &mut ViewContext<Self>,
) -> ViewHandle<EditorView> {
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
})
}
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 => "Claude Code",
}
}
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>,
) {
self.cancel_acp_setup();
self.cancel_claude_signin();
self.step = ProviderSetupStep::Configure;
self.editing_index = None;
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.claude_credentials_available = false;
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: "claude-acp".to_string(),
command: String::new(),
args: Vec::new(),
config_options: Vec::new(),
};
self.discovery_state = DiscoveryState::Idle;
self.sync_editors(ctx);
if self.provider_type == ProviderSetupProviderType::Bedrock {
self.load_bedrock_profile_region(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<Self>,
) {
self.cancel_claude_signin();
// 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_locked = true;
self.provider_type = match provider.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,
};
self.draft_name = provider.name;
self.draft_base_url = provider.base_url;
self.draft_api_key = provider.api_key;
{
use galaxyui_extras::secure_storage::AppContextExt as _;
self.claude_credentials_available =
self.draft_api_key.as_deref().is_some_and(|reference| {
crate::ai::claude_subscription::is_stored_credential(reference)
&& ctx.secure_storage().read_value(reference).is_ok()
});
}
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>) {
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;
self.draft_models.clear();
self.discovery_state = DiscoveryState::Idle;
self.sync_editors(ctx);
if self.draft_bedrock.region.is_empty() {
self.load_bedrock_profile_region(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>) {
self.cancel_acp_setup();
self.step = if draft.config_options.is_empty() {
ProviderSetupStep::Configure
} else {
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;
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 acp_discovery_generation(&self) -> u64 {
self.acp_discovery_generation
}
pub fn acp_discovery_is_current(&self, generation: u64) -> bool {
self.provider_type == ProviderSetupProviderType::Acp
&& self.step == ProviderSetupStep::Discover
&& matches!(self.discovery_state, DiscoveryState::Loading)
&& self.acp_discovery_generation == generation
}
#[cfg(feature = "local_fs")]
pub fn prepare_acp_setup(&mut self) -> futures::future::AbortRegistration {
self.cancel_acp_setup();
let (handle, registration) = futures::future::AbortHandle::new_pair();
self.acp_setup_abort = Some(handle);
registration
}
fn cancel_acp_setup(&mut self) {
if let Some(handle) = self.acp_setup_abort.take() {
handle.abort();
}
}
pub fn set_acp_setup_status(&mut self, status: String, ctx: &mut ViewContext<Self>) {
self.acp_setup_status = status;
ctx.notify();
}
pub fn show_connection_error(&mut self, error: String, ctx: &mut ViewContext<Self>) {
self.discovery_state = DiscoveryState::Failed(error);
self.step = ProviderSetupStep::Discover;
self.update_next_button(ctx);
ctx.notify();
}
pub fn finish_acp_discovery(
&mut self,
result: Result<(), String>,
config_options: Vec<AcpConfigOptionSettings>,
ctx: &mut ViewContext<Self>,
) {
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 load_bedrock_profile_region(&mut self, ctx: &mut ViewContext<Self>) {
self.bedrock_region_request += 1;
#[cfg(not(target_family = "wasm"))]
{
use aws_config::meta::region::ProvideRegion;
let request = self.bedrock_region_request;
let profile = self.draft_bedrock.profile.clone();
let requested_profile = profile.clone();
let previous_region = self.draft_bedrock.region.clone();
ctx.spawn(
async move {
let provider = aws_config::profile::ProfileFileRegionProvider::builder()
.profile_name(profile)
.build();
ProvideRegion::region(&provider)
.await
.map(|region| region.as_ref().to_owned())
},
move |me, region, ctx| {
if request != me.bedrock_region_request
|| me.provider_type != ProviderSetupProviderType::Bedrock
|| me.draft_bedrock.profile != requested_profile
|| me.draft_bedrock.region != previous_region
{
return;
}
me.draft_bedrock.region = region.unwrap_or_default();
me.bedrock_region_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&me.draft_bedrock.region, ctx);
});
me.update_next_button(ctx);
ctx.notify();
},
);
}
}
fn sync_editors(&self, ctx: &mut ViewContext<Self>) {
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| {
let credential = self.draft_api_key.as_deref().unwrap_or_default();
let text = if crate::ai::claude_subscription::is_stored_credential(credential) {
""
} else {
credential
};
editor.system_reset_buffer_text(text, 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);
});
let mut profiles = vec!["default".to_owned()];
#[cfg(not(target_family = "wasm"))]
profiles.extend(crate::ai::provider::external_config::list_aws_profiles());
profiles.push(self.draft_bedrock.profile.clone());
profiles.retain(|profile| !profile.is_empty());
profiles.sort();
profiles.dedup();
self.bedrock_profile_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_items(
profiles
.into_iter()
.map(|profile| {
DropdownItem::new(
profile.clone(),
ProviderSetupViewAction::SelectBedrockProfile(profile),
)
})
.collect(),
ctx,
);
dropdown.set_selected_by_action(
ProviderSetupViewAction::SelectBedrockProfile(self.draft_bedrock.profile.clone()),
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<Self>) {
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<Self>) {
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);
});
}
}
fn sync_bedrock_auth_buttons(&self, ctx: &mut ViewContext<Self>) {
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<Self>) {
self.bedrock_model_switches
.resize_with(self.draft_bedrock.models.len(), SwitchStateHandle::default);
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(
ProviderSetupViewAction::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);
model.default_context_size = 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<Self>) {
let (label, disabled) = match self.step {
ProviderSetupStep::ProviderType => ("Next", false),
ProviderSetupStep::Configure => ("Next", !self.is_configure_valid()),
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::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => (
"Save",
self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled)
|| (self.provider_type == ProviderSetupProviderType::Anthropic
&& !self.is_configure_valid()),
),
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::OpenAI => OpenAIProviderKind::OpenAI,
ProviderSetupProviderType::LiteLLM => OpenAIProviderKind::LiteLLM,
ProviderSetupProviderType::ChatGPTSubscription => {
OpenAIProviderKind::ChatGPTSubscription
}
ProviderSetupProviderType::Bedrock | ProviderSetupProviderType::Acp => {
OpenAIProviderKind::LiteLLM
}
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::OpenAI
| ProviderSetupProviderType::LiteLLM
| 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 jump_to_step(&mut self, step: ProviderSetupStep, ctx: &mut ViewContext<Self>) {
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 bedrock_client_config(&self) -> crate::ai::provider::client::BedrockClientConfig {
crate::ai::provider::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,
credentials_expiry: None,
invocation_model_id: None,
cross_region_inference: self.draft_bedrock.cross_region_inference,
}
}
fn begin_discovery(&mut self, ctx: &mut ViewContext<Self>) {
self.cancel_acp_setup();
self.step = ProviderSetupStep::Discover;
self.discovery_state = DiscoveryState::Loading;
self.acp_setup_status.clear();
self.acp_discovery_generation = self.acp_discovery_generation.wrapping_add(1);
self.update_next_button(ctx);
ctx.notify();
match self.provider_type {
ProviderSetupProviderType::ChatGPTSubscription => {
self.try_discover_chatgpt(ctx);
return;
}
ProviderSetupProviderType::Bedrock => {
let config = self.bedrock_client_config();
let requested_config = config.clone();
let generation = self.acp_discovery_generation;
let verification_generation = LLMPreferences::handle(ctx)
.update(ctx, |preferences, _| preferences.begin_bedrock_verification());
ctx.spawn(
async move {
crate::ai::provider::discovery::discover_available_models(config).await
},
move |me, result, ctx| {
if generation != me.acp_discovery_generation
|| me.provider_type != ProviderSetupProviderType::Bedrock
|| me.step != ProviderSetupStep::Discover
|| me.bedrock_client_config() != requested_config
{
return;
}
match result {
Ok(discovery) => {
for record in discovery.records {
crate::ai::remote_logging::log_view_event(ctx, record);
}
let models = discovery.models;
let accepted = LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| {
preferences.record_bedrock_verification(
verification_generation,
requested_config,
&models,
ctx,
)
});
if !accepted {
me.discovery_state = DiscoveryState::Failed(
"A newer Bedrock connection test superseded this result. Retry the test.".to_owned(),
);
me.update_next_button(ctx);
ctx.notify();
return;
}
me.draft_bedrock.models =
crate::ai::provider::discovery::preserve_model_toggles(
&me.draft_bedrock.models,
models,
);
me.sync_model_switches(ctx);
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(ProviderSetupViewEvent::RequestAcpDiscovery(
self.draft_acp.clone(),
));
return;
}
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {}
}
self.discovery_state = DiscoveryState::Loading;
self.update_next_button(ctx);
ctx.notify();
let provider = self.draft_provider();
let existing_models = self.draft_models.clone();
let authoritative_catalog = provider.kind == OpenAIProviderKind::LiteLLM;
let generation = self.acp_discovery_generation;
let requested_kind = self.provider_type;
let requested_credential = self.draft_api_key.clone();
ctx.spawn(
async move { LLMPreferences::discover_openai_provider_models(provider).await },
move |me, result, ctx| {
if generation != me.acp_discovery_generation
|| requested_kind != me.provider_type
|| requested_credential != me.draft_api_key
|| me.step != ProviderSetupStep::Discover
{
return;
}
match result {
Ok(models) => {
me.draft_models = if authoritative_catalog {
replace_litellm_catalog(&existing_models, models)
} else {
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<Self>) {
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;
}
self.discovery_state = DiscoveryState::Loading;
self.update_next_button(ctx);
ctx.notify();
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 = crate::ai::llms::merge_discovered_chatgpt_subscription_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(target_family = "wasm")]
fn try_discover_chatgpt(&mut self, ctx: &mut ViewContext<Self>) {
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<dyn galaxyui::Element> {
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<EditorView>,
) -> Box<dyn galaxyui::Element> {
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<dyn galaxyui::Element> {
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::<Vec<_>>();
let list = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.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()
}
fn cancel_claude_signin(&mut self) {
self.claude_signin_generation = self.claude_signin_generation.wrapping_add(1);
if let Some(abort) = self.claude_signin_abort.take() {
abort.abort();
}
#[cfg(not(target_family = "wasm"))]
if let Some(reference) = self.draft_api_key.as_deref() {
crate::ai::claude_oauth::discard_draft(reference);
}
self.claude_signin_status.clear();
}
#[cfg(not(target_family = "wasm"))]
fn connect_claude(&mut self, ctx: &mut ViewContext<Self>) {
if self.claude_signin_abort.is_some() {
return;
}
self.cancel_claude_signin();
let login = match crate::ai::claude_oauth::BrowserLogin::start() {
Ok(login) => login,
Err(error) => {
self.claude_signin_status = error;
ctx.notify();
return;
}
};
let generation = self.claude_signin_generation;
let (abort, registration) = futures::future::AbortHandle::new_pair();
self.claude_signin_abort = Some(abort);
self.claude_signin_status = "Waiting for Claude sign-in in your browser…".to_string();
self.update_next_button(ctx);
ctx.open_url(login.url.as_str());
ctx.notify();
ctx.spawn(
futures::future::Abortable::new(login.finish(), registration),
move |me, result, ctx| {
if generation != me.claude_signin_generation
|| me.provider_type != ProviderSetupProviderType::Anthropic
{
return;
}
me.claude_signin_abort = None;
match result {
Ok(Ok(credentials)) => {
let reference = format!(
"{}{}",
crate::ai::claude_subscription::OAUTH_REFERENCE_PREFIX,
uuid::Uuid::new_v4()
);
match crate::ai::claude_oauth::save_credentials(
&reference,
&credentials,
ctx,
) {
Ok(()) => {
me.draft_api_key = Some(reference);
me.claude_credentials_available = true;
me.draft_models.clear();
me.claude_signin_status =
"Claude subscription connected.".to_string();
me.sync_editors(ctx);
}
Err(error) => me.claude_signin_status = error,
}
}
Ok(Err(error)) => me.claude_signin_status = error,
Err(_) => me.claude_signin_status = "Claude sign-in cancelled.".to_string(),
}
me.update_next_button(ctx);
ctx.notify();
},
);
}
fn render_claude_auth(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let connected = self.claude_credentials_available;
let description = if !self.claude_signin_status.is_empty() {
self.claude_signin_status.as_str()
} else if connected {
"Claude subscription credentials saved on this device."
} else {
"Sign in with your Claude subscription in your browser."
};
let mut children = vec![
Self::render_label(appearance, "Claude subscription authorization"),
Text::new(
description.to_string(),
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
];
{
let pending = self.claude_signin_abort.is_some();
children.push(
appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.claude_connect_mouse_state.clone(),
)
.with_text_label(
if pending {
"Cancel sign-in"
} else if connected {
"Sign out"
} else {
"Sign in with Claude"
}
.to_string(),
)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(if pending {
ProviderSetupViewAction::CancelClaudeSignIn
} else if connected {
ProviderSetupViewAction::SignOutClaude
} else {
ProviderSetupViewAction::ConnectClaude
});
})
.finish(),
);
}
Flex::column()
.with_spacing(10.)
.with_children(children)
.finish()
}
#[cfg(not(target_family = "wasm"))]
fn render_chatgpt_auth(
&self,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn galaxyui::Element> {
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(ProviderSetupViewAction::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<dyn galaxyui::Element> {
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<dyn galaxyui::Element> {
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::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. 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,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
);
}
ProviderSetupProviderType::Anthropic => {
children.push(self.render_claude_auth(appearance));
if !self
.draft_api_key
.as_deref()
.is_some_and(crate::ai::claude_subscription::is_stored_credential)
{
children.push(self.render_input(
appearance,
"API key or setup token (optional alternative)",
&self.api_key_editor,
));
}
children.push(
Text::new(
"Sign in with Claude to use your subscription. OAuth credentials are saved in Galaxy’s secure credential store and refreshed automatically. Alternatively, paste an API key or a token from `claude setup-token`; pasted setup tokens must be replaced manually if they expire. API keys use Anthropic API billing.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
);
children.push(
Text::new(
crate::ai::claude_subscription::SUBSCRIPTION_WARNING,
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().accent().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_label(appearance, "AWS profile"));
children.push(ChildView::new(&self.bedrock_profile_dropdown).finish());
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(
ProviderSetupViewAction::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(
ProviderSetupViewAction::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(
"Test downloads Claude Code and its ACP adapter when missing. Sign-in opens through Claude Code in your browser; Galaxy does not store your Claude credentials.",
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<dyn galaxyui::Element> {
let (message, color) = match &self.discovery_state {
DiscoveryState::Idle | DiscoveryState::Loading => (
if self.provider_type == ProviderSetupProviderType::Acp
&& !self.acp_setup_status.is_empty()
{
self.acp_setup_status.clone()
} else {
"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<Box<dyn galaxyui::Element>>,
spacing: f32,
) -> Box<dyn galaxyui::Element> {
let list = Flex::column()
.with_spacing(spacing)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.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_max_height(430.)
.finish()
}
fn model_logo(&self) -> (Icon, ColorU) {
match self.provider_type {
ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM => {
(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<dyn galaxyui::Element> {
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_capabilities(
buttons: &[ViewHandle<ActionButton>],
) -> Box<dyn galaxyui::Element> {
let first_row = buttons[..3]
.iter()
.map(|button| ChildView::new(button).finish())
.collect::<Vec<_>>();
let second_row = buttons[3..]
.iter()
.map(|button| ChildView::new(button).finish())
.collect::<Vec<_>>();
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_cells(
appearance: &Appearance,
index: usize,
model: &OpenAIModelConfig,
switch: SwitchStateHandle,
context_editor: ViewHandle<EditorView>,
buttons: &[ViewHandle<ActionButton>],
) -> Vec<Box<dyn Element>> {
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 context_input = appearance
.ui_builder()
.text_input(context_editor)
.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();
let toggle = appearance
.ui_builder()
.switch(switch)
.check(model.enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ProviderSetupViewAction::ToggleModel(index))
})
.finish();
vec![
Flex::row().with_child(toggle).finish(),
model_info,
ConstrainedBox::new(context_input).with_width(100.).finish(),
Self::render_model_capabilities(buttons),
]
}
fn render_models(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
if self.provider_type == ProviderSetupProviderType::Bedrock {
let models = self.draft_bedrock.models.clone();
let switches = self.bedrock_model_switches.clone();
let table = model_catalog_table(
self.model_table_state.clone(),
models.len(),
move |index, app| {
let appearance = Appearance::as_ref(app);
let model = &models[index];
let text = |value: String| {
Text::new(value, appearance.ui_font_family(), 11.)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.finish()
};
let toggle = appearance
.ui_builder()
.switch(switches[index].clone())
.check(model.enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ProviderSetupViewAction::ToggleModel(index))
})
.finish();
let name = Flex::column()
.with_spacing(3.)
.with_child(text(model.display_name.clone()))
.with_child(text(model.model_id.clone()))
.finish();
let context = galaxy_bedrock_model_catalog::model_metadata(&model.model_id)
.and_then(|metadata| metadata.context_window_tokens)
.map(|tokens| tokens.to_string())
.unwrap_or_else(|| "Unknown".to_owned());
vec![
Flex::row().with_child(toggle).finish(),
name,
text(context),
text(
if model.vision_supported {
"Images"
} else {
"Text"
}
.to_owned(),
),
]
},
appearance,
);
return Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch).with_spacing(12.)
.with_child(self.render_input(appearance, "Connection name", &self.name_editor))
.with_child(Text::new("Only models passing AWS agreement, authorization, entitlement, and region checks are offered. Choose which models Galaxy should enable.", 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, vec![table], 0.)).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::<Vec<_>>()
.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::<Vec<_>>();
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 models = self.draft_models.clone();
let switches = self.model_switches.clone();
let editors = self.model_context_editors.clone();
let buttons = self.model_capability_buttons.clone();
let table = model_catalog_table(
self.model_table_state.clone(),
models.len(),
move |index, app| {
Self::render_openai_model_cells(
Appearance::as_ref(app),
index,
&models[index],
switches[index].clone(),
editors[index].clone(),
&buttons[index],
)
},
appearance,
);
let table = self.render_model_table(appearance, vec![table], 0.);
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.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<dyn galaxyui::Element> {
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.can_go_back() {
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 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 {
if self.claude_signin_abort.is_some() {
return false;
}
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 => {
self.draft_api_key.as_deref().is_some_and(|key| {
if crate::ai::claude_subscription::is_stored_credential(key) {
self.claude_credentials_available
} else {
!key.trim().is_empty()
}
})
}
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<dyn galaxyui::Element> {
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<dyn galaxyui::Element> {
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
.iter()
.map(|(step, label)| self.render_step_tab(*step, label, appearance)),
)
.finish()
}
}
impl Entity for ProviderSetupView {
type Event = ProviderSetupViewEvent;
}
impl View for ProviderSetupView {
fn ui_name() -> &'static str {
"ProviderSetupView"
}
fn render(&self, app: &AppContext) -> Box<dyn galaxyui::Element> {
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_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_spacing(20.)
.with_child(self.render_step_indicator(appearance))
.with_child(content)
.with_child(self.render_footer())
.finish()
}
}
impl TypedActionView for ProviderSetupView {
type Action = ProviderSetupViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
ProviderSetupViewAction::SelectProvider(kind) => {
if self.provider_type != *kind {
self.cancel_claude_signin();
self.draft_api_key = None;
self.claude_credentials_available = false;
self.draft_models.clear();
self.discovery_state = DiscoveryState::Idle;
}
self.provider_type = *kind;
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);
if self.provider_type == ProviderSetupProviderType::Bedrock
&& self.draft_bedrock.region.is_empty()
{
self.load_bedrock_profile_region(ctx);
}
self.sync_provider_type_buttons(ctx);
self.sync_bedrock_auth_buttons(ctx);
self.update_next_button(ctx);
ctx.notify();
}
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);
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::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {
if self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled)
|| (self.provider_type == ProviderSetupProviderType::Anthropic
&& !self.is_configure_valid())
{
return;
}
ctx.emit(ProviderSetupViewEvent::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(ProviderSetupViewEvent::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(ProviderSetupViewEvent::SaveAcp(draft));
}
},
},
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();
}
ProviderSetupStep::Discover => {
self.cancel_acp_setup();
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();
}
},
ProviderSetupViewAction::Cancel => {
self.cancel_acp_setup();
self.cancel_claude_signin();
self.discovery_state = DiscoveryState::Idle;
ctx.emit(ProviderSetupViewEvent::Close);
}
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();
self.draft_acp.args.clear();
}
self.sync_editors(ctx);
self.sync_acp_agent_buttons(ctx);
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupViewAction::ToggleModel(index) => {
let models = if self.provider_type == ProviderSetupProviderType::Bedrock {
&mut self.draft_bedrock.models
} else {
if let Some(model) = self.draft_models.get_mut(*index) {
model.enabled = !model.enabled;
self.update_next_button(ctx);
ctx.notify();
}
return;
};
if let Some(model) = models.get_mut(*index) {
model.enabled = !model.enabled;
self.update_next_button(ctx);
ctx.notify();
}
}
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();
model.capability_overrides.insert(key, next);
self.update_next_button(ctx);
self.sync_model_switches(ctx);
ctx.notify();
}
}
ProviderSetupViewAction::ConnectChatGPT => {
#[cfg(not(target_family = "wasm"))]
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
}
ProviderSetupViewAction::ConnectClaude => {
#[cfg(not(target_family = "wasm"))]
self.connect_claude(ctx);
}
ProviderSetupViewAction::SignOutClaude => {
if let Some(reference) = self.draft_api_key.take() {
crate::ai::claude_subscription::remove_credential(&reference, ctx);
}
self.cancel_claude_signin();
self.claude_credentials_available = false;
self.claude_signin_status = "Signed out of Claude on this device.".to_string();
self.draft_models.clear();
self.discovery_state = DiscoveryState::Idle;
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupViewAction::CancelClaudeSignIn => {
self.cancel_claude_signin();
self.claude_signin_status = "Claude sign-in cancelled.".to_string();
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupViewAction::SelectBedrockProfile(profile) => {
if self.draft_bedrock.profile != *profile {
self.draft_bedrock.profile = profile.clone();
self.draft_bedrock.region.clear();
self.draft_bedrock.models.clear();
self.discovery_state = DiscoveryState::Idle;
self.bedrock_region_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text("", ctx);
});
// The dropdown dispatches this action while it is borrowed.
// Update its selected label only after that handler returns.
let selected_profile = profile.clone();
ctx.spawn(async {}, move |me, _, ctx| {
if me.draft_bedrock.profile != selected_profile {
return;
}
me.bedrock_profile_dropdown.update(ctx, |dropdown, ctx| {
dropdown.set_selected_by_action(
ProviderSetupViewAction::SelectBedrockProfile(selected_profile),
ctx,
);
});
});
self.load_bedrock_profile_region(ctx);
self.update_next_button(ctx);
ctx.notify();
}
}
ProviderSetupViewAction::SelectBedrockAuth(method) => {
self.draft_bedrock.auth_method = *method;
self.sync_bedrock_auth_buttons(ctx);
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupViewAction::ToggleBedrockCrossRegion => {
self.draft_bedrock.cross_region_inference =
!self.draft_bedrock.cross_region_inference;
ctx.notify();
}
ProviderSetupViewAction::ToggleBedrockAutoLogin => {
self.draft_bedrock.auto_login = !self.draft_bedrock.auto_login;
ctx.notify();
}
}
}
}
fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str {
match kind {
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 => "Claude Code",
}
}
impl Drop for ProviderSetupView {
fn drop(&mut self) {
self.cancel_claude_signin();
self.cancel_acp_setup();
}
}