mirror of
https://github.com/getAsterisk/claudia.git
synced 2025-07-07 18:15:00 +00:00
feat(claude-binary): implement robust version selector with enhanced binary detection
This commit provides a comprehensive solution to Claude binary detection issues by implementing a user-friendly version selector UI and improving the binary discovery logic. It addresses all concerns raised in multiple PRs and comments. Changes: - Add ClaudeVersionSelector component for selecting from multiple installations - Update ClaudeBinaryDialog to use version selector instead of manual path input - Fix unused variable warning in production builds (claude.rs:442) - Improve select_best_installation to handle production build restrictions - Add listClaudeInstallations API endpoint to fetch all available installations - Make Claude version indicator clickable to navigate to Settings - Move Claude installation selector to General tab in Settings (per user request) - Enhance dialog UX with loading states and clear installation instructions - Add Radix UI radio-group dependency for version selector Fixes: - Production build warning about unused claude_path variable - Version detection failures in production builds due to process restrictions - Poor UX when Claude binary is not found (now shows helpful dialog) - Inability to easily switch between multiple Claude installations This implementation takes inspiration from: - PR #3: Version selector dropdown approach (preferred by users) - PR #4: Binary detection improvements and path validation - PR #39: Additional detection methods and error handling - Commit5a29f9a
: Shared claude binary detection module architecture Addresses feedback from: - getAsterisk/claudia#4 (comment): User preference for dropdown selector - Production build restrictions that prevent version detection - Need for better error handling when Claude is not installed The solution provides a seamless experience whether Claude is installed via: - npm/yarn/bun global installation - nvm-managed Node.js versions - Homebrew on macOS - System-wide installation - Local user installation (~/.local/bin, etc.) Refs: #3, #4, #39,5a29f9a
This commit is contained in:
parent
97290e5665
commit
c48a63f170
14 changed files with 556 additions and 77 deletions
3
bun.lock
3
bun.lock
|
@ -9,6 +9,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-select": "^2.1.3",
|
"@radix-ui/react-select": "^2.1.3",
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
|
@ -286,6 +287,8 @@
|
||||||
|
|
||||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g=="],
|
||||||
|
|
||||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
|
||||||
|
|
||||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
|
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-select": "^2.1.3",
|
"@radix-ui/react-select": "^2.1.3",
|
||||||
"@radix-ui/react-switch": "^1.1.3",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
|
|
|
@ -6,9 +6,10 @@ use log::{info, warn, debug, error};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
/// Represents a Claude installation with metadata
|
/// Represents a Claude installation with metadata
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ClaudeInstallation {
|
pub struct ClaudeInstallation {
|
||||||
/// Full path to the Claude binary
|
/// Full path to the Claude binary
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
@ -68,6 +69,55 @@ pub fn find_claude_binary(app_handle: &tauri::AppHandle) -> Result<String, Strin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Discovers all available Claude installations and returns them for selection
|
||||||
|
/// This allows UI to show a version selector
|
||||||
|
pub fn discover_claude_installations() -> Vec<ClaudeInstallation> {
|
||||||
|
info!("Discovering all Claude installations...");
|
||||||
|
|
||||||
|
let installations = discover_all_installations();
|
||||||
|
|
||||||
|
// Sort by version (highest first), then by source preference
|
||||||
|
let mut sorted = installations;
|
||||||
|
sorted.sort_by(|a, b| {
|
||||||
|
match (&a.version, &b.version) {
|
||||||
|
(Some(v1), Some(v2)) => {
|
||||||
|
// Compare versions in descending order (newest first)
|
||||||
|
match compare_versions(v2, v1) {
|
||||||
|
Ordering::Equal => {
|
||||||
|
// If versions are equal, prefer by source
|
||||||
|
source_preference(a).cmp(&source_preference(b))
|
||||||
|
}
|
||||||
|
other => other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(_), None) => Ordering::Less, // Version comes before no version
|
||||||
|
(None, Some(_)) => Ordering::Greater,
|
||||||
|
(None, None) => source_preference(a).cmp(&source_preference(b))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a preference score for installation sources (lower is better)
|
||||||
|
fn source_preference(installation: &ClaudeInstallation) -> u8 {
|
||||||
|
match installation.source.as_str() {
|
||||||
|
"which" => 1,
|
||||||
|
"homebrew" => 2,
|
||||||
|
"system" => 3,
|
||||||
|
source if source.starts_with("nvm") => 4,
|
||||||
|
"local-bin" => 5,
|
||||||
|
"claude-local" => 6,
|
||||||
|
"npm-global" => 7,
|
||||||
|
"yarn" | "yarn-global" => 8,
|
||||||
|
"bun" => 9,
|
||||||
|
"node-modules" => 10,
|
||||||
|
"home-bin" => 11,
|
||||||
|
"PATH" => 12,
|
||||||
|
_ => 13,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Discovers all Claude installations on the system
|
/// Discovers all Claude installations on the system
|
||||||
fn discover_all_installations() -> Vec<ClaudeInstallation> {
|
fn discover_all_installations() -> Vec<ClaudeInstallation> {
|
||||||
let mut installations = Vec::new();
|
let mut installations = Vec::new();
|
||||||
|
@ -263,19 +313,25 @@ fn extract_version_from_output(stdout: &[u8]) -> Option<String> {
|
||||||
|
|
||||||
/// Select the best installation based on version
|
/// Select the best installation based on version
|
||||||
fn select_best_installation(installations: Vec<ClaudeInstallation>) -> Option<ClaudeInstallation> {
|
fn select_best_installation(installations: Vec<ClaudeInstallation>) -> Option<ClaudeInstallation> {
|
||||||
|
// In production builds, version information may not be retrievable because
|
||||||
|
// spawning external processes can be restricted. We therefore no longer
|
||||||
|
// discard installations that lack a detected version – the mere presence
|
||||||
|
// of a readable binary on disk is enough to consider it valid. We still
|
||||||
|
// prefer binaries with version information when it is available so that
|
||||||
|
// in development builds we keep the previous behaviour of picking the
|
||||||
|
// most recent version.
|
||||||
installations.into_iter()
|
installations.into_iter()
|
||||||
.filter(|i| {
|
|
||||||
// Prefer installations with known versions
|
|
||||||
i.version.is_some() || i.path == "claude"
|
|
||||||
})
|
|
||||||
.max_by(|a, b| {
|
.max_by(|a, b| {
|
||||||
// First compare by version presence
|
|
||||||
match (&a.version, &b.version) {
|
match (&a.version, &b.version) {
|
||||||
|
// If both have versions, compare them semantically.
|
||||||
(Some(v1), Some(v2)) => compare_versions(v1, v2),
|
(Some(v1), Some(v2)) => compare_versions(v1, v2),
|
||||||
|
// Prefer the entry that actually has version information.
|
||||||
(Some(_), None) => Ordering::Greater,
|
(Some(_), None) => Ordering::Greater,
|
||||||
(None, Some(_)) => Ordering::Less,
|
(None, Some(_)) => Ordering::Less,
|
||||||
|
// Neither have version info: prefer the one that is not just
|
||||||
|
// the bare "claude" lookup from PATH, because that may fail
|
||||||
|
// at runtime if PATH is sandbox-stripped.
|
||||||
(None, None) => {
|
(None, None) => {
|
||||||
// Both have no version, prefer non-PATH entries
|
|
||||||
if a.path == "claude" && b.path != "claude" {
|
if a.path == "claude" && b.path != "claude" {
|
||||||
Ordering::Less
|
Ordering::Less
|
||||||
} else if a.path != "claude" && b.path == "claude" {
|
} else if a.path != "claude" && b.path == "claude" {
|
||||||
|
|
|
@ -1807,6 +1807,18 @@ pub async fn set_claude_binary_path(db: State<'_, AgentDb>, path: String) -> Res
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all available Claude installations on the system
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_claude_installations() -> Result<Vec<crate::claude_binary::ClaudeInstallation>, String> {
|
||||||
|
let installations = crate::claude_binary::discover_claude_installations();
|
||||||
|
|
||||||
|
if installations.is_empty() {
|
||||||
|
return Err("No Claude Code installations found on the system".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(installations)
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper function to create a tokio Command with proper environment variables
|
/// Helper function to create a tokio Command with proper environment variables
|
||||||
/// This ensures commands like Claude can find Node.js and other dependencies
|
/// This ensures commands like Claude can find Node.js and other dependencies
|
||||||
fn create_command_with_env(program: &str) -> Command {
|
fn create_command_with_env(program: &str) -> Command {
|
||||||
|
|
|
@ -440,6 +440,10 @@ pub async fn get_claude_settings() -> Result<ClaudeSettings, String> {
|
||||||
pub async fn open_new_session(app: AppHandle, path: Option<String>) -> Result<String, String> {
|
pub async fn open_new_session(app: AppHandle, path: Option<String>) -> Result<String, String> {
|
||||||
log::info!("Opening new Claude Code session at path: {:?}", path);
|
log::info!("Opening new Claude Code session at path: {:?}", path);
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
let _claude_path = find_claude_binary(&app)?;
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
let claude_path = find_claude_binary(&app)?;
|
let claude_path = find_claude_binary(&app)?;
|
||||||
|
|
||||||
// In production, we can't use std::process::Command directly
|
// In production, we can't use std::process::Command directly
|
||||||
|
|
|
@ -24,12 +24,12 @@ use commands::agents::{
|
||||||
init_database, list_agents, create_agent, update_agent, delete_agent,
|
init_database, list_agents, create_agent, update_agent, delete_agent,
|
||||||
get_agent, execute_agent, list_agent_runs, get_agent_run,
|
get_agent, execute_agent, list_agent_runs, get_agent_run,
|
||||||
get_agent_run_with_real_time_metrics, list_agent_runs_with_metrics,
|
get_agent_run_with_real_time_metrics, list_agent_runs_with_metrics,
|
||||||
migrate_agent_runs_to_session_ids, list_running_sessions, kill_agent_session,
|
list_running_sessions, kill_agent_session,
|
||||||
get_session_status, cleanup_finished_processes, get_session_output,
|
get_session_status, cleanup_finished_processes, get_session_output,
|
||||||
get_live_session_output, stream_session_output, get_claude_binary_path,
|
get_live_session_output, stream_session_output, get_claude_binary_path,
|
||||||
set_claude_binary_path, export_agent, export_agent_to_file, import_agent,
|
set_claude_binary_path, export_agent, export_agent_to_file, import_agent,
|
||||||
import_agent_from_file, fetch_github_agents, fetch_github_agent_content,
|
import_agent_from_file, fetch_github_agents, fetch_github_agent_content,
|
||||||
import_agent_from_github, AgentDb
|
import_agent_from_github, list_claude_installations, AgentDb
|
||||||
};
|
};
|
||||||
use commands::sandbox::{
|
use commands::sandbox::{
|
||||||
list_sandbox_profiles, create_sandbox_profile, update_sandbox_profile, delete_sandbox_profile,
|
list_sandbox_profiles, create_sandbox_profile, update_sandbox_profile, delete_sandbox_profile,
|
||||||
|
@ -139,19 +139,11 @@ fn main() {
|
||||||
update_agent,
|
update_agent,
|
||||||
delete_agent,
|
delete_agent,
|
||||||
get_agent,
|
get_agent,
|
||||||
export_agent,
|
|
||||||
export_agent_to_file,
|
|
||||||
import_agent,
|
|
||||||
import_agent_from_file,
|
|
||||||
fetch_github_agents,
|
|
||||||
fetch_github_agent_content,
|
|
||||||
import_agent_from_github,
|
|
||||||
execute_agent,
|
execute_agent,
|
||||||
list_agent_runs,
|
list_agent_runs,
|
||||||
get_agent_run,
|
get_agent_run,
|
||||||
get_agent_run_with_real_time_metrics,
|
|
||||||
list_agent_runs_with_metrics,
|
list_agent_runs_with_metrics,
|
||||||
migrate_agent_runs_to_session_ids,
|
get_agent_run_with_real_time_metrics,
|
||||||
list_running_sessions,
|
list_running_sessions,
|
||||||
kill_agent_session,
|
kill_agent_session,
|
||||||
get_session_status,
|
get_session_status,
|
||||||
|
@ -161,6 +153,14 @@ fn main() {
|
||||||
stream_session_output,
|
stream_session_output,
|
||||||
get_claude_binary_path,
|
get_claude_binary_path,
|
||||||
set_claude_binary_path,
|
set_claude_binary_path,
|
||||||
|
list_claude_installations,
|
||||||
|
export_agent,
|
||||||
|
export_agent_to_file,
|
||||||
|
import_agent,
|
||||||
|
import_agent_from_file,
|
||||||
|
fetch_github_agents,
|
||||||
|
fetch_github_agent_content,
|
||||||
|
import_agent_from_github,
|
||||||
list_sandbox_profiles,
|
list_sandbox_profiles,
|
||||||
get_sandbox_profile,
|
get_sandbox_profile,
|
||||||
create_sandbox_profile,
|
create_sandbox_profile,
|
||||||
|
|
|
@ -70,7 +70,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [projectPath, setProjectPath] = useState("");
|
const [projectPath, setProjectPath] = useState("");
|
||||||
const [task, setTask] = useState("");
|
const [task, setTask] = useState(agent.default_task || "");
|
||||||
const [model, setModel] = useState(agent.model || "sonnet");
|
const [model, setModel] = useState(agent.model || "sonnet");
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
|
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
|
||||||
|
@ -646,7 +646,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
||||||
<Input
|
<Input
|
||||||
value={task}
|
value={task}
|
||||||
onChange={(e) => setTask(e.target.value)}
|
onChange={(e) => setTask(e.target.value)}
|
||||||
placeholder={agent.default_task || "Enter the task for the agent"}
|
placeholder="Enter the task for the agent"
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onKeyPress={(e) => {
|
onKeyPress={(e) => {
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { api } from "@/lib/api";
|
import { api, type ClaudeInstallation } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { ExternalLink, FileQuestion, Terminal } from "lucide-react";
|
import { ExternalLink, FileQuestion, Terminal, AlertCircle, Loader2 } from "lucide-react";
|
||||||
|
import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
|
||||||
|
|
||||||
interface ClaudeBinaryDialogProps {
|
interface ClaudeBinaryDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
@ -13,18 +13,39 @@ interface ClaudeBinaryDialogProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: ClaudeBinaryDialogProps) {
|
export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: ClaudeBinaryDialogProps) {
|
||||||
const [binaryPath, setBinaryPath] = useState("");
|
const [selectedInstallation, setSelectedInstallation] = useState<ClaudeInstallation | null>(null);
|
||||||
const [isValidating, setIsValidating] = useState(false);
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
const [hasInstallations, setHasInstallations] = useState(true);
|
||||||
|
const [checkingInstallations, setCheckingInstallations] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
checkInstallations();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const checkInstallations = async () => {
|
||||||
|
try {
|
||||||
|
setCheckingInstallations(true);
|
||||||
|
const installations = await api.listClaudeInstallations();
|
||||||
|
setHasInstallations(installations.length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
// If the API call fails, it means no installations found
|
||||||
|
setHasInstallations(false);
|
||||||
|
} finally {
|
||||||
|
setCheckingInstallations(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!binaryPath.trim()) {
|
if (!selectedInstallation) {
|
||||||
onError("Please enter a valid path");
|
onError("Please select a Claude installation");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsValidating(true);
|
setIsValidating(true);
|
||||||
try {
|
try {
|
||||||
await api.setClaudeBinaryPath(binaryPath.trim());
|
await api.setClaudeBinaryPath(selectedInstallation.path);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -37,46 +58,58 @@ export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: C
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<FileQuestion className="w-5 h-5" />
|
<FileQuestion className="w-5 h-5" />
|
||||||
Couldn't locate Claude Code installation
|
Select Claude Code Installation
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="space-y-3 mt-4">
|
<DialogDescription className="space-y-3 mt-4">
|
||||||
<p>
|
{checkingInstallations ? (
|
||||||
Claude Code was not found in any of the common installation locations.
|
<div className="flex items-center justify-center py-8">
|
||||||
Please specify the path to the Claude binary manually.
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
</p>
|
<span className="ml-2 text-sm text-muted-foreground">Searching for Claude installations...</span>
|
||||||
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
|
</div>
|
||||||
<Terminal className="w-4 h-4 text-muted-foreground" />
|
) : hasInstallations ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p>
|
||||||
<span className="font-medium">Tip:</span> Run{" "}
|
Multiple Claude Code installations were found on your system.
|
||||||
<code className="px-1 py-0.5 bg-black/10 dark:bg-white/10 rounded">which claude</code>{" "}
|
Please select which one you'd like to use.
|
||||||
in your terminal to find the installation path
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
Claude Code was not found in any of the common installation locations.
|
||||||
|
Please install Claude Code to continue.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
|
||||||
|
<AlertCircle className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium">Searched locations:</span> PATH, /usr/local/bin,
|
||||||
|
/opt/homebrew/bin, ~/.nvm/versions/node/*/bin, ~/.claude/local, ~/.local/bin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!checkingInstallations && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
|
||||||
|
<Terminal className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium">Tip:</span> You can install Claude Code using{" "}
|
||||||
|
<code className="px-1 py-0.5 bg-black/10 dark:bg-white/10 rounded">npm install -g @claude</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="py-4">
|
{!checkingInstallations && hasInstallations && (
|
||||||
<Input
|
<div className="py-4">
|
||||||
type="text"
|
<ClaudeVersionSelector
|
||||||
placeholder="/usr/local/bin/claude"
|
onSelect={(installation) => setSelectedInstallation(installation)}
|
||||||
value={binaryPath}
|
selectedPath={null}
|
||||||
onChange={(e) => setBinaryPath(e.target.value)}
|
/>
|
||||||
onKeyDown={(e) => {
|
</div>
|
||||||
if (e.key === "Enter" && !isValidating) {
|
)}
|
||||||
handleSave();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
Common locations: /usr/local/bin/claude, /opt/homebrew/bin/claude, ~/.claude/local/claude
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-3">
|
<DialogFooter className="gap-3">
|
||||||
<Button
|
<Button
|
||||||
|
@ -94,8 +127,11 @@ export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: C
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={isValidating || !binaryPath.trim()}>
|
<Button
|
||||||
{isValidating ? "Validating..." : "Save Path"}
|
onClick={handleSave}
|
||||||
|
disabled={isValidating || !selectedInstallation || !hasInstallations}
|
||||||
|
>
|
||||||
|
{isValidating ? "Validating..." : hasInstallations ? "Save Selection" : "No Installations Found"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
231
src/components/ClaudeVersionSelector.tsx
Normal file
231
src/components/ClaudeVersionSelector.tsx
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { api, type ClaudeInstallation } from "@/lib/api";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Loader2, Terminal, Package, Check } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ClaudeVersionSelectorProps {
|
||||||
|
/**
|
||||||
|
* Currently selected Claude installation path
|
||||||
|
*/
|
||||||
|
selectedPath?: string | null;
|
||||||
|
/**
|
||||||
|
* Callback when a Claude installation is selected
|
||||||
|
*/
|
||||||
|
onSelect: (installation: ClaudeInstallation) => void;
|
||||||
|
/**
|
||||||
|
* Optional className for styling
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Whether to show a save button (for settings page)
|
||||||
|
*/
|
||||||
|
showSaveButton?: boolean;
|
||||||
|
/**
|
||||||
|
* Callback when save button is clicked
|
||||||
|
*/
|
||||||
|
onSave?: () => void;
|
||||||
|
/**
|
||||||
|
* Whether the save operation is in progress
|
||||||
|
*/
|
||||||
|
isSaving?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClaudeVersionSelector component for selecting Claude Code installations
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <ClaudeVersionSelector
|
||||||
|
* selectedPath={currentPath}
|
||||||
|
* onSelect={(installation) => setSelectedInstallation(installation)}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
export const ClaudeVersionSelector: React.FC<ClaudeVersionSelectorProps> = ({
|
||||||
|
selectedPath,
|
||||||
|
onSelect,
|
||||||
|
className,
|
||||||
|
showSaveButton = false,
|
||||||
|
onSave,
|
||||||
|
isSaving = false,
|
||||||
|
}) => {
|
||||||
|
const [installations, setInstallations] = useState<ClaudeInstallation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedInstallation, setSelectedInstallation] = useState<ClaudeInstallation | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadInstallations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Update selected installation when selectedPath changes
|
||||||
|
if (selectedPath && installations.length > 0) {
|
||||||
|
const found = installations.find(i => i.path === selectedPath);
|
||||||
|
if (found) {
|
||||||
|
setSelectedInstallation(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedPath, installations]);
|
||||||
|
|
||||||
|
const loadInstallations = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const foundInstallations = await api.listClaudeInstallations();
|
||||||
|
setInstallations(foundInstallations);
|
||||||
|
|
||||||
|
// If we have a selected path, find and select it
|
||||||
|
if (selectedPath) {
|
||||||
|
const found = foundInstallations.find(i => i.path === selectedPath);
|
||||||
|
if (found) {
|
||||||
|
setSelectedInstallation(found);
|
||||||
|
}
|
||||||
|
} else if (foundInstallations.length > 0) {
|
||||||
|
// Auto-select the first (best) installation
|
||||||
|
setSelectedInstallation(foundInstallations[0]);
|
||||||
|
onSelect(foundInstallations[0]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load Claude installations:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load Claude installations");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (installation: ClaudeInstallation) => {
|
||||||
|
setSelectedInstallation(installation);
|
||||||
|
onSelect(installation);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSourceIcon = (source: string) => {
|
||||||
|
if (source.includes("nvm")) return <Package className="w-4 h-4" />;
|
||||||
|
return <Terminal className="w-4 h-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSourceLabel = (source: string) => {
|
||||||
|
if (source === "which") return "System PATH";
|
||||||
|
if (source === "homebrew") return "Homebrew";
|
||||||
|
if (source === "system") return "System";
|
||||||
|
if (source.startsWith("nvm")) return source.replace("nvm ", "NVM ");
|
||||||
|
if (source === "local-bin") return "Local bin";
|
||||||
|
if (source === "claude-local") return "Claude local";
|
||||||
|
if (source === "npm-global") return "NPM global";
|
||||||
|
if (source === "yarn" || source === "yarn-global") return "Yarn";
|
||||||
|
if (source === "bun") return "Bun";
|
||||||
|
return source;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center justify-center py-8", className)}>
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className={cn("p-4", className)}>
|
||||||
|
<div className="text-sm text-destructive">{error}</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installations.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className={cn("p-4", className)}>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No Claude Code installations found on your system.
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-4", className)}>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium mb-3 block">
|
||||||
|
Select Claude Code Installation
|
||||||
|
</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={selectedInstallation?.path}
|
||||||
|
onValueChange={(value: string) => {
|
||||||
|
const installation = installations.find(i => i.path === value);
|
||||||
|
if (installation) {
|
||||||
|
handleSelect(installation);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{installations.map((installation) => (
|
||||||
|
<Card
|
||||||
|
key={installation.path}
|
||||||
|
className={cn(
|
||||||
|
"relative cursor-pointer transition-colors",
|
||||||
|
selectedInstallation?.path === installation.path
|
||||||
|
? "border-primary"
|
||||||
|
: "hover:border-muted-foreground/50"
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelect(installation)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start p-4">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={installation.path}
|
||||||
|
id={installation.path}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div className="ml-3 flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{getSourceIcon(installation.source)}
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{getSourceLabel(installation.source)}
|
||||||
|
</span>
|
||||||
|
{installation.version && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
v{installation.version}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{selectedPath === installation.path && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
<Check className="w-3 h-3 mr-1" />
|
||||||
|
Current
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||||
|
{installation.path}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSaveButton && onSave && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!selectedInstallation || isSaving}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save Selection"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -20,10 +20,12 @@ import { Card } from "@/components/ui/card";
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type ClaudeSettings
|
type ClaudeSettings,
|
||||||
|
type ClaudeInstallation
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||||
|
import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
/**
|
/**
|
||||||
|
@ -69,12 +71,30 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||||
// Environment variables state
|
// Environment variables state
|
||||||
const [envVars, setEnvVars] = useState<EnvironmentVariable[]>([]);
|
const [envVars, setEnvVars] = useState<EnvironmentVariable[]>([]);
|
||||||
|
|
||||||
|
// Claude binary path state
|
||||||
|
const [currentBinaryPath, setCurrentBinaryPath] = useState<string | null>(null);
|
||||||
|
const [selectedInstallation, setSelectedInstallation] = useState<ClaudeInstallation | null>(null);
|
||||||
|
const [binaryPathChanged, setBinaryPathChanged] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
// Load settings on mount
|
// Load settings on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
loadClaudeBinaryPath();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the current Claude binary path
|
||||||
|
*/
|
||||||
|
const loadClaudeBinaryPath = async () => {
|
||||||
|
try {
|
||||||
|
const path = await api.getClaudeBinaryPath();
|
||||||
|
setCurrentBinaryPath(path);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load Claude binary path:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the current Claude settings
|
* Loads the current Claude settings
|
||||||
*/
|
*/
|
||||||
|
@ -159,6 +179,14 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||||
|
|
||||||
await api.saveClaudeSettings(updatedSettings);
|
await api.saveClaudeSettings(updatedSettings);
|
||||||
setSettings(updatedSettings);
|
setSettings(updatedSettings);
|
||||||
|
|
||||||
|
// Save Claude binary path if changed
|
||||||
|
if (binaryPathChanged && selectedInstallation) {
|
||||||
|
await api.setClaudeBinaryPath(selectedInstallation.path);
|
||||||
|
setCurrentBinaryPath(selectedInstallation.path);
|
||||||
|
setBinaryPathChanged(false);
|
||||||
|
}
|
||||||
|
|
||||||
setToast({ message: "Settings saved successfully!", type: "success" });
|
setToast({ message: "Settings saved successfully!", type: "success" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save settings:", err);
|
console.error("Failed to save settings:", err);
|
||||||
|
@ -246,6 +274,13 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||||
setEnvVars(prev => prev.filter(envVar => envVar.id !== id));
|
setEnvVars(prev => prev.filter(envVar => envVar.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Claude installation selection
|
||||||
|
*/
|
||||||
|
const handleClaudeInstallationSelect = (installation: ClaudeInstallation) => {
|
||||||
|
setSelectedInstallation(installation);
|
||||||
|
setBinaryPathChanged(installation.path !== currentBinaryPath);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full bg-background text-foreground", className)}>
|
<div className={cn("flex flex-col h-full bg-background text-foreground", className)}>
|
||||||
|
@ -391,6 +426,25 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||||
How long to retain chat transcripts locally (default: 30 days)
|
How long to retain chat transcripts locally (default: 30 days)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Claude Binary Path Selector */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium mb-2 block">Claude Code Installation</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
|
Select which Claude Code installation to use
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ClaudeVersionSelector
|
||||||
|
selectedPath={currentBinaryPath}
|
||||||
|
onSelect={handleClaudeInstallationSelect}
|
||||||
|
/>
|
||||||
|
{binaryPathChanged && (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
⚠️ Claude binary path has been changed. Remember to save your settings.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
@ -95,21 +95,28 @@ export const Topbar: React.FC<TopbarProps> = ({
|
||||||
if (!versionStatus) return null;
|
if (!versionStatus) return null;
|
||||||
|
|
||||||
const statusContent = (
|
const statusContent = (
|
||||||
<div className="flex items-center space-x-2 text-xs">
|
<Button
|
||||||
<Circle
|
variant="ghost"
|
||||||
className={cn(
|
size="sm"
|
||||||
"h-3 w-3",
|
className="h-auto py-1 px-2 hover:bg-accent"
|
||||||
versionStatus.is_installed
|
onClick={onSettingsClick}
|
||||||
? "fill-green-500 text-green-500"
|
>
|
||||||
: "fill-red-500 text-red-500"
|
<div className="flex items-center space-x-2 text-xs">
|
||||||
)}
|
<Circle
|
||||||
/>
|
className={cn(
|
||||||
<span>
|
"h-3 w-3",
|
||||||
{versionStatus.is_installed && versionStatus.version
|
versionStatus.is_installed
|
||||||
? `Claude Code ${versionStatus.version}`
|
? "fill-green-500 text-green-500"
|
||||||
: "Claude Code"}
|
: "fill-red-500 text-red-500"
|
||||||
</span>
|
)}
|
||||||
</div>
|
/>
|
||||||
|
<span>
|
||||||
|
{versionStatus.is_installed && versionStatus.version
|
||||||
|
? `Claude Code ${versionStatus.version}`
|
||||||
|
: "Claude Code"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!versionStatus.is_installed) {
|
if (!versionStatus.is_installed) {
|
||||||
|
@ -124,6 +131,14 @@ export const Topbar: React.FC<TopbarProps> = ({
|
||||||
{versionStatus.output}
|
{versionStatus.output}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={onSettingsClick}
|
||||||
|
>
|
||||||
|
Select Claude Installation
|
||||||
|
</Button>
|
||||||
<a
|
<a
|
||||||
href="https://www.anthropic.com/claude-code"
|
href="https://www.anthropic.com/claude-code"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
@ -10,6 +10,7 @@ export * from "./MCPManager";
|
||||||
export * from "./MCPServerList";
|
export * from "./MCPServerList";
|
||||||
export * from "./MCPAddServer";
|
export * from "./MCPAddServer";
|
||||||
export * from "./MCPImportExport";
|
export * from "./MCPImportExport";
|
||||||
|
export * from "./ClaudeVersionSelector";
|
||||||
export * from "./ui/badge";
|
export * from "./ui/badge";
|
||||||
export * from "./ui/button";
|
export * from "./ui/button";
|
||||||
export * from "./ui/card";
|
export * from "./ui/card";
|
||||||
|
|
41
src/components/ui/radio-group.tsx
Normal file
41
src/components/ui/radio-group.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||||
|
import { Circle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
|
@ -78,6 +78,18 @@ export interface FileEntry {
|
||||||
extension?: string;
|
extension?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Claude installation found on the system
|
||||||
|
*/
|
||||||
|
export interface ClaudeInstallation {
|
||||||
|
/** Full path to the Claude binary */
|
||||||
|
path: string;
|
||||||
|
/** Version string if available */
|
||||||
|
version?: string;
|
||||||
|
/** Source of discovery (e.g., "nvm", "system", "homebrew", "which") */
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Sandbox API types
|
// Sandbox API types
|
||||||
export interface SandboxProfile {
|
export interface SandboxProfile {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
@ -1908,4 +1920,17 @@ export const api = {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all available Claude installations on the system
|
||||||
|
* @returns Promise resolving to an array of Claude installations
|
||||||
|
*/
|
||||||
|
async listClaudeInstallations(): Promise<ClaudeInstallation[]> {
|
||||||
|
try {
|
||||||
|
return await invoke<ClaudeInstallation[]>("list_claude_installations");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to list Claude installations:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue