diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72d9d40..6deacc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,12 +14,40 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Install Linux desktop dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - name: Cache Rust build uses: Swatinem/rust-cache@v2 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: apps/desktop/pnpm-lock.yaml + + - name: Install frontend dependencies + run: pnpm -C apps/desktop install --frozen-lockfile + + - name: Build frontend + run: pnpm -C apps/desktop build + - name: Run Rust tests run: cargo test --workspace @@ -75,7 +103,7 @@ jobs: libappindicator3-dev \ librsvg2-dev \ patchelf \ - webkitgtk-webdriver \ + webkit2gtk-driver \ xvfb - name: Install Rust toolchain @@ -99,11 +127,14 @@ jobs: - name: Install JS dependencies run: pnpm install --frozen-lockfile + - name: Install e2e dependencies + run: pnpm --dir e2e install --frozen-lockfile + - name: Install tauri-driver run: cargo install tauri-driver --locked - name: Verify WebKit WebDriver - run: which WebKitWebDriver + run: which WebKitWebDriver || which webkit2gtk-driver || dpkg -L webkit2gtk-driver | grep bin - name: Run desktop E2E run: pnpm test:e2e diff --git a/Cargo.lock b/Cargo.lock index 1a0cf0c..d8d9491 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -479,6 +529,52 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -1827,6 +1923,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -2457,6 +2559,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.4" @@ -2474,6 +2582,8 @@ name = "opencherry-desktop" version = "0.0.1" dependencies = [ "anyhow", + "clap", + "dirs", "git2", "opencherry_agents", "opencherry_core", @@ -2497,6 +2607,7 @@ version = "0.0.1" dependencies = [ "opencherry_core", "serde", + "serde_json", "sysinfo", "thiserror 2.0.18", "tracing", @@ -2515,6 +2626,7 @@ name = "opencherry_persistence" version = "0.0.1" dependencies = [ "anyhow", + "dirs", "opencherry_core", "rusqlite", "serde", @@ -4447,6 +4559,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" diff --git a/Cargo.toml b/Cargo.toml index a44404c..0c10d18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,9 @@ tauri-build = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" +# CLI +clap = { version = "4", features = ["derive"] } + # Error handling / logging anyhow = "1" thiserror = "2" @@ -48,6 +51,8 @@ sysinfo = "0.32" # Async tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "process", "io-util"] } +ureq = "2" +dirs = "6" # Filesystem watching notify = "7" diff --git a/README.md b/README.md index 93996a8..274745c 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,28 @@ Additional crates (`opencherry_inject`, `opencherry_commit_msg`, ## Development -### System dependencies (Ubuntu 24.04+) +### Quick Start (Recommended) + +The easiest way to set up the project is using the provided setup script and `just` task runner. + +1. **Install all dependencies:** + ```bash + ./scripts/setup.sh + ``` + +2. **Run the app:** + ```bash + just dev + ``` + +3. **Run tests:** + ```bash + just test + ``` + +### Manual Setup + +#### System dependencies (Ubuntu 24.04+) ```bash sudo apt install libwebkit2gtk-4.1-dev \ @@ -109,28 +130,23 @@ sudo apt install libwebkit2gtk-4.1-dev \ libayatana-appindicator3-dev ``` -### Toolchain +#### Toolchain - Rust stable (1.80+) - Node.js 20+ - pnpm 9+ -### Run dev shell +#### Run dev shell ```bash -cd apps/desktop -pnpm install -pnpm tauri dev +pnpm -C apps/desktop install +pnpm -C apps/desktop tauri dev ``` ### Validate ```bash -cargo check --workspace -cargo test --workspace -pnpm -C apps/desktop test -pnpm -C apps/desktop build -pnpm -C apps/desktop test:e2e +just check ``` ## Packaging & local install diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index be544ca..96f1586 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -37,6 +37,8 @@ thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } tokio = { workspace = true } +clap = { workspace = true } +dirs = "6" [features] default = ["custom-protocol"] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4dfdf0e..746dede 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -190,12 +190,61 @@ fn remove_custom_theme(app: tauri::AppHandle, id: String) -> Result Result, String> { let dir = config_dir(&app)?; let repos = persist::list_repos(&dir).map_err(|e| e.to_string())?; + let defs = persist::list_agent_definitions(&dir).map_err(|e| e.to_string())?; Ok(opencherry_agents::correlate_agents_to_targets( - opencherry_agents::detect_running_agents(), + opencherry_agents::detect_running_agents(&defs), &repos, )) } +#[tauri::command] +fn list_agent_definitions( + app: tauri::AppHandle, +) -> Result, String> { + let dir = config_dir(&app)?; + persist::list_agent_definitions(&dir).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn upsert_agent_definition( + app: tauri::AppHandle, + def: opencherry_core::AgentDefinition, +) -> Result<(), String> { + let dir = config_dir(&app)?; + persist::upsert_agent_definition(&dir, &def).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn remove_agent_definition(app: tauri::AppHandle, id: String) -> Result { + let dir = config_dir(&app)?; + persist::remove_agent_definition(&dir, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn update_agent_name(app: tauri::AppHandle, id: String, name: String) -> Result<(), String> { + let dir = config_dir(&app)?; + let defs = persist::list_agent_definitions(&dir).map_err(|e| e.to_string())?; + if let Some(mut def) = defs.into_iter().find(|d| d.id == id) { + def.display_name = name.clone(); + def.kind = opencherry_core::AgentKind::Custom(name); + persist::upsert_agent_definition(&dir, &def).map_err(|e| e.to_string()) + } else { + Err(format!("agent definition '{id}' not found")) + } +} + +#[tauri::command] +fn sync_agent_rules(app: tauri::AppHandle, _url: Option) -> Result { + let dir = config_dir(&app)?; + let defs = persist::list_agent_definitions(&dir).map_err(|e| e.to_string())?; + Ok(defs.len()) +} + +#[tauri::command] +fn debug_list_processes() -> Result { + Ok(opencherry_agents::debug_dump_processes()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tracing_subscriber::fmt() @@ -228,6 +277,12 @@ pub fn run() { publish_repo_branch, sync_repo_changes, list_agents, + list_agent_definitions, + upsert_agent_definition, + update_agent_name, + remove_agent_definition, + sync_agent_rules, + debug_list_processes, get_preferences, set_preferences, list_custom_themes, @@ -236,10 +291,13 @@ pub fn run() { ]) .setup(|app| { // Eagerly create config dir so first save doesn't race. - if let Ok(dir) = app.path().app_config_dir() { - let _ = std::fs::create_dir_all(&dir); - tracing::info!(path = %dir.display(), "config dir ready"); - } + let dir = app + .path() + .app_config_dir() + .expect("failed to resolve config dir"); + let _ = std::fs::create_dir_all(&dir); + tracing::info!(path = %dir.display(), "config dir ready"); + Ok(()) }) .run(tauri::generate_context!()) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index a590e0a..3910520 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,6 +1,181 @@ // Prevents additional console window on Windows in release. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use clap::{Parser, Subcommand}; +use opencherry_persistence as persist; + +#[derive(Parser)] +#[command(name = "opencherry")] +#[command(about = "OpenCherry Desktop & CLI", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Agent registry management + Agent { + #[clap(subcommand)] + action: AgentAction, + }, +} + +#[derive(Subcommand, Clone)] +enum AgentAction { + /// Sync agent rules from a remote URL + Sync { + /// Optional URL to fetch rules from + #[clap(long)] + url: Option, + }, + /// List all currently defined agent rules + List, + /// Run agent detection and show results + Detect, + /// Dump raw process list for debugging + Raw, + /// Add a new agent definition + Add { + /// Unique ID for the agent + #[clap(long)] + id: String, + /// Display name for the agent + #[clap(long)] + name: String, + /// Executable basename to match + #[clap(long)] + exe: String, + /// If true, requires shell parent + #[clap(long)] + shell: bool, + }, + /// Update an existing agent definition + Update { + /// Unique ID for the agent + #[clap(long)] + id: String, + /// New display name + #[clap(long)] + name: Option, + /// New executable basename + #[clap(long)] + exe: Option, + /// New shell parent requirement + #[clap(long)] + shell: Option, + }, +} + fn main() { - opencherry_desktop_lib::run() + let cli = Cli::parse(); + + if let Some(command) = cli.command { + let config_dir = dirs::config_dir() + .expect("could not find config dir") + .join("ai.opencherry.desktop"); + + // Ensure config dir exists + let _ = std::fs::create_dir_all(&config_dir); + + match command { + Commands::Agent { action } => match action { + AgentAction::Sync { .. } => { + println!("Sync is obsolete as OpenCherry is now 100% offline-first."); + println!("Built-in rules are embedded inside the binary. Custom rules can be added to ~/.opencherry/opencherry.json"); + } + AgentAction::List => match persist::list_agent_definitions(&config_dir) { + Ok(defs) => { + for def in defs { + println!( + "{}: {} ({})", + def.id, + def.display_name, + if def.is_builtin { "builtin" } else { "custom" } + ); + } + } + Err(e) => eprintln!("Error listing agents: {}", e), + }, + AgentAction::Detect => { + let defs = persist::list_agent_definitions(&config_dir).unwrap_or_default(); + let agents = opencherry_agents::detect_running_agents(&defs); + println!("Detected {} agents:", agents.len()); + for agent in agents { + println!( + "[{:?}] {} (PID: {}){} - {}", + agent.status, + agent.display_name, + agent.pid, + if let Some(ref p) = agent.parent_id { + format!(" [Child of {}]", p.0) + } else { + "".to_string() + }, + agent.command_line + ); + } + } + AgentAction::Raw => { + let raw = opencherry_agents::debug_dump_processes(); + println!("{}", serde_json::to_string_pretty(&raw).unwrap()); + } + AgentAction::Add { + id, + name, + exe, + shell, + } => { + let def = opencherry_core::AgentDefinition { + id: id.clone(), + kind: opencherry_core::AgentKind::Custom(name.clone()), + display_name: name, + rules: vec![opencherry_core::AgentRule { + exe_basename: Some(exe), + exe_path_contains: None, + argv_contains: None, + exclude_path_contains: None, + require_shell_parent: shell, + }], + is_builtin: false, + }; + if let Err(e) = persist::upsert_agent_definition(&config_dir, &def) { + eprintln!("Error adding agent: {}", e); + } else { + println!("Agent '{}' added successfully.", id); + } + } + AgentAction::Update { + id, + name, + exe, + shell, + } => { + // Load existing + let defs = persist::list_agent_definitions(&config_dir).unwrap_or_default(); + if let Some(mut def) = defs.into_iter().find(|d| d.id == id) { + if let Some(n) = name { + def.display_name = n.clone(); + def.kind = opencherry_core::AgentKind::Custom(n); + } + if let Some(e) = exe { + def.rules[0].exe_basename = Some(e); + } + if let Some(s) = shell { + def.rules[0].require_shell_parent = s; + } + if let Err(e) = persist::upsert_agent_definition(&config_dir, &def) { + eprintln!("Error updating agent: {}", e); + } else { + println!("Agent '{}' updated successfully.", id); + } + } else { + eprintln!("Agent '{}' not found.", id); + } + } + }, + } + } else { + opencherry_desktop_lib::run() + } } diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 9082d5c..d36de87 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -179,6 +179,7 @@ describe("TargetAgentsPanel", () => { function makeAgent(overrides: Partial = {}): DetectedAgent { return { id: "agent-1", + definition_id: "open-code", kind: "open-code", display_name: "OpenCode (pid 42)", pid: 42, @@ -591,6 +592,7 @@ describe("App", () => { const repoAgents: DetectedAgent[] = [ { id: "agent-1", + definition_id: "open-code", kind: "open-code", display_name: "OpenCode (pid 42)", pid: 42, @@ -660,6 +662,7 @@ describe("App", () => { const mixedAgents: DetectedAgent[] = [ { id: "agent-1", + definition_id: "open-code", kind: "open-code", display_name: "OpenCode (pid 42)", pid: 42, @@ -672,6 +675,7 @@ describe("App", () => { }, { id: "agent-2", + definition_id: "gemini-cli", kind: "gemini-cli", display_name: "Gemini CLI (pid 77)", pid: 77, @@ -872,6 +876,7 @@ describe("App", () => { const groupAgents: DetectedAgent[] = [ { id: "agent-1", + definition_id: "open-code", kind: "open-code", display_name: "OpenCode (pid 42)", pid: 42, @@ -968,6 +973,7 @@ describe("App", () => { const groupAgents: DetectedAgent[] = [ { id: "agent-1", + definition_id: "open-code", kind: "open-code", display_name: "OpenCode (pid 42)", pid: 42, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index bf3588e..3107e1c 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -11,6 +11,7 @@ import { import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import type { + AgentKind, CommitResult, DetectedAgent, DiscardOutcome, @@ -37,6 +38,11 @@ function pathContains(path: string, base: string) { return normalizedPath === normalizedBase || normalizedPath.startsWith(`${normalizedBase}/`); } +function formatAgentKind(kind: AgentKind): string { + if (typeof kind === "string") return kind; + return kind.custom; +} + function agentMatchesRepoPath(agent: DetectedAgent, repoPath: string) { return ( agent.targets.repos.some((repo) => repo.path === repoPath) || @@ -90,6 +96,10 @@ async function syncChanges(path: string): Promise { return await invoke("sync_repo_changes", { path }); } +async function syncAgentRules(): Promise { + return await invoke("sync_agent_rules"); +} + export const __testables = { fetchRepos, fetchAgents, @@ -122,10 +132,15 @@ export default function App() { return agentMatchesRepoPath(agent, repo.path); }).length; - // Poll agents every 3s. + // Poll agents every 3s, but pause if any agent is being edited. + const [isEditingAny, setIsEditingAny] = createSignal(false); let agentsTimer: ReturnType | undefined; onMount(() => { - agentsTimer = setInterval(() => refetchAgents(), 3000); + agentsTimer = setInterval(() => { + if (!isEditingAny()) { + refetchAgents(); + } + }, 3000); }); onCleanup(() => { if (agentsTimer) clearInterval(agentsTimer); @@ -221,10 +236,12 @@ export default function App() { } > - }> + { await refetchAgents(); }} onEditStateChange={setIsEditingAny} />}> { await refetchAgents(); }} + onEditStateChange={setIsEditingAny} trackedRepoPaths={trackedRepoPaths()} onRegisterRepo={async (repo) => { setError(null); @@ -261,7 +278,17 @@ export default function App() { {(e) => } - + { + await syncAgentRules(); + // Give the DB a split second to settle before scanning processes + await new Promise(r => setTimeout(r, 100)); + await refetchAgents(); + }} + onRename={async () => { await refetchAgents(); }} + onEditStateChange={setIsEditingAny} + /> Promise; onRegisterRepos: (repos: RepoRef[]) => Promise; onSelectRepo: (repo: RepoRef) => void; + onRename: () => Promise; + onEditStateChange?: (editing: boolean) => void; }) { const [snapshot, { refetch }] = createResource( () => props.group.path, @@ -395,6 +424,8 @@ function RepoGroupView(props: { title="Agents in This Group" agents={groupAgents()} empty="No detected agents mapped to this group." + onRename={props.onRename} + onEditStateChange={props.onEditStateChange} />
@@ -503,7 +534,7 @@ function RepoGroupView(props: { {repoAgents(entry.repo.path).length} agent{repoAgents(entry.repo.path).length === 1 ? "" : "s"} - {(agent) => {agent.kind}} + {(agent) => {formatAgentKind(agent.kind)}} @@ -524,6 +555,8 @@ function RepoView(props: { repo: RepoRef; agents: DetectedAgent[]; requestConfirm: (req: ConfirmRequest) => void; + onRename: () => Promise; + onEditStateChange?: (editing: boolean) => void; }) { const [status, { refetch }] = createResource( () => props.repo.path, @@ -667,6 +700,8 @@ function RepoView(props: { title="Agents in This Repo" agents={repoAgents()} empty="No detected agents mapped to this repository." + onRename={props.onRename} + onEditStateChange={props.onEditStateChange} />
@@ -1013,7 +1048,13 @@ function StatusCard(props: { ); } -export function TargetAgentsPanel(props: { title: string; agents: DetectedAgent[]; empty: string }) { +export function TargetAgentsPanel(props: { + title: string; + agents: DetectedAgent[]; + empty: string; + onRename?: (id: string, name: string) => void; + onEditStateChange?: (editing: boolean) => void; +}) { return (
@@ -1026,7 +1067,7 @@ export function TargetAgentsPanel(props: { title: string; agents: DetectedAgent[ >
    - {(a) => } + {(a) => }
@@ -1034,18 +1075,86 @@ export function TargetAgentsPanel(props: { title: string; agents: DetectedAgent[ ); } -function AgentRow(props: { agent: DetectedAgent }) { +function AgentRow(props: { + agent: DetectedAgent; + onRename?: (id: string, newName: string) => void; + onEditStateChange?: (editing: boolean) => void; +}) { const status = () => props.agent.status ?? "idle"; const isSubprocess = () => Boolean(props.agent.parent_id); + const isInactive = () => status() === "suspended" || status() === "zombie"; + + const [isEditing, setIsEditing] = createSignal(false); + const [tempName, setTempName] = createSignal(props.agent.display_name); + + let inputRef: HTMLInputElement | undefined; + + const toggleEditing = (editing: boolean) => { + setIsEditing(editing); + props.onEditStateChange?.(editing); + }; + + createEffect(() => { + if (isEditing()) { + inputRef?.focus(); + inputRef?.select(); + } + }); + + const handleRename = () => { + if (tempName() !== props.agent.display_name) { + props.onRename?.(props.agent.id, tempName()); + } + toggleEditing(false); + }; + return ( -
  • +
  • - {props.agent.display_name} + toggleEditing(true)} + > + {props.agent.display_name} + (pid {props.agent.pid}) + + } + > + setTempName(e.currentTarget.value)} + onBlur={handleRename} + onKeyDown={(e) => e.key === "Enter" && handleRename()} + /> + + + + + + + POSSIBLY SUSPENDED + +
    @@ -1056,7 +1165,7 @@ function AgentRow(props: { agent: DetectedAgent }) { ↳ subprocess - {props.agent.kind} + {formatAgentKind(props.agent.kind)} 0}> @@ -1072,15 +1181,53 @@ function AgentRow(props: { agent: DetectedAgent }) { ); } -function AgentsPanel(props: { agents: Resource }) { +function AgentsPanel(props: { + agents: Resource; + onSync: () => Promise; + onRename: () => Promise; + onEditStateChange?: (editing: boolean) => void; +}) { const unmatchedAgents = () => (props.agents() ?? []).filter((agent) => agent.targets.repos.length === 0 && agent.targets.groups.length === 0); + const [syncing, setSyncing] = createSignal(false); + + const handleSync = async () => { + setSyncing(true); + try { + await props.onSync(); + } finally { + setSyncing(false); + } + }; + + const handleRename = async (id: string, name: string) => { + try { + const agent = (props.agents() ?? []).find(a => a.id === id); + if (agent) { + await invoke("update_agent_name", { id: agent.definition_id, name }); + await props.onRename(); + } + } catch (e) { + console.error("Failed to rename agent:", e); + } + }; + return (
    -

    Detected agents

    - {(props.agents() ?? []).length} +
    +

    Detected agents

    + {(props.agents() ?? []).length} +
    +
    0}>