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
+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();
}
}