Merge branch 'main' into main

This commit is contained in:
github-actions[bot]
2026-04-29 09:28:10 +00:00
committed by GitHub
24 changed files with 741 additions and 72 deletions

View File

@@ -736,7 +736,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' | 'opencode' | 'pi' | null
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
hide_gitignored_files: boolean | null // null = default true
}
```

View File

@@ -227,8 +227,8 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header
2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
4. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools, while Power User adds Bash without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs` and run through `codex --sandbox workspace-write --ask-for-approval never exec --json` in both modes. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
5. **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, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, and Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`
4. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools, while Power User adds Bash without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs` and run through `codex --sandbox workspace-write --ask-for-approval never exec --json` in both modes. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config. Gemini runs through `gemini --output-format json --prompt` with Safe using `auto_edit` plus `tools.exclude=["run_shell_command"]` and Power User using `yolo` against a trusted transient Tolaria MCP entry. OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
5. **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, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
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, nvm-managed Node installs, 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.
@@ -331,7 +331,7 @@ Tolaria can register itself as an MCP server in:
- `~/.cursor/mcp.json` (Cursor)
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria only writes the MCP entry and optional vault guidance shim. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
### Architecture
@@ -475,7 +475,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 <vault@tolaria.md>` 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, Codex, OpenCode, and Pi 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, OpenCode, Pi, and Gemini CLI 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.

View File

@@ -115,7 +115,7 @@ tolaria/
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi availability polling
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini availability polling
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
│ │ ├── useAiActivity.ts # MCP UI bridge listener
│ │ ├── useAutoSync.ts # Auto git pull/push
@@ -266,7 +266,7 @@ tolaria/
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
| `src-tauri/src/ai_agents.rs` | CLI-agent request normalization, availability aggregation, adapter dispatch, and Claude event mapping. |
| `src-tauri/src/cli_agent_runtime.rs` | Shared CLI-agent request shape, prompt wrapping, JSON subprocess lifecycle, version probing, and MCP path helpers. |
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs` | Per-agent command, config, discovery, and JSON event adapters. |
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs` | Per-agent command, config, discovery, and JSON event adapters. |
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
### Editor
@@ -426,12 +426,12 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts`
5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling.
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`). Keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode.
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`). Keep Codex sandboxed with active-vault `workspace-write`, keep Pi and Gemini on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode.
### Work with external MCP setup
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; Gemini itself still needs its own install and sign-in outside Tolaria
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent
5. **Process lifecycle**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit

View File

@@ -0,0 +1,42 @@
---
type: ADR
id: "0097"
title: "Gemini CLI agent adapter"
status: active
date: 2026-04-29
---
## Context
ADR 0091 added Gemini CLI to explicit external MCP setup, but Gemini was still absent from Tolaria's selectable app-managed AI agents. That left the AI panel able to generate Gemini-compatible MCP configuration while the actual agent picker, availability checks, install links, and streaming dispatch did not treat Gemini like Claude Code, Codex, OpenCode, or Pi.
Gemini CLI supports headless `--prompt` execution with JSON output, configurable approval modes, tool exclusion, and settings-file overrides through `GEMINI_CLI_SYSTEM_SETTINGS_PATH`. Those features are enough to launch Gemini from Tolaria without mutating the user's durable `~/.gemini/settings.json` during app-managed sessions.
## Decision
Tolaria adds Gemini CLI as a first-class `AiAgentId`. The frontend agent definitions, onboarding prompt, install links, default-agent normalization, status badge, command registry, settings persistence, and mock Tauri status payloads include `gemini`.
The desktop backend adds a Gemini adapter that:
- discovers `gemini` through the process path, login shell, and common local/toolchain install locations
- runs `gemini --output-format json --approval-mode <mode> --prompt <prompt>` from the active vault
- supplies Tolaria MCP through a temporary settings file referenced by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
- uses Safe mode with `auto_edit`, an untrusted MCP entry, and `tools.exclude=["run_shell_command"]`
- uses Power User mode with `yolo` and a trusted Tolaria MCP entry
- maps Gemini JSON responses into Tolaria's existing AI panel stream events
The existing external MCP setup remains explicit and durable. The app-managed Gemini adapter uses transient settings so selecting Gemini in Tolaria does not rewrite the user's global Gemini config.
## Options Considered
- **Add Gemini as a first-class app-managed agent** (chosen): matches the existing agent picker and onboarding UI, uses Gemini's headless JSON mode, and keeps MCP setup vault-scoped.
- **Keep Gemini as external MCP setup only**: avoids another adapter, but keeps the interface inconsistent and requires users to leave Tolaria for a flow that other agents support in-panel.
- **Write app-managed Gemini config into `~/.gemini/settings.json`**: reuses the external setup path, but would blur the consent boundary and risk overwriting user preferences during normal AI panel usage.
- **Use interactive Gemini sessions**: could preserve richer CLI state, but does not fit Tolaria's current one-request stream lifecycle and would make cleanup/auth/error handling harder.
## Consequences
- Gemini appears anywhere users can choose, install, or switch local AI agents.
- End-to-end native Gemini QA requires the Gemini CLI to be installed and authenticated, but missing/auth failures now produce agent-specific guidance.
- Safe and Power User behavior is limited by Gemini's own approval/tool semantics; if Gemini changes those names, the adapter tests and docs need updating.
- The durable MCP setup path and optional `GEMINI.md` shim continue to serve external Gemini usage outside Tolaria's AI panel.

View File

@@ -148,3 +148,4 @@ proposed → active → superseded
| [0093](0093-shared-cli-agent-runtime-adapters.md) | Shared CLI agent runtime adapters | active |
| [0094](0094-gitignored-content-visibility-boundary-filter.md) | Gitignored content visibility as a command-boundary filter | active |
| [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active |
| [0097](0097-gemini-cli-agent-adapter.md) | Gemini CLI agent adapter | active |

View File

@@ -7,6 +7,7 @@ pub enum AiAgentId {
Codex,
Opencode,
Pi,
Gemini,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -29,6 +30,7 @@ pub struct AiAgentsStatus {
pub codex: AiAgentAvailability,
pub opencode: AiAgentAvailability,
pub pi: AiAgentAvailability,
pub gemini: AiAgentAvailability,
}
#[derive(Debug, Clone, Serialize)]
@@ -81,6 +83,7 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
codex: crate::codex_cli::check_cli(),
opencode: crate::opencode_cli::check_cli(),
pi: crate::pi_cli::check_cli(),
gemini: crate::gemini_cli::check_cli(),
}
}
@@ -130,6 +133,15 @@ where
};
crate::pi_cli::run_agent_stream(mapped, emit)
}
AiAgentId::Gemini => {
let mapped = crate::gemini_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::gemini_cli::run_agent_stream(mapped, emit)
}
}
}
@@ -187,6 +199,7 @@ mod tests {
status.codex.installed,
status.opencode.installed,
status.pi.installed,
status.gemini.installed,
];
assert!(install_flags

View File

@@ -120,6 +120,10 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
installed: false,
version: None,
},
gemini: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
}
}

220
src-tauri/src/gemini_cli.rs Normal file
View File

@@ -0,0 +1,220 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
pub use crate::cli_agent_runtime::AgentStreamRequest;
use std::path::Path;
use std::process::Output;
pub fn check_cli() -> AiAgentAvailability {
crate::gemini_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::gemini_discovery::find_binary()?;
run_agent_stream_with_binary(&binary, request, emit)
}
fn run_agent_stream_with_binary<F>(
binary: &Path,
request: AgentStreamRequest,
mut emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let settings_dir = tempfile::Builder::new()
.prefix("tolaria-gemini-agent-")
.tempdir()
.map_err(|error| format!("Failed to create Gemini settings directory: {error}"))?;
let mut command = crate::gemini_config::build_command(binary, &request, settings_dir.path())?;
let output = command
.output()
.map_err(|error| format!("Failed to spawn gemini: {error}"))?;
emit(AiAgentStreamEvent::Init {
session_id: "gemini-headless".into(),
});
if output.status.success() {
emit_gemini_success(&output, &mut emit);
} else {
emit(AiAgentStreamEvent::Error {
message: format_gemini_error(output_stderr(&output), output.status.to_string()),
});
}
emit(AiAgentStreamEvent::Done);
Ok("gemini-headless".into())
}
fn emit_gemini_success<F>(output: &Output, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
match gemini_response_text(&output_stdout(output)) {
GeminiOutput::Response(text) => emit(AiAgentStreamEvent::TextDelta { text }),
GeminiOutput::Error(message) => emit(AiAgentStreamEvent::Error { message }),
GeminiOutput::Empty => {}
}
}
enum GeminiOutput {
Response(String),
Error(String),
Empty,
}
fn gemini_response_text(stdout: &str) -> GeminiOutput {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return GeminiOutput::Empty;
}
match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(json) => response_from_json(&json),
Err(_) => GeminiOutput::Response(trimmed.to_string()),
}
}
fn response_from_json(json: &serde_json::Value) -> GeminiOutput {
if let Some(message) = json["error"]["message"].as_str() {
return GeminiOutput::Error(message.to_string());
}
if let Some(response) = json["response"].as_str() {
return GeminiOutput::Response(response.to_string());
}
GeminiOutput::Empty
}
fn output_stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}
fn output_stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
fn format_gemini_error(stderr_output: String, status: String) -> String {
let lower = stderr_output.to_ascii_lowercase();
if is_auth_error(&lower) {
return "Gemini CLI is not authenticated. Run `gemini` in your terminal to sign in, or set GEMINI_API_KEY and retry.".into();
}
if stderr_output.trim().is_empty() {
format!("gemini exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
}
}
fn is_auth_error(lower: &str) -> bool {
[
"auth",
"login",
"sign in",
"api key",
"gemini_api_key",
"google_api_key",
"oauth",
"401",
]
.iter()
.any(|pattern| lower.contains(pattern))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai_agents::AiAgentPermissionMode;
#[cfg(unix)]
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join("gemini");
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
fn request(vault_path: String) -> AgentStreamRequest {
AgentStreamRequest {
message: "Summarize".into(),
system_prompt: None,
vault_path,
permission_mode: AiAgentPermissionMode::Safe,
}
}
#[cfg(unix)]
#[test]
fn run_agent_stream_maps_gemini_json_response() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"response":"Done","stats":{"tools":{"totalCalls":0}}}'"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "gemini-headless");
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == "gemini-headless"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::TextDelta { text } if text == "Done"
));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[cfg(unix)]
#[test]
fn run_agent_stream_reports_gemini_auth_errors() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' 'oauth login required' >&2
exit 3
"#,
);
let mut events = Vec::new();
let session_id = run_agent_stream_with_binary(
&binary,
request(vault.path().to_string_lossy().into_owned()),
|event| events.push(event),
)
.unwrap();
assert_eq!(session_id, "gemini-headless");
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[test]
fn gemini_response_text_reads_json_response_or_plain_text() {
match gemini_response_text(r#"{"response":"Structured"}"#) {
GeminiOutput::Response(text) => assert_eq!(text, "Structured"),
_ => panic!("expected response"),
}
match gemini_response_text("Plain answer") {
GeminiOutput::Response(text) => assert_eq!(text, "Plain answer"),
_ => panic!("expected response"),
}
}
}

View File

@@ -0,0 +1,164 @@
use crate::ai_agents::AiAgentPermissionMode;
use crate::gemini_cli::AgentStreamRequest;
use std::path::{Path, PathBuf};
use std::process::Stdio;
pub(crate) fn build_command(
binary: &Path,
request: &AgentStreamRequest,
settings_dir: &Path,
) -> Result<std::process::Command, String> {
let settings_path = write_settings(settings_dir, &request.vault_path, request.permission_mode)?;
let mut command = crate::hidden_command(binary);
command
.args(build_args(request.permission_mode))
.arg("--prompt")
.arg(build_prompt(request))
.env("GEMINI_CLI_SYSTEM_SETTINGS_PATH", settings_path)
.env("NO_COLOR", "1")
.current_dir(&request.vault_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Ok(command)
}
fn build_args(permission_mode: AiAgentPermissionMode) -> Vec<String> {
vec![
"--output-format".into(),
"json".into(),
"--approval-mode".into(),
approval_mode(permission_mode).into(),
]
}
fn approval_mode(permission_mode: AiAgentPermissionMode) -> &'static str {
match permission_mode {
AiAgentPermissionMode::Safe => "auto_edit",
AiAgentPermissionMode::PowerUser => "yolo",
}
}
fn build_prompt(request: &AgentStreamRequest) -> String {
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
}
fn write_settings(
settings_dir: &Path,
vault_path: &str,
permission_mode: AiAgentPermissionMode,
) -> Result<PathBuf, String> {
std::fs::create_dir_all(settings_dir)
.map_err(|error| format!("Failed to create Gemini settings directory: {error}"))?;
let settings_path = settings_dir.join("settings.json");
let settings = build_settings(vault_path, permission_mode)?;
std::fs::write(&settings_path, settings)
.map_err(|error| format!("Failed to write Gemini settings: {error}"))?;
Ok(settings_path)
}
fn build_settings(
vault_path: &str,
permission_mode: AiAgentPermissionMode,
) -> Result<String, String> {
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
let mut settings = serde_json::json!({
"mcpServers": {
"tolaria": {
"command": "node",
"args": [mcp_server_path],
"env": {
"VAULT_PATH": vault_path,
"WS_UI_PORT": "9711"
},
"description": "Tolaria active vault MCP server",
"trust": permission_mode == AiAgentPermissionMode::PowerUser
}
}
});
if permission_mode == AiAgentPermissionMode::Safe {
settings["tools"] = serde_json::json!({
"exclude": ["run_shell_command"]
});
}
serde_json::to_string(&settings)
.map_err(|error| format!("Failed to serialize Gemini settings: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
fn request() -> AgentStreamRequest {
AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: AiAgentPermissionMode::Safe,
}
}
#[test]
fn command_uses_headless_json_mode_and_temp_settings() {
let settings_dir = tempfile::tempdir().unwrap();
let command =
build_command(&PathBuf::from("gemini"), &request(), settings_dir.path()).unwrap();
let actual_args: Vec<&OsStr> = command.get_args().collect();
let settings_path = command
.get_envs()
.find(|(key, _)| *key == OsStr::new("GEMINI_CLI_SYSTEM_SETTINGS_PATH"))
.and_then(|(_, value)| value);
assert_eq!(command.get_program(), OsStr::new("gemini"));
assert_eq!(actual_args[0], OsStr::new("--output-format"));
assert_eq!(actual_args[1], OsStr::new("json"));
assert!(actual_args.contains(&OsStr::new("--prompt")));
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
assert!(settings_path.is_some());
assert!(settings_dir.path().join("settings.json").exists());
}
#[test]
fn safe_settings_include_tolaria_mcp_and_exclude_shell() {
let settings = build_settings("/tmp/vault", AiAgentPermissionMode::Safe).unwrap();
let json: serde_json::Value = serde_json::from_str(&settings).unwrap();
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
assert_eq!(
json["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
"/tmp/vault"
);
assert_eq!(json["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"], "9711");
assert_eq!(json["mcpServers"]["tolaria"]["trust"], false);
assert_eq!(json["tools"]["exclude"][0], "run_shell_command");
assert!(json["mcpServers"]["tolaria"]["args"][0]
.as_str()
.unwrap()
.ends_with("index.js"));
}
#[test]
fn power_user_settings_trust_tolaria_and_allow_shell_discovery() {
let settings = build_settings("/tmp/vault", AiAgentPermissionMode::PowerUser).unwrap();
let json: serde_json::Value = serde_json::from_str(&settings).unwrap();
assert_eq!(json["mcpServers"]["tolaria"]["trust"], true);
assert!(json.get("tools").is_none());
assert_eq!(approval_mode(AiAgentPermissionMode::PowerUser), "yolo");
}
#[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"));
}
}

View File

@@ -0,0 +1,187 @@
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: crate::cli_agent_runtime::version_for_binary(&binary),
},
Err(_) => AiAgentAvailability {
installed: false,
version: None,
},
}
}
pub(crate) fn find_binary() -> Result<PathBuf, String> {
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(gemini_binary_candidates()) {
return Ok(binary);
}
Err("Gemini CLI not found. Install it: https://google-gemini.github.io/gemini-cli/".into())
}
fn find_binary_on_path() -> Option<PathBuf> {
crate::hidden_command(path_lookup_command())
.arg("gemini")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_lookup_command() -> &'static str {
if cfg!(windows) {
"where"
} else {
"which"
}
}
fn find_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "gemini"))
}
fn user_shell_candidates() -> Vec<PathBuf> {
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<PathBuf> {
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<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
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<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn gemini_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| gemini_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn gemini_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
home.join(".local/bin/gemini"),
home.join(".local/bin/gemini.exe"),
home.join(".gemini/bin/gemini"),
home.join(".gemini/bin/gemini.exe"),
home.join(".local/share/mise/shims/gemini"),
home.join(".local/share/mise/shims/gemini.exe"),
home.join(".asdf/shims/gemini"),
home.join(".asdf/shims/gemini.exe"),
home.join(".npm-global/bin/gemini"),
home.join(".npm-global/bin/gemini.cmd"),
home.join(".npm-global/bin/gemini.exe"),
home.join(".npm/bin/gemini"),
home.join(".npm/bin/gemini.cmd"),
home.join(".npm/bin/gemini.exe"),
home.join(".bun/bin/gemini"),
home.join(".bun/bin/gemini.exe"),
home.join("AppData/Roaming/npm/gemini.cmd"),
home.join("AppData/Roaming/npm/gemini.exe"),
home.join("AppData/Local/pnpm/gemini.cmd"),
home.join("AppData/Local/pnpm/gemini.exe"),
home.join("scoop/shims/gemini.exe"),
PathBuf::from("/usr/local/bin/gemini"),
PathBuf::from("/opt/homebrew/bin/gemini"),
];
candidates.extend(nvm_binary_candidates_for_home(home));
candidates
}
fn nvm_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
return Vec::new();
};
let mut candidates = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.map(|path| path.join("bin").join("gemini"))
.collect::<Vec<_>>();
candidates.sort();
candidates
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binary_candidates_include_supported_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = gemini_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/gemini"),
home.join(".gemini/bin/gemini"),
home.join(".local/share/mise/shims/gemini"),
home.join(".asdf/shims/gemini"),
home.join(".npm-global/bin/gemini"),
home.join(".bun/bin/gemini"),
PathBuf::from("/opt/homebrew/bin/gemini"),
];
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-gemini");
let gemini = dir.path().join("gemini");
std::fs::write(&gemini, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), gemini.display());
assert_eq!(first_existing_path(&stdout), Some(gemini));
}
}

View File

@@ -5,6 +5,9 @@ mod cli_agent_runtime;
pub mod codex_cli;
mod commands;
pub mod frontmatter;
pub mod gemini_cli;
mod gemini_config;
mod gemini_discovery;
pub mod git;
pub mod mcp;
#[cfg(desktop)]

View File

@@ -4,7 +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", "pi"];
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi", "gemini"];
pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
@@ -398,6 +398,15 @@ mod tests {
assert_eq!(loaded.default_ai_agent.as_deref(), Some("pi"));
}
#[test]
fn test_gemini_default_ai_agent_is_preserved() {
let loaded = save_and_reload(Settings {
default_ai_agent: Some("gemini".to_string()),
..Default::default()
});
assert_eq!(loaded.default_ai_agent.as_deref(), Some("gemini"));
}
#[test]
fn test_invalid_theme_mode_is_filtered() {
let loaded = save_and_reload(Settings {

View File

@@ -635,6 +635,7 @@ describe('App', () => {
codex: { installed: true, version: '0.122.0-alpha.1' },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
gemini: { installed: false, version: null },
}
mockCommandResults.check_mcp_status = 'installed'
@@ -675,6 +676,7 @@ describe('App', () => {
codex: { installed: true, version: '0.122.0-alpha.1' },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
gemini: { installed: false, version: null },
}
render(<App />)
@@ -708,6 +710,7 @@ describe('App', () => {
codex: { installed: true, version: '0.122.0-alpha.1' },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
gemini: { installed: false, version: null },
}
render(<App />)

View File

@@ -1,9 +1,30 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiAgentStatuses } from '../lib/aiAgents'
import { AiAgentsOnboardingPrompt } from './AiAgentsOnboardingPrompt'
const openExternalUrl = vi.fn()
const dragRegionMouseDown = vi.fn()
const missingStatuses: AiAgentStatuses = {
claude_code: { status: 'missing', version: null },
codex: { status: 'missing', version: null },
opencode: { status: 'missing', version: null },
pi: { status: 'missing', version: null },
gemini: { status: 'missing', version: null },
}
const missingAgentInstallTestIds = [
'ai-agents-onboarding-install-codex',
'ai-agents-onboarding-install-opencode',
'ai-agents-onboarding-install-pi',
'ai-agents-onboarding-install-gemini',
] as const
const installLinkTargets = [
['ai-agents-onboarding-install-claude_code', 'https://docs.anthropic.com/en/docs/claude-code'],
['ai-agents-onboarding-install-codex', 'https://developers.openai.com/codex/cli'],
['ai-agents-onboarding-install-opencode', 'https://opencode.ai/docs/'],
['ai-agents-onboarding-install-pi', 'https://pi.dev'],
['ai-agents-onboarding-install-gemini', 'https://google-gemini.github.io/gemini-cli/'],
] as const
vi.mock('../utils/url', () => ({
openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
@@ -12,90 +33,63 @@ vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
function renderPrompt(statuses: Partial<AiAgentStatuses> = {}) {
return render(
<AiAgentsOnboardingPrompt
statuses={{ ...missingStatuses, ...statuses }}
onContinue={vi.fn()}
/>,
)
}
function expectMissingAgentInstallLinks() {
missingAgentInstallTestIds.forEach(testId => {
expect(screen.getByTestId(testId)).toBeInTheDocument()
})
}
describe('AiAgentsOnboardingPrompt', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows the ready state when at least one agent is installed', () => {
render(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'installed', version: '1.0.20' },
codex: { status: 'missing', version: null },
opencode: { status: 'missing', version: null },
pi: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
renderPrompt({
claude_code: { status: 'installed', version: '1.0.20' },
})
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-install-pi')).toBeInTheDocument()
expectMissingAgentInstallLinks()
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue')
})
it('shows the missing state when no agents are installed', () => {
render(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'missing', version: null },
codex: { status: 'missing', version: null },
opencode: { status: 'missing', version: null },
pi: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
renderPrompt()
expect(screen.getByText('No AI agents detected')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-screen')).toBeInTheDocument()
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-install-pi')).toBeInTheDocument()
expectMissingAgentInstallLinks()
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue without it')
})
it('opens the agent install links', () => {
render(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'missing', version: null },
codex: { status: 'missing', version: null },
opencode: { status: 'missing', version: null },
pi: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
renderPrompt()
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'))
fireEvent.click(screen.getByTestId('ai-agents-onboarding-install-pi'))
installLinkTargets.forEach(([testId]) => {
fireEvent.click(screen.getByTestId(testId))
})
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/')
expect(openExternalUrl).toHaveBeenCalledWith('https://pi.dev')
installLinkTargets.forEach(([, url]) => {
expect(openExternalUrl).toHaveBeenCalledWith(url)
})
})
it('uses the surrounding surface as a drag region and excludes the card', () => {
render(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'installed', version: '1.0.20' },
codex: { status: 'missing', version: null },
opencode: { status: 'missing', version: null },
pi: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
renderPrompt({
claude_code: { status: 'installed', version: '1.0.20' },
})
const screenContainer = screen.getByTestId('ai-agents-onboarding-screen')
fireEvent.mouseDown(screenContainer)

View File

@@ -205,6 +205,7 @@ describe('AiPanel', () => {
const onCopyMcpConfig = vi.fn()
render(<AiPanel onClose={vi.fn()} onCopyMcpConfig={onCopyMcpConfig} vaultPath="/tmp/vault" />)
expect(screen.getByText('MCP config')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Copy MCP config' }))
expect(onCopyMcpConfig).toHaveBeenCalledOnce()

View File

@@ -171,14 +171,16 @@ export function AiPanelHeader({
{onCopyMcpConfig ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
variant="outline"
size="xs"
onClick={onCopyMcpConfig}
className="h-7 gap-1.5 px-2 text-[11px]"
aria-label="Copy MCP config"
title="Copy MCP config"
data-testid="ai-copy-mcp-config"
>
<Copy size={15} />
<span>MCP config</span>
</Button>
) : null}
<Button

View File

@@ -21,6 +21,7 @@ const installedAiAgentsStatus = {
codex: { status: 'installed' as const, version: '0.37.0' },
opencode: { status: 'installed' as const, version: '0.3.1' },
pi: { status: 'installed' as const, version: '0.70.2' },
gemini: { status: 'installed' as const, version: '0.5.1' },
}
const DEFAULT_WINDOW_WIDTH = 1280

View File

@@ -14,6 +14,7 @@ const installedStatuses = {
codex: { status: 'installed' as const, version: '0.37.0' },
opencode: { status: 'installed' as const, version: '0.3.1' },
pi: { status: 'installed' as const, version: '0.70.2' },
gemini: { status: 'installed' as const, version: '0.5.1' },
}
function render(ui: ReactElement) {

View File

@@ -17,6 +17,7 @@ const aiAgentsStatus = {
codex: { status: 'missing' as const, version: null },
opencode: { status: 'missing' as const, version: null },
pi: { status: 'missing' as const, version: null },
gemini: { status: 'missing' as const, version: null },
}
describe('useAiAgentPreferences', () => {
@@ -83,6 +84,7 @@ describe('useAiAgentPreferences', () => {
codex: { status: 'missing', version: null },
opencode: { status: 'missing', version: null },
pi: { status: 'missing', version: null },
gemini: { status: 'missing', version: null },
},
}))

View File

@@ -26,6 +26,7 @@ describe('useAiAgentsStatus', () => {
codex: { installed: false, version: null },
opencode: { installed: true, version: '0.3.1' },
pi: { installed: true, version: '0.70.2' },
gemini: { installed: true, version: '0.5.1' },
})
}
return Promise.resolve(null)
@@ -37,12 +38,14 @@ describe('useAiAgentsStatus', () => {
expect(result.current.codex.status).toBe('checking')
expect(result.current.opencode.status).toBe('checking')
expect(result.current.pi.status).toBe('checking')
expect(result.current.gemini.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' })
expect(result.current.pi).toEqual({ status: 'installed', version: '0.70.2' })
expect(result.current.gemini).toEqual({ status: 'installed', version: '0.5.1' })
})
})
@@ -56,6 +59,7 @@ describe('useAiAgentsStatus', () => {
expect(result.current.codex.status).toBe('missing')
expect(result.current.opencode.status).toBe('missing')
expect(result.current.pi.status).toBe('missing')
expect(result.current.gemini.status).toBe('missing')
})
})
})

View File

@@ -798,6 +798,7 @@ describe('reload-vault command', () => {
codex: { status: 'installed', version: '0.37.0' },
opencode: { status: 'installed', version: '0.3.1' },
pi: { status: 'installed', version: '0.70.2' },
gemini: { status: 'installed', version: '0.5.1' },
},
selectedAiAgent: 'claude_code',
onSetDefaultAiAgent,
@@ -809,6 +810,7 @@ describe('reload-vault command', () => {
expect(cmd!.label).toBe('Switch AI Agent to Codex')
expect(findCommand(result.current, 'switch-ai-agent-opencode')).toBeDefined()
expect(findCommand(result.current, 'switch-ai-agent-pi')).toBeDefined()
expect(findCommand(result.current, 'switch-ai-agent-gemini')).toBeDefined()
cmd!.execute()
expect(onSetDefaultAiAgent).toHaveBeenCalledWith('codex')
@@ -822,6 +824,7 @@ describe('reload-vault command', () => {
codex: { status: 'missing', version: null },
opencode: { status: 'missing', version: null },
pi: { status: 'missing', version: null },
gemini: { status: 'missing', version: null },
},
selectedAiAgent: 'claude_code',
onSetDefaultAiAgent: vi.fn(),
@@ -831,6 +834,7 @@ describe('reload-vault command', () => {
expect(findCommand(result.current, 'switch-ai-agent-codex')).toBeUndefined()
expect(findCommand(result.current, 'switch-ai-agent-opencode')).toBeUndefined()
expect(findCommand(result.current, 'switch-ai-agent-pi')).toBeUndefined()
expect(findCommand(result.current, 'switch-ai-agent-gemini')).toBeUndefined()
expect(findCommand(result.current, 'switch-default-ai-agent')).toBeUndefined()
})
})

View File

@@ -12,6 +12,7 @@ describe('aiAgents helpers', () => {
expect(normalizeStoredAiAgent('codex')).toBe('codex')
expect(normalizeStoredAiAgent('opencode')).toBe('opencode')
expect(normalizeStoredAiAgent('pi')).toBe('pi')
expect(normalizeStoredAiAgent('gemini')).toBe('gemini')
expect(normalizeStoredAiAgent('cursor')).toBeNull()
})
@@ -26,18 +27,21 @@ describe('aiAgents helpers', () => {
codex: { installed: false, version: null },
opencode: { installed: true, version: '0.3.1' },
pi: { installed: true, version: '0.70.2' },
gemini: { installed: true, version: '0.5.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' })
expect(statuses.pi).toEqual({ status: 'installed', version: '0.70.2' })
expect(statuses.gemini).toEqual({ status: 'installed', version: '0.5.1' })
})
it('cycles through the supported agents', () => {
expect(getNextAiAgentId('claude_code')).toBe('codex')
expect(getNextAiAgentId('codex')).toBe('opencode')
expect(getNextAiAgentId('opencode')).toBe('pi')
expect(getNextAiAgentId('pi')).toBe('claude_code')
expect(getNextAiAgentId('pi')).toBe('gemini')
expect(getNextAiAgentId('gemini')).toBe('claude_code')
})
})

View File

@@ -1,4 +1,4 @@
export type AiAgentId = 'claude_code' | 'codex' | 'opencode' | 'pi'
export type AiAgentId = 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini'
export type AiAgentStatus = 'checking' | 'installed' | 'missing'
export type AiAgentReadiness = 'checking' | 'ready' | 'missing'
@@ -44,6 +44,12 @@ export const AI_AGENT_DEFINITIONS: readonly AiAgentDefinition[] = [
shortLabel: 'Pi',
installUrl: 'https://pi.dev',
},
{
id: 'gemini',
label: 'Gemini CLI',
shortLabel: 'Gemini',
installUrl: 'https://google-gemini.github.io/gemini-cli/',
},
] as const
export function createAiAgentAvailability(status: AiAgentStatus = 'checking', version: string | null = null): AiAgentAvailability {
@@ -56,6 +62,7 @@ export function createCheckingAiAgentsStatus(): AiAgentsStatus {
codex: createAiAgentAvailability(),
opencode: createAiAgentAvailability(),
pi: createAiAgentAvailability(),
gemini: createAiAgentAvailability(),
}
}
@@ -65,6 +72,7 @@ export function createMissingAiAgentsStatus(): AiAgentsStatus {
codex: createAiAgentAvailability('missing'),
opencode: createAiAgentAvailability('missing'),
pi: createAiAgentAvailability('missing'),
gemini: createAiAgentAvailability('missing'),
}
}
@@ -95,6 +103,7 @@ export function normalizeAiAgentsStatus(payload: Partial<Record<AiAgentId, { ins
codex: normalizeAvailability(payload?.codex),
opencode: normalizeAvailability(payload?.opencode),
pi: normalizeAvailability(payload?.pi),
gemini: normalizeAvailability(payload?.gemini),
}
}

View File

@@ -381,6 +381,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
codex: { installed: false, version: null },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
gemini: { installed: false, version: null },
}),
get_vault_ai_guidance_status: () => ({ ...mockVaultAiGuidanceStatus }),
restore_vault_ai_guidance: () => {