diff --git a/docs/acp.md b/docs/acp.md new file mode 100644 index 0000000..050516d --- /dev/null +++ b/docs/acp.md @@ -0,0 +1,102 @@ +# ACP backend (Agent Client Protocol) + +The desktop can drive any agent CLI that speaks the +[Agent Client Protocol](https://agentclientprotocol.com) (JSON-RPC 2.0 over +stdio, protocol v1) as a fourth engine backend, next to the native `jucode`, +`codex` and `claude` adapters. `jucode acp` works out of the box; other agents +(e.g. `gemini --experimental-acp`) can be registered in Settings. + +## Where the pieces live + +| piece | file | +|---|---| +| Agent registry (Rust-owned, validated) | `src-tauri/src/acp_registry.rs` → `~app-config/acp-agents.json` | +| Spawn plumbing (`BackendKind::Acp`) | `src-tauri/src/backend.rs`, `src-tauri/src/lib.rs` | +| Webview adapter (JSON-RPC ↔ AgentEvents) | `src/lib/backends/acp.ts` (+ `acp-types.ts`) | +| Settings UI (add / edit / remove agents) | `src/lib/settings/AcpSection.svelte` | +| New-session picker rows | `src/lib/Composer.svelte` (below the native engines) | + +## Launch model: registry, not argv + +The frontend never sends a command line. A registered agent is +`{ id, name, command, args, env }`; the webview passes only the allowlisted +`agent: ""` spawn option and the Rust side looks the entry up, resolves +`command` (PATH + well-known install dirs; explicit paths kept as-is) and +spawns `command args…` with `env` applied on top of the shell-env snapshot. +Every entry is re-validated on **every read and write** of the registry file, +so a hand-edited `acp-agents.json` cannot smuggle malformed entries into a +spawn. `bin_override`, raw args and argv-shaped options are rejected for the +`acp` backend kind. + +The JSON-RPC transport reuses the existing per-session stdio pipe +(`create_session` / `send_line` / one event per stdout line) — no tokio, no +extra runtime in Tauri, no SDK in the webview bundle (hand-rolled frames like +`backends/codex.ts`). + +## Protocol mapping + +Handshake per child process (initial spawn and every crash restart): +`initialize` (protocol v1, client advertises **no** fs/terminal capability) → +`session/new` (project cwd, no MCP servers) → ready. Then one +`session/prompt` per user turn; the turn is over when the prompt request +answers with a `stopReason`. + +### What maps + +| ACP | desktop surface | +|---|---| +| `session/update: agent_message_chunk` | assistant text stream | +| `session/update: agent_thought_chunk` | reasoning stream (thinking block) | +| `session/update: tool_call` / `tool_call_update` | ToolCard (start / progress / done), `content` + `locations` folded into the card body | +| tool-call `diff` content | ToolCard diff view (rendered as a unified diff) | +| `session/update: plan` | Plan panel in the right dock (entries + status) | +| `session/request_permission` | ApprovalCard; allow / always / deny picks the matching advertised option (`allow_once` / `allow_always` / `reject_once` / `reject_always`), no usable option ⇒ `cancelled` | +| stop button | `session/cancel` (outstanding permission requests are answered `cancelled` first, per spec) | +| `stopReason: refusal` / `max_tokens` / `max_turn_requests` | info notice in the transcript | +| `session/new` model info (when the agent reports it) | model label (read-only) | +| image attachments | `image` content blocks with `file://` uris, only when the agent advertised `promptCapabilities.image` — agents that require inline base64 data won't see them | +| agent stderr | dimmed `[acp]` info lines (ANSI stripped) | + +ACP has no turn-started notification, so the busy indicator flips on the first +frame the agent sends after a prompt goes out. While a turn is in flight, +further user messages queue adapter-side and run as their own turns once the +current one settles (same UX as claude's stdin queue). + +### What deliberately does NOT map (conservative caps) + +- **Approval modes** — ACP session modes are agent-defined ids with no provable + mapping onto the desktop's ask / auto-edit / full-auto trio. The picker is + hidden; the desktop's startup mode sync is swallowed. +- **Steer** — no mid-turn injection in ACP; messages sent while busy become + the next turn instead. +- **Hunk-subset approvals** — permission responses are whole-call option + picks; there is no per-hunk protocol. +- **Resume / transcript replay** — `session/load` is optional (jucode acp + does not advertise it), so ACP conversations are not persisted as + restorable tabs (`startup.session_id` stays empty on purpose). +- **Model picker** — `session/set_model` is optional; off until provable. +- **MCP live management** — `session/new` accepts an MCP server list, but + there is no runtime add/remove/reconnect RPC; the desktop passes none and + hides the management UI. +- **Skills, checkpoints, branch tree, goals tab, sub-agents, context/usage + telemetry, /compact** — no ACP v1 equivalent. +- **Slash commands** — `available_commands_update` is received but there is no + invocation RPC (commands are plain prompt text), so no command surface. +- **Client fs / terminal services** — the client advertises neither; + `fs/read_text_file`, `fs/write_text_file` and `terminal/*` requests are + declined with a JSON-RPC error so the agent falls back to its own tools + instead of hanging. + +## Adding an agent + +Settings → Behavior → ACP agents → *Add agent*: a display name, a command +(name or absolute path), optional fixed arguments (quoted tokens supported) +and optional per-agent `KEY=VALUE` env lines. Registered agents appear at the +bottom of the engine picker on new (virgin) sessions; each project remembers +its last choice. Bounds: ≤ 32 agents, ≤ 32 args, names/commands/args length- +and control-character-checked, env names/values validated like custom backend +env (dangerous variables rejected). + +Tests: `src/lib/backends/acp.test.ts` (adapter, fake agent frames), +`src/lib/backends/acp-agents.test.ts` (settings-form helpers), and the +registry/spawn tests in `src-tauri/src/acp_registry.rs` / `backend.rs`. diff --git a/src-tauri/src/acp_registry.rs b/src-tauri/src/acp_registry.rs new file mode 100644 index 0000000..d5418fc --- /dev/null +++ b/src-tauri/src/acp_registry.rs @@ -0,0 +1,315 @@ +//! ACP agent registry: which external Agent Client Protocol agents the +//! desktop may launch, and with what fixed command line. +//! +//! Safety model: mirrors `backend.rs` — the frontend NEVER passes argv to +//! `create_session`. Users register agents (id, name, command, args, env) +//! through the dedicated registry commands below, where every field is +//! validated; a session then only references a registry *id*, and the command +//! line is looked up here at spawn time. Registered args are fixed +//! configuration (they may legitimately start with `-`, e.g. +//! `--experimental-acp`), but they can never be extended or reordered by a +//! session request. +//! +//! The registry persists as `acp-agents.json` in the per-app config dir +//! (same directory as the other desktop app-data files). + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +use crate::backend; + +/// One launchable ACP agent. `env` is applied on top of the shell-env +/// snapshot / custom backend env when the child is spawned. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct AcpAgent { + pub id: String, + pub name: String, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: BTreeMap, +} + +#[derive(Serialize, Deserialize, Default)] +struct RegistryFile { + agents: Vec, +} + +const FILE_NAME: &str = "acp-agents.json"; +const MAX_AGENTS: usize = 32; +const MAX_ARGS: usize = 32; +const MAX_ARG_LEN: usize = 300; +const MAX_NAME_LEN: usize = 100; +const MAX_COMMAND_LEN: usize = 300; + +fn valid_text(s: &str, max: usize) -> bool { + !s.is_empty() && s.len() <= max && !s.chars().any(|c| c.is_control()) +} + +/// Validates one registry entry. Applied on every write AND on every read, so +/// a hand-edited registry file can't smuggle malformed entries into a spawn. +pub fn validate_agent(agent: &AcpAgent) -> Result<(), String> { + if !backend::is_valid_acp_agent_id(&agent.id) { + return Err(format!( + "invalid agent id: {} (lowercase letters, digits, - and _ only)", + agent.id + )); + } + if !valid_text(&agent.name, MAX_NAME_LEN) { + return Err("agent name must be 1-100 characters without control characters".to_string()); + } + if !valid_text(&agent.command, MAX_COMMAND_LEN) || agent.command.starts_with('-') { + return Err("agent command must be a program name or path (not a flag)".to_string()); + } + if agent.args.len() > MAX_ARGS { + return Err(format!("agent accepts at most {MAX_ARGS} arguments")); + } + for arg in &agent.args { + if arg.is_empty() || arg.len() > MAX_ARG_LEN || arg.chars().any(|c| c.is_control()) { + return Err(format!("invalid agent argument: {arg:?}")); + } + } + if agent.env.len() > backend::MAX_CUSTOM_ENV_VARS { + return Err(format!( + "agent env accepts at most {} variables", + backend::MAX_CUSTOM_ENV_VARS + )); + } + for (name, value) in &agent.env { + if !backend::is_valid_env_name(name) { + return Err(format!("invalid env variable name: {name}")); + } + if value.len() > backend::MAX_CUSTOM_ENV_VALUE_LEN || value.contains('\0') { + return Err(format!("invalid env value for {name}")); + } + } + Ok(()) +} + +/// First-run default: the native engine's own ACP surface, so the picker has +/// a working ACP option out of the box. +fn default_agents() -> Vec { + vec![AcpAgent { + id: "jucode-acp".to_string(), + name: "JuCode (ACP)".to_string(), + command: "jucode".to_string(), + args: vec!["acp".to_string()], + env: BTreeMap::new(), + }] +} + +/// Parses the registry file contents, validating every entry. +fn parse_registry(text: &str) -> Result, String> { + let file: RegistryFile = + serde_json::from_str(text).map_err(|e| format!("acp registry is malformed: {e}"))?; + for agent in &file.agents { + validate_agent(agent)?; + } + Ok(file.agents) +} + +fn registry_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_config_dir() + .map_err(|e| format!("app config dir unavailable: {e}"))?; + Ok(dir.join(FILE_NAME)) +} + +/// Loads the registry; a missing file seeds the defaults (an existing but +/// EMPTY registry stays empty — deleting the default entry is respected). +pub fn load(app: &AppHandle) -> Result, String> { + let path = registry_path(app)?; + match std::fs::read_to_string(&path) { + Ok(text) => parse_registry(&text), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(default_agents()), + Err(e) => Err(format!("failed to read {}: {e}", path.display())), + } +} + +/// Write-then-rename so a crash mid-write can't truncate the registry. +fn save(app: &AppHandle, agents: &[AcpAgent]) -> Result<(), String> { + let path = registry_path(app)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let text = serde_json::to_string_pretty(&RegistryFile { + agents: agents.to_vec(), + }) + .map_err(|e| e.to_string())?; + let mut tmp = path.clone(); + tmp.set_file_name(format!("{FILE_NAME}.tmp")); + std::fs::write(&tmp, text.as_bytes()) + .map_err(|e| format!("failed to write {}: {e}", tmp.display()))?; + std::fs::rename(&tmp, &path).map_err(|e| format!("failed to write {}: {e}", path.display())) +} + +/// Looks up (and re-validates) one agent for spawning. +pub fn find_agent(app: &AppHandle, id: &str) -> Result { + load(app)? + .into_iter() + .find(|a| a.id == id) + .ok_or_else(|| format!("unknown acp agent: {id}")) +} + +/// Lists the registered ACP agents (settings UI + new-session picker). +#[tauri::command] +pub fn acp_agents_list(app: AppHandle) -> Result, String> { + load(&app) +} + +/// Adds or replaces one agent (matched by id). Returns the updated list. +#[tauri::command] +pub fn acp_agent_upsert(app: AppHandle, agent: AcpAgent) -> Result, String> { + validate_agent(&agent)?; + let mut agents = load(&app)?; + if let Some(existing) = agents.iter_mut().find(|a| a.id == agent.id) { + *existing = agent; + } else { + if agents.len() >= MAX_AGENTS { + return Err(format!("at most {MAX_AGENTS} acp agents can be registered")); + } + agents.push(agent); + } + save(&app, &agents)?; + Ok(agents) +} + +/// Removes one agent by id. Returns the updated list. +#[tauri::command] +pub fn acp_agent_remove(app: AppHandle, id: String) -> Result, String> { + let mut agents = load(&app)?; + agents.retain(|a| a.id != id); + save(&app, &agents)?; + Ok(agents) +} + +/// Availability probe for one registered agent: resolves its command and runs +/// ` --version` best-effort (mirrors `check_backend`). +#[tauri::command(async)] +pub fn acp_agent_check(app: AppHandle, id: String) -> Result { + let agent = find_agent(&app, &id)?; + let bin = backend::resolve_acp_program(&agent.command); + let path = if bin.components().count() == 1 { + crate::which(&bin.to_string_lossy()) + } else if bin.is_file() { + Some(bin) + } else { + None + }; + let Some(path) = path else { + return Ok(crate::BackendStatus { + found: false, + path: None, + version: None, + }); + }; + let mut cmd = std::process::Command::new(&path); + crate::no_window(&mut cmd); + cmd.arg("--version"); + let version = crate::run_with_timeout(cmd, std::time::Duration::from_secs(15)) + .ok() + .filter(|out| out.status.success()) + .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()) + .filter(|v| !v.is_empty()); + Ok(crate::BackendStatus { + found: true, + path: Some(path.display().to_string()), + version, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn agent(id: &str) -> AcpAgent { + AcpAgent { + id: id.to_string(), + name: "Test Agent".to_string(), + command: "test-agent".to_string(), + args: vec!["--experimental-acp".to_string()], + env: BTreeMap::new(), + } + } + + #[test] + fn default_registry_launches_jucode_acp() { + let agents = default_agents(); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].id, "jucode-acp"); + assert_eq!(agents[0].command, "jucode"); + assert_eq!(agents[0].args, vec!["acp"]); + validate_agent(&agents[0]).unwrap(); + } + + #[test] + fn registered_args_may_be_flags_but_stay_bounded() { + // Fixed config flags like --experimental-acp are legitimate. + validate_agent(&agent("gemini")).unwrap(); + let mut too_many = agent("gemini"); + too_many.args = vec!["x".to_string(); MAX_ARGS + 1]; + assert!(validate_agent(&too_many).is_err()); + let mut newline = agent("gemini"); + newline.args = vec!["a\nb".to_string()]; + assert!(validate_agent(&newline).is_err()); + let mut empty = agent("gemini"); + empty.args = vec![String::new()]; + assert!(validate_agent(&empty).is_err()); + } + + #[test] + fn ids_names_and_commands_are_validated() { + for bad in ["", "UPPER", "a b", "--x", "a/b"] { + assert!(validate_agent(&agent(bad)).is_err(), "{bad:?}"); + } + let mut flag_cmd = agent("ok"); + flag_cmd.command = "--rm".to_string(); + assert!(validate_agent(&flag_cmd).is_err()); + let mut no_name = agent("ok"); + no_name.name = String::new(); + assert!(validate_agent(&no_name).is_err()); + // Paths (with spaces) are fine as commands. + let mut path_cmd = agent("ok"); + path_cmd.command = "/opt/agents/my agent/bin/agent".to_string(); + validate_agent(&path_cmd).unwrap(); + } + + #[test] + fn env_entries_are_validated_like_backend_env() { + let mut bad_env = agent("ok"); + bad_env + .env + .insert("DYLD_INSERT_LIBRARIES".to_string(), "/evil".to_string()); + assert!(validate_agent(&bad_env).is_err()); + let mut bad_name = agent("ok"); + bad_name.env.insert("1BAD".to_string(), "x".to_string()); + assert!(validate_agent(&bad_name).is_err()); + let mut ok_env = agent("ok"); + ok_env + .env + .insert("GEMINI_API_KEY".to_string(), "k".to_string()); + validate_agent(&ok_env).unwrap(); + } + + #[test] + fn parse_registry_validates_every_entry() { + let ok = r#"{"agents":[{"id":"gemini","name":"Gemini CLI","command":"gemini","args":["--experimental-acp"]}]}"#; + let agents = parse_registry(ok).unwrap(); + assert_eq!(agents[0].id, "gemini"); + assert_eq!(agents[0].args, vec!["--experimental-acp"]); + assert!(agents[0].env.is_empty()); + // A hand-edited file with a smuggled entry is rejected wholesale. + let bad = r#"{"agents":[{"id":"OK?","name":"x","command":"sh"}]}"#; + assert!(parse_registry(bad).is_err()); + assert!(parse_registry("not json").is_err()); + // An explicitly emptied registry stays empty (no re-seeding). + assert_eq!( + parse_registry(r#"{"agents":[]}"#).unwrap(), + Vec::::new() + ); + } +} diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index ada88d3..58a6476 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -18,6 +18,10 @@ pub enum BackendKind { Codex, /// Claude Code CLI in stream-json print mode. Claude, + /// Any Agent Client Protocol agent (JSON-RPC over stdio). The command + /// line comes from the user-managed ACP registry (`acp_registry.rs`) — + /// the frontend only ever passes a registry entry id. + Acp, } impl BackendKind { @@ -26,25 +30,30 @@ impl BackendKind { "jucode" => Ok(Self::Jucode), "codex" => Ok(Self::Codex), "claude" => Ok(Self::Claude), + "acp" => Ok(Self::Acp), other => Err(format!("unknown backend: {other}")), } } - /// Binary base name (without the Windows `.exe` suffix). + /// Binary base name (without the Windows `.exe` suffix). For `Acp` this is + /// only used in error messages — the actual command comes from the registry. pub fn bin_name(self) -> &'static str { match self { Self::Jucode => "jucode", Self::Codex => "codex", Self::Claude => "claude", + Self::Acp => "acp", } } - /// Environment variable that force-overrides binary resolution. + /// Environment variable that force-overrides binary resolution. Never + /// consulted for `Acp` (its binary resolution goes through the registry). pub fn env_override(self) -> &'static str { match self { Self::Jucode => "JUCODE_BIN", Self::Codex => "CODEX_BIN", Self::Claude => "CLAUDE_BIN", + Self::Acp => "JUCODE_ACP_BIN_UNUSED", } } @@ -65,6 +74,9 @@ impl BackendKind { "use_shell_env", "env", ], + // ACP sessions select a registry entry — never a binary path or + // argv. Everything else about the spawn is fixed by the registry. + Self::Acp => &["agent", "use_shell_env", "env"], } } } @@ -86,6 +98,8 @@ pub struct BackendOpts { pub session_id: Option, /// claude: `--model `. pub model: Option, + /// acp: registry entry id of the agent to launch (`acp_registry.rs`). + pub agent: Option, /// Build the child env from the login-shell snapshot (default true; see /// `shell_env`). Off = inherit the GUI environment as before. pub use_shell_env: bool, @@ -102,6 +116,7 @@ impl Default for BackendOpts { resume_session_at: None, session_id: None, model: None, + agent: None, use_shell_env: true, env: Vec::new(), } @@ -110,7 +125,7 @@ impl Default for BackendOpts { /// Custom env var names: POSIX-style identifiers only, with dangerous /// dynamic-linker prefixes rejected outright. -fn is_valid_env_name(name: &str) -> bool { +pub(crate) fn is_valid_env_name(name: &str) -> bool { !name.is_empty() && name.len() <= 128 && !name.starts_with("DYLD_") @@ -123,12 +138,27 @@ fn is_valid_env_name(name: &str) -> bool { && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') } -const MAX_CUSTOM_ENV_VARS: usize = 50; -const MAX_CUSTOM_ENV_VALUE_LEN: usize = 4096; +pub(crate) const MAX_CUSTOM_ENV_VARS: usize = 50; +pub(crate) const MAX_CUSTOM_ENV_VALUE_LEN: usize = 4096; + +/// ACP registry entry ids: short lowercase slugs, so an id can never look +/// like a flag, a path or contain whitespace tricks. +pub(crate) fn is_valid_acp_agent_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 64 + && !s.starts_with('-') + && s.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') +} /// Claude Code permission modes the desktop is allowed to request. -const CLAUDE_PERMISSION_MODES: &[&str] = - &["default", "plan", "auto", "acceptEdits", "bypassPermissions"]; +const CLAUDE_PERMISSION_MODES: &[&str] = &[ + "default", + "plan", + "auto", + "acceptEdits", + "bypassPermissions", +]; fn expect_string(key: &str, v: &serde_json::Value) -> Result { v.as_str() @@ -190,7 +220,9 @@ pub fn validate_opts( .as_object() .ok_or_else(|| "env must be an object of string values".to_string())?; if obj.len() > MAX_CUSTOM_ENV_VARS { - return Err(format!("env accepts at most {MAX_CUSTOM_ENV_VARS} variables")); + return Err(format!( + "env accepts at most {MAX_CUSTOM_ENV_VARS} variables" + )); } let mut vars = Vec::with_capacity(obj.len()); for (name, val) in obj { @@ -249,6 +281,12 @@ pub fn validate_opts( } opts.model = Some(s); } + "agent" => { + if !is_valid_acp_agent_id(&s) { + return Err(format!("invalid acp agent id: {s}")); + } + opts.agent = Some(s); + } _ => unreachable!("key was checked against the allowlist"), } } @@ -267,6 +305,9 @@ pub fn build_args(kind: BackendKind, opts: &BackendOpts) -> Vec { match kind { BackendKind::Jucode => vec!["serve".to_string()], BackendKind::Codex => vec!["app-server".to_string()], + // ACP argv comes from the registry entry, not from options — + // create_session never calls build_args for this kind. + BackendKind::Acp => Vec::new(), BackendKind::Claude => { let yolo = opts.permission_mode.as_deref() == Some("bypassPermissions"); let mut args: Vec = [ @@ -325,19 +366,39 @@ pub fn build_args(kind: BackendKind, opts: &BackendOpts) -> Vec { /// Well-known install locations probed after PATH (a packaged app inherits a /// minimal PATH from launchd / the desktop session). fn well_known_paths(kind: BackendKind) -> Vec { - let name = kind.bin_name(); - let exe = if cfg!(windows) { + let mut paths = well_known_candidates(kind.bin_name(), kind == BackendKind::Jucode); + if kind == BackendKind::Claude { + let exe = exe_name("claude"); + let home = home_dir(); + // Claude Code's native installer keeps a launcher here too. + paths.push(home.join(".claude").join("local").join(&exe)); + } + paths +} + +fn exe_name(name: &str) -> String { + if cfg!(windows) { format!("{name}.exe") } else { name.to_string() - }; - let home = std::env::var_os("HOME") + } +} + +fn home_dir() -> PathBuf { + std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) - .unwrap_or_default(); + .unwrap_or_default() +} + +/// Well-known install dirs for an arbitrary program name (shared by the fixed +/// backends and the ACP registry's command resolution). +fn well_known_candidates(name: &str, jucode_installer_dir: bool) -> Vec { + let exe = exe_name(name); + let home = home_dir(); let mut paths: Vec = Vec::new(); if cfg!(windows) { - if kind == BackendKind::Jucode { + if jucode_installer_dir { // Per-user installer dir and the npm global prefix. if let Some(la) = std::env::var_os("LOCALAPPDATA") { paths.push(PathBuf::from(la).join("Programs").join("jucode").join(&exe)); @@ -354,17 +415,42 @@ fn well_known_paths(kind: BackendKind) -> Vec { paths.push(home.join(".cargo/bin").join(&exe)); paths.push(home.join(".local/bin").join(&exe)); // per-user installs (incl. Claude Code native) } - if kind == BackendKind::Claude { - // Claude Code's native installer keeps a launcher here too. - paths.push(home.join(".claude").join("local").join(&exe)); - } paths } +/// Resolves an ACP registry entry's command to a program path. A command with +/// a path separator is used as-is; the engine binaries reuse the full backend +/// resolution (env override → PATH → well-known dirs → dev builds); anything +/// else goes PATH → well-known dirs → bare name (PATH-spawn at run time). +pub fn resolve_acp_program(command: &str) -> PathBuf { + for kind in [BackendKind::Jucode, BackendKind::Codex, BackendKind::Claude] { + if command == kind.bin_name() { + return resolve_backend_bin(kind, None); + } + } + let p = Path::new(command); + if p.components().count() > 1 || p.is_absolute() { + return p.to_path_buf(); + } + if let Some(found) = crate::which(command) { + return found; + } + for candidate in well_known_candidates(command, false) { + if candidate.is_file() { + return candidate; + } + } + PathBuf::from(command) +} + /// Dev fallback for the native engine only: the freshly-built binary from the /// sibling `JuCode-CLI` checkout (mirrors the pre-multi-backend behavior). fn jucode_dev_candidates() -> Vec { - let exe = if cfg!(windows) { "jucode.exe" } else { "jucode" }; + let exe = if cfg!(windows) { + "jucode.exe" + } else { + "jucode" + }; let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); // /src-tauri [ format!("../../JuCode-CLI/target/debug/{exe}"), @@ -434,10 +520,54 @@ mod tests { assert_eq!(BackendKind::parse("jucode").unwrap(), BackendKind::Jucode); assert_eq!(BackendKind::parse("codex").unwrap(), BackendKind::Codex); assert_eq!(BackendKind::parse("claude").unwrap(), BackendKind::Claude); + assert_eq!(BackendKind::parse("acp").unwrap(), BackendKind::Acp); assert!(BackendKind::parse("bash").is_err()); assert!(BackendKind::parse("").is_err()); } + // --- acp options --- + + #[test] + fn acp_accepts_only_a_registry_agent_id() { + let opts = + validate_opts(BackendKind::Acp, Some(&json!({ "agent": "gemini-cli" }))).unwrap(); + assert_eq!(opts.agent.as_deref(), Some("gemini-cli")); + // No binary override, no argv-shaped anything. + assert!(validate_opts( + BackendKind::Acp, + Some(&json!({ "bin_override": "/bin/sh" })) + ) + .is_err()); + assert!(validate_opts(BackendKind::Acp, Some(&json!({ "args": ["-x"] }))).is_err()); + assert!(validate_opts(BackendKind::Acp, Some(&json!({ "command": "sh" }))).is_err()); + // Ids are slugs: no flags, spaces, dots or uppercase. + for bad in ["--help", "a b", "../etc", "UPPER", ""] { + assert!( + validate_opts(BackendKind::Acp, Some(&json!({ "agent": bad }))).is_err(), + "{bad:?} must be rejected" + ); + } + // Other backends don't take `agent`. + assert!(validate_opts(BackendKind::Jucode, Some(&json!({ "agent": "x" }))).is_err()); + assert!(validate_opts(BackendKind::Claude, Some(&json!({ "agent": "x" }))).is_err()); + } + + #[test] + fn acp_build_args_is_empty_registry_supplies_argv() { + assert!(build_args(BackendKind::Acp, &BackendOpts::default()).is_empty()); + } + + #[test] + fn acp_program_resolution_keeps_explicit_paths_and_reuses_engine_resolution() { + // A path with separators is used exactly as configured. + let p = resolve_acp_program("/usr/local/bin/my-agent"); + assert_eq!(p, PathBuf::from("/usr/local/bin/my-agent")); + // The engine binaries route through the shared backend resolution + // (worst case they fall back to the bare name, never to an empty path). + let ju = resolve_acp_program("jucode"); + assert!(!ju.as_os_str().is_empty()); + } + // --- arg templates --- #[test] @@ -538,7 +668,10 @@ mod tests { let args = build_args(BackendKind::Claude, &opts); let ri = args.iter().position(|a| a == "--resume").unwrap(); assert_eq!(args[ri + 1], "0f3d7a1c-9e2b-4b7e-9d4d-2a1b3c4d5e6f"); - let ai = args.iter().position(|a| a == "--resume-session-at").unwrap(); + let ai = args + .iter() + .position(|a| a == "--resume-session-at") + .unwrap(); assert_eq!(args[ai + 1], "aa11bb22-cc33-dd44-ee55-ff6677889900"); // resume_session_at without resume is rejected. assert!(validate_opts( @@ -573,7 +706,11 @@ mod tests { assert!(err.is_err(), "{kind:?} must reject permission_mode"); } assert!(validate_opts(BackendKind::Claude, Some(&json!({ "argv": ["-x"] }))).is_err()); - assert!(validate_opts(BackendKind::Claude, Some(&json!({ "extra_flag": "--yolo" }))).is_err()); + assert!(validate_opts( + BackendKind::Claude, + Some(&json!({ "extra_flag": "--yolo" })) + ) + .is_err()); } #[test] @@ -583,31 +720,29 @@ mod tests { Some(&json!({ "model": "--dangerously-skip-permissions" })) ) .is_err()); - assert!(validate_opts( - BackendKind::Claude, - Some(&json!({ "resume": "--help" })) - ) - .is_err()); - assert!(validate_opts( - BackendKind::Jucode, - Some(&json!({ "bin_override": "-rf" })) - ) - .is_err()); + assert!(validate_opts(BackendKind::Claude, Some(&json!({ "resume": "--help" }))).is_err()); + assert!( + validate_opts(BackendKind::Jucode, Some(&json!({ "bin_override": "-rf" }))).is_err() + ); } #[test] fn permission_mode_is_a_fixed_enum() { for ok in CLAUDE_PERMISSION_MODES { - assert!(validate_opts(BackendKind::Claude, Some(&json!({ "permission_mode": ok }))).is_ok()); + assert!( + validate_opts(BackendKind::Claude, Some(&json!({ "permission_mode": ok }))).is_ok() + ); } assert!(validate_opts( BackendKind::Claude, Some(&json!({ "permission_mode": "bypassPermissions --verbose" })) ) .is_err()); - assert!( - validate_opts(BackendKind::Claude, Some(&json!({ "permission_mode": "yolo" }))).is_err() - ); + assert!(validate_opts( + BackendKind::Claude, + Some(&json!({ "permission_mode": "yolo" })) + ) + .is_err()); } #[test] @@ -648,7 +783,10 @@ mod tests { #[test] fn null_or_missing_opts_mean_defaults() { - assert_eq!(validate_opts(BackendKind::Jucode, None).unwrap(), BackendOpts::default()); + assert_eq!( + validate_opts(BackendKind::Jucode, None).unwrap(), + BackendOpts::default() + ); assert_eq!( validate_opts(BackendKind::Codex, Some(&serde_json::Value::Null)).unwrap(), BackendOpts::default() @@ -665,11 +803,17 @@ mod tests { fn use_shell_env_defaults_true_and_accepts_bool_only() { for kind in [BackendKind::Jucode, BackendKind::Codex, BackendKind::Claude] { assert!(validate_opts(kind, None).unwrap().use_shell_env); - assert!(!validate_opts(kind, Some(&json!({ "use_shell_env": false }))) - .unwrap() - .use_shell_env); + assert!( + !validate_opts(kind, Some(&json!({ "use_shell_env": false }))) + .unwrap() + .use_shell_env + ); } - assert!(validate_opts(BackendKind::Jucode, Some(&json!({ "use_shell_env": "yes" }))).is_err()); + assert!(validate_opts( + BackendKind::Jucode, + Some(&json!({ "use_shell_env": "yes" })) + ) + .is_err()); } #[test] @@ -688,7 +832,10 @@ mod tests { json!({ "env": { "A": 42 } }), json!({ "env": "PATH=/x" }), ] { - assert!(validate_opts(BackendKind::Claude, Some(&bad)).is_err(), "{bad}"); + assert!( + validate_opts(BackendKind::Claude, Some(&bad)).is_err(), + "{bad}" + ); } } @@ -763,7 +910,9 @@ mod tests { assert_eq!(p, PathBuf::from("claude")); let probed = hits.lock().unwrap(); assert!(!probed.is_empty()); - assert!(probed.iter().any(|c| c.ends_with(".local/bin/claude") || c.ends_with(".local\\bin\\claude.exe"))); + assert!(probed + .iter() + .any(|c| c.ends_with(".local/bin/claude") || c.ends_with(".local\\bin\\claude.exe"))); } #[test] @@ -772,7 +921,13 @@ mod tests { c.to_string_lossy().contains("JuCode-CLI/target/debug") }); assert!(p.to_string_lossy().contains("JuCode-CLI/target/debug")); - let none = resolve_with(BackendKind::Jucode, None, &no_env, &no_which, ¬hing_exists); + let none = resolve_with( + BackendKind::Jucode, + None, + &no_env, + &no_which, + ¬hing_exists, + ); assert_eq!(none, PathBuf::from("jucode")); } } diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index fb65566..1cdd77d 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -148,7 +148,9 @@ pub fn browser_open( #[tauri::command] pub fn browser_navigate(app: AppHandle, url: String) -> Result<(), String> { let target = normalize_url(&url)?; - get_browser(&app)?.navigate(target).map_err(|e| e.to_string()) + get_browser(&app)? + .navigate(target) + .map_err(|e| e.to_string()) } #[tauri::command] diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs index 2cbe18d..ea2a710 100644 --- a/src-tauri/src/capture.rs +++ b/src-tauri/src/capture.rs @@ -94,7 +94,9 @@ fn pick_shot_backend( "windows" => Ok(ShotBackend::WinSnip), "linux" => { if wayland && has("grim") { - return Ok(ShotBackend::Grim { slurp: has("slurp") }); + return Ok(ShotBackend::Grim { + slurp: has("slurp"), + }); } if has("gnome-screenshot") { return Ok(ShotBackend::GnomeScreenshot); @@ -329,7 +331,10 @@ fn win_capture_fullscreen(path: &Path) -> Result { #[tauri::command] pub fn start_screen_recording(rec: tauri::State) -> Result<(), String> { let backend = pick_rec_backend(std::env::consts::OS, is_wayland(), &has_tool)?; - let mut guard = rec.inner.lock().map_err(|e| format!("lock poisoned: {e}"))?; + let mut guard = rec + .inner + .lock() + .map_err(|e| format!("lock poisoned: {e}"))?; if guard.is_some() { return Err("已有录屏进行中 / A screen recording is already in progress".to_string()); } @@ -402,8 +407,8 @@ pub fn stop_screen_recording(rec: tauri::State<'_, Recorder>) -> Result { let _ = Command::new("kill") @@ -456,7 +461,12 @@ fn find_tool(name: &str) -> Result { let mut dirs: Vec = Vec::new(); if let Some(la) = std::env::var_os("LOCALAPPDATA") { // winget (Gyan.FFmpeg) shim directory. - dirs.push(PathBuf::from(la).join("Microsoft").join("WinGet").join("Links")); + dirs.push( + PathBuf::from(la) + .join("Microsoft") + .join("WinGet") + .join("Links"), + ); } dirs.push(PathBuf::from("C:\\ffmpeg\\bin")); for dir in dirs { @@ -466,7 +476,12 @@ fn find_tool(name: &str) -> Result { } } } else { - for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin", "/usr/bin"] { + for dir in [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/opt/local/bin", + "/usr/bin", + ] { let candidate = Path::new(dir).join(name); if candidate.is_file() { return Ok(candidate); @@ -623,9 +638,12 @@ mod tests { #[test] fn wayland_prefers_grim_with_slurp() { - let got = - pick_shot_backend("linux", true, &avail(&["grim", "slurp", "gnome-screenshot"])) - .unwrap(); + let got = pick_shot_backend( + "linux", + true, + &avail(&["grim", "slurp", "gnome-screenshot"]), + ) + .unwrap(); assert_eq!(got, ShotBackend::Grim { slurp: true }); } @@ -645,8 +663,8 @@ mod tests { #[test] fn x11_ignores_grim_and_probes_in_order() { // grim is wayland-only: never picked on X11 even if installed. - let got = pick_shot_backend("linux", false, &avail(&["grim", "spectacle", "scrot"])) - .unwrap(); + let got = + pick_shot_backend("linux", false, &avail(&["grim", "spectacle", "scrot"])).unwrap(); assert_eq!(got, ShotBackend::Spectacle); let got = pick_shot_backend("linux", false, &avail(&["grim", "scrot"])).unwrap(); assert_eq!(got, ShotBackend::Scrot); diff --git a/src-tauri/src/claude_history.rs b/src-tauri/src/claude_history.rs index d0c3838..50b44dd 100644 --- a/src-tauri/src/claude_history.rs +++ b/src-tauri/src/claude_history.rs @@ -297,7 +297,9 @@ mod tests { &home, cwd, "aaaaaaaa-1111-4111-8111-111111111111", - &[r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"fix the login bug"}]}}"#], + &[ + r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"fix the login bug"}]}}"#, + ], ); let newer = write_session( &home, @@ -375,9 +377,18 @@ mod tests { assert_eq!( rows, vec![ - ClaudeTranscriptRow { role: "user".into(), content: "run a command".into() }, - ClaudeTranscriptRow { role: "assistant".into(), content: "on it".into() }, - ClaudeTranscriptRow { role: "assistant".into(), content: "done".into() }, + ClaudeTranscriptRow { + role: "user".into(), + content: "run a command".into() + }, + ClaudeTranscriptRow { + role: "assistant".into(), + content: "on it".into() + }, + ClaudeTranscriptRow { + role: "assistant".into(), + content: "done".into() + }, ] ); } @@ -431,6 +442,9 @@ mod tests { assert_eq!(rows.len(), MAX_TRANSCRIPT_ROWS); // The oldest rows were dropped, the newest kept. assert_eq!(rows.last().unwrap().role, "assistant"); - assert_eq!(rows.last().unwrap().content.chars().count(), MAX_ROW_CHARS + 1); // +1 = ellipsis + assert_eq!( + rows.last().unwrap().content.chars().count(), + MAX_ROW_CHARS + 1 + ); // +1 = ellipsis } } diff --git a/src-tauri/src/installer.rs b/src-tauri/src/installer.rs index 48bb2a5..f4d651f 100644 --- a/src-tauri/src/installer.rs +++ b/src-tauri/src/installer.rs @@ -142,21 +142,29 @@ fn system_plan( if has("winget") { winget(winget_id) } else { - Plan::OpenUrl { url: url.to_string() } + Plan::OpenUrl { + url: url.to_string(), + } } } "macos" => { if has("brew") { brew(brew_pkg) } else { - Plan::OpenUrl { url: url.to_string() } + Plan::OpenUrl { + url: url.to_string(), + } } } "linux" => match linux_pkg_command(linux_pkgs, has) { Some(command) => Plan::Manual { command }, - None => Plan::OpenUrl { url: url.to_string() }, + None => Plan::OpenUrl { + url: url.to_string(), + }, + }, + _ => Plan::OpenUrl { + url: url.to_string(), }, - _ => Plan::OpenUrl { url: url.to_string() }, } } @@ -169,14 +177,18 @@ pub fn plan(dep: Dep, os: &str, has: &dyn Fn(&str) -> bool) -> Plan { if has("npm") { npm_global("@openai/codex") } else { - Plan::NeedsPrereq { prereq: "node".to_string() } + Plan::NeedsPrereq { + prereq: "node".to_string(), + } } } Dep::Jucode => { if has("npm") { npm_global("@jucode/cli") } else { - Plan::NeedsPrereq { prereq: "node".to_string() } + Plan::NeedsPrereq { + prereq: "node".to_string(), + } } } Dep::Claude => { @@ -248,10 +260,15 @@ mod tests { // Linux with apt → copyable sudo command (nodejs + npm). assert_eq!( plan(Dep::Node, "linux", &avail(&["apt-get"])), - Plan::Manual { command: "sudo apt-get install -y nodejs npm".to_string() } + Plan::Manual { + command: "sudo apt-get install -y nodejs npm".to_string() + } ); // Linux without a known manager → download page. - assert!(matches!(plan(Dep::Node, "linux", &avail(&[])), Plan::OpenUrl { .. })); + assert!(matches!( + plan(Dep::Node, "linux", &avail(&[])), + Plan::OpenUrl { .. } + )); } #[test] @@ -260,10 +277,15 @@ mod tests { plan(Dep::Ffmpeg, "windows", &avail(&["winget"])), winget("Gyan.FFmpeg") ); - assert_eq!(plan(Dep::Ffmpeg, "macos", &avail(&["brew"])), brew("ffmpeg")); + assert_eq!( + plan(Dep::Ffmpeg, "macos", &avail(&["brew"])), + brew("ffmpeg") + ); assert_eq!( plan(Dep::Ffmpeg, "linux", &avail(&["dnf"])), - Plan::Manual { command: "sudo dnf install -y ffmpeg".to_string() } + Plan::Manual { + command: "sudo dnf install -y ffmpeg".to_string() + } ); } @@ -272,16 +294,29 @@ mod tests { // No npm → prereq. assert_eq!( plan(Dep::Codex, "windows", &avail(&[])), - Plan::NeedsPrereq { prereq: "node".to_string() } + Plan::NeedsPrereq { + prereq: "node".to_string() + } ); assert_eq!( plan(Dep::Jucode, "linux", &avail(&[])), - Plan::NeedsPrereq { prereq: "node".to_string() } + Plan::NeedsPrereq { + prereq: "node".to_string() + } ); // With npm → npm i -g , identical across platforms. - assert_eq!(plan(Dep::Codex, "windows", &avail(&["npm"])), npm_global("@openai/codex")); - assert_eq!(plan(Dep::Codex, "macos", &avail(&["npm"])), npm_global("@openai/codex")); - assert_eq!(plan(Dep::Jucode, "linux", &avail(&["npm"])), npm_global("@jucode/cli")); + assert_eq!( + plan(Dep::Codex, "windows", &avail(&["npm"])), + npm_global("@openai/codex") + ); + assert_eq!( + plan(Dep::Codex, "macos", &avail(&["npm"])), + npm_global("@openai/codex") + ); + assert_eq!( + plan(Dep::Jucode, "linux", &avail(&["npm"])), + npm_global("@jucode/cli") + ); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c5d53e..42fe654 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,13 @@ +use serde::Serialize; use std::collections::HashMap; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; +mod acp_registry; mod backend; mod browser; mod capture; @@ -130,8 +131,31 @@ fn create_session( ) -> Result<(), String> { let kind = BackendKind::parse(backend.as_deref().unwrap_or("jucode"))?; let opts = backend::validate_opts(kind, backend_opts.as_ref())?; - let bin = backend::resolve_backend_bin(kind, opts.bin_override.as_deref()); - let args = backend::build_args(kind, &opts); + // ACP sessions spawn a registered agent: the command line is looked up in + // the validated registry by id — never composed from request data. + let (bin, args, agent_env) = if kind == BackendKind::Acp { + let agent_id = opts + .agent + .as_deref() + .ok_or_else(|| "acp backend requires an `agent` registry id".to_string())?; + let agent = acp_registry::find_agent(&app, agent_id)?; + let env: Vec<(String, String)> = agent + .env + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + ( + backend::resolve_acp_program(&agent.command), + agent.args, + env, + ) + } else { + ( + backend::resolve_backend_bin(kind, opts.bin_override.as_deref()), + backend::build_args(kind, &opts), + Vec::new(), + ) + }; let dir = cwd .map(PathBuf::from) .filter(|p| p.is_dir()) @@ -159,7 +183,11 @@ fn create_session( } else { &[] }; - shell_env::apply_to_command(&mut cmd, opts.use_shell_env, explicit, &opts.env); + // Per-backend custom env first, then the registry entry's per-agent env + // (the more specific configuration wins). + let mut custom_env = opts.env.clone(); + custom_env.extend(agent_env); + shell_env::apply_to_command(&mut cmd, opts.use_shell_env, explicit, &custom_env); let mut child = cmd.spawn().map_err(|error| match kind { BackendKind::Jucode => format!("failed to start jucode serve: {error}"), _ => format!("failed to start {} backend: {error}", kind.bin_name()), @@ -267,11 +295,7 @@ fn send_op( /// own protocol frame (JSON-RPC for codex, stream-json for claude) and sends it /// as one line. Embedded newlines are rejected — one call, one frame. #[tauri::command] -fn send_line( - session: String, - line: String, - engines: tauri::State, -) -> Result<(), String> { +fn send_line(session: String, line: String, engines: tauri::State) -> Result<(), String> { if line.contains('\n') || line.contains('\r') { return Err("line must be a single frame (no embedded newlines)".to_string()); } @@ -280,10 +304,10 @@ fn send_line( /// Availability report for one backend binary (settings / new-session UI). #[derive(Serialize)] -struct BackendStatus { - found: bool, - path: Option, - version: Option, +pub(crate) struct BackendStatus { + pub(crate) found: bool, + pub(crate) path: Option, + pub(crate) version: Option, } /// Probes a backend binary: resolves it (honoring `bin_override`) and runs @@ -292,6 +316,10 @@ struct BackendStatus { #[tauri::command(async)] fn check_backend(backend: String, bin_override: Option) -> Result { let kind = BackendKind::parse(&backend)?; + if kind == BackendKind::Acp { + // ACP has no single binary — probe a specific agent with acp_agent_check. + return Err("use acp_agent_check to probe a registered ACP agent".to_string()); + } let bin = backend::resolve_backend_bin(kind, bin_override.as_deref()); // A bare name means "nothing found, hope PATH has it at spawn time" — // resolve it through PATH for the report (None when truly absent). @@ -343,8 +371,12 @@ fn read_json(path: &std::path::Path) -> serde_json::Value { fn read_json_strict(path: &std::path::Path) -> Result { match std::fs::read_to_string(path) { Ok(text) if text.trim().is_empty() => Ok(serde_json::json!({})), - Ok(text) => serde_json::from_str(&text) - .map_err(|e| format!("{} 解析失败,已中止写入以免覆盖现有内容:{e}", path.display())), + Ok(text) => serde_json::from_str(&text).map_err(|e| { + format!( + "{} 解析失败,已中止写入以免覆盖现有内容:{e}", + path.display() + ) + }), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::json!({})), Err(e) => Err(format!("读取 {} 失败:{e}", path.display())), } @@ -556,10 +588,7 @@ fn remove_auth_key(provider: String) -> Result<(), String> { root.remove("jucode"); } } - if let Some(map) = current - .get_mut("providers") - .and_then(|v| v.as_object_mut()) - { + if let Some(map) = current.get_mut("providers").and_then(|v| v.as_object_mut()) { map.remove(&provider); } write_auth(&mut current) @@ -589,7 +618,10 @@ fn unix_now() -> u64 { /// only keeps the Desktop's own API calls authenticated between logins. fn jucode_access_token() -> Result { let auth = read_auth(); - let jucode = auth.get("jucode").cloned().unwrap_or_else(|| serde_json::json!({})); + let jucode = auth + .get("jucode") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); let access = jucode .get("access_token") .and_then(|v| v.as_str()) @@ -600,7 +632,10 @@ fn jucode_access_token() -> Result { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - let access_exp = jucode.get("access_expires_at").and_then(|v| v.as_u64()).unwrap_or(0); + let access_exp = jucode + .get("access_expires_at") + .and_then(|v| v.as_u64()) + .unwrap_or(0); if refresh.is_empty() { return Err("not logged in to JuCode".to_string()); } @@ -617,7 +652,10 @@ fn jucode_access_token() -> Result { .map_err(|e| format!("lock poisoned: {e}"))?; // Re-read after acquiring the lock: another thread may have just refreshed. let fresh = read_auth(); - let fresh_jucode = fresh.get("jucode").cloned().unwrap_or_else(|| serde_json::json!({})); + let fresh_jucode = fresh + .get("jucode") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); let fresh_access = fresh_jucode .get("access_token") .and_then(|v| v.as_str()) @@ -652,12 +690,23 @@ fn jucode_access_token() -> Result { .map_err(|e| e.to_string())? .into_json() .map_err(|e| e.to_string())?; - let new_access = resp.get("access_token").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let new_refresh = resp.get("refresh_token").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let new_access = resp + .get("access_token") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let new_refresh = resp + .get("refresh_token") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); if new_access.is_empty() || new_refresh.is_empty() { return Err("JuCode session expired; please sign in again".to_string()); } - let expires_in = resp.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(3600); + let expires_in = resp + .get("expires_in") + .and_then(|v| v.as_u64()) + .unwrap_or(3600); let refresh_expires_in = resp .get("refresh_expires_in") .and_then(|v| v.as_u64()) @@ -843,7 +892,11 @@ fn resolve_asr_config(config: &serde_json::Value) -> Result { .filter(|value| !value.is_empty()) .unwrap_or(provider.model) .to_string(); - Ok(AsrConfig { provider, base_url, model }) + Ok(AsrConfig { + provider, + base_url, + model, + }) } fn append_multipart_field(body: &mut Vec, boundary: &str, name: &str, value: &str) { @@ -926,7 +979,10 @@ fn build_asr_request( url: format!("{}/audio/transcriptions", config.base_url), headers: vec![ ("Authorization", format!("Bearer {key}")), - ("Content-Type", format!("multipart/form-data; boundary={boundary}")), + ( + "Content-Type", + format!("multipart/form-data; boundary={boundary}"), + ), ], body, }) @@ -995,10 +1051,12 @@ fn transcribe_audio( .and_then(|value| value.as_str()) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) - .ok_or_else(|| format!( - "No API key configured for {} (Settings → Account → Speech recognition)", - config.provider.name - ))?; + .ok_or_else(|| { + format!( + "No API key configured for {} (Settings → Account → Speech recognition)", + config.provider.name + ) + })?; let audio = base64::engine::general_purpose::STANDARD .decode(audio_base64) .map_err(|error| format!("Invalid base64 audio: {error}"))?; @@ -1093,8 +1151,8 @@ fn generate_text( ], "temperature": 0.3, }); - let mut req = ureq::post(&format!("{base}/chat/completions")) - .timeout(std::time::Duration::from_secs(90)); + let mut req = + ureq::post(&format!("{base}/chat/completions")).timeout(std::time::Duration::from_secs(90)); if !key.is_empty() { req = req.set("Authorization", &format!("Bearer {key}")); } @@ -1335,7 +1393,9 @@ fn check_environment() -> EnvReport { }; let engine = DepStatus { present: engine_path.is_some(), - detail: engine_path.map(|p| p.display().to_string()).unwrap_or_default(), + detail: engine_path + .map(|p| p.display().to_string()) + .unwrap_or_default(), }; EnvReport { @@ -1550,9 +1610,7 @@ fn run_install(name: String, app: AppHandle) -> Result { let (program, args) = match plan { installer::Plan::Manual { command } => return Ok(InstallStart::ManualCommand { command }), installer::Plan::OpenUrl { url } => return Ok(InstallStart::OpenUrl { url }), - installer::Plan::NeedsPrereq { prereq } => { - return Ok(InstallStart::NeedsPrereq { prereq }) - } + installer::Plan::NeedsPrereq { prereq } => return Ok(InstallStart::NeedsPrereq { prereq }), installer::Plan::Run { program, args } => (program, args), }; // Resolve the logical program name through PATH (e.g. `npm` → `npm.cmd`). @@ -1616,7 +1674,11 @@ fn list_dir(path: Option, root: Option) -> Result, is_dir, }); } - entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + entries.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); Ok(entries) } @@ -1625,10 +1687,7 @@ fn list_dir(path: Option, root: Option) -> Result, fn list_providers() -> Result { let mut cmd = Command::new(resolve_bin()); no_window(&mut cmd); - let out = cmd - .arg("providers") - .output() - .map_err(|e| e.to_string())?; + let out = cmd.arg("providers").output().map_err(|e| e.to_string())?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).to_string()); } @@ -1877,7 +1936,10 @@ fn git_head_text(path: String, cwd: Option) -> Result { return Err(String::from_utf8_lossy(&out.stderr).into_owned()); } if out.stdout.len() as u64 > MAX_TEXT_READ { - return Err(format!("file too large to diff ({} bytes)", out.stdout.len())); + return Err(format!( + "file too large to diff ({} bytes)", + out.stdout.len() + )); } String::from_utf8(out.stdout).map_err(|_| "not a UTF-8 text file".to_string()) } @@ -1887,9 +1949,27 @@ fn git_head_text(path: String, cwd: Option) -> Result { /// argument-validated set of remote operations (fetch / pull / push / remote -v). /// Anything that can run arbitrary programs is intentionally excluded. const GIT_SUBCOMMANDS: &[&str] = &[ - "status", "log", "diff", "add", "reset", "restore", "commit", "stash", "show", - "rev-parse", "branch", "checkout", "switch", "ls-files", "clean", "fetch", "pull", - "push", "remote", "merge", "rev-list", + "status", + "log", + "diff", + "add", + "reset", + "restore", + "commit", + "stash", + "show", + "rev-parse", + "branch", + "checkout", + "switch", + "ls-files", + "clean", + "fetch", + "pull", + "push", + "remote", + "merge", + "rev-list", ]; /// 需要访问网络的子命令:禁用凭据交互提示、限时执行(防止卡在等待输入上)。 @@ -1935,16 +2015,52 @@ fn is_valid_ref_name(s: &str) -> bool { fn git_flag_allowed(sub: &str, arg: &str) -> bool { let base = arg.split_once('=').map(|(k, _)| k).unwrap_or(arg); let allowed: &[&str] = match sub { - "status" => &["--porcelain", "-s", "-b", "-sb", "--short", "--branch", "--no-color"], - "log" => &["--oneline", "-n", "-1", "--no-color", "--pretty", "--format", "--max-count"], - "diff" => &["--cached", "--staged", "--no-color", "--stat", "--numstat", "--name-only"], + "status" => &[ + "--porcelain", + "-s", + "-b", + "-sb", + "--short", + "--branch", + "--no-color", + ], + "log" => &[ + "--oneline", + "-n", + "-1", + "--no-color", + "--pretty", + "--format", + "--max-count", + ], + "diff" => &[ + "--cached", + "--staged", + "--no-color", + "--stat", + "--numstat", + "--name-only", + ], "add" => &["-A", "--all"], "restore" => &["--staged", "--worktree"], "commit" => &["-m"], "stash" => &["-m", "-u", "--include-untracked"], "show" => &["--no-color", "--stat", "--pretty", "--format", "-s"], - "rev-parse" => &["--abbrev-ref", "--symbolic-full-name", "--short", "--verify"], - "branch" => &["--show-current", "--format", "--list", "--no-color", "-d", "-D", "--delete"], + "rev-parse" => &[ + "--abbrev-ref", + "--symbolic-full-name", + "--short", + "--verify", + ], + "branch" => &[ + "--show-current", + "--format", + "--list", + "--no-color", + "-d", + "-D", + "--delete", + ], "checkout" => &["-b"], // 注意:不放行 `-c`(与全局禁用的 config 短参撞名),创建分支用 // `switch --create` 或 `checkout -b`。 @@ -2222,7 +2338,7 @@ fn worktree_base(cwd: String) -> Result { /// Runs a spawned command to completion with a hard deadline: stdout/stderr are /// drained on threads, and the child is killed if it outlives `timeout` (e.g. a /// remote op stuck on the network even with prompts disabled). -fn run_with_timeout( +pub(crate) fn run_with_timeout( mut cmd: Command, timeout: std::time::Duration, ) -> Result { @@ -2262,7 +2378,11 @@ fn run_with_timeout( }; let stdout = out_thread.join().unwrap_or_default(); let stderr = err_thread.join().unwrap_or_default(); - Ok(std::process::Output { status, stdout, stderr }) + Ok(std::process::Output { + status, + stdout, + stderr, + }) } /// Runs a git command in the project root and returns stdout (or stderr on failure). @@ -2293,7 +2413,8 @@ fn git(args: Vec, cwd: Option) -> Result { let output = if is_remote { run_with_timeout(cmd, REMOTE_OP_TIMEOUT)? } else { - cmd.output().map_err(|e| format!("failed to run git: {e}"))? + cmd.output() + .map_err(|e| format!("failed to run git: {e}"))? }; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) @@ -2363,7 +2484,11 @@ fn git_checkpoint_capture(cwd: String) -> Result { &["commit-tree", &tree, "-p", &head, "-m", "jucode-checkpoint"], )? } else { - git_plumb(&dir, None, &["commit-tree", &tree, "-m", "jucode-checkpoint"])? + git_plumb( + &dir, + None, + &["commit-tree", &tree, "-m", "jucode-checkpoint"], + )? }; Ok(commit) } @@ -2519,7 +2644,10 @@ fn pty_write(id: String, data: String, ptys: tauri::State) -> Result<(), S .get(&id) .cloned(); let pty = pty.ok_or_else(|| "unknown terminal".to_string())?; - let mut writer = pty.writer.lock().map_err(|e| format!("lock poisoned: {e}"))?; + let mut writer = pty + .writer + .lock() + .map_err(|e| format!("lock poisoned: {e}"))?; writer.write_all(data.as_bytes()).map_err(|e| e.to_string()) } @@ -2533,7 +2661,10 @@ fn pty_resize(id: String, cols: u16, rows: u16, ptys: tauri::State) -> Res .get(&id) .cloned(); let pty = pty.ok_or_else(|| "unknown terminal".to_string())?; - let master = pty.master.lock().map_err(|e| format!("lock poisoned: {e}"))?; + let master = pty + .master + .lock() + .map_err(|e| format!("lock poisoned: {e}"))?; master .resize(PtySize { rows, @@ -2641,7 +2772,9 @@ pub fn run() { // 不透明、只让侧栏半透明,于是磨砂只在侧栏透出(见 app.css 的 [data-vibrancy])。 #[cfg(target_os = "macos")] if let Some(win) = app.get_webview_window("main") { - use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState}; + use window_vibrancy::{ + apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState, + }; let _ = apply_vibrancy( &win, NSVisualEffectMaterial::Sidebar, @@ -2659,7 +2792,8 @@ pub fn run() { let _ = app.deep_link().register_all(); // 深链到达时先把窗口带到前台,具体路由由前端解析处理。 let handle = app.handle().clone(); - app.deep_link().on_open_url(move |_| show_main_window(&handle)); + app.deep_link() + .on_open_url(move |_| show_main_window(&handle)); } Ok(()) }) @@ -2681,6 +2815,10 @@ pub fn run() { send_op, send_line, check_backend, + acp_registry::acp_agents_list, + acp_registry::acp_agent_upsert, + acp_registry::acp_agent_remove, + acp_registry::acp_agent_check, shell_env::shell_env_status, shell_env::refresh_shell_env, close_session, @@ -2782,9 +2920,8 @@ mod tests { assert_eq!(mimo.provider.protocol, AsrProtocol::Mimo); assert_eq!(mimo.model, "mimo-v2.5-asr"); - let groq = resolve_asr_config( - &serde_json::json!({ "asr": { "provider": "groq" } }) - ).unwrap(); + let groq = + resolve_asr_config(&serde_json::json!({ "asr": { "provider": "groq" } })).unwrap(); assert_eq!(groq.provider.protocol, AsrProtocol::OpenAiWhisper); assert_eq!(groq.base_url, "https://api.groq.com/openai/v1"); assert_eq!(groq.model, "whisper-large-v3-turbo"); @@ -2796,7 +2933,8 @@ mod tests { "base_url": "https://speech.example/v1/", "model": "custom-model" } - })).unwrap(); + })) + .unwrap(); assert_eq!(deepgram.provider.protocol, AsrProtocol::Deepgram); assert_eq!(deepgram.base_url, "https://speech.example/v1"); assert_eq!(deepgram.model, "custom-model"); @@ -2808,43 +2946,53 @@ mod tests { let audio = b"wav bytes"; let mimo = resolve_asr_config(&serde_json::json!({})).unwrap(); - let request = build_asr_request( - &mimo, "mimo-key", audio, "audio/wav", "auto", "boundary" - ).unwrap(); - assert_eq!(request.url, "https://api.xiaomimimo.com/v1/chat/completions"); - assert!(request.headers.contains(&("api-key", "mimo-key".to_string()))); + let request = + build_asr_request(&mimo, "mimo-key", audio, "audio/wav", "auto", "boundary").unwrap(); + assert_eq!( + request.url, + "https://api.xiaomimimo.com/v1/chat/completions" + ); + assert!(request + .headers + .contains(&("api-key", "mimo-key".to_string()))); let json: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); assert_eq!(json["model"], "mimo-v2.5-asr"); assert!(json["messages"][0]["content"][0]["input_audio"]["data"] - .as_str().unwrap().starts_with("data:audio/wav;base64,")); - - let openai = resolve_asr_config( - &serde_json::json!({ "asr": { "provider": "openai" } }) - ).unwrap(); - let request = build_asr_request( - &openai, "openai-key", audio, "audio/wav", "en", "boundary" - ).unwrap(); - assert_eq!(request.url, "https://api.openai.com/v1/audio/transcriptions"); - assert!(request.headers.contains( - &("Authorization", "Bearer openai-key".to_string()) - )); + .as_str() + .unwrap() + .starts_with("data:audio/wav;base64,")); + + let openai = + resolve_asr_config(&serde_json::json!({ "asr": { "provider": "openai" } })).unwrap(); + let request = + build_asr_request(&openai, "openai-key", audio, "audio/wav", "en", "boundary").unwrap(); + assert_eq!( + request.url, + "https://api.openai.com/v1/audio/transcriptions" + ); + assert!(request + .headers + .contains(&("Authorization", "Bearer openai-key".to_string()))); let body = String::from_utf8_lossy(&request.body); assert!(body.contains("name=\"model\"\r\n\r\nwhisper-1")); assert!(body.contains("name=\"language\"\r\n\r\nen")); assert!(body.contains("filename=\"recording.wav\"")); - assert!(request.body.windows(audio.len()).any(|window| window == audio)); - - let deepgram = resolve_asr_config( - &serde_json::json!({ "asr": { "provider": "deepgram" } }) - ).unwrap(); - let request = build_asr_request( - &deepgram, "dg-key", audio, "audio/wav", "zh", "boundary" - ).unwrap(); + assert!(request + .body + .windows(audio.len()) + .any(|window| window == audio)); + + let deepgram = + resolve_asr_config(&serde_json::json!({ "asr": { "provider": "deepgram" } })).unwrap(); + let request = + build_asr_request(&deepgram, "dg-key", audio, "audio/wav", "zh", "boundary").unwrap(); assert_eq!( request.url, "https://api.deepgram.com/v1/listen?model=nova-3&smart_format=true&language=zh" ); - assert!(request.headers.contains(&("Authorization", "Token dg-key".to_string()))); + assert!(request + .headers + .contains(&("Authorization", "Token dg-key".to_string()))); assert_eq!(request.body, audio); } @@ -2935,8 +3083,8 @@ mod tests { /// An `AuthStore` over throwaway paths, with the switch preset. fn auth_store(name: &str, encrypt: bool) -> (super::AuthStore, std::path::PathBuf) { - let dir = std::env::temp_dir() - .join(format!("jucode-authstore-{}-{name}", std::process::id())); + let dir = + std::env::temp_dir().join(format!("jucode-authstore-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( @@ -2984,7 +3132,10 @@ mod tests { assert!(on_disk.contains("\"access_expires_at\": 9"), "{on_disk}"); let back = store.read(); - assert_eq!(back["providers"]["deepseek"], serde_json::json!("sk-legacy")); + assert_eq!( + back["providers"]["deepseek"], + serde_json::json!("sk-legacy") + ); assert_eq!(back["providers"]["mimo"], serde_json::json!("sk-mimo")); assert_eq!(back["jucode"]["refresh_token"], serde_json::json!("rt-1")); @@ -2996,7 +3147,9 @@ mod tests { let (store, dir) = auth_store("disable", true); let mut current = plaintext_auth(); store.write(&mut current).unwrap(); - assert!(!std::fs::read_to_string(&store.auth).unwrap().contains("sk-legacy")); + assert!(!std::fs::read_to_string(&store.auth) + .unwrap() + .contains("sk-legacy")); let mut current = store.read_strict().unwrap(); std::fs::write(&store.config, r#"{"encrypt_secrets":false}"#).unwrap(); @@ -3105,7 +3258,10 @@ mod tests { fn windows_advice_depends_on_winget() { let advice = git_install_advice("windows", &avail(&["winget"])); assert_eq!(advice.kind, "auto"); - assert!(advice.command.unwrap().contains("winget install --id Git.Git")); + assert!(advice + .command + .unwrap() + .contains("winget install --id Git.Git")); let advice = git_install_advice("windows", &avail(&[])); assert_eq!(advice.kind, "open-url"); assert_eq!(advice.url, "https://git-scm.com/download/win"); @@ -3150,7 +3306,10 @@ mod tests { vec!["checkout", "main"], vec!["remote", "-v"], ] { - assert!(validate_git_args(&args(&cmd)).is_ok(), "should allow: {cmd:?}"); + assert!( + validate_git_args(&args(&cmd)).is_ok(), + "should allow: {cmd:?}" + ); } } @@ -3162,7 +3321,10 @@ mod tests { vec!["push"], vec!["push", "-u", "origin", "feature/new-ui"], ] { - assert!(validate_git_args(&args(&cmd)).is_ok(), "should allow: {cmd:?}"); + assert!( + validate_git_args(&args(&cmd)).is_ok(), + "should allow: {cmd:?}" + ); } } @@ -3195,7 +3357,10 @@ mod tests { // 远端子命令禁止 `--` 逃逸 vec!["push", "--", "origin"], ] { - assert!(validate_git_args(&args(&cmd)).is_err(), "should reject: {cmd:?}"); + assert!( + validate_git_args(&args(&cmd)).is_err(), + "should reject: {cmd:?}" + ); } } @@ -3267,11 +3432,8 @@ mod tests { /// Creates //repo and returns (tmp_root, repo_root). fn tmp_repo(name: &str) -> (std::path::PathBuf, std::path::PathBuf) { - let root = std::env::temp_dir().join(format!( - "jucode-wt-test-{}-{}", - std::process::id(), - name - )); + let root = + std::env::temp_dir().join(format!("jucode-wt-test-{}-{}", std::process::id(), name)); let repo = root.join("repo"); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&repo).unwrap(); @@ -3284,7 +3446,10 @@ mod tests { let base = worktree_base_dir(&repo).unwrap(); assert_eq!( base, - root.canonicalize().unwrap().join(".jucode-worktrees").join("repo") + root.canonicalize() + .unwrap() + .join(".jucode-worktrees") + .join("repo") ); let _ = std::fs::remove_dir_all(&root); } @@ -3312,8 +3477,14 @@ mod tests { &repo ) .is_ok()); - assert!(validate_worktree_args(&args(&["worktree", "add", &ok, "-b", "task/my-task"]), &repo).is_ok()); - assert!(validate_worktree_args(&args(&["worktree", "add", &ok, "task/my-task"]), &repo).is_ok()); + assert!(validate_worktree_args( + &args(&["worktree", "add", &ok, "-b", "task/my-task"]), + &repo + ) + .is_ok()); + assert!( + validate_worktree_args(&args(&["worktree", "add", &ok, "task/my-task"]), &repo).is_ok() + ); // 容器外 / 穿越 / 非法 slug / 非法分支名 / 非法 flag 一律拒绝。 let escape = base.join("../evil").display().to_string(); @@ -3346,7 +3517,9 @@ mod tests { std::fs::create_dir_all(base.join("done-task")).unwrap(); let ok = base.join("done-task").display().to_string(); assert!(validate_worktree_args(&args(&["worktree", "remove", &ok]), &repo).is_ok()); - assert!(validate_worktree_args(&args(&["worktree", "remove", &ok, "--force"]), &repo).is_ok()); + assert!( + validate_worktree_args(&args(&["worktree", "remove", &ok, "--force"]), &repo).is_ok() + ); assert!(validate_worktree_args(&args(&["worktree", "list", "--porcelain"]), &repo).is_ok()); assert!(validate_worktree_args(&args(&["worktree", "prune"]), &repo).is_ok()); @@ -3386,15 +3559,26 @@ mod tests { assert!(in_root_or_task_container(&inside_repo, &canon_repo)); assert!(in_root_or_task_container(&inside_container, &canon_repo)); assert!(!in_root_or_task_container(&outside, &canon_repo)); - assert!(!in_root_or_task_container(std::path::Path::new("/etc"), &canon_repo)); + assert!(!in_root_or_task_container( + std::path::Path::new("/etc"), + &canon_repo + )); let _ = std::fs::remove_dir_all(&root); } #[test] fn merge_and_revlist_whitelist() { - assert!(validate_git_args(&args(&["merge", "--no-ff", "--no-edit", "task/fix-login"])).is_ok()); + assert!( + validate_git_args(&args(&["merge", "--no-ff", "--no-edit", "task/fix-login"])).is_ok() + ); assert!(validate_git_args(&args(&["merge", "--abort"])).is_ok()); - assert!(validate_git_args(&args(&["rev-list", "--left-right", "--count", "main...task/x"])).is_ok()); + assert!(validate_git_args(&args(&[ + "rev-list", + "--left-right", + "--count", + "main...task/x" + ])) + .is_ok()); assert!(validate_git_args(&args(&["merge", "--squash", "task/x"])).is_err()); assert!(validate_git_args(&args(&["merge", "--no-ff", "-evil"])).is_err()); @@ -3402,5 +3586,4 @@ mod tests { // worktree 不走通用校验入口。 assert!(validate_git_args(&args(&["worktree", "list", "--porcelain"])).is_err()); } - } diff --git a/src-tauri/src/shell_env.rs b/src-tauri/src/shell_env.rs index 6b55876..e42bec9 100644 --- a/src-tauri/src/shell_env.rs +++ b/src-tauri/src/shell_env.rs @@ -53,9 +53,7 @@ fn is_denied(name: &str) -> bool { /// 从捕获输出里解析 NUL 分隔的 KEY=VALUE(值可含换行)。 /// 若整段找不到 NUL 分隔(env 不支持 -0 的降级情形),退回按行解析。 pub(crate) fn parse_env_output(bytes: &[u8]) -> Option> { - let pos = bytes - .windows(MARKER.len()) - .position(|w| w == MARKER)?; + let pos = bytes.windows(MARKER.len()).position(|w| w == MARKER)?; let rest = &bytes[pos + MARKER.len()..]; let mut vars = parse_entries(rest.split(|b| *b == 0)); if vars.len() <= 1 { @@ -84,13 +82,16 @@ fn parse_entries<'a>(entries: impl Iterator) -> HashMap String { - std::env::var("SHELL").ok().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| { - if cfg!(target_os = "macos") { - "/bin/zsh".to_string() - } else { - "/bin/bash".to_string() - } - }) + std::env::var("SHELL") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| { + if cfg!(target_os = "macos") { + "/bin/zsh".to_string() + } else { + "/bin/bash".to_string() + } + }) } /// 用指定 shell 捕获一次环境。测试用假 shell 脚本注入即可覆盖全链路。 @@ -178,7 +179,11 @@ pub fn status() -> ShellEnvStatus { /// 快照的 PATH(供二进制解析优先使用)。 pub fn snapshot_path() -> Option { - state().read().unwrap().as_ref().and_then(|e| e.vars.get("PATH").cloned()) + state() + .read() + .unwrap() + .as_ref() + .and_then(|e| e.vars.get("PATH").cloned()) } /// 在不清空现有环境的前提下合并快照(git/gh 这类命令用:既要终端 PATH/ @@ -304,7 +309,10 @@ mod tests { std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap(); let env = capture_with(fake.to_str().unwrap()).expect("capture should succeed"); - assert_eq!(env.vars.get("SNAP_TEST_VAR").map(String::as_str), Some("hello")); + assert_eq!( + env.vars.get("SNAP_TEST_VAR").map(String::as_str), + Some("hello") + ); assert!(env.vars.contains_key("PATH")); let _ = std::fs::remove_dir_all(&dir); } @@ -336,7 +344,12 @@ mod tests { ); let envs: HashMap<_, _> = cmd .get_envs() - .filter_map(|(k, v)| Some((k.to_string_lossy().to_string(), v?.to_string_lossy().to_string()))) + .filter_map(|(k, v)| { + Some(( + k.to_string_lossy().to_string(), + v?.to_string_lossy().to_string(), + )) + }) .collect(); assert_eq!(envs["PATH"], "/snap/bin"); assert_eq!(envs["SHARED"], "from-custom"); diff --git a/src/lib/BackendIcon.svelte b/src/lib/BackendIcon.svelte index d141af3..238f472 100644 --- a/src/lib/BackendIcon.svelte +++ b/src/lib/BackendIcon.svelte @@ -1,8 +1,9 @@ + +
+
{t('settings.acp.groupLabel')}
+

{t('settings.acp.hint')}

+ {#if listError} +

{listError}

+ {/if} + +
+ {#each agents as agent (agent.id)} + {@const st = status[agent.id]} +
+ +
+
+ {agent.name} + {#if st === 'checking'} + {t('settings.acp.checking')} + {:else if st && st.found} + {versionLabel(st) || t('settings.acp.found')} + {:else if st} + {t('settings.acp.notFound')} + {/if} +
+ + {agent.command} {formatArgs(agent.args)} + + + {#if open[agent.id]} +
+ + + + {#if rowError[agent.id]} + {rowError[agent.id]} + {/if} +
+ {/if} +
+ check(agent.id)} label="re-check agent" title={t('settings.acp.recheck')}> + + + remove(agent.id)} label="remove agent" title={t('settings.acp.remove')}> + + +
+ {/each} + {#if !agents.length && !listError} +

{t('settings.acp.empty')}

+ {/if} +
+ + {#if adding} +
+ + + + + {#if draftError} + {draftError} + {/if} +
+ + +
+
+ {:else} + + {/if} +
+ + diff --git a/src/lib/settings/BackendSection.svelte b/src/lib/settings/BackendSection.svelte index f3110ca..7e9c8ac 100644 --- a/src/lib/settings/BackendSection.svelte +++ b/src/lib/settings/BackendSection.svelte @@ -12,7 +12,7 @@ type BackendStatus, type ShellEnvStatus } from '$lib/protocol'; - import { BACKEND_IDS, BACKEND_LABELS, type BackendId } from '$lib/backends'; + import { NATIVE_BACKEND_IDS, BACKEND_LABELS, type BackendId } from '$lib/backends'; import { loadBackendSettings, saveBackendSettings, @@ -91,7 +91,7 @@ } onMount(() => { - for (const id of BACKEND_IDS) { + for (const id of NATIVE_BACKEND_IDS) { check(id); envText[id] = formatEnvLines(settings.env[id]); } @@ -100,7 +100,7 @@ .catch(() => {}); }); - const defaultOpts = $derived(BACKEND_IDS.map((id) => ({ value: id, label: BACKEND_LABELS[id] }))); + const defaultOpts = $derived(NATIVE_BACKEND_IDS.map((id) => ({ value: id, label: BACKEND_LABELS[id] })));
@@ -135,7 +135,7 @@ {/if}
- {#each BACKEND_IDS as id (id)} + {#each NATIVE_BACKEND_IDS as id (id)} {@const st = status[id]}
diff --git a/src/lib/types.ts b/src/lib/types.ts index 856e7e1..f0a5906 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -6,6 +6,9 @@ export interface Session { chat: ChatState; /** Engine backend driving this session (persisted; 'jucode' default). */ backendId: BackendId; + /** For 'acp' sessions: the registry id + display name of the launched agent + * (passed as the allowlisted `agent` spawn option on every (re)spawn). */ + acpAgent?: { id: string; name: string }; /** Per-session adapter instance (stateful for codex/claude; not persisted). */ adapter: EngineAdapter; /** Archived threads are hidden from the sidebar by default (persisted); the @@ -39,4 +42,6 @@ export interface Project { stale?: boolean; /** 本项目最近一次新建会话所用的引擎后端(新建会话的默认值)。 */ lastBackend?: BackendId; + /** lastBackend 为 'acp' 时:上次选择的 ACP agent(注册表 id + 名称)。 */ + lastAcpAgent?: { id: string; name: string }; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 17b2208..aa31999 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1363,7 +1363,7 @@ modelTitle={pickerTitle} modelSearch={showPickerSearch} {backendLocked} - onBackend={(b) => store.switchBackend(activeId, b)} + onBackend={(b, acpAgent) => store.switchBackend(activeId, b, acpAgent)} bind:pickerQuery bind:pickerSelIdx={selIdx} onEffort={chooseEffort}