feat - updating icons and glyphs, added claude code subscription integration

This commit is contained in:
2026-10-04 18:20:58 -05:00
parent 188489c890
commit b29882d56c
49 changed files with 2374 additions and 288 deletions
+213 -33
View File
@@ -163,6 +163,7 @@ pub enum AISubpage {
ProviderLiteLLM,
/// ChatGPT subscription provider settings.
ProviderChatGPTSubscription,
ProviderAnthropic,
/// AWS Bedrock provider settings.
ProviderBedrock,
/// ACP provider settings.
@@ -185,6 +186,7 @@ impl AISubpage {
SettingsSection::ProviderOpenAI => Some(Self::ProviderOpenAI),
SettingsSection::ProviderLiteLLM => Some(Self::ProviderLiteLLM),
SettingsSection::ProviderChatGPTSubscription => Some(Self::ProviderChatGPTSubscription),
SettingsSection::ProviderAnthropic => Some(Self::ProviderAnthropic),
SettingsSection::ProviderBedrock => Some(Self::ProviderBedrock),
SettingsSection::ProviderACP => Some(Self::ProviderACP),
// AgentMCPServers renders the standalone MCPServers page, not an AI subpage.
@@ -676,6 +678,83 @@ impl AISettingsPageView {
&mut self,
draft: &AcpProviderDraft,
ctx: &mut ViewContext<Self>,
) {
let generation = self
.provider_setup_body
.read(ctx, |body, _| body.acp_discovery_generation());
#[cfg(feature = "local_fs")]
if draft.agent_id.eq_ignore_ascii_case("claude-acp") && draft.command.trim().is_empty() {
let (progress, updates) = async_channel::bounded(8);
self.watch_claude_setup_progress(updates, generation, ctx);
let draft = draft.clone();
let registration = self.provider_setup_body.update(ctx, |body, ctx| {
body.set_acp_setup_status("Checking Claude Code installation…".to_owned(), ctx);
body.prepare_acp_setup()
});
let _ = ctx.spawn(
futures::future::Abortable::new(
async move { crate::ai::acp::ensure_claude_code_installed(progress).await },
registration,
),
move |me, result, ctx| {
if !me
.provider_setup_body
.read(ctx, |body, _| body.acp_discovery_is_current(generation))
{
return;
}
let Ok(result) = result else {
return;
};
match result {
Ok(()) => me.resolve_and_start_acp_discovery(&draft, generation, ctx),
Err(error) => {
log::warn!("Claude Code setup failed: {error}");
me.provider_setup_body.update(ctx, |body, ctx| {
body.finish_acp_discovery(Err(error), Vec::new(), ctx);
});
}
}
},
);
return;
}
self.resolve_and_start_acp_discovery(draft, generation, ctx);
}
#[cfg(feature = "local_fs")]
fn watch_claude_setup_progress(
&mut self,
updates: async_channel::Receiver<&'static str>,
generation: u64,
ctx: &mut ViewContext<Self>,
) {
let remaining_updates = updates.clone();
let _ = ctx.spawn(
async move { updates.recv().await },
move |me, update, ctx| {
if !me
.provider_setup_body
.read(ctx, |body, _| body.acp_discovery_is_current(generation))
{
return;
}
if let Ok(update) = update {
me.provider_setup_body.update(ctx, |body, ctx| {
body.set_acp_setup_status(update.to_owned(), ctx);
});
me.watch_claude_setup_progress(remaining_updates, generation, ctx);
}
},
);
}
#[cfg(not(target_family = "wasm"))]
fn resolve_and_start_acp_discovery(
&mut self,
draft: &AcpProviderDraft,
generation: u64,
ctx: &mut ViewContext<Self>,
) {
let config = match crate::ai::acp::AcpRuntimeModel::discovery_config_for_values(
&draft.agent_id,
@@ -691,7 +770,7 @@ impl AISettingsPageView {
return;
}
};
self.start_acp_discovery(config, draft.agent_id.clone(), ctx);
self.start_acp_discovery(config, draft.agent_id.clone(), generation, ctx);
}
#[cfg(not(target_family = "wasm"))]
@@ -699,6 +778,7 @@ impl AISettingsPageView {
&mut self,
config: galaxy_acp::AcpManagerConfig,
agent_id: String,
generation: u64,
ctx: &mut ViewContext<Self>,
) {
let provider_setup_body = self.provider_setup_body.clone();
@@ -724,6 +804,11 @@ impl AISettingsPageView {
let _ = ctx.spawn(
async move { manager.discover_config_options(cwd, Vec::new()).await },
move |_me, result, ctx| {
if !provider_setup_body
.read(ctx, |body, _| body.acp_discovery_is_current(generation))
{
return;
}
match result {
Ok(options) => {
let option_count = options.len();
@@ -1710,19 +1795,64 @@ impl AISettingsPageView {
mut provider: OpenAIProviderConfig,
ctx: &mut ViewContext<Self>,
) {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let new_reference =
match crate::ai::claude_subscription::store_credential(&mut provider, ctx) {
Ok(reference) => reference,
Err(error) => {
self.provider_setup_body
.update(ctx, |body, ctx| body.show_connection_error(error, ctx));
return;
}
};
let old_reference = editing_index.and_then(|index| {
AISettings::as_ref(ctx)
.openai_providers
.value()
.get(index)
.and_then(|provider| provider.api_key.clone())
});
let saved = AISettings::handle(ctx).update(ctx, |settings, ctx| {
let mut providers = settings.openai_providers.value().clone();
match editing_index {
Some(index) => {
if let Some(existing) = providers.get_mut(index) {
provider.enabled = existing.enabled;
*existing = provider.clone();
}
let Some(existing) = providers.get_mut(index) else {
return false;
};
provider.enabled = existing.enabled;
*existing = provider.clone();
}
None => providers.push(provider.clone()),
}
report_if_error!(settings.openai_providers.set_value(providers, ctx));
match settings.openai_providers.set_value(providers, ctx) {
Ok(_) => true,
Err(error) => {
report_error!(error);
false
}
}
});
if !saved {
if let Some(reference) = new_reference {
crate::ai::claude_subscription::remove_credential(&reference, ctx);
}
self.provider_setup_body.update(ctx, |body, ctx| {
body.show_connection_error(
"Could not save the provider connection.".to_string(),
ctx,
);
});
return;
}
if let Some(reference) = old_reference {
if !AISettings::as_ref(ctx)
.openai_providers
.value()
.iter()
.any(|provider| provider.api_key.as_deref() == Some(reference.as_str()))
{
crate::ai::claude_subscription::remove_credential(&reference, ctx);
}
}
self.clear_inline_provider_setup(ctx);
}
@@ -1929,6 +2059,12 @@ impl AISettingsPageView {
ProviderSetupProviderType::ChatGPTSubscription,
)));
}
Some(AISubpage::ProviderAnthropic) => {
widgets.push(Box::new(ProviderSettingsWidget::new(
ctx,
ProviderSetupProviderType::Anthropic,
)));
}
Some(AISubpage::ProviderBedrock) => {
widgets.push(Box::new(ProviderSettingsWidget::new(
ctx,
@@ -3297,8 +3433,22 @@ impl TypedActionView for AISettingsPageView {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let mut providers = settings.openai_providers.value().clone();
if *provider_index < providers.len() {
providers.remove(*provider_index);
report_if_error!(settings.openai_providers.set_value(providers, ctx));
let removed = providers.remove(*provider_index);
let remaining = providers.clone();
match settings.openai_providers.set_value(providers, ctx) {
Ok(_) => {
if let Some(reference) = removed.api_key {
if !remaining.iter().any(|provider| {
provider.api_key.as_deref() == Some(reference.as_str())
}) {
crate::ai::claude_subscription::remove_credential(
&reference, ctx,
);
}
}
}
Err(error) => report_error!(error),
}
}
});
self.rebuild_active_subpage(ctx);
@@ -3337,7 +3487,9 @@ impl TypedActionView for AISettingsPageView {
let mut providers = settings.acp_providers.value().clone();
if providers.is_empty() && provider_id == "legacy" {
report_if_error!(settings.acp_enabled.set_value(false, ctx));
report_if_error!(settings.acp_agent_id.set_value("codex".to_string(), ctx));
report_if_error!(settings
.acp_agent_id
.set_value("claude-acp".to_string(), ctx));
report_if_error!(settings.acp_agent_command.set_value(String::new(), ctx));
report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx));
report_if_error!(settings
@@ -3366,7 +3518,9 @@ impl TypedActionView for AISettingsPageView {
.set_value(first_provider.name.clone(), ctx));
} else {
report_if_error!(settings.acp_enabled.set_value(false, ctx));
report_if_error!(settings.acp_agent_id.set_value("codex".to_string(), ctx));
report_if_error!(settings
.acp_agent_id
.set_value("claude-acp".to_string(), ctx));
report_if_error!(settings.acp_agent_command.set_value(String::new(), ctx));
report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx));
report_if_error!(settings
@@ -6372,7 +6526,7 @@ impl SettingsWidget for ModelsOverviewWidget {
type View = AISettingsPageView;
fn search_terms(&self) -> &str {
"models providers runtimes rig litellm openai compatible ollama lm studio bedrock acp agent client protocol"
"models providers runtimes rig litellm openai compatible ollama lm studio bedrock acp agent client protocol anthropic claude subscription token"
}
fn render(
@@ -6390,6 +6544,12 @@ impl SettingsWidget for ModelsOverviewWidget {
ModelProviderSection::for_provider(provider) == ModelProviderSection::OpenAI
})
.count();
let anthropic_count = settings
.openai_providers
.value()
.iter()
.filter(|provider| provider.kind == OpenAIProviderKind::Anthropic)
.count();
let litellm_count = settings
.openai_providers
.value()
@@ -6420,13 +6580,13 @@ impl SettingsWidget for ModelsOverviewWidget {
.with_spacing(8.)
.with_child(build_sub_header(appearance, "Models", None).finish())
.with_child(render_ai_setting_description(
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI, LiteLLM, ChatGPT subscription, and Bedrock connections are configured here. ACP coding agents are shown read-only because the agent runtime controls their model, login, session, and tool loop.",
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI, LiteLLM, ChatGPT subscription, Anthropic / Claude subscription, and Bedrock connections are configured here. ACP coding agents are shown read-only because the agent runtime controls their model, login, session, and tool loop.",
true,
app,
))
.with_child(render_ai_setting_description(
format!(
"{openai_count} OpenAI provider(s); {litellm_count} LiteLLM provider(s); {chatgpt_count} ChatGPT subscription provider(s); {endpoint_model_count} direct model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled ACP runtime(s)."
"{openai_count} OpenAI provider(s); {litellm_count} LiteLLM provider(s); {chatgpt_count} ChatGPT subscription provider(s); {anthropic_count} Anthropic / Claude subscription provider(s); {endpoint_model_count} direct model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled ACP runtime(s)."
),
true,
app,
@@ -6437,6 +6597,7 @@ impl SettingsWidget for ModelsOverviewWidget {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ModelProviderSection {
Anthropic,
OpenAI,
LiteLLM,
ChatGPTSubscription,
@@ -6445,8 +6606,8 @@ enum ModelProviderSection {
impl ModelProviderSection {
fn for_provider(provider: &OpenAIProviderConfig) -> Self {
match provider.kind {
OpenAIProviderKind::Anthropic => Self::Anthropic,
OpenAIProviderKind::OpenAI
| OpenAIProviderKind::Anthropic
| OpenAIProviderKind::Gemini
| OpenAIProviderKind::VertexAI => Self::OpenAI,
OpenAIProviderKind::LiteLLM => Self::LiteLLM,
@@ -6472,6 +6633,7 @@ struct ProviderSettingsWidget {
bedrock_enabled_toggle: SwitchStateHandle,
bedrock_model_switches: RefCell<HashMap<String, SwitchStateHandle>>,
bedrock_model_table_state: TableStateHandle,
add_anthropic_provider_button: ViewHandle<ActionButton>,
add_openai_provider_button: ViewHandle<ActionButton>,
add_litellm_provider_button: ViewHandle<ActionButton>,
add_chatgpt_provider_button: ViewHandle<ActionButton>,
@@ -6529,6 +6691,15 @@ impl ProviderSettingsWidget {
}
})
.collect();
let add_anthropic_provider_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Add provider", SecondaryTheme)
.with_icon(Icon::Plus)
.on_click(|ctx| {
ctx.dispatch_typed_action(AISettingsPageAction::AddOpenAIProvider(
ProviderSetupProviderType::Anthropic,
));
})
});
let add_openai_provider_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Add provider", SecondaryTheme)
.with_icon(Icon::Plus)
@@ -6587,6 +6758,7 @@ impl ProviderSettingsWidget {
bedrock_enabled_toggle: SwitchStateHandle::default(),
bedrock_model_switches: RefCell::new(HashMap::new()),
bedrock_model_table_state: TableStateHandle::new(0, |_, _| Vec::new()),
add_anthropic_provider_button,
add_openai_provider_button,
add_litellm_provider_button,
add_chatgpt_provider_button,
@@ -6620,7 +6792,17 @@ impl ProviderSettingsWidget {
OpenAIProviderKind::OpenAI => "OpenAI",
OpenAIProviderKind::LiteLLM => "LiteLLM",
OpenAIProviderKind::ChatGPTSubscription => "ChatGPT subscription",
OpenAIProviderKind::Anthropic => "Anthropic",
OpenAIProviderKind::Anthropic => {
if provider
.api_key
.as_deref()
.is_some_and(crate::ai::claude_subscription::is_subscription_credential)
{
"Claude subscription (unsupported)"
} else {
"Anthropic API"
}
}
OpenAIProviderKind::Gemini => "Google Gemini",
OpenAIProviderKind::VertexAI => "Google Vertex AI",
}
@@ -7212,8 +7394,8 @@ impl ProviderSettingsWidget {
ProviderSetupProviderType::ChatGPTSubscription => {
Some(ModelProviderSection::ChatGPTSubscription)
}
ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
ProviderSetupProviderType::Anthropic => Some(ModelProviderSection::Anthropic),
ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI
| ProviderSetupProviderType::Bedrock
| ProviderSetupProviderType::Acp => None,
@@ -7226,8 +7408,8 @@ impl ProviderSettingsWidget {
ProviderSetupProviderType::LiteLLM => "LiteLLM",
ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT Subscription",
ProviderSetupProviderType::Bedrock => "Bedrock",
ProviderSetupProviderType::Acp => "ACP",
ProviderSetupProviderType::Anthropic => "Anthropic",
ProviderSetupProviderType::Acp => "Claude Code",
ProviderSetupProviderType::Anthropic => "Anthropic / Claude subscription",
ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
}
@@ -7248,9 +7430,9 @@ impl ProviderSettingsWidget {
"Use AWS Bedrock credentials and discovered Bedrock foundation models."
}
ProviderSetupProviderType::Acp => {
"Add an ACP agent connection here. After it is added, Settings shows it read-only because the agent controls its model, login, session, and tool loop."
"Connect Claude Code with your Claude account. Setup downloads missing components and opens sign-in in your browser."
}
ProviderSetupProviderType::Anthropic => "Connect directly to Anthropic.",
ProviderSetupProviderType::Anthropic => crate::ai::claude_subscription::SUBSCRIPTION_WARNING,
ProviderSetupProviderType::Gemini => "Connect directly to Google Gemini.",
ProviderSetupProviderType::VertexAI => "Connect directly to Google Vertex AI.",
}
@@ -7264,7 +7446,7 @@ impl ProviderSettingsWidget {
"No ChatGPT subscription provider configured."
}
ProviderSetupProviderType::Bedrock => "No Bedrock provider configured.",
ProviderSetupProviderType::Acp => "No ACP agent connection configured.",
ProviderSetupProviderType::Acp => "No Claude Code connection configured.",
ProviderSetupProviderType::Anthropic => "No Anthropic provider configured.",
ProviderSetupProviderType::Gemini => "No Google Gemini provider configured.",
ProviderSetupProviderType::VertexAI => "No Google Vertex AI provider configured.",
@@ -7283,9 +7465,8 @@ impl ProviderSettingsWidget {
}
ProviderSetupProviderType::Bedrock => Some(&self.bedrock_add_button),
ProviderSetupProviderType::Acp => Some(&self.acp_add_button),
ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => None,
ProviderSetupProviderType::Anthropic => Some(&self.add_anthropic_provider_button),
ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => None,
}
}
@@ -7369,7 +7550,8 @@ impl SettingsWidget for ProviderSettingsWidget {
match self.provider_type {
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::ChatGPTSubscription => {
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic => {
if is_setup_visible {
column.add_child(Self::render_inline_setup(appearance, view));
}
@@ -7462,22 +7644,20 @@ impl SettingsWidget for ProviderSettingsWidget {
None
};
let empty_message = if acp_supported {
"No ACP agent connection configured."
"No Claude Code connection configured."
} else {
"ACP providers are not available on this platform."
};
column.add_child(Self::render_provider_section(
"ACP",
"Add an ACP agent connection here. After it is added, Settings shows it read-only because the agent controls its model, login, session, and tool loop.",
"Claude Code",
"Connect Claude Code with your Claude account. Setup downloads missing components and opens sign-in in your browser.",
acp_add_button,
acp_cards,
empty_message,
appearance,
));
}
ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {}
ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => {}
}
column.finish()
+8
View File
@@ -250,6 +250,7 @@ pub enum SettingsSection {
ProviderOpenAI,
ProviderLiteLLM,
ProviderChatGPTSubscription,
ProviderAnthropic,
ProviderBedrock,
ProviderACP,
/// Internal backing-page identifier for CodeSettingsPageView. Multiple subpages
@@ -282,6 +283,7 @@ impl Display for SettingsSection {
SettingsSection::ProviderOpenAI => write!(f, "OpenAI"),
SettingsSection::ProviderLiteLLM => write!(f, "LiteLLM"),
SettingsSection::ProviderChatGPTSubscription => write!(f, "ChatGPT Subscription"),
SettingsSection::ProviderAnthropic => write!(f, "Anthropic / Claude subscription"),
SettingsSection::ProviderBedrock => write!(f, "Bedrock"),
SettingsSection::ProviderACP => write!(f, "ACP"),
SettingsSection::Wormhole => write!(f, "Wormhole"),
@@ -318,6 +320,7 @@ impl SettingsSection {
Self::ProviderOpenAI
| Self::ProviderLiteLLM
| Self::ProviderChatGPTSubscription
| Self::ProviderAnthropic
| Self::ProviderBedrock
| Self::ProviderACP
)
@@ -335,6 +338,7 @@ impl SettingsSection {
| Self::ProviderOpenAI
| Self::ProviderLiteLLM
| Self::ProviderChatGPTSubscription
| Self::ProviderAnthropic
| Self::ProviderBedrock
| Self::ProviderACP
)
@@ -376,6 +380,7 @@ impl SettingsSection {
Self::ProviderOpenAI,
Self::ProviderLiteLLM,
Self::ProviderChatGPTSubscription,
Self::ProviderAnthropic,
Self::ProviderBedrock,
Self::ProviderACP,
]
@@ -413,6 +418,9 @@ impl FromStr for SettingsSection {
"OpenAI / LiteLLM" | "LiteLLM" => Ok(Self::ProviderLiteLLM),
"OpenAI" => Ok(Self::ProviderOpenAI),
"ChatGPT Subscription" | "ChatGPTSubscription" => Ok(Self::ProviderChatGPTSubscription),
"Anthropic" | "Claude Subscription" | "Anthropic / Claude subscription" => {
Ok(Self::ProviderAnthropic)
}
"Agent Client Protocol" | "ACP" => Ok(Self::ProviderACP),
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
+5
View File
@@ -41,6 +41,7 @@ fn provider_subpages_are_classified_and_map_to_ai_backing_page() {
SettingsSection::ProviderOpenAI,
SettingsSection::ProviderLiteLLM,
SettingsSection::ProviderChatGPTSubscription,
SettingsSection::ProviderAnthropic,
SettingsSection::ProviderBedrock,
SettingsSection::ProviderACP,
]
@@ -116,6 +117,10 @@ fn current_settings_display_names_round_trip() {
SettingsSection::ProviderChatGPTSubscription,
"ChatGPT Subscription",
),
(
SettingsSection::ProviderAnthropic,
"Anthropic / Claude subscription",
),
(SettingsSection::ProviderBedrock, "Bedrock"),
(SettingsSection::ProviderACP, "ACP"),
(SettingsSection::CodeIndexing, "Indexing and projects"),
+344 -46
View File
@@ -67,7 +67,7 @@ use crate::ai::harness_display;
use crate::ai::llms::{merge_discovered_provider_models, replace_litellm_catalog, LLMPreferences};
use crate::appearance::Appearance;
use crate::editor::{
EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions,
EditOrigin, EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions,
};
use crate::settings::ai::{
AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride,
@@ -118,6 +118,11 @@ const PROVIDER_TYPE_OPTIONS: &[(ProviderSetupProviderType, &str, &str)] = &[
"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",
@@ -215,6 +220,9 @@ pub enum ProviderSetupViewAction {
ToggleModel(usize),
CycleModelCapability(usize, CapabilityKey),
ConnectChatGPT,
ConnectClaude,
SignOutClaude,
CancelClaudeSignIn,
SelectBedrockAuth(BedrockAuthMethod),
SelectBedrockProfile(String),
ToggleBedrockCrossRegion,
@@ -236,6 +244,9 @@ pub struct ProviderSetupView {
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>,
@@ -253,6 +264,11 @@ pub struct ProviderSetupView {
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,
@@ -300,11 +316,11 @@ impl ProviderSetupView {
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_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 mut acp_agent_buttons = galaxy_acp::known_acp_agents()
let acp_agent_buttons = galaxy_acp::known_acp_agents()
.iter()
.map(|agent| {
let id = agent.id.to_owned();
@@ -322,15 +338,6 @@ impl ProviderSetupView {
})
})
.collect::<Vec<_>>();
acp_agent_buttons.push(ctx.add_typed_action_view(|_| {
ActionButton::new("Custom", NakedTheme)
.with_full_width(true)
.on_click(|ctx| {
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectAcpAgent(
"custom".to_owned(),
));
})
}));
let bedrock_auth_buttons = [
BedrockAuthMethod::Profile,
@@ -362,9 +369,15 @@ impl ProviderSetupView {
}
});
ctx.subscribe_to_view(&api_key_editor, |me, editor, event, ctx| {
if matches!(event, EditorEvent::Edited(_)) {
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();
}
});
@@ -465,12 +478,15 @@ impl ProviderSetupView {
},
draft_acp: AcpProviderDraft {
name: String::new(),
agent_id: "codex".to_string(),
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,
@@ -488,6 +504,11 @@ impl ProviderSetupView {
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(),
@@ -542,7 +563,7 @@ impl ProviderSetupView {
ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
ProviderSetupProviderType::Bedrock => "AWS Bedrock",
ProviderSetupProviderType::Acp => "ACP agent runtime",
ProviderSetupProviderType::Acp => "Claude Code",
}
}
@@ -564,6 +585,8 @@ impl ProviderSetupView {
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;
@@ -571,6 +594,7 @@ impl ProviderSetupView {
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();
@@ -588,7 +612,7 @@ impl ProviderSetupView {
};
self.draft_acp = AcpProviderDraft {
name: String::new(),
agent_id: "codex".to_string(),
agent_id: "claude-acp".to_string(),
command: String::new(),
args: Vec::new(),
config_options: Vec::new(),
@@ -613,6 +637,7 @@ impl ProviderSetupView {
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;
@@ -631,6 +656,14 @@ impl ProviderSetupView {
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;
@@ -667,6 +700,7 @@ impl ProviderSetupView {
}
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 {
@@ -688,6 +722,43 @@ impl ProviderSetupView {
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>,
@@ -754,7 +825,13 @@ impl ProviderSetupView {
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);
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);
@@ -834,11 +911,6 @@ impl ProviderSetupView {
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<Self>) {
@@ -948,7 +1020,9 @@ impl ProviderSetupView {
| ProviderSetupProviderType::VertexAI => (
"Save",
self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled),
|| !self.draft_models.iter().any(|model| model.enabled)
|| (self.provider_type == ProviderSetupProviderType::Anthropic
&& !self.is_configure_valid()),
),
ProviderSetupProviderType::Bedrock => (
"Save",
@@ -1057,8 +1131,11 @@ impl ProviderSetupView {
}
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();
@@ -1136,26 +1213,38 @@ impl ProviderSetupView {
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| 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();
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;
}
Err(error) => {
me.discovery_state = DiscoveryState::Failed(error);
me.update_next_button(ctx);
ctx.notify();
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();
}
}
},
);
@@ -1313,6 +1402,138 @@ impl ProviderSetupView {
.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,
@@ -1438,10 +1659,21 @@ impl ProviderSetupView {
);
}
ProviderSetupProviderType::Anthropic => {
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
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(
"The key is stored locally and is never synced to the cloud. Models will be discovered from Anthropic after the connection test.",
"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,
)
@@ -1449,6 +1681,16 @@ impl ProviderSetupView {
.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));
@@ -1576,7 +1818,7 @@ impl ProviderSetupView {
}
children.push(
Text::new(
"Known NPM-backed clients use npx with a pinned package version. If an ACP client needs another toolchain, choose Custom and configure the executable after installing it yourself.",
"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,
)
@@ -1596,7 +1838,13 @@ impl ProviderSetupView {
fn render_discovery(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let (message, color) = match &self.discovery_state {
DiscoveryState::Idle | DiscoveryState::Loading => (
"Testing the connection and discovering available models...".to_string(),
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()),
@@ -2012,6 +2260,9 @@ impl ProviderSetupView {
}
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()
@@ -2021,7 +2272,16 @@ impl ProviderSetupView {
.is_some_and(|key| !key.trim().is_empty())
}
ProviderSetupProviderType::LiteLLM => !self.draft_base_url.trim().is_empty(),
ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini => self
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()),
@@ -2202,6 +2462,9 @@ impl TypedActionView for ProviderSetupView {
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;
}
@@ -2244,6 +2507,8 @@ impl TypedActionView for ProviderSetupView {
| 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;
}
@@ -2282,6 +2547,7 @@ impl TypedActionView for ProviderSetupView {
ctx.notify();
}
ProviderSetupStep::Discover => {
self.cancel_acp_setup();
self.step = ProviderSetupStep::Configure;
self.discovery_state = DiscoveryState::Idle;
self.update_next_button(ctx);
@@ -2294,6 +2560,9 @@ impl TypedActionView for ProviderSetupView {
}
},
ProviderSetupViewAction::Cancel => {
self.cancel_acp_setup();
self.cancel_claude_signin();
self.discovery_state = DiscoveryState::Idle;
ctx.emit(ProviderSetupViewEvent::Close);
}
ProviderSetupViewAction::SelectAcpAgent(agent_id) => {
@@ -2338,6 +2607,28 @@ impl TypedActionView for ProviderSetupView {
#[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();
@@ -2394,6 +2685,13 @@ fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str {
ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
ProviderSetupProviderType::Bedrock => "AWS Bedrock",
ProviderSetupProviderType::Acp => "ACP agent runtime",
ProviderSetupProviderType::Acp => "Claude Code",
}
}
impl Drop for ProviderSetupView {
fn drop(&mut self) {
self.cancel_claude_signin();
self.cancel_acp_setup();
}
}