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

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

After

Width:  |  Height:  |  Size: 2.3 KiB

+22
View File
@@ -189,6 +189,8 @@ fn preset_version(agent_id: &str, custom_command: &str) -> Option<&'static str>
match agent_id.trim().to_ascii_lowercase().as_str() { match agent_id.trim().to_ascii_lowercase().as_str() {
"codex" => Some(CODEX_ACP_NPM_VERSION), "codex" => Some(CODEX_ACP_NPM_VERSION),
"opencode" => Some(OPENCODE_NPM_VERSION), "opencode" => Some(OPENCODE_NPM_VERSION),
#[cfg(feature = "local_fs")]
"claude-acp" => Some(super::setup::CLAUDE_ACP_VERSION),
_ => None, _ => None,
} }
} }
@@ -241,6 +243,26 @@ pub(crate) fn resolve_acp_launch(
match agent_id.trim().to_ascii_lowercase().as_str() { match agent_id.trim().to_ascii_lowercase().as_str() {
"codex" => AcpAgentPreset::Codex.resolve_launch_config(), "codex" => AcpAgentPreset::Codex.resolve_launch_config(),
"opencode" => AcpAgentPreset::OpenCode.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| { _ => resolve_known_acp_agent(agent_id).map_err(|error| {
format!( format!(
"{error} Configure a custom ACP executable if this client uses a different command." "{error} Configure a custom ACP executable if this client uses a different command."
+4
View File
@@ -8,6 +8,8 @@ mod launch;
mod permissions; mod permissions;
mod prompt; mod prompt;
mod runtime_model; mod runtime_model;
#[cfg(feature = "local_fs")]
mod setup;
mod transport; mod transport;
pub(crate) use launch::{ pub(crate) use launch::{
@@ -17,6 +19,8 @@ pub(crate) use launch::{
}; };
pub(crate) use permissions::resolve_acp_permissions; pub(crate) use permissions::resolve_acp_permissions;
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel}; pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
#[cfg(feature = "local_fs")]
pub(crate) use setup::ensure_claude_code_installed;
pub(crate) use transport::{ pub(crate) use transport::{
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionMetadata, acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionMetadata,
AcpTurnControlSlot, GalaxyMcpTarget, AcpTurnControlSlot, GalaxyMcpTarget,
+282
View File
@@ -0,0 +1,282 @@
//! Explicit Claude Code setup. Downloads happen only when the user tests a
//! connection; normal launches resolve installed components without network I/O.
use std::path::{Component, Path, PathBuf};
use std::process::Output;
use std::time::Duration;
use command::r#async::Command;
use futures::future::{select, Either};
use galaxy_acp::AcpLaunchConfig;
use tokio::sync::Mutex;
// Keep the adapter and the official native CLI installation reproducible.
// npm verifies package integrity and selects Anthropic's platform binary.
pub(super) const CLAUDE_ACP_VERSION: &str = "0.85.1";
const CLAUDE_CODE_VERSION: &str = "2.1.288";
static SETUP_LOCK: Mutex<()> = Mutex::const_new(());
fn installation_dir() -> PathBuf {
galaxy_core::paths::data_dir()
.join("acp")
.join("claude-code")
}
fn installed_command(command: &str) -> Option<PathBuf> {
AcpLaunchConfig::new(command)
.resolve_command()
.ok()
.map(|launch| launch.command)
}
fn package_binary(package_dir: &Path) -> Option<PathBuf> {
let manifest: serde_json::Value =
serde_json::from_slice(&std::fs::read(package_dir.join("package.json")).ok()?).ok()?;
let binary = manifest.get("bin")?.get("claude")?.as_str()?;
let binary = Path::new(binary);
if binary.is_absolute()
|| binary
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return None;
}
let binary = package_dir.join(binary);
binary.is_file().then_some(binary)
}
fn claude_binary() -> Option<PathBuf> {
installed_command("claude")
.or_else(|| {
let home = std::env::var_os("HOME")?;
let path = PathBuf::from(home).join(".local/bin/claude");
path.is_file().then_some(path)
})
.or_else(|| {
package_binary(&installation_dir().join("node_modules/@anthropic-ai/claude-code"))
})
}
fn adapter_script() -> PathBuf {
installation_dir().join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js")
}
fn managed_adapter_ready(directory: &Path) -> bool {
directory
.join(format!("ready-{CLAUDE_ACP_VERSION}"))
.is_file()
&& directory
.join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js")
.is_file()
}
pub(super) fn resolve_claude_code_launch() -> Result<AcpLaunchConfig, String> {
let claude = claude_binary().ok_or_else(|| {
"Claude Code is not installed. Test the Claude Code connection in Settings to download it."
.to_owned()
})?;
let launch = if let Some(adapter) = installed_command("claude-agent-acp") {
AcpLaunchConfig::new(adapter)
} else {
let script = adapter_script();
if !managed_adapter_ready(&installation_dir()) {
return Err("The Claude Code ACP adapter is missing. Test the connection in Settings to download it.".to_owned());
}
let managed_node = node_runtime::node_binary_path().map_err(|error| error.to_string())?;
let node = if managed_node.is_file() {
managed_node
} else {
installed_command("node").ok_or_else(|| "Claude Code's ACP adapter needs Node.js. Test the connection in Settings to install it.".to_owned())?
};
AcpLaunchConfig::new(node).args([script.to_string_lossy().into_owned()])
};
let mut path = launch
.command
.parent()
.into_iter()
.map(Path::to_owned)
.collect::<Vec<_>>();
if let Ok(node) = node_runtime::node_binary_path() {
if node.is_file() {
if let Some(directory) = node.parent() {
path.insert(0, directory.to_owned());
}
}
}
if let Some(existing) = std::env::var_os("PATH") {
path.extend(std::env::split_paths(&existing));
}
let path = std::env::join_paths(path).map_err(|error| error.to_string())?;
Ok(launch
// The adapter must use this unmodified binary, rather than silently
// selecting another SDK-bundled CLI. Auth stays entirely in Claude Code.
.env("CLAUDE_CODE_EXECUTABLE", claude.to_string_lossy())
.env("PATH", path.to_string_lossy()))
}
/// Prepare missing components and authenticate with Claude Code's own browser
/// flow. Never read its token store or include auth command output in logs.
pub(crate) async fn ensure_claude_code_installed(
progress: async_channel::Sender<&'static str>,
) -> Result<(), String> {
let _lock = SETUP_LOCK.lock().await;
let needs_cli = claude_binary().is_none();
let needs_adapter = installed_command("claude-agent-acp").is_none()
&& !managed_adapter_ready(&installation_dir());
if needs_cli || needs_adapter {
let _ = progress.try_send("Downloading missing Claude Code components…");
log::info!("Claude Code setup: missing_cli={needs_cli}, missing_adapter={needs_adapter}");
// Reuse Galaxy's private Node runtime; no global install or administrator
// permission is needed, even when Node/npm are absent from the user's PATH.
let client = http_client::Client::new();
match select(
Box::pin(node_runtime::install_npm(&client)),
Box::pin(async_io::Timer::after(Duration::from_secs(300))),
)
.await
{
Either::Left((result, _)) => {
result.map_err(|error| {
format!("Could not install Claude Code's Node runtime: {error}")
})?;
}
Either::Right(_) => {
return Err(
"Downloading Claude Code's Node runtime timed out. Test again to retry."
.to_owned(),
)
}
}
let node = node_runtime::node_binary_path().map_err(|error| error.to_string())?;
let npm = node_runtime::npm_binary_path().map_err(|error| error.to_string())?;
let directory = installation_dir();
async_fs::create_dir_all(&directory)
.await
.map_err(|error| error.to_string())?;
let mut command = Command::new(&node);
command
.arg(npm)
.args([
"install",
"--registry=https://registry.npmjs.org",
"--ignore-scripts=false",
"--no-audit",
"--no-fund",
"--save-exact",
])
.arg("--prefix")
.arg(&directory)
.current_dir(&directory);
if needs_cli {
command.arg(format!("@anthropic-ai/claude-code@{CLAUDE_CODE_VERSION}"));
}
if needs_adapter {
command.arg(format!(
"@agentclientprotocol/claude-agent-acp@{CLAUDE_ACP_VERSION}"
));
}
let mut paths = vec![node
.parent()
.ok_or("Node runtime has no directory")?
.to_owned()];
if let Some(path) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&path));
}
command.env(
"PATH",
std::env::join_paths(paths).map_err(|error| error.to_string())?,
);
let output = run_command(&mut command, Duration::from_secs(300)).await?;
if !output.status.success() {
// Installation runs no model request and has no Claude credentials.
let error = String::from_utf8_lossy(&output.stderr);
let detail = error.chars().take(2000).collect::<String>();
return Err(format!(
"Could not download Claude Code components: {detail}"
));
}
if needs_adapter {
if !adapter_script().is_file() {
return Err("The Claude Code adapter download completed without its executable. Test again to retry.".to_owned());
}
// A cancelled/failed npm install may leave the entrypoint behind.
// Only a completed install is eligible for future launches.
async_fs::write(
directory.join(format!("ready-{CLAUDE_ACP_VERSION}")),
b"ready",
)
.await
.map_err(|error| error.to_string())?;
}
}
let launch = resolve_claude_code_launch()?;
let claude = launch
.env
.get("CLAUDE_CODE_EXECUTABLE")
.ok_or("Claude Code executable is missing")?;
let _ = progress.try_send("Checking Claude Code sign-in…");
let status = run_command(
claude_auth_command(claude).args(["auth", "status", "--json"]),
Duration::from_secs(15),
)
.await?;
let status_json: serde_json::Value = serde_json::from_slice(&status.stdout)
.map_err(|_| "Claude Code could not report its login status. Run `claude auth login`, then test again.".to_owned())?;
if !status.status.success()
|| status_json
.get("loggedIn")
.and_then(serde_json::Value::as_bool)
!= Some(true)
{
let _ = progress.try_send("Opening Claude Code sign-in in your browser…");
let login = run_command(
claude_auth_command(claude).args(["auth", "login", "--claudeai"]),
Duration::from_secs(300),
)
.await?;
if !login.status.success() {
return Err("Claude Code sign-in did not complete. Complete sign-in through Claude Code and test again.".to_owned());
}
}
let _ = progress.try_send("Discovering Claude Code models…");
log::info!("Claude Code setup: components ready; starting ACP discovery");
Ok(())
}
fn claude_auth_command(claude: &str) -> Command {
let mut command = Command::new(claude);
// Match the ACP launch's sanitized environment: an inherited API key or
// provider redirect must not make setup report a subscription as signed in.
for variable in [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CONFIG_DIR",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
] {
command.env_remove(variable);
}
command
}
async fn run_command(command: &mut Command, timeout: Duration) -> Result<Output, String> {
command.kill_on_drop(true);
match select(
Box::pin(command.output()),
Box::pin(async_io::Timer::after(timeout)),
)
.await
{
Either::Left((result, _)) => result.map_err(|error| error.to_string()),
Either::Right(_) => {
Err("Claude Code setup timed out. Test the connection again to retry.".to_owned())
}
}
}
#[cfg(test)]
#[path = "setup_tests.rs"]
mod tests;
+52
View File
@@ -0,0 +1,52 @@
use super::*;
#[test]
fn incomplete_adapter_installation_is_not_launchable() {
let directory = tempfile::tempdir().unwrap();
let script = directory
.path()
.join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(&script, b"entrypoint").unwrap();
assert!(!managed_adapter_ready(directory.path()));
std::fs::write(
directory.path().join(format!("ready-{CLAUDE_ACP_VERSION}")),
b"ready",
)
.unwrap();
assert!(managed_adapter_ready(directory.path()));
std::fs::remove_file(script).unwrap();
assert!(!managed_adapter_ready(directory.path()));
}
#[test]
fn official_package_binary_is_resolved_from_its_manifest() {
let directory = tempfile::tempdir().unwrap();
std::fs::create_dir(directory.path().join("bin")).unwrap();
std::fs::write(directory.path().join("bin/claude.exe"), b"binary").unwrap();
std::fs::write(
directory.path().join("package.json"),
r#"{"bin":{"claude":"bin/claude.exe"}}"#,
)
.unwrap();
assert_eq!(
package_binary(directory.path()),
Some(directory.path().join("bin/claude.exe"))
);
}
#[test]
fn package_binary_cannot_escape_the_installation() {
let directory = tempfile::tempdir().unwrap();
let package = directory.path().join("package");
std::fs::create_dir(&package).unwrap();
std::fs::write(directory.path().join("claude"), b"outside").unwrap();
for binary in ["../claude", "/bin/sh", "missing"] {
std::fs::write(
package.join("package.json"),
serde_json::json!({"bin": {"claude": binary}}).to_string(),
)
.unwrap();
assert_eq!(package_binary(&package), None);
}
}
@@ -333,17 +333,16 @@ impl SearchCodebaseExecutor {
)); ));
}; };
let search_dir; let (is_cross_repo, search_dir) = if FeatureFlag::CrossRepoContext.is_enabled() {
let is_cross_repo; (
if FeatureFlag::CrossRepoContext.is_enabled() { codebase_path
is_cross_repo = codebase_path .as_ref()
.as_ref() .is_some_and(|path| !current_working_directory.starts_with(path)),
.is_some_and(|path| !current_working_directory.starts_with(path)); codebase_path.unwrap_or(current_working_directory),
search_dir = codebase_path.unwrap_or(current_working_directory); )
} else { } else {
is_cross_repo = false; (false, current_working_directory)
search_dir = current_working_directory; };
}
let server_output_id = get_server_output_id(conversation_id, ctx); let server_output_id = get_server_output_id(conversation_id, ctx);
send_telemetry_from_ctx!( send_telemetry_from_ctx!(
TelemetryEvent::SearchCodebaseRequested { TelemetryEvent::SearchCodebaseRequested {
+9 -26
View File
@@ -79,7 +79,7 @@ use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
use crate::ai::blocklist::secret_redaction::{redact_secrets_in_element, SecretRedactionState}; use crate::ai::blocklist::secret_redaction::{redact_secrets_in_element, SecretRedactionState};
use crate::ai::blocklist::view_util::error_color; use crate::ai::blocklist::view_util::error_color;
use crate::ai::blocklist::{BlocklistAIActionModel, ShellCommandExecutor, TextLocation}; 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::ai::AIRequestUsageModel;
use crate::code::editor::view::CodeEditorView; use crate::code::editor::view::CodeEditorView;
use crate::code::editor_management::CodeSource; 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 // 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. // `secondary_element` is consumed so the container can reserve room for it.
let has_secondary_element = secondary_element.is_some(); 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 appearance = Appearance::as_ref(app);
let should_indent_tip_for_warp_glyph = matches!( let should_indent_tip_for_logo = matches!(
warping_indicator_text, warping_indicator_text,
MaybeShimmeringText::Shimmering { .. } 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 = render_output_status_text(warping_indicator_text, appearance, app);
let text_content = { 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 { if let Some(non_shimmering) = non_shimmering_text {
let additional = render_output_status_text( let additional = render_output_status_text(
@@ -660,25 +657,11 @@ pub fn render_warping_indicator_base(
let mut text_col = Flex::column(); let mut text_col = Flex::column();
if let Some(sub_element) = secondary_element { if let Some(sub_element) = secondary_element {
// Our warping indicator text prepends the Warp glyph (and a space) to the label. // Align the tip with the text after the Galaxy logo and its spacing.
// If we render the tip directly underneath, it will align to the glyph instead of let sub_element = if should_indent_tip_for_logo {
// the start of the actual warping text.
let sub_element = if should_indent_tip_for_warp_glyph {
let font_size = appearance.monospace_font_size() - 3.; let font_size = appearance.monospace_font_size() - 3.;
let glyph_indent = Text::new_inline( Container::new(Shrinkable::new(1., sub_element).finish())
format!("{WARP_GLYPH} "), .with_margin_left(loading_logo_size(font_size) + LOADING_LOGO_GAP)
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())
.finish() .finish()
} else { } else {
Shrinkable::new(1., sub_element).finish() Shrinkable::new(1., sub_element).finish()
+443
View File
@@ -0,0 +1,443 @@
//! Pi-compatible Claude subscription OAuth. Secrets are never logged or serialized into settings.
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct Credentials {
pub(crate) access_token: String,
refresh_token: String,
expires_at: u64,
}
#[cfg(not(target_family = "wasm"))]
pub(crate) use desktop::*;
#[cfg(not(target_family = "wasm"))]
mod desktop {
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use futures::channel::oneshot;
use futures::StreamExt;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use galaxyui_extras::secure_storage::AppContextExt as _;
use instant::Instant;
use rand::RngCore;
use settings::Setting;
use sha2::{Digest, Sha256};
use url::Url;
use super::Credentials;
use crate::ai::claude_subscription::{is_stored_credential, OAUTH_REFERENCE_PREFIX};
use crate::settings::AISettings;
// Pi's browser flow, checked 2026-10-04:
// https://github.com/earendil-works/pi/blob/main/packages/ai/src/auth/oauth/anthropic.ts
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize";
const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
const REDIRECT_URI: &str = "http://localhost:53692/callback";
const SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(5 * 60);
type Reply = oneshot::Sender<Result<String, String>>;
enum Command {
Resolve(String, Reply),
Invalidate(String),
DiscardDraft(String),
}
static SENDER: OnceLock<Mutex<Option<async_channel::Sender<Command>>>> = OnceLock::new();
fn send(command: Command) -> Result<(), String> {
SENDER
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap()
.as_ref()
.ok_or("Claude authorization service is unavailable.")?
.try_send(command)
.map_err(|_| "Claude authorization service is busy or unavailable.".to_string())
}
pub(crate) fn invalidate(reference: &str) {
let _ = send(Command::Invalidate(reference.to_string()));
}
pub(crate) fn discard_draft(reference: &str) {
if reference.starts_with(OAUTH_REFERENCE_PREFIX) {
let _ = send(Command::DiscardDraft(reference.to_string()));
}
}
pub(crate) async fn load_credential(reference: String) -> Result<String, String> {
if !is_stored_credential(&reference) {
return Ok(reference);
}
let (tx, rx) = oneshot::channel();
send(Command::Resolve(reference, tx))?;
let result = futures::future::select(
Box::pin(rx),
Box::pin(async_io::Timer::after(Duration::from_secs(35))),
)
.await;
match result {
futures::future::Either::Left((result, _)) => {
result.map_err(|_| "Claude authorization service stopped.".to_string())?
}
futures::future::Either::Right(_) => {
Err("Claude token refresh timed out. Try again or sign in again.".to_string())
}
}
}
struct Refresh {
id: uuid::Uuid,
replies: Vec<Reply>,
}
/// Marshals secure-storage access to the UI thread and coalesces concurrent refreshes.
pub(crate) struct ClaudeOAuthModel {
refreshing: HashMap<String, Refresh>,
}
impl Entity for ClaudeOAuthModel {
type Event = ();
}
impl SingletonEntity for ClaudeOAuthModel {}
impl ClaudeOAuthModel {
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
let (tx, rx) = async_channel::bounded(64);
*SENDER.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(tx);
ctx.spawn_stream_local(
rx,
|me, command, ctx| match command {
Command::Resolve(reference, reply) => me.resolve(reference, reply, ctx),
Command::Invalidate(reference) => me.invalidate(&reference),
Command::DiscardDraft(reference) => {
let saved = AISettings::as_ref(ctx)
.openai_providers
.value()
.iter()
.any(|provider| provider.api_key.as_deref() == Some(&reference));
if !saved {
me.invalidate(&reference);
let _ = ctx.secure_storage().remove_value(&reference);
}
}
},
|_, _| {},
);
Self {
refreshing: HashMap::new(),
}
}
fn invalidate(&mut self, reference: &str) {
if let Some(refresh) = self.refreshing.remove(reference) {
for reply in refresh.replies {
let _ = reply.send(Err("Claude subscription was signed out.".to_string()));
}
}
}
fn resolve(&mut self, reference: String, reply: Reply, ctx: &mut ModelContext<Self>) {
if reply.is_canceled() {
return;
}
if let Some(refresh) = self.refreshing.get_mut(&reference) {
refresh.replies.push(reply);
return;
}
let value = match ctx.secure_storage().read_value(&reference) {
Ok(value) => value,
Err(_) => {
let _ = reply.send(Err(
"Claude subscription is signed out. Sign in again.".to_string()
));
return;
}
};
if !reference.starts_with(OAUTH_REFERENCE_PREFIX) {
let _ = reply.send(Ok(value));
return;
}
let credentials: Credentials = match serde_json::from_str(&value) {
Ok(credentials) => credentials,
Err(_) => {
let _ = reply.send(Err(
"Claude credentials are invalid. Sign in again.".to_string()
));
return;
}
};
if credentials.expires_at > now() {
let _ = reply.send(Ok(credentials.access_token));
return;
}
let id = uuid::Uuid::new_v4();
self.refreshing.insert(
reference.clone(),
Refresh {
id,
replies: vec![reply],
},
);
ctx.spawn(
async move {
token_request(serde_json::json!({
"grant_type": "refresh_token", "client_id": CLIENT_ID,
"refresh_token": credentials.refresh_token,
}))
.await
},
move |me, result, ctx| {
if me
.refreshing
.get(&reference)
.is_none_or(|refresh| refresh.id != id)
{
return;
}
let refresh = me.refreshing.remove(&reference).unwrap();
let result = result.and_then(|credentials| {
// Signing out deletes the store entry before this callback can commit.
if ctx.secure_storage().read_value(&reference).is_err() {
return Err("Claude subscription was signed out.".to_string());
}
save_credentials(&reference, &credentials, ctx)?;
Ok(credentials.access_token)
});
for reply in refresh.replies {
let _ = reply.send(result.clone());
}
},
);
}
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub(crate) fn save_credentials(
reference: &str,
credentials: &Credentials,
ctx: &galaxyui::AppContext,
) -> Result<(), String> {
let json = serde_json::to_string(credentials)
.map_err(|_| "Could not encode Claude credentials.".to_string())?;
ctx.secure_storage()
.write_value_with_owner_only_fallback(reference, &json)
.map_err(|_| {
"Could not securely save Claude credentials. Sign-in was not saved.".to_string()
})
}
#[derive(serde::Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: String,
expires_in: u64,
}
async fn token_request(body: serde_json::Value) -> Result<Credentials, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| "Could not create Claude authorization client.".to_string())?;
let response = client
.post(TOKEN_URL)
.header("Accept", "application/json")
.json(&body)
.send()
.await
.map_err(|_| {
"Claude authorization request failed. Try signing in again.".to_string()
})?;
if !response.status().is_success() {
// Never expose response bodies: providers may echo authorization codes or tokens.
return Err(format!(
"Claude authorization failed (HTTP {}). Sign in again.",
response.status().as_u16()
));
}
if response
.content_length()
.is_some_and(|length| length > 64 * 1024)
{
return Err("Claude authorization returned an oversized response.".to_string());
}
let mut bytes = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| "Claude authorization response failed.".to_string())?;
if bytes.len().saturating_add(chunk.len()) > 64 * 1024 {
return Err("Claude authorization returned an oversized response.".to_string());
}
bytes.extend_from_slice(&chunk);
}
let tokens: TokenResponse = serde_json::from_slice(&bytes)
.map_err(|_| "Claude authorization returned invalid credentials.".to_string())?;
credentials_from_response(tokens)
}
fn credentials_from_response(tokens: TokenResponse) -> Result<Credentials, String> {
if !tokens.access_token.contains("sk-ant-oat")
|| tokens.refresh_token.is_empty()
|| tokens.expires_in == 0
{
return Err("Claude authorization returned incomplete credentials.".to_string());
}
Ok(Credentials {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: now()
.saturating_add(tokens.expires_in)
.saturating_sub(5 * 60),
})
}
pub(crate) struct BrowserLogin {
pub(crate) url: Url,
verifier: String,
callback: oneshot::Receiver<Result<String, String>>,
cancelled: Arc<AtomicBool>,
}
impl Drop for BrowserLogin {
fn drop(&mut self) {
self.cancelled.store(true, Ordering::Relaxed);
}
}
impl BrowserLogin {
pub(crate) fn start() -> Result<Self, String> {
let listener = TcpListener::bind(("127.0.0.1", 53692))
.map_err(|_| "Claude sign-in needs localhost port 53692. Close another sign-in attempt and try again.".to_string())?;
listener
.set_nonblocking(true)
.map_err(|_| "Could not configure Claude sign-in callback.".to_string())?;
let mut bytes = [0; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let verifier = URL_SAFE_NO_PAD.encode(bytes);
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let url = authorize_url(&verifier, &challenge)?;
let cancelled = Arc::new(AtomicBool::new(false));
let stop = cancelled.clone();
let state = verifier.clone();
let (tx, callback) = oneshot::channel();
std::thread::Builder::new()
.name("claude-oauth-callback".to_string())
.spawn(move || {
let deadline = Instant::now() + SIGN_IN_TIMEOUT;
while !stop.load(Ordering::Relaxed) && Instant::now() < deadline {
match listener.accept() {
Ok((stream, _)) => {
if let Some(code) = read_callback(stream, &state) {
let _ = tx.send(Ok(code));
return;
}
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(50))
}
Err(_) => {
let _ = tx.send(Err("Claude sign-in callback failed.".to_string()));
return;
}
}
}
let _ = tx.send(Err(
"Claude browser sign-in expired or was cancelled.".to_string()
));
})
.map_err(|_| "Could not start Claude sign-in callback.".to_string())?;
Ok(Self {
url,
verifier,
callback,
cancelled,
})
}
pub(crate) async fn finish(mut self) -> Result<Credentials, String> {
let code = (&mut self.callback)
.await
.map_err(|_| "Claude sign-in callback stopped.".to_string())??;
token_request(serde_json::json!({
"grant_type": "authorization_code", "client_id": CLIENT_ID, "code": code,
"state": self.verifier, "redirect_uri": REDIRECT_URI, "code_verifier": self.verifier,
})).await
}
}
pub(super) fn authorize_url(verifier: &str, challenge: &str) -> Result<Url, String> {
let mut url = Url::parse(AUTHORIZE_URL)
.map_err(|_| "Invalid Claude authorization URL.".to_string())?;
url.query_pairs_mut()
.append_pair("code", "true")
.append_pair("client_id", CLIENT_ID)
.append_pair("response_type", "code")
.append_pair("redirect_uri", REDIRECT_URI)
.append_pair("scope", SCOPES)
.append_pair("code_challenge", challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("state", verifier);
Ok(url)
}
pub(super) fn callback_code(request: &str, state: &str) -> Option<String> {
let mut parts = request.split_whitespace();
if parts.next()? != "GET" {
return None;
}
let path = parts.next()?;
if !path.starts_with('/') || path.starts_with("//") {
return None;
}
let url = Url::parse(&format!("http://localhost:53692{path}")).ok()?;
if url.path() != "/callback" {
return None;
}
let params: HashMap<_, _> = url.query_pairs().into_owned().collect();
if params.get("state")?.as_str() != state {
return None;
}
params.get("code").filter(|code| !code.is_empty()).cloned()
}
fn read_callback(mut stream: TcpStream, state: &str) -> Option<String> {
stream.set_read_timeout(Some(Duration::from_secs(1))).ok()?;
stream
.set_write_timeout(Some(Duration::from_secs(1)))
.ok()?;
let mut request = String::new();
// Bound the request line independently of whether a newline is supplied.
std::io::Read::take(BufReader::new(&mut stream), 8192)
.read_line(&mut request)
.ok()?;
let code = callback_code(&request, state);
let (status, message) = if code.is_some() {
(
"200 OK",
"Signed in to Claude. You can close this tab and return to Galaxy.",
)
} else {
("400 Bad Request", "This callback did not match the active Galaxy sign-in. Return to Galaxy and try again.")
};
let body = format!("<!doctype html><title>Galaxy — Claude sign-in</title><p>{message}</p>");
let response = format!("HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = stream.write_all(response.as_bytes());
code
}
}
#[cfg(all(test, not(target_family = "wasm")))]
#[path = "claude_oauth_tests.rs"]
mod tests;
+47
View File
@@ -0,0 +1,47 @@
use super::desktop::{authorize_url, callback_code};
#[test]
fn browser_login_matches_pi_pkce_parameters() {
let url = authorize_url("test-verifier", "test-challenge").unwrap();
assert_eq!(url.origin().ascii_serialization(), "https://claude.ai");
let params: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(params["redirect_uri"], "http://localhost:53692/callback");
assert_eq!(params["client_id"], "9d1c250a-e61b-44d9-88ed-5944d1962f5e");
assert_eq!(params["state"], "test-verifier");
assert_eq!(params["code_challenge"], "test-challenge");
assert_eq!(params["code_challenge_method"], "S256");
assert_eq!(params["response_type"], "code");
assert_eq!(params["code"], "true");
assert!(params["scope"].contains("user:inference"));
}
#[test]
fn callback_requires_matching_state_and_expected_path() {
assert_eq!(
callback_code("GET /callback?code=abc&state=expected HTTP/1.1", "expected"),
Some("abc".to_string())
);
for request in [
"GET /callback?code=abc&state=wrong HTTP/1.1",
"GET /callback?code=abc HTTP/1.1",
"GET /callback?state=expected HTTP/1.1",
"GET /favicon.ico?code=abc&state=expected HTTP/1.1",
"POST /callback?code=abc&state=expected HTTP/1.1",
"GET //evil.example/callback?code=abc&state=expected HTTP/1.1",
] {
assert_eq!(callback_code(request, "expected"), None);
}
}
#[test]
fn persisted_credentials_do_not_count_as_api_keys() {
assert!(crate::ai::claude_subscription::is_stored_credential(
"galaxy-claude-oauth:test"
));
assert!(crate::ai::claude_subscription::is_subscription_credential(
"galaxy-claude-oauth:test"
));
assert!(!crate::ai::claude_subscription::is_stored_credential(
"sk-ant-api-test"
));
}
+52
View File
@@ -0,0 +1,52 @@
//! User-supplied subscription tokens stay in Galaxy's secure credential store.
use galaxyui::AppContext;
use galaxyui_extras::secure_storage::AppContextExt as _;
use crate::settings::{OpenAIProviderConfig, OpenAIProviderKind};
const TOKEN_REFERENCE_PREFIX: &str = "galaxy-claude-token:";
pub(crate) const OAUTH_REFERENCE_PREFIX: &str = "galaxy-claude-oauth:";
pub(crate) const SUBSCRIPTION_WARNING: &str = "Unsupported Claude subscription integration. Anthropic does not permit third-party applications to use Claude subscription tokens. This may stop working without notice or lead to account restrictions. Galaxy retains its own tools, permissions, discovery, and orchestration.";
pub(crate) fn is_subscription_credential(credential: &str) -> bool {
credential.trim().contains("sk-ant-oat")
|| credential.starts_with(TOKEN_REFERENCE_PREFIX)
|| is_stored_credential(credential)
}
pub(crate) fn is_stored_credential(credential: &str) -> bool {
credential.starts_with(TOKEN_REFERENCE_PREFIX) || credential.starts_with(OAUTH_REFERENCE_PREFIX)
}
pub(crate) fn store_credential(
provider: &mut OpenAIProviderConfig,
ctx: &AppContext,
) -> Result<Option<String>, String> {
if provider.kind != OpenAIProviderKind::Anthropic {
return Ok(None);
}
let Some(token) = provider
.api_key
.as_deref()
.filter(|token| token.trim().contains("sk-ant-oat"))
else {
return Ok(None);
};
let reference = format!("{TOKEN_REFERENCE_PREFIX}{}", uuid::Uuid::new_v4());
ctx.secure_storage()
.write_value_with_owner_only_fallback(&reference, token.trim())
.map_err(|_| {
"Could not securely save the Claude subscription token. The connection was not saved."
.to_string()
})?;
provider.api_key = Some(reference.clone());
Ok(Some(reference))
}
pub(crate) fn remove_credential(reference: &str, ctx: &AppContext) {
if is_stored_credential(reference) {
#[cfg(not(target_family = "wasm"))]
super::claude_oauth::invalidate(reference);
let _ = ctx.secure_storage().remove_value(reference);
}
}
+3 -2
View File
@@ -1828,9 +1828,10 @@ impl LLMPreferences {
.as_deref() .as_deref()
.filter(|key| !key.trim().is_empty()) .filter(|key| !key.trim().is_empty())
.ok_or_else(|| { .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 => { OpenAIProviderKind::Gemini => {
let api_key = provider let api_key = provider
+3 -1
View File
@@ -1,4 +1,6 @@
//! Loading animation components for AI features. //! Loading animation components for AI features.
mod shimmering_warp_loading_text; 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 galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::shimmering_text::{ use galaxyui::elements::shimmering_text::{
@@ -7,10 +7,13 @@ use galaxyui::elements::shimmering_text::{
use galaxyui::elements::Element; use galaxyui::elements::Element;
use galaxyui::{AppContext, SingletonEntity}; use galaxyui::{AppContext, SingletonEntity};
/// Warp icon glyph character pub const LOADING_LOGO_GAP: f32 = 8.;
const WARP_GLYPH: &str = "\u{E500}";
/// 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( pub fn shimmering_warp_loading_text(
text: impl Into<String>, text: impl Into<String>,
font_size: f32, font_size: f32,
@@ -27,9 +30,8 @@ pub fn shimmering_warp_loading_text(
// Hardcoded shimmer config for consistent animation // Hardcoded shimmer config for consistent animation
let config = ShimmerConfig::default(); let config = ShimmerConfig::default();
// Create a single shimmering element with glyph and text
ShimmeringTextElement::new( ShimmeringTextElement::new(
format!("{} {}", WARP_GLYPH, text.into()), text.into(),
appearance.ui_font_family(), appearance.ui_font_family(),
font_size, font_size,
base_color, base_color,
@@ -37,5 +39,10 @@ pub fn shimmering_warp_loading_text(
config, config,
shimmer_handle, shimmer_handle,
) )
.with_leading_icon(
"bundled/svg/galaxy-mark.svg",
loading_logo_size(font_size),
LOADING_LOGO_GAP,
)
.finish() .finish()
} }
+4
View File
@@ -24,6 +24,8 @@ pub(crate) mod block_context;
pub(crate) mod blocklist; pub(crate) mod blocklist;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub(crate) mod chatgpt_auth; pub(crate) mod chatgpt_auth;
pub(crate) mod claude_oauth;
pub(crate) mod claude_subscription;
#[cfg(any(feature = "local_fs", not(target_family = "wasm")))] #[cfg(any(feature = "local_fs", not(target_family = "wasm")))]
pub(crate) mod codebase_auto_indexing; pub(crate) mod codebase_auto_indexing;
pub mod control_code_parser; pub mod control_code_parser;
@@ -87,6 +89,8 @@ pub fn init(app: &mut AppContext) {
app.add_singleton_model(acp::AcpRuntimeModel::new); app.add_singleton_model(acp::AcpRuntimeModel::new);
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
app.add_singleton_model(|_| chatgpt_auth::ChatGPTAuthModel::new()); 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::keyboard_navigable_buttons::init(app);
blocklist::block::number_shortcut_buttons::init(app); blocklist::block::number_shortcut_buttons::init(app);
blocklist::toggleable_items::init(app); blocklist::toggleable_items::init(app);
+11 -3
View File
@@ -318,11 +318,19 @@ pub(crate) async fn provider_runtime_for_request(
}, },
)), )),
OpenAIProviderKind::Anthropic => { OpenAIProviderKind::Anthropic => {
Arc::new(AnthropicRuntime::new(AnthropicRuntimeConfig { let credential = config.api_key.unwrap_or_default();
api_key: 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, model,
max_output_tokens: config.max_output_tokens.map(u64::from), 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 { OpenAIProviderKind::Gemini => Arc::new(GeminiRuntime::new(GeminiRuntimeConfig {
api_key: config.api_key.unwrap_or_default(), api_key: config.api_key.unwrap_or_default(),
+7 -13
View File
@@ -144,19 +144,13 @@ impl FeatureItem {
feature: Tip, feature: Tip,
ctx: &mut AppContext, ctx: &mut AppContext,
) -> Self { ) -> Self {
let editable_binding_name; let (editable_binding_name, shortcut) = match feature {
let shortcut; Tip::Hint(_) => (None, None),
Tip::Action(tip) => (
match feature { Some(tip.editable_binding_name()),
Tip::Hint(_) => { tip.keyboard_shortcut(ctx),
editable_binding_name = None; ),
shortcut = None; };
}
Tip::Action(tip) => {
editable_binding_name = Some(tip.editable_binding_name());
shortcut = tip.keyboard_shortcut(ctx);
}
}
Self { Self {
title, title,
+3 -3
View File
@@ -1048,7 +1048,7 @@ impl std::fmt::Debug for OpenAIProviderConfig {
impl settings_value::SettingsValue for OpenAIProviderConfig {} impl settings_value::SettingsValue for OpenAIProviderConfig {}
fn default_acp_agent_id() -> String { fn default_acp_agent_id() -> String {
"codex".to_string() "claude-acp".to_string()
} }
fn default_remote_logging_endpoint() -> String { fn default_remote_logging_endpoint() -> String {
@@ -1512,7 +1512,7 @@ define_settings_group!(AISettings, settings: [
// Stable identifier for the selected ACP agent preset. // Stable identifier for the selected ACP agent preset.
acp_agent_id: AcpAgentId { acp_agent_id: AcpAgentId {
type: String, type: String,
default: "codex".to_string(), default: "claude-acp".to_string(),
supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()), supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()),
sync_to_cloud: SyncToCloud::Never, sync_to_cloud: SyncToCloud::Never,
private: false, private: false,
@@ -2422,7 +2422,7 @@ impl AISettings {
} }
let agent_id = self.acp_agent_id.value().trim(); let agent_id = self.acp_agent_id.value().trim();
let agent_id = if agent_id.is_empty() { let agent_id = if agent_id.is_empty() {
"codex" "claude-acp"
} else { } else {
agent_id agent_id
}; };
+213 -33
View File
@@ -163,6 +163,7 @@ pub enum AISubpage {
ProviderLiteLLM, ProviderLiteLLM,
/// ChatGPT subscription provider settings. /// ChatGPT subscription provider settings.
ProviderChatGPTSubscription, ProviderChatGPTSubscription,
ProviderAnthropic,
/// AWS Bedrock provider settings. /// AWS Bedrock provider settings.
ProviderBedrock, ProviderBedrock,
/// ACP provider settings. /// ACP provider settings.
@@ -185,6 +186,7 @@ impl AISubpage {
SettingsSection::ProviderOpenAI => Some(Self::ProviderOpenAI), SettingsSection::ProviderOpenAI => Some(Self::ProviderOpenAI),
SettingsSection::ProviderLiteLLM => Some(Self::ProviderLiteLLM), SettingsSection::ProviderLiteLLM => Some(Self::ProviderLiteLLM),
SettingsSection::ProviderChatGPTSubscription => Some(Self::ProviderChatGPTSubscription), SettingsSection::ProviderChatGPTSubscription => Some(Self::ProviderChatGPTSubscription),
SettingsSection::ProviderAnthropic => Some(Self::ProviderAnthropic),
SettingsSection::ProviderBedrock => Some(Self::ProviderBedrock), SettingsSection::ProviderBedrock => Some(Self::ProviderBedrock),
SettingsSection::ProviderACP => Some(Self::ProviderACP), SettingsSection::ProviderACP => Some(Self::ProviderACP),
// AgentMCPServers renders the standalone MCPServers page, not an AI subpage. // AgentMCPServers renders the standalone MCPServers page, not an AI subpage.
@@ -676,6 +678,83 @@ impl AISettingsPageView {
&mut self, &mut self,
draft: &AcpProviderDraft, draft: &AcpProviderDraft,
ctx: &mut ViewContext<Self>, 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( let config = match crate::ai::acp::AcpRuntimeModel::discovery_config_for_values(
&draft.agent_id, &draft.agent_id,
@@ -691,7 +770,7 @@ impl AISettingsPageView {
return; 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"))] #[cfg(not(target_family = "wasm"))]
@@ -699,6 +778,7 @@ impl AISettingsPageView {
&mut self, &mut self,
config: galaxy_acp::AcpManagerConfig, config: galaxy_acp::AcpManagerConfig,
agent_id: String, agent_id: String,
generation: u64,
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) { ) {
let provider_setup_body = self.provider_setup_body.clone(); let provider_setup_body = self.provider_setup_body.clone();
@@ -724,6 +804,11 @@ impl AISettingsPageView {
let _ = ctx.spawn( let _ = ctx.spawn(
async move { manager.discover_config_options(cwd, Vec::new()).await }, async move { manager.discover_config_options(cwd, Vec::new()).await },
move |_me, result, ctx| { move |_me, result, ctx| {
if !provider_setup_body
.read(ctx, |body, _| body.acp_discovery_is_current(generation))
{
return;
}
match result { match result {
Ok(options) => { Ok(options) => {
let option_count = options.len(); let option_count = options.len();
@@ -1710,19 +1795,64 @@ impl AISettingsPageView {
mut provider: OpenAIProviderConfig, mut provider: OpenAIProviderConfig,
ctx: &mut ViewContext<Self>, 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(); let mut providers = settings.openai_providers.value().clone();
match editing_index { match editing_index {
Some(index) => { Some(index) => {
if let Some(existing) = providers.get_mut(index) { let Some(existing) = providers.get_mut(index) else {
provider.enabled = existing.enabled; return false;
*existing = provider.clone(); };
} provider.enabled = existing.enabled;
*existing = provider.clone();
} }
None => providers.push(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); self.clear_inline_provider_setup(ctx);
} }
@@ -1929,6 +2059,12 @@ impl AISettingsPageView {
ProviderSetupProviderType::ChatGPTSubscription, ProviderSetupProviderType::ChatGPTSubscription,
))); )));
} }
Some(AISubpage::ProviderAnthropic) => {
widgets.push(Box::new(ProviderSettingsWidget::new(
ctx,
ProviderSetupProviderType::Anthropic,
)));
}
Some(AISubpage::ProviderBedrock) => { Some(AISubpage::ProviderBedrock) => {
widgets.push(Box::new(ProviderSettingsWidget::new( widgets.push(Box::new(ProviderSettingsWidget::new(
ctx, ctx,
@@ -3297,8 +3433,22 @@ impl TypedActionView for AISettingsPageView {
AISettings::handle(ctx).update(ctx, |settings, ctx| { AISettings::handle(ctx).update(ctx, |settings, ctx| {
let mut providers = settings.openai_providers.value().clone(); let mut providers = settings.openai_providers.value().clone();
if *provider_index < providers.len() { if *provider_index < providers.len() {
providers.remove(*provider_index); let removed = providers.remove(*provider_index);
report_if_error!(settings.openai_providers.set_value(providers, ctx)); 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); self.rebuild_active_subpage(ctx);
@@ -3337,7 +3487,9 @@ impl TypedActionView for AISettingsPageView {
let mut providers = settings.acp_providers.value().clone(); let mut providers = settings.acp_providers.value().clone();
if providers.is_empty() && provider_id == "legacy" { if providers.is_empty() && provider_id == "legacy" {
report_if_error!(settings.acp_enabled.set_value(false, ctx)); 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_command.set_value(String::new(), ctx));
report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx)); report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx));
report_if_error!(settings report_if_error!(settings
@@ -3366,7 +3518,9 @@ impl TypedActionView for AISettingsPageView {
.set_value(first_provider.name.clone(), ctx)); .set_value(first_provider.name.clone(), ctx));
} else { } else {
report_if_error!(settings.acp_enabled.set_value(false, ctx)); 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_command.set_value(String::new(), ctx));
report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx)); report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx));
report_if_error!(settings report_if_error!(settings
@@ -6372,7 +6526,7 @@ impl SettingsWidget for ModelsOverviewWidget {
type View = AISettingsPageView; type View = AISettingsPageView;
fn search_terms(&self) -> &str { 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( fn render(
@@ -6390,6 +6544,12 @@ impl SettingsWidget for ModelsOverviewWidget {
ModelProviderSection::for_provider(provider) == ModelProviderSection::OpenAI ModelProviderSection::for_provider(provider) == ModelProviderSection::OpenAI
}) })
.count(); .count();
let anthropic_count = settings
.openai_providers
.value()
.iter()
.filter(|provider| provider.kind == OpenAIProviderKind::Anthropic)
.count();
let litellm_count = settings let litellm_count = settings
.openai_providers .openai_providers
.value() .value()
@@ -6420,13 +6580,13 @@ impl SettingsWidget for ModelsOverviewWidget {
.with_spacing(8.) .with_spacing(8.)
.with_child(build_sub_header(appearance, "Models", None).finish()) .with_child(build_sub_header(appearance, "Models", None).finish())
.with_child(render_ai_setting_description( .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, true,
app, app,
)) ))
.with_child(render_ai_setting_description( .with_child(render_ai_setting_description(
format!( 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, true,
app, app,
@@ -6437,6 +6597,7 @@ impl SettingsWidget for ModelsOverviewWidget {
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ModelProviderSection { enum ModelProviderSection {
Anthropic,
OpenAI, OpenAI,
LiteLLM, LiteLLM,
ChatGPTSubscription, ChatGPTSubscription,
@@ -6445,8 +6606,8 @@ enum ModelProviderSection {
impl ModelProviderSection { impl ModelProviderSection {
fn for_provider(provider: &OpenAIProviderConfig) -> Self { fn for_provider(provider: &OpenAIProviderConfig) -> Self {
match provider.kind { match provider.kind {
OpenAIProviderKind::Anthropic => Self::Anthropic,
OpenAIProviderKind::OpenAI OpenAIProviderKind::OpenAI
| OpenAIProviderKind::Anthropic
| OpenAIProviderKind::Gemini | OpenAIProviderKind::Gemini
| OpenAIProviderKind::VertexAI => Self::OpenAI, | OpenAIProviderKind::VertexAI => Self::OpenAI,
OpenAIProviderKind::LiteLLM => Self::LiteLLM, OpenAIProviderKind::LiteLLM => Self::LiteLLM,
@@ -6472,6 +6633,7 @@ struct ProviderSettingsWidget {
bedrock_enabled_toggle: SwitchStateHandle, bedrock_enabled_toggle: SwitchStateHandle,
bedrock_model_switches: RefCell<HashMap<String, SwitchStateHandle>>, bedrock_model_switches: RefCell<HashMap<String, SwitchStateHandle>>,
bedrock_model_table_state: TableStateHandle, bedrock_model_table_state: TableStateHandle,
add_anthropic_provider_button: ViewHandle<ActionButton>,
add_openai_provider_button: ViewHandle<ActionButton>, add_openai_provider_button: ViewHandle<ActionButton>,
add_litellm_provider_button: ViewHandle<ActionButton>, add_litellm_provider_button: ViewHandle<ActionButton>,
add_chatgpt_provider_button: ViewHandle<ActionButton>, add_chatgpt_provider_button: ViewHandle<ActionButton>,
@@ -6529,6 +6691,15 @@ impl ProviderSettingsWidget {
} }
}) })
.collect(); .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(|_| { let add_openai_provider_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Add provider", SecondaryTheme) ActionButton::new("Add provider", SecondaryTheme)
.with_icon(Icon::Plus) .with_icon(Icon::Plus)
@@ -6587,6 +6758,7 @@ impl ProviderSettingsWidget {
bedrock_enabled_toggle: SwitchStateHandle::default(), bedrock_enabled_toggle: SwitchStateHandle::default(),
bedrock_model_switches: RefCell::new(HashMap::new()), bedrock_model_switches: RefCell::new(HashMap::new()),
bedrock_model_table_state: TableStateHandle::new(0, |_, _| Vec::new()), bedrock_model_table_state: TableStateHandle::new(0, |_, _| Vec::new()),
add_anthropic_provider_button,
add_openai_provider_button, add_openai_provider_button,
add_litellm_provider_button, add_litellm_provider_button,
add_chatgpt_provider_button, add_chatgpt_provider_button,
@@ -6620,7 +6792,17 @@ impl ProviderSettingsWidget {
OpenAIProviderKind::OpenAI => "OpenAI", OpenAIProviderKind::OpenAI => "OpenAI",
OpenAIProviderKind::LiteLLM => "LiteLLM", OpenAIProviderKind::LiteLLM => "LiteLLM",
OpenAIProviderKind::ChatGPTSubscription => "ChatGPT subscription", 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::Gemini => "Google Gemini",
OpenAIProviderKind::VertexAI => "Google Vertex AI", OpenAIProviderKind::VertexAI => "Google Vertex AI",
} }
@@ -7212,8 +7394,8 @@ impl ProviderSettingsWidget {
ProviderSetupProviderType::ChatGPTSubscription => { ProviderSetupProviderType::ChatGPTSubscription => {
Some(ModelProviderSection::ChatGPTSubscription) Some(ModelProviderSection::ChatGPTSubscription)
} }
ProviderSetupProviderType::Anthropic ProviderSetupProviderType::Anthropic => Some(ModelProviderSection::Anthropic),
| ProviderSetupProviderType::Gemini ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI | ProviderSetupProviderType::VertexAI
| ProviderSetupProviderType::Bedrock | ProviderSetupProviderType::Bedrock
| ProviderSetupProviderType::Acp => None, | ProviderSetupProviderType::Acp => None,
@@ -7226,8 +7408,8 @@ impl ProviderSettingsWidget {
ProviderSetupProviderType::LiteLLM => "LiteLLM", ProviderSetupProviderType::LiteLLM => "LiteLLM",
ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT Subscription", ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT Subscription",
ProviderSetupProviderType::Bedrock => "Bedrock", ProviderSetupProviderType::Bedrock => "Bedrock",
ProviderSetupProviderType::Acp => "ACP", ProviderSetupProviderType::Acp => "Claude Code",
ProviderSetupProviderType::Anthropic => "Anthropic", ProviderSetupProviderType::Anthropic => "Anthropic / Claude subscription",
ProviderSetupProviderType::Gemini => "Google Gemini", ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI", ProviderSetupProviderType::VertexAI => "Google Vertex AI",
} }
@@ -7248,9 +7430,9 @@ impl ProviderSettingsWidget {
"Use AWS Bedrock credentials and discovered Bedrock foundation models." "Use AWS Bedrock credentials and discovered Bedrock foundation models."
} }
ProviderSetupProviderType::Acp => { 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::Gemini => "Connect directly to Google Gemini.",
ProviderSetupProviderType::VertexAI => "Connect directly to Google Vertex AI.", ProviderSetupProviderType::VertexAI => "Connect directly to Google Vertex AI.",
} }
@@ -7264,7 +7446,7 @@ impl ProviderSettingsWidget {
"No ChatGPT subscription provider configured." "No ChatGPT subscription provider configured."
} }
ProviderSetupProviderType::Bedrock => "No Bedrock 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::Anthropic => "No Anthropic provider configured.",
ProviderSetupProviderType::Gemini => "No Google Gemini provider configured.", ProviderSetupProviderType::Gemini => "No Google Gemini provider configured.",
ProviderSetupProviderType::VertexAI => "No Google Vertex AI provider configured.", ProviderSetupProviderType::VertexAI => "No Google Vertex AI provider configured.",
@@ -7283,9 +7465,8 @@ impl ProviderSettingsWidget {
} }
ProviderSetupProviderType::Bedrock => Some(&self.bedrock_add_button), ProviderSetupProviderType::Bedrock => Some(&self.bedrock_add_button),
ProviderSetupProviderType::Acp => Some(&self.acp_add_button), ProviderSetupProviderType::Acp => Some(&self.acp_add_button),
ProviderSetupProviderType::Anthropic ProviderSetupProviderType::Anthropic => Some(&self.add_anthropic_provider_button),
| ProviderSetupProviderType::Gemini ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => None,
| ProviderSetupProviderType::VertexAI => None,
} }
} }
@@ -7369,7 +7550,8 @@ impl SettingsWidget for ProviderSettingsWidget {
match self.provider_type { match self.provider_type {
ProviderSetupProviderType::OpenAI ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM | ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::ChatGPTSubscription => { | ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic => {
if is_setup_visible { if is_setup_visible {
column.add_child(Self::render_inline_setup(appearance, view)); column.add_child(Self::render_inline_setup(appearance, view));
} }
@@ -7462,22 +7644,20 @@ impl SettingsWidget for ProviderSettingsWidget {
None None
}; };
let empty_message = if acp_supported { let empty_message = if acp_supported {
"No ACP agent connection configured." "No Claude Code connection configured."
} else { } else {
"ACP providers are not available on this platform." "ACP providers are not available on this platform."
}; };
column.add_child(Self::render_provider_section( column.add_child(Self::render_provider_section(
"ACP", "Claude Code",
"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.",
acp_add_button, acp_add_button,
acp_cards, acp_cards,
empty_message, empty_message,
appearance, appearance,
)); ));
} }
ProviderSetupProviderType::Anthropic ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => {}
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {}
} }
column.finish() column.finish()
+8
View File
@@ -250,6 +250,7 @@ pub enum SettingsSection {
ProviderOpenAI, ProviderOpenAI,
ProviderLiteLLM, ProviderLiteLLM,
ProviderChatGPTSubscription, ProviderChatGPTSubscription,
ProviderAnthropic,
ProviderBedrock, ProviderBedrock,
ProviderACP, ProviderACP,
/// Internal backing-page identifier for CodeSettingsPageView. Multiple subpages /// Internal backing-page identifier for CodeSettingsPageView. Multiple subpages
@@ -282,6 +283,7 @@ impl Display for SettingsSection {
SettingsSection::ProviderOpenAI => write!(f, "OpenAI"), SettingsSection::ProviderOpenAI => write!(f, "OpenAI"),
SettingsSection::ProviderLiteLLM => write!(f, "LiteLLM"), SettingsSection::ProviderLiteLLM => write!(f, "LiteLLM"),
SettingsSection::ProviderChatGPTSubscription => write!(f, "ChatGPT Subscription"), SettingsSection::ProviderChatGPTSubscription => write!(f, "ChatGPT Subscription"),
SettingsSection::ProviderAnthropic => write!(f, "Anthropic / Claude subscription"),
SettingsSection::ProviderBedrock => write!(f, "Bedrock"), SettingsSection::ProviderBedrock => write!(f, "Bedrock"),
SettingsSection::ProviderACP => write!(f, "ACP"), SettingsSection::ProviderACP => write!(f, "ACP"),
SettingsSection::Wormhole => write!(f, "Wormhole"), SettingsSection::Wormhole => write!(f, "Wormhole"),
@@ -318,6 +320,7 @@ impl SettingsSection {
Self::ProviderOpenAI Self::ProviderOpenAI
| Self::ProviderLiteLLM | Self::ProviderLiteLLM
| Self::ProviderChatGPTSubscription | Self::ProviderChatGPTSubscription
| Self::ProviderAnthropic
| Self::ProviderBedrock | Self::ProviderBedrock
| Self::ProviderACP | Self::ProviderACP
) )
@@ -335,6 +338,7 @@ impl SettingsSection {
| Self::ProviderOpenAI | Self::ProviderOpenAI
| Self::ProviderLiteLLM | Self::ProviderLiteLLM
| Self::ProviderChatGPTSubscription | Self::ProviderChatGPTSubscription
| Self::ProviderAnthropic
| Self::ProviderBedrock | Self::ProviderBedrock
| Self::ProviderACP | Self::ProviderACP
) )
@@ -376,6 +380,7 @@ impl SettingsSection {
Self::ProviderOpenAI, Self::ProviderOpenAI,
Self::ProviderLiteLLM, Self::ProviderLiteLLM,
Self::ProviderChatGPTSubscription, Self::ProviderChatGPTSubscription,
Self::ProviderAnthropic,
Self::ProviderBedrock, Self::ProviderBedrock,
Self::ProviderACP, Self::ProviderACP,
] ]
@@ -413,6 +418,9 @@ impl FromStr for SettingsSection {
"OpenAI / LiteLLM" | "LiteLLM" => Ok(Self::ProviderLiteLLM), "OpenAI / LiteLLM" | "LiteLLM" => Ok(Self::ProviderLiteLLM),
"OpenAI" => Ok(Self::ProviderOpenAI), "OpenAI" => Ok(Self::ProviderOpenAI),
"ChatGPT Subscription" | "ChatGPTSubscription" => Ok(Self::ProviderChatGPTSubscription), "ChatGPT Subscription" | "ChatGPTSubscription" => Ok(Self::ProviderChatGPTSubscription),
"Anthropic" | "Claude Subscription" | "Anthropic / Claude subscription" => {
Ok(Self::ProviderAnthropic)
}
"Agent Client Protocol" | "ACP" => Ok(Self::ProviderACP), "Agent Client Protocol" | "ACP" => Ok(Self::ProviderACP),
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing), "Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview), "Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
+5
View File
@@ -41,6 +41,7 @@ fn provider_subpages_are_classified_and_map_to_ai_backing_page() {
SettingsSection::ProviderOpenAI, SettingsSection::ProviderOpenAI,
SettingsSection::ProviderLiteLLM, SettingsSection::ProviderLiteLLM,
SettingsSection::ProviderChatGPTSubscription, SettingsSection::ProviderChatGPTSubscription,
SettingsSection::ProviderAnthropic,
SettingsSection::ProviderBedrock, SettingsSection::ProviderBedrock,
SettingsSection::ProviderACP, SettingsSection::ProviderACP,
] ]
@@ -116,6 +117,10 @@ fn current_settings_display_names_round_trip() {
SettingsSection::ProviderChatGPTSubscription, SettingsSection::ProviderChatGPTSubscription,
"ChatGPT Subscription", "ChatGPT Subscription",
), ),
(
SettingsSection::ProviderAnthropic,
"Anthropic / Claude subscription",
),
(SettingsSection::ProviderBedrock, "Bedrock"), (SettingsSection::ProviderBedrock, "Bedrock"),
(SettingsSection::ProviderACP, "ACP"), (SettingsSection::ProviderACP, "ACP"),
(SettingsSection::CodeIndexing, "Indexing and projects"), (SettingsSection::CodeIndexing, "Indexing and projects"),
+344 -46
View File
@@ -67,7 +67,7 @@ use crate::ai::harness_display;
use crate::ai::llms::{merge_discovered_provider_models, replace_litellm_catalog, LLMPreferences}; use crate::ai::llms::{merge_discovered_provider_models, replace_litellm_catalog, LLMPreferences};
use crate::appearance::Appearance; use crate::appearance::Appearance;
use crate::editor::{ use crate::editor::{
EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions, EditOrigin, EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions,
}; };
use crate::settings::ai::{ use crate::settings::ai::{
AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride, AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride,
@@ -118,6 +118,11 @@ const PROVIDER_TYPE_OPTIONS: &[(ProviderSetupProviderType, &str, &str)] = &[
"ChatGPT subscription", "ChatGPT subscription",
"Use your ChatGPT Plus or Pro subscription with native OAuth.", "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, ProviderSetupProviderType::Bedrock,
"AWS Bedrock", "AWS Bedrock",
@@ -215,6 +220,9 @@ pub enum ProviderSetupViewAction {
ToggleModel(usize), ToggleModel(usize),
CycleModelCapability(usize, CapabilityKey), CycleModelCapability(usize, CapabilityKey),
ConnectChatGPT, ConnectChatGPT,
ConnectClaude,
SignOutClaude,
CancelClaudeSignIn,
SelectBedrockAuth(BedrockAuthMethod), SelectBedrockAuth(BedrockAuthMethod),
SelectBedrockProfile(String), SelectBedrockProfile(String),
ToggleBedrockCrossRegion, ToggleBedrockCrossRegion,
@@ -236,6 +244,9 @@ pub struct ProviderSetupView {
draft_bedrock: BedrockProviderDraft, draft_bedrock: BedrockProviderDraft,
draft_acp: AcpProviderDraft, draft_acp: AcpProviderDraft,
discovery_state: DiscoveryState, discovery_state: DiscoveryState,
acp_setup_status: String,
acp_discovery_generation: u64,
acp_setup_abort: Option<futures::future::AbortHandle>,
provider_type_buttons: Vec<ViewHandle<ActionButton>>, provider_type_buttons: Vec<ViewHandle<ActionButton>>,
acp_agent_buttons: Vec<ViewHandle<ActionButton>>, acp_agent_buttons: Vec<ViewHandle<ActionButton>>,
name_editor: ViewHandle<EditorView>, name_editor: ViewHandle<EditorView>,
@@ -253,6 +264,11 @@ pub struct ProviderSetupView {
acp_command_editor: ViewHandle<EditorView>, acp_command_editor: ViewHandle<EditorView>,
acp_args_editor: ViewHandle<EditorView>, acp_args_editor: ViewHandle<EditorView>,
chatgpt_connect_mouse_state: MouseStateHandle, 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_auth_buttons: Vec<ViewHandle<ActionButton>>,
bedrock_cross_region_toggle: SwitchStateHandle, bedrock_cross_region_toggle: SwitchStateHandle,
bedrock_auto_login_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_refresh_command_editor = Self::create_editor("aws sso login", false, ctx);
let bedrock_access_key_editor = Self::create_editor("AKIA...", 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 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_command_editor = Self::create_editor("Optional executable", false, ctx);
let acp_args_editor = Self::create_editor(r#"["arg1", "arg2"]"#, 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() .iter()
.map(|agent| { .map(|agent| {
let id = agent.id.to_owned(); let id = agent.id.to_owned();
@@ -322,15 +338,6 @@ impl ProviderSetupView {
}) })
}) })
.collect::<Vec<_>>(); .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 = [ let bedrock_auth_buttons = [
BedrockAuthMethod::Profile, BedrockAuthMethod::Profile,
@@ -362,9 +369,15 @@ impl ProviderSetupView {
} }
}); });
ctx.subscribe_to_view(&api_key_editor, |me, editor, event, ctx| { 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); 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.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(); ctx.notify();
} }
}); });
@@ -465,12 +478,15 @@ impl ProviderSetupView {
}, },
draft_acp: AcpProviderDraft { draft_acp: AcpProviderDraft {
name: String::new(), name: String::new(),
agent_id: "codex".to_string(), agent_id: "claude-acp".to_string(),
command: String::new(), command: String::new(),
args: Vec::new(), args: Vec::new(),
config_options: Vec::new(), config_options: Vec::new(),
}, },
discovery_state: DiscoveryState::Idle, discovery_state: DiscoveryState::Idle,
acp_setup_status: String::new(),
acp_discovery_generation: 0,
acp_setup_abort: None,
provider_type_buttons, provider_type_buttons,
acp_agent_buttons, acp_agent_buttons,
name_editor, name_editor,
@@ -488,6 +504,11 @@ impl ProviderSetupView {
acp_command_editor, acp_command_editor,
acp_args_editor, acp_args_editor,
chatgpt_connect_mouse_state: MouseStateHandle::default(), 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_auth_buttons,
bedrock_cross_region_toggle: SwitchStateHandle::default(), bedrock_cross_region_toggle: SwitchStateHandle::default(),
bedrock_auto_login_toggle: SwitchStateHandle::default(), bedrock_auto_login_toggle: SwitchStateHandle::default(),
@@ -542,7 +563,7 @@ impl ProviderSetupView {
ProviderSetupProviderType::Gemini => "Google Gemini", ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI", ProviderSetupProviderType::VertexAI => "Google Vertex AI",
ProviderSetupProviderType::Bedrock => "AWS Bedrock", ProviderSetupProviderType::Bedrock => "AWS Bedrock",
ProviderSetupProviderType::Acp => "ACP agent runtime", ProviderSetupProviderType::Acp => "Claude Code",
} }
} }
@@ -564,6 +585,8 @@ impl ProviderSetupView {
provider_type: ProviderSetupProviderType, provider_type: ProviderSetupProviderType,
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) { ) {
self.cancel_acp_setup();
self.cancel_claude_signin();
self.step = ProviderSetupStep::Configure; self.step = ProviderSetupStep::Configure;
self.editing_index = None; self.editing_index = None;
self.provider_type = provider_type; self.provider_type = provider_type;
@@ -571,6 +594,7 @@ impl ProviderSetupView {
self.draft_name = Self::default_name(provider_type).to_string(); self.draft_name = Self::default_name(provider_type).to_string();
self.draft_base_url = Self::default_base_url(provider_type).to_string(); self.draft_base_url = Self::default_base_url(provider_type).to_string();
self.draft_api_key = None; self.draft_api_key = None;
self.claude_credentials_available = false;
self.draft_project_id.clear(); self.draft_project_id.clear();
self.draft_location = "global".to_string(); self.draft_location = "global".to_string();
self.draft_models.clear(); self.draft_models.clear();
@@ -588,7 +612,7 @@ impl ProviderSetupView {
}; };
self.draft_acp = AcpProviderDraft { self.draft_acp = AcpProviderDraft {
name: String::new(), name: String::new(),
agent_id: "codex".to_string(), agent_id: "claude-acp".to_string(),
command: String::new(), command: String::new(),
args: Vec::new(), args: Vec::new(),
config_options: Vec::new(), config_options: Vec::new(),
@@ -613,6 +637,7 @@ impl ProviderSetupView {
provider: OpenAIProviderConfig, provider: OpenAIProviderConfig,
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) { ) {
self.cancel_claude_signin();
// Editing an existing provider is a local catalog operation. Do not // Editing an existing provider is a local catalog operation. Do not
// send the user through credentials or model discovery again. // send the user through credentials or model discovery again.
self.step = ProviderSetupStep::Models; self.step = ProviderSetupStep::Models;
@@ -631,6 +656,14 @@ impl ProviderSetupView {
self.draft_name = provider.name; self.draft_name = provider.name;
self.draft_base_url = provider.base_url; self.draft_base_url = provider.base_url;
self.draft_api_key = provider.api_key; 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_project_id = provider.project_id.unwrap_or_default();
self.draft_location = provider.location.unwrap_or_else(|| "global".to_string()); self.draft_location = provider.location.unwrap_or_else(|| "global".to_string());
self.draft_models = provider.models; self.draft_models = provider.models;
@@ -667,6 +700,7 @@ impl ProviderSetupView {
} }
pub fn begin_edit_acp(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext<Self>) { 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() { self.step = if draft.config_options.is_empty() {
ProviderSetupStep::Configure ProviderSetupStep::Configure
} else { } else {
@@ -688,6 +722,43 @@ impl ProviderSetupView {
ctx.notify(); 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( pub fn finish_acp_discovery(
&mut self, &mut self,
result: Result<(), String>, result: Result<(), String>,
@@ -754,7 +825,13 @@ impl ProviderSetupView {
editor.system_reset_buffer_text(&self.draft_base_url, ctx); editor.system_reset_buffer_text(&self.draft_base_url, ctx);
}); });
self.api_key_editor.update(ctx, |editor, 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| { self.project_id_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_project_id, 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); 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>) { fn sync_bedrock_auth_buttons(&self, ctx: &mut ViewContext<Self>) {
@@ -948,7 +1020,9 @@ impl ProviderSetupView {
| ProviderSetupProviderType::VertexAI => ( | ProviderSetupProviderType::VertexAI => (
"Save", "Save",
self.draft_name.trim().is_empty() 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 => ( ProviderSetupProviderType::Bedrock => (
"Save", "Save",
@@ -1057,8 +1131,11 @@ impl ProviderSetupView {
} }
fn begin_discovery(&mut self, ctx: &mut ViewContext<Self>) { fn begin_discovery(&mut self, ctx: &mut ViewContext<Self>) {
self.cancel_acp_setup();
self.step = ProviderSetupStep::Discover; self.step = ProviderSetupStep::Discover;
self.discovery_state = DiscoveryState::Loading; 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); self.update_next_button(ctx);
ctx.notify(); ctx.notify();
@@ -1136,26 +1213,38 @@ impl ProviderSetupView {
let provider = self.draft_provider(); let provider = self.draft_provider();
let existing_models = self.draft_models.clone(); let existing_models = self.draft_models.clone();
let authoritative_catalog = provider.kind == OpenAIProviderKind::LiteLLM; 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( ctx.spawn(
async move { LLMPreferences::discover_openai_provider_models(provider).await }, async move { LLMPreferences::discover_openai_provider_models(provider).await },
move |me, result, ctx| match result { move |me, result, ctx| {
Ok(models) => { if generation != me.acp_discovery_generation
me.draft_models = if authoritative_catalog { || requested_kind != me.provider_type
replace_litellm_catalog(&existing_models, models) || requested_credential != me.draft_api_key
} else { || me.step != ProviderSetupStep::Discover
merge_discovered_provider_models(&existing_models, models) {
}; return;
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) => { match result {
me.discovery_state = DiscoveryState::Failed(error); Ok(models) => {
me.update_next_button(ctx); me.draft_models = if authoritative_catalog {
ctx.notify(); 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() .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"))] #[cfg(not(target_family = "wasm"))]
fn render_chatgpt_auth( fn render_chatgpt_auth(
&self, &self,
@@ -1438,10 +1659,21 @@ impl ProviderSetupView {
); );
} }
ProviderSetupProviderType::Anthropic => { 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( children.push(
Text::new( 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(), appearance.ui_font_family(),
INPUT_FONT_SIZE, INPUT_FONT_SIZE,
) )
@@ -1449,6 +1681,16 @@ impl ProviderSetupView {
.soft_wrap(true) .soft_wrap(true)
.finish(), .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 => { ProviderSetupProviderType::Gemini => {
children.push(self.render_input(appearance, "API key", &self.api_key_editor)); children.push(self.render_input(appearance, "API key", &self.api_key_editor));
@@ -1576,7 +1818,7 @@ impl ProviderSetupView {
} }
children.push( children.push(
Text::new( 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(), appearance.ui_font_family(),
INPUT_FONT_SIZE, INPUT_FONT_SIZE,
) )
@@ -1596,7 +1838,13 @@ impl ProviderSetupView {
fn render_discovery(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> { fn render_discovery(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let (message, color) = match &self.discovery_state { let (message, color) = match &self.discovery_state {
DiscoveryState::Idle | DiscoveryState::Loading => ( 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(), appearance.theme().active_ui_text_color().into_solid(),
), ),
DiscoveryState::Failed(error) => (error.clone(), appearance.theme().ui_error_color()), DiscoveryState::Failed(error) => (error.clone(), appearance.theme().ui_error_color()),
@@ -2012,6 +2260,9 @@ impl ProviderSetupView {
} }
fn is_configure_valid(&self) -> bool { fn is_configure_valid(&self) -> bool {
if self.claude_signin_abort.is_some() {
return false;
}
match self.provider_type { match self.provider_type {
ProviderSetupProviderType::OpenAI => { ProviderSetupProviderType::OpenAI => {
!self.draft_base_url.trim().is_empty() !self.draft_base_url.trim().is_empty()
@@ -2021,7 +2272,16 @@ impl ProviderSetupView {
.is_some_and(|key| !key.trim().is_empty()) .is_some_and(|key| !key.trim().is_empty())
} }
ProviderSetupProviderType::LiteLLM => !self.draft_base_url.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 .draft_api_key
.as_deref() .as_deref()
.is_some_and(|key| !key.trim().is_empty()), .is_some_and(|key| !key.trim().is_empty()),
@@ -2202,6 +2462,9 @@ impl TypedActionView for ProviderSetupView {
match action { match action {
ProviderSetupViewAction::SelectProvider(kind) => { ProviderSetupViewAction::SelectProvider(kind) => {
if self.provider_type != *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.draft_models.clear();
self.discovery_state = DiscoveryState::Idle; self.discovery_state = DiscoveryState::Idle;
} }
@@ -2244,6 +2507,8 @@ impl TypedActionView for ProviderSetupView {
| ProviderSetupProviderType::VertexAI => { | ProviderSetupProviderType::VertexAI => {
if self.draft_name.trim().is_empty() if 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())
{ {
return; return;
} }
@@ -2282,6 +2547,7 @@ impl TypedActionView for ProviderSetupView {
ctx.notify(); ctx.notify();
} }
ProviderSetupStep::Discover => { ProviderSetupStep::Discover => {
self.cancel_acp_setup();
self.step = ProviderSetupStep::Configure; self.step = ProviderSetupStep::Configure;
self.discovery_state = DiscoveryState::Idle; self.discovery_state = DiscoveryState::Idle;
self.update_next_button(ctx); self.update_next_button(ctx);
@@ -2294,6 +2560,9 @@ impl TypedActionView for ProviderSetupView {
} }
}, },
ProviderSetupViewAction::Cancel => { ProviderSetupViewAction::Cancel => {
self.cancel_acp_setup();
self.cancel_claude_signin();
self.discovery_state = DiscoveryState::Idle;
ctx.emit(ProviderSetupViewEvent::Close); ctx.emit(ProviderSetupViewEvent::Close);
} }
ProviderSetupViewAction::SelectAcpAgent(agent_id) => { ProviderSetupViewAction::SelectAcpAgent(agent_id) => {
@@ -2338,6 +2607,28 @@ impl TypedActionView for ProviderSetupView {
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx)); 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) => { ProviderSetupViewAction::SelectBedrockProfile(profile) => {
if self.draft_bedrock.profile != *profile { if self.draft_bedrock.profile != *profile {
self.draft_bedrock.profile = profile.clone(); self.draft_bedrock.profile = profile.clone();
@@ -2394,6 +2685,13 @@ fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str {
ProviderSetupProviderType::Gemini => "Google Gemini", ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI", ProviderSetupProviderType::VertexAI => "Google Vertex AI",
ProviderSetupProviderType::Bedrock => "AWS Bedrock", ProviderSetupProviderType::Bedrock => "AWS Bedrock",
ProviderSetupProviderType::Acp => "ACP agent runtime", ProviderSetupProviderType::Acp => "Claude Code",
}
}
impl Drop for ProviderSetupView {
fn drop(&mut self) {
self.cancel_claude_signin();
self.cancel_acp_setup();
} }
} }
+9 -7
View File
@@ -1096,16 +1096,18 @@ impl BlockListElement {
.finish(), .finish(),
); );
let snackbar_toggle_icon; let (snackbar_toggle_icon, rounded_corners) = if self.snackbar_header_state.show_snackbar {
let rounded_corners;
if self.snackbar_header_state.show_snackbar {
// Snackbar expanded. Show collapse button attached to bottom of 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 { } else {
// Snackbar collapsed. Show expand button attached to top of blocklist. // 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( let icon = Container::new(
ConstrainedBox::new(Icon::new(snackbar_toggle_icon.into(), icon_color).finish()) ConstrainedBox::new(Icon::new(snackbar_toggle_icon.into(), icon_color).finish())
+4 -5
View File
@@ -7231,8 +7231,7 @@ impl Input {
.active_block_mut() .active_block_mut()
.set_nld_overridden(nld_overridden); .set_nld_overridden(nld_overridden);
let did_execute: bool; let did_execute = if self
if self
.model .model
.lock() .lock()
.block_list() .block_list()
@@ -7311,14 +7310,14 @@ impl Input {
} }
self.start_block_and_write_command_to_pty(command, source, ctx); self.start_block_and_write_command_to_pty(command, source, ctx);
did_execute = true; true
} else { } else {
// We don't want to submit the command if precmd has not // We don't want to submit the command if precmd has not
// been received. Instead, we want the user to be aware // been received. Instead, we want the user to be aware
// that the prompt might not be up to date. // that the prompt might not be up to date.
send_telemetry_from_ctx!(TelemetryEvent::TriedToExecuteBeforePrecmd, ctx); send_telemetry_from_ctx!(TelemetryEvent::TriedToExecuteBeforePrecmd, ctx);
did_execute = false; false
} };
// Close the workflows info box if it was open. // Close the workflows info box if it was open.
self.clear_selected_workflow(ctx); self.clear_selected_workflow(ctx);
@@ -129,7 +129,9 @@ pub fn test_append_to_row_translation_empty_displayed_rows() {
}; };
// Append displayed rows 0..=1. // 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)]); let expected_bimap = create_bimap_with_insertions(&[(0, 0), (1, 1)]);
assert_eq!(displayed_output.row_translation_map, expected_bimap.into()); 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. // 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(6, 4);
bimap.insert(7, 5); bimap.insert(7, 5);
@@ -163,7 +167,8 @@ pub fn test_prepend_to_row_translation_empty_displayed_rows() {
}; };
// Prepend displayed rows 0..=1. // 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_output
.displayed_rows .displayed_rows
.splice(0..0, new_line_ranges); .splice(0..0, new_line_ranges);
@@ -186,7 +191,8 @@ pub fn test_prepend_to_row_translation() {
}; };
// Prepend displayed rows 0..=1. // 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_output
.displayed_rows .displayed_rows
.splice(0..0, new_line_ranges); .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. // Replace displayed row 2 with displayed row 3.
let replace_range = 1..2; 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_output
.displayed_rows .displayed_rows
.splice(replace_range.clone(), new_line_ranges); .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. // Replace displayed rows 2, 4 with displayed row 3..=5.
let replace_range = 1..3; 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_output
.displayed_rows .displayed_rows
.splice(replace_range.clone(), new_line_ranges); .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. // Replace displayed rows 0, 2 with displayed row 0..=1.
let replace_range = 0..2; 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_output
.displayed_rows .displayed_rows
.splice(replace_range.clone(), new_line_ranges); .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. // Start with displayed rows 0.
let bimap = create_bimap_with_insertions(&[(0, 0)]); let bimap = create_bimap_with_insertions(&[(0, 0)]);
let mut displayed_output = DisplayedOutput { 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, height: 1,
row_translation_map: bimap.into(), 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. // Replace displayed rows 0 with displayed row 0.
// This is intended to simulate what happens if we process dirty bytes multiple times. // This is intended to simulate what happens if we process dirty bytes multiple times.
let replace_range = 0..1; 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_output
.displayed_rows .displayed_rows
.splice(replace_range.clone(), new_line_ranges); .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. // Replace displayed rows 2..=3 with displayed row 2.
let replace_range = 1..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_output
.displayed_rows .displayed_rows
.splice(replace_range.clone(), new_line_ranges); .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. // Insert displayed rows 3..=4.
let replace_range = 1..1; 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_output
.displayed_rows .displayed_rows
.splice(replace_range.clone(), new_line_ranges); .splice(replace_range.clone(), new_line_ranges);
@@ -537,7 +549,8 @@ fn test_new_from_displayed_lines() {
#[test] #[test]
fn test_extend_displayed_lines() { 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()); 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])); 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] #[test]
fn test_prepend_displayed_lines() { 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()); 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])); 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 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 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); displayed_output.splice_displayed_lines(2..2, new_displayed_rows);
let expected_displayed_rows = let expected_displayed_rows =
+23 -14
View File
@@ -818,11 +818,11 @@ pub fn test_add_context_lines_at_grid_bounds() {
line\r\n\ line\r\n\
line\r\n", line\r\n",
); );
let line_ranges = [2..=2]; let line_range = 2..=2;
let displayed_rows = blockgrid let displayed_rows = blockgrid
.grid_handler() .grid_handler()
.add_context_lines(line_ranges.iter(), 5); .add_context_lines(std::iter::once(&line_range), 5);
assert_eq!( assert_eq!(
displayed_rows, displayed_rows,
vec![ vec![
@@ -845,11 +845,11 @@ pub fn test_add_context_lines_at_grid_bounds() {
#[test] #[test]
pub fn test_add_context_lines_no_context() { pub fn test_add_context_lines_no_context() {
let blockgrid = mock_blockgrid("match"); let blockgrid = mock_blockgrid("match");
let line_ranges = [0..=0]; let line_range = 0..=0;
let displayed_rows = blockgrid let displayed_rows = blockgrid
.grid_handler() .grid_handler()
.add_context_lines(line_ranges.iter(), 5); .add_context_lines(std::iter::once(&line_range), 5);
assert_eq!( assert_eq!(
displayed_rows, displayed_rows,
vec![DisplayedRows { vec![DisplayedRows {
@@ -949,7 +949,8 @@ pub fn test_update_filtered_dirty_lines() {
grid.grid_handler_mut() grid.grid_handler_mut()
.set_displayed_output(displayed_output); .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() grid.grid_handler_mut()
.update_filtered_dirty_lines(5..=17, new_displayed_rows, 0); .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() grid.grid_handler_mut()
.set_displayed_output(displayed_output); .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() grid.grid_handler_mut()
.update_filtered_dirty_lines(6..=15, new_displayed_rows, 0); .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() grid.grid_handler_mut()
.set_displayed_output(displayed_output); .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() grid.grid_handler_mut()
.update_filtered_dirty_lines(10..=13, new_displayed_rows, 0); .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() grid.grid_handler_mut()
.set_displayed_output(displayed_output); .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() grid.grid_handler_mut()
.update_filtered_dirty_lines(6..=10, new_displayed_rows, 0); .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() grid.grid_handler_mut()
.set_displayed_output(displayed_output); .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() grid.grid_handler_mut()
.update_filtered_dirty_lines(7..=7, new_displayed_rows, 0); .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() grid.grid_handler_mut()
.set_displayed_output(displayed_output); .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() grid.grid_handler_mut()
.update_filtered_dirty_lines(1..=11, new_displayed_rows, 0); .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() grid.grid_handler_mut()
.set_displayed_output(displayed_output); .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() grid.grid_handler_mut()
.update_filtered_dirty_lines(12..=16, new_displayed_rows, 0); .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'; grid[4][0].c = 'e';
// Initialize displayed output with all rows in the grid. // 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 // Set the cursor to the middle of the grid
grid_handler.set_cursor_point(2, 0); 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 dfas = RegexDFAs::new("match").unwrap();
let non_matching_lines = blockgrid.grid_handler().find_non_matching_lines(&dfas); 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 dfas = RegexDFAs::new("match").unwrap();
let non_matching_lines = blockgrid.grid_handler().find_non_matching_lines_in_range( let non_matching_lines = blockgrid.grid_handler().find_non_matching_lines_in_range(
+3 -1
View File
@@ -1689,7 +1689,9 @@ fn decode_uuid_hex(hex: &str) -> Option<Vec<u8>> {
return None; return None;
} }
hex.chunks_exact(2) hex.as_chunks::<2>()
.0
.iter()
.map(|pair| { .map(|pair| {
let high = (pair[0] as char).to_digit(16)?; let high = (pair[0] as char).to_digit(16)?;
let low = (pair[1] as char).to_digit(16)?; let low = (pair[1] as char).to_digit(16)?;
+1 -1
View File
@@ -21420,7 +21420,7 @@ impl Workspace {
Align::new( Align::new(
self.render_tab_bar_icon_button( self.render_tab_bar_icon_button(
appearance, appearance,
icons::Icon::Stars, icons::Icon::Gear,
&self.mouse_states.settings_icon, &self.mouse_states.settings_icon,
WorkspaceAction::ShowSettings, WorkspaceAction::ShowSettings,
"Settings".to_string(), "Settings".to_string(),
+9 -67
View File
@@ -26,73 +26,15 @@ pub struct AcpKnownAgent {
pub args: &'static [&'static str], pub args: &'static [&'static str],
} }
/// Curated ACP Registry catalog. Launch commands are intentionally local-only: /// ACP agents offered by Galaxy's connection setup. Existing explicit launch
/// Galaxy never installs or downloads an agent on the user's behalf. /// configurations remain supported independently of this catalog.
pub const KNOWN_ACP_AGENTS: &[AcpKnownAgent] = &[ pub const KNOWN_ACP_AGENTS: &[AcpKnownAgent] = &[AcpKnownAgent {
AcpKnownAgent { id: "claude-acp",
id: "codex", name: "Claude Code",
name: "Codex", description: "Claude Code with your Claude subscription or API account",
description: "OpenAI's coding assistant", command: "claude-agent-acp",
command: "codex", args: &[],
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"],
},
];
pub fn known_acp_agents() -> &'static [AcpKnownAgent] { pub fn known_acp_agents() -> &'static [AcpKnownAgent] {
KNOWN_ACP_AGENTS KNOWN_ACP_AGENTS
+8
View File
@@ -3,6 +3,14 @@ use std::time::Duration;
use super::*; 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] #[test]
fn codex_preset_uses_the_adapter_with_npx() { fn codex_preset_uses_the_adapter_with_npx() {
let launch = AcpAgentPreset::Codex.launch_config(); 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 /// GDI does not populate the alpha channel for `BI_RGB`, so we force it to `0xFF` to produce a
/// fully opaque RGBA image. /// fully opaque RGBA image.
fn convert_bgra_to_rgba(mut buffer: Vec<u8>) -> Vec<u8> { 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. // Swap B and R channels so the 4-byte BGRA pixel becomes RGBA.
chunk.swap(0, 2); chunk.swap(0, 2);
chunk[3] = 0xFF; chunk[3] = 0xFF;
+1
View File
@@ -12,6 +12,7 @@ aws-sdk-bedrockruntime.workspace = true
base64.workspace = true base64.workspace = true
futures.workspace = true futures.workspace = true
galaxy_agent_core.workspace = true galaxy_agent_core.workspace = true
http.workspace = true
rig-core.workspace = true rig-core.workspace = true
rig-bedrock.workspace = true rig-bedrock.workspace = true
rig-vertexai.workspace = true rig-vertexai.workspace = true
@@ -0,0 +1,254 @@
//! Explicit Claude subscription tokens for the direct, Galaxy-owned runtime.
//! This is an unsupported authentication path; it never reads Claude Code's credentials.
use std::collections::HashMap;
use galaxy_agent_core::{AgentError, AgentErrorKind, AgentEvent, ToolEvent};
use http::{HeaderMap, HeaderValue};
use rig_core::OneOrMany;
use rig_core::client::{ApiKey, Client, ClientBuilder, Provider, ProviderBuilder};
use rig_core::completion::{AssistantContent, CompletionRequest, Message};
use rig_core::http_client::{self, HttpClientExt};
use rig_core::message::UserContent;
use rig_core::providers::anthropic;
use rig_core::providers::anthropic::client::AnthropicExt;
use serde_json::json;
pub fn is_claude_subscription_token(credential: &str) -> bool {
credential.trim().contains("sk-ant-oat")
}
// Pi OAuth compatibility profile, reviewed 2026-10-03:
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/api/anthropic-messages.ts
// Keep this independent of the version of the separately installed ACP CLI.
const PI_CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.280";
pub(crate) const CLAUDE_CODE_IDENTITY: &str =
"You are Claude Code, Anthropic's official CLI for Claude.";
// Use Rig's native Messages translation and streaming, but replace its API-key
// header at client construction rather than sending both authentication methods.
#[derive(Clone, Default)]
struct SubscriptionBuilder;
struct SubscriptionProvider;
struct SubscriptionToken(String);
impl ApiKey for SubscriptionToken {
fn into_header(self) -> Option<http_client::Result<(http::HeaderName, HeaderValue)>> {
Some(
http_client::make_auth_header(self.0).map(|(name, mut value)| {
value.set_sensitive(true);
(name, value)
}),
)
}
}
impl Provider for SubscriptionProvider {
type Builder = SubscriptionBuilder;
const VERIFY_PATH: &'static str = "/v1/models";
}
impl ProviderBuilder for SubscriptionBuilder {
type Extension<H>
= AnthropicExt
where
H: HttpClientExt;
type ApiKey = SubscriptionToken;
const BASE_URL: &'static str = "https://api.anthropic.com";
fn build<H>(_: &ClientBuilder<Self, Self::ApiKey, H>) -> http_client::Result<Self::Extension<H>>
where
H: HttpClientExt,
{
Ok(AnthropicExt)
}
}
pub(crate) fn anthropic_client(credential: &str) -> Result<anthropic::Client, String> {
let credential = credential.trim();
if !is_claude_subscription_token(credential) {
return anthropic::Client::new(credential).map_err(|error| error.to_string());
}
let headers = subscription_headers();
Client::<SubscriptionProvider>::builder()
.api_key(SubscriptionToken(credential.to_string()))
.http_headers(headers)
.build()
// Never include a credential or raw header-validation error in diagnostics.
.map_err(|_| "Could not configure Claude subscription authentication.".to_string())
}
const CLAUDE_CODE_TOOLS: &[&str] = &[
"Read",
"Write",
"Edit",
"Bash",
"Grep",
"Glob",
"AskUserQuestion",
"EnterPlanMode",
"ExitPlanMode",
"KillShell",
"NotebookEdit",
"Skill",
"Task",
"TaskOutput",
"TodoWrite",
"WebFetch",
"WebSearch",
];
fn wire_tool_name(name: &str) -> String {
CLAUDE_CODE_TOOLS
.iter()
.find(|candidate| candidate.eq_ignore_ascii_case(name))
.copied()
.unwrap_or(name)
.to_string()
}
fn normalize_call_id(id: &mut String) {
*id = id
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '_' || character == '-' {
character
} else {
'_'
}
})
.take(64)
.collect();
}
pub(crate) fn prepare_subscription_request(
request: &mut CompletionRequest,
) -> Result<HashMap<String, String>, AgentError> {
let mut aliases = HashMap::new();
let mut tools = Vec::new();
let tool_count = request.tools.len();
for (index, tool) in request.tools.drain(..).enumerate() {
let name = wire_tool_name(&tool.name);
if aliases.insert(name.clone(), tool.name.clone()).is_some() {
return Err(AgentError::new(
AgentErrorKind::InvalidRequest,
"Claude subscription tool names collide after canonical casing.",
));
}
// Pi's default non-strict schema preserves properties and required.
let mut definition = json!({
"name": name,
"description": tool.description,
"input_schema": {
"type": "object",
"properties": tool.parameters.get("properties").cloned().unwrap_or(json!({})),
"required": tool.parameters.get("required").cloned().unwrap_or(json!([])),
},
"eager_input_streaming": true,
});
if index + 1 == tool_count {
definition["cache_control"] = json!({"type": "ephemeral"});
}
tools.push(definition);
}
let mut history = Vec::new();
let mut galaxy_system = Vec::new();
for mut message in request.chat_history.iter().cloned() {
match &mut message {
Message::System { content } => {
galaxy_system.push(content.clone());
continue;
}
Message::Assistant { content, .. } => {
for part in content.iter_mut() {
if let AssistantContent::ToolCall(call) = part {
call.function.name = wire_tool_name(&call.function.name);
normalize_call_id(&mut call.id);
if let Some(id) = call.call_id.as_mut() {
normalize_call_id(id);
}
}
}
}
Message::User { content } => {
for part in content.iter_mut() {
if let UserContent::ToolResult(result) = part {
normalize_call_id(&mut result.id);
if let Some(id) = result.call_id.as_mut() {
normalize_call_id(id);
}
}
}
}
}
history.push(message);
}
request.chat_history = OneOrMany::many(history)
.map_err(|error| AgentError::new(AgentErrorKind::InvalidRequest, error.to_string()))?;
let mut system = vec![json!({
"type": "text", "text": CLAUDE_CODE_IDENTITY,
"cache_control": {"type": "ephemeral"},
})];
if !galaxy_system.is_empty() {
system.push(json!({
"type": "text", "text": galaxy_system.join("\n\n"),
"cache_control": {"type": "ephemeral"},
}));
}
let mut parameters = request.additional_params.take().unwrap_or(json!({}));
let Some(parameters_map) = parameters.as_object_mut() else {
return Err(AgentError::new(
AgentErrorKind::InvalidRequest,
"Claude subscription request parameters must be an object.",
));
};
parameters_map.insert("system".to_string(), system.into());
if !tools.is_empty() {
parameters_map.insert("tools".to_string(), tools.into());
}
request.additional_params = Some(parameters);
Ok(aliases)
}
pub(crate) fn restore_tool_names(event: &mut AgentEvent, aliases: &HashMap<String, String>) {
let name = match event {
AgentEvent::Tool {
event: ToolEvent::Proposed { call },
} => Some(&mut call.name),
AgentEvent::ToolCallProgress { name, .. } => name.as_mut(),
_ => None,
};
if let Some(name) = name {
// Pi matches against the advertised tools case-insensitively on return.
if let Some(original) = aliases
.iter()
.find_map(|(wire, original)| wire.eq_ignore_ascii_case(name).then_some(original))
{
name.clone_from(original);
}
}
}
fn subscription_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
headers.insert(
"anthropic-beta",
HeaderValue::from_static("claude-code-20250219,oauth-2025-04-20"),
);
headers.insert(
"user-agent",
HeaderValue::from_static(PI_CLAUDE_CODE_USER_AGENT),
);
headers.insert(
"anthropic-dangerous-direct-browser-access",
HeaderValue::from_static("true"),
);
headers.insert("x-app", HeaderValue::from_static("cli"));
headers.insert("accept", HeaderValue::from_static("application/json"));
headers
}
#[cfg(test)]
#[path = "anthropic_auth_tests.rs"]
mod tests;
@@ -0,0 +1,311 @@
use galaxy_agent_core::{
ConversationMessage, MessageContent, MessageRole, ToolDefinition, TurnRequest,
};
use super::*;
use crate::request::build_completion_request;
#[test]
fn subscription_auth_is_bearer_only_and_redacted() {
let token = "sk-ant-oat01-test-subscription-token";
let client = anthropic_client(token).expect("subscription client");
assert_eq!(client.headers()["authorization"], format!("Bearer {token}"));
assert!(client.headers()["authorization"].is_sensitive());
assert!(!client.headers().contains_key("x-api-key"));
assert_eq!(
client.headers()["anthropic-beta"],
"claude-code-20250219,oauth-2025-04-20"
);
assert_eq!(client.headers()["user-agent"], "claude-cli/2.1.280");
assert_eq!(
client.headers()["anthropic-dangerous-direct-browser-access"],
"true"
);
assert_eq!(client.headers()["x-app"], "cli");
assert_eq!(client.headers()["accept"], "application/json");
assert!(!format!("{client:?}").contains(token));
let config = crate::AnthropicRuntimeConfig {
api_key: token.to_string(),
model: "claude-test".to_string(),
max_output_tokens: None,
};
assert!(!format!("{config:?}").contains(token));
}
#[test]
fn api_key_keeps_normal_authentication() {
let client = anthropic_client("sk-ant-api03-test-key").expect("API client");
assert_eq!(client.headers()["x-api-key"], "sk-ant-api03-test-key");
assert!(!client.headers().contains_key("authorization"));
}
#[test]
fn subscription_request_keeps_galaxy_prompt_and_tool_names() {
let mut turn = TurnRequest::new(
"claude-test".to_string(),
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Fix the bug".to_string()),
}],
);
turn.system_prompt = Some("Use Galaxy discovery and permissions.".to_string());
turn.tools = vec![ToolDefinition {
name: "search_codebase".to_string(),
description: "Search Galaxy's index".to_string(),
input_schema: serde_json::json!({"type":"object"}),
}];
let mut request = build_completion_request(turn, Some(4096), true, true, None).unwrap();
prepare_subscription_request(&mut request).unwrap();
let history = request.chat_history.iter().collect::<Vec<_>>();
let parameters = request.additional_params.as_ref().unwrap();
assert_eq!(parameters["system"][0]["text"], CLAUDE_CODE_IDENTITY);
assert_eq!(
parameters["system"][1]["text"],
"Use Galaxy discovery and permissions."
);
assert_eq!(parameters["tools"][0]["name"], "search_codebase");
assert_eq!(parameters["tools"][0]["eager_input_streaming"], true);
assert!(request.tools.is_empty());
assert_eq!(request.max_tokens, Some(4096));
assert_eq!(history.len(), 1);
}
#[test]
fn pi_tool_casing_round_trips_without_changing_galaxy_execution_identity() {
let mut turn = TurnRequest::new(
"claude-test".to_string(),
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Inspect".to_string()),
}],
);
turn.tools = vec![ToolDefinition {
name: "grep".to_string(),
description: "Search".to_string(),
input_schema: serde_json::json!({"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}),
}];
let mut request = build_completion_request(turn, Some(4096), true, true, None).unwrap();
let aliases = prepare_subscription_request(&mut request).unwrap();
assert_eq!(
request.additional_params.as_ref().unwrap()["tools"][0]["name"],
"Grep"
);
let mut event = AgentEvent::Tool {
event: ToolEvent::Proposed {
call: galaxy_agent_core::ToolCall {
id: "toolu_123".to_string(),
name: "Grep".to_string(),
arguments: json!({"query":"foo"}),
},
},
};
restore_tool_names(&mut event, &aliases);
assert!(
matches!(event, AgentEvent::Tool { event: ToolEvent::Proposed { call } }
if call.name == "grep" && call.id == "toolu_123")
);
let mut progress = AgentEvent::ToolCallProgress {
call_id: "toolu_123".to_string(),
name: Some("GREP".to_string()),
arguments_bytes: 42,
};
restore_tool_names(&mut progress, &aliases);
assert!(
matches!(progress, AgentEvent::ToolCallProgress { name: Some(name), .. } if name == "grep")
);
}
#[test]
fn pi_call_id_normalization_preserves_tool_result_pairing() {
let mut turn = TurnRequest::new(
"claude-test".to_string(),
vec![
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::ToolUse {
tool_use_id: "tool.call/1".to_string(),
name: "grep".to_string(),
input: json!({}),
},
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: "tool.call/1".to_string(),
content: "found".to_string(),
is_error: false,
},
},
],
);
turn.system_prompt = Some("Galaxy".to_string());
let mut request = build_completion_request(turn, Some(4096), true, true, None).unwrap();
prepare_subscription_request(&mut request).unwrap();
for message in request.chat_history.iter() {
match message {
Message::Assistant { content, .. } => {
let AssistantContent::ToolCall(call) = content.first() else {
panic!("tool call");
};
assert_eq!(call.function.name, "Grep");
assert_eq!(call.call_id.as_deref(), Some("tool_call_1"));
}
Message::User { content } => {
let UserContent::ToolResult(result) = content.first() else {
panic!("tool result");
};
assert_eq!(result.call_id.as_deref(), Some("tool_call_1"));
}
_ => panic!("unexpected system message on wire history"),
}
}
}
#[tokio::test]
async fn serialized_subscription_request_matches_pi_auth_and_tool_profile() {
use futures::StreamExt;
use rig_core::client::CompletionClient;
use rig_core::completion::CompletionModel;
let http_client = RecordingStreamingClient::new(
r#"event: message_start
data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-test","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":0}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}
event: message_stop
data: {"type":"message_stop"}
"#,
);
let client = Client::<SubscriptionProvider>::builder()
.api_key(SubscriptionToken("sk-ant-oat01-test".to_string()))
.http_headers(subscription_headers())
.http_client(http_client.clone())
.build()
.unwrap();
let mut turn = TurnRequest::new(
"claude-test".to_string(),
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Inspect".to_string()),
}],
);
turn.system_prompt = Some("Galaxy discovery rules".to_string());
turn.tools = vec![ToolDefinition {
name: "read".to_string(),
description: "Read".to_string(),
input_schema: json!({"type":"object","properties":{},"required":[]}),
}];
let mut request = build_completion_request(turn, Some(4096), true, true, None).unwrap();
prepare_subscription_request(&mut request).unwrap();
let mut stream = client
.completion_model("claude-test")
.with_prompt_caching()
.stream(request)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.unwrap();
}
let requests = http_client.recording.requests();
assert_eq!(requests.len(), 1);
let sent = &requests[0];
assert_eq!(sent.headers["user-agent"], "claude-cli/2.1.280");
assert_eq!(sent.headers["accept"], "application/json");
assert_eq!(
sent.headers["anthropic-dangerous-direct-browser-access"],
"true"
);
assert_eq!(
sent.headers["anthropic-beta"],
"claude-code-20250219,oauth-2025-04-20"
);
assert!(!sent.headers.contains_key("x-api-key"));
let body: serde_json::Value = serde_json::from_slice(&sent.body).unwrap();
assert_eq!(body["stream"], true);
assert_eq!(body["system"].as_array().unwrap().len(), 2);
assert_eq!(body["system"][0]["text"], CLAUDE_CODE_IDENTITY);
assert_eq!(body["system"][1]["text"], "Galaxy discovery rules");
assert_eq!(body["tools"].as_array().unwrap().len(), 1);
assert_eq!(body["tools"][0]["name"], "Read");
assert_eq!(body["tools"][0]["eager_input_streaming"], true);
assert_eq!(body["tools"][0]["cache_control"]["type"], "ephemeral");
}
// Rig's unary recording client does not implement streaming. Compose it with the
// SSE fixture client to capture the actual streaming payload and headers.
#[derive(Clone, Default)]
struct RecordingStreamingClient {
recording: rig_core::test_utils::RecordingHttpClient,
streaming: rig_core::test_utils::MockStreamingClient,
}
impl RecordingStreamingClient {
fn new(sse: &'static str) -> Self {
Self {
recording: rig_core::test_utils::RecordingHttpClient::default(),
streaming: rig_core::test_utils::MockStreamingClient {
sse_bytes: bytes::Bytes::from_static(sse.as_bytes()),
},
}
}
}
impl HttpClientExt for RecordingStreamingClient {
fn send<T, U>(
&self,
request: http_client::Request<T>,
) -> impl std::future::Future<
Output = http_client::Result<http_client::Response<http_client::LazyBody<U>>>,
> + rig_core::wasm_compat::WasmCompatSend
+ 'static
where
T: Into<bytes::Bytes> + rig_core::wasm_compat::WasmCompatSend,
U: From<bytes::Bytes> + rig_core::wasm_compat::WasmCompatSend + 'static,
{
self.recording.send(request)
}
fn send_multipart<U>(
&self,
request: http_client::Request<http_client::MultipartForm>,
) -> impl std::future::Future<
Output = http_client::Result<http_client::Response<http_client::LazyBody<U>>>,
> + rig_core::wasm_compat::WasmCompatSend
+ 'static
where
U: From<bytes::Bytes> + rig_core::wasm_compat::WasmCompatSend + 'static,
{
self.recording.send_multipart(request)
}
fn send_streaming<T>(
&self,
request: http_client::Request<T>,
) -> impl std::future::Future<Output = http_client::Result<http_client::StreamingResponse>>
+ rig_core::wasm_compat::WasmCompatSend
where
T: Into<bytes::Bytes> + rig_core::wasm_compat::WasmCompatSend,
{
let recorded = self
.recording
.send::<bytes::Bytes, bytes::Bytes>(request.map(Into::into));
let streaming = self.streaming.clone();
async move {
recorded.await?;
streaming
.send_streaming(http_client::Request::new(bytes::Bytes::new()))
.await
}
}
}
+2
View File
@@ -1,5 +1,6 @@
//! Rig-backed implementations of Galaxy's provider-neutral agent runtime. //! Rig-backed implementations of Galaxy's provider-neutral agent runtime.
mod anthropic_auth;
mod bedrock; mod bedrock;
mod chatgpt; mod chatgpt;
mod native; mod native;
@@ -7,6 +8,7 @@ mod openai_compatible;
mod request; mod request;
mod stream; mod stream;
pub use anthropic_auth::is_claude_subscription_token;
pub use bedrock::*; pub use bedrock::*;
pub use chatgpt::*; pub use chatgpt::*;
pub use native::*; pub use native::*;
+78 -11
View File
@@ -1,11 +1,16 @@
use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use futures::StreamExt;
use futures::future::BoxFuture;
use galaxy_agent_core::{ use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, RuntimeCapabilities, AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, RuntimeCapabilities,
RuntimeDescriptor, RuntimeKind, TurnControl, TurnRequest, RuntimeDescriptor, RuntimeKind, TurnControl, TurnRequest,
}; };
use rig_core::client::{CompletionClient, ModelListingClient}; 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::request::build_completion_request;
use crate::stream::{start_model_completion, start_model_turn}; 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> { 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 let models = client
.list_models() .list_models()
.await .await
@@ -84,23 +89,62 @@ pub fn validate_vertex_ai_credentials(project_id: &str, location: &str) -> Resul
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
} }
#[derive(Clone, Debug)] #[derive(Clone)]
pub struct AnthropicRuntimeConfig { pub struct AnthropicRuntimeConfig {
pub api_key: String, pub api_key: String,
pub model: String, pub model: String,
pub max_output_tokens: Option<u64>, 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 { pub struct AnthropicRuntime {
config: AnthropicRuntimeConfig, config: AnthropicRuntimeConfig,
descriptor: RuntimeDescriptor, 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 { impl AnthropicRuntime {
pub fn new(config: AnthropicRuntimeConfig) -> Self { pub fn new(config: AnthropicRuntimeConfig) -> Self {
let descriptor = native_descriptor("anthropic", &config.model); 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, request: TurnRequest,
control: TurnControl, control: TurnControl,
) -> Result<AgentEventStream, AgentError> { ) -> 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( return Err(AgentError::new(
AgentErrorKind::Configuration, 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()))?; .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 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)?; 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::*; use super::*;
#[test] #[test]
@@ -74,7 +74,7 @@ pub fn capture_frame(
#[cfg(test)] #[cfg(test)]
pub(crate) fn convert_bgra_to_rgba(data: &mut [u8]) { 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); chunk.swap(0, 2);
} }
} }
@@ -934,7 +934,9 @@ fn caret_positions_for_line(
// edges and RTL text. // edges and RTL text.
positions.sort_unstable_by_key(|position| position.utf16_index); positions.sort_unstable_by_key(|position| position.utf16_index);
let mut carets: Vec<_> = positions let mut carets: Vec<_> = positions
.chunks_exact(2) .as_chunks::<2>()
.0
.iter()
.map(|edges| { .map(|edges| {
// Guaranteed by chunks_exact that there are 2 elements. // Guaranteed by chunks_exact that there are 2 elements.
let first = &edges[0]; let first = &edges[0];
@@ -265,7 +265,7 @@ fn capture_surface_texture(
staging_buffer.unmap(); staging_buffer.unmap();
if format == wgpu::TextureFormat::Bgra8Unorm || format == wgpu::TextureFormat::Bgra8UnormSrgb { 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); chunk.swap(0, 2);
} }
} }
@@ -14,7 +14,7 @@ use string_offset::CharOffset;
use crate::color::ColorU; use crate::color::ColorU;
pub use crate::elements::shimmering_text::config::ShimmerConfig; pub use crate::elements::shimmering_text::config::ShimmerConfig;
use crate::elements::shimmering_text::glyph_index::GlyphIndex; 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::fonts::{FamilyId, Properties};
use crate::geometry::rect::RectF; use crate::geometry::rect::RectF;
use crate::geometry::vector::{vec2f, Vector2F}; use crate::geometry::vector::{vec2f, Vector2F};
@@ -90,6 +90,7 @@ pub struct ShimmeringTextElement {
origin: Option<Point>, origin: Option<Point>,
handle: ShimmeringTextStateHandle, handle: ShimmeringTextStateHandle,
leading_icon: Option<(Icon, f32, f32)>,
} }
impl ShimmeringTextElement { impl ShimmeringTextElement {
@@ -112,9 +113,16 @@ impl ShimmeringTextElement {
size: None, size: None,
origin: None, origin: None,
handle: state_handle, 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". /// 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> { fn shimmer_center(&self, number_of_glyphs: usize, state: &StateInternal) -> GlyphIndex<f32> {
if number_of_glyphs <= 1 { if number_of_glyphs <= 1 {
@@ -165,11 +173,12 @@ impl ShimmeringTextElement {
return PaintStyleOverride::default(); 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(); let mut overrides = RangeMap::new();
for (glyph_index, char_index) in glyph_indices_in_order.iter() { 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 let color = self
.base_color .base_color
.to_f32() .to_f32()
@@ -189,6 +198,20 @@ impl Element for ShimmeringTextElement {
ctx: &mut crate::LayoutContext, ctx: &mut crate::LayoutContext,
app: &AppContext, app: &AppContext,
) -> Vector2F { ) -> 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 mut state = self.handle.get();
let text_len = self.text.chars().count(); let text_len = self.text.chars().count();
@@ -197,7 +220,7 @@ impl Element for ShimmeringTextElement {
StyleAndFont::new(self.font_family, Properties::default(), TextStyle::new()), 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 { let layout_key = LayoutKey {
text: self.text.clone(), text: self.text.clone(),
@@ -247,15 +270,22 @@ impl Element for ShimmeringTextElement {
}; };
let size = vec2f( let size = vec2f(
line.width.max(constraint.min.x()).min(constraint.max.x()), (line.width + icon_width)
line.height(), .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); self.size = Some(size);
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) { fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
/// Duration, in ms, for which to repaint. Approximately 30fps. /// 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)); 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(); let style_overrides = self.build_color_overrides();
line.paint( line.paint(
@@ -475,9 +475,7 @@ impl TableStateHandle {
/// and you want all columns to resize to fit their new content. /// and you want all columns to resize to fit their new content.
pub fn invalidate_all_intrinsic_widths(&self) { pub fn invalidate_all_intrinsic_widths(&self) {
let mut state = self.inner.borrow_mut(); let mut state = self.inner.borrow_mut();
for width in &mut state.intrinsic_column_widths { state.intrinsic_column_widths.fill(None);
*width = None;
}
} }
/// Scroll to a specific row with an optional offset. /// Scroll to a specific row with an optional offset.
@@ -497,7 +497,9 @@ fn test_svg_text_rasterizes_with_loaded_system_fonts() {
assert!(image assert!(image
.rgba_bytes() .rgba_bytes()
.chunks_exact(4) .as_chunks::<4>()
.0
.iter()
.any(|pixel| pixel[3] != 0)); .any(|pixel| pixel[3] != 0));
} }
@@ -534,7 +536,9 @@ fn test_svg_text_rasterizes_with_bundled_sans_serif_fallback() {
assert!(image assert!(image
.rgba_bytes() .rgba_bytes()
.chunks_exact(4) .as_chunks::<4>()
.0
.iter()
.any(|pixel| pixel[3] != 0)); .any(|pixel| pixel[3] != 0));
} }
@@ -382,7 +382,7 @@ cfg_if::cfg_if! {
use crate::platform::CapturedFrameFormat; use crate::platform::CapturedFrameFormat;
let pixel_count = data.len() / 4; let pixel_count = data.len() / 4;
let mut rgb = Vec::with_capacity(pixel_count * 3); 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 { match format {
CapturedFrameFormat::Rgba => { CapturedFrameFormat::Rgba => {
rgb.push(chunk[0]); 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> { fn rgba_to_rgb(rgba: &[u8]) -> Vec<u8> {
let pixel_count = rgba.len() / 4; let pixel_count = rgba.len() / 4;
let mut rgb = Vec::with_capacity(pixel_count * 3); 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[0]);
rgb.push(chunk[1]); rgb.push(chunk[1]);
rgb.push(chunk[2]); rgb.push(chunk[2]);
+1 -1
View File
@@ -529,7 +529,7 @@ impl CapturedFrame {
pub fn ensure_rgba(&mut self) { pub fn ensure_rgba(&mut self) {
if self.format == CapturedFrameFormat::Bgra { 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); chunk.swap(0, 2);
} }
self.format = CapturedFrameFormat::Rgba; self.format = CapturedFrameFormat::Rgba;
-3
View File
@@ -1,6 +1,3 @@
#[cfg(unix)]
use std::io::{Read as _, Write as _};
#[cfg(unix)] #[cfg(unix)]
use chrono::Duration; use chrono::Duration;
use chrono::Utc; use chrono::Utc;