feat - updating icons and glyphs, added claude code subscription integration
This commit is contained in:
@@ -189,6 +189,8 @@ fn preset_version(agent_id: &str, custom_command: &str) -> Option<&'static str>
|
||||
match agent_id.trim().to_ascii_lowercase().as_str() {
|
||||
"codex" => Some(CODEX_ACP_NPM_VERSION),
|
||||
"opencode" => Some(OPENCODE_NPM_VERSION),
|
||||
#[cfg(feature = "local_fs")]
|
||||
"claude-acp" => Some(super::setup::CLAUDE_ACP_VERSION),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -241,6 +243,26 @@ pub(crate) fn resolve_acp_launch(
|
||||
match agent_id.trim().to_ascii_lowercase().as_str() {
|
||||
"codex" => AcpAgentPreset::Codex.resolve_launch_config(),
|
||||
"opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(),
|
||||
#[cfg(feature = "local_fs")]
|
||||
"claude-acp" => super::setup::resolve_claude_code_launch(),
|
||||
// Removed from the setup catalog, but keep saved connections and their
|
||||
// conversation launch identities usable rather than silently migrating
|
||||
// an existing session to Claude Code.
|
||||
"gemini" | "cline" => AcpLaunchConfig::new(agent_id.trim().to_ascii_lowercase())
|
||||
.args(["--acp"])
|
||||
.resolve_command(),
|
||||
"cursor" => AcpLaunchConfig::new("cursor-agent")
|
||||
.args(["acp"])
|
||||
.resolve_command(),
|
||||
"github-copilot-cli" => AcpLaunchConfig::new("copilot")
|
||||
.args(["--acp"])
|
||||
.resolve_command(),
|
||||
"goose" => AcpLaunchConfig::new("goose")
|
||||
.args(["acp"])
|
||||
.resolve_command(),
|
||||
"auggie" => AcpLaunchConfig::new("auggie")
|
||||
.args(["acp"])
|
||||
.resolve_command(),
|
||||
_ => resolve_known_acp_agent(agent_id).map_err(|error| {
|
||||
format!(
|
||||
"{error} Configure a custom ACP executable if this client uses a different command."
|
||||
|
||||
@@ -8,6 +8,8 @@ mod launch;
|
||||
mod permissions;
|
||||
mod prompt;
|
||||
mod runtime_model;
|
||||
#[cfg(feature = "local_fs")]
|
||||
mod setup;
|
||||
mod transport;
|
||||
|
||||
pub(crate) use launch::{
|
||||
@@ -17,6 +19,8 @@ pub(crate) use launch::{
|
||||
};
|
||||
pub(crate) use permissions::resolve_acp_permissions;
|
||||
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) use setup::ensure_claude_code_installed;
|
||||
pub(crate) use transport::{
|
||||
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionMetadata,
|
||||
AcpTurnControlSlot, GalaxyMcpTarget,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Explicit Claude Code setup. Downloads happen only when the user tests a
|
||||
//! connection; normal launches resolve installed components without network I/O.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::process::Output;
|
||||
use std::time::Duration;
|
||||
|
||||
use command::r#async::Command;
|
||||
use futures::future::{select, Either};
|
||||
use galaxy_acp::AcpLaunchConfig;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// Keep the adapter and the official native CLI installation reproducible.
|
||||
// npm verifies package integrity and selects Anthropic's platform binary.
|
||||
pub(super) const CLAUDE_ACP_VERSION: &str = "0.85.1";
|
||||
const CLAUDE_CODE_VERSION: &str = "2.1.288";
|
||||
static SETUP_LOCK: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
fn installation_dir() -> PathBuf {
|
||||
galaxy_core::paths::data_dir()
|
||||
.join("acp")
|
||||
.join("claude-code")
|
||||
}
|
||||
|
||||
fn installed_command(command: &str) -> Option<PathBuf> {
|
||||
AcpLaunchConfig::new(command)
|
||||
.resolve_command()
|
||||
.ok()
|
||||
.map(|launch| launch.command)
|
||||
}
|
||||
|
||||
fn package_binary(package_dir: &Path) -> Option<PathBuf> {
|
||||
let manifest: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(package_dir.join("package.json")).ok()?).ok()?;
|
||||
let binary = manifest.get("bin")?.get("claude")?.as_str()?;
|
||||
let binary = Path::new(binary);
|
||||
if binary.is_absolute()
|
||||
|| binary
|
||||
.components()
|
||||
.any(|component| !matches!(component, Component::Normal(_)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let binary = package_dir.join(binary);
|
||||
binary.is_file().then_some(binary)
|
||||
}
|
||||
|
||||
fn claude_binary() -> Option<PathBuf> {
|
||||
installed_command("claude")
|
||||
.or_else(|| {
|
||||
let home = std::env::var_os("HOME")?;
|
||||
let path = PathBuf::from(home).join(".local/bin/claude");
|
||||
path.is_file().then_some(path)
|
||||
})
|
||||
.or_else(|| {
|
||||
package_binary(&installation_dir().join("node_modules/@anthropic-ai/claude-code"))
|
||||
})
|
||||
}
|
||||
|
||||
fn adapter_script() -> PathBuf {
|
||||
installation_dir().join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js")
|
||||
}
|
||||
|
||||
fn managed_adapter_ready(directory: &Path) -> bool {
|
||||
directory
|
||||
.join(format!("ready-{CLAUDE_ACP_VERSION}"))
|
||||
.is_file()
|
||||
&& directory
|
||||
.join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js")
|
||||
.is_file()
|
||||
}
|
||||
|
||||
pub(super) fn resolve_claude_code_launch() -> Result<AcpLaunchConfig, String> {
|
||||
let claude = claude_binary().ok_or_else(|| {
|
||||
"Claude Code is not installed. Test the Claude Code connection in Settings to download it."
|
||||
.to_owned()
|
||||
})?;
|
||||
let launch = if let Some(adapter) = installed_command("claude-agent-acp") {
|
||||
AcpLaunchConfig::new(adapter)
|
||||
} else {
|
||||
let script = adapter_script();
|
||||
if !managed_adapter_ready(&installation_dir()) {
|
||||
return Err("The Claude Code ACP adapter is missing. Test the connection in Settings to download it.".to_owned());
|
||||
}
|
||||
let managed_node = node_runtime::node_binary_path().map_err(|error| error.to_string())?;
|
||||
let node = if managed_node.is_file() {
|
||||
managed_node
|
||||
} else {
|
||||
installed_command("node").ok_or_else(|| "Claude Code's ACP adapter needs Node.js. Test the connection in Settings to install it.".to_owned())?
|
||||
};
|
||||
AcpLaunchConfig::new(node).args([script.to_string_lossy().into_owned()])
|
||||
};
|
||||
let mut path = launch
|
||||
.command
|
||||
.parent()
|
||||
.into_iter()
|
||||
.map(Path::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
if let Ok(node) = node_runtime::node_binary_path() {
|
||||
if node.is_file() {
|
||||
if let Some(directory) = node.parent() {
|
||||
path.insert(0, directory.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(existing) = std::env::var_os("PATH") {
|
||||
path.extend(std::env::split_paths(&existing));
|
||||
}
|
||||
let path = std::env::join_paths(path).map_err(|error| error.to_string())?;
|
||||
Ok(launch
|
||||
// The adapter must use this unmodified binary, rather than silently
|
||||
// selecting another SDK-bundled CLI. Auth stays entirely in Claude Code.
|
||||
.env("CLAUDE_CODE_EXECUTABLE", claude.to_string_lossy())
|
||||
.env("PATH", path.to_string_lossy()))
|
||||
}
|
||||
|
||||
/// Prepare missing components and authenticate with Claude Code's own browser
|
||||
/// flow. Never read its token store or include auth command output in logs.
|
||||
pub(crate) async fn ensure_claude_code_installed(
|
||||
progress: async_channel::Sender<&'static str>,
|
||||
) -> Result<(), String> {
|
||||
let _lock = SETUP_LOCK.lock().await;
|
||||
let needs_cli = claude_binary().is_none();
|
||||
let needs_adapter = installed_command("claude-agent-acp").is_none()
|
||||
&& !managed_adapter_ready(&installation_dir());
|
||||
if needs_cli || needs_adapter {
|
||||
let _ = progress.try_send("Downloading missing Claude Code components…");
|
||||
log::info!("Claude Code setup: missing_cli={needs_cli}, missing_adapter={needs_adapter}");
|
||||
// Reuse Galaxy's private Node runtime; no global install or administrator
|
||||
// permission is needed, even when Node/npm are absent from the user's PATH.
|
||||
let client = http_client::Client::new();
|
||||
match select(
|
||||
Box::pin(node_runtime::install_npm(&client)),
|
||||
Box::pin(async_io::Timer::after(Duration::from_secs(300))),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either::Left((result, _)) => {
|
||||
result.map_err(|error| {
|
||||
format!("Could not install Claude Code's Node runtime: {error}")
|
||||
})?;
|
||||
}
|
||||
Either::Right(_) => {
|
||||
return Err(
|
||||
"Downloading Claude Code's Node runtime timed out. Test again to retry."
|
||||
.to_owned(),
|
||||
)
|
||||
}
|
||||
}
|
||||
let node = node_runtime::node_binary_path().map_err(|error| error.to_string())?;
|
||||
let npm = node_runtime::npm_binary_path().map_err(|error| error.to_string())?;
|
||||
let directory = installation_dir();
|
||||
async_fs::create_dir_all(&directory)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut command = Command::new(&node);
|
||||
command
|
||||
.arg(npm)
|
||||
.args([
|
||||
"install",
|
||||
"--registry=https://registry.npmjs.org",
|
||||
"--ignore-scripts=false",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--save-exact",
|
||||
])
|
||||
.arg("--prefix")
|
||||
.arg(&directory)
|
||||
.current_dir(&directory);
|
||||
if needs_cli {
|
||||
command.arg(format!("@anthropic-ai/claude-code@{CLAUDE_CODE_VERSION}"));
|
||||
}
|
||||
if needs_adapter {
|
||||
command.arg(format!(
|
||||
"@agentclientprotocol/claude-agent-acp@{CLAUDE_ACP_VERSION}"
|
||||
));
|
||||
}
|
||||
let mut paths = vec![node
|
||||
.parent()
|
||||
.ok_or("Node runtime has no directory")?
|
||||
.to_owned()];
|
||||
if let Some(path) = std::env::var_os("PATH") {
|
||||
paths.extend(std::env::split_paths(&path));
|
||||
}
|
||||
command.env(
|
||||
"PATH",
|
||||
std::env::join_paths(paths).map_err(|error| error.to_string())?,
|
||||
);
|
||||
let output = run_command(&mut command, Duration::from_secs(300)).await?;
|
||||
if !output.status.success() {
|
||||
// Installation runs no model request and has no Claude credentials.
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
let detail = error.chars().take(2000).collect::<String>();
|
||||
return Err(format!(
|
||||
"Could not download Claude Code components: {detail}"
|
||||
));
|
||||
}
|
||||
if needs_adapter {
|
||||
if !adapter_script().is_file() {
|
||||
return Err("The Claude Code adapter download completed without its executable. Test again to retry.".to_owned());
|
||||
}
|
||||
// A cancelled/failed npm install may leave the entrypoint behind.
|
||||
// Only a completed install is eligible for future launches.
|
||||
async_fs::write(
|
||||
directory.join(format!("ready-{CLAUDE_ACP_VERSION}")),
|
||||
b"ready",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
let launch = resolve_claude_code_launch()?;
|
||||
let claude = launch
|
||||
.env
|
||||
.get("CLAUDE_CODE_EXECUTABLE")
|
||||
.ok_or("Claude Code executable is missing")?;
|
||||
let _ = progress.try_send("Checking Claude Code sign-in…");
|
||||
let status = run_command(
|
||||
claude_auth_command(claude).args(["auth", "status", "--json"]),
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.await?;
|
||||
let status_json: serde_json::Value = serde_json::from_slice(&status.stdout)
|
||||
.map_err(|_| "Claude Code could not report its login status. Run `claude auth login`, then test again.".to_owned())?;
|
||||
if !status.status.success()
|
||||
|| status_json
|
||||
.get("loggedIn")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
!= Some(true)
|
||||
{
|
||||
let _ = progress.try_send("Opening Claude Code sign-in in your browser…");
|
||||
let login = run_command(
|
||||
claude_auth_command(claude).args(["auth", "login", "--claudeai"]),
|
||||
Duration::from_secs(300),
|
||||
)
|
||||
.await?;
|
||||
if !login.status.success() {
|
||||
return Err("Claude Code sign-in did not complete. Complete sign-in through Claude Code and test again.".to_owned());
|
||||
}
|
||||
}
|
||||
let _ = progress.try_send("Discovering Claude Code models…");
|
||||
log::info!("Claude Code setup: components ready; starting ACP discovery");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn claude_auth_command(claude: &str) -> Command {
|
||||
let mut command = Command::new(claude);
|
||||
// Match the ACP launch's sanitized environment: an inherited API key or
|
||||
// provider redirect must not make setup report a subscription as signed in.
|
||||
for variable in [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"CLAUDE_CODE_USE_FOUNDRY",
|
||||
] {
|
||||
command.env_remove(variable);
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
async fn run_command(command: &mut Command, timeout: Duration) -> Result<Output, String> {
|
||||
command.kill_on_drop(true);
|
||||
match select(
|
||||
Box::pin(command.output()),
|
||||
Box::pin(async_io::Timer::after(timeout)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Either::Left((result, _)) => result.map_err(|error| error.to_string()),
|
||||
Either::Right(_) => {
|
||||
Err("Claude Code setup timed out. Test the connection again to retry.".to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "setup_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,52 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn incomplete_adapter_installation_is_not_launchable() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let script = directory
|
||||
.path()
|
||||
.join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js");
|
||||
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
|
||||
std::fs::write(&script, b"entrypoint").unwrap();
|
||||
assert!(!managed_adapter_ready(directory.path()));
|
||||
std::fs::write(
|
||||
directory.path().join(format!("ready-{CLAUDE_ACP_VERSION}")),
|
||||
b"ready",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(managed_adapter_ready(directory.path()));
|
||||
std::fs::remove_file(script).unwrap();
|
||||
assert!(!managed_adapter_ready(directory.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_package_binary_is_resolved_from_its_manifest() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(directory.path().join("bin")).unwrap();
|
||||
std::fs::write(directory.path().join("bin/claude.exe"), b"binary").unwrap();
|
||||
std::fs::write(
|
||||
directory.path().join("package.json"),
|
||||
r#"{"bin":{"claude":"bin/claude.exe"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
package_binary(directory.path()),
|
||||
Some(directory.path().join("bin/claude.exe"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_binary_cannot_escape_the_installation() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let package = directory.path().join("package");
|
||||
std::fs::create_dir(&package).unwrap();
|
||||
std::fs::write(directory.path().join("claude"), b"outside").unwrap();
|
||||
for binary in ["../claude", "/bin/sh", "missing"] {
|
||||
std::fs::write(
|
||||
package.join("package.json"),
|
||||
serde_json::json!({"bin": {"claude": binary}}).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(package_binary(&package), None);
|
||||
}
|
||||
}
|
||||
@@ -333,17 +333,16 @@ impl SearchCodebaseExecutor {
|
||||
));
|
||||
};
|
||||
|
||||
let search_dir;
|
||||
let is_cross_repo;
|
||||
if FeatureFlag::CrossRepoContext.is_enabled() {
|
||||
is_cross_repo = codebase_path
|
||||
.as_ref()
|
||||
.is_some_and(|path| !current_working_directory.starts_with(path));
|
||||
search_dir = codebase_path.unwrap_or(current_working_directory);
|
||||
let (is_cross_repo, search_dir) = if FeatureFlag::CrossRepoContext.is_enabled() {
|
||||
(
|
||||
codebase_path
|
||||
.as_ref()
|
||||
.is_some_and(|path| !current_working_directory.starts_with(path)),
|
||||
codebase_path.unwrap_or(current_working_directory),
|
||||
)
|
||||
} else {
|
||||
is_cross_repo = false;
|
||||
search_dir = current_working_directory;
|
||||
}
|
||||
(false, current_working_directory)
|
||||
};
|
||||
let server_output_id = get_server_output_id(conversation_id, ctx);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SearchCodebaseRequested {
|
||||
|
||||
@@ -79,7 +79,7 @@ use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
|
||||
use crate::ai::blocklist::secret_redaction::{redact_secrets_in_element, SecretRedactionState};
|
||||
use crate::ai::blocklist::view_util::error_color;
|
||||
use crate::ai::blocklist::{BlocklistAIActionModel, ShellCommandExecutor, TextLocation};
|
||||
use crate::ai::loading::shimmering_warp_loading_text;
|
||||
use crate::ai::loading::{loading_logo_size, shimmering_warp_loading_text, LOADING_LOGO_GAP};
|
||||
use crate::ai::AIRequestUsageModel;
|
||||
use crate::code::editor::view::CodeEditorView;
|
||||
use crate::code::editor_management::CodeSource;
|
||||
@@ -625,14 +625,9 @@ pub fn render_warping_indicator_base(
|
||||
// will be rendered on a second line below the warping text. Captured before
|
||||
// `secondary_element` is consumed so the container can reserve room for it.
|
||||
let has_secondary_element = secondary_element.is_some();
|
||||
// Unicode code point for the Warp glyph that is embedded in the version of Roboto we bundle
|
||||
// into the app. This code point MUST be rendered using Roboto (the default ui font) or else the
|
||||
// glyph may not be rendered.
|
||||
const WARP_GLYPH: &str = "\u{E500}";
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let should_indent_tip_for_warp_glyph = matches!(
|
||||
let should_indent_tip_for_logo = matches!(
|
||||
warping_indicator_text,
|
||||
MaybeShimmeringText::Shimmering { .. }
|
||||
);
|
||||
@@ -640,7 +635,9 @@ pub fn render_warping_indicator_base(
|
||||
let text = render_output_status_text(warping_indicator_text, appearance, app);
|
||||
|
||||
let text_content = {
|
||||
let mut row = Flex::row().with_child(Shrinkable::new(1., text).finish());
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., text).finish());
|
||||
|
||||
if let Some(non_shimmering) = non_shimmering_text {
|
||||
let additional = render_output_status_text(
|
||||
@@ -660,25 +657,11 @@ pub fn render_warping_indicator_base(
|
||||
|
||||
let mut text_col = Flex::column();
|
||||
if let Some(sub_element) = secondary_element {
|
||||
// Our warping indicator text prepends the Warp glyph (and a space) to the label.
|
||||
// If we render the tip directly underneath, it will align to the glyph instead of
|
||||
// the start of the actual warping text.
|
||||
let sub_element = if should_indent_tip_for_warp_glyph {
|
||||
// Align the tip with the text after the Galaxy logo and its spacing.
|
||||
let sub_element = if should_indent_tip_for_logo {
|
||||
let font_size = appearance.monospace_font_size() - 3.;
|
||||
let glyph_indent = Text::new_inline(
|
||||
format!("{WARP_GLYPH} "),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
)
|
||||
.with_color(ColorU::new(0, 0, 0, 0))
|
||||
.with_selectable(false)
|
||||
.soft_wrap(false)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(glyph_indent)
|
||||
.with_child(Shrinkable::new(1., sub_element).finish())
|
||||
Container::new(Shrinkable::new(1., sub_element).finish())
|
||||
.with_margin_left(loading_logo_size(font_size) + LOADING_LOGO_GAP)
|
||||
.finish()
|
||||
} else {
|
||||
Shrinkable::new(1., sub_element).finish()
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
//! Pi-compatible Claude subscription OAuth. Secrets are never logged or serialized into settings.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct Credentials {
|
||||
pub(crate) access_token: String,
|
||||
refresh_token: String,
|
||||
expires_at: u64,
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) use desktop::*;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod desktop {
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use futures::channel::oneshot;
|
||||
use futures::StreamExt;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use galaxyui_extras::secure_storage::AppContextExt as _;
|
||||
use instant::Instant;
|
||||
use rand::RngCore;
|
||||
use settings::Setting;
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
|
||||
use super::Credentials;
|
||||
use crate::ai::claude_subscription::{is_stored_credential, OAUTH_REFERENCE_PREFIX};
|
||||
use crate::settings::AISettings;
|
||||
|
||||
// Pi's browser flow, checked 2026-10-04:
|
||||
// https://github.com/earendil-works/pi/blob/main/packages/ai/src/auth/oauth/anthropic.ts
|
||||
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize";
|
||||
const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
|
||||
const REDIRECT_URI: &str = "http://localhost:53692/callback";
|
||||
const SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
||||
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
type Reply = oneshot::Sender<Result<String, String>>;
|
||||
enum Command {
|
||||
Resolve(String, Reply),
|
||||
Invalidate(String),
|
||||
DiscardDraft(String),
|
||||
}
|
||||
static SENDER: OnceLock<Mutex<Option<async_channel::Sender<Command>>>> = OnceLock::new();
|
||||
|
||||
fn send(command: Command) -> Result<(), String> {
|
||||
SENDER
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.ok_or("Claude authorization service is unavailable.")?
|
||||
.try_send(command)
|
||||
.map_err(|_| "Claude authorization service is busy or unavailable.".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate(reference: &str) {
|
||||
let _ = send(Command::Invalidate(reference.to_string()));
|
||||
}
|
||||
|
||||
pub(crate) fn discard_draft(reference: &str) {
|
||||
if reference.starts_with(OAUTH_REFERENCE_PREFIX) {
|
||||
let _ = send(Command::DiscardDraft(reference.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_credential(reference: String) -> Result<String, String> {
|
||||
if !is_stored_credential(&reference) {
|
||||
return Ok(reference);
|
||||
}
|
||||
let (tx, rx) = oneshot::channel();
|
||||
send(Command::Resolve(reference, tx))?;
|
||||
let result = futures::future::select(
|
||||
Box::pin(rx),
|
||||
Box::pin(async_io::Timer::after(Duration::from_secs(35))),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
futures::future::Either::Left((result, _)) => {
|
||||
result.map_err(|_| "Claude authorization service stopped.".to_string())?
|
||||
}
|
||||
futures::future::Either::Right(_) => {
|
||||
Err("Claude token refresh timed out. Try again or sign in again.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Refresh {
|
||||
id: uuid::Uuid,
|
||||
replies: Vec<Reply>,
|
||||
}
|
||||
|
||||
/// Marshals secure-storage access to the UI thread and coalesces concurrent refreshes.
|
||||
pub(crate) struct ClaudeOAuthModel {
|
||||
refreshing: HashMap<String, Refresh>,
|
||||
}
|
||||
impl Entity for ClaudeOAuthModel {
|
||||
type Event = ();
|
||||
}
|
||||
impl SingletonEntity for ClaudeOAuthModel {}
|
||||
|
||||
impl ClaudeOAuthModel {
|
||||
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let (tx, rx) = async_channel::bounded(64);
|
||||
*SENDER.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(tx);
|
||||
ctx.spawn_stream_local(
|
||||
rx,
|
||||
|me, command, ctx| match command {
|
||||
Command::Resolve(reference, reply) => me.resolve(reference, reply, ctx),
|
||||
Command::Invalidate(reference) => me.invalidate(&reference),
|
||||
Command::DiscardDraft(reference) => {
|
||||
let saved = AISettings::as_ref(ctx)
|
||||
.openai_providers
|
||||
.value()
|
||||
.iter()
|
||||
.any(|provider| provider.api_key.as_deref() == Some(&reference));
|
||||
if !saved {
|
||||
me.invalidate(&reference);
|
||||
let _ = ctx.secure_storage().remove_value(&reference);
|
||||
}
|
||||
}
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
Self {
|
||||
refreshing: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate(&mut self, reference: &str) {
|
||||
if let Some(refresh) = self.refreshing.remove(reference) {
|
||||
for reply in refresh.replies {
|
||||
let _ = reply.send(Err("Claude subscription was signed out.".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(&mut self, reference: String, reply: Reply, ctx: &mut ModelContext<Self>) {
|
||||
if reply.is_canceled() {
|
||||
return;
|
||||
}
|
||||
if let Some(refresh) = self.refreshing.get_mut(&reference) {
|
||||
refresh.replies.push(reply);
|
||||
return;
|
||||
}
|
||||
let value = match ctx.secure_storage().read_value(&reference) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
let _ = reply.send(Err(
|
||||
"Claude subscription is signed out. Sign in again.".to_string()
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !reference.starts_with(OAUTH_REFERENCE_PREFIX) {
|
||||
let _ = reply.send(Ok(value));
|
||||
return;
|
||||
}
|
||||
let credentials: Credentials = match serde_json::from_str(&value) {
|
||||
Ok(credentials) => credentials,
|
||||
Err(_) => {
|
||||
let _ = reply.send(Err(
|
||||
"Claude credentials are invalid. Sign in again.".to_string()
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if credentials.expires_at > now() {
|
||||
let _ = reply.send(Ok(credentials.access_token));
|
||||
return;
|
||||
}
|
||||
let id = uuid::Uuid::new_v4();
|
||||
self.refreshing.insert(
|
||||
reference.clone(),
|
||||
Refresh {
|
||||
id,
|
||||
replies: vec![reply],
|
||||
},
|
||||
);
|
||||
ctx.spawn(
|
||||
async move {
|
||||
token_request(serde_json::json!({
|
||||
"grant_type": "refresh_token", "client_id": CLIENT_ID,
|
||||
"refresh_token": credentials.refresh_token,
|
||||
}))
|
||||
.await
|
||||
},
|
||||
move |me, result, ctx| {
|
||||
if me
|
||||
.refreshing
|
||||
.get(&reference)
|
||||
.is_none_or(|refresh| refresh.id != id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let refresh = me.refreshing.remove(&reference).unwrap();
|
||||
let result = result.and_then(|credentials| {
|
||||
// Signing out deletes the store entry before this callback can commit.
|
||||
if ctx.secure_storage().read_value(&reference).is_err() {
|
||||
return Err("Claude subscription was signed out.".to_string());
|
||||
}
|
||||
save_credentials(&reference, &credentials, ctx)?;
|
||||
Ok(credentials.access_token)
|
||||
});
|
||||
for reply in refresh.replies {
|
||||
let _ = reply.send(result.clone());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
pub(crate) fn save_credentials(
|
||||
reference: &str,
|
||||
credentials: &Credentials,
|
||||
ctx: &galaxyui::AppContext,
|
||||
) -> Result<(), String> {
|
||||
let json = serde_json::to_string(credentials)
|
||||
.map_err(|_| "Could not encode Claude credentials.".to_string())?;
|
||||
ctx.secure_storage()
|
||||
.write_value_with_owner_only_fallback(reference, &json)
|
||||
.map_err(|_| {
|
||||
"Could not securely save Claude credentials. Sign-in was not saved.".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
expires_in: u64,
|
||||
}
|
||||
|
||||
async fn token_request(body: serde_json::Value) -> Result<Credentials, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|_| "Could not create Claude authorization client.".to_string())?;
|
||||
let response = client
|
||||
.post(TOKEN_URL)
|
||||
.header("Accept", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
"Claude authorization request failed. Try signing in again.".to_string()
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
// Never expose response bodies: providers may echo authorization codes or tokens.
|
||||
return Err(format!(
|
||||
"Claude authorization failed (HTTP {}). Sign in again.",
|
||||
response.status().as_u16()
|
||||
));
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > 64 * 1024)
|
||||
{
|
||||
return Err("Claude authorization returned an oversized response.".to_string());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|_| "Claude authorization response failed.".to_string())?;
|
||||
if bytes.len().saturating_add(chunk.len()) > 64 * 1024 {
|
||||
return Err("Claude authorization returned an oversized response.".to_string());
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let tokens: TokenResponse = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| "Claude authorization returned invalid credentials.".to_string())?;
|
||||
credentials_from_response(tokens)
|
||||
}
|
||||
|
||||
fn credentials_from_response(tokens: TokenResponse) -> Result<Credentials, String> {
|
||||
if !tokens.access_token.contains("sk-ant-oat")
|
||||
|| tokens.refresh_token.is_empty()
|
||||
|| tokens.expires_in == 0
|
||||
{
|
||||
return Err("Claude authorization returned incomplete credentials.".to_string());
|
||||
}
|
||||
Ok(Credentials {
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
expires_at: now()
|
||||
.saturating_add(tokens.expires_in)
|
||||
.saturating_sub(5 * 60),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct BrowserLogin {
|
||||
pub(crate) url: Url,
|
||||
verifier: String,
|
||||
callback: oneshot::Receiver<Result<String, String>>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
impl Drop for BrowserLogin {
|
||||
fn drop(&mut self) {
|
||||
self.cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
impl BrowserLogin {
|
||||
pub(crate) fn start() -> Result<Self, String> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 53692))
|
||||
.map_err(|_| "Claude sign-in needs localhost port 53692. Close another sign-in attempt and try again.".to_string())?;
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|_| "Could not configure Claude sign-in callback.".to_string())?;
|
||||
let mut bytes = [0; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
let verifier = URL_SAFE_NO_PAD.encode(bytes);
|
||||
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
|
||||
let url = authorize_url(&verifier, &challenge)?;
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let stop = cancelled.clone();
|
||||
let state = verifier.clone();
|
||||
let (tx, callback) = oneshot::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("claude-oauth-callback".to_string())
|
||||
.spawn(move || {
|
||||
let deadline = Instant::now() + SIGN_IN_TIMEOUT;
|
||||
while !stop.load(Ordering::Relaxed) && Instant::now() < deadline {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
if let Some(code) = read_callback(stream, &state) {
|
||||
let _ = tx.send(Ok(code));
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(50))
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = tx.send(Err("Claude sign-in callback failed.".to_string()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = tx.send(Err(
|
||||
"Claude browser sign-in expired or was cancelled.".to_string()
|
||||
));
|
||||
})
|
||||
.map_err(|_| "Could not start Claude sign-in callback.".to_string())?;
|
||||
Ok(Self {
|
||||
url,
|
||||
verifier,
|
||||
callback,
|
||||
cancelled,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn finish(mut self) -> Result<Credentials, String> {
|
||||
let code = (&mut self.callback)
|
||||
.await
|
||||
.map_err(|_| "Claude sign-in callback stopped.".to_string())??;
|
||||
token_request(serde_json::json!({
|
||||
"grant_type": "authorization_code", "client_id": CLIENT_ID, "code": code,
|
||||
"state": self.verifier, "redirect_uri": REDIRECT_URI, "code_verifier": self.verifier,
|
||||
})).await
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn authorize_url(verifier: &str, challenge: &str) -> Result<Url, String> {
|
||||
let mut url = Url::parse(AUTHORIZE_URL)
|
||||
.map_err(|_| "Invalid Claude authorization URL.".to_string())?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("code", "true")
|
||||
.append_pair("client_id", CLIENT_ID)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("redirect_uri", REDIRECT_URI)
|
||||
.append_pair("scope", SCOPES)
|
||||
.append_pair("code_challenge", challenge)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("state", verifier);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub(super) fn callback_code(request: &str, state: &str) -> Option<String> {
|
||||
let mut parts = request.split_whitespace();
|
||||
if parts.next()? != "GET" {
|
||||
return None;
|
||||
}
|
||||
let path = parts.next()?;
|
||||
if !path.starts_with('/') || path.starts_with("//") {
|
||||
return None;
|
||||
}
|
||||
let url = Url::parse(&format!("http://localhost:53692{path}")).ok()?;
|
||||
if url.path() != "/callback" {
|
||||
return None;
|
||||
}
|
||||
let params: HashMap<_, _> = url.query_pairs().into_owned().collect();
|
||||
if params.get("state")?.as_str() != state {
|
||||
return None;
|
||||
}
|
||||
params.get("code").filter(|code| !code.is_empty()).cloned()
|
||||
}
|
||||
|
||||
fn read_callback(mut stream: TcpStream, state: &str) -> Option<String> {
|
||||
stream.set_read_timeout(Some(Duration::from_secs(1))).ok()?;
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(1)))
|
||||
.ok()?;
|
||||
let mut request = String::new();
|
||||
// Bound the request line independently of whether a newline is supplied.
|
||||
std::io::Read::take(BufReader::new(&mut stream), 8192)
|
||||
.read_line(&mut request)
|
||||
.ok()?;
|
||||
let code = callback_code(&request, state);
|
||||
let (status, message) = if code.is_some() {
|
||||
(
|
||||
"200 OK",
|
||||
"Signed in to Claude. You can close this tab and return to Galaxy.",
|
||||
)
|
||||
} else {
|
||||
("400 Bad Request", "This callback did not match the active Galaxy sign-in. Return to Galaxy and try again.")
|
||||
};
|
||||
let body = format!("<!doctype html><title>Galaxy — Claude sign-in</title><p>{message}</p>");
|
||||
let response = format!("HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}", body.len());
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
code
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_family = "wasm")))]
|
||||
#[path = "claude_oauth_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,47 @@
|
||||
use super::desktop::{authorize_url, callback_code};
|
||||
|
||||
#[test]
|
||||
fn browser_login_matches_pi_pkce_parameters() {
|
||||
let url = authorize_url("test-verifier", "test-challenge").unwrap();
|
||||
assert_eq!(url.origin().ascii_serialization(), "https://claude.ai");
|
||||
let params: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
|
||||
assert_eq!(params["redirect_uri"], "http://localhost:53692/callback");
|
||||
assert_eq!(params["client_id"], "9d1c250a-e61b-44d9-88ed-5944d1962f5e");
|
||||
assert_eq!(params["state"], "test-verifier");
|
||||
assert_eq!(params["code_challenge"], "test-challenge");
|
||||
assert_eq!(params["code_challenge_method"], "S256");
|
||||
assert_eq!(params["response_type"], "code");
|
||||
assert_eq!(params["code"], "true");
|
||||
assert!(params["scope"].contains("user:inference"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_requires_matching_state_and_expected_path() {
|
||||
assert_eq!(
|
||||
callback_code("GET /callback?code=abc&state=expected HTTP/1.1", "expected"),
|
||||
Some("abc".to_string())
|
||||
);
|
||||
for request in [
|
||||
"GET /callback?code=abc&state=wrong HTTP/1.1",
|
||||
"GET /callback?code=abc HTTP/1.1",
|
||||
"GET /callback?state=expected HTTP/1.1",
|
||||
"GET /favicon.ico?code=abc&state=expected HTTP/1.1",
|
||||
"POST /callback?code=abc&state=expected HTTP/1.1",
|
||||
"GET //evil.example/callback?code=abc&state=expected HTTP/1.1",
|
||||
] {
|
||||
assert_eq!(callback_code(request, "expected"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_credentials_do_not_count_as_api_keys() {
|
||||
assert!(crate::ai::claude_subscription::is_stored_credential(
|
||||
"galaxy-claude-oauth:test"
|
||||
));
|
||||
assert!(crate::ai::claude_subscription::is_subscription_credential(
|
||||
"galaxy-claude-oauth:test"
|
||||
));
|
||||
assert!(!crate::ai::claude_subscription::is_stored_credential(
|
||||
"sk-ant-api-test"
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! User-supplied subscription tokens stay in Galaxy's secure credential store.
|
||||
use galaxyui::AppContext;
|
||||
use galaxyui_extras::secure_storage::AppContextExt as _;
|
||||
|
||||
use crate::settings::{OpenAIProviderConfig, OpenAIProviderKind};
|
||||
|
||||
const TOKEN_REFERENCE_PREFIX: &str = "galaxy-claude-token:";
|
||||
pub(crate) const OAUTH_REFERENCE_PREFIX: &str = "galaxy-claude-oauth:";
|
||||
pub(crate) const SUBSCRIPTION_WARNING: &str = "Unsupported Claude subscription integration. Anthropic does not permit third-party applications to use Claude subscription tokens. This may stop working without notice or lead to account restrictions. Galaxy retains its own tools, permissions, discovery, and orchestration.";
|
||||
|
||||
pub(crate) fn is_subscription_credential(credential: &str) -> bool {
|
||||
credential.trim().contains("sk-ant-oat")
|
||||
|| credential.starts_with(TOKEN_REFERENCE_PREFIX)
|
||||
|| is_stored_credential(credential)
|
||||
}
|
||||
|
||||
pub(crate) fn is_stored_credential(credential: &str) -> bool {
|
||||
credential.starts_with(TOKEN_REFERENCE_PREFIX) || credential.starts_with(OAUTH_REFERENCE_PREFIX)
|
||||
}
|
||||
|
||||
pub(crate) fn store_credential(
|
||||
provider: &mut OpenAIProviderConfig,
|
||||
ctx: &AppContext,
|
||||
) -> Result<Option<String>, String> {
|
||||
if provider.kind != OpenAIProviderKind::Anthropic {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(token) = provider
|
||||
.api_key
|
||||
.as_deref()
|
||||
.filter(|token| token.trim().contains("sk-ant-oat"))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let reference = format!("{TOKEN_REFERENCE_PREFIX}{}", uuid::Uuid::new_v4());
|
||||
ctx.secure_storage()
|
||||
.write_value_with_owner_only_fallback(&reference, token.trim())
|
||||
.map_err(|_| {
|
||||
"Could not securely save the Claude subscription token. The connection was not saved."
|
||||
.to_string()
|
||||
})?;
|
||||
provider.api_key = Some(reference.clone());
|
||||
Ok(Some(reference))
|
||||
}
|
||||
|
||||
pub(crate) fn remove_credential(reference: &str, ctx: &AppContext) {
|
||||
if is_stored_credential(reference) {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
super::claude_oauth::invalidate(reference);
|
||||
let _ = ctx.secure_storage().remove_value(reference);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1828,9 +1828,10 @@ impl LLMPreferences {
|
||||
.as_deref()
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
"Enter an Anthropic API key before testing the connection.".to_string()
|
||||
"Enter an Anthropic API key or Claude subscription token before testing the connection.".to_string()
|
||||
})?;
|
||||
Some(discover_anthropic_models(api_key).await?)
|
||||
let api_key = crate::ai::claude_oauth::load_credential(api_key.to_string()).await?;
|
||||
Some(discover_anthropic_models(&api_key).await?)
|
||||
}
|
||||
OpenAIProviderKind::Gemini => {
|
||||
let api_key = provider
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Loading animation components for AI features.
|
||||
|
||||
mod shimmering_warp_loading_text;
|
||||
pub use shimmering_warp_loading_text::shimmering_warp_loading_text;
|
||||
pub use shimmering_warp_loading_text::{
|
||||
loading_logo_size, shimmering_warp_loading_text, LOADING_LOGO_GAP,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Shimmering Warp loading text - renders Warp logo with shimmering text for loading states.
|
||||
//! Renders the Galaxy logo with shimmering text for loading states.
|
||||
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::elements::shimmering_text::{
|
||||
@@ -7,10 +7,13 @@ use galaxyui::elements::shimmering_text::{
|
||||
use galaxyui::elements::Element;
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
|
||||
/// Warp icon glyph character
|
||||
const WARP_GLYPH: &str = "\u{E500}";
|
||||
pub const LOADING_LOGO_GAP: f32 = 8.;
|
||||
|
||||
/// Creates a shimmering text element with the Warp glyph.
|
||||
pub fn loading_logo_size(font_size: f32) -> f32 {
|
||||
(font_size * 1.5).max(24.)
|
||||
}
|
||||
|
||||
/// Creates a shimmering text element beside the Galaxy logo.
|
||||
pub fn shimmering_warp_loading_text(
|
||||
text: impl Into<String>,
|
||||
font_size: f32,
|
||||
@@ -27,9 +30,8 @@ pub fn shimmering_warp_loading_text(
|
||||
// Hardcoded shimmer config for consistent animation
|
||||
let config = ShimmerConfig::default();
|
||||
|
||||
// Create a single shimmering element with glyph and text
|
||||
ShimmeringTextElement::new(
|
||||
format!("{} {}", WARP_GLYPH, text.into()),
|
||||
text.into(),
|
||||
appearance.ui_font_family(),
|
||||
font_size,
|
||||
base_color,
|
||||
@@ -37,5 +39,10 @@ pub fn shimmering_warp_loading_text(
|
||||
config,
|
||||
shimmer_handle,
|
||||
)
|
||||
.with_leading_icon(
|
||||
"bundled/svg/galaxy-mark.svg",
|
||||
loading_logo_size(font_size),
|
||||
LOADING_LOGO_GAP,
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ pub(crate) mod block_context;
|
||||
pub(crate) mod blocklist;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) mod chatgpt_auth;
|
||||
pub(crate) mod claude_oauth;
|
||||
pub(crate) mod claude_subscription;
|
||||
#[cfg(any(feature = "local_fs", not(target_family = "wasm")))]
|
||||
pub(crate) mod codebase_auto_indexing;
|
||||
pub mod control_code_parser;
|
||||
@@ -87,6 +89,8 @@ pub fn init(app: &mut AppContext) {
|
||||
app.add_singleton_model(acp::AcpRuntimeModel::new);
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
app.add_singleton_model(|_| chatgpt_auth::ChatGPTAuthModel::new());
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
app.add_singleton_model(claude_oauth::ClaudeOAuthModel::new);
|
||||
blocklist::keyboard_navigable_buttons::init(app);
|
||||
blocklist::block::number_shortcut_buttons::init(app);
|
||||
blocklist::toggleable_items::init(app);
|
||||
|
||||
@@ -318,11 +318,19 @@ pub(crate) async fn provider_runtime_for_request(
|
||||
},
|
||||
)),
|
||||
OpenAIProviderKind::Anthropic => {
|
||||
Arc::new(AnthropicRuntime::new(AnthropicRuntimeConfig {
|
||||
api_key: config.api_key.unwrap_or_default(),
|
||||
let credential = config.api_key.unwrap_or_default();
|
||||
let stored = crate::ai::claude_subscription::is_stored_credential(&credential);
|
||||
let mut runtime = AnthropicRuntime::new(AnthropicRuntimeConfig {
|
||||
api_key: credential.clone(),
|
||||
model,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
}))
|
||||
});
|
||||
if stored {
|
||||
runtime = runtime.with_credential_resolver(Arc::new(move || {
|
||||
Box::pin(crate::ai::claude_oauth::load_credential(credential.clone()))
|
||||
}));
|
||||
}
|
||||
Arc::new(runtime)
|
||||
}
|
||||
OpenAIProviderKind::Gemini => Arc::new(GeminiRuntime::new(GeminiRuntimeConfig {
|
||||
api_key: config.api_key.unwrap_or_default(),
|
||||
|
||||
Reference in New Issue
Block a user