diff --git a/AGENTS.md b/AGENTS.md index 33e2949f..963118a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,18 @@ Direct provider: controller.rs → prepare_provider_run() → ProviderRunCoordin ACP: controller.rs → ResponseStream → acp_output_stream (session-owned lifecycle) ``` +**Direct Claude subscription authentication (unsupported)**: +- Settings > Providers > Anthropic / Claude subscription offers browser OAuth sign-in, an Anthropic API key, or a user-supplied `claude setup-token` token. Subscription credentials use the native Rig Messages runtime and Galaxy-owned tools, discovery, permission checks, and `ProviderRun` lifecycle, not ACP. +- Subscription setup and provider overview display an explicit warning that Anthropic prohibits third-party subscription-token use and may restrict accounts or disable access. Pasted tokens are not automatically refreshed; expired tokens require replacement. +- The subscription wire profile follows Pi’s Anthropic OAuth implementation: `claude-cli/2.1.280`, bearer-only auth, `x-app: cli`, direct-browser-access header, OAuth/Claude Code betas, identity prefix, eager tool streaming, canonical tool-name casing (mapped back before Galaxy execution), and tool-call ID normalization. The separately installed ACP CLI version must not change this user agent. +- `app/src/ai/claude_oauth.rs` follows Pi’s PKCE browser flow and localhost callback on port 53692. OAuth access/refresh tokens and expiry are stored using Galaxy secure storage (owner-only file fallback where applicable); settings contain only a credential reference. A UI-thread credential service coalesces concurrent refresh requests five minutes before expiry. Every native Anthropic model turn and discovery resolves the current token; signing out invalidates refresh callbacks and deletes local credentials. Cancelled setup discards unsaved credentials. +- Tokens are stored using Galaxy secure storage (owner-only file fallback where applicable); settings contain only a credential reference. Never read Claude Code's credential store, print tokens, or include them in logs/checkpoints. + +**Claude Code ACP setup**: +- Connection setup offers Claude Code only. Existing explicit ACP configurations remain supported for saved conversations. +- `app/src/ai/acp/setup.rs` downloads missing official Claude Code and ACP adapter packages on explicit connection Test into Galaxy's per-user data directory. It reuses the private Node runtime, avoids global package installs, and pins package versions. +- Normal agent launches never download dependencies. The adapter receives `CLAUDE_CODE_EXECUTABLE` pointing to the unmodified CLI. Claude Code owns sign-in, browser OAuth, credential storage, and refresh; Galaxy never reads its token store or logs authentication command output. + **Durable direct-provider run**: - `crates/galaxy_agent_core/src/provider_run.rs` — Serializable `ProviderRun` state machine, run/epoch identity, bounded model retries, ordered tool batches, cancellation, and terminal outcomes - `app/src/ai/runtime/provider_run_coordinator.rs` — Drives one-turn `AgentRuntime` calls, validates model events, projects output, and commits exact tool lifecycle events diff --git a/Cargo.lock b/Cargo.lock index 532baf74..13e4786b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5727,6 +5727,7 @@ dependencies = [ "mockito", "natural_language_detection", "nix 0.26.4", + "node_runtime", "notify-debouncer-full", "num-traits", "num_cpus", @@ -5892,6 +5893,7 @@ dependencies = [ "bytes", "futures", "galaxy_agent_core", + "http 1.5.0", "rig-bedrock", "rig-core", "rig-vertexai", diff --git a/app/Cargo.toml b/app/Cargo.toml index 5e55d227..dc8c1b7f 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -92,6 +92,7 @@ handlebars.workspace = true hex.workspace = true http.workspace = true http_client.workspace = true +node_runtime.workspace = true image.workspace = true infer = "0.19.0" jaq-json.workspace = true @@ -706,6 +707,7 @@ local_claude_codex_child_harnesses = [] local_fs = [ "ai/local_fs", "lsp/local_fs", + "node_runtime/local_fs", "persistence/local_fs", "repo_metadata/local_fs", "galaxy_core/local_fs", diff --git a/app/assets/bundled/svg/galaxy-mark.svg b/app/assets/bundled/svg/galaxy-mark.svg new file mode 100644 index 00000000..aee53244 --- /dev/null +++ b/app/assets/bundled/svg/galaxy-mark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/src/ai/acp/launch.rs b/app/src/ai/acp/launch.rs index a1dbd7d1..47fb0191 100644 --- a/app/src/ai/acp/launch.rs +++ b/app/src/ai/acp/launch.rs @@ -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." diff --git a/app/src/ai/acp/mod.rs b/app/src/ai/acp/mod.rs index deaaedfd..44b579ca 100644 --- a/app/src/ai/acp/mod.rs +++ b/app/src/ai/acp/mod.rs @@ -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, diff --git a/app/src/ai/acp/setup.rs b/app/src/ai/acp/setup.rs new file mode 100644 index 00000000..f3f56f32 --- /dev/null +++ b/app/src/ai/acp/setup.rs @@ -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 { + AcpLaunchConfig::new(command) + .resolve_command() + .ok() + .map(|launch| launch.command) +} + +fn package_binary(package_dir: &Path) -> Option { + 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 { + 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 { + 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::>(); + 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::(); + 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 { + 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; diff --git a/app/src/ai/acp/setup_tests.rs b/app/src/ai/acp/setup_tests.rs new file mode 100644 index 00000000..3603796b --- /dev/null +++ b/app/src/ai/acp/setup_tests.rs @@ -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); + } +} diff --git a/app/src/ai/blocklist/action_model/execute/search_codebase.rs b/app/src/ai/blocklist/action_model/execute/search_codebase.rs index bcc05c8a..65af6696 100644 --- a/app/src/ai/blocklist/action_model/execute/search_codebase.rs +++ b/app/src/ai/blocklist/action_model/execute/search_codebase.rs @@ -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 { diff --git a/app/src/ai/blocklist/block/view_impl/common.rs b/app/src/ai/blocklist/block/view_impl/common.rs index 90ff12ce..488316aa 100644 --- a/app/src/ai/blocklist/block/view_impl/common.rs +++ b/app/src/ai/blocklist/block/view_impl/common.rs @@ -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() diff --git a/app/src/ai/claude_oauth.rs b/app/src/ai/claude_oauth.rs new file mode 100644 index 00000000..1a6a500f --- /dev/null +++ b/app/src/ai/claude_oauth.rs @@ -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>; + enum Command { + Resolve(String, Reply), + Invalidate(String), + DiscardDraft(String), + } + static SENDER: OnceLock>>> = 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 { + 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, + } + + /// Marshals secure-storage access to the UI thread and coalesces concurrent refreshes. + pub(crate) struct ClaudeOAuthModel { + refreshing: HashMap, + } + impl Entity for ClaudeOAuthModel { + type Event = (); + } + impl SingletonEntity for ClaudeOAuthModel {} + + impl ClaudeOAuthModel { + pub(crate) fn new(ctx: &mut ModelContext) -> 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) { + 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 { + 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 { + 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>, + cancelled: Arc, + } + impl Drop for BrowserLogin { + fn drop(&mut self) { + self.cancelled.store(true, Ordering::Relaxed); + } + } + impl BrowserLogin { + pub(crate) fn start() -> Result { + 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 { + 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 { + 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 { + 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 { + 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!("Galaxy — Claude sign-in

{message}

"); + 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; diff --git a/app/src/ai/claude_oauth_tests.rs b/app/src/ai/claude_oauth_tests.rs new file mode 100644 index 00000000..a4b0aaa2 --- /dev/null +++ b/app/src/ai/claude_oauth_tests.rs @@ -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" + )); +} diff --git a/app/src/ai/claude_subscription.rs b/app/src/ai/claude_subscription.rs new file mode 100644 index 00000000..83b3d763 --- /dev/null +++ b/app/src/ai/claude_subscription.rs @@ -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, 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); + } +} diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 3d4eaae1..07e249cd 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -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 diff --git a/app/src/ai/loading/mod.rs b/app/src/ai/loading/mod.rs index c592bade..b99d4b25 100644 --- a/app/src/ai/loading/mod.rs +++ b/app/src/ai/loading/mod.rs @@ -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, +}; diff --git a/app/src/ai/loading/shimmering_warp_loading_text.rs b/app/src/ai/loading/shimmering_warp_loading_text.rs index 8cc3ab1f..a7284976 100644 --- a/app/src/ai/loading/shimmering_warp_loading_text.rs +++ b/app/src/ai/loading/shimmering_warp_loading_text.rs @@ -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, 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() } diff --git a/app/src/ai/mod.rs b/app/src/ai/mod.rs index 20fc1b3a..0d63273b 100644 --- a/app/src/ai/mod.rs +++ b/app/src/ai/mod.rs @@ -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); diff --git a/app/src/ai/runtime/rig.rs b/app/src/ai/runtime/rig.rs index dd7fc7a7..85140b4a 100644 --- a/app/src/ai/runtime/rig.rs +++ b/app/src/ai/runtime/rig.rs @@ -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(), diff --git a/app/src/resource_center/mod.rs b/app/src/resource_center/mod.rs index 196e2756..8f22133a 100644 --- a/app/src/resource_center/mod.rs +++ b/app/src/resource_center/mod.rs @@ -144,19 +144,13 @@ impl FeatureItem { feature: Tip, ctx: &mut AppContext, ) -> Self { - let editable_binding_name; - let shortcut; - - match feature { - Tip::Hint(_) => { - editable_binding_name = None; - shortcut = None; - } - Tip::Action(tip) => { - editable_binding_name = Some(tip.editable_binding_name()); - shortcut = tip.keyboard_shortcut(ctx); - } - } + let (editable_binding_name, shortcut) = match feature { + Tip::Hint(_) => (None, None), + Tip::Action(tip) => ( + Some(tip.editable_binding_name()), + tip.keyboard_shortcut(ctx), + ), + }; Self { title, diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index c052b36a..be2b2c1e 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -1048,7 +1048,7 @@ impl std::fmt::Debug for OpenAIProviderConfig { impl settings_value::SettingsValue for OpenAIProviderConfig {} fn default_acp_agent_id() -> String { - "codex".to_string() + "claude-acp".to_string() } fn default_remote_logging_endpoint() -> String { @@ -1512,7 +1512,7 @@ define_settings_group!(AISettings, settings: [ // Stable identifier for the selected ACP agent preset. acp_agent_id: AcpAgentId { type: String, - default: "codex".to_string(), + default: "claude-acp".to_string(), supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()), sync_to_cloud: SyncToCloud::Never, private: false, @@ -2422,7 +2422,7 @@ impl AISettings { } let agent_id = self.acp_agent_id.value().trim(); let agent_id = if agent_id.is_empty() { - "codex" + "claude-acp" } else { agent_id }; diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index 51bd016d..5aeeceec 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -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, + ) { + 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, + ) { + 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, ) { 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, ) { 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, ) { - 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>, bedrock_model_table_state: TableStateHandle, + add_anthropic_provider_button: ViewHandle, add_openai_provider_button: ViewHandle, add_litellm_provider_button: ViewHandle, add_chatgpt_provider_button: ViewHandle, @@ -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() diff --git a/app/src/settings_view/mod.rs b/app/src/settings_view/mod.rs index 738862a7..51cf43df 100644 --- a/app/src/settings_view/mod.rs +++ b/app/src/settings_view/mod.rs @@ -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), diff --git a/app/src/settings_view/mod_tests.rs b/app/src/settings_view/mod_tests.rs index b5e22433..7a1d9670 100644 --- a/app/src/settings_view/mod_tests.rs +++ b/app/src/settings_view/mod_tests.rs @@ -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"), diff --git a/app/src/settings_view/provider_setup_view.rs b/app/src/settings_view/provider_setup_view.rs index 21b5569b..4e8278b4 100644 --- a/app/src/settings_view/provider_setup_view.rs +++ b/app/src/settings_view/provider_setup_view.rs @@ -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, provider_type_buttons: Vec>, acp_agent_buttons: Vec>, name_editor: ViewHandle, @@ -253,6 +264,11 @@ pub struct ProviderSetupView { acp_command_editor: ViewHandle, acp_args_editor: ViewHandle, chatgpt_connect_mouse_state: MouseStateHandle, + claude_connect_mouse_state: MouseStateHandle, + claude_signin_abort: Option, + claude_signin_generation: u64, + claude_signin_status: String, + claude_credentials_available: bool, bedrock_auth_buttons: Vec>, 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::>(); - 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.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.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.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.acp_setup_status = status; + ctx.notify(); + } + + pub fn show_connection_error(&mut self, error: String, ctx: &mut ViewContext) { + 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) { @@ -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.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) { + 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 { + 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 { 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(); } } diff --git a/app/src/terminal/block_list_element.rs b/app/src/terminal/block_list_element.rs index 213b899f..b30ba514 100644 --- a/app/src/terminal/block_list_element.rs +++ b/app/src/terminal/block_list_element.rs @@ -1096,16 +1096,18 @@ impl BlockListElement { .finish(), ); - let snackbar_toggle_icon; - let rounded_corners; - if self.snackbar_header_state.show_snackbar { + let (snackbar_toggle_icon, rounded_corners) = if self.snackbar_header_state.show_snackbar { // Snackbar expanded. Show collapse button attached to bottom of snackbar. - snackbar_toggle_icon = UIIcon::Icon::ChevronUp; - rounded_corners = CornerRadius::with_top(Radius::Pixels(8.)); + ( + UIIcon::Icon::ChevronUp, + CornerRadius::with_top(Radius::Pixels(8.)), + ) } else { // Snackbar collapsed. Show expand button attached to top of blocklist. - snackbar_toggle_icon = UIIcon::Icon::ChevronDown; - rounded_corners = CornerRadius::with_bottom(Radius::Pixels(8.)); + ( + UIIcon::Icon::ChevronDown, + CornerRadius::with_bottom(Radius::Pixels(8.)), + ) }; let icon = Container::new( ConstrainedBox::new(Icon::new(snackbar_toggle_icon.into(), icon_color).finish()) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index cf7ab5b8..0210ad51 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -7231,8 +7231,7 @@ impl Input { .active_block_mut() .set_nld_overridden(nld_overridden); - let did_execute: bool; - if self + let did_execute = if self .model .lock() .block_list() @@ -7311,14 +7310,14 @@ impl Input { } self.start_block_and_write_command_to_pty(command, source, ctx); - did_execute = true; + true } else { // We don't want to submit the command if precmd has not // been received. Instead, we want the user to be aware // that the prompt might not be up to date. send_telemetry_from_ctx!(TelemetryEvent::TriedToExecuteBeforePrecmd, ctx); - did_execute = false; - } + false + }; // Close the workflows info box if it was open. self.clear_selected_workflow(ctx); diff --git a/app/src/terminal/model/grid/displayed_output_tests.rs b/app/src/terminal/model/grid/displayed_output_tests.rs index c55326e1..4c69ab67 100644 --- a/app/src/terminal/model/grid/displayed_output_tests.rs +++ b/app/src/terminal/model/grid/displayed_output_tests.rs @@ -129,7 +129,9 @@ pub fn test_append_to_row_translation_empty_displayed_rows() { }; // Append displayed rows 0..=1. - displayed_output.append_to_row_translation(make_displayed_rows_from_ranges(vec![0..=1]).iter()); + displayed_output.append_to_row_translation( + make_displayed_rows_from_ranges(std::iter::once(0..=1).collect::>()).iter(), + ); let expected_bimap = create_bimap_with_insertions(&[(0, 0), (1, 1)]); assert_eq!(displayed_output.row_translation_map, expected_bimap.into()); @@ -146,7 +148,9 @@ pub fn test_append_to_row_translation() { }; // Append displayed rows 6..7. - displayed_output.append_to_row_translation(make_displayed_rows_from_ranges(vec![6..=7]).iter()); + displayed_output.append_to_row_translation( + make_displayed_rows_from_ranges(std::iter::once(6..=7).collect::>()).iter(), + ); bimap.insert(6, 4); bimap.insert(7, 5); @@ -163,7 +167,8 @@ pub fn test_prepend_to_row_translation_empty_displayed_rows() { }; // Prepend displayed rows 0..=1. - let new_line_ranges = make_displayed_rows_from_ranges(vec![0..=1]); + let new_line_ranges = + make_displayed_rows_from_ranges(std::iter::once(0..=1).collect::>()); displayed_output .displayed_rows .splice(0..0, new_line_ranges); @@ -186,7 +191,8 @@ pub fn test_prepend_to_row_translation() { }; // Prepend displayed rows 0..=1. - let new_line_ranges = make_displayed_rows_from_ranges(vec![0..=1]); + let new_line_ranges = + make_displayed_rows_from_ranges(std::iter::once(0..=1).collect::>()); displayed_output .displayed_rows .splice(0..0, new_line_ranges); @@ -209,7 +215,8 @@ pub fn test_replace_rows_from_row_translation() { // Replace displayed row 2 with displayed row 3. let replace_range = 1..2; - let new_line_ranges = make_displayed_rows_from_ranges(vec![3..=3]); + let new_line_ranges = + make_displayed_rows_from_ranges(std::iter::once(3..=3).collect::>()); displayed_output .displayed_rows .splice(replace_range.clone(), new_line_ranges); @@ -231,7 +238,8 @@ pub fn test_replace_rows_from_row_translation_from_middle_to_end() { // Replace displayed rows 2, 4 with displayed row 3..=5. let replace_range = 1..3; - let new_line_ranges = make_displayed_rows_from_ranges(vec![3..=5]); + let new_line_ranges = + make_displayed_rows_from_ranges(std::iter::once(3..=5).collect::>()); displayed_output .displayed_rows .splice(replace_range.clone(), new_line_ranges); @@ -253,7 +261,8 @@ pub fn test_replace_rows_from_row_translation_from_beginning_to_middle() { // Replace displayed rows 0, 2 with displayed row 0..=1. let replace_range = 0..2; - let new_line_ranges = make_displayed_rows_from_ranges(vec![0..=1]); + let new_line_ranges = + make_displayed_rows_from_ranges(std::iter::once(0..=1).collect::>()); displayed_output .displayed_rows .splice(replace_range.clone(), new_line_ranges); @@ -268,7 +277,7 @@ pub fn test_replace_rows_from_row_translation_replace_beginning_row() { // Start with displayed rows 0. let bimap = create_bimap_with_insertions(&[(0, 0)]); let mut displayed_output = DisplayedOutput { - displayed_rows: make_displayed_rows_from_ranges(vec![0..=0]), + displayed_rows: make_displayed_rows_from_ranges(std::iter::once(0..=0).collect::>()), height: 1, row_translation_map: bimap.into(), }; @@ -276,7 +285,8 @@ pub fn test_replace_rows_from_row_translation_replace_beginning_row() { // Replace displayed rows 0 with displayed row 0. // This is intended to simulate what happens if we process dirty bytes multiple times. let replace_range = 0..1; - let new_line_ranges = make_displayed_rows_from_ranges(vec![0..=0]); + let new_line_ranges = + make_displayed_rows_from_ranges(std::iter::once(0..=0).collect::>()); displayed_output .displayed_rows .splice(replace_range.clone(), new_line_ranges); @@ -298,7 +308,8 @@ pub fn test_replace_rows_from_row_translation_break_range() { // Replace displayed rows 2..=3 with displayed row 2. let replace_range = 1..2; - let new_line_ranges = make_displayed_rows_from_ranges(vec![2..=2]); + let new_line_ranges = + make_displayed_rows_from_ranges(std::iter::once(2..=2).collect::>()); displayed_output .displayed_rows .splice(replace_range.clone(), new_line_ranges); @@ -329,7 +340,8 @@ fn test_replace_rows_from_row_translation_no_removed_entries() { // Insert displayed rows 3..=4. let replace_range = 1..1; - let new_line_ranges = make_displayed_rows_from_ranges(vec![3..=4]); + let new_line_ranges = + make_displayed_rows_from_ranges(std::iter::once(3..=4).collect::>()); displayed_output .displayed_rows .splice(replace_range.clone(), new_line_ranges); @@ -537,7 +549,8 @@ fn test_new_from_displayed_lines() { #[test] fn test_extend_displayed_lines() { - let displayed_rows = make_displayed_rows_from_ranges(vec![1..=3]); + let displayed_rows = + make_displayed_rows_from_ranges(std::iter::once(1..=3).collect::>()); let mut displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone()); displayed_output.extend_displayed_lines(make_displayed_rows_from_ranges(vec![7..=7, 14..=18])); @@ -561,7 +574,8 @@ fn test_extend_displayed_lines() { #[test] fn test_prepend_displayed_lines() { - let displayed_rows = make_displayed_rows_from_ranges(vec![14..=18]); + let displayed_rows = + make_displayed_rows_from_ranges(std::iter::once(14..=18).collect::>()); let mut displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone()); displayed_output.prepend_displayed_lines(make_displayed_rows_from_ranges(vec![1..=3, 7..=7])); @@ -626,7 +640,8 @@ fn test_splice_displayed_lines_nothing_replaced() { let displayed_rows = make_displayed_rows_from_ranges(vec![0..=1, 4..=5, 14..=16, 19..=19]); let mut displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone()); - let new_displayed_rows = make_displayed_rows_from_ranges(vec![8..=9]); + let new_displayed_rows = + make_displayed_rows_from_ranges(std::iter::once(8..=9).collect::>()); displayed_output.splice_displayed_lines(2..2, new_displayed_rows); let expected_displayed_rows = diff --git a/app/src/terminal/model/grid/filtering_tests.rs b/app/src/terminal/model/grid/filtering_tests.rs index cc49bf1e..7f7002b8 100644 --- a/app/src/terminal/model/grid/filtering_tests.rs +++ b/app/src/terminal/model/grid/filtering_tests.rs @@ -818,11 +818,11 @@ pub fn test_add_context_lines_at_grid_bounds() { line\r\n\ line\r\n", ); - let line_ranges = [2..=2]; + let line_range = 2..=2; let displayed_rows = blockgrid .grid_handler() - .add_context_lines(line_ranges.iter(), 5); + .add_context_lines(std::iter::once(&line_range), 5); assert_eq!( displayed_rows, vec![ @@ -845,11 +845,11 @@ pub fn test_add_context_lines_at_grid_bounds() { #[test] pub fn test_add_context_lines_no_context() { let blockgrid = mock_blockgrid("match"); - let line_ranges = [0..=0]; + let line_range = 0..=0; let displayed_rows = blockgrid .grid_handler() - .add_context_lines(line_ranges.iter(), 5); + .add_context_lines(std::iter::once(&line_range), 5); assert_eq!( displayed_rows, vec![DisplayedRows { @@ -949,7 +949,8 @@ pub fn test_update_filtered_dirty_lines() { grid.grid_handler_mut() .set_displayed_output(displayed_output); - let new_displayed_rows = make_displayed_rows_from_ranges(vec![5..=5]); + let new_displayed_rows = + make_displayed_rows_from_ranges(std::iter::once(5..=5).collect::>()); grid.grid_handler_mut() .update_filtered_dirty_lines(5..=17, new_displayed_rows, 0); @@ -968,7 +969,8 @@ pub fn test_update_filtered_dirty_lines_at_range_borders() { grid.grid_handler_mut() .set_displayed_output(displayed_output); - let new_displayed_rows = make_displayed_rows_from_ranges(vec![11..=12]); + let new_displayed_rows = + make_displayed_rows_from_ranges(std::iter::once(11..=12).collect::>()); grid.grid_handler_mut() .update_filtered_dirty_lines(6..=15, new_displayed_rows, 0); @@ -983,7 +985,8 @@ pub fn test_update_filtered_dirty_lines_at_range_borders() { grid.grid_handler_mut() .set_displayed_output(displayed_output); - let new_displayed_rows = make_displayed_rows_from_ranges(vec![11..=12]); + let new_displayed_rows = + make_displayed_rows_from_ranges(std::iter::once(11..=12).collect::>()); grid.grid_handler_mut() .update_filtered_dirty_lines(10..=13, new_displayed_rows, 0); @@ -1040,7 +1043,8 @@ pub fn test_update_filtered_dirty_lines_replace_one_line() { grid.grid_handler_mut() .set_displayed_output(displayed_output); - let new_displayed_rows = make_displayed_rows_from_ranges(vec![7..=7]); + let new_displayed_rows = + make_displayed_rows_from_ranges(std::iter::once(7..=7).collect::>()); grid.grid_handler_mut() .update_filtered_dirty_lines(6..=10, new_displayed_rows, 0); @@ -1059,7 +1063,8 @@ pub fn test_update_filtered_dirty_lines_replace_single_row_line() { grid.grid_handler_mut() .set_displayed_output(displayed_output); - let new_displayed_rows = make_displayed_rows_from_ranges(vec![6..=8]); + let new_displayed_rows = + make_displayed_rows_from_ranges(std::iter::once(6..=8).collect::>()); grid.grid_handler_mut() .update_filtered_dirty_lines(7..=7, new_displayed_rows, 0); @@ -1078,7 +1083,8 @@ pub fn test_update_filtered_dirty_lines_replace_at_start() { grid.grid_handler_mut() .set_displayed_output(displayed_output); - let new_displayed_rows = make_displayed_rows_from_ranges(vec![3..=5]); + let new_displayed_rows = + make_displayed_rows_from_ranges(std::iter::once(3..=5).collect::>()); grid.grid_handler_mut() .update_filtered_dirty_lines(1..=11, new_displayed_rows, 0); @@ -1097,7 +1103,8 @@ pub fn test_update_filtered_dirty_lines_replace_at_end() { grid.grid_handler_mut() .set_displayed_output(displayed_output); - let new_displayed_rows = make_displayed_rows_from_ranges(vec![16..=18]); + let new_displayed_rows = + make_displayed_rows_from_ranges(std::iter::once(16..=18).collect::>()); grid.grid_handler_mut() .update_filtered_dirty_lines(12..=16, new_displayed_rows, 0); @@ -1830,7 +1837,9 @@ fn test_truncate_cursor_updates_displayed_output() { grid[4][0].c = 'e'; // Initialize displayed output with all rows in the grid. - grid_handler.displayed_output = Some(DisplayedOutput::new_for_test(vec![0..=4])); + grid_handler.displayed_output = Some(DisplayedOutput::new_for_test( + std::iter::once(0..=4).collect::>(), + )); // Set the cursor to the middle of the grid grid_handler.set_cursor_point(2, 0); @@ -2064,7 +2073,7 @@ pub fn test_find_non_matching_lines_all_returned() { ", ); - let expected_non_matching_lines = vec![0..=10]; + let expected_non_matching_lines = std::iter::once(0..=10).collect::>(); let dfas = RegexDFAs::new("match").unwrap(); let non_matching_lines = blockgrid.grid_handler().find_non_matching_lines(&dfas); @@ -2095,7 +2104,7 @@ pub fn test_find_non_matching_lines_in_range_all_returned() { ", ); - let expected_non_matching_lines = vec![2..=12]; + let expected_non_matching_lines = std::iter::once(2..=12).collect::>(); let dfas = RegexDFAs::new("match").unwrap(); let non_matching_lines = blockgrid.grid_handler().find_non_matching_lines_in_range( diff --git a/app/src/uri/mod.rs b/app/src/uri/mod.rs index 93c2fb98..ec1ff5d4 100644 --- a/app/src/uri/mod.rs +++ b/app/src/uri/mod.rs @@ -1689,7 +1689,9 @@ fn decode_uuid_hex(hex: &str) -> Option> { return None; } - hex.chunks_exact(2) + hex.as_chunks::<2>() + .0 + .iter() .map(|pair| { let high = (pair[0] as char).to_digit(16)?; let low = (pair[1] as char).to_digit(16)?; diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 57d82eb9..42e3720f 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -21420,7 +21420,7 @@ impl Workspace { Align::new( self.render_tab_bar_icon_button( appearance, - icons::Icon::Stars, + icons::Icon::Gear, &self.mouse_states.settings_icon, WorkspaceAction::ShowSettings, "Settings".to_string(), diff --git a/crates/acp/src/config.rs b/crates/acp/src/config.rs index 15b43a94..399ec44c 100644 --- a/crates/acp/src/config.rs +++ b/crates/acp/src/config.rs @@ -26,73 +26,15 @@ pub struct AcpKnownAgent { pub args: &'static [&'static str], } -/// Curated ACP Registry catalog. Launch commands are intentionally local-only: -/// Galaxy never installs or downloads an agent on the user's behalf. -pub const KNOWN_ACP_AGENTS: &[AcpKnownAgent] = &[ - AcpKnownAgent { - id: "codex", - name: "Codex", - description: "OpenAI's coding assistant", - command: "codex", - args: &[], - }, - AcpKnownAgent { - id: "opencode", - name: "OpenCode", - description: "Open source coding agent", - command: "opencode", - args: &["acp"], - }, - AcpKnownAgent { - id: "claude-acp", - name: "Claude Agent", - description: "Anthropic's coding agent", - command: "claude-agent-acp", - args: &[], - }, - AcpKnownAgent { - id: "gemini", - name: "Gemini CLI", - description: "Google's coding agent", - command: "gemini", - args: &["--acp"], - }, - AcpKnownAgent { - id: "cline", - name: "Cline", - description: "Autonomous coding agent", - command: "cline", - args: &["--acp"], - }, - AcpKnownAgent { - id: "cursor", - name: "Cursor", - description: "Cursor's coding agent", - command: "cursor-agent", - args: &["acp"], - }, - AcpKnownAgent { - id: "github-copilot-cli", - name: "GitHub Copilot", - description: "GitHub's AI pair programmer", - command: "copilot", - args: &["--acp"], - }, - AcpKnownAgent { - id: "goose", - name: "Goose", - description: "Block's open source AI agent", - command: "goose", - args: &["acp"], - }, - AcpKnownAgent { - id: "auggie", - name: "Auggie CLI", - description: "Augment Code's coding agent", - command: "auggie", - args: &["--acp"], - }, -]; +/// ACP agents offered by Galaxy's connection setup. Existing explicit launch +/// configurations remain supported independently of this catalog. +pub const KNOWN_ACP_AGENTS: &[AcpKnownAgent] = &[AcpKnownAgent { + id: "claude-acp", + name: "Claude Code", + description: "Claude Code with your Claude subscription or API account", + command: "claude-agent-acp", + args: &[], +}]; pub fn known_acp_agents() -> &'static [AcpKnownAgent] { KNOWN_ACP_AGENTS diff --git a/crates/acp/src/config_tests.rs b/crates/acp/src/config_tests.rs index efec74dc..e0830e8f 100644 --- a/crates/acp/src/config_tests.rs +++ b/crates/acp/src/config_tests.rs @@ -3,6 +3,14 @@ use std::time::Duration; use super::*; +#[test] +fn setup_catalog_offers_only_claude_code() { + let agents = known_acp_agents(); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].id, "claude-acp"); + assert_eq!(agents[0].name, "Claude Code"); +} + #[test] fn codex_preset_uses_the_adapter_with_npx() { let launch = AcpAgentPreset::Codex.launch_config(); diff --git a/crates/computer_use/src/windows/screenshot.rs b/crates/computer_use/src/windows/screenshot.rs index 15dbf196..6dc8168a 100644 --- a/crates/computer_use/src/windows/screenshot.rs +++ b/crates/computer_use/src/windows/screenshot.rs @@ -225,7 +225,7 @@ fn read_bitmap_bits( /// GDI does not populate the alpha channel for `BI_RGB`, so we force it to `0xFF` to produce a /// fully opaque RGBA image. fn convert_bgra_to_rgba(mut buffer: Vec) -> Vec { - for chunk in buffer.chunks_exact_mut(4) { + for chunk in buffer.as_chunks_mut::<4>().0 { // Swap B and R channels so the 4-byte BGRA pixel becomes RGBA. chunk.swap(0, 2); chunk[3] = 0xFF; diff --git a/crates/galaxy_agent_rig/Cargo.toml b/crates/galaxy_agent_rig/Cargo.toml index 796bc5e5..d555eea7 100644 --- a/crates/galaxy_agent_rig/Cargo.toml +++ b/crates/galaxy_agent_rig/Cargo.toml @@ -12,6 +12,7 @@ aws-sdk-bedrockruntime.workspace = true base64.workspace = true futures.workspace = true galaxy_agent_core.workspace = true +http.workspace = true rig-core.workspace = true rig-bedrock.workspace = true rig-vertexai.workspace = true diff --git a/crates/galaxy_agent_rig/src/anthropic_auth.rs b/crates/galaxy_agent_rig/src/anthropic_auth.rs new file mode 100644 index 00000000..2dc73067 --- /dev/null +++ b/crates/galaxy_agent_rig/src/anthropic_auth.rs @@ -0,0 +1,254 @@ +//! Explicit Claude subscription tokens for the direct, Galaxy-owned runtime. +//! This is an unsupported authentication path; it never reads Claude Code's credentials. +use std::collections::HashMap; + +use galaxy_agent_core::{AgentError, AgentErrorKind, AgentEvent, ToolEvent}; +use http::{HeaderMap, HeaderValue}; +use rig_core::OneOrMany; +use rig_core::client::{ApiKey, Client, ClientBuilder, Provider, ProviderBuilder}; +use rig_core::completion::{AssistantContent, CompletionRequest, Message}; +use rig_core::http_client::{self, HttpClientExt}; +use rig_core::message::UserContent; +use rig_core::providers::anthropic; +use rig_core::providers::anthropic::client::AnthropicExt; +use serde_json::json; + +pub fn is_claude_subscription_token(credential: &str) -> bool { + credential.trim().contains("sk-ant-oat") +} + +// Pi OAuth compatibility profile, reviewed 2026-10-03: +// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/api/anthropic-messages.ts +// Keep this independent of the version of the separately installed ACP CLI. +const PI_CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.280"; + +pub(crate) const CLAUDE_CODE_IDENTITY: &str = + "You are Claude Code, Anthropic's official CLI for Claude."; + +// Use Rig's native Messages translation and streaming, but replace its API-key +// header at client construction rather than sending both authentication methods. +#[derive(Clone, Default)] +struct SubscriptionBuilder; +struct SubscriptionProvider; +struct SubscriptionToken(String); + +impl ApiKey for SubscriptionToken { + fn into_header(self) -> Option> { + Some( + http_client::make_auth_header(self.0).map(|(name, mut value)| { + value.set_sensitive(true); + (name, value) + }), + ) + } +} + +impl Provider for SubscriptionProvider { + type Builder = SubscriptionBuilder; + const VERIFY_PATH: &'static str = "/v1/models"; +} + +impl ProviderBuilder for SubscriptionBuilder { + type Extension + = AnthropicExt + where + H: HttpClientExt; + type ApiKey = SubscriptionToken; + const BASE_URL: &'static str = "https://api.anthropic.com"; + + fn build(_: &ClientBuilder) -> http_client::Result> + where + H: HttpClientExt, + { + Ok(AnthropicExt) + } +} + +pub(crate) fn anthropic_client(credential: &str) -> Result { + let credential = credential.trim(); + if !is_claude_subscription_token(credential) { + return anthropic::Client::new(credential).map_err(|error| error.to_string()); + } + let headers = subscription_headers(); + Client::::builder() + .api_key(SubscriptionToken(credential.to_string())) + .http_headers(headers) + .build() + // Never include a credential or raw header-validation error in diagnostics. + .map_err(|_| "Could not configure Claude subscription authentication.".to_string()) +} + +const CLAUDE_CODE_TOOLS: &[&str] = &[ + "Read", + "Write", + "Edit", + "Bash", + "Grep", + "Glob", + "AskUserQuestion", + "EnterPlanMode", + "ExitPlanMode", + "KillShell", + "NotebookEdit", + "Skill", + "Task", + "TaskOutput", + "TodoWrite", + "WebFetch", + "WebSearch", +]; + +fn wire_tool_name(name: &str) -> String { + CLAUDE_CODE_TOOLS + .iter() + .find(|candidate| candidate.eq_ignore_ascii_case(name)) + .copied() + .unwrap_or(name) + .to_string() +} + +fn normalize_call_id(id: &mut String) { + *id = id + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '_' || character == '-' { + character + } else { + '_' + } + }) + .take(64) + .collect(); +} + +pub(crate) fn prepare_subscription_request( + request: &mut CompletionRequest, +) -> Result, AgentError> { + let mut aliases = HashMap::new(); + let mut tools = Vec::new(); + let tool_count = request.tools.len(); + for (index, tool) in request.tools.drain(..).enumerate() { + let name = wire_tool_name(&tool.name); + if aliases.insert(name.clone(), tool.name.clone()).is_some() { + return Err(AgentError::new( + AgentErrorKind::InvalidRequest, + "Claude subscription tool names collide after canonical casing.", + )); + } + // Pi's default non-strict schema preserves properties and required. + let mut definition = json!({ + "name": name, + "description": tool.description, + "input_schema": { + "type": "object", + "properties": tool.parameters.get("properties").cloned().unwrap_or(json!({})), + "required": tool.parameters.get("required").cloned().unwrap_or(json!([])), + }, + "eager_input_streaming": true, + }); + if index + 1 == tool_count { + definition["cache_control"] = json!({"type": "ephemeral"}); + } + tools.push(definition); + } + let mut history = Vec::new(); + let mut galaxy_system = Vec::new(); + for mut message in request.chat_history.iter().cloned() { + match &mut message { + Message::System { content } => { + galaxy_system.push(content.clone()); + continue; + } + Message::Assistant { content, .. } => { + for part in content.iter_mut() { + if let AssistantContent::ToolCall(call) = part { + call.function.name = wire_tool_name(&call.function.name); + normalize_call_id(&mut call.id); + if let Some(id) = call.call_id.as_mut() { + normalize_call_id(id); + } + } + } + } + Message::User { content } => { + for part in content.iter_mut() { + if let UserContent::ToolResult(result) = part { + normalize_call_id(&mut result.id); + if let Some(id) = result.call_id.as_mut() { + normalize_call_id(id); + } + } + } + } + } + history.push(message); + } + request.chat_history = OneOrMany::many(history) + .map_err(|error| AgentError::new(AgentErrorKind::InvalidRequest, error.to_string()))?; + let mut system = vec![json!({ + "type": "text", "text": CLAUDE_CODE_IDENTITY, + "cache_control": {"type": "ephemeral"}, + })]; + if !galaxy_system.is_empty() { + system.push(json!({ + "type": "text", "text": galaxy_system.join("\n\n"), + "cache_control": {"type": "ephemeral"}, + })); + } + let mut parameters = request.additional_params.take().unwrap_or(json!({})); + let Some(parameters_map) = parameters.as_object_mut() else { + return Err(AgentError::new( + AgentErrorKind::InvalidRequest, + "Claude subscription request parameters must be an object.", + )); + }; + parameters_map.insert("system".to_string(), system.into()); + if !tools.is_empty() { + parameters_map.insert("tools".to_string(), tools.into()); + } + request.additional_params = Some(parameters); + Ok(aliases) +} + +pub(crate) fn restore_tool_names(event: &mut AgentEvent, aliases: &HashMap) { + let name = match event { + AgentEvent::Tool { + event: ToolEvent::Proposed { call }, + } => Some(&mut call.name), + AgentEvent::ToolCallProgress { name, .. } => name.as_mut(), + _ => None, + }; + if let Some(name) = name { + // Pi matches against the advertised tools case-insensitively on return. + if let Some(original) = aliases + .iter() + .find_map(|(wire, original)| wire.eq_ignore_ascii_case(name).then_some(original)) + { + name.clone_from(original); + } + } +} + +fn subscription_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); + headers.insert( + "anthropic-beta", + HeaderValue::from_static("claude-code-20250219,oauth-2025-04-20"), + ); + headers.insert( + "user-agent", + HeaderValue::from_static(PI_CLAUDE_CODE_USER_AGENT), + ); + headers.insert( + "anthropic-dangerous-direct-browser-access", + HeaderValue::from_static("true"), + ); + headers.insert("x-app", HeaderValue::from_static("cli")); + headers.insert("accept", HeaderValue::from_static("application/json")); + headers +} + +#[cfg(test)] +#[path = "anthropic_auth_tests.rs"] +mod tests; diff --git a/crates/galaxy_agent_rig/src/anthropic_auth_tests.rs b/crates/galaxy_agent_rig/src/anthropic_auth_tests.rs new file mode 100644 index 00000000..ef99dcfc --- /dev/null +++ b/crates/galaxy_agent_rig/src/anthropic_auth_tests.rs @@ -0,0 +1,311 @@ +use galaxy_agent_core::{ + ConversationMessage, MessageContent, MessageRole, ToolDefinition, TurnRequest, +}; + +use super::*; +use crate::request::build_completion_request; + +#[test] +fn subscription_auth_is_bearer_only_and_redacted() { + let token = "sk-ant-oat01-test-subscription-token"; + let client = anthropic_client(token).expect("subscription client"); + assert_eq!(client.headers()["authorization"], format!("Bearer {token}")); + assert!(client.headers()["authorization"].is_sensitive()); + assert!(!client.headers().contains_key("x-api-key")); + assert_eq!( + client.headers()["anthropic-beta"], + "claude-code-20250219,oauth-2025-04-20" + ); + assert_eq!(client.headers()["user-agent"], "claude-cli/2.1.280"); + assert_eq!( + client.headers()["anthropic-dangerous-direct-browser-access"], + "true" + ); + assert_eq!(client.headers()["x-app"], "cli"); + assert_eq!(client.headers()["accept"], "application/json"); + assert!(!format!("{client:?}").contains(token)); + let config = crate::AnthropicRuntimeConfig { + api_key: token.to_string(), + model: "claude-test".to_string(), + max_output_tokens: None, + }; + assert!(!format!("{config:?}").contains(token)); +} + +#[test] +fn api_key_keeps_normal_authentication() { + let client = anthropic_client("sk-ant-api03-test-key").expect("API client"); + assert_eq!(client.headers()["x-api-key"], "sk-ant-api03-test-key"); + assert!(!client.headers().contains_key("authorization")); +} + +#[test] +fn subscription_request_keeps_galaxy_prompt_and_tool_names() { + let mut turn = TurnRequest::new( + "claude-test".to_string(), + vec![ConversationMessage { + role: MessageRole::User, + content: MessageContent::Text("Fix the bug".to_string()), + }], + ); + turn.system_prompt = Some("Use Galaxy discovery and permissions.".to_string()); + turn.tools = vec![ToolDefinition { + name: "search_codebase".to_string(), + description: "Search Galaxy's index".to_string(), + input_schema: serde_json::json!({"type":"object"}), + }]; + let mut request = build_completion_request(turn, Some(4096), true, true, None).unwrap(); + prepare_subscription_request(&mut request).unwrap(); + let history = request.chat_history.iter().collect::>(); + let parameters = request.additional_params.as_ref().unwrap(); + assert_eq!(parameters["system"][0]["text"], CLAUDE_CODE_IDENTITY); + assert_eq!( + parameters["system"][1]["text"], + "Use Galaxy discovery and permissions." + ); + assert_eq!(parameters["tools"][0]["name"], "search_codebase"); + assert_eq!(parameters["tools"][0]["eager_input_streaming"], true); + assert!(request.tools.is_empty()); + assert_eq!(request.max_tokens, Some(4096)); + assert_eq!(history.len(), 1); +} + +#[test] +fn pi_tool_casing_round_trips_without_changing_galaxy_execution_identity() { + let mut turn = TurnRequest::new( + "claude-test".to_string(), + vec![ConversationMessage { + role: MessageRole::User, + content: MessageContent::Text("Inspect".to_string()), + }], + ); + turn.tools = vec![ToolDefinition { + name: "grep".to_string(), + description: "Search".to_string(), + input_schema: serde_json::json!({"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}), + }]; + let mut request = build_completion_request(turn, Some(4096), true, true, None).unwrap(); + let aliases = prepare_subscription_request(&mut request).unwrap(); + assert_eq!( + request.additional_params.as_ref().unwrap()["tools"][0]["name"], + "Grep" + ); + let mut event = AgentEvent::Tool { + event: ToolEvent::Proposed { + call: galaxy_agent_core::ToolCall { + id: "toolu_123".to_string(), + name: "Grep".to_string(), + arguments: json!({"query":"foo"}), + }, + }, + }; + restore_tool_names(&mut event, &aliases); + assert!( + matches!(event, AgentEvent::Tool { event: ToolEvent::Proposed { call } } + if call.name == "grep" && call.id == "toolu_123") + ); + let mut progress = AgentEvent::ToolCallProgress { + call_id: "toolu_123".to_string(), + name: Some("GREP".to_string()), + arguments_bytes: 42, + }; + restore_tool_names(&mut progress, &aliases); + assert!( + matches!(progress, AgentEvent::ToolCallProgress { name: Some(name), .. } if name == "grep") + ); +} + +#[test] +fn pi_call_id_normalization_preserves_tool_result_pairing() { + let mut turn = TurnRequest::new( + "claude-test".to_string(), + vec![ + ConversationMessage { + role: MessageRole::Assistant, + content: MessageContent::ToolUse { + tool_use_id: "tool.call/1".to_string(), + name: "grep".to_string(), + input: json!({}), + }, + }, + ConversationMessage { + role: MessageRole::User, + content: MessageContent::ToolResult { + tool_use_id: "tool.call/1".to_string(), + content: "found".to_string(), + is_error: false, + }, + }, + ], + ); + turn.system_prompt = Some("Galaxy".to_string()); + let mut request = build_completion_request(turn, Some(4096), true, true, None).unwrap(); + prepare_subscription_request(&mut request).unwrap(); + for message in request.chat_history.iter() { + match message { + Message::Assistant { content, .. } => { + let AssistantContent::ToolCall(call) = content.first() else { + panic!("tool call"); + }; + assert_eq!(call.function.name, "Grep"); + assert_eq!(call.call_id.as_deref(), Some("tool_call_1")); + } + Message::User { content } => { + let UserContent::ToolResult(result) = content.first() else { + panic!("tool result"); + }; + assert_eq!(result.call_id.as_deref(), Some("tool_call_1")); + } + _ => panic!("unexpected system message on wire history"), + } + } +} + +#[tokio::test] +async fn serialized_subscription_request_matches_pi_auth_and_tool_profile() { + use futures::StreamExt; + use rig_core::client::CompletionClient; + use rig_core::completion::CompletionModel; + let http_client = RecordingStreamingClient::new( + r#"event: message_start +data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-test","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} + +"#, + ); + let client = Client::::builder() + .api_key(SubscriptionToken("sk-ant-oat01-test".to_string())) + .http_headers(subscription_headers()) + .http_client(http_client.clone()) + .build() + .unwrap(); + let mut turn = TurnRequest::new( + "claude-test".to_string(), + vec![ConversationMessage { + role: MessageRole::User, + content: MessageContent::Text("Inspect".to_string()), + }], + ); + turn.system_prompt = Some("Galaxy discovery rules".to_string()); + turn.tools = vec![ToolDefinition { + name: "read".to_string(), + description: "Read".to_string(), + input_schema: json!({"type":"object","properties":{},"required":[]}), + }]; + let mut request = build_completion_request(turn, Some(4096), true, true, None).unwrap(); + prepare_subscription_request(&mut request).unwrap(); + let mut stream = client + .completion_model("claude-test") + .with_prompt_caching() + .stream(request) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + let requests = http_client.recording.requests(); + assert_eq!(requests.len(), 1); + let sent = &requests[0]; + assert_eq!(sent.headers["user-agent"], "claude-cli/2.1.280"); + assert_eq!(sent.headers["accept"], "application/json"); + assert_eq!( + sent.headers["anthropic-dangerous-direct-browser-access"], + "true" + ); + assert_eq!( + sent.headers["anthropic-beta"], + "claude-code-20250219,oauth-2025-04-20" + ); + assert!(!sent.headers.contains_key("x-api-key")); + let body: serde_json::Value = serde_json::from_slice(&sent.body).unwrap(); + assert_eq!(body["stream"], true); + assert_eq!(body["system"].as_array().unwrap().len(), 2); + assert_eq!(body["system"][0]["text"], CLAUDE_CODE_IDENTITY); + assert_eq!(body["system"][1]["text"], "Galaxy discovery rules"); + assert_eq!(body["tools"].as_array().unwrap().len(), 1); + assert_eq!(body["tools"][0]["name"], "Read"); + assert_eq!(body["tools"][0]["eager_input_streaming"], true); + assert_eq!(body["tools"][0]["cache_control"]["type"], "ephemeral"); +} + +// Rig's unary recording client does not implement streaming. Compose it with the +// SSE fixture client to capture the actual streaming payload and headers. +#[derive(Clone, Default)] +struct RecordingStreamingClient { + recording: rig_core::test_utils::RecordingHttpClient, + streaming: rig_core::test_utils::MockStreamingClient, +} + +impl RecordingStreamingClient { + fn new(sse: &'static str) -> Self { + Self { + recording: rig_core::test_utils::RecordingHttpClient::default(), + streaming: rig_core::test_utils::MockStreamingClient { + sse_bytes: bytes::Bytes::from_static(sse.as_bytes()), + }, + } + } +} + +impl HttpClientExt for RecordingStreamingClient { + fn send( + &self, + request: http_client::Request, + ) -> impl std::future::Future< + Output = http_client::Result>>, + > + rig_core::wasm_compat::WasmCompatSend + + 'static + where + T: Into + rig_core::wasm_compat::WasmCompatSend, + U: From + rig_core::wasm_compat::WasmCompatSend + 'static, + { + self.recording.send(request) + } + + fn send_multipart( + &self, + request: http_client::Request, + ) -> impl std::future::Future< + Output = http_client::Result>>, + > + rig_core::wasm_compat::WasmCompatSend + + 'static + where + U: From + rig_core::wasm_compat::WasmCompatSend + 'static, + { + self.recording.send_multipart(request) + } + + fn send_streaming( + &self, + request: http_client::Request, + ) -> impl std::future::Future> + + rig_core::wasm_compat::WasmCompatSend + where + T: Into + rig_core::wasm_compat::WasmCompatSend, + { + let recorded = self + .recording + .send::(request.map(Into::into)); + let streaming = self.streaming.clone(); + async move { + recorded.await?; + streaming + .send_streaming(http_client::Request::new(bytes::Bytes::new())) + .await + } + } +} diff --git a/crates/galaxy_agent_rig/src/lib.rs b/crates/galaxy_agent_rig/src/lib.rs index db457f79..cb247379 100644 --- a/crates/galaxy_agent_rig/src/lib.rs +++ b/crates/galaxy_agent_rig/src/lib.rs @@ -1,5 +1,6 @@ //! Rig-backed implementations of Galaxy's provider-neutral agent runtime. +mod anthropic_auth; mod bedrock; mod chatgpt; mod native; @@ -7,6 +8,7 @@ mod openai_compatible; mod request; mod stream; +pub use anthropic_auth::is_claude_subscription_token; pub use bedrock::*; pub use chatgpt::*; pub use native::*; diff --git a/crates/galaxy_agent_rig/src/native.rs b/crates/galaxy_agent_rig/src/native.rs index ab62c1d8..8450fa17 100644 --- a/crates/galaxy_agent_rig/src/native.rs +++ b/crates/galaxy_agent_rig/src/native.rs @@ -1,11 +1,16 @@ +use std::sync::Arc; + use async_trait::async_trait; +use futures::StreamExt; +use futures::future::BoxFuture; use galaxy_agent_core::{ AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, RuntimeCapabilities, RuntimeDescriptor, RuntimeKind, TurnControl, TurnRequest, }; use rig_core::client::{CompletionClient, ModelListingClient}; -use rig_core::providers::{anthropic, gemini}; +use rig_core::providers::gemini; +use crate::anthropic_auth::{anthropic_client, prepare_subscription_request, restore_tool_names}; use crate::request::build_completion_request; use crate::stream::{start_model_completion, start_model_turn}; @@ -17,7 +22,7 @@ pub struct RigModelInfo { } pub async fn discover_anthropic_models(api_key: &str) -> Result, String> { - let client = anthropic::Client::new(api_key).map_err(|error| error.to_string())?; + let client = anthropic_client(api_key)?; let models = client .list_models() .await @@ -84,23 +89,62 @@ pub fn validate_vertex_ai_credentials(project_id: &str, location: &str) -> Resul .map_err(|error| error.to_string()) } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct AnthropicRuntimeConfig { pub api_key: String, pub model: String, pub max_output_tokens: Option, } -#[derive(Clone, Debug)] +impl std::fmt::Debug for AnthropicRuntimeConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AnthropicRuntimeConfig") + .field("credential_configured", &!self.api_key.is_empty()) + .field( + "subscription", + &crate::is_claude_subscription_token(&self.api_key), + ) + .field("model", &self.model) + .field("max_output_tokens", &self.max_output_tokens) + .finish() + } +} + +pub type AnthropicCredentialResolver = + Arc BoxFuture<'static, Result> + Send + Sync>; + +#[derive(Clone)] pub struct AnthropicRuntime { config: AnthropicRuntimeConfig, descriptor: RuntimeDescriptor, + credential_resolver: Option, +} + +impl std::fmt::Debug for AnthropicRuntime { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AnthropicRuntime") + .field("config", &self.config) + .field("descriptor", &self.descriptor) + .finish() + } } impl AnthropicRuntime { pub fn new(config: AnthropicRuntimeConfig) -> Self { let descriptor = native_descriptor("anthropic", &config.model); - Self { config, descriptor } + Self { + config, + descriptor, + credential_resolver: None, + } + } + + /// Resolves renewable credentials before each turn without owning model retries. + pub fn with_credential_resolver(mut self, resolver: AnthropicCredentialResolver) -> Self { + self.credential_resolver = Some(resolver); + self } } @@ -115,19 +159,42 @@ impl AgentRuntime for AnthropicRuntime { request: TurnRequest, control: TurnControl, ) -> Result { - if self.config.api_key.trim().is_empty() { + let credential = match &self.credential_resolver { + Some(resolve) => resolve() + .await + .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error))?, + None => self.config.api_key.clone(), + }; + if credential.trim().is_empty() { return Err(AgentError::new( AgentErrorKind::Configuration, - "An Anthropic API key is required", + "An Anthropic API key or Claude subscription token is required", )); } - let client = anthropic::Client::new(&self.config.api_key) + let client = anthropic_client(&credential) .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; - let model = client.completion_model(&self.config.model); + let mut model = client.completion_model(&self.config.model); let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); - let completion_request = + let mut completion_request = build_completion_request(request, max_output_tokens, true, true, None)?; - start_model_turn(model, completion_request, control, max_output_tokens).await + let aliases = if crate::is_claude_subscription_token(&credential) { + model = model.with_prompt_caching(); + Some(prepare_subscription_request(&mut completion_request)?) + } else { + None + }; + let stream = + start_model_turn(model, completion_request, control, max_output_tokens).await?; + if let Some(aliases) = aliases { + Ok(Box::pin(stream.map(move |result| { + result.map(|mut event| { + restore_tool_names(&mut event, &aliases); + event + }) + }))) + } else { + Ok(stream) + } } } diff --git a/crates/galaxy_logging/src/native_tests.rs b/crates/galaxy_logging/src/native_tests.rs index 1e9a38c9..a7c3f252 100644 --- a/crates/galaxy_logging/src/native_tests.rs +++ b/crates/galaxy_logging/src/native_tests.rs @@ -1,5 +1,3 @@ -use std::io::Write as _; - use super::*; #[test] diff --git a/crates/galaxyui/src/platform/mac/rendering/metal/frame_capture.rs b/crates/galaxyui/src/platform/mac/rendering/metal/frame_capture.rs index cb0da129..73bbdecd 100644 --- a/crates/galaxyui/src/platform/mac/rendering/metal/frame_capture.rs +++ b/crates/galaxyui/src/platform/mac/rendering/metal/frame_capture.rs @@ -74,7 +74,7 @@ pub fn capture_frame( #[cfg(test)] pub(crate) fn convert_bgra_to_rgba(data: &mut [u8]) { - for chunk in data.chunks_exact_mut(4) { + for chunk in data.as_chunks_mut::<4>().0 { chunk.swap(0, 2); } } diff --git a/crates/galaxyui/src/platform/mac/text_layout.rs b/crates/galaxyui/src/platform/mac/text_layout.rs index e94b19de..536f8063 100644 --- a/crates/galaxyui/src/platform/mac/text_layout.rs +++ b/crates/galaxyui/src/platform/mac/text_layout.rs @@ -934,7 +934,9 @@ fn caret_positions_for_line( // edges and RTL text. positions.sort_unstable_by_key(|position| position.utf16_index); let mut carets: Vec<_> = positions - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|edges| { // Guaranteed by chunks_exact that there are 2 elements. let first = &edges[0]; diff --git a/crates/galaxyui/src/rendering/wgpu/renderer.rs b/crates/galaxyui/src/rendering/wgpu/renderer.rs index 687e89b6..18e2ac7c 100644 --- a/crates/galaxyui/src/rendering/wgpu/renderer.rs +++ b/crates/galaxyui/src/rendering/wgpu/renderer.rs @@ -265,7 +265,7 @@ fn capture_surface_texture( staging_buffer.unmap(); if format == wgpu::TextureFormat::Bgra8Unorm || format == wgpu::TextureFormat::Bgra8UnormSrgb { - for chunk in rgba_data.chunks_exact_mut(4) { + for chunk in rgba_data.as_chunks_mut::<4>().0 { chunk.swap(0, 2); } } diff --git a/crates/galaxyui_core/src/elements/shimmering_text.rs b/crates/galaxyui_core/src/elements/shimmering_text.rs index 166180dd..7559abe6 100644 --- a/crates/galaxyui_core/src/elements/shimmering_text.rs +++ b/crates/galaxyui_core/src/elements/shimmering_text.rs @@ -14,7 +14,7 @@ use string_offset::CharOffset; use crate::color::ColorU; pub use crate::elements::shimmering_text::config::ShimmerConfig; use crate::elements::shimmering_text::glyph_index::GlyphIndex; -use crate::elements::{Axis, Point, DEFAULT_UI_LINE_HEIGHT_RATIO}; +use crate::elements::{Axis, Icon, Point, DEFAULT_UI_LINE_HEIGHT_RATIO}; use crate::fonts::{FamilyId, Properties}; use crate::geometry::rect::RectF; use crate::geometry::vector::{vec2f, Vector2F}; @@ -90,6 +90,7 @@ pub struct ShimmeringTextElement { origin: Option, handle: ShimmeringTextStateHandle, + leading_icon: Option<(Icon, f32, f32)>, } impl ShimmeringTextElement { @@ -112,9 +113,16 @@ impl ShimmeringTextElement { size: None, origin: None, handle: state_handle, + leading_icon: None, } } + /// Includes a leading icon in the same shimmer track as the text. + pub fn with_leading_icon(mut self, path: &'static str, size: f32, gap: f32) -> Self { + self.leading_icon = Some((Icon::new(path, self.base_color), size, gap)); + self + } + /// Returns the center of the shimmer as a fractional glyph index along the "track". fn shimmer_center(&self, number_of_glyphs: usize, state: &StateInternal) -> GlyphIndex { if number_of_glyphs <= 1 { @@ -165,11 +173,12 @@ impl ShimmeringTextElement { return PaintStyleOverride::default(); } - let center = self.shimmer_center(n, &state); + let icon_offset = usize::from(self.leading_icon.is_some()); + let center = self.shimmer_center(n + icon_offset, &state); let mut overrides = RangeMap::new(); for (glyph_index, char_index) in glyph_indices_in_order.iter() { - let intensity = self.intensity_at(*glyph_index, center); + let intensity = self.intensity_at(GlyphIndex(glyph_index.0 + icon_offset), center); let color = self .base_color .to_f32() @@ -189,6 +198,20 @@ impl Element for ShimmeringTextElement { ctx: &mut crate::LayoutContext, app: &AppContext, ) -> Vector2F { + let icon_width = self + .leading_icon + .as_ref() + .map_or(0., |(_, size, gap)| size + gap); + if let Some((icon, size, _)) = &mut self.leading_icon { + icon.layout( + SizeConstraint { + min: vec2f(*size, *size), + max: vec2f(*size, *size), + }, + ctx, + app, + ); + } let mut state = self.handle.get(); let text_len = self.text.chars().count(); @@ -197,7 +220,7 @@ impl Element for ShimmeringTextElement { StyleAndFont::new(self.font_family, Properties::default(), TextStyle::new()), )]; - let max_width = constraint.max_along(Axis::Horizontal); + let max_width = (constraint.max_along(Axis::Horizontal) - icon_width).max(0.); let layout_key = LayoutKey { text: self.text.clone(), @@ -247,15 +270,22 @@ impl Element for ShimmeringTextElement { }; let size = vec2f( - line.width.max(constraint.min.x()).min(constraint.max.x()), - line.height(), + (line.width + icon_width) + .max(constraint.min.x()) + .min(constraint.max.x()), + line.height() + .max(self.leading_icon.as_ref().map_or(0., |(_, size, _)| *size)), ); self.size = Some(size); size } - fn after_layout(&mut self, _: &mut crate::AfterLayoutContext, _: &AppContext) {} + fn after_layout(&mut self, ctx: &mut crate::AfterLayoutContext, app: &AppContext) { + if let Some((icon, _, _)) = &mut self.leading_icon { + icon.after_layout(ctx, app); + } + } fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) { /// Duration, in ms, for which to repaint. Approximately 30fps. @@ -273,7 +303,31 @@ impl Element for ShimmeringTextElement { ctx.repaint_after(Duration::from_millis(REPAINT_DURATION)); - let bounds = RectF::from_points(origin, origin + size); + let mut text_origin = origin; + let icon_color = { + let state = self.handle.get(); + let center = self.shimmer_center(state.glyph_indices_in_order.len() + 1, &state); + self.base_color + .to_f32() + .lerp( + self.shimmer_color.to_f32(), + self.intensity_at(GlyphIndex(0), center), + ) + .to_u8() + }; + if let Some((icon, icon_size, gap)) = &mut self.leading_icon { + *icon = icon.with_color(icon_color); + icon.paint(origin + vec2f(0., (size.y() - *icon_size) / 2.), ctx, app); + text_origin += vec2f(*icon_size + *gap, 0.); + } + text_origin += vec2f(0., (size.y() - line.height()) / 2.); + let bounds = RectF::new( + text_origin, + vec2f( + (origin.x() + size.x() - text_origin.x()).max(0.), + line.height(), + ), + ); let style_overrides = self.build_color_overrides(); line.paint( diff --git a/crates/galaxyui_core/src/elements/table/mod.rs b/crates/galaxyui_core/src/elements/table/mod.rs index 73ab520a..df58e04d 100644 --- a/crates/galaxyui_core/src/elements/table/mod.rs +++ b/crates/galaxyui_core/src/elements/table/mod.rs @@ -475,9 +475,7 @@ impl TableStateHandle { /// and you want all columns to resize to fit their new content. pub fn invalidate_all_intrinsic_widths(&self) { let mut state = self.inner.borrow_mut(); - for width in &mut state.intrinsic_column_widths { - *width = None; - } + state.intrinsic_column_widths.fill(None); } /// Scroll to a specific row with an optional offset. diff --git a/crates/galaxyui_core/src/image_cache_tests.rs b/crates/galaxyui_core/src/image_cache_tests.rs index 536b7a86..ef47f8f2 100644 --- a/crates/galaxyui_core/src/image_cache_tests.rs +++ b/crates/galaxyui_core/src/image_cache_tests.rs @@ -497,7 +497,9 @@ fn test_svg_text_rasterizes_with_loaded_system_fonts() { assert!(image .rgba_bytes() - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .any(|pixel| pixel[3] != 0)); } @@ -534,7 +536,9 @@ fn test_svg_text_rasterizes_with_bundled_sans_serif_fallback() { assert!(image .rgba_bytes() - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .any(|pixel| pixel[3] != 0)); } diff --git a/crates/galaxyui_core/src/integration/capture_recorder.rs b/crates/galaxyui_core/src/integration/capture_recorder.rs index fe7eb1cd..e0f2f8dd 100644 --- a/crates/galaxyui_core/src/integration/capture_recorder.rs +++ b/crates/galaxyui_core/src/integration/capture_recorder.rs @@ -382,7 +382,7 @@ cfg_if::cfg_if! { use crate::platform::CapturedFrameFormat; let pixel_count = data.len() / 4; let mut rgb = Vec::with_capacity(pixel_count * 3); - for chunk in data.chunks_exact(4) { + for chunk in data.as_chunks::<4>().0 { match format { CapturedFrameFormat::Rgba => { rgb.push(chunk[0]); diff --git a/crates/galaxyui_core/src/integration/video_recorder.rs b/crates/galaxyui_core/src/integration/video_recorder.rs index 32c47667..091916bc 100644 --- a/crates/galaxyui_core/src/integration/video_recorder.rs +++ b/crates/galaxyui_core/src/integration/video_recorder.rs @@ -334,7 +334,7 @@ pub async fn run_capture_loop(app: crate::App, state: CaptureLoopState) { fn rgba_to_rgb(rgba: &[u8]) -> Vec { let pixel_count = rgba.len() / 4; let mut rgb = Vec::with_capacity(pixel_count * 3); - for chunk in rgba.chunks_exact(4) { + for chunk in rgba.as_chunks::<4>().0 { rgb.push(chunk[0]); rgb.push(chunk[1]); rgb.push(chunk[2]); diff --git a/crates/galaxyui_core/src/platform/mod.rs b/crates/galaxyui_core/src/platform/mod.rs index 51a959b6..9d8e96c6 100644 --- a/crates/galaxyui_core/src/platform/mod.rs +++ b/crates/galaxyui_core/src/platform/mod.rs @@ -529,7 +529,7 @@ impl CapturedFrame { pub fn ensure_rgba(&mut self) { if self.format == CapturedFrameFormat::Bgra { - for chunk in self.data.chunks_exact_mut(4) { + for chunk in self.data.as_chunks_mut::<4>().0 { chunk.swap(0, 2); } self.format = CapturedFrameFormat::Rgba; diff --git a/crates/local_control/src/client_tests.rs b/crates/local_control/src/client_tests.rs index a466a1a6..c937c7d6 100644 --- a/crates/local_control/src/client_tests.rs +++ b/crates/local_control/src/client_tests.rs @@ -1,6 +1,3 @@ -#[cfg(unix)] -use std::io::{Read as _, Write as _}; - #[cfg(unix)] use chrono::Duration; use chrono::Utc;