From 49717dc671e6d27a6c2df7bb44426823a9fe504f Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 28 Apr 2026 01:26:21 +0200 Subject: [PATCH] feat: add opencode ai agent option --- docs/ABSTRACTIONS.md | 2 +- docs/ARCHITECTURE.md | 20 +- src-tauri/src/ai_agents.rs | 16 ++ src-tauri/src/commands/ai.rs | 4 + src-tauri/src/lib.rs | 4 + src-tauri/src/opencode_cli.rs | 58 ++++++ src-tauri/src/opencode_config.rs | 149 +++++++++++++++ src-tauri/src/opencode_discovery.rs | 159 ++++++++++++++++ src-tauri/src/opencode_events.rs | 171 ++++++++++++++++++ src-tauri/src/opencode_events_tests.rs | 63 +++++++ src-tauri/src/settings.rs | 12 +- src/App.test.tsx | 1 + .../AiAgentsOnboardingPrompt.test.tsx | 8 + src/components/StatusBar.test.tsx | 1 + .../status-bar/AiAgentsBadge.test.tsx | 1 + src/hooks/commands/aiAgentCommands.ts | 8 +- src/hooks/useAiAgentPreferences.test.ts | 2 + src/hooks/useAiAgentsStatus.test.ts | 4 + src/hooks/useAiAgentsStatus.ts | 3 +- src/hooks/useCommandRegistry.test.ts | 4 + src/lib/aiAgents.test.ts | 6 +- src/lib/aiAgents.ts | 18 +- src/mock-tauri/mock-handlers.ts | 1 + tests/smoke/vault-ai-guidance-restore.spec.ts | 1 + 24 files changed, 692 insertions(+), 24 deletions(-) create mode 100644 src-tauri/src/opencode_cli.rs create mode 100644 src-tauri/src/opencode_config.rs create mode 100644 src-tauri/src/opencode_discovery.rs create mode 100644 src-tauri/src/opencode_events.rs create mode 100644 src-tauri/src/opencode_events_tests.rs diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 3912f8f3..214667e7 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -721,7 +721,7 @@ interface Settings { release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed theme_mode: 'light' | 'dark' | null ui_language: AppLocale | null - default_ai_agent: 'claude_code' | 'codex' | null + default_ai_agent: 'claude_code' | 'codex' | 'opencode' | null } ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 64401ed6..c3edccb9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -104,7 +104,7 @@ The main window starts a native watcher for the active vault through `start_vaul | Backend language | Rust (edition 2021) | 1.77.2 | | Frontmatter parsing | gray_matter | 0.2 | | Filesystem watcher | notify | 6.1 | -| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - | +| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode) | - | | Search | Keyword (walkdir-based file scan) | - | | Localization | App-owned runtime + JSON catalogs (`src/lib/i18n.ts`, `src/lib/locales/*.json`, `lara.yaml`) | English fallback + Lara CLI sync | | MCP | @modelcontextprotocol/sdk | 1.0 | @@ -143,7 +143,7 @@ flowchart TD end subgraph EXT["External Services"] - CCLI["Claude CLI / Codex CLI\n(agent subprocesses)"] + CCLI["Claude / Codex / OpenCode CLI\n(agent subprocesses)"] MCP["MCP Server\n(ws://9710, 9711)"] GCLI["git CLI\n(system executable)"] REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"] @@ -224,8 +224,8 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too 1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgents.ts`) — streaming UI with reasoning blocks, tool action cards, response display, onboarding, and default-agent selection 2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters -3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json` with the same hidden Windows launch policy. Both app-launched paths can edit the active vault without enabling dangerous permission bypass modes. -4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides +3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`; OpenCode runs through `opencode run --format json` from the active vault cwd with closed stdin and a transient config that allows vault-local reads/edits while denying shell and external-directory access. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags. +4. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, and OpenCode receives it through `OPENCODE_CONFIG_CONTENT` CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. @@ -242,11 +242,11 @@ sequenceDiagram U->>FE: sendMessage(text, references) FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs) FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath}) - R->>R: pick adapter for claude_code or codex + R->>R: pick adapter for claude_code, codex, or opencode R->>C: spawn agent with MCP-enabled config loop Normalized stream - C-->>R: Claude NDJSON or Codex JSONL events + C-->>R: Claude NDJSON, Codex JSONL, or OpenCode JSON events R-->>FE: emit("ai-agent-stream", event) alt TextDelta FE->>FE: accumulate response (revealed on Done) @@ -288,7 +288,7 @@ Token budget: 60% of 180k context limit (~108k tokens max). Active note gets pri ### Authentication -Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed. Tolaria does not store model-provider API keys in app settings. +Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed. Tolaria does not store model-provider API keys in app settings. ## MCP Server @@ -469,7 +469,7 @@ When an opened folder is not yet a git repo, Tolaria shows a dismissible Git set When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria ` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures. -Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch. +Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, and OpenCode are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch. `useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone. @@ -718,7 +718,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `stream_claude_chat` | Claude CLI chat mode (streaming) | | `stream_claude_agent` | Claude CLI agent mode (streaming + tools) | | `check_claude_cli` | Check if Claude CLI is available | -| `get_ai_agents_status` | Check Claude Code + Codex availability | +| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode availability | | `stream_ai_agent` | Stream the selected CLI agent through the normalized event layer | | `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault | | `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config | @@ -992,7 +992,7 @@ Desktop-only modules gated at the crate level: Desktop-only features gated at the function level in `commands/`: - Git operations (commit, pull, push, status, history, diff, conflicts) - Clone-by-URL via system git (`clone_repo`) -- CLI AI agent streaming (Claude, Codex) +- CLI AI agent streaming (Claude, Codex, OpenCode) - MCP registration and status - Menu state updates diff --git a/src-tauri/src/ai_agents.rs b/src-tauri/src/ai_agents.rs index 163add65..2326e62d 100644 --- a/src-tauri/src/ai_agents.rs +++ b/src-tauri/src/ai_agents.rs @@ -8,6 +8,7 @@ use std::process::Stdio; pub enum AiAgentId { ClaudeCode, Codex, + Opencode, } #[derive(Debug, Clone, Serialize)] @@ -20,6 +21,7 @@ pub struct AiAgentAvailability { pub struct AiAgentsStatus { pub claude_code: AiAgentAvailability, pub codex: AiAgentAvailability, + pub opencode: AiAgentAvailability, } #[derive(Debug, Clone, Serialize)] @@ -63,6 +65,7 @@ pub fn get_ai_agents_status() -> AiAgentsStatus { AiAgentsStatus { claude_code: availability_from_claude(), codex: availability_from_codex(), + opencode: availability_from_opencode(), } } @@ -84,6 +87,14 @@ where }) } AiAgentId::Codex => run_codex_agent_stream(request, emit), + AiAgentId::Opencode => { + let mapped = crate::opencode_cli::AgentStreamRequest { + message: request.message, + system_prompt: request.system_prompt, + vault_path: request.vault_path, + }; + crate::opencode_cli::run_agent_stream(mapped, emit) + } } } @@ -112,6 +123,10 @@ fn availability_from_codex() -> AiAgentAvailability { } } +fn availability_from_opencode() -> AiAgentAvailability { + crate::opencode_cli::check_cli() +} + fn version_for_binary(binary: &PathBuf) -> Option { crate::hidden_command(binary) .arg("--version") @@ -473,6 +488,7 @@ mod tests { let status = get_ai_agents_status(); assert!(matches!(status.claude_code.installed, true | false)); assert!(matches!(status.codex.installed, true | false)); + assert!(matches!(status.opencode.installed, true | false)); } #[test] diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 98539f4f..d201bb75 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -120,6 +120,10 @@ pub fn get_ai_agents_status() -> AiAgentsStatus { installed: false, version: None, }, + opencode: crate::ai_agents::AiAgentAvailability { + installed: false, + version: None, + }, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f8eca0d6..4af86e70 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,10 @@ pub mod git; pub mod mcp; #[cfg(desktop)] pub mod menu; +pub mod opencode_cli; +mod opencode_config; +mod opencode_discovery; +mod opencode_events; pub mod search; pub mod settings; pub mod telemetry; diff --git a/src-tauri/src/opencode_cli.rs b/src-tauri/src/opencode_cli.rs new file mode 100644 index 00000000..71fe58ea --- /dev/null +++ b/src-tauri/src/opencode_cli.rs @@ -0,0 +1,58 @@ +use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +use std::io::BufRead; + +#[derive(Debug, Clone)] +pub struct AgentStreamRequest { + pub message: String, + pub system_prompt: Option, + pub vault_path: String, +} + +pub fn check_cli() -> AiAgentAvailability { + crate::opencode_discovery::check_cli() +} + +pub fn run_agent_stream(request: AgentStreamRequest, mut emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let binary = crate::opencode_discovery::find_binary()?; + let mut command = crate::opencode_config::build_command(&binary, &request)?; + let mut child = command + .spawn() + .map_err(|error| format!("Failed to spawn opencode: {error}"))?; + + let stdout = child.stdout.take().ok_or("No stdout handle")?; + let reader = std::io::BufReader::new(stdout); + let mut session_id = String::new(); + + for line in reader.lines() { + let json = match crate::opencode_events::parse_line(line, &mut emit) { + Some(json) => json, + None => continue, + }; + + if let Some(id) = crate::opencode_events::session_id(&json) { + session_id = id.to_string(); + } + + crate::opencode_events::dispatch_event(&json, &mut emit); + } + + let stderr_output = child + .stderr + .take() + .and_then(|stderr| std::io::read_to_string(stderr).ok()) + .unwrap_or_default(); + let status = child + .wait() + .map_err(|error| format!("Wait failed: {error}"))?; + if !status.success() { + emit(AiAgentStreamEvent::Error { + message: crate::opencode_events::format_error(stderr_output, status.to_string()), + }); + } + + emit(AiAgentStreamEvent::Done); + Ok(session_id) +} diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs new file mode 100644 index 00000000..096fc1c7 --- /dev/null +++ b/src-tauri/src/opencode_config.rs @@ -0,0 +1,149 @@ +use crate::opencode_cli::AgentStreamRequest; +use std::path::Path; +use std::process::Stdio; + +pub(crate) fn build_command( + binary: &Path, + request: &AgentStreamRequest, +) -> Result { + let mut command = crate::hidden_command(binary); + command + .args(build_args()) + .arg(build_prompt(request)) + .env( + "OPENCODE_CONFIG_CONTENT", + build_config(&request.vault_path)?, + ) + .current_dir(&request.vault_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(command) +} + +fn build_args() -> Vec { + vec!["run".into(), "--format".into(), "json".into()] +} + +fn build_prompt(request: &AgentStreamRequest) -> String { + match request + .system_prompt + .as_ref() + .map(|prompt| prompt.trim()) + .filter(|prompt| !prompt.is_empty()) + { + Some(system_prompt) => format!( + "System instructions:\n{system_prompt}\n\nUser request:\n{}", + request.message + ), + None => request.message.clone(), + } +} + +fn build_config(vault_path: &str) -> Result { + let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js"); + let mcp_server_path = mcp_server + .to_str() + .ok_or("Invalid MCP server path")? + .to_string(); + + serde_json::to_string(&serde_json::json!({ + "$schema": "https://opencode.ai/config.json", + "permission": permission_config(), + "mcp": { + "tolaria": { + "type": "local", + "command": ["node", mcp_server_path], + "environment": { "VAULT_PATH": vault_path }, + "enabled": true + } + } + })) + .map_err(|error| format!("Failed to serialize opencode config: {error}")) +} + +fn permission_config() -> serde_json::Value { + serde_json::json!({ + "read": "allow", + "edit": "allow", + "glob": "allow", + "grep": "allow", + "list": "allow", + "external_directory": "deny", + "bash": "deny" + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsStr; + use std::path::PathBuf; + + fn request() -> AgentStreamRequest { + AgentStreamRequest { + message: "Rename the note".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + } + } + + #[test] + fn args_use_documented_safe_run_mode() { + let args = build_args(); + + assert_eq!(args, vec!["run", "--format", "json"]); + assert!(!args.contains(&"--dangerously-skip-permissions".to_string())); + assert!(!args.contains(&"--dir".to_string())); + assert!(!args.contains(&"--thinking".to_string())); + } + + #[test] + fn command_sets_vault_cwd_and_mcp_config() { + let command = build_command(&PathBuf::from("opencode"), &request()).unwrap(); + let actual_args: Vec<&OsStr> = command.get_args().collect(); + let config_value = command + .get_envs() + .find(|(key, _)| *key == OsStr::new("OPENCODE_CONFIG_CONTENT")) + .and_then(|(_, value)| value); + + assert_eq!(command.get_program(), OsStr::new("opencode")); + assert_eq!(actual_args[0], OsStr::new("run")); + assert_eq!(actual_args[1], OsStr::new("--format")); + assert_eq!(actual_args[2], OsStr::new("json")); + assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note"))); + assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); + assert!(config_value.is_some()); + } + + #[test] + fn config_includes_permissions_and_tolaria_mcp_server() { + if let Ok(config) = build_config("/tmp/vault") { + let json: serde_json::Value = serde_json::from_str(&config).unwrap(); + assert_eq!(json["permission"]["edit"], "allow"); + assert_eq!(json["permission"]["external_directory"], "deny"); + assert_eq!(json["permission"]["bash"], "deny"); + assert_eq!(json["mcp"]["tolaria"]["type"], "local"); + assert_eq!(json["mcp"]["tolaria"]["command"][0], "node"); + assert_eq!( + json["mcp"]["tolaria"]["environment"]["VAULT_PATH"], + "/tmp/vault" + ); + assert!(json["mcp"]["tolaria"]["command"][1] + .as_str() + .unwrap() + .ends_with("index.js")); + } + } + + #[test] + fn prompt_keeps_system_prompt_first() { + let prompt = build_prompt(&AgentStreamRequest { + system_prompt: Some("Be concise".into()), + ..request() + }); + + assert!(prompt.starts_with("System instructions:\nBe concise")); + assert!(prompt.contains("User request:\nRename the note")); + } +} diff --git a/src-tauri/src/opencode_discovery.rs b/src-tauri/src/opencode_discovery.rs new file mode 100644 index 00000000..7e2c0e77 --- /dev/null +++ b/src-tauri/src/opencode_discovery.rs @@ -0,0 +1,159 @@ +use crate::ai_agents::AiAgentAvailability; +use std::path::{Path, PathBuf}; + +pub(crate) fn check_cli() -> AiAgentAvailability { + match find_binary() { + Ok(binary) => AiAgentAvailability { + installed: true, + version: version_for_binary(&binary), + }, + Err(_) => AiAgentAvailability { + installed: false, + version: None, + }, + } +} + +pub(crate) fn find_binary() -> Result { + if let Some(binary) = find_binary_on_path() { + return Ok(binary); + } + + if let Some(binary) = find_binary_in_user_shell() { + return Ok(binary); + } + + if let Some(binary) = find_existing_binary(opencode_binary_candidates()) { + return Ok(binary); + } + + Err("OpenCode CLI not found. Install it: https://opencode.ai/docs/".into()) +} + +fn version_for_binary(binary: &PathBuf) -> Option { + crate::hidden_command(binary) + .arg("--version") + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn find_binary_on_path() -> Option { + crate::hidden_command("which") + .arg("opencode") + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn find_binary_in_user_shell() -> Option { + user_shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| command_path_from_shell(&shell, "opencode")) +} + +fn user_shell_candidates() -> Vec { + let mut shells = Vec::new(); + if let Some(shell) = std::env::var_os("SHELL") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + +fn command_path_from_shell(shell: &Path, command: &str) -> Option { + crate::hidden_command(shell) + .arg("-lc") + .arg(format!("command -v {command}")) + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn path_from_successful_output(output: &std::process::Output) -> Option { + if output.status.success() { + first_existing_path(&String::from_utf8_lossy(&output.stdout)) + } else { + None + } +} + +fn first_existing_path(stdout: &str) -> Option { + stdout.lines().find_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let candidate = PathBuf::from(trimmed); + candidate.exists().then_some(candidate) + }) +} + +fn find_existing_binary(candidates: Vec) -> Option { + candidates.into_iter().find(|candidate| candidate.exists()) +} + +fn opencode_binary_candidates() -> Vec { + dirs::home_dir() + .map(|home| opencode_binary_candidates_for_home(&home)) + .unwrap_or_default() +} + +fn opencode_binary_candidates_for_home(home: &Path) -> Vec { + vec![ + home.join(".local/bin/opencode"), + home.join(".opencode/bin/opencode"), + home.join(".local/share/mise/shims/opencode"), + home.join(".asdf/shims/opencode"), + home.join(".npm-global/bin/opencode"), + home.join(".npm/bin/opencode"), + home.join(".bun/bin/opencode"), + PathBuf::from("/usr/local/bin/opencode"), + PathBuf::from("/opt/homebrew/bin/opencode"), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binary_candidates_include_supported_local_installs() { + let home = PathBuf::from("/Users/alex"); + let candidates = opencode_binary_candidates_for_home(&home); + let expected = [ + home.join(".local/bin/opencode"), + home.join(".opencode/bin/opencode"), + home.join(".local/share/mise/shims/opencode"), + home.join(".asdf/shims/opencode"), + home.join(".npm-global/bin/opencode"), + home.join(".bun/bin/opencode"), + PathBuf::from("/opt/homebrew/bin/opencode"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn first_existing_path_skips_empty_and_missing_lines() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-opencode"); + let opencode = dir.path().join("opencode"); + std::fs::write(&opencode, "#!/bin/sh\n").unwrap(); + + let stdout = format!("\n{}\n{}\n", missing.display(), opencode.display()); + + assert_eq!(first_existing_path(&stdout), Some(opencode)); + } +} diff --git a/src-tauri/src/opencode_events.rs b/src-tauri/src/opencode_events.rs new file mode 100644 index 00000000..d6060cda --- /dev/null +++ b/src-tauri/src/opencode_events.rs @@ -0,0 +1,171 @@ +use crate::ai_agents::AiAgentStreamEvent; + +pub(crate) fn parse_line( + line: Result, + emit: &mut F, +) -> Option +where + F: FnMut(AiAgentStreamEvent), +{ + let line = match line { + Ok(line) => line, + Err(error) => { + emit(AiAgentStreamEvent::Error { + message: format!("Read error: {error}"), + }); + return None; + } + }; + + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + + serde_json::from_str::(trimmed).ok() +} + +pub(crate) fn dispatch_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if json["type"].as_str() == Some("session") { + emit_session_event(json, emit); + } + + match json["type"].as_str().unwrap_or_default() { + "message" | "text" => emit_text(json, emit), + "reasoning" => emit_reasoning(json, emit), + "tool_use" | "tool" => emit_tool_start(json, emit), + "tool_result" | "tool_done" => emit_tool_done(json, emit), + "error" => emit_error_event(json, emit), + _ => {} + } +} + +pub(crate) fn session_id(json: &serde_json::Value) -> Option<&str> { + json["sessionID"] + .as_str() + .or_else(|| json["session_id"].as_str()) + .or_else(|| json["session"]["id"].as_str()) +} + +pub(crate) fn format_error(stderr_output: String, status: String) -> String { + let lower = stderr_output.to_ascii_lowercase(); + if is_auth_error(&lower) { + return "OpenCode CLI is not authenticated or has no provider configured. Run `opencode auth login` or configure a provider in OpenCode, then retry.".into(); + } + + if stderr_output.trim().is_empty() { + format!("opencode exited with status {status}") + } else { + stderr_output.lines().take(3).collect::>().join("\n") + } +} + +fn emit_session_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if let Some(session_id) = session_id(json) { + emit(AiAgentStreamEvent::Init { + session_id: session_id.to_string(), + }); + } +} + +fn emit_text(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if let Some(text) = text_value(json) { + emit(AiAgentStreamEvent::TextDelta { + text: text.to_string(), + }); + } +} + +fn emit_reasoning(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if let Some(text) = text_value(json) { + emit(AiAgentStreamEvent::ThinkingDelta { + text: text.to_string(), + }); + } +} + +fn emit_tool_start(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + let tool_id = tool_id(json).unwrap_or("tool").to_string(); + let tool_name = json["name"] + .as_str() + .or_else(|| json["tool"].as_str()) + .unwrap_or("tool") + .to_string(); + let input = json.get("input").map(|input| input.to_string()); + + emit(AiAgentStreamEvent::ToolStart { + tool_name, + tool_id, + input, + }); +} + +fn emit_tool_done(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + let tool_id = tool_id(json).unwrap_or("tool").to_string(); + let output = json["output"] + .as_str() + .map(|output| output.to_string()) + .or_else(|| json.get("result").map(|result| result.to_string())); + + emit(AiAgentStreamEvent::ToolDone { tool_id, output }); +} + +fn emit_error_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + if let Some(message) = message_value(json) { + emit(AiAgentStreamEvent::Error { + message: message.to_string(), + }); + } +} + +fn tool_id(json: &serde_json::Value) -> Option<&str> { + json["id"] + .as_str() + .or_else(|| json["toolID"].as_str()) + .or_else(|| json["tool_id"].as_str()) +} + +fn text_value(json: &serde_json::Value) -> Option<&str> { + json["text"] + .as_str() + .or_else(|| json["content"].as_str()) + .or_else(|| json["message"].as_str()) +} + +fn message_value(json: &serde_json::Value) -> Option<&str> { + json["message"] + .as_str() + .or_else(|| json["error"].as_str()) + .or_else(|| json["text"].as_str()) +} + +fn is_auth_error(lower: &str) -> bool { + ["auth", "login", "sign in", "api key", "provider"] + .iter() + .any(|pattern| lower.contains(pattern)) +} + +#[cfg(test)] +#[path = "opencode_events_tests.rs"] +mod tests; diff --git a/src-tauri/src/opencode_events_tests.rs b/src-tauri/src/opencode_events_tests.rs new file mode 100644 index 00000000..7c721628 --- /dev/null +++ b/src-tauri/src/opencode_events_tests.rs @@ -0,0 +1,63 @@ +use super::*; + +#[test] +fn dispatch_maps_session_reasoning_and_text() { + let mut events = Vec::new(); + let started = serde_json::json!({ "type": "session", "sessionID": "ses_1" }); + let reasoning = serde_json::json!({ "type": "reasoning", "text": "Checking links" }); + let text = serde_json::json!({ "type": "message", "text": "Done" }); + + for event in [started, reasoning, text] { + dispatch_event(&event, &mut |event| events.push(event)); + } + + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id == "ses_1" + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links" + )); + assert!(matches!( + &events[2], + AiAgentStreamEvent::TextDelta { text } if text == "Done" + )); +} + +#[test] +fn dispatch_maps_tool_events() { + let mut events = Vec::new(); + let tool_start = serde_json::json!({ + "type": "tool_use", + "id": "tool_1", + "name": "read", + "input": { "path": "Note.md" } + }); + let tool_done = serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" }); + + dispatch_event(&tool_start, &mut |event| events.push(event)); + dispatch_event(&tool_done, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::ToolStart { tool_name, tool_id, input } + if tool_name == "read" && tool_id == "tool_1" && input.as_deref() == Some(r#"{"path":"Note.md"}"#) + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ToolDone { tool_id, output } + if tool_id == "tool_1" && output.as_deref() == Some("ok") + )); +} + +#[test] +fn format_error_explains_missing_auth_or_provider_setup() { + let message = format_error( + "provider auth failed: please login".into(), + "exit status: 1".into(), + ); + + assert!(message.contains("OpenCode CLI is not authenticated")); + assert!(message.contains("opencode auth login")); +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 6a36a1e1..a893adf3 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; const APP_CONFIG_DIR: &str = "com.tolaria.app"; const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app"; +const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode"]; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct Settings { @@ -50,7 +51,7 @@ pub fn effective_release_channel(value: Option<&str>) -> &'static str { pub fn normalize_default_ai_agent(value: Option<&str>) -> Option { match value.map(|candidate| candidate.trim().to_ascii_lowercase()) { - Some(agent) if agent == "claude_code" || agent == "codex" => Some(agent), + Some(agent) if SUPPORTED_DEFAULT_AI_AGENTS.contains(&agent.as_str()) => Some(agent), _ => None, } } @@ -348,6 +349,15 @@ mod tests { assert!(loaded.default_ai_agent.is_none()); } + #[test] + fn test_opencode_default_ai_agent_is_preserved() { + let loaded = save_and_reload(Settings { + default_ai_agent: Some("opencode".to_string()), + ..Default::default() + }); + assert_eq!(loaded.default_ai_agent.as_deref(), Some("opencode")); + } + #[test] fn test_invalid_theme_mode_is_filtered() { let loaded = save_and_reload(Settings { diff --git a/src/App.test.tsx b/src/App.test.tsx index d3769f15..5bf2987f 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -593,6 +593,7 @@ describe('App', () => { mockCommandResults.get_ai_agents_status = { claude_code: { installed: true, version: '2.1.90' }, codex: { installed: true, version: '0.122.0-alpha.1' }, + opencode: { installed: false, version: null }, } mockCommandResults.check_mcp_status = 'installed' diff --git a/src/components/AiAgentsOnboardingPrompt.test.tsx b/src/components/AiAgentsOnboardingPrompt.test.tsx index 6791cdf5..f0615ac8 100644 --- a/src/components/AiAgentsOnboardingPrompt.test.tsx +++ b/src/components/AiAgentsOnboardingPrompt.test.tsx @@ -23,6 +23,7 @@ describe('AiAgentsOnboardingPrompt', () => { statuses={{ claude_code: { status: 'installed', version: '1.0.20' }, codex: { status: 'missing', version: null }, + opencode: { status: 'missing', version: null }, }} onContinue={vi.fn()} />, @@ -30,6 +31,7 @@ describe('AiAgentsOnboardingPrompt', () => { expect(screen.getByText('AI agents ready')).toBeInTheDocument() expect(screen.getByTestId('ai-agents-onboarding-install-codex')).toBeInTheDocument() + expect(screen.getByTestId('ai-agents-onboarding-install-opencode')).toBeInTheDocument() expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue') }) @@ -39,6 +41,7 @@ describe('AiAgentsOnboardingPrompt', () => { statuses={{ claude_code: { status: 'missing', version: null }, codex: { status: 'missing', version: null }, + opencode: { status: 'missing', version: null }, }} onContinue={vi.fn()} />, @@ -49,6 +52,7 @@ describe('AiAgentsOnboardingPrompt', () => { expect(screen.getByText('Claude Code not detected')).toBeInTheDocument() expect(screen.getByTestId('ai-agents-onboarding-install-claude_code')).toBeInTheDocument() expect(screen.getByTestId('ai-agents-onboarding-install-codex')).toBeInTheDocument() + expect(screen.getByTestId('ai-agents-onboarding-install-opencode')).toBeInTheDocument() expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue without it') }) @@ -58,6 +62,7 @@ describe('AiAgentsOnboardingPrompt', () => { statuses={{ claude_code: { status: 'missing', version: null }, codex: { status: 'missing', version: null }, + opencode: { status: 'missing', version: null }, }} onContinue={vi.fn()} />, @@ -65,9 +70,11 @@ describe('AiAgentsOnboardingPrompt', () => { fireEvent.click(screen.getByTestId('ai-agents-onboarding-install-claude_code')) fireEvent.click(screen.getByTestId('ai-agents-onboarding-install-codex')) + fireEvent.click(screen.getByTestId('ai-agents-onboarding-install-opencode')) expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code') expect(openExternalUrl).toHaveBeenCalledWith('https://developers.openai.com/codex/cli') + expect(openExternalUrl).toHaveBeenCalledWith('https://opencode.ai/docs/') }) it('uses the surrounding surface as a drag region and excludes the card', () => { @@ -76,6 +83,7 @@ describe('AiAgentsOnboardingPrompt', () => { statuses={{ claude_code: { status: 'installed', version: '1.0.20' }, codex: { status: 'missing', version: null }, + opencode: { status: 'missing', version: null }, }} onContinue={vi.fn()} />, diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 447cce44..5b588bc8 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -19,6 +19,7 @@ const vaults: VaultOption[] = [ const installedAiAgentsStatus = { claude_code: { status: 'installed' as const, version: '1.0.20' }, codex: { status: 'installed' as const, version: '0.37.0' }, + opencode: { status: 'installed' as const, version: '0.3.1' }, } const DEFAULT_WINDOW_WIDTH = 1280 diff --git a/src/components/status-bar/AiAgentsBadge.test.tsx b/src/components/status-bar/AiAgentsBadge.test.tsx index 3818c4cb..08fe72b0 100644 --- a/src/components/status-bar/AiAgentsBadge.test.tsx +++ b/src/components/status-bar/AiAgentsBadge.test.tsx @@ -12,6 +12,7 @@ vi.mock('../../utils/url', async () => { const installedStatuses = { claude_code: { status: 'installed' as const, version: '1.0.20' }, codex: { status: 'installed' as const, version: '0.37.0' }, + opencode: { status: 'installed' as const, version: '0.3.1' }, } function render(ui: ReactElement) { diff --git a/src/hooks/commands/aiAgentCommands.ts b/src/hooks/commands/aiAgentCommands.ts index bcd2fe41..7fdc9f2b 100644 --- a/src/hooks/commands/aiAgentCommands.ts +++ b/src/hooks/commands/aiAgentCommands.ts @@ -36,7 +36,7 @@ function explicitSwitchCommands({ id: `switch-ai-agent-${definition.id}`, label: `Switch AI Agent to ${definition.label}`, group: 'Settings' as const, - keywords: ['ai', 'agent', 'default', 'switch', 'claude', 'codex', definition.shortLabel.toLowerCase()], + keywords: ['ai', 'agent', 'default', 'switch', 'claude', 'codex', 'opencode', definition.shortLabel.toLowerCase()], enabled: true, execute: () => onSetDefaultAiAgent(definition.id), })) @@ -55,7 +55,7 @@ function restoreGuidanceCommands({ id: 'restore-vault-ai-guidance', label: 'Restore Tolaria AI Guidance', group: 'Settings', - keywords: ['ai', 'agent', 'guidance', 'restore', 'repair', 'claude', 'codex', 'agents'], + keywords: ['ai', 'agent', 'guidance', 'restore', 'repair', 'claude', 'codex', 'opencode', 'agents'], enabled: true, execute: () => onRestoreVaultAiGuidance(), }, @@ -77,7 +77,7 @@ export function buildAiAgentCommands({ id: 'open-ai-agents', label: 'Open AI Agents', group: 'Settings', - keywords: ['ai', 'agent', 'agents', 'assistant', 'claude', 'codex', 'settings'], + keywords: ['ai', 'agent', 'agents', 'assistant', 'claude', 'codex', 'opencode', 'settings'], enabled: !!onOpenAiAgents, execute: () => onOpenAiAgents?.(), }, @@ -101,7 +101,7 @@ export function buildAiAgentCommands({ id: 'switch-default-ai-agent', label: selectedAiAgentLabel ? `Switch Default AI Agent (${selectedAiAgentLabel})` : 'Switch Default AI Agent', group: 'Settings', - keywords: ['ai', 'agent', 'default', 'switch', 'claude', 'codex'], + keywords: ['ai', 'agent', 'default', 'switch', 'claude', 'codex', 'opencode'], enabled: !!onCycleDefaultAiAgent, execute: () => onCycleDefaultAiAgent?.(), }) diff --git a/src/hooks/useAiAgentPreferences.test.ts b/src/hooks/useAiAgentPreferences.test.ts index 2bc31c25..158102ab 100644 --- a/src/hooks/useAiAgentPreferences.test.ts +++ b/src/hooks/useAiAgentPreferences.test.ts @@ -15,6 +15,7 @@ const settings = { const aiAgentsStatus = { claude_code: { status: 'installed' as const, version: '1.0.20' }, codex: { status: 'missing' as const, version: null }, + opencode: { status: 'missing' as const, version: null }, } describe('useAiAgentPreferences', () => { @@ -63,6 +64,7 @@ describe('useAiAgentPreferences', () => { aiAgentsStatus: { claude_code: { status: 'missing', version: null }, codex: { status: 'missing', version: null }, + opencode: { status: 'missing', version: null }, }, })) diff --git a/src/hooks/useAiAgentsStatus.test.ts b/src/hooks/useAiAgentsStatus.test.ts index ff76425f..e8fdd884 100644 --- a/src/hooks/useAiAgentsStatus.test.ts +++ b/src/hooks/useAiAgentsStatus.test.ts @@ -24,6 +24,7 @@ describe('useAiAgentsStatus', () => { return Promise.resolve({ claude_code: { installed: true, version: '1.0.20' }, codex: { installed: false, version: null }, + opencode: { installed: true, version: '0.3.1' }, }) } return Promise.resolve(null) @@ -33,10 +34,12 @@ describe('useAiAgentsStatus', () => { expect(result.current.claude_code.status).toBe('checking') expect(result.current.codex.status).toBe('checking') + expect(result.current.opencode.status).toBe('checking') await waitFor(() => { expect(result.current.claude_code).toEqual({ status: 'installed', version: '1.0.20' }) expect(result.current.codex).toEqual({ status: 'missing', version: null }) + expect(result.current.opencode).toEqual({ status: 'installed', version: '0.3.1' }) }) }) @@ -48,6 +51,7 @@ describe('useAiAgentsStatus', () => { await waitFor(() => { expect(result.current.claude_code.status).toBe('missing') expect(result.current.codex.status).toBe('missing') + expect(result.current.opencode.status).toBe('missing') }) }) }) diff --git a/src/hooks/useAiAgentsStatus.ts b/src/hooks/useAiAgentsStatus.ts index 255a4912..887f573c 100644 --- a/src/hooks/useAiAgentsStatus.ts +++ b/src/hooks/useAiAgentsStatus.ts @@ -5,10 +5,11 @@ import { createCheckingAiAgentsStatus, createMissingAiAgentsStatus, normalizeAiAgentsStatus, + type AiAgentId, type AiAgentsStatus, } from '../lib/aiAgents' -type RawAiAgentsStatus = Partial> +type RawAiAgentsStatus = Partial> function tauriCall(command: string): Promise { return isTauri() ? invoke(command) : mockInvoke(command) diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index a8919f2e..10efb688 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -724,6 +724,7 @@ describe('reload-vault command', () => { aiAgentsStatus: { claude_code: { status: 'installed', version: '1.0.20' }, codex: { status: 'installed', version: '0.37.0' }, + opencode: { status: 'installed', version: '0.3.1' }, }, selectedAiAgent: 'claude_code', onSetDefaultAiAgent, @@ -733,6 +734,7 @@ describe('reload-vault command', () => { expect(cmd).toBeDefined() expect(cmd!.label).toBe('Switch AI Agent to Codex') + expect(findCommand(result.current, 'switch-ai-agent-opencode')).toBeDefined() cmd!.execute() expect(onSetDefaultAiAgent).toHaveBeenCalledWith('codex') @@ -744,6 +746,7 @@ describe('reload-vault command', () => { aiAgentsStatus: { claude_code: { status: 'installed', version: '1.0.20' }, codex: { status: 'missing', version: null }, + opencode: { status: 'missing', version: null }, }, selectedAiAgent: 'claude_code', onSetDefaultAiAgent: vi.fn(), @@ -751,6 +754,7 @@ describe('reload-vault command', () => { const { result } = renderHook(() => useCommandRegistry(config)) expect(findCommand(result.current, 'switch-ai-agent-codex')).toBeUndefined() + expect(findCommand(result.current, 'switch-ai-agent-opencode')).toBeUndefined() expect(findCommand(result.current, 'switch-default-ai-agent')).toBeUndefined() }) }) diff --git a/src/lib/aiAgents.test.ts b/src/lib/aiAgents.test.ts index c0d489f5..4b95e4fe 100644 --- a/src/lib/aiAgents.test.ts +++ b/src/lib/aiAgents.test.ts @@ -10,6 +10,7 @@ describe('aiAgents helpers', () => { it('normalizes stored agent ids', () => { expect(normalizeStoredAiAgent('claude_code')).toBe('claude_code') expect(normalizeStoredAiAgent('codex')).toBe('codex') + expect(normalizeStoredAiAgent('opencode')).toBe('opencode') expect(normalizeStoredAiAgent('cursor')).toBeNull() }) @@ -22,14 +23,17 @@ describe('aiAgents helpers', () => { const statuses = normalizeAiAgentsStatus({ claude_code: { installed: true, version: '1.0.20' }, codex: { installed: false, version: null }, + opencode: { installed: true, version: '0.3.1' }, }) expect(statuses.claude_code).toEqual({ status: 'installed', version: '1.0.20' }) expect(statuses.codex).toEqual({ status: 'missing', version: null }) + expect(statuses.opencode).toEqual({ status: 'installed', version: '0.3.1' }) }) it('cycles between the supported agents', () => { expect(getNextAiAgentId('claude_code')).toBe('codex') - expect(getNextAiAgentId('codex')).toBe('claude_code') + expect(getNextAiAgentId('codex')).toBe('opencode') + expect(getNextAiAgentId('opencode')).toBe('claude_code') }) }) diff --git a/src/lib/aiAgents.ts b/src/lib/aiAgents.ts index 2abd885d..1f061e89 100644 --- a/src/lib/aiAgents.ts +++ b/src/lib/aiAgents.ts @@ -1,4 +1,4 @@ -export type AiAgentId = 'claude_code' | 'codex' +export type AiAgentId = 'claude_code' | 'codex' | 'opencode' export type AiAgentStatus = 'checking' | 'installed' | 'missing' @@ -7,10 +7,7 @@ export interface AiAgentAvailability { version: string | null } -export interface AiAgentsStatus { - claude_code: AiAgentAvailability - codex: AiAgentAvailability -} +export type AiAgentsStatus = Record export interface AiAgentDefinition { id: AiAgentId @@ -34,6 +31,12 @@ export const AI_AGENT_DEFINITIONS: readonly AiAgentDefinition[] = [ shortLabel: 'Codex', installUrl: 'https://developers.openai.com/codex/cli', }, + { + id: 'opencode', + label: 'OpenCode', + shortLabel: 'OpenCode', + installUrl: 'https://opencode.ai/docs/', + }, ] as const export function createAiAgentAvailability(status: AiAgentStatus = 'checking', version: string | null = null): AiAgentAvailability { @@ -44,6 +47,7 @@ export function createCheckingAiAgentsStatus(): AiAgentsStatus { return { claude_code: createAiAgentAvailability(), codex: createAiAgentAvailability(), + opencode: createAiAgentAvailability(), } } @@ -51,11 +55,12 @@ export function createMissingAiAgentsStatus(): AiAgentsStatus { return { claude_code: createAiAgentAvailability('missing'), codex: createAiAgentAvailability('missing'), + opencode: createAiAgentAvailability('missing'), } } export function normalizeStoredAiAgent(value: string | null | undefined): AiAgentId | null { - if (value === 'claude_code' || value === 'codex') return value + if (AI_AGENT_DEFINITIONS.some((definition) => definition.id === value)) return value as AiAgentId return null } @@ -79,6 +84,7 @@ export function normalizeAiAgentsStatus(payload: Partial any> = { get_ai_agents_status: () => ({ claude_code: { installed: false, version: null }, codex: { installed: false, version: null }, + opencode: { installed: false, version: null }, }), get_vault_ai_guidance_status: () => ({ ...mockVaultAiGuidanceStatus }), restore_vault_ai_guidance: () => { diff --git a/tests/smoke/vault-ai-guidance-restore.spec.ts b/tests/smoke/vault-ai-guidance-restore.spec.ts index 46c23707..f1d2112d 100644 --- a/tests/smoke/vault-ai-guidance-restore.spec.ts +++ b/tests/smoke/vault-ai-guidance-restore.spec.ts @@ -32,6 +32,7 @@ test('vault guidance restore command recovers missing managed guidance', async ( ref.get_ai_agents_status = () => ({ claude_code: { installed: false, version: null }, codex: { installed: true, version: '1.2.3' }, + opencode: { installed: false, version: null }, }) ref.get_vault_ai_guidance_status = () => ({ ...guidanceStatus }) ref.restore_vault_ai_guidance = () => {