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