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
+22
View File
@@ -189,6 +189,8 @@ fn preset_version(agent_id: &str, custom_command: &str) -> Option<&'static str>
match agent_id.trim().to_ascii_lowercase().as_str() {
"codex" => Some(CODEX_ACP_NPM_VERSION),
"opencode" => Some(OPENCODE_NPM_VERSION),
#[cfg(feature = "local_fs")]
"claude-acp" => Some(super::setup::CLAUDE_ACP_VERSION),
_ => None,
}
}
@@ -241,6 +243,26 @@ pub(crate) fn resolve_acp_launch(
match agent_id.trim().to_ascii_lowercase().as_str() {
"codex" => AcpAgentPreset::Codex.resolve_launch_config(),
"opencode" => AcpAgentPreset::OpenCode.resolve_launch_config(),
#[cfg(feature = "local_fs")]
"claude-acp" => super::setup::resolve_claude_code_launch(),
// Removed from the setup catalog, but keep saved connections and their
// conversation launch identities usable rather than silently migrating
// an existing session to Claude Code.
"gemini" | "cline" => AcpLaunchConfig::new(agent_id.trim().to_ascii_lowercase())
.args(["--acp"])
.resolve_command(),
"cursor" => AcpLaunchConfig::new("cursor-agent")
.args(["acp"])
.resolve_command(),
"github-copilot-cli" => AcpLaunchConfig::new("copilot")
.args(["--acp"])
.resolve_command(),
"goose" => AcpLaunchConfig::new("goose")
.args(["acp"])
.resolve_command(),
"auggie" => AcpLaunchConfig::new("auggie")
.args(["acp"])
.resolve_command(),
_ => resolve_known_acp_agent(agent_id).map_err(|error| {
format!(
"{error} Configure a custom ACP executable if this client uses a different command."
+4
View File
@@ -8,6 +8,8 @@ mod launch;
mod permissions;
mod prompt;
mod runtime_model;
#[cfg(feature = "local_fs")]
mod setup;
mod transport;
pub(crate) use launch::{
@@ -17,6 +19,8 @@ pub(crate) use launch::{
};
pub(crate) use permissions::resolve_acp_permissions;
pub(crate) use runtime_model::{AcpDiscoveryState, AcpRuntimeModel};
#[cfg(feature = "local_fs")]
pub(crate) use setup::ensure_claude_code_installed;
pub(crate) use transport::{
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, AcpSessionMetadata,
AcpTurnControlSlot, GalaxyMcpTarget,
+282
View File
@@ -0,0 +1,282 @@
//! Explicit Claude Code setup. Downloads happen only when the user tests a
//! connection; normal launches resolve installed components without network I/O.
use std::path::{Component, Path, PathBuf};
use std::process::Output;
use std::time::Duration;
use command::r#async::Command;
use futures::future::{select, Either};
use galaxy_acp::AcpLaunchConfig;
use tokio::sync::Mutex;
// Keep the adapter and the official native CLI installation reproducible.
// npm verifies package integrity and selects Anthropic's platform binary.
pub(super) const CLAUDE_ACP_VERSION: &str = "0.85.1";
const CLAUDE_CODE_VERSION: &str = "2.1.288";
static SETUP_LOCK: Mutex<()> = Mutex::const_new(());
fn installation_dir() -> PathBuf {
galaxy_core::paths::data_dir()
.join("acp")
.join("claude-code")
}
fn installed_command(command: &str) -> Option<PathBuf> {
AcpLaunchConfig::new(command)
.resolve_command()
.ok()
.map(|launch| launch.command)
}
fn package_binary(package_dir: &Path) -> Option<PathBuf> {
let manifest: serde_json::Value =
serde_json::from_slice(&std::fs::read(package_dir.join("package.json")).ok()?).ok()?;
let binary = manifest.get("bin")?.get("claude")?.as_str()?;
let binary = Path::new(binary);
if binary.is_absolute()
|| binary
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return None;
}
let binary = package_dir.join(binary);
binary.is_file().then_some(binary)
}
fn claude_binary() -> Option<PathBuf> {
installed_command("claude")
.or_else(|| {
let home = std::env::var_os("HOME")?;
let path = PathBuf::from(home).join(".local/bin/claude");
path.is_file().then_some(path)
})
.or_else(|| {
package_binary(&installation_dir().join("node_modules/@anthropic-ai/claude-code"))
})
}
fn adapter_script() -> PathBuf {
installation_dir().join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js")
}
fn managed_adapter_ready(directory: &Path) -> bool {
directory
.join(format!("ready-{CLAUDE_ACP_VERSION}"))
.is_file()
&& directory
.join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js")
.is_file()
}
pub(super) fn resolve_claude_code_launch() -> Result<AcpLaunchConfig, String> {
let claude = claude_binary().ok_or_else(|| {
"Claude Code is not installed. Test the Claude Code connection in Settings to download it."
.to_owned()
})?;
let launch = if let Some(adapter) = installed_command("claude-agent-acp") {
AcpLaunchConfig::new(adapter)
} else {
let script = adapter_script();
if !managed_adapter_ready(&installation_dir()) {
return Err("The Claude Code ACP adapter is missing. Test the connection in Settings to download it.".to_owned());
}
let managed_node = node_runtime::node_binary_path().map_err(|error| error.to_string())?;
let node = if managed_node.is_file() {
managed_node
} else {
installed_command("node").ok_or_else(|| "Claude Code's ACP adapter needs Node.js. Test the connection in Settings to install it.".to_owned())?
};
AcpLaunchConfig::new(node).args([script.to_string_lossy().into_owned()])
};
let mut path = launch
.command
.parent()
.into_iter()
.map(Path::to_owned)
.collect::<Vec<_>>();
if let Ok(node) = node_runtime::node_binary_path() {
if node.is_file() {
if let Some(directory) = node.parent() {
path.insert(0, directory.to_owned());
}
}
}
if let Some(existing) = std::env::var_os("PATH") {
path.extend(std::env::split_paths(&existing));
}
let path = std::env::join_paths(path).map_err(|error| error.to_string())?;
Ok(launch
// The adapter must use this unmodified binary, rather than silently
// selecting another SDK-bundled CLI. Auth stays entirely in Claude Code.
.env("CLAUDE_CODE_EXECUTABLE", claude.to_string_lossy())
.env("PATH", path.to_string_lossy()))
}
/// Prepare missing components and authenticate with Claude Code's own browser
/// flow. Never read its token store or include auth command output in logs.
pub(crate) async fn ensure_claude_code_installed(
progress: async_channel::Sender<&'static str>,
) -> Result<(), String> {
let _lock = SETUP_LOCK.lock().await;
let needs_cli = claude_binary().is_none();
let needs_adapter = installed_command("claude-agent-acp").is_none()
&& !managed_adapter_ready(&installation_dir());
if needs_cli || needs_adapter {
let _ = progress.try_send("Downloading missing Claude Code components…");
log::info!("Claude Code setup: missing_cli={needs_cli}, missing_adapter={needs_adapter}");
// Reuse Galaxy's private Node runtime; no global install or administrator
// permission is needed, even when Node/npm are absent from the user's PATH.
let client = http_client::Client::new();
match select(
Box::pin(node_runtime::install_npm(&client)),
Box::pin(async_io::Timer::after(Duration::from_secs(300))),
)
.await
{
Either::Left((result, _)) => {
result.map_err(|error| {
format!("Could not install Claude Code's Node runtime: {error}")
})?;
}
Either::Right(_) => {
return Err(
"Downloading Claude Code's Node runtime timed out. Test again to retry."
.to_owned(),
)
}
}
let node = node_runtime::node_binary_path().map_err(|error| error.to_string())?;
let npm = node_runtime::npm_binary_path().map_err(|error| error.to_string())?;
let directory = installation_dir();
async_fs::create_dir_all(&directory)
.await
.map_err(|error| error.to_string())?;
let mut command = Command::new(&node);
command
.arg(npm)
.args([
"install",
"--registry=https://registry.npmjs.org",
"--ignore-scripts=false",
"--no-audit",
"--no-fund",
"--save-exact",
])
.arg("--prefix")
.arg(&directory)
.current_dir(&directory);
if needs_cli {
command.arg(format!("@anthropic-ai/claude-code@{CLAUDE_CODE_VERSION}"));
}
if needs_adapter {
command.arg(format!(
"@agentclientprotocol/claude-agent-acp@{CLAUDE_ACP_VERSION}"
));
}
let mut paths = vec![node
.parent()
.ok_or("Node runtime has no directory")?
.to_owned()];
if let Some(path) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&path));
}
command.env(
"PATH",
std::env::join_paths(paths).map_err(|error| error.to_string())?,
);
let output = run_command(&mut command, Duration::from_secs(300)).await?;
if !output.status.success() {
// Installation runs no model request and has no Claude credentials.
let error = String::from_utf8_lossy(&output.stderr);
let detail = error.chars().take(2000).collect::<String>();
return Err(format!(
"Could not download Claude Code components: {detail}"
));
}
if needs_adapter {
if !adapter_script().is_file() {
return Err("The Claude Code adapter download completed without its executable. Test again to retry.".to_owned());
}
// A cancelled/failed npm install may leave the entrypoint behind.
// Only a completed install is eligible for future launches.
async_fs::write(
directory.join(format!("ready-{CLAUDE_ACP_VERSION}")),
b"ready",
)
.await
.map_err(|error| error.to_string())?;
}
}
let launch = resolve_claude_code_launch()?;
let claude = launch
.env
.get("CLAUDE_CODE_EXECUTABLE")
.ok_or("Claude Code executable is missing")?;
let _ = progress.try_send("Checking Claude Code sign-in…");
let status = run_command(
claude_auth_command(claude).args(["auth", "status", "--json"]),
Duration::from_secs(15),
)
.await?;
let status_json: serde_json::Value = serde_json::from_slice(&status.stdout)
.map_err(|_| "Claude Code could not report its login status. Run `claude auth login`, then test again.".to_owned())?;
if !status.status.success()
|| status_json
.get("loggedIn")
.and_then(serde_json::Value::as_bool)
!= Some(true)
{
let _ = progress.try_send("Opening Claude Code sign-in in your browser…");
let login = run_command(
claude_auth_command(claude).args(["auth", "login", "--claudeai"]),
Duration::from_secs(300),
)
.await?;
if !login.status.success() {
return Err("Claude Code sign-in did not complete. Complete sign-in through Claude Code and test again.".to_owned());
}
}
let _ = progress.try_send("Discovering Claude Code models…");
log::info!("Claude Code setup: components ready; starting ACP discovery");
Ok(())
}
fn claude_auth_command(claude: &str) -> Command {
let mut command = Command::new(claude);
// Match the ACP launch's sanitized environment: an inherited API key or
// provider redirect must not make setup report a subscription as signed in.
for variable in [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CONFIG_DIR",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
] {
command.env_remove(variable);
}
command
}
async fn run_command(command: &mut Command, timeout: Duration) -> Result<Output, String> {
command.kill_on_drop(true);
match select(
Box::pin(command.output()),
Box::pin(async_io::Timer::after(timeout)),
)
.await
{
Either::Left((result, _)) => result.map_err(|error| error.to_string()),
Either::Right(_) => {
Err("Claude Code setup timed out. Test the connection again to retry.".to_owned())
}
}
}
#[cfg(test)]
#[path = "setup_tests.rs"]
mod tests;
+52
View File
@@ -0,0 +1,52 @@
use super::*;
#[test]
fn incomplete_adapter_installation_is_not_launchable() {
let directory = tempfile::tempdir().unwrap();
let script = directory
.path()
.join("node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(&script, b"entrypoint").unwrap();
assert!(!managed_adapter_ready(directory.path()));
std::fs::write(
directory.path().join(format!("ready-{CLAUDE_ACP_VERSION}")),
b"ready",
)
.unwrap();
assert!(managed_adapter_ready(directory.path()));
std::fs::remove_file(script).unwrap();
assert!(!managed_adapter_ready(directory.path()));
}
#[test]
fn official_package_binary_is_resolved_from_its_manifest() {
let directory = tempfile::tempdir().unwrap();
std::fs::create_dir(directory.path().join("bin")).unwrap();
std::fs::write(directory.path().join("bin/claude.exe"), b"binary").unwrap();
std::fs::write(
directory.path().join("package.json"),
r#"{"bin":{"claude":"bin/claude.exe"}}"#,
)
.unwrap();
assert_eq!(
package_binary(directory.path()),
Some(directory.path().join("bin/claude.exe"))
);
}
#[test]
fn package_binary_cannot_escape_the_installation() {
let directory = tempfile::tempdir().unwrap();
let package = directory.path().join("package");
std::fs::create_dir(&package).unwrap();
std::fs::write(directory.path().join("claude"), b"outside").unwrap();
for binary in ["../claude", "/bin/sh", "missing"] {
std::fs::write(
package.join("package.json"),
serde_json::json!({"bin": {"claude": binary}}).to_string(),
)
.unwrap();
assert_eq!(package_binary(&package), None);
}
}
@@ -333,17 +333,16 @@ impl SearchCodebaseExecutor {
));
};
let search_dir;
let is_cross_repo;
if FeatureFlag::CrossRepoContext.is_enabled() {
is_cross_repo = codebase_path
.as_ref()
.is_some_and(|path| !current_working_directory.starts_with(path));
search_dir = codebase_path.unwrap_or(current_working_directory);
let (is_cross_repo, search_dir) = if FeatureFlag::CrossRepoContext.is_enabled() {
(
codebase_path
.as_ref()
.is_some_and(|path| !current_working_directory.starts_with(path)),
codebase_path.unwrap_or(current_working_directory),
)
} else {
is_cross_repo = false;
search_dir = current_working_directory;
}
(false, current_working_directory)
};
let server_output_id = get_server_output_id(conversation_id, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::SearchCodebaseRequested {
+9 -26
View File
@@ -79,7 +79,7 @@ use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
use crate::ai::blocklist::secret_redaction::{redact_secrets_in_element, SecretRedactionState};
use crate::ai::blocklist::view_util::error_color;
use crate::ai::blocklist::{BlocklistAIActionModel, ShellCommandExecutor, TextLocation};
use crate::ai::loading::shimmering_warp_loading_text;
use crate::ai::loading::{loading_logo_size, shimmering_warp_loading_text, LOADING_LOGO_GAP};
use crate::ai::AIRequestUsageModel;
use crate::code::editor::view::CodeEditorView;
use crate::code::editor_management::CodeSource;
@@ -625,14 +625,9 @@ pub fn render_warping_indicator_base(
// will be rendered on a second line below the warping text. Captured before
// `secondary_element` is consumed so the container can reserve room for it.
let has_secondary_element = secondary_element.is_some();
// Unicode code point for the Warp glyph that is embedded in the version of Roboto we bundle
// into the app. This code point MUST be rendered using Roboto (the default ui font) or else the
// glyph may not be rendered.
const WARP_GLYPH: &str = "\u{E500}";
let appearance = Appearance::as_ref(app);
let should_indent_tip_for_warp_glyph = matches!(
let should_indent_tip_for_logo = matches!(
warping_indicator_text,
MaybeShimmeringText::Shimmering { .. }
);
@@ -640,7 +635,9 @@ pub fn render_warping_indicator_base(
let text = render_output_status_text(warping_indicator_text, appearance, app);
let text_content = {
let mut row = Flex::row().with_child(Shrinkable::new(1., text).finish());
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., text).finish());
if let Some(non_shimmering) = non_shimmering_text {
let additional = render_output_status_text(
@@ -660,25 +657,11 @@ pub fn render_warping_indicator_base(
let mut text_col = Flex::column();
if let Some(sub_element) = secondary_element {
// Our warping indicator text prepends the Warp glyph (and a space) to the label.
// If we render the tip directly underneath, it will align to the glyph instead of
// the start of the actual warping text.
let sub_element = if should_indent_tip_for_warp_glyph {
// Align the tip with the text after the Galaxy logo and its spacing.
let sub_element = if should_indent_tip_for_logo {
let font_size = appearance.monospace_font_size() - 3.;
let glyph_indent = Text::new_inline(
format!("{WARP_GLYPH} "),
appearance.ui_font_family(),
font_size,
)
.with_color(ColorU::new(0, 0, 0, 0))
.with_selectable(false)
.soft_wrap(false)
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(glyph_indent)
.with_child(Shrinkable::new(1., sub_element).finish())
Container::new(Shrinkable::new(1., sub_element).finish())
.with_margin_left(loading_logo_size(font_size) + LOADING_LOGO_GAP)
.finish()
} else {
Shrinkable::new(1., sub_element).finish()
+443
View File
@@ -0,0 +1,443 @@
//! Pi-compatible Claude subscription OAuth. Secrets are never logged or serialized into settings.
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct Credentials {
pub(crate) access_token: String,
refresh_token: String,
expires_at: u64,
}
#[cfg(not(target_family = "wasm"))]
pub(crate) use desktop::*;
#[cfg(not(target_family = "wasm"))]
mod desktop {
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use futures::channel::oneshot;
use futures::StreamExt;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use galaxyui_extras::secure_storage::AppContextExt as _;
use instant::Instant;
use rand::RngCore;
use settings::Setting;
use sha2::{Digest, Sha256};
use url::Url;
use super::Credentials;
use crate::ai::claude_subscription::{is_stored_credential, OAUTH_REFERENCE_PREFIX};
use crate::settings::AISettings;
// Pi's browser flow, checked 2026-10-04:
// https://github.com/earendil-works/pi/blob/main/packages/ai/src/auth/oauth/anthropic.ts
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const AUTHORIZE_URL: &str = "https://claude.ai/oauth/authorize";
const TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
const REDIRECT_URI: &str = "http://localhost:53692/callback";
const SCOPES: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(5 * 60);
type Reply = oneshot::Sender<Result<String, String>>;
enum Command {
Resolve(String, Reply),
Invalidate(String),
DiscardDraft(String),
}
static SENDER: OnceLock<Mutex<Option<async_channel::Sender<Command>>>> = OnceLock::new();
fn send(command: Command) -> Result<(), String> {
SENDER
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap()
.as_ref()
.ok_or("Claude authorization service is unavailable.")?
.try_send(command)
.map_err(|_| "Claude authorization service is busy or unavailable.".to_string())
}
pub(crate) fn invalidate(reference: &str) {
let _ = send(Command::Invalidate(reference.to_string()));
}
pub(crate) fn discard_draft(reference: &str) {
if reference.starts_with(OAUTH_REFERENCE_PREFIX) {
let _ = send(Command::DiscardDraft(reference.to_string()));
}
}
pub(crate) async fn load_credential(reference: String) -> Result<String, String> {
if !is_stored_credential(&reference) {
return Ok(reference);
}
let (tx, rx) = oneshot::channel();
send(Command::Resolve(reference, tx))?;
let result = futures::future::select(
Box::pin(rx),
Box::pin(async_io::Timer::after(Duration::from_secs(35))),
)
.await;
match result {
futures::future::Either::Left((result, _)) => {
result.map_err(|_| "Claude authorization service stopped.".to_string())?
}
futures::future::Either::Right(_) => {
Err("Claude token refresh timed out. Try again or sign in again.".to_string())
}
}
}
struct Refresh {
id: uuid::Uuid,
replies: Vec<Reply>,
}
/// Marshals secure-storage access to the UI thread and coalesces concurrent refreshes.
pub(crate) struct ClaudeOAuthModel {
refreshing: HashMap<String, Refresh>,
}
impl Entity for ClaudeOAuthModel {
type Event = ();
}
impl SingletonEntity for ClaudeOAuthModel {}
impl ClaudeOAuthModel {
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
let (tx, rx) = async_channel::bounded(64);
*SENDER.get_or_init(|| Mutex::new(None)).lock().unwrap() = Some(tx);
ctx.spawn_stream_local(
rx,
|me, command, ctx| match command {
Command::Resolve(reference, reply) => me.resolve(reference, reply, ctx),
Command::Invalidate(reference) => me.invalidate(&reference),
Command::DiscardDraft(reference) => {
let saved = AISettings::as_ref(ctx)
.openai_providers
.value()
.iter()
.any(|provider| provider.api_key.as_deref() == Some(&reference));
if !saved {
me.invalidate(&reference);
let _ = ctx.secure_storage().remove_value(&reference);
}
}
},
|_, _| {},
);
Self {
refreshing: HashMap::new(),
}
}
fn invalidate(&mut self, reference: &str) {
if let Some(refresh) = self.refreshing.remove(reference) {
for reply in refresh.replies {
let _ = reply.send(Err("Claude subscription was signed out.".to_string()));
}
}
}
fn resolve(&mut self, reference: String, reply: Reply, ctx: &mut ModelContext<Self>) {
if reply.is_canceled() {
return;
}
if let Some(refresh) = self.refreshing.get_mut(&reference) {
refresh.replies.push(reply);
return;
}
let value = match ctx.secure_storage().read_value(&reference) {
Ok(value) => value,
Err(_) => {
let _ = reply.send(Err(
"Claude subscription is signed out. Sign in again.".to_string()
));
return;
}
};
if !reference.starts_with(OAUTH_REFERENCE_PREFIX) {
let _ = reply.send(Ok(value));
return;
}
let credentials: Credentials = match serde_json::from_str(&value) {
Ok(credentials) => credentials,
Err(_) => {
let _ = reply.send(Err(
"Claude credentials are invalid. Sign in again.".to_string()
));
return;
}
};
if credentials.expires_at > now() {
let _ = reply.send(Ok(credentials.access_token));
return;
}
let id = uuid::Uuid::new_v4();
self.refreshing.insert(
reference.clone(),
Refresh {
id,
replies: vec![reply],
},
);
ctx.spawn(
async move {
token_request(serde_json::json!({
"grant_type": "refresh_token", "client_id": CLIENT_ID,
"refresh_token": credentials.refresh_token,
}))
.await
},
move |me, result, ctx| {
if me
.refreshing
.get(&reference)
.is_none_or(|refresh| refresh.id != id)
{
return;
}
let refresh = me.refreshing.remove(&reference).unwrap();
let result = result.and_then(|credentials| {
// Signing out deletes the store entry before this callback can commit.
if ctx.secure_storage().read_value(&reference).is_err() {
return Err("Claude subscription was signed out.".to_string());
}
save_credentials(&reference, &credentials, ctx)?;
Ok(credentials.access_token)
});
for reply in refresh.replies {
let _ = reply.send(result.clone());
}
},
);
}
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub(crate) fn save_credentials(
reference: &str,
credentials: &Credentials,
ctx: &galaxyui::AppContext,
) -> Result<(), String> {
let json = serde_json::to_string(credentials)
.map_err(|_| "Could not encode Claude credentials.".to_string())?;
ctx.secure_storage()
.write_value_with_owner_only_fallback(reference, &json)
.map_err(|_| {
"Could not securely save Claude credentials. Sign-in was not saved.".to_string()
})
}
#[derive(serde::Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: String,
expires_in: u64,
}
async fn token_request(body: serde_json::Value) -> Result<Credentials, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| "Could not create Claude authorization client.".to_string())?;
let response = client
.post(TOKEN_URL)
.header("Accept", "application/json")
.json(&body)
.send()
.await
.map_err(|_| {
"Claude authorization request failed. Try signing in again.".to_string()
})?;
if !response.status().is_success() {
// Never expose response bodies: providers may echo authorization codes or tokens.
return Err(format!(
"Claude authorization failed (HTTP {}). Sign in again.",
response.status().as_u16()
));
}
if response
.content_length()
.is_some_and(|length| length > 64 * 1024)
{
return Err("Claude authorization returned an oversized response.".to_string());
}
let mut bytes = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| "Claude authorization response failed.".to_string())?;
if bytes.len().saturating_add(chunk.len()) > 64 * 1024 {
return Err("Claude authorization returned an oversized response.".to_string());
}
bytes.extend_from_slice(&chunk);
}
let tokens: TokenResponse = serde_json::from_slice(&bytes)
.map_err(|_| "Claude authorization returned invalid credentials.".to_string())?;
credentials_from_response(tokens)
}
fn credentials_from_response(tokens: TokenResponse) -> Result<Credentials, String> {
if !tokens.access_token.contains("sk-ant-oat")
|| tokens.refresh_token.is_empty()
|| tokens.expires_in == 0
{
return Err("Claude authorization returned incomplete credentials.".to_string());
}
Ok(Credentials {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: now()
.saturating_add(tokens.expires_in)
.saturating_sub(5 * 60),
})
}
pub(crate) struct BrowserLogin {
pub(crate) url: Url,
verifier: String,
callback: oneshot::Receiver<Result<String, String>>,
cancelled: Arc<AtomicBool>,
}
impl Drop for BrowserLogin {
fn drop(&mut self) {
self.cancelled.store(true, Ordering::Relaxed);
}
}
impl BrowserLogin {
pub(crate) fn start() -> Result<Self, String> {
let listener = TcpListener::bind(("127.0.0.1", 53692))
.map_err(|_| "Claude sign-in needs localhost port 53692. Close another sign-in attempt and try again.".to_string())?;
listener
.set_nonblocking(true)
.map_err(|_| "Could not configure Claude sign-in callback.".to_string())?;
let mut bytes = [0; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let verifier = URL_SAFE_NO_PAD.encode(bytes);
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let url = authorize_url(&verifier, &challenge)?;
let cancelled = Arc::new(AtomicBool::new(false));
let stop = cancelled.clone();
let state = verifier.clone();
let (tx, callback) = oneshot::channel();
std::thread::Builder::new()
.name("claude-oauth-callback".to_string())
.spawn(move || {
let deadline = Instant::now() + SIGN_IN_TIMEOUT;
while !stop.load(Ordering::Relaxed) && Instant::now() < deadline {
match listener.accept() {
Ok((stream, _)) => {
if let Some(code) = read_callback(stream, &state) {
let _ = tx.send(Ok(code));
return;
}
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(50))
}
Err(_) => {
let _ = tx.send(Err("Claude sign-in callback failed.".to_string()));
return;
}
}
}
let _ = tx.send(Err(
"Claude browser sign-in expired or was cancelled.".to_string()
));
})
.map_err(|_| "Could not start Claude sign-in callback.".to_string())?;
Ok(Self {
url,
verifier,
callback,
cancelled,
})
}
pub(crate) async fn finish(mut self) -> Result<Credentials, String> {
let code = (&mut self.callback)
.await
.map_err(|_| "Claude sign-in callback stopped.".to_string())??;
token_request(serde_json::json!({
"grant_type": "authorization_code", "client_id": CLIENT_ID, "code": code,
"state": self.verifier, "redirect_uri": REDIRECT_URI, "code_verifier": self.verifier,
})).await
}
}
pub(super) fn authorize_url(verifier: &str, challenge: &str) -> Result<Url, String> {
let mut url = Url::parse(AUTHORIZE_URL)
.map_err(|_| "Invalid Claude authorization URL.".to_string())?;
url.query_pairs_mut()
.append_pair("code", "true")
.append_pair("client_id", CLIENT_ID)
.append_pair("response_type", "code")
.append_pair("redirect_uri", REDIRECT_URI)
.append_pair("scope", SCOPES)
.append_pair("code_challenge", challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("state", verifier);
Ok(url)
}
pub(super) fn callback_code(request: &str, state: &str) -> Option<String> {
let mut parts = request.split_whitespace();
if parts.next()? != "GET" {
return None;
}
let path = parts.next()?;
if !path.starts_with('/') || path.starts_with("//") {
return None;
}
let url = Url::parse(&format!("http://localhost:53692{path}")).ok()?;
if url.path() != "/callback" {
return None;
}
let params: HashMap<_, _> = url.query_pairs().into_owned().collect();
if params.get("state")?.as_str() != state {
return None;
}
params.get("code").filter(|code| !code.is_empty()).cloned()
}
fn read_callback(mut stream: TcpStream, state: &str) -> Option<String> {
stream.set_read_timeout(Some(Duration::from_secs(1))).ok()?;
stream
.set_write_timeout(Some(Duration::from_secs(1)))
.ok()?;
let mut request = String::new();
// Bound the request line independently of whether a newline is supplied.
std::io::Read::take(BufReader::new(&mut stream), 8192)
.read_line(&mut request)
.ok()?;
let code = callback_code(&request, state);
let (status, message) = if code.is_some() {
(
"200 OK",
"Signed in to Claude. You can close this tab and return to Galaxy.",
)
} else {
("400 Bad Request", "This callback did not match the active Galaxy sign-in. Return to Galaxy and try again.")
};
let body = format!("<!doctype html><title>Galaxy — Claude sign-in</title><p>{message}</p>");
let response = format!("HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = stream.write_all(response.as_bytes());
code
}
}
#[cfg(all(test, not(target_family = "wasm")))]
#[path = "claude_oauth_tests.rs"]
mod tests;
+47
View File
@@ -0,0 +1,47 @@
use super::desktop::{authorize_url, callback_code};
#[test]
fn browser_login_matches_pi_pkce_parameters() {
let url = authorize_url("test-verifier", "test-challenge").unwrap();
assert_eq!(url.origin().ascii_serialization(), "https://claude.ai");
let params: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(params["redirect_uri"], "http://localhost:53692/callback");
assert_eq!(params["client_id"], "9d1c250a-e61b-44d9-88ed-5944d1962f5e");
assert_eq!(params["state"], "test-verifier");
assert_eq!(params["code_challenge"], "test-challenge");
assert_eq!(params["code_challenge_method"], "S256");
assert_eq!(params["response_type"], "code");
assert_eq!(params["code"], "true");
assert!(params["scope"].contains("user:inference"));
}
#[test]
fn callback_requires_matching_state_and_expected_path() {
assert_eq!(
callback_code("GET /callback?code=abc&state=expected HTTP/1.1", "expected"),
Some("abc".to_string())
);
for request in [
"GET /callback?code=abc&state=wrong HTTP/1.1",
"GET /callback?code=abc HTTP/1.1",
"GET /callback?state=expected HTTP/1.1",
"GET /favicon.ico?code=abc&state=expected HTTP/1.1",
"POST /callback?code=abc&state=expected HTTP/1.1",
"GET //evil.example/callback?code=abc&state=expected HTTP/1.1",
] {
assert_eq!(callback_code(request, "expected"), None);
}
}
#[test]
fn persisted_credentials_do_not_count_as_api_keys() {
assert!(crate::ai::claude_subscription::is_stored_credential(
"galaxy-claude-oauth:test"
));
assert!(crate::ai::claude_subscription::is_subscription_credential(
"galaxy-claude-oauth:test"
));
assert!(!crate::ai::claude_subscription::is_stored_credential(
"sk-ant-api-test"
));
}
+52
View File
@@ -0,0 +1,52 @@
//! User-supplied subscription tokens stay in Galaxy's secure credential store.
use galaxyui::AppContext;
use galaxyui_extras::secure_storage::AppContextExt as _;
use crate::settings::{OpenAIProviderConfig, OpenAIProviderKind};
const TOKEN_REFERENCE_PREFIX: &str = "galaxy-claude-token:";
pub(crate) const OAUTH_REFERENCE_PREFIX: &str = "galaxy-claude-oauth:";
pub(crate) const SUBSCRIPTION_WARNING: &str = "Unsupported Claude subscription integration. Anthropic does not permit third-party applications to use Claude subscription tokens. This may stop working without notice or lead to account restrictions. Galaxy retains its own tools, permissions, discovery, and orchestration.";
pub(crate) fn is_subscription_credential(credential: &str) -> bool {
credential.trim().contains("sk-ant-oat")
|| credential.starts_with(TOKEN_REFERENCE_PREFIX)
|| is_stored_credential(credential)
}
pub(crate) fn is_stored_credential(credential: &str) -> bool {
credential.starts_with(TOKEN_REFERENCE_PREFIX) || credential.starts_with(OAUTH_REFERENCE_PREFIX)
}
pub(crate) fn store_credential(
provider: &mut OpenAIProviderConfig,
ctx: &AppContext,
) -> Result<Option<String>, String> {
if provider.kind != OpenAIProviderKind::Anthropic {
return Ok(None);
}
let Some(token) = provider
.api_key
.as_deref()
.filter(|token| token.trim().contains("sk-ant-oat"))
else {
return Ok(None);
};
let reference = format!("{TOKEN_REFERENCE_PREFIX}{}", uuid::Uuid::new_v4());
ctx.secure_storage()
.write_value_with_owner_only_fallback(&reference, token.trim())
.map_err(|_| {
"Could not securely save the Claude subscription token. The connection was not saved."
.to_string()
})?;
provider.api_key = Some(reference.clone());
Ok(Some(reference))
}
pub(crate) fn remove_credential(reference: &str, ctx: &AppContext) {
if is_stored_credential(reference) {
#[cfg(not(target_family = "wasm"))]
super::claude_oauth::invalidate(reference);
let _ = ctx.secure_storage().remove_value(reference);
}
}
+3 -2
View File
@@ -1828,9 +1828,10 @@ impl LLMPreferences {
.as_deref()
.filter(|key| !key.trim().is_empty())
.ok_or_else(|| {
"Enter an Anthropic API key before testing the connection.".to_string()
"Enter an Anthropic API key or Claude subscription token before testing the connection.".to_string()
})?;
Some(discover_anthropic_models(api_key).await?)
let api_key = crate::ai::claude_oauth::load_credential(api_key.to_string()).await?;
Some(discover_anthropic_models(&api_key).await?)
}
OpenAIProviderKind::Gemini => {
let api_key = provider
+3 -1
View File
@@ -1,4 +1,6 @@
//! Loading animation components for AI features.
mod shimmering_warp_loading_text;
pub use shimmering_warp_loading_text::shimmering_warp_loading_text;
pub use shimmering_warp_loading_text::{
loading_logo_size, shimmering_warp_loading_text, LOADING_LOGO_GAP,
};
@@ -1,4 +1,4 @@
//! Shimmering Warp loading text - renders Warp logo with shimmering text for loading states.
//! Renders the Galaxy logo with shimmering text for loading states.
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::shimmering_text::{
@@ -7,10 +7,13 @@ use galaxyui::elements::shimmering_text::{
use galaxyui::elements::Element;
use galaxyui::{AppContext, SingletonEntity};
/// Warp icon glyph character
const WARP_GLYPH: &str = "\u{E500}";
pub const LOADING_LOGO_GAP: f32 = 8.;
/// Creates a shimmering text element with the Warp glyph.
pub fn loading_logo_size(font_size: f32) -> f32 {
(font_size * 1.5).max(24.)
}
/// Creates a shimmering text element beside the Galaxy logo.
pub fn shimmering_warp_loading_text(
text: impl Into<String>,
font_size: f32,
@@ -27,9 +30,8 @@ pub fn shimmering_warp_loading_text(
// Hardcoded shimmer config for consistent animation
let config = ShimmerConfig::default();
// Create a single shimmering element with glyph and text
ShimmeringTextElement::new(
format!("{} {}", WARP_GLYPH, text.into()),
text.into(),
appearance.ui_font_family(),
font_size,
base_color,
@@ -37,5 +39,10 @@ pub fn shimmering_warp_loading_text(
config,
shimmer_handle,
)
.with_leading_icon(
"bundled/svg/galaxy-mark.svg",
loading_logo_size(font_size),
LOADING_LOGO_GAP,
)
.finish()
}
+4
View File
@@ -24,6 +24,8 @@ pub(crate) mod block_context;
pub(crate) mod blocklist;
#[cfg(not(target_family = "wasm"))]
pub(crate) mod chatgpt_auth;
pub(crate) mod claude_oauth;
pub(crate) mod claude_subscription;
#[cfg(any(feature = "local_fs", not(target_family = "wasm")))]
pub(crate) mod codebase_auto_indexing;
pub mod control_code_parser;
@@ -87,6 +89,8 @@ pub fn init(app: &mut AppContext) {
app.add_singleton_model(acp::AcpRuntimeModel::new);
#[cfg(not(target_family = "wasm"))]
app.add_singleton_model(|_| chatgpt_auth::ChatGPTAuthModel::new());
#[cfg(not(target_family = "wasm"))]
app.add_singleton_model(claude_oauth::ClaudeOAuthModel::new);
blocklist::keyboard_navigable_buttons::init(app);
blocklist::block::number_shortcut_buttons::init(app);
blocklist::toggleable_items::init(app);
+11 -3
View File
@@ -318,11 +318,19 @@ pub(crate) async fn provider_runtime_for_request(
},
)),
OpenAIProviderKind::Anthropic => {
Arc::new(AnthropicRuntime::new(AnthropicRuntimeConfig {
api_key: config.api_key.unwrap_or_default(),
let credential = config.api_key.unwrap_or_default();
let stored = crate::ai::claude_subscription::is_stored_credential(&credential);
let mut runtime = AnthropicRuntime::new(AnthropicRuntimeConfig {
api_key: credential.clone(),
model,
max_output_tokens: config.max_output_tokens.map(u64::from),
}))
});
if stored {
runtime = runtime.with_credential_resolver(Arc::new(move || {
Box::pin(crate::ai::claude_oauth::load_credential(credential.clone()))
}));
}
Arc::new(runtime)
}
OpenAIProviderKind::Gemini => Arc::new(GeminiRuntime::new(GeminiRuntimeConfig {
api_key: config.api_key.unwrap_or_default(),
+7 -13
View File
@@ -144,19 +144,13 @@ impl FeatureItem {
feature: Tip,
ctx: &mut AppContext,
) -> Self {
let editable_binding_name;
let shortcut;
match feature {
Tip::Hint(_) => {
editable_binding_name = None;
shortcut = None;
}
Tip::Action(tip) => {
editable_binding_name = Some(tip.editable_binding_name());
shortcut = tip.keyboard_shortcut(ctx);
}
}
let (editable_binding_name, shortcut) = match feature {
Tip::Hint(_) => (None, None),
Tip::Action(tip) => (
Some(tip.editable_binding_name()),
tip.keyboard_shortcut(ctx),
),
};
Self {
title,
+3 -3
View File
@@ -1048,7 +1048,7 @@ impl std::fmt::Debug for OpenAIProviderConfig {
impl settings_value::SettingsValue for OpenAIProviderConfig {}
fn default_acp_agent_id() -> String {
"codex".to_string()
"claude-acp".to_string()
}
fn default_remote_logging_endpoint() -> String {
@@ -1512,7 +1512,7 @@ define_settings_group!(AISettings, settings: [
// Stable identifier for the selected ACP agent preset.
acp_agent_id: AcpAgentId {
type: String,
default: "codex".to_string(),
default: "claude-acp".to_string(),
supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()),
sync_to_cloud: SyncToCloud::Never,
private: false,
@@ -2422,7 +2422,7 @@ impl AISettings {
}
let agent_id = self.acp_agent_id.value().trim();
let agent_id = if agent_id.is_empty() {
"codex"
"claude-acp"
} else {
agent_id
};
+213 -33
View File
@@ -163,6 +163,7 @@ pub enum AISubpage {
ProviderLiteLLM,
/// ChatGPT subscription provider settings.
ProviderChatGPTSubscription,
ProviderAnthropic,
/// AWS Bedrock provider settings.
ProviderBedrock,
/// ACP provider settings.
@@ -185,6 +186,7 @@ impl AISubpage {
SettingsSection::ProviderOpenAI => Some(Self::ProviderOpenAI),
SettingsSection::ProviderLiteLLM => Some(Self::ProviderLiteLLM),
SettingsSection::ProviderChatGPTSubscription => Some(Self::ProviderChatGPTSubscription),
SettingsSection::ProviderAnthropic => Some(Self::ProviderAnthropic),
SettingsSection::ProviderBedrock => Some(Self::ProviderBedrock),
SettingsSection::ProviderACP => Some(Self::ProviderACP),
// AgentMCPServers renders the standalone MCPServers page, not an AI subpage.
@@ -676,6 +678,83 @@ impl AISettingsPageView {
&mut self,
draft: &AcpProviderDraft,
ctx: &mut ViewContext<Self>,
) {
let generation = self
.provider_setup_body
.read(ctx, |body, _| body.acp_discovery_generation());
#[cfg(feature = "local_fs")]
if draft.agent_id.eq_ignore_ascii_case("claude-acp") && draft.command.trim().is_empty() {
let (progress, updates) = async_channel::bounded(8);
self.watch_claude_setup_progress(updates, generation, ctx);
let draft = draft.clone();
let registration = self.provider_setup_body.update(ctx, |body, ctx| {
body.set_acp_setup_status("Checking Claude Code installation…".to_owned(), ctx);
body.prepare_acp_setup()
});
let _ = ctx.spawn(
futures::future::Abortable::new(
async move { crate::ai::acp::ensure_claude_code_installed(progress).await },
registration,
),
move |me, result, ctx| {
if !me
.provider_setup_body
.read(ctx, |body, _| body.acp_discovery_is_current(generation))
{
return;
}
let Ok(result) = result else {
return;
};
match result {
Ok(()) => me.resolve_and_start_acp_discovery(&draft, generation, ctx),
Err(error) => {
log::warn!("Claude Code setup failed: {error}");
me.provider_setup_body.update(ctx, |body, ctx| {
body.finish_acp_discovery(Err(error), Vec::new(), ctx);
});
}
}
},
);
return;
}
self.resolve_and_start_acp_discovery(draft, generation, ctx);
}
#[cfg(feature = "local_fs")]
fn watch_claude_setup_progress(
&mut self,
updates: async_channel::Receiver<&'static str>,
generation: u64,
ctx: &mut ViewContext<Self>,
) {
let remaining_updates = updates.clone();
let _ = ctx.spawn(
async move { updates.recv().await },
move |me, update, ctx| {
if !me
.provider_setup_body
.read(ctx, |body, _| body.acp_discovery_is_current(generation))
{
return;
}
if let Ok(update) = update {
me.provider_setup_body.update(ctx, |body, ctx| {
body.set_acp_setup_status(update.to_owned(), ctx);
});
me.watch_claude_setup_progress(remaining_updates, generation, ctx);
}
},
);
}
#[cfg(not(target_family = "wasm"))]
fn resolve_and_start_acp_discovery(
&mut self,
draft: &AcpProviderDraft,
generation: u64,
ctx: &mut ViewContext<Self>,
) {
let config = match crate::ai::acp::AcpRuntimeModel::discovery_config_for_values(
&draft.agent_id,
@@ -691,7 +770,7 @@ impl AISettingsPageView {
return;
}
};
self.start_acp_discovery(config, draft.agent_id.clone(), ctx);
self.start_acp_discovery(config, draft.agent_id.clone(), generation, ctx);
}
#[cfg(not(target_family = "wasm"))]
@@ -699,6 +778,7 @@ impl AISettingsPageView {
&mut self,
config: galaxy_acp::AcpManagerConfig,
agent_id: String,
generation: u64,
ctx: &mut ViewContext<Self>,
) {
let provider_setup_body = self.provider_setup_body.clone();
@@ -724,6 +804,11 @@ impl AISettingsPageView {
let _ = ctx.spawn(
async move { manager.discover_config_options(cwd, Vec::new()).await },
move |_me, result, ctx| {
if !provider_setup_body
.read(ctx, |body, _| body.acp_discovery_is_current(generation))
{
return;
}
match result {
Ok(options) => {
let option_count = options.len();
@@ -1710,19 +1795,64 @@ impl AISettingsPageView {
mut provider: OpenAIProviderConfig,
ctx: &mut ViewContext<Self>,
) {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let new_reference =
match crate::ai::claude_subscription::store_credential(&mut provider, ctx) {
Ok(reference) => reference,
Err(error) => {
self.provider_setup_body
.update(ctx, |body, ctx| body.show_connection_error(error, ctx));
return;
}
};
let old_reference = editing_index.and_then(|index| {
AISettings::as_ref(ctx)
.openai_providers
.value()
.get(index)
.and_then(|provider| provider.api_key.clone())
});
let saved = AISettings::handle(ctx).update(ctx, |settings, ctx| {
let mut providers = settings.openai_providers.value().clone();
match editing_index {
Some(index) => {
if let Some(existing) = providers.get_mut(index) {
provider.enabled = existing.enabled;
*existing = provider.clone();
}
let Some(existing) = providers.get_mut(index) else {
return false;
};
provider.enabled = existing.enabled;
*existing = provider.clone();
}
None => providers.push(provider.clone()),
}
report_if_error!(settings.openai_providers.set_value(providers, ctx));
match settings.openai_providers.set_value(providers, ctx) {
Ok(_) => true,
Err(error) => {
report_error!(error);
false
}
}
});
if !saved {
if let Some(reference) = new_reference {
crate::ai::claude_subscription::remove_credential(&reference, ctx);
}
self.provider_setup_body.update(ctx, |body, ctx| {
body.show_connection_error(
"Could not save the provider connection.".to_string(),
ctx,
);
});
return;
}
if let Some(reference) = old_reference {
if !AISettings::as_ref(ctx)
.openai_providers
.value()
.iter()
.any(|provider| provider.api_key.as_deref() == Some(reference.as_str()))
{
crate::ai::claude_subscription::remove_credential(&reference, ctx);
}
}
self.clear_inline_provider_setup(ctx);
}
@@ -1929,6 +2059,12 @@ impl AISettingsPageView {
ProviderSetupProviderType::ChatGPTSubscription,
)));
}
Some(AISubpage::ProviderAnthropic) => {
widgets.push(Box::new(ProviderSettingsWidget::new(
ctx,
ProviderSetupProviderType::Anthropic,
)));
}
Some(AISubpage::ProviderBedrock) => {
widgets.push(Box::new(ProviderSettingsWidget::new(
ctx,
@@ -3297,8 +3433,22 @@ impl TypedActionView for AISettingsPageView {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let mut providers = settings.openai_providers.value().clone();
if *provider_index < providers.len() {
providers.remove(*provider_index);
report_if_error!(settings.openai_providers.set_value(providers, ctx));
let removed = providers.remove(*provider_index);
let remaining = providers.clone();
match settings.openai_providers.set_value(providers, ctx) {
Ok(_) => {
if let Some(reference) = removed.api_key {
if !remaining.iter().any(|provider| {
provider.api_key.as_deref() == Some(reference.as_str())
}) {
crate::ai::claude_subscription::remove_credential(
&reference, ctx,
);
}
}
}
Err(error) => report_error!(error),
}
}
});
self.rebuild_active_subpage(ctx);
@@ -3337,7 +3487,9 @@ impl TypedActionView for AISettingsPageView {
let mut providers = settings.acp_providers.value().clone();
if providers.is_empty() && provider_id == "legacy" {
report_if_error!(settings.acp_enabled.set_value(false, ctx));
report_if_error!(settings.acp_agent_id.set_value("codex".to_string(), ctx));
report_if_error!(settings
.acp_agent_id
.set_value("claude-acp".to_string(), ctx));
report_if_error!(settings.acp_agent_command.set_value(String::new(), ctx));
report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx));
report_if_error!(settings
@@ -3366,7 +3518,9 @@ impl TypedActionView for AISettingsPageView {
.set_value(first_provider.name.clone(), ctx));
} else {
report_if_error!(settings.acp_enabled.set_value(false, ctx));
report_if_error!(settings.acp_agent_id.set_value("codex".to_string(), ctx));
report_if_error!(settings
.acp_agent_id
.set_value("claude-acp".to_string(), ctx));
report_if_error!(settings.acp_agent_command.set_value(String::new(), ctx));
report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx));
report_if_error!(settings
@@ -6372,7 +6526,7 @@ impl SettingsWidget for ModelsOverviewWidget {
type View = AISettingsPageView;
fn search_terms(&self) -> &str {
"models providers runtimes rig litellm openai compatible ollama lm studio bedrock acp agent client protocol"
"models providers runtimes rig litellm openai compatible ollama lm studio bedrock acp agent client protocol anthropic claude subscription token"
}
fn render(
@@ -6390,6 +6544,12 @@ impl SettingsWidget for ModelsOverviewWidget {
ModelProviderSection::for_provider(provider) == ModelProviderSection::OpenAI
})
.count();
let anthropic_count = settings
.openai_providers
.value()
.iter()
.filter(|provider| provider.kind == OpenAIProviderKind::Anthropic)
.count();
let litellm_count = settings
.openai_providers
.value()
@@ -6420,13 +6580,13 @@ impl SettingsWidget for ModelsOverviewWidget {
.with_spacing(8.)
.with_child(build_sub_header(appearance, "Models", None).finish())
.with_child(render_ai_setting_description(
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI, LiteLLM, ChatGPT subscription, and Bedrock connections are configured here. ACP coding agents are shown read-only because the agent runtime controls their model, login, session, and tool loop.",
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI, LiteLLM, ChatGPT subscription, Anthropic / Claude subscription, and Bedrock connections are configured here. ACP coding agents are shown read-only because the agent runtime controls their model, login, session, and tool loop.",
true,
app,
))
.with_child(render_ai_setting_description(
format!(
"{openai_count} OpenAI provider(s); {litellm_count} LiteLLM provider(s); {chatgpt_count} ChatGPT subscription provider(s); {endpoint_model_count} direct model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled ACP runtime(s)."
"{openai_count} OpenAI provider(s); {litellm_count} LiteLLM provider(s); {chatgpt_count} ChatGPT subscription provider(s); {anthropic_count} Anthropic / Claude subscription provider(s); {endpoint_model_count} direct model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled ACP runtime(s)."
),
true,
app,
@@ -6437,6 +6597,7 @@ impl SettingsWidget for ModelsOverviewWidget {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ModelProviderSection {
Anthropic,
OpenAI,
LiteLLM,
ChatGPTSubscription,
@@ -6445,8 +6606,8 @@ enum ModelProviderSection {
impl ModelProviderSection {
fn for_provider(provider: &OpenAIProviderConfig) -> Self {
match provider.kind {
OpenAIProviderKind::Anthropic => Self::Anthropic,
OpenAIProviderKind::OpenAI
| OpenAIProviderKind::Anthropic
| OpenAIProviderKind::Gemini
| OpenAIProviderKind::VertexAI => Self::OpenAI,
OpenAIProviderKind::LiteLLM => Self::LiteLLM,
@@ -6472,6 +6633,7 @@ struct ProviderSettingsWidget {
bedrock_enabled_toggle: SwitchStateHandle,
bedrock_model_switches: RefCell<HashMap<String, SwitchStateHandle>>,
bedrock_model_table_state: TableStateHandle,
add_anthropic_provider_button: ViewHandle<ActionButton>,
add_openai_provider_button: ViewHandle<ActionButton>,
add_litellm_provider_button: ViewHandle<ActionButton>,
add_chatgpt_provider_button: ViewHandle<ActionButton>,
@@ -6529,6 +6691,15 @@ impl ProviderSettingsWidget {
}
})
.collect();
let add_anthropic_provider_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Add provider", SecondaryTheme)
.with_icon(Icon::Plus)
.on_click(|ctx| {
ctx.dispatch_typed_action(AISettingsPageAction::AddOpenAIProvider(
ProviderSetupProviderType::Anthropic,
));
})
});
let add_openai_provider_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Add provider", SecondaryTheme)
.with_icon(Icon::Plus)
@@ -6587,6 +6758,7 @@ impl ProviderSettingsWidget {
bedrock_enabled_toggle: SwitchStateHandle::default(),
bedrock_model_switches: RefCell::new(HashMap::new()),
bedrock_model_table_state: TableStateHandle::new(0, |_, _| Vec::new()),
add_anthropic_provider_button,
add_openai_provider_button,
add_litellm_provider_button,
add_chatgpt_provider_button,
@@ -6620,7 +6792,17 @@ impl ProviderSettingsWidget {
OpenAIProviderKind::OpenAI => "OpenAI",
OpenAIProviderKind::LiteLLM => "LiteLLM",
OpenAIProviderKind::ChatGPTSubscription => "ChatGPT subscription",
OpenAIProviderKind::Anthropic => "Anthropic",
OpenAIProviderKind::Anthropic => {
if provider
.api_key
.as_deref()
.is_some_and(crate::ai::claude_subscription::is_subscription_credential)
{
"Claude subscription (unsupported)"
} else {
"Anthropic API"
}
}
OpenAIProviderKind::Gemini => "Google Gemini",
OpenAIProviderKind::VertexAI => "Google Vertex AI",
}
@@ -7212,8 +7394,8 @@ impl ProviderSettingsWidget {
ProviderSetupProviderType::ChatGPTSubscription => {
Some(ModelProviderSection::ChatGPTSubscription)
}
ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
ProviderSetupProviderType::Anthropic => Some(ModelProviderSection::Anthropic),
ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI
| ProviderSetupProviderType::Bedrock
| ProviderSetupProviderType::Acp => None,
@@ -7226,8 +7408,8 @@ impl ProviderSettingsWidget {
ProviderSetupProviderType::LiteLLM => "LiteLLM",
ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT Subscription",
ProviderSetupProviderType::Bedrock => "Bedrock",
ProviderSetupProviderType::Acp => "ACP",
ProviderSetupProviderType::Anthropic => "Anthropic",
ProviderSetupProviderType::Acp => "Claude Code",
ProviderSetupProviderType::Anthropic => "Anthropic / Claude subscription",
ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
}
@@ -7248,9 +7430,9 @@ impl ProviderSettingsWidget {
"Use AWS Bedrock credentials and discovered Bedrock foundation models."
}
ProviderSetupProviderType::Acp => {
"Add an ACP agent connection here. After it is added, Settings shows it read-only because the agent controls its model, login, session, and tool loop."
"Connect Claude Code with your Claude account. Setup downloads missing components and opens sign-in in your browser."
}
ProviderSetupProviderType::Anthropic => "Connect directly to Anthropic.",
ProviderSetupProviderType::Anthropic => crate::ai::claude_subscription::SUBSCRIPTION_WARNING,
ProviderSetupProviderType::Gemini => "Connect directly to Google Gemini.",
ProviderSetupProviderType::VertexAI => "Connect directly to Google Vertex AI.",
}
@@ -7264,7 +7446,7 @@ impl ProviderSettingsWidget {
"No ChatGPT subscription provider configured."
}
ProviderSetupProviderType::Bedrock => "No Bedrock provider configured.",
ProviderSetupProviderType::Acp => "No ACP agent connection configured.",
ProviderSetupProviderType::Acp => "No Claude Code connection configured.",
ProviderSetupProviderType::Anthropic => "No Anthropic provider configured.",
ProviderSetupProviderType::Gemini => "No Google Gemini provider configured.",
ProviderSetupProviderType::VertexAI => "No Google Vertex AI provider configured.",
@@ -7283,9 +7465,8 @@ impl ProviderSettingsWidget {
}
ProviderSetupProviderType::Bedrock => Some(&self.bedrock_add_button),
ProviderSetupProviderType::Acp => Some(&self.acp_add_button),
ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => None,
ProviderSetupProviderType::Anthropic => Some(&self.add_anthropic_provider_button),
ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => None,
}
}
@@ -7369,7 +7550,8 @@ impl SettingsWidget for ProviderSettingsWidget {
match self.provider_type {
ProviderSetupProviderType::OpenAI
| ProviderSetupProviderType::LiteLLM
| ProviderSetupProviderType::ChatGPTSubscription => {
| ProviderSetupProviderType::ChatGPTSubscription
| ProviderSetupProviderType::Anthropic => {
if is_setup_visible {
column.add_child(Self::render_inline_setup(appearance, view));
}
@@ -7462,22 +7644,20 @@ impl SettingsWidget for ProviderSettingsWidget {
None
};
let empty_message = if acp_supported {
"No ACP agent connection configured."
"No Claude Code connection configured."
} else {
"ACP providers are not available on this platform."
};
column.add_child(Self::render_provider_section(
"ACP",
"Add an ACP agent connection here. After it is added, Settings shows it read-only because the agent controls its model, login, session, and tool loop.",
"Claude Code",
"Connect Claude Code with your Claude account. Setup downloads missing components and opens sign-in in your browser.",
acp_add_button,
acp_cards,
empty_message,
appearance,
));
}
ProviderSetupProviderType::Anthropic
| ProviderSetupProviderType::Gemini
| ProviderSetupProviderType::VertexAI => {}
ProviderSetupProviderType::Gemini | ProviderSetupProviderType::VertexAI => {}
}
column.finish()
+8
View File
@@ -250,6 +250,7 @@ pub enum SettingsSection {
ProviderOpenAI,
ProviderLiteLLM,
ProviderChatGPTSubscription,
ProviderAnthropic,
ProviderBedrock,
ProviderACP,
/// Internal backing-page identifier for CodeSettingsPageView. Multiple subpages
@@ -282,6 +283,7 @@ impl Display for SettingsSection {
SettingsSection::ProviderOpenAI => write!(f, "OpenAI"),
SettingsSection::ProviderLiteLLM => write!(f, "LiteLLM"),
SettingsSection::ProviderChatGPTSubscription => write!(f, "ChatGPT Subscription"),
SettingsSection::ProviderAnthropic => write!(f, "Anthropic / Claude subscription"),
SettingsSection::ProviderBedrock => write!(f, "Bedrock"),
SettingsSection::ProviderACP => write!(f, "ACP"),
SettingsSection::Wormhole => write!(f, "Wormhole"),
@@ -318,6 +320,7 @@ impl SettingsSection {
Self::ProviderOpenAI
| Self::ProviderLiteLLM
| Self::ProviderChatGPTSubscription
| Self::ProviderAnthropic
| Self::ProviderBedrock
| Self::ProviderACP
)
@@ -335,6 +338,7 @@ impl SettingsSection {
| Self::ProviderOpenAI
| Self::ProviderLiteLLM
| Self::ProviderChatGPTSubscription
| Self::ProviderAnthropic
| Self::ProviderBedrock
| Self::ProviderACP
)
@@ -376,6 +380,7 @@ impl SettingsSection {
Self::ProviderOpenAI,
Self::ProviderLiteLLM,
Self::ProviderChatGPTSubscription,
Self::ProviderAnthropic,
Self::ProviderBedrock,
Self::ProviderACP,
]
@@ -413,6 +418,9 @@ impl FromStr for SettingsSection {
"OpenAI / LiteLLM" | "LiteLLM" => Ok(Self::ProviderLiteLLM),
"OpenAI" => Ok(Self::ProviderOpenAI),
"ChatGPT Subscription" | "ChatGPTSubscription" => Ok(Self::ProviderChatGPTSubscription),
"Anthropic" | "Claude Subscription" | "Anthropic / Claude subscription" => {
Ok(Self::ProviderAnthropic)
}
"Agent Client Protocol" | "ACP" => Ok(Self::ProviderACP),
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
+5
View File
@@ -41,6 +41,7 @@ fn provider_subpages_are_classified_and_map_to_ai_backing_page() {
SettingsSection::ProviderOpenAI,
SettingsSection::ProviderLiteLLM,
SettingsSection::ProviderChatGPTSubscription,
SettingsSection::ProviderAnthropic,
SettingsSection::ProviderBedrock,
SettingsSection::ProviderACP,
]
@@ -116,6 +117,10 @@ fn current_settings_display_names_round_trip() {
SettingsSection::ProviderChatGPTSubscription,
"ChatGPT Subscription",
),
(
SettingsSection::ProviderAnthropic,
"Anthropic / Claude subscription",
),
(SettingsSection::ProviderBedrock, "Bedrock"),
(SettingsSection::ProviderACP, "ACP"),
(SettingsSection::CodeIndexing, "Indexing and projects"),
+344 -46
View File
@@ -67,7 +67,7 @@ use crate::ai::harness_display;
use crate::ai::llms::{merge_discovered_provider_models, replace_litellm_catalog, LLMPreferences};
use crate::appearance::Appearance;
use crate::editor::{
EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions,
EditOrigin, EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions,
};
use crate::settings::ai::{
AcpConfigOptionSettings, BedrockAuthMethod, BedrockModelConfig, ModelCapabilityOverride,
@@ -118,6 +118,11 @@ const PROVIDER_TYPE_OPTIONS: &[(ProviderSetupProviderType, &str, &str)] = &[
"ChatGPT subscription",
"Use your ChatGPT Plus or Pro subscription with native OAuth.",
),
(
ProviderSetupProviderType::Anthropic,
"Anthropic / Claude subscription",
"Use an Anthropic API key or a Claude Code authorization token with Galaxy’s own tools.",
),
(
ProviderSetupProviderType::Bedrock,
"AWS Bedrock",
@@ -215,6 +220,9 @@ pub enum ProviderSetupViewAction {
ToggleModel(usize),
CycleModelCapability(usize, CapabilityKey),
ConnectChatGPT,
ConnectClaude,
SignOutClaude,
CancelClaudeSignIn,
SelectBedrockAuth(BedrockAuthMethod),
SelectBedrockProfile(String),
ToggleBedrockCrossRegion,
@@ -236,6 +244,9 @@ pub struct ProviderSetupView {
draft_bedrock: BedrockProviderDraft,
draft_acp: AcpProviderDraft,
discovery_state: DiscoveryState,
acp_setup_status: String,
acp_discovery_generation: u64,
acp_setup_abort: Option<futures::future::AbortHandle>,
provider_type_buttons: Vec<ViewHandle<ActionButton>>,
acp_agent_buttons: Vec<ViewHandle<ActionButton>>,
name_editor: ViewHandle<EditorView>,
@@ -253,6 +264,11 @@ pub struct ProviderSetupView {
acp_command_editor: ViewHandle<EditorView>,
acp_args_editor: ViewHandle<EditorView>,
chatgpt_connect_mouse_state: MouseStateHandle,
claude_connect_mouse_state: MouseStateHandle,
claude_signin_abort: Option<futures::future::AbortHandle>,
claude_signin_generation: u64,
claude_signin_status: String,
claude_credentials_available: bool,
bedrock_auth_buttons: Vec<ViewHandle<ActionButton>>,
bedrock_cross_region_toggle: SwitchStateHandle,
bedrock_auto_login_toggle: SwitchStateHandle,
@@ -300,11 +316,11 @@ impl ProviderSetupView {
let bedrock_refresh_command_editor = Self::create_editor("aws sso login", false, ctx);
let bedrock_access_key_editor = Self::create_editor("AKIA...", false, ctx);
let bedrock_secret_key_editor = Self::create_editor("Secret access key", true, ctx);
let acp_agent_id_editor = Self::create_editor("codex", false, ctx);
let acp_agent_id_editor = Self::create_editor("claude-acp", false, ctx);
let acp_command_editor = Self::create_editor("Optional executable", false, ctx);
let acp_args_editor = Self::create_editor(r#"["arg1", "arg2"]"#, false, ctx);
let mut acp_agent_buttons = galaxy_acp::known_acp_agents()
let acp_agent_buttons = galaxy_acp::known_acp_agents()
.iter()
.map(|agent| {
let id = agent.id.to_owned();
@@ -322,15 +338,6 @@ impl ProviderSetupView {
})
})
.collect::<Vec<_>>();
acp_agent_buttons.push(ctx.add_typed_action_view(|_| {
ActionButton::new("Custom", NakedTheme)
.with_full_width(true)
.on_click(|ctx| {
ctx.dispatch_typed_action(ProviderSetupViewAction::SelectAcpAgent(
"custom".to_owned(),
));
})
}));
let bedrock_auth_buttons = [
BedrockAuthMethod::Profile,
@@ -362,9 +369,15 @@ impl ProviderSetupView {
}
});
ctx.subscribe_to_view(&api_key_editor, |me, editor, event, ctx| {
if matches!(event, EditorEvent::Edited(_)) {
if matches!(event, EditorEvent::Edited(origin) if *origin != EditOrigin::SystemEdit) {
let value = editor.as_ref(ctx).buffer_text(ctx);
me.cancel_claude_signin();
me.draft_api_key = (!value.trim().is_empty()).then_some(value);
me.claude_credentials_available = false;
if me.provider_type == ProviderSetupProviderType::Anthropic {
me.draft_models.clear();
}
me.update_next_button(ctx);
ctx.notify();
}
});
@@ -465,12 +478,15 @@ impl ProviderSetupView {
},
draft_acp: AcpProviderDraft {
name: String::new(),
agent_id: "codex".to_string(),
agent_id: "claude-acp".to_string(),
command: String::new(),
args: Vec::new(),
config_options: Vec::new(),
},
discovery_state: DiscoveryState::Idle,
acp_setup_status: String::new(),
acp_discovery_generation: 0,
acp_setup_abort: None,
provider_type_buttons,
acp_agent_buttons,
name_editor,
@@ -488,6 +504,11 @@ impl ProviderSetupView {
acp_command_editor,
acp_args_editor,
chatgpt_connect_mouse_state: MouseStateHandle::default(),
claude_connect_mouse_state: MouseStateHandle::default(),
claude_signin_abort: None,
claude_signin_generation: 0,
claude_signin_status: String::new(),
claude_credentials_available: false,
bedrock_auth_buttons,
bedrock_cross_region_toggle: SwitchStateHandle::default(),
bedrock_auto_login_toggle: SwitchStateHandle::default(),
@@ -542,7 +563,7 @@ impl ProviderSetupView {
ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
ProviderSetupProviderType::Bedrock => "AWS Bedrock",
ProviderSetupProviderType::Acp => "ACP agent runtime",
ProviderSetupProviderType::Acp => "Claude Code",
}
}
@@ -564,6 +585,8 @@ impl ProviderSetupView {
provider_type: ProviderSetupProviderType,
ctx: &mut ViewContext<Self>,
) {
self.cancel_acp_setup();
self.cancel_claude_signin();
self.step = ProviderSetupStep::Configure;
self.editing_index = None;
self.provider_type = provider_type;
@@ -571,6 +594,7 @@ impl ProviderSetupView {
self.draft_name = Self::default_name(provider_type).to_string();
self.draft_base_url = Self::default_base_url(provider_type).to_string();
self.draft_api_key = None;
self.claude_credentials_available = false;
self.draft_project_id.clear();
self.draft_location = "global".to_string();
self.draft_models.clear();
@@ -588,7 +612,7 @@ impl ProviderSetupView {
};
self.draft_acp = AcpProviderDraft {
name: String::new(),
agent_id: "codex".to_string(),
agent_id: "claude-acp".to_string(),
command: String::new(),
args: Vec::new(),
config_options: Vec::new(),
@@ -613,6 +637,7 @@ impl ProviderSetupView {
provider: OpenAIProviderConfig,
ctx: &mut ViewContext<Self>,
) {
self.cancel_claude_signin();
// Editing an existing provider is a local catalog operation. Do not
// send the user through credentials or model discovery again.
self.step = ProviderSetupStep::Models;
@@ -631,6 +656,14 @@ impl ProviderSetupView {
self.draft_name = provider.name;
self.draft_base_url = provider.base_url;
self.draft_api_key = provider.api_key;
{
use galaxyui_extras::secure_storage::AppContextExt as _;
self.claude_credentials_available =
self.draft_api_key.as_deref().is_some_and(|reference| {
crate::ai::claude_subscription::is_stored_credential(reference)
&& ctx.secure_storage().read_value(reference).is_ok()
});
}
self.draft_project_id = provider.project_id.unwrap_or_default();
self.draft_location = provider.location.unwrap_or_else(|| "global".to_string());
self.draft_models = provider.models;
@@ -667,6 +700,7 @@ impl ProviderSetupView {
}
pub fn begin_edit_acp(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext<Self>) {
self.cancel_acp_setup();
self.step = if draft.config_options.is_empty() {
ProviderSetupStep::Configure
} else {
@@ -688,6 +722,43 @@ impl ProviderSetupView {
ctx.notify();
}
pub fn acp_discovery_generation(&self) -> u64 {
self.acp_discovery_generation
}
pub fn acp_discovery_is_current(&self, generation: u64) -> bool {
self.provider_type == ProviderSetupProviderType::Acp
&& self.step == ProviderSetupStep::Discover
&& matches!(self.discovery_state, DiscoveryState::Loading)
&& self.acp_discovery_generation == generation
}
#[cfg(feature = "local_fs")]
pub fn prepare_acp_setup(&mut self) -> futures::future::AbortRegistration {
self.cancel_acp_setup();
let (handle, registration) = futures::future::AbortHandle::new_pair();
self.acp_setup_abort = Some(handle);
registration
}
fn cancel_acp_setup(&mut self) {
if let Some(handle) = self.acp_setup_abort.take() {
handle.abort();
}
}
pub fn set_acp_setup_status(&mut self, status: String, ctx: &mut ViewContext<Self>) {
self.acp_setup_status = status;
ctx.notify();
}
pub fn show_connection_error(&mut self, error: String, ctx: &mut ViewContext<Self>) {
self.discovery_state = DiscoveryState::Failed(error);
self.step = ProviderSetupStep::Discover;
self.update_next_button(ctx);
ctx.notify();
}
pub fn finish_acp_discovery(
&mut self,
result: Result<(), String>,
@@ -754,7 +825,13 @@ impl ProviderSetupView {
editor.system_reset_buffer_text(&self.draft_base_url, ctx);
});
self.api_key_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(self.draft_api_key.as_deref().unwrap_or_default(), ctx);
let credential = self.draft_api_key.as_deref().unwrap_or_default();
let text = if crate::ai::claude_subscription::is_stored_credential(credential) {
""
} else {
credential
};
editor.system_reset_buffer_text(text, ctx);
});
self.project_id_editor.update(ctx, |editor, ctx| {
editor.system_reset_buffer_text(&self.draft_project_id, ctx);
@@ -834,11 +911,6 @@ impl ProviderSetupView {
button.set_active(agent.id.eq_ignore_ascii_case(selected), ctx);
});
}
if let Some(button) = self.acp_agent_buttons.last() {
button.update(ctx, |button, ctx| {
button.set_active(selected.eq_ignore_ascii_case("custom"), ctx);
});
}
}
fn sync_bedrock_auth_buttons(&self, ctx: &mut ViewContext<Self>) {
@@ -948,7 +1020,9 @@ impl ProviderSetupView {
| ProviderSetupProviderType::VertexAI => (
"Save",
self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled),
|| !self.draft_models.iter().any(|model| model.enabled)
|| (self.provider_type == ProviderSetupProviderType::Anthropic
&& !self.is_configure_valid()),
),
ProviderSetupProviderType::Bedrock => (
"Save",
@@ -1057,8 +1131,11 @@ impl ProviderSetupView {
}
fn begin_discovery(&mut self, ctx: &mut ViewContext<Self>) {
self.cancel_acp_setup();
self.step = ProviderSetupStep::Discover;
self.discovery_state = DiscoveryState::Loading;
self.acp_setup_status.clear();
self.acp_discovery_generation = self.acp_discovery_generation.wrapping_add(1);
self.update_next_button(ctx);
ctx.notify();
@@ -1136,26 +1213,38 @@ impl ProviderSetupView {
let provider = self.draft_provider();
let existing_models = self.draft_models.clone();
let authoritative_catalog = provider.kind == OpenAIProviderKind::LiteLLM;
let generation = self.acp_discovery_generation;
let requested_kind = self.provider_type;
let requested_credential = self.draft_api_key.clone();
ctx.spawn(
async move { LLMPreferences::discover_openai_provider_models(provider).await },
move |me, result, ctx| match result {
Ok(models) => {
me.draft_models = if authoritative_catalog {
replace_litellm_catalog(&existing_models, models)
} else {
merge_discovered_provider_models(&existing_models, models)
};
me.discovery_state = DiscoveryState::Idle;
me.step = ProviderSetupStep::Models;
me.sync_model_switches(ctx);
me.update_next_button(ctx);
ctx.focus(&me.name_editor);
ctx.notify();
move |me, result, ctx| {
if generation != me.acp_discovery_generation
|| requested_kind != me.provider_type
|| requested_credential != me.draft_api_key
|| me.step != ProviderSetupStep::Discover
{
return;
}
Err(error) => {
me.discovery_state = DiscoveryState::Failed(error);
me.update_next_button(ctx);
ctx.notify();
match result {
Ok(models) => {
me.draft_models = if authoritative_catalog {
replace_litellm_catalog(&existing_models, models)
} else {
merge_discovered_provider_models(&existing_models, models)
};
me.discovery_state = DiscoveryState::Idle;
me.step = ProviderSetupStep::Models;
me.sync_model_switches(ctx);
me.update_next_button(ctx);
ctx.focus(&me.name_editor);
ctx.notify();
}
Err(error) => {
me.discovery_state = DiscoveryState::Failed(error);
me.update_next_button(ctx);
ctx.notify();
}
}
},
);
@@ -1313,6 +1402,138 @@ impl ProviderSetupView {
.finish()
}
fn cancel_claude_signin(&mut self) {
self.claude_signin_generation = self.claude_signin_generation.wrapping_add(1);
if let Some(abort) = self.claude_signin_abort.take() {
abort.abort();
}
#[cfg(not(target_family = "wasm"))]
if let Some(reference) = self.draft_api_key.as_deref() {
crate::ai::claude_oauth::discard_draft(reference);
}
self.claude_signin_status.clear();
}
#[cfg(not(target_family = "wasm"))]
fn connect_claude(&mut self, ctx: &mut ViewContext<Self>) {
if self.claude_signin_abort.is_some() {
return;
}
self.cancel_claude_signin();
let login = match crate::ai::claude_oauth::BrowserLogin::start() {
Ok(login) => login,
Err(error) => {
self.claude_signin_status = error;
ctx.notify();
return;
}
};
let generation = self.claude_signin_generation;
let (abort, registration) = futures::future::AbortHandle::new_pair();
self.claude_signin_abort = Some(abort);
self.claude_signin_status = "Waiting for Claude sign-in in your browser…".to_string();
self.update_next_button(ctx);
ctx.open_url(login.url.as_str());
ctx.notify();
ctx.spawn(
futures::future::Abortable::new(login.finish(), registration),
move |me, result, ctx| {
if generation != me.claude_signin_generation
|| me.provider_type != ProviderSetupProviderType::Anthropic
{
return;
}
me.claude_signin_abort = None;
match result {
Ok(Ok(credentials)) => {
let reference = format!(
"{}{}",
crate::ai::claude_subscription::OAUTH_REFERENCE_PREFIX,
uuid::Uuid::new_v4()
);
match crate::ai::claude_oauth::save_credentials(
&reference,
&credentials,
ctx,
) {
Ok(()) => {
me.draft_api_key = Some(reference);
me.claude_credentials_available = true;
me.draft_models.clear();
me.claude_signin_status =
"Claude subscription connected.".to_string();
me.sync_editors(ctx);
}
Err(error) => me.claude_signin_status = error,
}
}
Ok(Err(error)) => me.claude_signin_status = error,
Err(_) => me.claude_signin_status = "Claude sign-in cancelled.".to_string(),
}
me.update_next_button(ctx);
ctx.notify();
},
);
}
fn render_claude_auth(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let connected = self.claude_credentials_available;
let description = if !self.claude_signin_status.is_empty() {
self.claude_signin_status.as_str()
} else if connected {
"Claude subscription credentials saved on this device."
} else {
"Sign in with your Claude subscription in your browser."
};
let mut children = vec![
Self::render_label(appearance, "Claude subscription authorization"),
Text::new(
description.to_string(),
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().nonactive_ui_text_color().into())
.soft_wrap(true)
.finish(),
];
{
let pending = self.claude_signin_abort.is_some();
children.push(
appearance
.ui_builder()
.button(
ButtonVariant::Secondary,
self.claude_connect_mouse_state.clone(),
)
.with_text_label(
if pending {
"Cancel sign-in"
} else if connected {
"Sign out"
} else {
"Sign in with Claude"
}
.to_string(),
)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(if pending {
ProviderSetupViewAction::CancelClaudeSignIn
} else if connected {
ProviderSetupViewAction::SignOutClaude
} else {
ProviderSetupViewAction::ConnectClaude
});
})
.finish(),
);
}
Flex::column()
.with_spacing(10.)
.with_children(children)
.finish()
}
#[cfg(not(target_family = "wasm"))]
fn render_chatgpt_auth(
&self,
@@ -1438,10 +1659,21 @@ impl ProviderSetupView {
);
}
ProviderSetupProviderType::Anthropic => {
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
children.push(self.render_claude_auth(appearance));
if !self
.draft_api_key
.as_deref()
.is_some_and(crate::ai::claude_subscription::is_stored_credential)
{
children.push(self.render_input(
appearance,
"API key or setup token (optional alternative)",
&self.api_key_editor,
));
}
children.push(
Text::new(
"The key is stored locally and is never synced to the cloud. Models will be discovered from Anthropic after the connection test.",
"Sign in with Claude to use your subscription. OAuth credentials are saved in Galaxy’s secure credential store and refreshed automatically. Alternatively, paste an API key or a token from `claude setup-token`; pasted setup tokens must be replaced manually if they expire. API keys use Anthropic API billing.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
@@ -1449,6 +1681,16 @@ impl ProviderSetupView {
.soft_wrap(true)
.finish(),
);
children.push(
Text::new(
crate::ai::claude_subscription::SUBSCRIPTION_WARNING,
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
.with_color(appearance.theme().accent().into())
.soft_wrap(true)
.finish(),
);
}
ProviderSetupProviderType::Gemini => {
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
@@ -1576,7 +1818,7 @@ impl ProviderSetupView {
}
children.push(
Text::new(
"Known NPM-backed clients use npx with a pinned package version. If an ACP client needs another toolchain, choose Custom and configure the executable after installing it yourself.",
"Test downloads Claude Code and its ACP adapter when missing. Sign-in opens through Claude Code in your browser; Galaxy does not store your Claude credentials.",
appearance.ui_font_family(),
INPUT_FONT_SIZE,
)
@@ -1596,7 +1838,13 @@ impl ProviderSetupView {
fn render_discovery(&self, appearance: &Appearance) -> Box<dyn galaxyui::Element> {
let (message, color) = match &self.discovery_state {
DiscoveryState::Idle | DiscoveryState::Loading => (
"Testing the connection and discovering available models...".to_string(),
if self.provider_type == ProviderSetupProviderType::Acp
&& !self.acp_setup_status.is_empty()
{
self.acp_setup_status.clone()
} else {
"Testing the connection and discovering available models...".to_string()
},
appearance.theme().active_ui_text_color().into_solid(),
),
DiscoveryState::Failed(error) => (error.clone(), appearance.theme().ui_error_color()),
@@ -2012,6 +2260,9 @@ impl ProviderSetupView {
}
fn is_configure_valid(&self) -> bool {
if self.claude_signin_abort.is_some() {
return false;
}
match self.provider_type {
ProviderSetupProviderType::OpenAI => {
!self.draft_base_url.trim().is_empty()
@@ -2021,7 +2272,16 @@ impl ProviderSetupView {
.is_some_and(|key| !key.trim().is_empty())
}
ProviderSetupProviderType::LiteLLM => !self.draft_base_url.trim().is_empty(),
ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini => self
ProviderSetupProviderType::Anthropic => {
self.draft_api_key.as_deref().is_some_and(|key| {
if crate::ai::claude_subscription::is_stored_credential(key) {
self.claude_credentials_available
} else {
!key.trim().is_empty()
}
})
}
ProviderSetupProviderType::Gemini => self
.draft_api_key
.as_deref()
.is_some_and(|key| !key.trim().is_empty()),
@@ -2202,6 +2462,9 @@ impl TypedActionView for ProviderSetupView {
match action {
ProviderSetupViewAction::SelectProvider(kind) => {
if self.provider_type != *kind {
self.cancel_claude_signin();
self.draft_api_key = None;
self.claude_credentials_available = false;
self.draft_models.clear();
self.discovery_state = DiscoveryState::Idle;
}
@@ -2244,6 +2507,8 @@ impl TypedActionView for ProviderSetupView {
| ProviderSetupProviderType::VertexAI => {
if self.draft_name.trim().is_empty()
|| !self.draft_models.iter().any(|model| model.enabled)
|| (self.provider_type == ProviderSetupProviderType::Anthropic
&& !self.is_configure_valid())
{
return;
}
@@ -2282,6 +2547,7 @@ impl TypedActionView for ProviderSetupView {
ctx.notify();
}
ProviderSetupStep::Discover => {
self.cancel_acp_setup();
self.step = ProviderSetupStep::Configure;
self.discovery_state = DiscoveryState::Idle;
self.update_next_button(ctx);
@@ -2294,6 +2560,9 @@ impl TypedActionView for ProviderSetupView {
}
},
ProviderSetupViewAction::Cancel => {
self.cancel_acp_setup();
self.cancel_claude_signin();
self.discovery_state = DiscoveryState::Idle;
ctx.emit(ProviderSetupViewEvent::Close);
}
ProviderSetupViewAction::SelectAcpAgent(agent_id) => {
@@ -2338,6 +2607,28 @@ impl TypedActionView for ProviderSetupView {
#[cfg(not(target_family = "wasm"))]
ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx));
}
ProviderSetupViewAction::ConnectClaude => {
#[cfg(not(target_family = "wasm"))]
self.connect_claude(ctx);
}
ProviderSetupViewAction::SignOutClaude => {
if let Some(reference) = self.draft_api_key.take() {
crate::ai::claude_subscription::remove_credential(&reference, ctx);
}
self.cancel_claude_signin();
self.claude_credentials_available = false;
self.claude_signin_status = "Signed out of Claude on this device.".to_string();
self.draft_models.clear();
self.discovery_state = DiscoveryState::Idle;
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupViewAction::CancelClaudeSignIn => {
self.cancel_claude_signin();
self.claude_signin_status = "Claude sign-in cancelled.".to_string();
self.update_next_button(ctx);
ctx.notify();
}
ProviderSetupViewAction::SelectBedrockProfile(profile) => {
if self.draft_bedrock.profile != *profile {
self.draft_bedrock.profile = profile.clone();
@@ -2394,6 +2685,13 @@ fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str {
ProviderSetupProviderType::Gemini => "Google Gemini",
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
ProviderSetupProviderType::Bedrock => "AWS Bedrock",
ProviderSetupProviderType::Acp => "ACP agent runtime",
ProviderSetupProviderType::Acp => "Claude Code",
}
}
impl Drop for ProviderSetupView {
fn drop(&mut self) {
self.cancel_claude_signin();
self.cancel_acp_setup();
}
}
+9 -7
View File
@@ -1096,16 +1096,18 @@ impl BlockListElement {
.finish(),
);
let snackbar_toggle_icon;
let rounded_corners;
if self.snackbar_header_state.show_snackbar {
let (snackbar_toggle_icon, rounded_corners) = if self.snackbar_header_state.show_snackbar {
// Snackbar expanded. Show collapse button attached to bottom of snackbar.
snackbar_toggle_icon = UIIcon::Icon::ChevronUp;
rounded_corners = CornerRadius::with_top(Radius::Pixels(8.));
(
UIIcon::Icon::ChevronUp,
CornerRadius::with_top(Radius::Pixels(8.)),
)
} else {
// Snackbar collapsed. Show expand button attached to top of blocklist.
snackbar_toggle_icon = UIIcon::Icon::ChevronDown;
rounded_corners = CornerRadius::with_bottom(Radius::Pixels(8.));
(
UIIcon::Icon::ChevronDown,
CornerRadius::with_bottom(Radius::Pixels(8.)),
)
};
let icon = Container::new(
ConstrainedBox::new(Icon::new(snackbar_toggle_icon.into(), icon_color).finish())
+4 -5
View File
@@ -7231,8 +7231,7 @@ impl Input {
.active_block_mut()
.set_nld_overridden(nld_overridden);
let did_execute: bool;
if self
let did_execute = if self
.model
.lock()
.block_list()
@@ -7311,14 +7310,14 @@ impl Input {
}
self.start_block_and_write_command_to_pty(command, source, ctx);
did_execute = true;
true
} else {
// We don't want to submit the command if precmd has not
// been received. Instead, we want the user to be aware
// that the prompt might not be up to date.
send_telemetry_from_ctx!(TelemetryEvent::TriedToExecuteBeforePrecmd, ctx);
did_execute = false;
}
false
};
// Close the workflows info box if it was open.
self.clear_selected_workflow(ctx);
@@ -129,7 +129,9 @@ pub fn test_append_to_row_translation_empty_displayed_rows() {
};
// Append displayed rows 0..=1.
displayed_output.append_to_row_translation(make_displayed_rows_from_ranges(vec![0..=1]).iter());
displayed_output.append_to_row_translation(
make_displayed_rows_from_ranges(std::iter::once(0..=1).collect::<Vec<_>>()).iter(),
);
let expected_bimap = create_bimap_with_insertions(&[(0, 0), (1, 1)]);
assert_eq!(displayed_output.row_translation_map, expected_bimap.into());
@@ -146,7 +148,9 @@ pub fn test_append_to_row_translation() {
};
// Append displayed rows 6..7.
displayed_output.append_to_row_translation(make_displayed_rows_from_ranges(vec![6..=7]).iter());
displayed_output.append_to_row_translation(
make_displayed_rows_from_ranges(std::iter::once(6..=7).collect::<Vec<_>>()).iter(),
);
bimap.insert(6, 4);
bimap.insert(7, 5);
@@ -163,7 +167,8 @@ pub fn test_prepend_to_row_translation_empty_displayed_rows() {
};
// Prepend displayed rows 0..=1.
let new_line_ranges = make_displayed_rows_from_ranges(vec![0..=1]);
let new_line_ranges =
make_displayed_rows_from_ranges(std::iter::once(0..=1).collect::<Vec<_>>());
displayed_output
.displayed_rows
.splice(0..0, new_line_ranges);
@@ -186,7 +191,8 @@ pub fn test_prepend_to_row_translation() {
};
// Prepend displayed rows 0..=1.
let new_line_ranges = make_displayed_rows_from_ranges(vec![0..=1]);
let new_line_ranges =
make_displayed_rows_from_ranges(std::iter::once(0..=1).collect::<Vec<_>>());
displayed_output
.displayed_rows
.splice(0..0, new_line_ranges);
@@ -209,7 +215,8 @@ pub fn test_replace_rows_from_row_translation() {
// Replace displayed row 2 with displayed row 3.
let replace_range = 1..2;
let new_line_ranges = make_displayed_rows_from_ranges(vec![3..=3]);
let new_line_ranges =
make_displayed_rows_from_ranges(std::iter::once(3..=3).collect::<Vec<_>>());
displayed_output
.displayed_rows
.splice(replace_range.clone(), new_line_ranges);
@@ -231,7 +238,8 @@ pub fn test_replace_rows_from_row_translation_from_middle_to_end() {
// Replace displayed rows 2, 4 with displayed row 3..=5.
let replace_range = 1..3;
let new_line_ranges = make_displayed_rows_from_ranges(vec![3..=5]);
let new_line_ranges =
make_displayed_rows_from_ranges(std::iter::once(3..=5).collect::<Vec<_>>());
displayed_output
.displayed_rows
.splice(replace_range.clone(), new_line_ranges);
@@ -253,7 +261,8 @@ pub fn test_replace_rows_from_row_translation_from_beginning_to_middle() {
// Replace displayed rows 0, 2 with displayed row 0..=1.
let replace_range = 0..2;
let new_line_ranges = make_displayed_rows_from_ranges(vec![0..=1]);
let new_line_ranges =
make_displayed_rows_from_ranges(std::iter::once(0..=1).collect::<Vec<_>>());
displayed_output
.displayed_rows
.splice(replace_range.clone(), new_line_ranges);
@@ -268,7 +277,7 @@ pub fn test_replace_rows_from_row_translation_replace_beginning_row() {
// Start with displayed rows 0.
let bimap = create_bimap_with_insertions(&[(0, 0)]);
let mut displayed_output = DisplayedOutput {
displayed_rows: make_displayed_rows_from_ranges(vec![0..=0]),
displayed_rows: make_displayed_rows_from_ranges(std::iter::once(0..=0).collect::<Vec<_>>()),
height: 1,
row_translation_map: bimap.into(),
};
@@ -276,7 +285,8 @@ pub fn test_replace_rows_from_row_translation_replace_beginning_row() {
// Replace displayed rows 0 with displayed row 0.
// This is intended to simulate what happens if we process dirty bytes multiple times.
let replace_range = 0..1;
let new_line_ranges = make_displayed_rows_from_ranges(vec![0..=0]);
let new_line_ranges =
make_displayed_rows_from_ranges(std::iter::once(0..=0).collect::<Vec<_>>());
displayed_output
.displayed_rows
.splice(replace_range.clone(), new_line_ranges);
@@ -298,7 +308,8 @@ pub fn test_replace_rows_from_row_translation_break_range() {
// Replace displayed rows 2..=3 with displayed row 2.
let replace_range = 1..2;
let new_line_ranges = make_displayed_rows_from_ranges(vec![2..=2]);
let new_line_ranges =
make_displayed_rows_from_ranges(std::iter::once(2..=2).collect::<Vec<_>>());
displayed_output
.displayed_rows
.splice(replace_range.clone(), new_line_ranges);
@@ -329,7 +340,8 @@ fn test_replace_rows_from_row_translation_no_removed_entries() {
// Insert displayed rows 3..=4.
let replace_range = 1..1;
let new_line_ranges = make_displayed_rows_from_ranges(vec![3..=4]);
let new_line_ranges =
make_displayed_rows_from_ranges(std::iter::once(3..=4).collect::<Vec<_>>());
displayed_output
.displayed_rows
.splice(replace_range.clone(), new_line_ranges);
@@ -537,7 +549,8 @@ fn test_new_from_displayed_lines() {
#[test]
fn test_extend_displayed_lines() {
let displayed_rows = make_displayed_rows_from_ranges(vec![1..=3]);
let displayed_rows =
make_displayed_rows_from_ranges(std::iter::once(1..=3).collect::<Vec<_>>());
let mut displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
displayed_output.extend_displayed_lines(make_displayed_rows_from_ranges(vec![7..=7, 14..=18]));
@@ -561,7 +574,8 @@ fn test_extend_displayed_lines() {
#[test]
fn test_prepend_displayed_lines() {
let displayed_rows = make_displayed_rows_from_ranges(vec![14..=18]);
let displayed_rows =
make_displayed_rows_from_ranges(std::iter::once(14..=18).collect::<Vec<_>>());
let mut displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
displayed_output.prepend_displayed_lines(make_displayed_rows_from_ranges(vec![1..=3, 7..=7]));
@@ -626,7 +640,8 @@ fn test_splice_displayed_lines_nothing_replaced() {
let displayed_rows = make_displayed_rows_from_ranges(vec![0..=1, 4..=5, 14..=16, 19..=19]);
let mut displayed_output = DisplayedOutput::new_from_displayed_lines(displayed_rows.clone());
let new_displayed_rows = make_displayed_rows_from_ranges(vec![8..=9]);
let new_displayed_rows =
make_displayed_rows_from_ranges(std::iter::once(8..=9).collect::<Vec<_>>());
displayed_output.splice_displayed_lines(2..2, new_displayed_rows);
let expected_displayed_rows =
+23 -14
View File
@@ -818,11 +818,11 @@ pub fn test_add_context_lines_at_grid_bounds() {
line\r\n\
line\r\n",
);
let line_ranges = [2..=2];
let line_range = 2..=2;
let displayed_rows = blockgrid
.grid_handler()
.add_context_lines(line_ranges.iter(), 5);
.add_context_lines(std::iter::once(&line_range), 5);
assert_eq!(
displayed_rows,
vec![
@@ -845,11 +845,11 @@ pub fn test_add_context_lines_at_grid_bounds() {
#[test]
pub fn test_add_context_lines_no_context() {
let blockgrid = mock_blockgrid("match");
let line_ranges = [0..=0];
let line_range = 0..=0;
let displayed_rows = blockgrid
.grid_handler()
.add_context_lines(line_ranges.iter(), 5);
.add_context_lines(std::iter::once(&line_range), 5);
assert_eq!(
displayed_rows,
vec![DisplayedRows {
@@ -949,7 +949,8 @@ pub fn test_update_filtered_dirty_lines() {
grid.grid_handler_mut()
.set_displayed_output(displayed_output);
let new_displayed_rows = make_displayed_rows_from_ranges(vec![5..=5]);
let new_displayed_rows =
make_displayed_rows_from_ranges(std::iter::once(5..=5).collect::<Vec<_>>());
grid.grid_handler_mut()
.update_filtered_dirty_lines(5..=17, new_displayed_rows, 0);
@@ -968,7 +969,8 @@ pub fn test_update_filtered_dirty_lines_at_range_borders() {
grid.grid_handler_mut()
.set_displayed_output(displayed_output);
let new_displayed_rows = make_displayed_rows_from_ranges(vec![11..=12]);
let new_displayed_rows =
make_displayed_rows_from_ranges(std::iter::once(11..=12).collect::<Vec<_>>());
grid.grid_handler_mut()
.update_filtered_dirty_lines(6..=15, new_displayed_rows, 0);
@@ -983,7 +985,8 @@ pub fn test_update_filtered_dirty_lines_at_range_borders() {
grid.grid_handler_mut()
.set_displayed_output(displayed_output);
let new_displayed_rows = make_displayed_rows_from_ranges(vec![11..=12]);
let new_displayed_rows =
make_displayed_rows_from_ranges(std::iter::once(11..=12).collect::<Vec<_>>());
grid.grid_handler_mut()
.update_filtered_dirty_lines(10..=13, new_displayed_rows, 0);
@@ -1040,7 +1043,8 @@ pub fn test_update_filtered_dirty_lines_replace_one_line() {
grid.grid_handler_mut()
.set_displayed_output(displayed_output);
let new_displayed_rows = make_displayed_rows_from_ranges(vec![7..=7]);
let new_displayed_rows =
make_displayed_rows_from_ranges(std::iter::once(7..=7).collect::<Vec<_>>());
grid.grid_handler_mut()
.update_filtered_dirty_lines(6..=10, new_displayed_rows, 0);
@@ -1059,7 +1063,8 @@ pub fn test_update_filtered_dirty_lines_replace_single_row_line() {
grid.grid_handler_mut()
.set_displayed_output(displayed_output);
let new_displayed_rows = make_displayed_rows_from_ranges(vec![6..=8]);
let new_displayed_rows =
make_displayed_rows_from_ranges(std::iter::once(6..=8).collect::<Vec<_>>());
grid.grid_handler_mut()
.update_filtered_dirty_lines(7..=7, new_displayed_rows, 0);
@@ -1078,7 +1083,8 @@ pub fn test_update_filtered_dirty_lines_replace_at_start() {
grid.grid_handler_mut()
.set_displayed_output(displayed_output);
let new_displayed_rows = make_displayed_rows_from_ranges(vec![3..=5]);
let new_displayed_rows =
make_displayed_rows_from_ranges(std::iter::once(3..=5).collect::<Vec<_>>());
grid.grid_handler_mut()
.update_filtered_dirty_lines(1..=11, new_displayed_rows, 0);
@@ -1097,7 +1103,8 @@ pub fn test_update_filtered_dirty_lines_replace_at_end() {
grid.grid_handler_mut()
.set_displayed_output(displayed_output);
let new_displayed_rows = make_displayed_rows_from_ranges(vec![16..=18]);
let new_displayed_rows =
make_displayed_rows_from_ranges(std::iter::once(16..=18).collect::<Vec<_>>());
grid.grid_handler_mut()
.update_filtered_dirty_lines(12..=16, new_displayed_rows, 0);
@@ -1830,7 +1837,9 @@ fn test_truncate_cursor_updates_displayed_output() {
grid[4][0].c = 'e';
// Initialize displayed output with all rows in the grid.
grid_handler.displayed_output = Some(DisplayedOutput::new_for_test(vec![0..=4]));
grid_handler.displayed_output = Some(DisplayedOutput::new_for_test(
std::iter::once(0..=4).collect::<Vec<_>>(),
));
// Set the cursor to the middle of the grid
grid_handler.set_cursor_point(2, 0);
@@ -2064,7 +2073,7 @@ pub fn test_find_non_matching_lines_all_returned() {
",
);
let expected_non_matching_lines = vec![0..=10];
let expected_non_matching_lines = std::iter::once(0..=10).collect::<Vec<_>>();
let dfas = RegexDFAs::new("match").unwrap();
let non_matching_lines = blockgrid.grid_handler().find_non_matching_lines(&dfas);
@@ -2095,7 +2104,7 @@ pub fn test_find_non_matching_lines_in_range_all_returned() {
",
);
let expected_non_matching_lines = vec![2..=12];
let expected_non_matching_lines = std::iter::once(2..=12).collect::<Vec<_>>();
let dfas = RegexDFAs::new("match").unwrap();
let non_matching_lines = blockgrid.grid_handler().find_non_matching_lines_in_range(
+3 -1
View File
@@ -1689,7 +1689,9 @@ fn decode_uuid_hex(hex: &str) -> Option<Vec<u8>> {
return None;
}
hex.chunks_exact(2)
hex.as_chunks::<2>()
.0
.iter()
.map(|pair| {
let high = (pair[0] as char).to_digit(16)?;
let low = (pair[1] as char).to_digit(16)?;
+1 -1
View File
@@ -21420,7 +21420,7 @@ impl Workspace {
Align::new(
self.render_tab_bar_icon_button(
appearance,
icons::Icon::Stars,
icons::Icon::Gear,
&self.mouse_states.settings_icon,
WorkspaceAction::ShowSettings,
"Settings".to_string(),