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

This commit is contained in:
2026-10-04 18:20:58 -05:00
parent 188489c890
commit b29882d56c
49 changed files with 2374 additions and 288 deletions
+12
View File
@@ -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
Generated
+2
View File
@@ -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",
+2
View File
@@ -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",
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<g transform="rotate(-25 16 16)" fill="none" stroke="#ffffff" stroke-linecap="round">
<path d="M18.50 16.00 L18.74 16.20 L18.94 16.43 L19.10 16.69 L19.22 16.98 L19.29 17.29 L19.30 17.63 L19.25 17.97 L19.14 18.32 L18.95 18.68 L18.70 19.03 L18.38 19.37 L17.99 19.69 L17.54 19.99 L17.02 20.26 L16.44 20.49 L15.81 20.68 L15.13 20.82 L14.41 20.91 L13.66 20.94 L12.88 20.91 L12.09 20.82 L11.29 20.66 L10.50 20.43 L9.73 20.13 L8.99 19.77 L8.29 19.34 L7.64 18.85 L7.05 18.29 L6.53 17.68 L6.10 17.02 L5.76 16.31 L5.52 15.56 L5.38 14.78 L5.37 13.98 L5.46 13.16 L5.69 12.34 L6.03 11.52 L6.51 10.71 L7.11 9.93 L7.83 9.19 L8.67 8.49 L9.63 7.84 L10.69 7.26 L11.85 6.75" stroke-width="1.6"/>
<path d="M14.75 17.56 L14.39 17.61 L14.01 17.62 L13.62 17.59 L13.21 17.52 L12.80 17.40 L12.39 17.25 L12.00 17.04 L11.64 16.79 L11.30 16.50 L11.01 16.17 L10.76 15.80 L10.56 15.40 L10.43 14.96 L10.37 14.51 L10.38 14.03 L10.47 13.54 L10.64 13.05 L10.89 12.55 L11.23 12.07 L11.65 11.60 L12.16 11.15 L12.75 10.74 L13.42 10.36 L14.16 10.02 L14.97 9.74 L15.84 9.52 L16.76 9.36 L17.72 9.27 L18.71 9.26 L19.73 9.32 L20.75 9.46 L21.77 9.68 L22.78 9.99 L23.75 10.38 L24.69 10.85 L25.56 11.40 L26.37 12.03 L27.10 12.72 L27.74 13.49 L28.28 14.31 L28.70 15.19 L29.00 16.10 L29.17 17.06 L29.20 18.04" stroke-width="1.6"/>
<path d="M14.75 14.44 L14.87 14.20 L15.05 13.95 L15.28 13.72 L15.57 13.50 L15.91 13.30 L16.31 13.13 L16.75 12.99 L17.23 12.88 L17.75 12.82 L18.29 12.80 L18.86 12.83 L19.44 12.91 L20.03 13.05 L20.61 13.24 L21.18 13.48 L21.72 13.78 L22.23 14.13 L22.70 14.54 L23.11 14.99 L23.47 15.49 L23.75 16.03 L23.96 16.61 L24.08 17.21 L24.11 17.84 L24.04 18.49 L23.87 19.14 L23.60 19.79 L23.23 20.44 L22.75 21.06 L22.17 21.66 L21.49 22.23 L20.71 22.76 L19.84 23.23 L18.88 23.64 L17.85 23.99 L16.75 24.26 L15.59 24.45 L14.39 24.56 L13.15 24.58 L11.89 24.50 L10.63 24.33 L9.37 24.05 L8.14 23.68 L6.95 23.21" stroke-width="1.6"/>
</g>
<circle cx="16" cy="16" r="2.2" fill="#ffffff"/>
<circle cx="4" cy="7" r="0.85" fill="#ffffff"/>
<circle cx="26" cy="6" r="0.85" fill="#ffffff"/>
<circle cx="28" cy="22" r="0.7" fill="#ffffff"/>
<circle cx="8" cy="27" r="0.75" fill="#ffffff"/>
<circle cx="3" cy="20" r="0.6" fill="#ffffff"/>
<circle cx="23" cy="28" r="0.6" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+22
View File
@@ -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."
+4
View File
@@ -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,
+282
View File
@@ -0,0 +1,282 @@
//! Explicit Claude Code setup. Downloads happen only when the user tests a
//! connection; normal launches resolve installed components without network I/O.
use std::path::{Component, Path, PathBuf};
use std::process::Output;
use std::time::Duration;
use command::r#async::Command;
use futures::future::{select, Either};
use galaxy_acp::AcpLaunchConfig;
use tokio::sync::Mutex;
// Keep the adapter and the official native CLI installation reproducible.
// npm verifies package integrity and selects Anthropic's platform binary.
pub(super) const CLAUDE_ACP_VERSION: &str = "0.85.1";
const CLAUDE_CODE_VERSION: &str = "2.1.288";
static SETUP_LOCK: Mutex<()> = Mutex::const_new(());
fn installation_dir() -> PathBuf {
galaxy_core::paths::data_dir()
.join("acp")
.join("claude-code")
}
fn installed_command(command: &str) -> Option<PathBuf> {
AcpLaunchConfig::new(command)
.resolve_command()
.ok()
.map(|launch| launch.command)
}
fn package_binary(package_dir: &Path) -> Option<PathBuf> {
let manifest: serde_json::Value =
serde_json::from_slice(&std::fs::read(package_dir.join("package.json")).ok()?).ok()?;
let binary = manifest.get("bin")?.get("claude")?.as_str()?;
let binary = Path::new(binary);
if binary.is_absolute()
|| binary
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return None;
}
let binary = package_dir.join(binary);
binary.is_file().then_some(binary)
}
fn claude_binary() -> Option<PathBuf> {
installed_command("claude")
.or_else(|| {
let home = std::env::var_os("HOME")?;
let path = PathBuf::from(home).join(".local/bin/claude");
path.is_file().then_some(path)
})
.or_else(|| {
package_binary(&installation_dir().join("node_modules/@anthropic-ai/claude-code"))
})
}
fn adapter_script() -> PathBuf {
installation_dir().join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js")
}
fn managed_adapter_ready(directory: &Path) -> bool {
directory
.join(format!("ready-{CLAUDE_ACP_VERSION}"))
.is_file()
&& directory
.join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js")
.is_file()
}
pub(super) fn resolve_claude_code_launch() -> Result<AcpLaunchConfig, String> {
let claude = claude_binary().ok_or_else(|| {
"Claude Code is not installed. Test the Claude Code connection in Settings to download it."
.to_owned()
})?;
let launch = if let Some(adapter) = installed_command("claude-agent-acp") {
AcpLaunchConfig::new(adapter)
} else {
let script = adapter_script();
if !managed_adapter_ready(&installation_dir()) {
return Err("The Claude Code ACP adapter is missing. Test the connection in Settings to download it.".to_owned());
}
let managed_node = node_runtime::node_binary_path().map_err(|error| error.to_string())?;
let node = if managed_node.is_file() {
managed_node
} else {
installed_command("node").ok_or_else(|| "Claude Code's ACP adapter needs Node.js. Test the connection in Settings to install it.".to_owned())?
};
AcpLaunchConfig::new(node).args([script.to_string_lossy().into_owned()])
};
let mut path = launch
.command
.parent()
.into_iter()
.map(Path::to_owned)
.collect::<Vec<_>>();
if let Ok(node) = node_runtime::node_binary_path() {
if node.is_file() {
if let Some(directory) = node.parent() {
path.insert(0, directory.to_owned());
}
}
}
if let Some(existing) = std::env::var_os("PATH") {
path.extend(std::env::split_paths(&existing));
}
let path = std::env::join_paths(path).map_err(|error| error.to_string())?;
Ok(launch
// The adapter must use this unmodified binary, rather than silently
// selecting another SDK-bundled CLI. Auth stays entirely in Claude Code.
.env("CLAUDE_CODE_EXECUTABLE", claude.to_string_lossy())
.env("PATH", path.to_string_lossy()))
}
/// Prepare missing components and authenticate with Claude Code's own browser
/// flow. Never read its token store or include auth command output in logs.
pub(crate) async fn ensure_claude_code_installed(
progress: async_channel::Sender<&'static str>,
) -> Result<(), String> {
let _lock = SETUP_LOCK.lock().await;
let needs_cli = claude_binary().is_none();
let needs_adapter = installed_command("claude-agent-acp").is_none()
&& !managed_adapter_ready(&installation_dir());
if needs_cli || needs_adapter {
let _ = progress.try_send("Downloading missing Claude Code components…");
log::info!("Claude Code setup: missing_cli={needs_cli}, missing_adapter={needs_adapter}");
// Reuse Galaxy's private Node runtime; no global install or administrator
// permission is needed, even when Node/npm are absent from the user's PATH.
let client = http_client::Client::new();
match select(
Box::pin(node_runtime::install_npm(&client)),
Box::pin(async_io::Timer::after(Duration::from_secs(300))),
)
.await
{
Either::Left((result, _)) => {
result.map_err(|error| {
format!("Could not install Claude Code's Node runtime: {error}")
})?;
}
Either::Right(_) => {
return Err(
"Downloading Claude Code's Node runtime timed out. Test again to retry."
.to_owned(),
)
}
}
let node = node_runtime::node_binary_path().map_err(|error| error.to_string())?;
let npm = node_runtime::npm_binary_path().map_err(|error| error.to_string())?;
let directory = installation_dir();
async_fs::create_dir_all(&directory)
.await
.map_err(|error| error.to_string())?;
let mut command = Command::new(&node);
command
.arg(npm)
.args([
"install",
"--registry=https://registry.npmjs.org",
"--ignore-scripts=false",
"--no-audit",
"--no-fund",
"--save-exact",
])
.arg("--prefix")
.arg(&directory)
.current_dir(&directory);
if needs_cli {
command.arg(format!("@anthropic-ai/claude-code@{CLAUDE_CODE_VERSION}"));
}
if needs_adapter {
command.arg(format!(
"@agentclientprotocol/claude-agent-acp@{CLAUDE_ACP_VERSION}"
));
}
let mut paths = vec![node
.parent()
.ok_or("Node runtime has no directory")?
.to_owned()];
if let Some(path) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&path));
}
command.env(
"PATH",
std::env::join_paths(paths).map_err(|error| error.to_string())?,
);
let output = run_command(&mut command, Duration::from_secs(300)).await?;
if !output.status.success() {
// Installation runs no model request and has no Claude credentials.
let error = String::from_utf8_lossy(&output.stderr);
let detail = error.chars().take(2000).collect::<String>();
return Err(format!(
"Could not download Claude Code components: {detail}"
));
}
if needs_adapter {
if !adapter_script().is_file() {
return Err("The Claude Code adapter download completed without its executable. Test again to retry.".to_owned());
}
// A cancelled/failed npm install may leave the entrypoint behind.
// Only a completed install is eligible for future launches.
async_fs::write(
directory.join(format!("ready-{CLAUDE_ACP_VERSION}")),
b"ready",
)
.await
.map_err(|error| error.to_string())?;
}
}
let launch = resolve_claude_code_launch()?;
let claude = launch
.env
.get("CLAUDE_CODE_EXECUTABLE")
.ok_or("Claude Code executable is missing")?;
let _ = progress.try_send("Checking Claude Code sign-in…");
let status = run_command(
claude_auth_command(claude).args(["auth", "status", "--json"]),
Duration::from_secs(15),
)
.await?;
let status_json: serde_json::Value = serde_json::from_slice(&status.stdout)
.map_err(|_| "Claude Code could not report its login status. Run `claude auth login`, then test again.".to_owned())?;
if !status.status.success()
|| status_json
.get("loggedIn")
.and_then(serde_json::Value::as_bool)
!= Some(true)
{
let _ = progress.try_send("Opening Claude Code sign-in in your browser…");
let login = run_command(
claude_auth_command(claude).args(["auth", "login", "--claudeai"]),
Duration::from_secs(300),
)
.await?;
if !login.status.success() {
return Err("Claude Code sign-in did not complete. Complete sign-in through Claude Code and test again.".to_owned());
}
}
let _ = progress.try_send("Discovering Claude Code models…");
log::info!("Claude Code setup: components ready; starting ACP discovery");
Ok(())
}
fn claude_auth_command(claude: &str) -> Command {
let mut command = Command::new(claude);
// Match the ACP launch's sanitized environment: an inherited API key or
// provider redirect must not make setup report a subscription as signed in.
for variable in [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CONFIG_DIR",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
] {
command.env_remove(variable);
}
command
}
async fn run_command(command: &mut Command, timeout: Duration) -> Result<Output, String> {
command.kill_on_drop(true);
match select(
Box::pin(command.output()),
Box::pin(async_io::Timer::after(timeout)),
)
.await
{
Either::Left((result, _)) => result.map_err(|error| error.to_string()),
Either::Right(_) => {
Err("Claude Code setup timed out. Test the connection again to retry.".to_owned())
}
}
}
#[cfg(test)]
#[path = "setup_tests.rs"]
mod tests;
+52
View File
@@ -0,0 +1,52 @@
use super::*;
#[test]
fn incomplete_adapter_installation_is_not_launchable() {
let directory = tempfile::tempdir().unwrap();
let script = directory
.path()
.join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(&script, b"entrypoint").unwrap();
assert!(!managed_adapter_ready(directory.path()));
std::fs::write(
directory.path().join(format!("ready-{CLAUDE_ACP_VERSION}")),
b"ready",
)
.unwrap();
assert!(managed_adapter_ready(directory.path()));
std::fs::remove_file(script).unwrap();
assert!(!managed_adapter_ready(directory.path()));
}
#[test]
fn official_package_binary_is_resolved_from_its_manifest() {
let directory = tempfile::tempdir().unwrap();
std::fs::create_dir(directory.path().join("bin")).unwrap();
std::fs::write(directory.path().join("bin/claude.exe"), b"binary").unwrap();
std::fs::write(
directory.path().join("package.json"),
r#"{"bin":{"claude":"bin/claude.exe"}}"#,
)
.unwrap();
assert_eq!(
package_binary(directory.path()),
Some(directory.path().join("bin/claude.exe"))
);
}
#[test]
fn package_binary_cannot_escape_the_installation() {
let directory = tempfile::tempdir().unwrap();
let package = directory.path().join("package");
std::fs::create_dir(&package).unwrap();
std::fs::write(directory.path().join("claude"), b"outside").unwrap();
for binary in ["../claude", "/bin/sh", "missing"] {
std::fs::write(
package.join("package.json"),
serde_json::json!({"bin": {"claude": binary}}).to_string(),
)
.unwrap();
assert_eq!(package_binary(&package), None);
}
}
@@ -333,17 +333,16 @@ impl SearchCodebaseExecutor {
));
};
let search_dir;
let is_cross_repo;
if FeatureFlag::CrossRepoContext.is_enabled() {
is_cross_repo = codebase_path
.as_ref()
.is_some_and(|path| !current_working_directory.starts_with(path));
search_dir = codebase_path.unwrap_or(current_working_directory);
let (is_cross_repo, search_dir) = if FeatureFlag::CrossRepoContext.is_enabled() {
(
codebase_path
.as_ref()
.is_some_and(|path| !current_working_directory.starts_with(path)),
codebase_path.unwrap_or(current_working_directory),
)
} else {
is_cross_repo = false;
search_dir = current_working_directory;
}
(false, current_working_directory)
};
let server_output_id = get_server_output_id(conversation_id, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::SearchCodebaseRequested {
+9 -26
View File
@@ -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()
+443
View File
@@ -0,0 +1,443 @@
//! Pi-compatible Claude subscription OAuth. Secrets are never logged or serialized into settings.
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct Credentials {
pub(crate) access_token: String,
refresh_token: String,
expires_at: u64,
}
#[cfg(not(target_family = "wasm"))]
pub(crate) use desktop::*;
#[cfg(not(target_family = "wasm"))]
mod desktop {
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use futures::channel::oneshot;
use futures::StreamExt;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use galaxyui_extras::secure_storage::AppContextExt as _;
use instant::Instant;
use rand::RngCore;
use settings::Setting;
use sha2::{Digest, Sha256};
use url::Url;
use super::Credentials;
use crate::ai::claude_subscription::{is_stored_credential, OAUTH_REFERENCE_PREFIX};
use crate::settings::AISettings;
// Pi's browser flow, checked 2026-10-04:
// https://github.com/earendil-works/pi/blob/main/packages/ai/src/auth/oauth/anthropic.ts
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize";
const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
const REDIRECT_URI: &str = "http://localhost:53692/callback";
const SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(5 * 60);
type Reply = oneshot::Sender<Result<String, String>>;
enum Command {
Resolve(String, Reply),
Invalidate(String),
DiscardDraft(String),
}
static SENDER: OnceLock<Mutex<Option<async_channel::Sender<Command>>>> = OnceLock::new();
fn send(command: Command) -> Result<(), String> {
SENDER
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap()
.as_ref()
.ok_or("Claude authorization service is unavailable.")?
.try_send(command)
.map_err(|_| "Claude authorization service is busy or unavailable.".to_string())
}
pub(crate) fn invalidate(reference: &str) {
let _ = send(Command::Invalidate(reference.to_string()));
}
pub(crate) fn discard_draft(reference: &str) {
if reference.starts_with(OAUTH_REFERENCE_PREFIX) {
let _ = send(Command::DiscardDraft(reference.to_string()));
}
}
pub(crate) async fn load_credential(reference: String) -> Result<String, String> {
if !is_stored_credential(&reference) {
return Ok(reference);
}
let (tx, rx) = oneshot::channel();
send(Command::Resolve(reference, tx))?;
let result = futures::future::select(
Box::pin(rx),
Box::pin(async_io::Timer::after(Duration::from_secs(35))),
)
.await;
match result {
futures::future::Either::Left((result, _)) => {
result.map_err(|_| "Claude authorization service stopped.".to_string())?
}
futures::future::Either::Right(_) => {
Err("Claude token refresh timed out. Try again or sign in again.".to_string())
}
}
}
struct Refresh {
id: uuid::Uuid,
replies: Vec<Reply>,
}
/// Marshals secure-storage access to the UI thread and coalesces concurrent refreshes.
pub(crate) struct ClaudeOAuthModel {
refreshing: HashMap<String, Refresh>,
}
impl Entity for ClaudeOAuthModel {
type Event = ();
}
impl SingletonEntity for ClaudeOAuthModel {}
impl ClaudeOAuthModel {
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
let (tx, rx) = async_channel::bounded(64);
*SENDER.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(tx);
ctx.spawn_stream_local(
rx,
|me, command, ctx| match command {
Command::Resolve(reference, reply) => me.resolve(reference, reply, ctx),
Command::Invalidate(reference) => me.invalidate(&reference),
Command::DiscardDraft(reference) => {
let saved = AISettings::as_ref(ctx)
.openai_providers
.value()
.iter()
.any(|provider| provider.api_key.as_deref() == Some(&reference));
if !saved {
me.invalidate(&reference);
let _ = ctx.secure_storage().remove_value(&reference);
}
}
},
|_, _| {},
);
Self {
refreshing: HashMap::new(),
}
}
fn invalidate(&mut self, reference: &str) {
if let Some(refresh) = self.refreshing.remove(reference) {
for reply in refresh.replies {
let _ = reply.send(Err("Claude subscription was signed out.".to_string()));
}
}
}
fn resolve(&mut self, reference: String, reply: Reply, ctx: &mut ModelContext<Self>) {
if reply.is_canceled() {
return;
}
if let Some(refresh) = self.refreshing.get_mut(&reference) {
refresh.replies.push(reply);
return;
}
let value = match ctx.secure_storage().read_value(&reference) {
Ok(value) => value,
Err(_) => {
let _ = reply.send(Err(
"Claude subscription is signed out. Sign in again.".to_string()
));
return;
}
};
if !reference.starts_with(OAUTH_REFERENCE_PREFIX) {
let _ = reply.send(Ok(value));
return;
}
let credentials: Credentials = match serde_json::from_str(&value) {
Ok(credentials) => credentials,
Err(_) => {
let _ = reply.send(Err(
"Claude credentials are invalid. Sign in again.".to_string()
));
return;
}
};
if credentials.expires_at > now() {
let _ = reply.send(Ok(credentials.access_token));
return;
}
let id = uuid::Uuid::new_v4();
self.refreshing.insert(
reference.clone(),
Refresh {
id,
replies: vec![reply],
},
);
ctx.spawn(
async move {
token_request(serde_json::json!({
"grant_type": "refresh_token", "client_id": CLIENT_ID,
"refresh_token": credentials.refresh_token,
}))
.await
},
move |me, result, ctx| {
if me
.refreshing
.get(&reference)
.is_none_or(|refresh| refresh.id != id)
{
return;
}
let refresh = me.refreshing.remove(&reference).unwrap();
let result = result.and_then(|credentials| {
// Signing out deletes the store entry before this callback can commit.
if ctx.secure_storage().read_value(&reference).is_err() {
return Err("Claude subscription was signed out.".to_string());
}
save_credentials(&reference, &credentials, ctx)?;
Ok(credentials.access_token)
});
for reply in refresh.replies {
let _ = reply.send(result.clone());
}
},
);
}
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub(crate) fn save_credentials(
reference: &str,
credentials: &Credentials,
ctx: &galaxyui::AppContext,
) -> Result<(), String> {
let json = serde_json::to_string(credentials)
.map_err(|_| "Could not encode Claude credentials.".to_string())?;
ctx.secure_storage()
.write_value_with_owner_only_fallback(reference, &json)
.map_err(|_| {
"Could not securely save Claude credentials. Sign-in was not saved.".to_string()
})
}
#[derive(serde::Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: String,
expires_in: u64,
}
async fn token_request(body: serde_json::Value) -> Result<Credentials, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| "Could not create Claude authorization client.".to_string())?;
let response = client
.post(TOKEN_URL)
.header("Accept", "application/json")
.json(&body)
.send()
.await
.map_err(|_| {
"Claude authorization request failed. Try signing in again.".to_string()
})?;
if !response.status().is_success() {
// Never expose response bodies: providers may echo authorization codes or tokens.
return Err(format!(
"Claude authorization failed (HTTP {}). Sign in again.",
response.status().as_u16()
));
}
if response
.content_length()
.is_some_and(|length| length > 64 * 1024)
{
return Err("Claude authorization returned an oversized response.".to_string());
}
let mut bytes = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| "Claude authorization response failed.".to_string())?;
if bytes.len().saturating_add(chunk.len()) > 64 * 1024 {
return Err("Claude authorization returned an oversized response.".to_string());
}
bytes.extend_from_slice(&chunk);
}
let tokens: TokenResponse = serde_json::from_slice(&bytes)
.map_err(|_| "Claude authorization returned invalid credentials.".to_string())?;
credentials_from_response(tokens)
}
fn credentials_from_response(tokens: TokenResponse) -> Result<Credentials, String> {
if !tokens.access_token.contains("sk-ant-oat")
|| tokens.refresh_token.is_empty()
|| tokens.expires_in == 0
{
return Err("Claude authorization returned incomplete credentials.".to_string());
}
Ok(Credentials {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: now()
.saturating_add(tokens.expires_in)
.saturating_sub(5 * 60),
})
}
pub(crate) struct BrowserLogin {
pub(crate) url: Url,
verifier: String,
callback: oneshot::Receiver<Result<String, String>>,
cancelled: Arc<AtomicBool>,
}
impl Drop for BrowserLogin {
fn drop(&mut self) {
self.cancelled.store(true, Ordering::Relaxed);
}
}
impl BrowserLogin {
pub(crate) fn start() -> Result<Self, String> {
let listener = TcpListener::bind(("127.0.0.1", 53692))
.map_err(|_| "Claude sign-in needs localhost port 53692. Close another sign-in attempt and try again.".to_string())?;
listener
.set_nonblocking(true)
.map_err(|_| "Could not configure Claude sign-in callback.".to_string())?;
let mut bytes = [0; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let verifier = URL_SAFE_NO_PAD.encode(bytes);
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let url = authorize_url(&verifier, &challenge)?;
let cancelled = Arc::new(AtomicBool::new(false));
let stop = cancelled.clone();
let state = verifier.clone();
let (tx, callback) = oneshot::channel();
std::thread::Builder::new()
.name("claude-oauth-callback".to_string())
.spawn(move || {
let deadline = Instant::now() + SIGN_IN_TIMEOUT;
while !stop.load(Ordering::Relaxed) && Instant::now() < deadline {
match listener.accept() {
Ok((stream, _)) => {
if let Some(code) = read_callback(stream, &state) {
let _ = tx.send(Ok(code));
return;
}
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(50))
}
Err(_) => {
let _ = tx.send(Err("Claude sign-in callback failed.".to_string()));
return;
}
}
}
let _ = tx.send(Err(
"Claude browser sign-in expired or was cancelled.".to_string()
));
})
.map_err(|_| "Could not start Claude sign-in callback.".to_string())?;
Ok(Self {
url,
verifier,
callback,
cancelled,
})
}
pub(crate) async fn finish(mut self) -> Result<Credentials, String> {
let code = (&mut self.callback)
.await
.map_err(|_| "Claude sign-in callback stopped.".to_string())??;
token_request(serde_json::json!({
"grant_type": "authorization_code", "client_id": CLIENT_ID, "code": code,
"state": self.verifier, "redirect_uri": REDIRECT_URI, "code_verifier": self.verifier,
})).await
}
}
pub(super) fn authorize_url(verifier: &str, challenge: &str) -> Result<Url, String> {
let mut url = Url::parse(AUTHORIZE_URL)
.map_err(|_| "Invalid Claude authorization URL.".to_string())?;
url.query_pairs_mut()
.append_pair("code", "true")
.append_pair("client_id", CLIENT_ID)
.append_pair("response_type", "code")
.append_pair("redirect_uri", REDIRECT_URI)
.append_pair("scope", SCOPES)
.append_pair("code_challenge", challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("state", verifier);
Ok(url)
}
pub(super) fn callback_code(request: &str, state: &str) -> Option<String> {
let mut parts = request.split_whitespace();
if parts.next()? != "GET" {
return None;
}
let path = parts.next()?;
if !path.starts_with('/') || path.starts_with("//") {
return None;
}
let url = Url::parse(&format!("http://localhost:53692{path}")).ok()?;
if url.path() != "/callback" {
return None;
}
let params: HashMap<_, _> = url.query_pairs().into_owned().collect();
if params.get("state")?.as_str() != state {
return None;
}
params.get("code").filter(|code| !code.is_empty()).cloned()
}
fn read_callback(mut stream: TcpStream, state: &str) -> Option<String> {
stream.set_read_timeout(Some(Duration::from_secs(1))).ok()?;
stream
.set_write_timeout(Some(Duration::from_secs(1)))
.ok()?;
let mut request = String::new();
// Bound the request line independently of whether a newline is supplied.
std::io::Read::take(BufReader::new(&mut stream), 8192)
.read_line(&mut request)
.ok()?;
let code = callback_code(&request, state);
let (status, message) = if code.is_some() {
(
"200 OK",
"Signed in to Claude. You can close this tab and return to Galaxy.",
)
} else {
("400 Bad Request", "This callback did not match the active Galaxy sign-in. Return to Galaxy and try again.")
};
let body = format!("<!doctype html><title>Galaxy — Claude sign-in</title><p>{message}</p>");
let response = format!("HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = stream.write_all(response.as_bytes());
code
}
}
#[cfg(all(test, not(target_family = "wasm")))]
#[path = "claude_oauth_tests.rs"]
mod tests;
+47
View File
@@ -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"
));
}
+52
View File
@@ -0,0 +1,52 @@
//! User-supplied subscription tokens stay in Galaxy's secure credential store.
use galaxyui::AppContext;
use galaxyui_extras::secure_storage::AppContextExt as _;
use crate::settings::{OpenAIProviderConfig, OpenAIProviderKind};
const TOKEN_REFERENCE_PREFIX: &str = "galaxy-claude-token:";
pub(crate) const OAUTH_REFERENCE_PREFIX: &str = "galaxy-claude-oauth:";
pub(crate) const SUBSCRIPTION_WARNING: &str = "Unsupported Claude subscription integration. Anthropic does not permit third-party applications to use Claude subscription tokens. This may stop working without notice or lead to account restrictions. Galaxy retains its own tools, permissions, discovery, and orchestration.";
pub(crate) fn is_subscription_credential(credential: &str) -> bool {
credential.trim().contains("sk-ant-oat")
|| credential.starts_with(TOKEN_REFERENCE_PREFIX)
|| is_stored_credential(credential)
}
pub(crate) fn is_stored_credential(credential: &str) -> bool {
credential.starts_with(TOKEN_REFERENCE_PREFIX) || credential.starts_with(OAUTH_REFERENCE_PREFIX)
}
pub(crate) fn store_credential(
provider: &mut OpenAIProviderConfig,
ctx: &AppContext,
) -> Result<Option<String>, String> {
if provider.kind != OpenAIProviderKind::Anthropic {
return Ok(None);
}
let Some(token) = provider
.api_key
.as_deref()
.filter(|token| token.trim().contains("sk-ant-oat"))
else {
return Ok(None);
};
let reference = format!("{TOKEN_REFERENCE_PREFIX}{}", uuid::Uuid::new_v4());
ctx.secure_storage()
.write_value_with_owner_only_fallback(&reference, token.trim())
.map_err(|_| {
"Could not securely save the Claude subscription token. The connection was not saved."
.to_string()
})?;
provider.api_key = Some(reference.clone());
Ok(Some(reference))
}
pub(crate) fn remove_credential(reference: &str, ctx: &AppContext) {
if is_stored_credential(reference) {
#[cfg(not(target_family = "wasm"))]
super::claude_oauth::invalidate(reference);
let _ = ctx.secure_storage().remove_value(reference);
}
}
+3 -2
View File
@@ -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
+3 -1
View File
@@ -1,4 +1,6 @@
//! Loading animation components for AI features.
mod shimmering_warp_loading_text;
pub use shimmering_warp_loading_text::shimmering_warp_loading_text;
pub use shimmering_warp_loading_text::{
loading_logo_size, shimmering_warp_loading_text, LOADING_LOGO_GAP,
};
@@ -1,4 +1,4 @@
//! Shimmering Warp loading text - renders Warp logo with shimmering text for loading states.
//! Renders the Galaxy logo with shimmering text for loading states.
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::shimmering_text::{
@@ -7,10 +7,13 @@ use galaxyui::elements::shimmering_text::{
use galaxyui::elements::Element;
use galaxyui::{AppContext, SingletonEntity};
/// Warp icon glyph character
const WARP_GLYPH: &str = "\u{E500}";
pub const LOADING_LOGO_GAP: f32 = 8.;
/// Creates a shimmering text element with the Warp glyph.
pub fn loading_logo_size(font_size: f32) -> f32 {
(font_size * 1.5).max(24.)
}
/// Creates a shimmering text element beside the Galaxy logo.
pub fn shimmering_warp_loading_text(
text: impl Into<String>,
font_size: f32,
@@ -27,9 +30,8 @@ pub fn shimmering_warp_loading_text(
// Hardcoded shimmer config for consistent animation
let config = ShimmerConfig::default();
// Create a single shimmering element with glyph and text
ShimmeringTextElement::new(
format!("{} {}", WARP_GLYPH, text.into()),
text.into(),
appearance.ui_font_family(),
font_size,
base_color,
@@ -37,5 +39,10 @@ pub fn shimmering_warp_loading_text(
config,
shimmer_handle,
)
.with_leading_icon(
"bundled/svg/galaxy-mark.svg",
loading_logo_size(font_size),
LOADING_LOGO_GAP,
)
.finish()
}
+4
View File
@@ -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);
+11 -3
View File
@@ -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(),
+7 -13
View File
@@ -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,
+3 -3
View File
@@ -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
};
+213 -33
View File
@@ -163,6 +163,7 @@ pub enum AISubpage {
ProviderLiteLLM,
/// ChatGPT subscription provider settings.
ProviderChatGPTSubscription,
ProviderAnthropic,
/// AWS Bedrock provider settings.
ProviderBedrock,
/// ACP provider settings.
@@ -185,6 +186,7 @@ impl AISubpage {
SettingsSection::ProviderOpenAI => Some(Self::ProviderOpenAI),
SettingsSection::ProviderLiteLLM => Some(Self::ProviderLiteLLM),
SettingsSection::ProviderChatGPTSubscription => Some(Self::ProviderChatGPTSubscription),
SettingsSection::ProviderAnthropic => Some(Self::ProviderAnthropic),
SettingsSection::ProviderBedrock => Some(Self::ProviderBedrock),
SettingsSection::ProviderACP => Some(Self::ProviderACP),
// AgentMCPServers renders the standalone MCPServers page, not an AI subpage.
@@ -676,6 +678,83 @@ impl AISettingsPageView {
&mut self,
draft: &AcpProviderDraft,
ctx: &mut ViewContext<Self>,
) {
let generation = self
.provider_setup_body
.read(ctx, |body, _| body.acp_discovery_generation());
#[cfg(feature = "local_fs")]
if draft.agent_id.eq_ignore_ascii_case("claude-acp") && draft.command.trim().is_empty() {
let (progress, updates) = async_channel::bounded(8);
self.watch_claude_setup_progress(updates, generation, ctx);
let draft = draft.clone();
let registration = self.provider_setup_body.update(ctx, |body, ctx| {
body.set_acp_setup_status("Checking Claude Code installation…".to_owned(), ctx);
body.prepare_acp_setup()
});
let _ = ctx.spawn(
futures::future::Abortable::new(
async move { crate::ai::acp::ensure_claude_code_installed(progress).await },
registration,
),
move |me, result, ctx| {
if !me
.provider_setup_body
.read(ctx, |body, _| body.acp_discovery_is_current(generation))
{
return;
}
let Ok(result) = result else {
return;
};
match result {
Ok(()) => me.resolve_and_start_acp_discovery(&draft, generation, ctx),
Err(error) => {
log::warn!("Claude Code setup failed: {error}");
me.provider_setup_body.update(ctx, |body, ctx| {
body.finish_acp_discovery(Err(error), Vec::new(), ctx);
});
}
}
},
);
return;
}
self.resolve_and_start_acp_discovery(draft, generation, ctx);
}
#[cfg(feature = "local_fs")]
fn watch_claude_setup_progress(
&mut self,
updates: async_channel::Receiver<&'static str>,
generation: u64,
ctx: &mut ViewContext<Self>,
) {
let remaining_updates = updates.clone();
let _ = ctx.spawn(
async move { updates.recv().await },
move |me, update, ctx| {
if !me
.provider_setup_body
.read(ctx, |body, _| body.acp_discovery_is_current(generation))
{
return;
}
if let Ok(update) = update {
me.provider_setup_body.update(ctx, |body, ctx| {
body.set_acp_setup_status(update.to_owned(), ctx);
});
me.watch_claude_setup_progress(remaining_updates, generation, ctx);
}
},
);
}
#[cfg(not(target_family = "wasm"))]
fn resolve_and_start_acp_discovery(
&mut self,
draft: &AcpProviderDraft,
generation: u64,
ctx: &mut ViewContext<Self>,
) {
let config = match crate::ai::acp::AcpRuntimeModel::discovery_config_for_values(
&draft.agent_id,
@@ -691,7 +770,7 @@ impl AISettingsPageView {
return;
}
};
self.start_acp_discovery(config, draft.agent_id.clone(), ctx);
self.start_acp_discovery(config, draft.agent_id.clone(), generation, ctx);
}
#[cfg(not(target_family = "wasm"))]
@@ -699,6 +778,7 @@ impl AISettingsPageView {
&mut self,
config: galaxy_acp::AcpManagerConfig,
agent_id: String,
generation: u64,
ctx: &mut ViewContext<Self>,
) {
let provider_setup_body = self.provider_setup_body.clone();
@@ -724,6 +804,11 @@ impl AISettingsPageView {
let _ = ctx.spawn(
async move { manager.discover_config_options(cwd, Vec::new()).await },
move |_me, result, ctx| {
if !provider_setup_body
.read(ctx, |body, _| body.acp_discovery_is_current(generation))
{
return;
}
match result {
Ok(options) => {
let option_count = options.len();
@@ -1710,19 +1795,64 @@ impl AISettingsPageView {
mut provider: OpenAIProviderConfig,
ctx: &mut ViewContext<Self>,
) {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let new_reference =
match crate::ai::claude_subscription::store_credential(&mut provider, ctx) {
Ok(reference) => reference,
Err(error) => {
self.provider_setup_body
.update(ctx, |body, ctx| body.show_connection_error(error, ctx));
return;
}
};
let old_reference = editing_index.and_then(|index| {
AISettings::as_ref(ctx)
.openai_providers
.value()
.get(index)
.and_then(|provider| provider.api_key.clone())
});
let saved = AISettings::handle(ctx).update(ctx, |settings, ctx| {
let mut providers = settings.openai_providers.value().clone();
match editing_index {
Some(index) => {
if let Some(existing) = providers.get_mut(index) {
provider.enabled = existing.enabled;
*existing = provider.clone();
}
let Some(existing) = providers.get_mut(index) else {
return false;
};
provider.enabled = existing.enabled;
*existing = provider.clone();
}
None => providers.push(provider.clone()),
}
report_if_error!(settings.openai_providers.set_value(providers, ctx));
match settings.openai_providers.set_value(providers, ctx) {
Ok(_) => true,
Err(error) => {
report_error!(error);
false
}
}
});
if !saved {
if let Some(reference) = new_reference {
crate::ai::claude_subscription::remove_credential(&reference, ctx);
}
self.provider_setup_body.update(ctx, |body, ctx| {
body.show_connection_error(
"Could not save the provider connection.".to_string(),
ctx,
);
});
return;
}
if let Some(reference) = old_reference {
if !AISettings::as_ref(ctx)
.openai_providers
.value()
.iter()
.any(|provider| provider.api_key.as_deref() == Some(reference.as_str()))
{
crate::ai::claude_subscription::remove_credential(&reference, ctx);
}
}
self.clear_inline_provider_setup(ctx);
}
@@ -1929,6 +2059,12 @@ impl AISettingsPageView {
ProviderSetupProviderType::ChatGPTSubscription,
)));
}
Some(AISubpage::ProviderAnthropic) => {
widgets.push(Box::new(ProviderSettingsWidget::new(
ctx,
ProviderSetupProviderType::Anthropic,
)));
}
Some(AISubpage::ProviderBedrock) => {
widgets.push(Box::new(ProviderSettingsWidget::new(
ctx,
@@ -3297,8 +3433,22 @@ impl TypedActionView for AISettingsPageView {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let mut providers = settings.openai_providers.value().clone();
if *provider_index < providers.len() {
providers.remove(*provider_index);
report_if_error!(settings.openai_providers.set_value(providers, ctx));
let removed = providers.remove(*provider_index);
let remaining = providers.clone();
match settings.openai_providers.set_value(providers, ctx) {
Ok(_) => {
if let Some(reference) = removed.api_key {
if !remaining.iter().any(|provider| {
provider.api_key.as_deref() == Some(reference.as_str())
}) {
crate::ai::claude_subscription::remove_credential(
&reference, ctx,
);
}
}
}
Err(error) => report_error!(error),
}
}
});
self.rebuild_active_subpage(ctx);
@@ -3337,7 +3487,9 @@ impl TypedActionView for AISettingsPageView {
let mut providers = settings.acp_providers.value().clone();
if providers.is_empty() && provider_id == "legacy" {
report_if_error!(settings.acp_enabled.set_value(false, ctx));
report_if_error!(settings.acp_agent_id.set_value("codex".to_string(), ctx));
report_if_error!(settings
.acp_agent_id
.set_value("claude-acp".to_string(), ctx));
report_if_error!(settings.acp_agent_command.set_value(String::new(), ctx));
report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx));
report_if_error!(settings
@@ -3366,7 +3518,9 @@ impl TypedActionView for AISettingsPageView {
.set_value(first_provider.name.clone(), ctx));
} else {
report_if_error!(settings.acp_enabled.set_value(false, ctx));
report_if_error!(settings.acp_agent_id.set_value("codex".to_string(), ctx));
report_if_error!(settings
.acp_agent_id
.set_value("claude-acp".to_string(), ctx));
report_if_error!(settings.acp_agent_command.set_value(String::new(), ctx));
report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx));
report_if_error!(settings
@@ -6372,7 +6526,7 @@ impl SettingsWidget for ModelsOverviewWidget {
type View = AISettingsPageView;
fn search_terms(&self) -> &str {
"models providers runtimes rig litellm openai compatible ollama lm studio bedrock acp agent client protocol"
"models providers runtimes rig litellm openai compatible ollama lm studio bedrock acp agent client protocol anthropic claude subscription token"
}
fn render(
@@ -6390,6 +6544,12 @@ impl SettingsWidget for ModelsOverviewWidget {
ModelProviderSection::for_provider(provider) == ModelProviderSection::OpenAI
})
.count();
let anthropic_count = settings
.openai_providers
.value()
.iter()
.filter(|provider| provider.kind == OpenAIProviderKind::Anthropic)
.count();
let litellm_count = settings
.openai_providers
.value()
@@ -6420,13 +6580,13 @@ impl SettingsWidget for ModelsOverviewWidget {
.with_spacing(8.)
.with_child(build_sub_header(appearance, "Models", None).finish())
.with_child(render_ai_setting_description(
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI, LiteLLM, ChatGPT subscription, and Bedrock connections are configured here. ACP coding agents are shown read-only because the agent runtime controls their model, login, session, and tool loop.",
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI, LiteLLM, ChatGPT subscription, Anthropic / Claude subscription, and Bedrock connections are configured here. ACP coding agents are shown read-only because the agent runtime controls their model, login, session, and tool loop.",
true,
app,
))
.with_child(render_ai_setting_description(
format!(
"{openai_count} OpenAI provider(s); {litellm_count} LiteLLM provider(s); {chatgpt_count} ChatGPT subscription provider(s); {endpoint_model_count} direct model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled ACP runtime(s)."
"{openai_count} OpenAI provider(s); {litellm_count} LiteLLM provider(s); {chatgpt_count} ChatGPT subscription provider(s); {anthropic_count} Anthropic / Claude subscription provider(s); {endpoint_model_count} direct model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled ACP runtime(s)."
),
true,
app,
@@ -6437,6 +6597,7 @@ impl SettingsWidget for ModelsOverviewWidget {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ModelProviderSection {
Anthropic,
OpenAI,
LiteLLM,
ChatGPTSubscription,
@@ -6445,8 +6606,8 @@ enum ModelProviderSection {
impl ModelProviderSection {
fn for_provider(provider: &OpenAIProviderConfig) -> Self {
match provider.kind {
OpenAIProviderKind::Anthropic => Self::Anthropic,
OpenAIProviderKind::OpenAI
| OpenAIProviderKind::Anthropic
| OpenAIProviderKind::Gemini
| OpenAIProviderKind::VertexAI => Self::OpenAI,
OpenAIProviderKind::LiteLLM => Self::LiteLLM,
@@ -6472,6 +6633,7 @@ struct ProviderSettingsWidget {
bedrock_enabled_toggle: SwitchStateHandle,
bedrock_model_switches: RefCell<HashMap<String, SwitchStateHandle>>,
bedrock_model_table_state: TableStateHandle,
add_anthropic_provider_button: ViewHandle<ActionButton>,
add_openai_provider_button: ViewHandle<ActionButton>,
add_litellm_provider_button: ViewHandle<ActionButton>,
add_chatgpt_provider_button: ViewHandle<ActionButton>,
@@ -6529,6 +6691,15 @@ impl ProviderSettingsWidget {
}
})
.collect();
let add_anthropic_provider_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Add provider", SecondaryTheme)
.with_icon(Icon::Plus)
.on_click(|ctx| {
ctx.dispatch_typed_action(AISettingsPageAction::AddOpenAIProvider(
ProviderSetupProviderType::Anthropic,
));
})
});
let add_openai_provider_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Add provider", SecondaryTheme)
.with_icon(Icon::Plus)
@@ -6587,6 +6758,7 @@ impl ProviderSettingsWidget {
bedrock_enabled_toggle: SwitchStateHandle::default(),
bedrock_model_switches: RefCell::new(HashMap::new()),
bedrock_model_table_state: TableStateHandle::new(0, |_, _| Vec::new()),
add_anthropic_provider_button,
add_openai_provider_button,
add_litellm_provider_button,
add_chatgpt_provider_button,
@@ -6620,7 +6792,17 @@ impl ProviderSettingsWidget {
OpenAIProviderKind::OpenAI => "OpenAI",
OpenAIProviderKind::LiteLLM => "LiteLLM",
OpenAIProviderKind::ChatGPTSubscription => "ChatGPT subscription",
OpenAIProviderKind::Anthropic => "Anthropic",
OpenAIProviderKind::Anthropic => {
if provider
.api_key
.as_deref()
.is_some_and(crate::ai::claude_subscription::is_subscription_credential)
{
"Claude subscription (unsupported)"
} else {
"Anthropic API"
}
}
OpenAIProviderKind::Gemini => "Google Gemini",
OpenAIProviderKind::VertexAI => "Google Vertex AI",
}
@@ -7212,8 +7394,8 @@ impl ProviderSettingsWidget {
ProviderSetupProviderType::ChatGPTSubscription => {
Some(ModelProviderSection::ChatGPTSubscription)
}
ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
ProviderSetupProviderType::Anthropic => Some(ModelProviderSection::Anthropic),
ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI
| ProviderSetupProviderType::Bedrock
| ProviderSetupProviderType::Acp => None,
@@ -7226,8 +7408,8 @@ impl ProviderSettingsWidget {
ProviderSetupProviderType::LiteLLM => "LiteLLM",
ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT Subscription",
ProviderSetupProviderType::Bedrock => "Bedrock",
ProviderSetupProviderType::Acp => "ACP",
ProviderSetupProviderType::Anthropic => "Anthropic",
ProviderSetupProviderType::Acp => "Claude Code",
ProviderSetupProviderType::Anthropic => "Anthropic / Claude subscription",
ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
}
@@ -7248,9 +7430,9 @@ impl ProviderSettingsWidget {
"Use AWS Bedrock credentials and discovered Bedrock foundation models."
}
ProviderSetupProviderType::Acp => {
"Add an ACP agent connection here. After it is added, Settings shows it read-only because the agent controls its model, login, session, and tool loop."
"Connect Claude Code with your Claude account. Setup downloads missing components and opens sign-in in your browser."
}
ProviderSetupProviderType::Anthropic => "Connect directly to Anthropic.",
ProviderSetupProviderType::Anthropic => crate::ai::claude_subscription::SUBSCRIPTION_WARNING,
ProviderSetupProviderType::Gemini => "Connect directly to Google Gemini.",
ProviderSetupProviderType::VertexAI => "Connect directly to Google Vertex AI.",
}
@@ -7264,7 +7446,7 @@ impl ProviderSettingsWidget {
"No ChatGPT subscription provider configured."
}
ProviderSetupProviderType::Bedrock => "No Bedrock provider configured.",
ProviderSetupProviderType::Acp => "No ACP agent connection configured.",
ProviderSetupProviderType::Acp => "No Claude Code connection configured.",
ProviderSetupProviderType::Anthropic => "No Anthropic provider configured.",
ProviderSetupProviderType::Gemini => "No Google Gemini provider configured.",
ProviderSetupProviderType::VertexAI => "No Google Vertex AI provider configured.",
@@ -7283,9 +7465,8 @@ impl ProviderSettingsWidget {
}
ProviderSetupProviderType::Bedrock => Some(&self.bedrock_add_button),
ProviderSetupProviderType::Acp => Some(&self.acp_add_button),
ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => None,
ProviderSetupProviderType::Anthropic => Some(&self.add_anthropic_provider_button),
ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => None,
}
}
@@ -7369,7 +7550,8 @@ impl SettingsWidget for ProviderSettingsWidget {
match self.provider_type {
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::ChatGPTSubscription => {
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic => {
if is_setup_visible {
column.add_child(Self::render_inline_setup(appearance, view));
}
@@ -7462,22 +7644,20 @@ impl SettingsWidget for ProviderSettingsWidget {
None
};
let empty_message = if acp_supported {
"No ACP agent connection configured."
"No Claude Code connection configured."
} else {
"ACP providers are not available on this platform."
};
column.add_child(Self::render_provider_section(
"ACP",
"Add an ACP agent connection here. After it is added, Settings shows it read-only because the agent controls its model, login, session, and tool loop.",
"Claude Code",
"Connect Claude Code with your Claude account. Setup downloads missing components and opens sign-in in your browser.",
acp_add_button,
acp_cards,
empty_message,
appearance,
));
}
ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {}
ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => {}
}
column.finish()
+8
View File
@@ -250,6 +250,7 @@ pub enum SettingsSection {
ProviderOpenAI,
ProviderLiteLLM,
ProviderChatGPTSubscription,
ProviderAnthropic,
ProviderBedrock,
ProviderACP,
/// Internal backing-page identifier for CodeSettingsPageView. Multiple subpages
@@ -282,6 +283,7 @@ impl Display for SettingsSection {
SettingsSection::ProviderOpenAI => write!(f, "OpenAI"),
SettingsSection::ProviderLiteLLM => write!(f, "LiteLLM"),
SettingsSection::ProviderChatGPTSubscription => write!(f, "ChatGPT Subscription"),
SettingsSection::ProviderAnthropic => write!(f, "Anthropic / Claude subscription"),
SettingsSection::ProviderBedrock => write!(f, "Bedrock"),
SettingsSection::ProviderACP => write!(f, "ACP"),
SettingsSection::Wormhole => write!(f, "Wormhole"),
@@ -318,6 +320,7 @@ impl SettingsSection {
Self::ProviderOpenAI
| Self::ProviderLiteLLM
| Self::ProviderChatGPTSubscription
| Self::ProviderAnthropic
| Self::ProviderBedrock
| Self::ProviderACP
)
@@ -335,6 +338,7 @@ impl SettingsSection {
| Self::ProviderOpenAI
| Self::ProviderLiteLLM
| Self::ProviderChatGPTSubscription
| Self::ProviderAnthropic
| Self::ProviderBedrock
| Self::ProviderACP
)
@@ -376,6 +380,7 @@ impl SettingsSection {
Self::ProviderOpenAI,
Self::ProviderLiteLLM,
Self::ProviderChatGPTSubscription,
Self::ProviderAnthropic,
Self::ProviderBedrock,
Self::ProviderACP,
]
@@ -413,6 +418,9 @@ impl FromStr for SettingsSection {
"OpenAI / LiteLLM" | "LiteLLM" => Ok(Self::ProviderLiteLLM),
"OpenAI" => Ok(Self::ProviderOpenAI),
"ChatGPT Subscription" | "ChatGPTSubscription" => Ok(Self::ProviderChatGPTSubscription),
"Anthropic" | "Claude Subscription" | "Anthropic / Claude subscription" => {
Ok(Self::ProviderAnthropic)
}
"Agent Client Protocol" | "ACP" => Ok(Self::ProviderACP),
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
+5
View File
@@ -41,6 +41,7 @@ fn provider_subpages_are_classified_and_map_to_ai_backing_page() {
SettingsSection::ProviderOpenAI,
SettingsSection::ProviderLiteLLM,
SettingsSection::ProviderChatGPTSubscription,
SettingsSection::ProviderAnthropic,
SettingsSection::ProviderBedrock,
SettingsSection::ProviderACP,
]
@@ -116,6 +117,10 @@ fn current_settings_display_names_round_trip() {
SettingsSection::ProviderChatGPTSubscription,
"ChatGPT Subscription",
),
(
SettingsSection::ProviderAnthropic,
"Anthropic / Claude subscription",
),
(SettingsSection::ProviderBedrock, "Bedrock"),
(SettingsSection::ProviderACP, "ACP"),
(SettingsSection::CodeIndexing, "Indexing and projects"),
+344 -46
View File
@@ -67,7 +67,7 @@ use crate::ai::harness_display;
use crate::ai::llms::{merge_discovered_provider_models, replace_litellm_catalog, LLMPreferences};
use crate::appearance::Appearance;
use crate::editor::{
EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions,
EditOrigin, EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions,
};
use crate::settings::ai::{
AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride,
@@ -118,6 +118,11 @@ const PROVIDER_TYPE_OPTIONS: &[(ProviderSetupProviderType, &str, &str)] = &[
"ChatGPT subscription",
"Use your ChatGPT Plus or Pro subscription with native OAuth.",
),
(
ProviderSetupProviderType::Anthropic,
"Anthropic / Claude subscription",
"Use an Anthropic API key or a Claude Code authorization token with Galaxy’s own tools.",
),
(
ProviderSetupProviderType::Bedrock,
"AWS Bedrock",
@@ -215,6 +220,9 @@ pub enum ProviderSetupViewAction {
ToggleModel(usize),
CycleModelCapability(usize, CapabilityKey),
ConnectChatGPT,
ConnectClaude,
SignOutClaude,
CancelClaudeSignIn,
SelectBedrockAuth(BedrockAuthMethod),
SelectBedrockProfile(String),
ToggleBedrockCrossRegion,
@@ -236,6 +244,9 @@ pub struct ProviderSetupView {
draft_bedrock: BedrockProviderDraft,
draft_acp: AcpProviderDraft,
discovery_state: DiscoveryState,
acp_setup_status: String,
acp_discovery_generation: u64,
acp_setup_abort: Option<futures::future::AbortHandle>,
provider_type_buttons: Vec<ViewHandle<ActionButton>>,
acp_agent_buttons: Vec<ViewHandle<ActionButton>>,
name_editor: ViewHandle<EditorView>,
@@ -253,6 +264,11 @@ pub struct ProviderSetupView {
acp_command_editor: ViewHandle<EditorView>,
acp_args_editor: ViewHandle<EditorView>,
chatgpt_connect_mouse_state: MouseStateHandle,
claude_connect_mouse_state: MouseStateHandle,
claude_signin_abort: Option<futures::future::AbortHandle>,
claude_signin_generation: u64,
claude_signin_status: String,
claude_credentials_available: bool,
bedrock_auth_buttons: Vec<ViewHandle<ActionButton>>,
bedrock_cross_region_toggle: SwitchStateHandle,
bedrock_auto_login_toggle: SwitchStateHandle,
@@ -300,11 +316,11 @@ impl ProviderSetupView {
let bedrock_refresh_command_editor = Self::create_editor("aws sso login", false, ctx);
let bedrock_access_key_editor = Self::create_editor("AKIA...", false, ctx);
let bedrock_secret_key_editor = Self::create_editor("Secret access key", true, ctx);
let acp_agent_id_editor = Self::create_editor("codex", false, ctx);
let acp_agent_id_editor = Self::create_editor("claude-acp", false, ctx);
let acp_command_editor = Self::create_editor("Optional executable", false, ctx);
let acp_args_editor = Self::create_editor(r#"["arg1", "arg2"]"#, false, ctx);
let mut acp_agent_buttons = galaxy_acp::known_acp_agents()
let acp_agent_buttons = galaxy_acp::known_acp_agents()
.iter()
.map(|agent| {
let id = agent.id.to_owned();
@@ -322,15 +338,6 @@ impl ProviderSetupView {
})
})
.collect::<Vec<_>>();
acp_agent_buttons.push(ctx.add_typed_action_view(|_| {
ActionButton::new("Custom", NakedTheme)
.with_full_width(true)
.on_click(|ctx| {
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectAcpAgent(
"custom".to_owned(),
));
})
}));
let bedrock_auth_buttons = [
BedrockAuthMethod::Profile,
@@ -362,9 +369,15 @@ impl ProviderSetupView {
}
});
ctx.subscribe_to_view(&api_key_editor, |me, editor, event, ctx| {
if matches!(event, EditorEvent::Edited(_)) {
if matches!(event, EditorEvent::Edited(origin) if *origin != EditOrigin::SystemEdit) {
let value = editor.as_ref(ctx).buffer_text(ctx);
me.cancel_claude_signin();
me.draft_api_key = (!value.trim().is_empty()).then_some(value);
me.claude_credentials_available = false;
if me.provider_type == ProviderSetupProviderType::Anthropic {
me.draft_models.clear();
}
me.update_next_button(ctx);
ctx.notify();
}
});
@@ -465,12 +478,15 @@ impl ProviderSetupView {
},
draft_acp: AcpProviderDraft {
name: String::new(),
agent_id: "codex".to_string(),
agent_id: "claude-acp".to_string(),
command: String::new(),
args: Vec::new(),
config_options: Vec::new(),
},
discovery_state: DiscoveryState::Idle,
acp_setup_status: String::new(),
acp_discovery_generation: 0,
acp_setup_abort: None,
provider_type_buttons,
acp_agent_buttons,
name_editor,
@@ -488,6 +504,11 @@ impl ProviderSetupView {
acp_command_editor,
acp_args_editor,
chatgpt_connect_mouse_state: MouseStateHandle::default(),
claude_connect_mouse_state: MouseStateHandle::default(),
claude_signin_abort: None,
claude_signin_generation: 0,
claude_signin_status: String::new(),
claude_credentials_available: false,
bedrock_auth_buttons,
bedrock_cross_region_toggle: SwitchStateHandle::default(),
bedrock_auto_login_toggle: SwitchStateHandle::default(),
@@ -542,7 +563,7 @@ impl ProviderSetupView {
ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
ProviderSetupProviderType::Bedrock => "AWS Bedrock",
ProviderSetupProviderType::Acp => "ACP agent runtime",
ProviderSetupProviderType::Acp => "Claude Code",
}
}
@@ -564,6 +585,8 @@ impl ProviderSetupView {
provider_type: ProviderSetupProviderType,
ctx: &mut ViewContext<Self>,
) {
self.cancel_acp_setup();
self.cancel_claude_signin();
self.step = ProviderSetupStep::Configure;
self.editing_index = None;
self.provider_type = provider_type;
@@ -571,6 +594,7 @@ impl ProviderSetupView {
self.draft_name = Self::default_name(provider_type).to_string();
self.draft_base_url = Self::default_base_url(provider_type).to_string();
self.draft_api_key = None;
self.claude_credentials_available = false;
self.draft_project_id.clear();
self.draft_location = "global".to_string();
self.draft_models.clear();
@@ -588,7 +612,7 @@ impl ProviderSetupView {
};
self.draft_acp = AcpProviderDraft {
name: String::new(),
agent_id: "codex".to_string(),
agent_id: "claude-acp".to_string(),
command: String::new(),
args: Vec::new(),
config_options: Vec::new(),
@@ -613,6 +637,7 @@ impl ProviderSetupView {
provider: OpenAIProviderConfig,
ctx: &mut ViewContext<Self>,
) {
self.cancel_claude_signin();
// Editing an existing provider is a local catalog operation. Do not
// send the user through credentials or model discovery again.
self.step = ProviderSetupStep::Models;
@@ -631,6 +656,14 @@ impl ProviderSetupView {
self.draft_name = provider.name;
self.draft_base_url = provider.base_url;
self.draft_api_key = provider.api_key;
{
use galaxyui_extras::secure_storage::AppContextExt as _;
self.claude_credentials_available =
self.draft_api_key.as_deref().is_some_and(|reference| {
crate::ai::claude_subscription::is_stored_credential(reference)
&& ctx.secure_storage().read_value(reference).is_ok()
});
}
self.draft_project_id = provider.project_id.unwrap_or_default();
self.draft_location = provider.location.unwrap_or_else(|| "global".to_string());
self.draft_models = provider.models;
@@ -667,6 +700,7 @@ impl ProviderSetupView {
}
pub fn begin_edit_acp(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext<Self>) {
self.cancel_acp_setup();
self.step = if draft.config_options.is_empty() {
ProviderSetupStep::Configure
} else {
@@ -688,6 +722,43 @@ impl ProviderSetupView {
ctx.notify();
}
pub fn acp_discovery_generation(&self) -> u64 {
self.acp_discovery_generation
}
pub fn acp_discovery_is_current(&self, generation: u64) -> bool {
self.provider_type == ProviderSetupProviderType::Acp
&& self.step == ProviderSetupStep::Discover
&& matches!(self.discovery_state, DiscoveryState::Loading)
&& self.acp_discovery_generation == generation
}
#[cfg(feature = "local_fs")]
pub fn prepare_acp_setup(&mut self) -> futures::future::AbortRegistration {
self.cancel_acp_setup();
let (handle, registration) = futures::future::AbortHandle::new_pair();
self.acp_setup_abort = Some(handle);
registration
}
fn cancel_acp_setup(&mut self) {
if let Some(handle) = self.acp_setup_abort.take() {
handle.abort();
}
}
pub fn set_acp_setup_status(&mut self, status: String, ctx: &mut ViewContext<Self>) {
self.acp_setup_status = status;
ctx.notify();
}
pub fn show_connection_error(&mut self, error: String, ctx: &mut ViewContext<Self>) {
self.discovery_state = DiscoveryState::Failed(error);
self.step = ProviderSetupStep::Discover;
self.update_next_button(ctx);
ctx.notify();
}
pub fn finish_acp_discovery(
&mut self,
result: Result<(), String>,
@@ -754,7 +825,13 @@ impl ProviderSetupView {
editor.system_reset_buffer_text(&self.draft_base_url, ctx);
});
self.api_key_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(self.draft_api_key.as_deref().unwrap_or_default(), ctx);
let credential = self.draft_api_key.as_deref().unwrap_or_default();
let text = if crate::ai::claude_subscription::is_stored_credential(credential) {
""
} else {
credential
};
editor.system_reset_buffer_text(text, ctx);
});
self.project_id_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_project_id, ctx);
@@ -834,11 +911,6 @@ impl ProviderSetupView {
button.set_active(agent.id.eq_ignore_ascii_case(selected), ctx);
});
}
if let Some(button) = self.acp_agent_buttons.last() {
button.update(ctx, |button, ctx| {
button.set_active(selected.eq_ignore_ascii_case("custom"), ctx);
});
}
}
fn sync_bedrock_auth_buttons(&self, ctx: &mut ViewContext<Self>) {
@@ -948,7 +1020,9 @@ impl ProviderSetupView {
| ProviderSetupProviderType::VertexAI => (
"Save",
self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled),
|| !self.draft_models.iter().any(|model| model.enabled)
|| (self.provider_type == ProviderSetupProviderType::Anthropic
&& !self.is_configure_valid()),
),
ProviderSetupProviderType::Bedrock => (
"Save",
@@ -1057,8 +1131,11 @@ impl ProviderSetupView {
}
fn begin_discovery(&mut self, ctx: &mut ViewContext<Self>) {
self.cancel_acp_setup();
self.step = ProviderSetupStep::Discover;
self.discovery_state = DiscoveryState::Loading;
self.acp_setup_status.clear();
self.acp_discovery_generation = self.acp_discovery_generation.wrapping_add(1);
self.update_next_button(ctx);
ctx.notify();
@@ -1136,26 +1213,38 @@ impl ProviderSetupView {
let provider = self.draft_provider();
let existing_models = self.draft_models.clone();
let authoritative_catalog = provider.kind == OpenAIProviderKind::LiteLLM;
let generation = self.acp_discovery_generation;
let requested_kind = self.provider_type;
let requested_credential = self.draft_api_key.clone();
ctx.spawn(
async move { LLMPreferences::discover_openai_provider_models(provider).await },
move |me, result, ctx| match result {
Ok(models) => {
me.draft_models = if authoritative_catalog {
replace_litellm_catalog(&existing_models, models)
} else {
merge_discovered_provider_models(&existing_models, models)
};
me.discovery_state = DiscoveryState::Idle;
me.step = ProviderSetupStep::Models;
me.sync_model_switches(ctx);
me.update_next_button(ctx);
ctx.focus(&me.name_editor);
ctx.notify();
move |me, result, ctx| {
if generation != me.acp_discovery_generation
|| requested_kind != me.provider_type
|| requested_credential != me.draft_api_key
|| me.step != ProviderSetupStep::Discover
{
return;
}
Err(error) => {
me.discovery_state = DiscoveryState::Failed(error);
me.update_next_button(ctx);
ctx.notify();
match result {
Ok(models) => {
me.draft_models = if authoritative_catalog {
replace_litellm_catalog(&existing_models, models)
} else {
merge_discovered_provider_models(&existing_models, models)
};
me.discovery_state = DiscoveryState::Idle;
me.step = ProviderSetupStep::Models;
me.sync_model_switches(ctx);
me.update_next_button(ctx);
ctx.focus(&me.name_editor);
ctx.notify();
}
Err(error) => {
me.discovery_state = DiscoveryState::Failed(error);
me.update_next_button(ctx);
ctx.notify();
}
}
},
);
@@ -1313,6 +1402,138 @@ impl ProviderSetupView {
.finish()
}
fn cancel_claude_signin(&mut self) {
self.claude_signin_generation = self.claude_signin_generation.wrapping_add(1);
if let Some(abort) = self.claude_signin_abort.take() {
abort.abort();
}
#[cfg(not(target_family = "wasm"))]
if let Some(reference) = self.draft_api_key.as_deref() {
crate::ai::claude_oauth::discard_draft(reference);
}
self.claude_signin_status.clear();
}
#[cfg(not(target_family = "wasm"))]
fn connect_claude(&mut self, ctx: &mut ViewContext<Self>) {
if self.claude_signin_abort.is_some() {
return;
}
self.cancel_claude_signin();
let login = match crate::ai::claude_oauth::BrowserLogin::start() {
Ok(login) => login,
Err(error) => {
self.claude_signin_status = error;
ctx.notify();
return;
}
};
let generation = self.claude_signin_generation;
let (abort, registration) = futures::future::AbortHandle::new_pair();
self.claude_signin_abort = Some(abort);
self.claude_signin_status = "Waiting for Claude sign-in in your browser…".to_string();
self.update_next_button(ctx);
ctx.open_url(login.url.as_str());
ctx.notify();
ctx.spawn(
futures::future::Abortable::new(login.finish(), registration),
move |me, result, ctx| {
if generation != me.claude_signin_generation
|| me.provider_type != ProviderSetupProviderType::Anthropic
{
return;
}
me.claude_signin_abort = None;
match result {
Ok(Ok(credentials)) => {
let reference = format!(
"{}{}",
crate::ai::claude_subscription::OAUTH_REFERENCE_PREFIX,
uuid::Uuid::new_v4()
);
match crate::ai::claude_oauth::save_credentials(
&reference,
&credentials,
ctx,
) {
Ok(()) => {
me.draft_api_key = Some(reference);
me.claude_credentials_available = true;
me.draft_models.clear();
me.claude_signin_status =
"Claude subscription connected.".to_string();
me.sync_editors(ctx);
}
Err(error) => me.claude_signin_status = error,
}
}
Ok(Err(error)) => me.claude_signin_status = error,
Err(_) => me.claude_signin_status = "Claude sign-in cancelled.".to_string(),
}
me.update_next_button(ctx);
ctx.notify();
},
);
}
fn render_claude_auth(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let connected = self.claude_credentials_available;
let description = if !self.claude_signin_status.is_empty() {
self.claude_signin_status.as_str()
} else if connected {
"Claude subscription credentials saved on this device."
} else {
"Sign in with your Claude subscription in your browser."
};
let mut children = vec![
Self::render_label(appearance, "Claude subscription authorization"),
Text::new(
description.to_string(),
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
];
{
let pending = self.claude_signin_abort.is_some();
children.push(
appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.claude_connect_mouse_state.clone(),
)
.with_text_label(
if pending {
"Cancel sign-in"
} else if connected {
"Sign out"
} else {
"Sign in with Claude"
}
.to_string(),
)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(if pending {
ProviderSetupViewAction::CancelClaudeSignIn
} else if connected {
ProviderSetupViewAction::SignOutClaude
} else {
ProviderSetupViewAction::ConnectClaude
});
})
.finish(),
);
}
Flex::column()
.with_spacing(10.)
.with_children(children)
.finish()
}
#[cfg(not(target_family = "wasm"))]
fn render_chatgpt_auth(
&self,
@@ -1438,10 +1659,21 @@ impl ProviderSetupView {
);
}
ProviderSetupProviderType::Anthropic => {
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
children.push(self.render_claude_auth(appearance));
if !self
.draft_api_key
.as_deref()
.is_some_and(crate::ai::claude_subscription::is_stored_credential)
{
children.push(self.render_input(
appearance,
"API key or setup token (optional alternative)",
&self.api_key_editor,
));
}
children.push(
Text::new(
"The key is stored locally and is never synced to the cloud. Models will be discovered from Anthropic after the connection test.",
"Sign in with Claude to use your subscription. OAuth credentials are saved in Galaxy’s secure credential store and refreshed automatically. Alternatively, paste an API key or a token from `claude setup-token`; pasted setup tokens must be replaced manually if they expire. API keys use Anthropic API billing.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
@@ -1449,6 +1681,16 @@ impl ProviderSetupView {
.soft_wrap(true)
.finish(),
);
children.push(
Text::new(
crate::ai::claude_subscription::SUBSCRIPTION_WARNING,
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().accent().into())
.soft_wrap(true)
.finish(),
);
}
ProviderSetupProviderType::Gemini => {
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
@@ -1576,7 +1818,7 @@ impl ProviderSetupView {
}
children.push(
Text::new(
"Known NPM-backed clients use npx with a pinned package version. If an ACP client needs another toolchain, choose Custom and configure the executable after installing it yourself.",
"Test downloads Claude Code and its ACP adapter when missing. Sign-in opens through Claude Code in your browser; Galaxy does not store your Claude credentials.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
@@ -1596,7 +1838,13 @@ impl ProviderSetupView {
fn render_discovery(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let (message, color) = match &self.discovery_state {
DiscoveryState::Idle | DiscoveryState::Loading => (
"Testing the connection and discovering available models...".to_string(),
if self.provider_type == ProviderSetupProviderType::Acp
&& !self.acp_setup_status.is_empty()
{
self.acp_setup_status.clone()
} else {
"Testing the connection and discovering available models...".to_string()
},
appearance.theme().active_ui_text_color().into_solid(),
),
DiscoveryState::Failed(error) => (error.clone(), appearance.theme().ui_error_color()),
@@ -2012,6 +2260,9 @@ impl ProviderSetupView {
}
fn is_configure_valid(&self) -> bool {
if self.claude_signin_abort.is_some() {
return false;
}
match self.provider_type {
ProviderSetupProviderType::OpenAI => {
!self.draft_base_url.trim().is_empty()
@@ -2021,7 +2272,16 @@ impl ProviderSetupView {
.is_some_and(|key| !key.trim().is_empty())
}
ProviderSetupProviderType::LiteLLM => !self.draft_base_url.trim().is_empty(),
ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini => self
ProviderSetupProviderType::Anthropic => {
self.draft_api_key.as_deref().is_some_and(|key| {
if crate::ai::claude_subscription::is_stored_credential(key) {
self.claude_credentials_available
} else {
!key.trim().is_empty()
}
})
}
ProviderSetupProviderType::Gemini => self
.draft_api_key
.as_deref()
.is_some_and(|key| !key.trim().is_empty()),
@@ -2202,6 +2462,9 @@ impl TypedActionView for ProviderSetupView {
match action {
ProviderSetupViewAction::SelectProvider(kind) => {
if self.provider_type != *kind {
self.cancel_claude_signin();
self.draft_api_key = None;
self.claude_credentials_available = false;
self.draft_models.clear();
self.discovery_state = DiscoveryState::Idle;
}
@@ -2244,6 +2507,8 @@ impl TypedActionView for ProviderSetupView {
| ProviderSetupProviderType::VertexAI => {
if self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled)
|| (self.provider_type == ProviderSetupProviderType::Anthropic
&& !self.is_configure_valid())
{
return;
}
@@ -2282,6 +2547,7 @@ impl TypedActionView for ProviderSetupView {
ctx.notify();
}
ProviderSetupStep::Discover => {
self.cancel_acp_setup();
self.step = ProviderSetupStep::Configure;
self.discovery_state = DiscoveryState::Idle;
self.update_next_button(ctx);
@@ -2294,6 +2560,9 @@ impl TypedActionView for ProviderSetupView {
}
},
ProviderSetupViewAction::Cancel => {
self.cancel_acp_setup();
self.cancel_claude_signin();
self.discovery_state = DiscoveryState::Idle;
ctx.emit(ProviderSetupViewEvent::Close);
}
ProviderSetupViewAction::SelectAcpAgent(agent_id) => {
@@ -2338,6 +2607,28 @@ impl TypedActionView for ProviderSetupView {
#[cfg(not(target_family = "wasm"))]
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
}
ProviderSetupViewAction::ConnectClaude => {
#[cfg(not(target_family = "wasm"))]
self.connect_claude(ctx);
}
ProviderSetupViewAction::SignOutClaude => {
if let Some(reference) = self.draft_api_key.take() {
crate::ai::claude_subscription::remove_credential(&reference, ctx);
}
self.cancel_claude_signin();
self.claude_credentials_available = false;
self.claude_signin_status = "Signed out of Claude on this device.".to_string();
self.draft_models.clear();
self.discovery_state = DiscoveryState::Idle;
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupViewAction::CancelClaudeSignIn => {
self.cancel_claude_signin();
self.claude_signin_status = "Claude sign-in cancelled.".to_string();
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupViewAction::SelectBedrockProfile(profile) => {
if self.draft_bedrock.profile != *profile {
self.draft_bedrock.profile = profile.clone();
@@ -2394,6 +2685,13 @@ fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str {
ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
ProviderSetupProviderType::Bedrock => "AWS Bedrock",
ProviderSetupProviderType::Acp => "ACP agent runtime",
ProviderSetupProviderType::Acp => "Claude Code",
}
}
impl Drop for ProviderSetupView {
fn drop(&mut self) {
self.cancel_claude_signin();
self.cancel_acp_setup();
}
}
+9 -7
View File
@@ -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())
+4 -5
View File
@@ -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);
@@ -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::<Vec<_>>()).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::<Vec<_>>()).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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>()),
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
displayed_output.splice_displayed_lines(2..2, new_displayed_rows);
let expected_displayed_rows =
+23 -14
View File
@@ -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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>());
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::<Vec<_>>(),
));
// 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::<Vec<_>>();
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::<Vec<_>>();
let dfas = RegexDFAs::new("match").unwrap();
let non_matching_lines = blockgrid.grid_handler().find_non_matching_lines_in_range(
+3 -1
View File
@@ -1689,7 +1689,9 @@ fn decode_uuid_hex(hex: &str) -> Option<Vec<u8>> {
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)?;
+1 -1
View File
@@ -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(),
+9 -67
View File
@@ -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
+8
View File
@@ -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();
@@ -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<u8>) -> Vec<u8> {
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;
+1
View File
@@ -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
@@ -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<http_client::Result<(http::HeaderName, HeaderValue)>> {
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<H>
= AnthropicExt
where
H: HttpClientExt;
type ApiKey = SubscriptionToken;
const BASE_URL: &'static str = "https://api.anthropic.com";
fn build<H>(_: &ClientBuilder<Self, Self::ApiKey, H>) -> http_client::Result<Self::Extension<H>>
where
H: HttpClientExt,
{
Ok(AnthropicExt)
}
}
pub(crate) fn anthropic_client(credential: &str) -> Result<anthropic::Client, String> {
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::<SubscriptionProvider>::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<HashMap<String, String>, 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<String, String>) {
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;
@@ -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::<Vec<_>>();
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::<SubscriptionProvider>::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<T, U>(
&self,
request: http_client::Request<T>,
) -> impl std::future::Future<
Output = http_client::Result<http_client::Response<http_client::LazyBody<U>>>,
> + rig_core::wasm_compat::WasmCompatSend
+ 'static
where
T: Into<bytes::Bytes> + rig_core::wasm_compat::WasmCompatSend,
U: From<bytes::Bytes> + rig_core::wasm_compat::WasmCompatSend + 'static,
{
self.recording.send(request)
}
fn send_multipart<U>(
&self,
request: http_client::Request<http_client::MultipartForm>,
) -> impl std::future::Future<
Output = http_client::Result<http_client::Response<http_client::LazyBody<U>>>,
> + rig_core::wasm_compat::WasmCompatSend
+ 'static
where
U: From<bytes::Bytes> + rig_core::wasm_compat::WasmCompatSend + 'static,
{
self.recording.send_multipart(request)
}
fn send_streaming<T>(
&self,
request: http_client::Request<T>,
) -> impl std::future::Future<Output = http_client::Result<http_client::StreamingResponse>>
+ rig_core::wasm_compat::WasmCompatSend
where
T: Into<bytes::Bytes> + rig_core::wasm_compat::WasmCompatSend,
{
let recorded = self
.recording
.send::<bytes::Bytes, bytes::Bytes>(request.map(Into::into));
let streaming = self.streaming.clone();
async move {
recorded.await?;
streaming
.send_streaming(http_client::Request::new(bytes::Bytes::new()))
.await
}
}
}
+2
View File
@@ -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::*;
+78 -11
View File
@@ -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<Vec<RigModelInfo>, 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<u64>,
}
#[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<dyn Fn() -> BoxFuture<'static, Result<String, String>> + Send + Sync>;
#[derive(Clone)]
pub struct AnthropicRuntime {
config: AnthropicRuntimeConfig,
descriptor: RuntimeDescriptor,
credential_resolver: Option<AnthropicCredentialResolver>,
}
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<AgentEventStream, AgentError> {
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)
}
}
}
@@ -1,5 +1,3 @@
use std::io::Write as _;
use super::*;
#[test]
@@ -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);
}
}
@@ -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];
@@ -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);
}
}
@@ -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<Point>,
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<f32> {
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(
@@ -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.
@@ -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));
}
@@ -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]);
@@ -334,7 +334,7 @@ pub async fn run_capture_loop(app: crate::App, state: CaptureLoopState) {
fn rgba_to_rgb(rgba: &[u8]) -> Vec<u8> {
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]);
+1 -1
View File
@@ -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;
-3
View File
@@ -1,6 +1,3 @@
#[cfg(unix)]
use std::io::{Read as _, Write as _};
#[cfg(unix)]
use chrono::Duration;
use chrono::Utc;